Keep React Flight's development-only stack parsing out of production builds, and make stack mode cheaper (#1330)

This commit is contained in:
Chris Tate
2026-10-18 18:01:06 -05:00
committed by GitHub
14 changed files with 858 additions and 15 deletions
+1
View File
@@ -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/**",
+39 -13
View File
@@ -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);
+1 -1
View File
@@ -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",
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6170-flight-dev-stacks-dead.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6171-error-stack-capture-sites.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6172-unreachable-statements.ts"
],
"diags": []
}
+4
View File
@@ -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 */
+173
View File
@@ -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);
}
}
+32 -1
View File
@@ -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