diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 52cbf5026..0c5cad25f 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -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/**", diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 84614b6cb..1c74dbe86 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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(); logArgSlots = 0; private readonly stackTraces: boolean; + /** Stack-trace mode: functions no capture can include (no frame push). */ + private readonly unframedFunctions: ReadonlySet; 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); diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index ac45129f0..f07bbfa78 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -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 { +export function unreachableStatements(body: readonly IrStmt[]): Set { const dead = new Set(); const { statement } = completion(); const list = (stmts: readonly IrStmt[]): void => { diff --git a/packages/compiler/src/backend/stack-observations.test.ts b/packages/compiler/src/backend/stack-observations.test.ts new file mode 100644 index 000000000..f58c89ad8 --- /dev/null +++ b/packages/compiler/src/backend/stack-observations.test.ts @@ -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 => ({ + 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); +}); diff --git a/packages/compiler/src/backend/stack-observations.ts b/packages/compiler/src/backend/stack-observations.ts new file mode 100644 index 000000000..7debf0751 --- /dev/null +++ b/packages/compiler/src/backend/stack-observations.ts @@ -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 = 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(); + 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 = 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 = 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 | null, + published: PublishedTypes | null, +): Set { + const free = new Set(); + if (mod.workers === true) return free; + const methodImpls = new Map(); + 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(); + 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; +} diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 64b4fd6f7..972dd8766 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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([ "func", "string", diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6170-flight-dev-stacks-dead.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6170-flight-dev-stacks-dead.ts.json new file mode 100644 index 000000000..f78bd38f5 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6170-flight-dev-stacks-dead.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6170-flight-dev-stacks-dead.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6171-error-stack-capture-sites.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6171-error-stack-capture-sites.ts.json new file mode 100644 index 000000000..b4cdd2191 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6171-error-stack-capture-sites.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6171-error-stack-capture-sites.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6172-unreachable-statements.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6172-unreachable-statements.ts.json new file mode 100644 index 000000000..3ecbd301c --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6172-unreachable-statements.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6172-unreachable-statements.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index 80fb98957..eaef90f3d 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -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 */ diff --git a/tests/corpus/6170-flight-dev-stacks-dead.ts b/tests/corpus/6170-flight-dev-stacks-dead.ts new file mode 100644 index 000000000..bddf70f6a --- /dev/null +++ b/tests/corpus/6170-flight-dev-stacks-dead.ts @@ -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 : ''; + } + 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() || ''; + 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 === '') { + name = ''; + } + let filename = callSite.getScriptNameOrSourceURL() || ''; + if (filename === '') { + filename = ''; + if (callSite.isEval()) { + const origin = callSite.getEvalOrigin(); + if (origin) { + filename = origin.toString() + ', '; + } + } + } + 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 {}; diff --git a/tests/corpus/6171-error-stack-capture-sites.ts b/tests/corpus/6171-error-stack-capture-sites.ts new file mode 100644 index 000000000..1a8c8494d --- /dev/null +++ b/tests/corpus/6171-error-stack-capture-sites.ts @@ -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)); diff --git a/tests/corpus/6172-unreachable-statements.ts b/tests/corpus/6172-unreachable-statements.ts new file mode 100644 index 000000000..53704f694 --- /dev/null +++ b/tests/corpus/6172-unreachable-statements.ts @@ -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); + } +} diff --git a/tests/harness/deadstrip.test.ts b/tests/harness/deadstrip.test.ts index 7db7ec404..1baa92ac9 100644 --- a/tests/harness/deadstrip.test.ts +++ b/tests/harness/deadstrip.test.ts @@ -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