mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-25 06:24:27 +08:00
Keep React Flight's development-only stack parsing out of production builds, and make stack mode cheaper (#1330)
This commit is contained in:
@@ -270,6 +270,7 @@
|
||||
"packages/compiler/src/backend/llvm/**",
|
||||
"packages/compiler/src/backend/loop-polls.test.ts",
|
||||
"packages/compiler/src/backend/may-throw.test.ts",
|
||||
"packages/compiler/src/backend/stack-observations.test.ts",
|
||||
"packages/compiler/src/ir/**",
|
||||
"packages/compiler/src/library/*.test.ts",
|
||||
"packages/compiler/test/**",
|
||||
|
||||
@@ -57,6 +57,7 @@ import {
|
||||
VOID,
|
||||
} from "../../ir/ir.js";
|
||||
import { moduleRuntimeFeatures, moduleUsesMockTimers } from "../../ir/runtime-features.js";
|
||||
import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
|
||||
import {
|
||||
matchIntegerArrayForLoop,
|
||||
matchIntegerBytesForLoop,
|
||||
@@ -1179,18 +1180,14 @@ export class LlEmitter {
|
||||
readonly chainSlots = new Map<string, LlValue>();
|
||||
logArgSlots = 0;
|
||||
private readonly stackTraces: boolean;
|
||||
/** Stack-trace mode: functions no capture can include (no frame push). */
|
||||
private readonly unframedFunctions: ReadonlySet<string>;
|
||||
|
||||
constructor(
|
||||
readonly mod: IrModule,
|
||||
options: LlvmTargetOptions,
|
||||
) {
|
||||
this.stackTraces = mod.functions.some(
|
||||
(fn) =>
|
||||
!everyStmtList(fn.body, {
|
||||
stmt: () => true,
|
||||
expr: (expr) => !(expr.kind === "libCall" && expr.fn === "error.stack"),
|
||||
}),
|
||||
);
|
||||
this.stackTraces = observesStacks(mod);
|
||||
this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined);
|
||||
this.mockTimers = moduleUsesMockTimers(mod);
|
||||
this.debug =
|
||||
@@ -1272,6 +1269,9 @@ export class LlEmitter {
|
||||
// which Windows lacks.
|
||||
this.stacklessAsyncAllowed = this.stacklessGenerators && mod.lib === undefined && !windows;
|
||||
this.publishedTypes = computePublishedTypes(mod);
|
||||
this.unframedFunctions = this.stackTraces
|
||||
? captureFreeFunctions(mod, this.stackChecks, this.publishedTypes)
|
||||
: new Set();
|
||||
this.immutableFields = computeImmutableFields(mod);
|
||||
this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`));
|
||||
this.indirectMayThrow = mt.indirect || mod.workers === true;
|
||||
@@ -7150,13 +7150,39 @@ export class LlEmitter {
|
||||
// Stackful native fibers and native coroutine frames retain these frames
|
||||
// while suspended. The active exception context owns the chain, so
|
||||
// concurrent fibers isolate it.
|
||||
if (this.stackTraces && !this.wasi && fn.sourceName !== undefined) {
|
||||
this.declare(`declare void @scr_stack_enter(ptr, ptr)`);
|
||||
this.declare(`declare void @scr_stack_leave(ptr)`);
|
||||
if (
|
||||
this.stackTraces &&
|
||||
!this.wasi &&
|
||||
fn.sourceName !== undefined &&
|
||||
!this.unframedFunctions.has(fn.name)
|
||||
) {
|
||||
B.entryAllocas.push(`%source_frame = alloca { ptr, ptr }`);
|
||||
const frame = ` at ${fn.sourceName} (${fn.loc.file})`;
|
||||
B.line(`call void @scr_stack_enter(ptr %source_frame, ptr ${this.cstr(frame)})`);
|
||||
B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`;
|
||||
const frame = this.cstr(` at ${fn.sourceName} (${fn.loc.file})`);
|
||||
if (
|
||||
B.excCellInvariant &&
|
||||
this.mod.lib === undefined &&
|
||||
this.mod.workers !== true &&
|
||||
this.sizeType === "i64"
|
||||
) {
|
||||
// Executables link the active-cell pointer as a plain global, and a
|
||||
// synchronous body sees one cell from entry to every return: push
|
||||
// and pop the frame inline (ScrExcCell.stack is at byte 56 on
|
||||
// 64-bit targets) instead of calling the runtime twice per call.
|
||||
this.declare(`@scr_exc_active = external global ptr`);
|
||||
B.line(`%source_frame.cell = load ptr, ptr @scr_exc_active`);
|
||||
B.line(`%source_frame.chain = getelementptr inbounds i8, ptr %source_frame.cell, i64 56`);
|
||||
B.line(`%source_frame.previous = load ptr, ptr %source_frame.chain`);
|
||||
B.line(`store ptr %source_frame.previous, ptr %source_frame`);
|
||||
B.line(`%source_frame.text = getelementptr inbounds i8, ptr %source_frame, i64 8`);
|
||||
B.line(`store ptr ${frame}, ptr %source_frame.text`);
|
||||
B.line(`store ptr %source_frame, ptr %source_frame.chain`);
|
||||
B.returnEpilogue = `store ptr %source_frame.previous, ptr %source_frame.chain`;
|
||||
} else {
|
||||
this.declare(`declare void @scr_stack_enter(ptr, ptr)`);
|
||||
this.declare(`declare void @scr_stack_leave(ptr)`);
|
||||
B.line(`call void @scr_stack_enter(ptr %source_frame, ptr ${frame})`);
|
||||
B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`;
|
||||
}
|
||||
}
|
||||
if (this.stackChecks?.has(fn.name)) this.emitStackCheck();
|
||||
if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true);
|
||||
|
||||
@@ -580,7 +580,7 @@ function breaksTo(
|
||||
|
||||
/** Statements no execution reaches: those after a statement that cannot
|
||||
* complete normally in the same statement list. */
|
||||
function unreachableStatements(body: readonly IrStmt[]): Set<IrStmt> {
|
||||
export function unreachableStatements(body: readonly IrStmt[]): Set<IrStmt> {
|
||||
const dead = new Set<IrStmt>();
|
||||
const { statement } = completion();
|
||||
const list = (stmts: readonly IrStmt[]): void => {
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { expect, test } from "vitest";
|
||||
import {
|
||||
F64,
|
||||
STRING,
|
||||
VOID,
|
||||
type IrExpr,
|
||||
type IrFunction,
|
||||
type IrModule,
|
||||
type IrStmt,
|
||||
} from "../ir/ir.js";
|
||||
import { captureFreeFunctions, observesStacks } from "./stack-observations.js";
|
||||
|
||||
const loc = { file: "stack-observations.ts", start: 0, end: 0 };
|
||||
const ERROR = { kind: "object", className: "%Error" } as const;
|
||||
const err: IrExpr = { loc, kind: "varRef", localId: "e", type: ERROR };
|
||||
const stackRead: IrStmt = {
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "libCall", fn: "error.stack", args: [err], type: STRING },
|
||||
};
|
||||
const call = (callee: string): IrStmt => ({
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "call", callee, args: [], type: VOID },
|
||||
});
|
||||
const throwStmt: IrStmt = { loc, kind: "throw", value: err };
|
||||
const ret: IrStmt = { loc, kind: "return", value: null };
|
||||
const fn = (name: string, body: IrStmt[]): IrFunction => ({
|
||||
loc,
|
||||
name,
|
||||
params: [],
|
||||
locals: [],
|
||||
returnType: VOID,
|
||||
body,
|
||||
});
|
||||
const mod = (functions: IrFunction[], extra: Partial<IrModule> = {}): IrModule => ({
|
||||
irVersion: 16,
|
||||
sourceFile: "stack-observations.ts",
|
||||
entry: "%main",
|
||||
functions,
|
||||
classes: [],
|
||||
records: [],
|
||||
unions: [],
|
||||
globals: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
test("a reachable stack read is observed", () => {
|
||||
expect(observesStacks(mod([fn("%main", [call("parse")]), fn("parse", [stackRead])]))).toBe(true);
|
||||
});
|
||||
|
||||
test("programs without stack reads are not", () => {
|
||||
expect(observesStacks(mod([fn("%main", [call("helper")]), fn("helper", [ret])]))).toBe(false);
|
||||
});
|
||||
|
||||
test("reads after an unconditional throw or return never run", () => {
|
||||
expect(observesStacks(mod([fn("%main", [throwStmt, stackRead])]))).toBe(false);
|
||||
expect(observesStacks(mod([fn("%main", [ret, call("parse")]), fn("parse", [stackRead])]))).toBe(
|
||||
false,
|
||||
);
|
||||
// A terminator ends only its own list: the outer list continues.
|
||||
const cond: IrExpr = { loc, kind: "numLit", value: 1, type: F64 };
|
||||
expect(
|
||||
observesStacks(
|
||||
mod([fn("%main", [{ kind: "if", cond, then: [ret], else_: null, loc }, stackRead])]),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("functions referenced only from dead code are unreached", () => {
|
||||
// The production React shape: a DEV-only body behind a throw stub calls
|
||||
// the stack parser, and a closure created after the stub reaches it too.
|
||||
const closure: IrStmt = {
|
||||
loc,
|
||||
kind: "assign",
|
||||
localId: "replacer",
|
||||
value: {
|
||||
loc,
|
||||
kind: "closure",
|
||||
fnName: "replacer",
|
||||
captures: [],
|
||||
type: { kind: "func", params: [], ret: VOID },
|
||||
},
|
||||
};
|
||||
const program = mod([
|
||||
fn("%main", [call("outline")]),
|
||||
fn("outline", [throwStmt, closure, call("parse")]),
|
||||
fn("replacer", [call("parse")]),
|
||||
fn("parse", [stackRead]),
|
||||
]);
|
||||
expect(observesStacks(program)).toBe(false);
|
||||
// The same closure created before the stub keeps the read reachable.
|
||||
const live = mod([
|
||||
fn("%main", [call("outline")]),
|
||||
fn("outline", [closure, throwStmt]),
|
||||
fn("replacer", [call("parse")]),
|
||||
fn("parse", [stackRead]),
|
||||
]);
|
||||
expect(observesStacks(live)).toBe(true);
|
||||
});
|
||||
|
||||
test("class methods are roots", () => {
|
||||
const program = mod([fn("%main", [ret]), fn("%Parser.read", [stackRead])], {
|
||||
classes: [
|
||||
{ name: "Parser", fields: [], methods: ["read"] } as unknown as NonNullable<
|
||||
IrModule["classes"]
|
||||
>[number],
|
||||
],
|
||||
});
|
||||
expect(observesStacks(program)).toBe(true);
|
||||
});
|
||||
|
||||
test("leaf functions that cannot create an Error need no frame", () => {
|
||||
const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 });
|
||||
const add: IrStmt = {
|
||||
loc,
|
||||
kind: "return",
|
||||
value: { loc, kind: "bin", op: "+", left: num(1), right: num(2), type: F64 },
|
||||
};
|
||||
const construct: IrStmt = {
|
||||
loc,
|
||||
kind: "exprStmt",
|
||||
expr: { loc, kind: "libCall", fn: "error.new", args: [], type: ERROR },
|
||||
};
|
||||
const program = mod([
|
||||
fn("%main", [call("leaf"), call("caller"), call("creates"), call("viaCreates")]),
|
||||
fn("leaf", [add]),
|
||||
fn("caller", [call("leaf"), add]),
|
||||
fn("creates", [construct]),
|
||||
fn("viaCreates", [call("creates")]),
|
||||
fn("recursive", [call("recursive")]),
|
||||
]);
|
||||
const free = captureFreeFunctions(program, new Set(["recursive"]), null);
|
||||
expect([...free].sort()).toEqual(["caller", "leaf"]);
|
||||
// A worker program keeps every frame.
|
||||
expect(captureFreeFunctions({ ...program, workers: true }, null, null).size).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
/* Whole-program stack-trace gate (see observesStacks). Pure function of the
|
||||
* IR module; the emitter keeps a source frame in every function only when
|
||||
* it answers true. */
|
||||
import { exprFunctionReference, moduleFunctionReferences } from "../ir/function-references.js";
|
||||
import { MAY_THROW_LIB_FNS } from "../ir/builtin-effects.js";
|
||||
import type { IrExpr, IrFunction, IrModule, IrStmt } from "../ir/ir.js";
|
||||
import { MAY_THROW_ARR_METHODS } from "../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild, everyStmtList } from "../ir/traverse.js";
|
||||
import { unreachableStatements } from "./may-throw.js";
|
||||
import type { PublishedTypes } from "./publication.js";
|
||||
import { writesPublished } from "./publication.js";
|
||||
|
||||
/** Lib calls that read a captured stack trace. */
|
||||
const STACK_READS: ReadonlySet<string> = new Set(["error.stack"]);
|
||||
|
||||
function readsStack(expr: IrExpr): boolean {
|
||||
return expr.kind === "libCall" && STACK_READS.has(expr.fn);
|
||||
}
|
||||
|
||||
function containsStackRead(fn: IrFunction): boolean {
|
||||
return !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !readsStack(expr) });
|
||||
}
|
||||
|
||||
/** Visit every expression of `body` that execution can reach: statements
|
||||
* after one that cannot complete normally in the same list are skipped. */
|
||||
function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void {
|
||||
const dead = unreachableStatements(body);
|
||||
const expr = (node: IrExpr): boolean => {
|
||||
visit(node);
|
||||
return everyExprChild(node, expr, stmt);
|
||||
};
|
||||
const stmt = (node: IrStmt): boolean => dead.has(node) || everyStmtChild(node, expr, stmt);
|
||||
body.every(stmt);
|
||||
}
|
||||
|
||||
/** Whether the program can read a captured stack trace, which is what makes
|
||||
* every function keep a source frame. A read counts when it can execute:
|
||||
* it sits in live code of a function reachable from the module's roots (its
|
||||
* entry, library exports, class prototype helpers and every class's
|
||||
* methods) through live code. Code after a statement that cannot complete
|
||||
* normally is not live: production builds of libraries such as React's
|
||||
* Flight server keep development-only stack parsing behind `throw` stubs,
|
||||
* and those reads must not cost every call of the program a frame.
|
||||
* Programs without any stack read answer false without the walk. */
|
||||
export function observesStacks(mod: IrModule): boolean {
|
||||
if (!mod.functions.some(containsStackRead)) return false;
|
||||
const byName = new Map(mod.functions.map((fn) => [fn.name, fn]));
|
||||
const classNames = new Set((mod.classes ?? []).map((cls) => cls.name));
|
||||
const pending: string[] = [...moduleFunctionReferences(mod)];
|
||||
// Class methods, accessors and constructors are reachable through
|
||||
// dispatch the IR does not name per site: all of them are roots.
|
||||
for (const fn of mod.functions) {
|
||||
if (!fn.name.startsWith("%")) continue;
|
||||
for (let dot = fn.name.indexOf("."); dot !== -1; dot = fn.name.indexOf(".", dot + 1)) {
|
||||
if (classNames.has(fn.name.slice(1, dot))) {
|
||||
pending.push(fn.name);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const reached = new Set<string>();
|
||||
let observed = false;
|
||||
while (pending.length > 0 && !observed) {
|
||||
const name = pending.pop()!;
|
||||
if (reached.has(name)) continue;
|
||||
reached.add(name);
|
||||
const fn = byName.get(name);
|
||||
if (fn === undefined) continue;
|
||||
visitLiveExprs(fn.body, (expr) => {
|
||||
if (readsStack(expr)) observed = true;
|
||||
if (expr.kind === "call" && !reached.has(expr.callee)) pending.push(expr.callee);
|
||||
const referenced = exprFunctionReference(expr);
|
||||
if (referenced !== undefined && !reached.has(referenced)) pending.push(referenced);
|
||||
});
|
||||
}
|
||||
return observed;
|
||||
}
|
||||
|
||||
/** Expressions that neither create an Error nor throw one by themselves.
|
||||
* Calls, `new` and virtual calls are capture-free when their callees are. */
|
||||
const CAPTURE_FREE_EXPRS: ReadonlySet<IrExpr["kind"]> = new Set([
|
||||
"numLit",
|
||||
"boolLit",
|
||||
"strLit",
|
||||
"unitLit",
|
||||
"varRef",
|
||||
"incDec",
|
||||
"assignExpr",
|
||||
"bin",
|
||||
"unary",
|
||||
"logical",
|
||||
"ternary",
|
||||
"nullish",
|
||||
"toBool",
|
||||
"fieldGet",
|
||||
"recordGet",
|
||||
"arrayGet",
|
||||
"arrayHas",
|
||||
"arrayState",
|
||||
"strCmp",
|
||||
"strEq",
|
||||
"strConcat",
|
||||
"upcast",
|
||||
"unionDisc",
|
||||
"unionIsTag",
|
||||
"unionEq",
|
||||
"instanceOf",
|
||||
"recordLit",
|
||||
"arrayLit",
|
||||
"unionWrap",
|
||||
"unionNarrow",
|
||||
"libCall",
|
||||
"arrIntrinsic",
|
||||
"call",
|
||||
"new",
|
||||
"virtualCall",
|
||||
]);
|
||||
|
||||
/** Statements that neither create an Error nor throw one by themselves. */
|
||||
const CAPTURE_FREE_STMTS: ReadonlySet<IrStmt["kind"]> = new Set([
|
||||
"varDecl",
|
||||
"assign",
|
||||
"exprStmt",
|
||||
"if",
|
||||
"while",
|
||||
"doWhile",
|
||||
"for",
|
||||
"switch",
|
||||
"block",
|
||||
"return",
|
||||
"break",
|
||||
"continue",
|
||||
"fieldSet",
|
||||
"recordSet",
|
||||
"arraySet",
|
||||
]);
|
||||
|
||||
/** Functions whose frame no stack capture can ever include: a capture runs
|
||||
* where an Error is created (by the program or by a throwing runtime
|
||||
* operation), and these functions only perform operations from a fixed
|
||||
* list that never create one and only call functions of the same kind.
|
||||
* Their frames are therefore never observable and the emitter omits them,
|
||||
* which keeps small leaf methods and helpers free of the per-call frame
|
||||
* push in programs that read stacks. Recursive functions (the stack guard
|
||||
* throws RangeError), async and generator bodies, and every function of a
|
||||
* worker program keep their frames. */
|
||||
export function captureFreeFunctions(
|
||||
mod: IrModule,
|
||||
stackChecks: ReadonlySet<string> | null,
|
||||
published: PublishedTypes | null,
|
||||
): Set<string> {
|
||||
const free = new Set<string>();
|
||||
if (mod.workers === true) return free;
|
||||
const methodImpls = new Map<string, string[]>();
|
||||
for (const cls of mod.classes ?? []) {
|
||||
for (const m of cls.methods ?? []) {
|
||||
let list = methodImpls.get(m);
|
||||
if (!list) methodImpls.set(m, (list = []));
|
||||
list.push(`%${cls.name}.${m}`);
|
||||
}
|
||||
}
|
||||
const tdzGlobals = new Set((mod.globals ?? []).filter((g) => g.tdz).map((g) => g.id));
|
||||
const callees = new Map<string, string[]>();
|
||||
for (const fn of mod.functions) {
|
||||
if (fn.async === true || fn.generator !== undefined || stackChecks?.has(fn.name)) continue;
|
||||
const tdz = new Set([...tdzGlobals, ...fn.locals.filter((l) => l.tdz).map((l) => l.id)]);
|
||||
const calls: string[] = [];
|
||||
const bigint = (e: IrExpr): boolean => e.type.kind === "bigint";
|
||||
const expr = (e: IrExpr): boolean => {
|
||||
if (!CAPTURE_FREE_EXPRS.has(e.kind)) return false;
|
||||
switch (e.kind) {
|
||||
case "varRef":
|
||||
case "incDec":
|
||||
case "assignExpr":
|
||||
if (tdz.has(e.localId)) return false;
|
||||
break;
|
||||
case "bin":
|
||||
// BigInt division and remainder by zero throw RangeError.
|
||||
if (bigint(e) || bigint(e.left) || bigint(e.right)) return false;
|
||||
break;
|
||||
case "unary":
|
||||
if (bigint(e) || bigint(e.operand)) return false;
|
||||
break;
|
||||
case "libCall":
|
||||
// Math functions only: they neither throw nor call back.
|
||||
if (!e.fn.startsWith("math.") || MAY_THROW_LIB_FNS.has(e.fn)) return false;
|
||||
break;
|
||||
case "arrIntrinsic": {
|
||||
// Methods that cannot throw, over numbers, booleans or strings
|
||||
// (no element conversion can run program code) and without a
|
||||
// callback argument.
|
||||
const elem = e.receiver.type.kind === "array" ? e.receiver.type.elem.kind : null;
|
||||
if (
|
||||
MAY_THROW_ARR_METHODS.has(e.method) ||
|
||||
(elem !== "f64" && elem !== "bool" && elem !== "string") ||
|
||||
e.args.some((arg) => arg.type.kind === "func")
|
||||
)
|
||||
return false;
|
||||
break;
|
||||
}
|
||||
case "call":
|
||||
calls.push(e.callee);
|
||||
break;
|
||||
case "new":
|
||||
calls.push(`%${e.className}.constructor`);
|
||||
break;
|
||||
case "virtualCall": {
|
||||
const impls = methodImpls.get(e.method);
|
||||
if (impls === undefined) return false;
|
||||
calls.push(...impls);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return everyExprChild(e, expr, stmt);
|
||||
};
|
||||
const stmt = (s: IrStmt): boolean => {
|
||||
if (!CAPTURE_FREE_STMTS.has(s.kind)) return false;
|
||||
if (s.kind === "assign" && tdz.has(s.localId)) return false;
|
||||
if (published !== null && writesPublished(published, s)) return false;
|
||||
return everyStmtChild(s, expr, stmt);
|
||||
};
|
||||
if (fn.body.every(stmt)) callees.set(fn.name, calls);
|
||||
}
|
||||
// Drop candidates that call a function outside the set until none do.
|
||||
for (const name of callees.keys()) free.add(name);
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [name, calls] of callees) {
|
||||
if (free.has(name) && !calls.every((callee) => free.has(callee))) {
|
||||
free.delete(name);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return free;
|
||||
}
|
||||
@@ -678,10 +678,14 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
|
||||
out,
|
||||
};
|
||||
lowerer.activeStmtLists.push(entry);
|
||||
const inFunction = stmts.length > 0 && enclosedByFunction(stmts[0]!);
|
||||
let unreachable = false;
|
||||
try {
|
||||
for (let i = 0; i < stmts.length; i++) {
|
||||
const stmt = stmts[i]!;
|
||||
entry.index = i;
|
||||
if (unreachable && skipsWhenUnreachable(stmt)) continue;
|
||||
if (inFunction && isAbruptStatement(stmt)) unreachable = true;
|
||||
if (!lowerer.suppressStats) {
|
||||
lowerer.stats.statementsTotal++;
|
||||
if (sf !== undefined) lowerer.bumpFileStat(sf.fileName, "total");
|
||||
@@ -803,6 +807,54 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether a statement list belongs to a function body (at any block
|
||||
* depth) rather than to a module's top level. */
|
||||
function enclosedByFunction(stmt: ts.Statement): boolean {
|
||||
let owner: ts.Node | undefined = stmt.parent;
|
||||
while (owner !== undefined && !ts.isFunctionLike(owner) && !ts.isSourceFile(owner))
|
||||
owner = owner.parent;
|
||||
return owner !== undefined && ts.isFunctionLike(owner);
|
||||
}
|
||||
|
||||
/** `return`, `throw`, `break` and `continue`: the rest of the enclosing
|
||||
* statement list never runs. */
|
||||
function isAbruptStatement(stmt: ts.Statement): boolean {
|
||||
return (
|
||||
ts.isReturnStatement(stmt) ||
|
||||
ts.isThrowStatement(stmt) ||
|
||||
ts.isBreakStatement(stmt) ||
|
||||
ts.isContinueStatement(stmt)
|
||||
);
|
||||
}
|
||||
|
||||
/** Statements after an abrupt statement in a function body that lowering
|
||||
* skips: they never execute, so neither their unsupported constructs nor the
|
||||
* functions they reference become part of the program (production builds of
|
||||
* libraries keep development-only code behind `throw` stubs this way).
|
||||
* Hoisting keeps its meaning: a reference above the abrupt statement to a
|
||||
* later function declaration lowers that declaration eagerly
|
||||
* (predeclareForwardFnDecl), and references to a later `var`, `let` or
|
||||
* `const` binding resolve through the forward-hoisting machinery to its
|
||||
* undefined or TDZ state, which skipping its initializer preserves. Class,
|
||||
* enum, module and import/export declarations and generic function
|
||||
* declarations still lower. */
|
||||
function skipsWhenUnreachable(stmt: ts.Statement): boolean {
|
||||
if (ts.isFunctionDeclaration(stmt)) return stmt.typeParameters === undefined;
|
||||
if (ts.isVariableStatement(stmt))
|
||||
return !(stmt.modifiers ?? []).some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
||||
return !(
|
||||
ts.isClassDeclaration(stmt) ||
|
||||
ts.isEnumDeclaration(stmt) ||
|
||||
ts.isModuleDeclaration(stmt) ||
|
||||
ts.isImportDeclaration(stmt) ||
|
||||
ts.isImportEqualsDeclaration(stmt) ||
|
||||
ts.isExportDeclaration(stmt) ||
|
||||
ts.isExportAssignment(stmt) ||
|
||||
ts.isInterfaceDeclaration(stmt) ||
|
||||
ts.isTypeAliasDeclaration(stmt)
|
||||
);
|
||||
}
|
||||
|
||||
const TDZ_KINDS = new Set<IrType["kind"]>([
|
||||
"func",
|
||||
"string",
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6170-flight-dev-stacks-dead.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6171-error-stack-capture-sites.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6172-unreachable-statements.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -84,6 +84,10 @@ typedef struct ScrExcCell {
|
||||
ScrTraceFn trace_fn;
|
||||
ScrStackFrame *stack; /* execution state; never moves with an exception */
|
||||
} ScrExcCell;
|
||||
/* Executables push and pop source frames inline (emitter.ts, stack-trace
|
||||
* mode) at this offset on 64-bit targets. */
|
||||
_Static_assert(sizeof(void *) != 8 || offsetof(ScrExcCell, stack) == 56,
|
||||
"ScrExcCell.stack moved: update the emitter's inline frame push");
|
||||
|
||||
/* Runtime-internal (fiber machinery in scr_async.c) — never emitted. */
|
||||
ScrExcCell *scr_exc_swap_cell(ScrExcCell *cell); /* NULL = main's cell */
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
// React Flight's production server as compiled from its sources: the V8
|
||||
// structured-stack parser (ReactFlightStackConfigV8) is development-only and
|
||||
// is referenced only after the production `throw` stubs, so a production
|
||||
// render never parses a stack. Statements after an abrupt statement never
|
||||
// run: they may use APIs with no static lowering (Error.prepareStackTrace,
|
||||
// CallSite) and must not make the program keep stack frames.
|
||||
// V8's structured stack API, declared here as React's Flow build declares
|
||||
// it (the program's own types, not Node's).
|
||||
interface CallSite {
|
||||
getTypeName(): string | null;
|
||||
getMethodName(): string | null;
|
||||
getFunctionName(): string | null;
|
||||
getScriptNameOrSourceURL(): string | null;
|
||||
getEvalOrigin(): string | undefined;
|
||||
getLineNumber(): number | null;
|
||||
getColumnNumber(): number | null;
|
||||
isNative(): boolean;
|
||||
isAsync(): boolean;
|
||||
isConstructor(): boolean;
|
||||
isToplevel(): boolean;
|
||||
isEval(): boolean;
|
||||
toString(): string;
|
||||
}
|
||||
declare global {
|
||||
interface ErrorConstructor {
|
||||
prepareStackTrace?: (error: Error, structuredStackTrace: CallSite[]) => string;
|
||||
}
|
||||
}
|
||||
type ReactStackTrace = Array<[string, string, number, number, number, number, boolean]>;
|
||||
|
||||
let framesToSkip: number = 0;
|
||||
let collectedStackTrace: null | ReactStackTrace = null;
|
||||
const identifierRegExp = /^[a-zA-Z_$][0-9a-zA-Z_$]*$/;
|
||||
|
||||
function getMethodCallName(callSite: CallSite): string {
|
||||
const typeName = callSite.getTypeName();
|
||||
const methodName = callSite.getMethodName();
|
||||
const functionName = callSite.getFunctionName();
|
||||
let result = '';
|
||||
if (functionName) {
|
||||
if (typeName && identifierRegExp.test(functionName) && functionName !== typeName) {
|
||||
result += typeName + '.';
|
||||
}
|
||||
result += functionName;
|
||||
if (methodName && functionName !== methodName && !functionName.endsWith('.' + methodName) && !functionName.endsWith(' ' + methodName)) {
|
||||
result += ' [as ' + methodName + ']';
|
||||
}
|
||||
} else {
|
||||
if (typeName) {
|
||||
result += typeName + '.';
|
||||
}
|
||||
result += methodName ? methodName : '<anonymous>';
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectStackTracePrivate(error: Error, structuredStackTrace: CallSite[]): string {
|
||||
const result: ReactStackTrace = [];
|
||||
for (let i = framesToSkip; i < structuredStackTrace.length; i++) {
|
||||
const callSite = structuredStackTrace[i];
|
||||
let name = callSite.getFunctionName() || '<anonymous>';
|
||||
if (name.includes('react_stack_bottom_frame')) {
|
||||
break;
|
||||
} else if (callSite.isNative()) {
|
||||
result.push([name, '', 0, 0, 0, 0, callSite.isAsync()]);
|
||||
} else {
|
||||
if (callSite.isConstructor()) {
|
||||
name = 'new ' + name;
|
||||
} else if (!callSite.isToplevel()) {
|
||||
name = getMethodCallName(callSite);
|
||||
}
|
||||
if (name === '<anonymous>') {
|
||||
name = '';
|
||||
}
|
||||
let filename = callSite.getScriptNameOrSourceURL() || '<anonymous>';
|
||||
if (filename === '<anonymous>') {
|
||||
filename = '';
|
||||
if (callSite.isEval()) {
|
||||
const origin = callSite.getEvalOrigin();
|
||||
if (origin) {
|
||||
filename = origin.toString() + ', <anonymous>';
|
||||
}
|
||||
}
|
||||
}
|
||||
const line = callSite.getLineNumber() || 0;
|
||||
const col = callSite.getColumnNumber() || 0;
|
||||
result.push([name, filename, line, col, 0, 0, callSite.isAsync()]);
|
||||
}
|
||||
}
|
||||
collectedStackTrace = result;
|
||||
return '';
|
||||
}
|
||||
|
||||
function collectStackTrace(error: Error, structuredStackTrace: CallSite[]): string {
|
||||
collectStackTracePrivate(error, structuredStackTrace);
|
||||
const name = error.name || 'Error';
|
||||
const message = error.message || '';
|
||||
let stack = name + ': ' + message;
|
||||
for (let i = 0; i < structuredStackTrace.length; i++) {
|
||||
stack += '\n at ' + structuredStackTrace[i].toString();
|
||||
}
|
||||
return stack;
|
||||
}
|
||||
|
||||
function parseStackTrace(error: Error, skipFrames: number): ReactStackTrace {
|
||||
collectedStackTrace = null;
|
||||
framesToSkip = skipFrames;
|
||||
const previousPrepare = Error.prepareStackTrace;
|
||||
Error.prepareStackTrace = collectStackTrace;
|
||||
let stack;
|
||||
try {
|
||||
stack = String(error.stack);
|
||||
} finally {
|
||||
Error.prepareStackTrace = previousPrepare;
|
||||
}
|
||||
if (collectedStackTrace !== null) {
|
||||
const result = collectedStackTrace;
|
||||
collectedStackTrace = null;
|
||||
return result;
|
||||
}
|
||||
return stack.split('\n').length > 0 ? [] : [];
|
||||
}
|
||||
|
||||
function renderDebugModel(value: any): any {
|
||||
if (value !== null && typeof value === 'object' && value._debugStack != null) {
|
||||
return parseStackTrace(value._debugStack, 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function emitOutlinedDebugModelChunk(model: any): string {
|
||||
throw new Error('emitOutlinedDebugModel should never be called in production mode. This is a bug in React.');
|
||||
function replacer(this: any, parentPropertyName: string, value: any): any {
|
||||
return renderDebugModel(this[parentPropertyName]);
|
||||
}
|
||||
const json: string = JSON.stringify(model, replacer);
|
||||
return json;
|
||||
}
|
||||
|
||||
function outlineComponentInfo(componentInfo: any): string {
|
||||
throw new Error('outlineComponentInfo should never be called in production mode. This is a bug in React.');
|
||||
const counter = { objectLimit: 10 };
|
||||
if (componentInfo.debugStack != null) {
|
||||
counter.objectLimit += parseStackTrace(componentInfo.debugStack, 1).length;
|
||||
}
|
||||
return emitOutlinedDebugModelChunk(componentInfo) + counter.objectLimit;
|
||||
}
|
||||
|
||||
// The production render: elements become `id:json` rows.
|
||||
type Element = { type: string; props: { [key: string]: string }; children: Element[] };
|
||||
function el(type: string, props: { [key: string]: string }, children: Element[]): Element {
|
||||
return { type, props, children };
|
||||
}
|
||||
function renderModel(node: Element): string {
|
||||
const children = node.children.map(renderModel);
|
||||
return '["$","' + node.type + '",null,' + JSON.stringify(node.props).slice(0, -1) + (children.length > 0 ? (Object.keys(node.props).length > 0 ? ',' : '') + '"children":[' + children.join(',') + ']' : '') + '}]';
|
||||
}
|
||||
const rows: string[] = [];
|
||||
const items: Element[] = [];
|
||||
for (let i = 0; i < 3; i++) items.push(el('li', { key: 'k' + i }, []));
|
||||
rows.push('0:' + renderModel(el('main', {}, [el('ul', { className: 'list' }, items)])));
|
||||
console.log(rows.join('\n'));
|
||||
|
||||
// The production stubs themselves run and throw; their DEV bodies never do.
|
||||
for (const stub of [() => outlineComponentInfo({ debugStack: new Error('owner') }), () => emitOutlinedDebugModelChunk({})]) {
|
||||
try {
|
||||
stub();
|
||||
} catch (error) {
|
||||
console.log((error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,79 @@
|
||||
// Stack captures keep the frames of every function active when the Error is
|
||||
// created: callers of constructors and closures, recursion, and errors a
|
||||
// runtime operation creates and a local catch takes. Leaf functions and
|
||||
// accessors that cannot create an Error run in between. Captures record
|
||||
// frames when the Error is created and format them on the first `.stack`
|
||||
// read.
|
||||
let lastCaught: Error | null = null;
|
||||
|
||||
function parseNumber(text: string): number {
|
||||
try {
|
||||
return JSON.parse(text) as number;
|
||||
} catch (error) {
|
||||
lastCaught = error as Error;
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
class Failure {
|
||||
readonly error: Error;
|
||||
constructor(reason: string) {
|
||||
this.error = new Error(reason);
|
||||
}
|
||||
}
|
||||
function buildFailure(reason: string): Failure {
|
||||
return new Failure(reason);
|
||||
}
|
||||
|
||||
class Counter {
|
||||
count: number = 0;
|
||||
get next(): number {
|
||||
return ++this.count;
|
||||
}
|
||||
}
|
||||
|
||||
function leaf(n: number): number {
|
||||
return n * 2 + 1;
|
||||
}
|
||||
function sumLeaves(n: number): number {
|
||||
let total = 0;
|
||||
for (let i = 0; i < n; i++) total += leaf(i);
|
||||
return total;
|
||||
}
|
||||
function viaClosure(make: () => Error): Error {
|
||||
return make();
|
||||
}
|
||||
function recurse(n: number): Error {
|
||||
return n === 0 ? new Error('deep') : recurse(n - 1);
|
||||
}
|
||||
function hasFrame(error: Error, name: string): boolean {
|
||||
return error.stack!.split('\n').some((line) => line.includes(' ' + name + ' ') || line.endsWith(' ' + name));
|
||||
}
|
||||
|
||||
const counter = new Counter();
|
||||
console.log(parseNumber('12'), parseNumber('{'), counter.next, counter.next, sumLeaves(4));
|
||||
const caught = lastCaught!;
|
||||
console.log(caught.name, caught.stack!.startsWith('SyntaxError: '), hasFrame(caught, 'parseNumber'));
|
||||
|
||||
const constructed = buildFailure('in constructor').error;
|
||||
console.log(constructed.stack!.split('\n')[0], hasFrame(constructed, 'buildFailure'));
|
||||
|
||||
const closure = viaClosure(() => new Error('in closure'));
|
||||
console.log(closure.stack!.split('\n')[0], hasFrame(closure, 'viaClosure'));
|
||||
|
||||
// Frames are recorded at creation: a later limit and later calls do not
|
||||
// change them, and the first read fixes the text.
|
||||
Error.stackTraceLimit = 3;
|
||||
const deep = recurse(10);
|
||||
Error.stackTraceLimit = 10;
|
||||
sumLeaves(100);
|
||||
const first = deep.stack!;
|
||||
console.log(first.split('\n').length, hasFrame(deep, 'recurse'), first === deep.stack);
|
||||
deep.message = 'changed';
|
||||
console.log(deep.stack === first);
|
||||
|
||||
// An Error created with the limit at zero has no frames.
|
||||
Error.stackTraceLimit = 0;
|
||||
const bare = recurse(2);
|
||||
Error.stackTraceLimit = 10;
|
||||
console.log(JSON.stringify(bare.stack));
|
||||
@@ -0,0 +1,85 @@
|
||||
// Statements after return, throw, break or continue never run, while the
|
||||
// declarations among them keep their hoisting: a later function declaration
|
||||
// is callable from above, a later `var` reads undefined, and a later
|
||||
// `let`/`const` stays in its temporal dead zone.
|
||||
function hoistedCall(): number {
|
||||
return twice(21);
|
||||
console.log('never');
|
||||
function twice(n: number): number {
|
||||
return n * 2;
|
||||
}
|
||||
}
|
||||
|
||||
function hoistedVar(): string {
|
||||
const before = typeof late;
|
||||
return before;
|
||||
var late: string | undefined = 'assigned';
|
||||
console.log(late);
|
||||
}
|
||||
|
||||
function deadZone(): string {
|
||||
const read = (): number => counted;
|
||||
try {
|
||||
return String(read());
|
||||
} catch (error) {
|
||||
return (error as Error).name + ': ' + (error as Error).message;
|
||||
}
|
||||
const counted = 1;
|
||||
}
|
||||
|
||||
function closureReference(): number {
|
||||
const call = (): number => helper();
|
||||
return call();
|
||||
function helper(): number {
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
function loops(): string {
|
||||
const seen: string[] = [];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (i === 1) {
|
||||
seen.push('skip');
|
||||
continue;
|
||||
seen.push('never');
|
||||
}
|
||||
if (i === 3) {
|
||||
seen.push('stop');
|
||||
break;
|
||||
seen.push('never');
|
||||
}
|
||||
seen.push('i' + i);
|
||||
}
|
||||
return seen.join(',');
|
||||
}
|
||||
|
||||
function cases(n: number): string {
|
||||
switch (n) {
|
||||
case 0:
|
||||
return 'zero';
|
||||
console.log('never');
|
||||
case 1:
|
||||
n += 10;
|
||||
case 2:
|
||||
return 'fell to ' + n;
|
||||
default:
|
||||
throw new RangeError('n=' + n);
|
||||
console.log('never');
|
||||
}
|
||||
}
|
||||
|
||||
function stub(): string {
|
||||
throw new Error('stub');
|
||||
const unused = [1, 2, 3].map((x) => x * 2);
|
||||
return unused.join();
|
||||
}
|
||||
|
||||
console.log(hoistedCall(), hoistedVar(), deadZone(), closureReference(), loops());
|
||||
console.log(cases(0), cases(1), cases(2));
|
||||
for (const run of [() => cases(5), stub]) {
|
||||
try {
|
||||
run();
|
||||
} catch (error) {
|
||||
console.log((error as Error).name, (error as Error).message);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
/* Reachability's build contract — scriptc-only, deliberately NOT
|
||||
* differential:
|
||||
* differential (tests/corpus/6170-flight-dev-stacks-dead pins statement-level
|
||||
* reachability differentially; here its emitted LLVM is grepped):
|
||||
*
|
||||
* - The dead-strip side (a program importing a module whose UNUSED exports
|
||||
* hold rejected constructs must build and run) is covered differentially
|
||||
@@ -49,6 +50,36 @@ test("unreached bodies leave no trace in the emitted LLVM", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("statements after an abrupt statement leave no trace and no stack frames", async () => {
|
||||
const outDir = join(cacheDir, `deadstrip-abrupt${sanitize ? "-san" : ""}`);
|
||||
const result = await compile(join(repoRoot, "tests/corpus/6170-flight-dev-stacks-dead.ts"), {
|
||||
outPath: join(outDir, "program"),
|
||||
outDir,
|
||||
sanitize,
|
||||
backend: "llvm",
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
}
|
||||
const c = readFileSync(result.llvmPath, "utf8");
|
||||
for (const reached of ["outlineComponentInfo", "emitOutlinedDebugModelChunk", "renderModel"]) {
|
||||
expect(c, `expected reached '${reached}' in the emitted LLVM`).toContain(reached);
|
||||
}
|
||||
// The development-only stack parser is referenced only after the
|
||||
// production stubs' throw: none of it is emitted, and the program keeps
|
||||
// no source frames (its only stack read never runs).
|
||||
for (const gone of [
|
||||
"parseStackTrace",
|
||||
"collectStackTrace",
|
||||
"getMethodCallName",
|
||||
"renderDebugModel",
|
||||
]) {
|
||||
expect(c, `unreached '${gone}' must leave no trace in the emitted LLVM`).not.toContain(gone);
|
||||
}
|
||||
expect(c).not.toContain("source_frame");
|
||||
expect(c).not.toContain("scr_stack_enter");
|
||||
});
|
||||
|
||||
/** Compiles an in-repo fixture and returns its diagnostics codes+messages.
|
||||
* Deliberately NO backend pin: every program here fails during lowering,
|
||||
* before any backend runs — the default is irrelevant by construction, and
|
||||
|
||||
Reference in New Issue
Block a user