From c2ec0b989d65ebbf526870419902fc7f7ff83f9a Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 22 Oct 2026 06:52:18 -0500 Subject: [PATCH 1/4] Capture stack traces with Error.captureStackTrace as Node does --- .../ambient/scriptc-node-fallback.d.ts | 1 + .../compiler/src/backend/llvm/lib-process.ts | 26 +++ .../compiler/src/backend/llvm/lib-shared.ts | 1 - .../src/frontend/lowering/builtins/errors.ts | 175 ++++++++++++------ .../src/frontend/lowering/lower-calls.ts | 8 + packages/compiler/src/ir/builtin-effects.ts | 4 +- .../compiler/src/ir/builtin-signatures.ts | 2 +- .../6873-error-capture-stack-trace.ts.json | 6 + ...error-capture-stack-trace-objects.cjs.json | 6 + .../error-capture-stack-trace-fences.ts.json | 6 + packages/runtime/src/scr_dyn.h | 2 +- packages/runtime/src/scr_exception.c | 24 ++- packages/runtime/src/scr_exception.h | 1 + packages/runtime/src/scr_json.c | 150 ++++++++++----- .../corpus/6873-error-capture-stack-trace.ts | 87 +++++++++ ...6874-error-capture-stack-trace-objects.cjs | 92 +++++++++ .../error-capture-stack-trace-fences.ts | 21 +++ .../error-capture-stack-trace-fences.ts.txt | 26 +++ .../6873-error-capture-stack-trace.ts.json | 1 + ...error-capture-stack-trace-objects.cjs.json | 1 + 20 files changed, 530 insertions(+), 110 deletions(-) create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6873-error-capture-stack-trace.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6874-error-capture-stack-trace-objects.cjs.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/error-capture-stack-trace-fences.ts.json create mode 100644 tests/corpus/6873-error-capture-stack-trace.ts create mode 100644 tests/corpus/6874-error-capture-stack-trace-objects.cjs create mode 100644 tests/diagnostics/error-capture-stack-trace-fences.ts create mode 100644 tests/harness/__snapshots__/error-capture-stack-trace-fences.ts.txt create mode 100644 tests/harness/corpus-costs/6873-error-capture-stack-trace.ts.json create mode 100644 tests/harness/corpus-costs/6874-error-capture-stack-trace-objects.cjs.json diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index b3d2954a8..c7dd06541 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -7586,4 +7586,5 @@ declare module "node:stream/web" { } interface ErrorConstructor { stackTraceLimit: number; + captureStackTrace(targetObject: object, constructorOpt?: Function): void; } diff --git a/packages/compiler/src/backend/llvm/lib-process.ts b/packages/compiler/src/backend/llvm/lib-process.ts index 3e8a9bff5..e60307fa0 100644 --- a/packages/compiler/src/backend/llvm/lib-process.ts +++ b/packages/compiler/src/backend/llvm/lib-process.ts @@ -677,6 +677,32 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue { const B = host.B; + if (e.fn === "error.captureStackTrace") { + // The second argument names the function constructorOpt skips through; + // its frame text is the constant its own frame pushes (emit-function.ts), + // or null when it has no frame, which leaves no frames. + const target = host.emitExpr(e.args[0]!); + const named = e.args[1]!; + const skip = e.args[2]!.kind === "boolLit" && e.args[2]!.value; + let through = "null"; + if (named.kind === "strLit" && named.value !== "") { + const fn = host.mod.functions.find((f) => f.name === named.value); + if ( + fn !== undefined && + host.stackTraces && + !host.wasi && + fn.sourceName !== undefined && + !host.unframedFunctions.has(fn.name) + ) + through = host.cstr(` at ${fn.sourceName} (${fn.loc.file})`); + } + host.declare(`declare void @scr_error_capture_stack_trace(ptr, ptr, i1)`); + B.line( + `call void @scr_error_capture_stack_trace(ptr ${target.name}, ptr ${through}, i1 ${skip ? "true" : "false"})`, + ); + host.emitPendingCheck(); + return { name: "", type: e.type }; + } if (e.fn === "error.argTypeThrow") { // Always throws with the runtime-rendered Received tail (the // error.nodeThrow dummy pattern). Borrows all three. diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 53c715108..bfe6a4ed6 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -1506,7 +1506,6 @@ export const LIB_FN_SYMS: Record = { "dyn.objectPrototype": "scr_dyn_object_prototype", "dyn.errorPrototype": "scr_dyn_error_prototype", "dyn.inheritsErrorPrototype": "scr_dyn_inherits_error_prototype", - "dyn.errorCaptureStackTrace": "scr_dyn_error_capture_stack_trace", "dyn.errorCaptureStackTraceFunction": "scr_dyn_error_capture_stack_trace_function", "dyn.functionApply": "scr_dyn_function_apply", "dyn.builtinMethod": "scr_dyn_builtin_method", diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 1371769d6..ce95d4a46 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -1,7 +1,6 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; -import { locOf } from "../../program.js"; -import { tryLowerExpression } from "../expressions/try-lower-expression.js"; +import { isJsSourceFile, locOf } from "../../program.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; import { BOOL, @@ -13,6 +12,7 @@ import { type IrStmt, type IrType, STRING, + VOID, } from "../../../ir/ir.js"; const ASSERTION_ERROR_PROPERTIES: ReadonlySet = new Set([ @@ -324,60 +324,6 @@ export function lowerErrorFunctionCall(lowerer: Lowerer, call: ts.CallExpression : error; } -/** `Error.captureStackTrace(target[, constructorOpt])` (also called as - * `Error.captureStackTrace?.(...)`, which always calls it). On a - * checked-dynamic target (`this` in a JavaScript constructor function) the - * runtime defines target's `stack`. An instance of a class that extends - * Error derives its stack from its name and message when the stack is - * read, so the call only evaluates its arguments. Both arguments are - * evaluated; the second only selects frames in Node. Null for other - * targets and callees. */ -export function lowerErrorCaptureStackTraceCall( - lowerer: Lowerer, - call: ts.CallExpression, - access: ts.PropertyAccessExpression, -): IrExpr | null { - if (access.questionDotToken) return null; - if (lowerer.stdlibGlobalMember(access, "Error") !== "captureStackTrace") return null; - const args = call.arguments.nodes; - if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) return null; - const probed = tryLowerExpression(lowerer, args[0]!); - const loc = locOf(call); - if ( - probed?.type.kind === "object" && - (probed.type.className === "%Error" || lowerer.isSubclassOf(probed.type.className, "%Error")) - ) { - const stmts: IrStmt[] = []; - for (const arg of args) { - const value = lowerer.lowerExpr(arg); - if (value.kind !== "varRef") stmts.push({ kind: "exprStmt", expr: value, loc: value.loc }); - } - const result = dynUndefinedExpr(loc); - return stmts.length > 0 ? { kind: "seqExpr", stmts, result, type: DYN, loc } : result; - } - if (probed?.type.kind !== "dyn") return null; - const target = lowerer.lowerExpr(args[0]!); - const result: IrExpr = { - kind: "libCall", - fn: "dyn.errorCaptureStackTrace", - args: [target], - type: DYN, - loc, - }; - if (args.length === 1) return result; - const local = lowerer.declareHiddenLocal("%captureTarget", DYN); - return { - kind: "seqExpr", - stmts: [ - { kind: "varDecl", localId: local.id, init: target, loc }, - { kind: "exprStmt", expr: lowerer.lowerExprExpecting(args[1]!, DYN), loc }, - ], - result: { ...result, args: [{ kind: "varRef", localId: local.id, type: DYN, loc }] }, - type: DYN, - loc, - }; -} - /** `Error.isError(value)`. A catch binding tests its payload against the * Error class, an `unknown` value tests the checked-dynamic tree's error * encoding, and a union tests its runtime tag against the arms whose class @@ -470,3 +416,120 @@ export function lowerErrorIsErrorCall( loc, }; } + +/** The class whose constructor body `node` is directly in (not inside a + * nested function): while it runs, `new` of that class is on the stack. */ +function enclosingConstructorClass(node: ts.Node): ts.ClassLikeDeclaration | undefined { + for (let n = node.parent; n !== undefined; n = n.parent) { + if (ts.isConstructorDeclaration(n)) + return ts.isClassDeclaration(n.parent) || ts.isClassExpression(n.parent) + ? n.parent + : undefined; + if (ts.isFunctionLike(n) && !ts.isArrowFunction(n)) return undefined; + } + return undefined; +} + +/** `Error.captureStackTrace(target, constructorOpt?)`, also through `?.`: + * target.stack becomes the target's heading and the current stack frames, + * as V8 does it (scr_error_capture_stack_trace). The target is an error, a + * checked-dynamic object, or a primitive (a TypeError at run time). + * constructorOpt leaves out the frames up to and including its topmost call: + * a function declaration skips through its own frame; `this.constructor`, + * `new.target` or the class's own name inside a class constructor leave the + * frames from the code that ran `new`, which are the current frames + * (constructors have no frames of their own). Other targets are refused, + * and so are other constructorOpt values in TypeScript; JavaScript keeps all + * frames for them. Null when the callee is not the + * standard library's Error.captureStackTrace. */ +export function lowerErrorCaptureStackTraceCall( + lowerer: Lowerer, + call: ts.CallExpression, + access: ts.PropertyAccessExpression, +): IrExpr | null { + if (lowerer.stdlibGlobalMember(access, "Error") !== "captureStackTrace") return null; + const args = call.arguments.nodes; + const loc = locOf(call); + if (args.length < 1 || args.length > 2 || args.some(ts.isSpreadElement)) { + lowerer.noLowering( + "Error.captureStackTrace arguments", + call, + "pass a target and an optional constructor", + ); + } + const target = lowerer.lowerExpr(args[0]!); + // Primitives (and unions of them) reach the runtime, which throws V8's + // TypeError for them; objects without a checked-dynamic view are refused, + // since a boxed copy would not show the stack. + const primitive = + ["f64", "string", "bool", "nullT", "undefinedT", "bigint"].includes(target.type.kind) || + (target.type.kind === "union" && + lowerer.dynConvertible(target.type) && + (lowerer.unions.get(target.type.unionId)?.arms ?? []).every((arm) => + ["f64", "string", "bool", "nullT", "undefinedT", "bigint"].includes(arm.kind), + )); + if (target.type.kind !== "dyn" && !primitive && !isErrorClassType(lowerer, target.type)) { + lowerer.noLowering( + `Error.captureStackTrace on '${lowerer.fmt(target.type)}' values`, + args[0]!, + "pass an Error instance, or an untyped object in JavaScript", + ); + } + let through = ""; + let skip = false; + const ctor = args[1]; + if (ctor !== undefined) { + let node: ts.Expression = ctor; + while (ts.isParenthesizedExpression(node)) node = node.expression; + const symbol = ts.isIdentifier(node) ? lowerer.checker.getSymbolAtLocation(node) : undefined; + const resolved = + symbol !== undefined && symbol.flags & ts.SymbolFlags.Alias + ? lowerer.checker.getAliasedSymbol(symbol) + : symbol; + const cls = enclosingConstructorClass(call); + const constructing = + (ts.isPropertyAccessExpression(node) && + node.expression.kind === ts.SyntaxKind.ThisKeyword && + node.name.text === "constructor") || + (ts.isMetaProperty(node) && node.name.text === "target") || + (resolved !== undefined && + cls?.name !== undefined && + lowerer.checker.getSymbolAtLocation(cls.name) === resolved); + const sig = resolved !== undefined ? lowerer.fnSigsBySymbol.get(resolved) : undefined; + if (constructing && cls !== undefined) { + skip = false; + } else if (sig !== undefined) { + lowerer.noteEdge(sig.name); + through = sig.name; + skip = true; + } else if (isJsSourceFile(ts.getSourceFileOfNode(call))) { + // JavaScript (undici's `captureStackTrace(err, module.exports.fetch)`) + // keeps every frame: the same frames V8 keeps, and the ones above them. + skip = false; + } else { + lowerer.noLowering( + "Error.captureStackTrace with this constructor argument", + ctor, + "pass a function declaration, or this.constructor or the class inside its constructor", + ); + } + } + const capture: IrExpr = { + kind: "libCall", + fn: "error.captureStackTrace", + args: [ + lowerer.coerceToExpected(target, DYN), + { kind: "strLit", value: through, type: STRING, loc }, + { kind: "boolLit", value: skip, type: BOOL, loc }, + ], + type: VOID, + loc, + }; + return { + kind: "seqExpr", + stmts: [{ kind: "exprStmt", expr: capture, loc }], + result: dynUndefinedExpr(loc), + type: DYN, + loc, + }; +} diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 39b7207c0..b9ec9418e 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -418,6 +418,14 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr { const timeoutOptional = lowerer.lowerTimeoutMethodCall(expr, expr.expression); if (timeoutOptional) return timeoutOptional; } + // `Error.captureStackTrace?.(this, this.constructor)`: Node always has it. + if ( + ts.isPropertyAccessExpression(expr.expression) && + (expr.questionDotToken || expr.expression.questionDotToken) + ) { + const captured = lowerErrorCaptureStackTraceCall(lowerer, expr, expr.expression); + if (captured) return captured; + } // `req.session?.m(...)` remains absent on compatibility requests. Live // `req.stream` calls now pass through the normal optional-chain and h2 // stream lowering using lowerServerProperty's checked getter. diff --git a/packages/compiler/src/ir/builtin-effects.ts b/packages/compiler/src/ir/builtin-effects.ts index c202b8538..40b9ff1ab 100644 --- a/packages/compiler/src/ir/builtin-effects.ts +++ b/packages/compiler/src/ir/builtin-effects.ts @@ -443,6 +443,7 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "js.toNumberCoerce", // Error messages use the same coercion protocol before installing cause. "error.newOptions", + "error.captureStackTrace", "error.ctorOptions", "error.newAggregate", "error.newSuppressed", @@ -610,9 +611,6 @@ export const MAY_THROW_LIB_FNS: ReadonlySet = new Set([ "dyn.objCreateWithProperties", "dyn.getPrototype", "dyn.setPrototype", - // Error.captureStackTrace: Node's TypeError for a primitive target, and - // the error string conversion of name and message - "dyn.errorCaptureStackTrace", // a proxy in the prototype chain can throw "dyn.builtinInstanceOf", "dyn.functionInstanceOf", diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index f37e8809f..a3bb620f1 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -3369,6 +3369,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ * Options retain cause presence; constructor calls borrow their args. * Message coercion may throw before the cause is installed. */ "error.newOptions": { argTypes: [DYN, DYN], result: VOID }, + "error.captureStackTrace": { argTypes: [DYN, STRING, BOOL], result: VOID }, "error.ctorOptions": { argTypes: [null, DYN, DYN], result: VOID }, /** Borrowed Error receiver. cause returns an owned dyn value (undefined * when absent); hasCause distinguishes absence from present undefined. */ @@ -3682,7 +3683,6 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "dyn.objectPrototype": { argTypes: [], result: DYN }, "dyn.errorPrototype": { argTypes: [], result: DYN }, "dyn.inheritsErrorPrototype": { argTypes: [DYN], result: BOOL }, - "dyn.errorCaptureStackTrace": { argTypes: [DYN], result: DYN }, "dyn.errorCaptureStackTraceFunction": { argTypes: [], result: DYN }, "dyn.functionApply": { argTypes: [], result: DYN }, "dyn.builtinMethod": { argTypes: [STRING, STRING], result: DYN }, diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6873-error-capture-stack-trace.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6873-error-capture-stack-trace.ts.json new file mode 100644 index 000000000..8544af112 --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6873-error-capture-stack-trace.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6873-error-capture-stack-trace.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6874-error-capture-stack-trace-objects.cjs.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6874-error-capture-stack-trace-objects.cjs.json new file mode 100644 index 000000000..3f25ca0aa --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/6874-error-capture-stack-trace-objects.cjs.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/6874-error-capture-stack-trace-objects.cjs" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/error-capture-stack-trace-fences.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/error-capture-stack-trace-fences.ts.json new file mode 100644 index 000000000..9159ca9cd --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/diagnostics/error-capture-stack-trace-fences.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/diagnostics/error-capture-stack-trace-fences.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index facf9f2ff..ef6f57926 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1092,7 +1092,6 @@ ScrStr *scr_dyn_to_string_method(const ScrDyn *d, const ScrStr *enc, const ScrSt ScrDyn *scr_dyn_object_prototype(void); ScrDyn *scr_dyn_error_prototype(void); bool scr_dyn_inherits_error_prototype(const ScrDyn *value); -ScrDyn *scr_dyn_error_capture_stack_trace(ScrDyn *target); ScrDyn *scr_dyn_error_capture_stack_trace_function(void); ScrDyn *scr_dyn_function_apply(void); ScrDyn *scr_dyn_reflect_apply(ScrDyn *target, ScrDyn *receiver, ScrDyn *arguments_list); @@ -1195,6 +1194,7 @@ ScrError *scr_error_from_dyn(const ScrDyn *d); /* scr_async_dyn.c (gated) */ /* The cache's runtime-internal access pair (scr_json.c owns the storage; * the gated extraction reads/writes through these). */ ScrError *scr_errdyn_err_of(const ScrDyn *d); /* +1 or NULL */ +void scr_error_capture_stack_trace(ScrDyn *target, const char *through, bool skip); void scr_errdyn_put(ScrError *e, ScrDyn *d); /* retains both sides */ /* Sets an own property on an error: its checked-dynamic node, which casts * and util.inspect read. Moves value. */ diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index c7ff71349..9dcad5b67 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -196,19 +196,21 @@ void scr_stack_limit_set(double limit) { scr_stack_limit = limit; } -ScrStr *scr_stack_capture(void) { - if (!SCR_EXC_CUR()->stack) return NULL; +/* The frames from `first` down, at most Error.stackTraceLimit of them, one + * "\n at ..." line each; NULL when there are none. */ +static ScrStr *scr_stack_capture_from(ScrStackFrame *first) { + if (!first) return NULL; const double limit = floor(scr_stack_limit); if (!(limit > 0)) return NULL; size_t length = 0; size_t count = 0; - for (ScrStackFrame *frame = SCR_EXC_CUR()->stack; frame && (double)count++ < limit; frame = frame->previous) + for (ScrStackFrame *frame = first; frame && (double)count++ < limit; frame = frame->previous) length += 1 + strlen(frame->text); char *text = malloc(length); if (!text) scr_trap("scriptc: out of memory\n"); size_t offset = 0; count = 0; - for (ScrStackFrame *frame = SCR_EXC_CUR()->stack; frame && (double)count++ < limit; frame = frame->previous) { + for (ScrStackFrame *frame = first; frame && (double)count++ < limit; frame = frame->previous) { text[offset++] = '\n'; size_t size = strlen(frame->text); memcpy(text + offset, frame->text, size); @@ -219,6 +221,20 @@ ScrStr *scr_stack_capture(void) { return out; } +ScrStr *scr_stack_capture(void) { + return scr_stack_capture_from(SCR_EXC_CUR()->stack); +} + +/* The frames below the topmost frame whose text is `through` (a function's + * " at name (file)" line), as Error.captureStackTrace's constructorOpt + * leaves them; NULL when no such frame is on the stack. */ +ScrStr *scr_stack_capture_after(const char *through) { + for (ScrStackFrame *frame = SCR_EXC_CUR()->stack; frame; frame = frame->previous) + if (frame->text == through || strcmp(frame->text, through) == 0) + return scr_stack_capture_from(frame->previous); + return NULL; +} + #define scr_exc_kind (SCR_EXC_CUR()->kind) #define scr_exc_f64 (SCR_EXC_CUR()->f64) #define scr_exc_bool (SCR_EXC_CUR()->b) diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index e99f84243..95153a4a8 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -68,6 +68,7 @@ typedef struct ScrStackFrame { void scr_stack_enter(ScrStackFrame *frame, const char *text); void scr_stack_leave(ScrStackFrame *frame); ScrStr *scr_stack_capture(void); /* +1, NULL when no source frames exist */ +ScrStr *scr_stack_capture_after(const char *through); /* +1, NULL when none remain */ double scr_stack_limit_get(void); void scr_stack_limit_set(double limit); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f610ffdbb..d7c084ae6 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1950,52 +1950,13 @@ ScrDyn *scr_dyn_error_prototype(void) { return scr_dyn_retain(scr_builtin_error_prototype); } -/* Error.captureStackTrace(target): defines a non-enumerable own `stack` - * on target. Node's value is the Error.prototype.toString text of target - * (read without calling an overriding toString) followed by stack frames; - * here, as for scriptc's own Error instances, it is the text alone. - * Answers undefined, or NULL with the exception pending. */ -ScrDyn *scr_dyn_error_capture_stack_trace(ScrDyn *target) { - /* An instance of a class that extends Error derives its stack from its - * name and message when the stack is read. */ - if (target->kind == SCR_DYN_TYPED_REF) { - ScrDyn *view = scr_dyn_typed_ref_materialize(target); - bool error = view && view->kind == SCR_DYN_OBJ && scr_dyn_obj_get(view, "%error", 6) != NULL; - scr_dyn_release(view); - if (scr_exc_pending()) return NULL; - if (error) return scr_dyn_retain(scr_dyn_undefined()); - } - if (target->kind != SCR_DYN_OBJ) { - if (target->kind == SCR_DYN_ARR || target->kind == SCR_DYN_FUNC || target->kind == SCR_DYN_TYPED_REF || - target->kind == SCR_DYN_HANDLE || target->kind == SCR_DYN_PROXY) { - static const char message[] = "Error.captureStackTrace on this object is not supported yet"; - scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020"); - } else { - static const char message[] = "invalid_argument"; - scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - } - return NULL; - } - ScrDynEntry *existing = scr_dyn_find_entry(target, "stack", 5); - if (existing ? existing->accessor || !existing->configurable : target->non_extensible) { - static const char message[] = "Cannot redefine property: stack"; - static const char extensible[] = "Cannot define property stack, object is not extensible"; - if (existing) scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1); - else scr_throw_error_msg(SCR_ERR_TYPE, extensible, sizeof extensible - 1); - return NULL; - } - ScrStr *text = scr_dyn_error_string(target); - if (!text) return NULL; - scr_dyn_obj_set_field(target, "stack", 5, scr_dyn_new_str(text)); - scr_str_release(text); - ScrDynEntry *entry = scr_dyn_find_entry(target, "stack", 5); - entry->enumerable = false; - entry->writable = entry->configurable = true; - return scr_dyn_retain(scr_dyn_undefined()); -} +/* Error.captureStackTrace called through its function value. The + * constructor argument's frame cannot be found from a function value, so + * every frame is kept: the frames V8 keeps and the ones above them. */ static ScrDyn *scr_error_capture_stack_trace_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { (void)closure; - return scr_dyn_error_capture_stack_trace(argc > 0 ? args[0] : scr_dyn_undefined()); + scr_error_capture_stack_trace(argc > 0 ? args[0] : scr_dyn_undefined(), NULL, false); + return scr_exc_pending() ? NULL : scr_dyn_retain(scr_dyn_undefined()); } /* Error.captureStackTrace read as a value: one function per thread. */ static SCR_TL ScrDyn *scr_error_capture_stack_trace_fn; @@ -15430,3 +15391,104 @@ bool scr_error_has_key(ScrError *e, const ScrStr *key) { return true; return scr_dyn_object_proto_name(key); } + +/* The `stack` accessor Error.captureStackTrace gives an object that is not + * an error. caps[0] holds the captured frames until the first read, then the + * stack; caps[1] is nonzero once caps[0] holds the stack. The first read + * formats it with the receiver's name and message then, as V8 does. */ +static ScrDyn *scr_captured_stack_get_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + (void)args; (void)argc; + ScrDyn *value = scr_box_get_ref(closure->caps[0]); + if (scr_box_get_f64(closure->caps[1]) != 0) return value; + ScrDyn *self = scr_dyn_this_get(); + ScrStr *heading = scr_dyn_error_string(self); + scr_dyn_release(self); + if (!heading) { + scr_dyn_release(value); + return NULL; + } + ScrStr *stack = scr_str_concat(heading, value->v.str); + scr_str_release(heading); + scr_dyn_release(value); + ScrDyn *out = scr_dyn_new_str(stack); + scr_str_release(stack); + scr_box_set_ref(closure->caps[0], scr_dyn_retain(out)); + scr_box_set_f64(closure->caps[1], 1); + return out; +} + +static ScrDyn *scr_captured_stack_set_call(ScrClosure *closure, ScrDyn *const *args, size_t argc) { + scr_box_set_ref(closure->caps[0], scr_dyn_retain(argc > 0 ? args[0] : scr_dyn_undefined())); + scr_box_set_f64(closure->caps[1], 1); + return scr_dyn_retain(scr_dyn_undefined()); +} + +/* Error.captureStackTrace(target, constructorOpt), as V8 does it: target.stack + * becomes the target's heading and the current stack frames, formatted when + * first read. With `skip`, the frames up to and including the topmost one + * whose text is `through` are left out (none remain when `through` is NULL or + * not on the stack). An error keeps the frames as its own; another object + * gets an own non-enumerable, configurable `stack` accessor. A primitive + * target throws `TypeError: invalid_argument` and a non-extensible object + * V8's define-property TypeError. */ +void scr_error_capture_stack_trace(ScrDyn *target, const char *through, bool skip) { + switch (target->kind) { + case SCR_DYN_UNDEF: + case SCR_DYN_NULL: + case SCR_DYN_BOOL: + case SCR_DYN_NUM: + case SCR_DYN_STR: + case SCR_DYN_BIGINT: + case SCR_DYN_SYMBOL: { + static const char msg[] = "invalid_argument"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + return; + } + default: + break; + } + ScrStr *frames = !skip ? scr_stack_capture() : through ? scr_stack_capture_after(through) : NULL; + ScrError *error = scr_errdyn_err_of(target); + if (error) { + scr_str_release(error->stack_frames); + error->stack_frames = frames; + scr_str_release(error->stack); + error->stack = NULL; + scr_error_release(error); + return; + } + if (target->kind != SCR_DYN_OBJ && target->kind != SCR_DYN_ARR) { + static const char msg[] = "Error.captureStackTrace on this object is not supported yet"; + scr_throw_error_msg_code(SCR_ERR_ERROR, msg, sizeof msg - 1, "SC2020"); + scr_str_release(frames); + return; + } + if (!scr_dyn_is_extensible(target)) { + static const char msg[] = "Cannot define property stack, object is not extensible"; + scr_throw_error_msg(SCR_ERR_TYPE, msg, sizeof msg - 1); + scr_str_release(frames); + return; + } + if (!frames) frames = scr_str_new("", 0); + /* The getter and setter share their boxes. */ + ScrClosure *state[2] = {scr_closure_new(NULL, 2), scr_closure_new(NULL, 2)}; + state[0]->caps[0] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v); + state[0]->caps[1] = scr_box_new(SCR_BOX_F64); + scr_box_set_ref(state[0]->caps[0], scr_dyn_new_str(frames)); + scr_str_release(frames); + for (int i = 0; i < 2; i++) state[1]->caps[i] = scr_box_retain(state[0]->caps[i]); + ScrDyn *descriptor = scr_dyn_new_obj(); + scr_dyn_obj_set(descriptor, "get", 3, + scr_dyn_new_func(state[0], scr_captured_stack_get_call, 0, "native:error.stack", NULL)); + scr_dyn_obj_set(descriptor, "set", 3, + scr_dyn_new_func(state[1], scr_captured_stack_set_call, 1, "native:error.stack", NULL)); + scr_dyn_obj_set(descriptor, "enumerable", 10, scr_dyn_new_bool(false)); + scr_dyn_obj_set(descriptor, "configurable", 12, scr_dyn_new_bool(true)); + ScrStr *key_text = scr_str_new("stack", 5); + ScrDyn *key = scr_dyn_new_str(key_text); + scr_str_release(key_text); + ScrDyn *result = scr_dyn_define_property(target, key, descriptor); + scr_dyn_release(result); + scr_dyn_release(key); + scr_dyn_release(descriptor); +} diff --git a/tests/corpus/6873-error-capture-stack-trace.ts b/tests/corpus/6873-error-capture-stack-trace.ts new file mode 100644 index 000000000..62db5572d --- /dev/null +++ b/tests/corpus/6873-error-capture-stack-trace.ts @@ -0,0 +1,87 @@ +// Error.captureStackTrace on errors, as Node does it: the error's stack +// becomes its heading and the current frames, starting at the function that +// called it; a constructor argument leaves out the frames up to and +// including that function's topmost call, and every frame when it is not on +// the stack. Error subclasses that call it with this.constructor (also +// through ?.) keep the frames from the code that ran `new`. +function frames(stack: string | undefined): string { + const lines = (stack ?? "").split("\n"); + const wanted = ["inner", "middle", "outer", "makeExpired", "skipped", "caller"]; + const names = lines.slice(1).map((line) => line.trim().split(" ")[1] ?? ""); + return `${lines[0]} | ${names.filter((name) => wanted.includes(name)).join(",")}`; +} + +function inner(target: Error): Error { + Error.captureStackTrace(target); + return target; +} +function middle(target: Error): Error { + return inner(target); +} +function outer(target: Error): Error { + return middle(target); +} +console.log("error", frames(outer(new Error("plain")).stack)); +console.log("type error", frames(outer(new TypeError("typed")).stack)); + +function skipped(target: Error): Error { + Error.captureStackTrace(target, skipped); + return target; +} +function caller(target: Error): Error { + return skipped(target); +} +console.log("constructorOpt", frames(caller(new Error("skip")).stack)); +function unrelated(): void {} +const missing = new Error("missing"); +Error.captureStackTrace(missing, unrelated); +console.log("not on the stack", JSON.stringify(missing.stack)); + +class AppError extends Error { + constructor(message: string) { + super(message); + this.name = "AppError"; + Error.captureStackTrace(this, this.constructor); + } +} +class Expired extends AppError { + constructor(message: string) { + super(message); + this.name = "Expired"; + } +} +class Optional extends Error { + constructor(message: string) { + super(message); + this.name = "Optional"; + Error.captureStackTrace?.(this, this.constructor); + } +} +class Named extends Error { + constructor(message: string) { + super(message); + Error.captureStackTrace(this, Named); + } +} +function makeExpired(): Error { + return new Expired("token expired"); +} +const expired = makeExpired(); +console.log("subclass", frames(expired.stack), expired instanceof AppError, expired.message); +console.log("optional call", frames(new Optional("maybe").stack)); +console.log("class name", frames(new Named("named").stack)); + +const renamed = new RangeError("range"); +renamed.name = "Custom"; +Error.captureStackTrace(renamed); +console.log("renamed", frames(renamed.stack)); +const late = new Error("late"); +Error.captureStackTrace(late); +late.name = "Late"; +console.log("renamed after", frames(late.stack)); + +Error.stackTraceLimit = 1; +console.log("limit", frames(outer(new Error("limited")).stack)); +Error.stackTraceLimit = 0; +console.log("limit 0", JSON.stringify(outer(new Error("none")).stack)); +Error.stackTraceLimit = 10; diff --git a/tests/corpus/6874-error-capture-stack-trace-objects.cjs b/tests/corpus/6874-error-capture-stack-trace-objects.cjs new file mode 100644 index 000000000..97d322659 --- /dev/null +++ b/tests/corpus/6874-error-capture-stack-trace-objects.cjs @@ -0,0 +1,92 @@ +// Error.captureStackTrace on objects in JavaScript, as Node does it: an own, +// non-enumerable, configurable `stack` accessor giving the object's heading +// (its name and message when first read, through Error.prototype.toString's +// rules, "Error" by default) and the frames from the caller; TypeErrors for a +// primitive and a non-extensible target. +'use strict'; +function frames(stack) { + const lines = String(stack).split('\n'); + const names = lines.slice(1).map((line) => line.trim().split(' ')[1]); + return `${lines[0]} | ${names.filter((name) => ['inner', 'middle', 'outer', 'skipped', 'caller'].includes(name)).join(',')}`; +} +function inner(target) { + Error.captureStackTrace(target); + return target; +} +function middle(target) { + return inner(target); +} +function outer(target) { + return middle(target); +} +console.log('plain', frames(outer({}).stack)); +console.log('named', frames(outer({ name: 'Parse', message: 'bad token' }).stack)); +console.log('message only', frames(outer({ message: 'just this' }).stack)); +console.log('empty name', frames(outer({ name: '', message: 'no name' }).stack)); +console.log('number fields', frames(outer({ name: 7, message: 8 }).stack)); +const own = inner({ kept: 1 }); +console.log('keys', JSON.stringify(Object.keys(own)), typeof own.stack); +const descriptor = Object.getOwnPropertyDescriptor(own, 'stack'); +console.log('descriptor', typeof descriptor.get, typeof descriptor.set, descriptor.enumerable, descriptor.configurable); +own.stack = 'replaced'; +console.log('writable', own.stack); +const later = inner({ message: 'first' }); +later.name = 'Renamed'; +later.message = 'second'; +console.log('formatted when read', frames(later.stack)); +later.name = 'Again'; +console.log('formatted once', frames(later.stack)); +const counted = { message: 'm' }; +let reads = 0; +Object.defineProperty(counted, 'name', { get() { reads++; return 'Counted'; } }); +inner(counted); +console.log('reads before stack', reads); +console.log('counted', frames(counted.stack), reads); +function skipped(target) { + Error.captureStackTrace(target, skipped); + return target; +} +function caller(target) { + return skipped(target); +} +console.log('constructorOpt', frames(caller({}).stack)); +const arr = []; +Error.captureStackTrace(arr); +console.log('array', frames(arr.stack), arr.length); +for (const value of [5, 'text', undefined, null, true]) { + try { + Error.captureStackTrace(value); + console.log('primitive', String(value), 'no error'); + } catch (error) { + console.log('primitive', String(value), error.name, error.message); + } +} +try { + Error.captureStackTrace(Object.freeze({})); + console.log('frozen', 'no error'); +} catch (error) { + console.log('frozen', error.name, error.message); +} +const api = { + run(target) { + Error.captureStackTrace(target, api.run); + return target; + }, +}; +function viaApi(target) { + return api.run(target); +} +console.log('member constructorOpt', frames(viaApi({ message: 'member' }).stack).split(' | ')[0]); +const captureFn = Error.captureStackTrace; +function viaValue(target) { + captureFn(target); + return target; +} +const fromValue = viaValue({ message: 'value' }); +fromValue.message = 'read later'; +console.log('function value', frames(fromValue.stack), captureFn.name, captureFn.length); +try { + captureFn(7); +} catch (error) { + console.log('function value primitive', error.name, error.message); +} diff --git a/tests/diagnostics/error-capture-stack-trace-fences.ts b/tests/diagnostics/error-capture-stack-trace-fences.ts new file mode 100644 index 000000000..b30afb7b7 --- /dev/null +++ b/tests/diagnostics/error-capture-stack-trace-fences.ts @@ -0,0 +1,21 @@ +// Error.captureStackTrace gives a stack to errors and untyped objects; a +// typed object has no stack property to receive it, and a constructor +// argument other than a function declaration or the constructing class +// cannot be found on the stack. +interface Report { + message: string; +} +const report: Report = { message: "typed" }; +Error.captureStackTrace(report); + +const arrow = (): void => {}; +Error.captureStackTrace(new Error("arrow"), arrow); + +class Other {} +class Wrapped extends Error { + constructor() { + super("wrapped"); + Error.captureStackTrace(this, Other); + } +} +console.log(new Wrapped().stack); diff --git a/tests/harness/__snapshots__/error-capture-stack-trace-fences.ts.txt b/tests/harness/__snapshots__/error-capture-stack-trace-fences.ts.txt new file mode 100644 index 000000000..b12866eb2 --- /dev/null +++ b/tests/harness/__snapshots__/error-capture-stack-trace-fences.ts.txt @@ -0,0 +1,26 @@ +error-capture-stack-trace-fences.ts:9:25 - error SC2020: 'Error.captureStackTrace on '{ message: string }' values' is part of the standard library types but has no scriptc lowering yet + + 8 | const report: Report = { message: "typed" }; + 9 | Error.captureStackTrace(report); + | ^~~~~~ + 10 | + + hint: pass an Error instance, or an untyped object in JavaScript + +error-capture-stack-trace-fences.ts:12:45 - error SC2020: 'Error.captureStackTrace with this constructor argument' is part of the standard library types but has no scriptc lowering yet + + 11 | const arrow = (): void => {}; + 12 | Error.captureStackTrace(new Error("arrow"), arrow); + | ^~~~~ + 13 | + + hint: pass a function declaration, or this.constructor or the class inside its constructor + +error-capture-stack-trace-fences.ts:18:35 - error SC2020: 'Error.captureStackTrace with this constructor argument' is part of the standard library types but has no scriptc lowering yet + + 17 | super("wrapped"); + 18 | Error.captureStackTrace(this, Other); + | ^~~~~ + 19 | } + + hint: pass a function declaration, or this.constructor or the class inside its constructor \ No newline at end of file diff --git a/tests/harness/corpus-costs/6873-error-capture-stack-trace.ts.json b/tests/harness/corpus-costs/6873-error-capture-stack-trace.ts.json new file mode 100644 index 000000000..21b254d87 --- /dev/null +++ b/tests/harness/corpus-costs/6873-error-capture-stack-trace.ts.json @@ -0,0 +1 @@ +{ "plain": 2446, "san": 2267 } diff --git a/tests/harness/corpus-costs/6874-error-capture-stack-trace-objects.cjs.json b/tests/harness/corpus-costs/6874-error-capture-stack-trace-objects.cjs.json new file mode 100644 index 000000000..a151f11d0 --- /dev/null +++ b/tests/harness/corpus-costs/6874-error-capture-stack-trace-objects.cjs.json @@ -0,0 +1 @@ +{ "plain": 2286, "san": 3360 } From 3121f6909db422e8429a4d2696cb6d6178385879 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sat, 24 Oct 2026 18:43:07 -0500 Subject: [PATCH 2/4] Evaluate a JavaScript constructorOpt, and keep a non-extensible error's stack - Error.captureStackTrace in JavaScript discarded a constructorOpt it does not recognize (a getter, a call), so its side effects and exceptions did not happen. It now evaluates after the target and before the capture, as V8 evaluates the call's arguments; the target is held in a local when evaluating it twice would not be safe. - Capturing on an error replaced its saved stack without the checks V8 makes when it defines `stack`. A frozen, sealed or non-extensible error now throws "Cannot define property stack, object is not extensible", and one whose `stack` is non-configurable throws "Cannot redefine property: stack"; both keep their stack. Corpus 6874 checks the evaluation order and a constructorOpt that throws. --- .../src/frontend/lowering/builtins/errors.ts | 29 +++++++++++++--- packages/runtime/src/scr_json.c | 21 ++++++++++-- ...6874-error-capture-stack-trace-objects.cjs | 33 ++++++++++++++++++- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index ce95d4a46..1eb2686fd 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -2,6 +2,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; +import { isSafeToDiscard, isSafeToRepeat } from "../expressions/evaluation-safety.js"; import { BOOL, CAUGHT, @@ -439,9 +440,9 @@ function enclosingConstructorClass(node: ts.Node): ts.ClassLikeDeclaration | und * `new.target` or the class's own name inside a class constructor leave the * frames from the code that ran `new`, which are the current frames * (constructors have no frames of their own). Other targets are refused, - * and so are other constructorOpt values in TypeScript; JavaScript keeps all - * frames for them. Null when the callee is not the - * standard library's Error.captureStackTrace. */ + * and so are other constructorOpt values in TypeScript; JavaScript evaluates + * them and keeps all frames. Null when the callee is not the standard + * library's Error.captureStackTrace. */ export function lowerErrorCaptureStackTraceCall( lowerer: Lowerer, call: ts.CallExpression, @@ -477,6 +478,11 @@ export function lowerErrorCaptureStackTraceCall( } let through = ""; let skip = false; + // A JavaScript constructorOpt that is not a recognized form still + // evaluates, after the target and before the capture, as V8 evaluates + // the call's arguments: its getters and calls run and its exceptions + // propagate. + let evaluated: IrExpr | null = null; const ctor = args[1]; if (ctor !== undefined) { let node: ts.Expression = ctor; @@ -506,6 +512,8 @@ export function lowerErrorCaptureStackTraceCall( // JavaScript (undici's `captureStackTrace(err, module.exports.fetch)`) // keeps every frame: the same frames V8 keeps, and the ones above them. skip = false; + const value = lowerer.lowerExpr(ctor); + if (!isSafeToDiscard(value)) evaluated = value; } else { lowerer.noLowering( "Error.captureStackTrace with this constructor argument", @@ -514,20 +522,31 @@ export function lowerErrorCaptureStackTraceCall( ); } } + const stmts: IrStmt[] = []; + let receiver = lowerer.coerceToExpected(target, DYN); + if (evaluated !== null) { + if (!isSafeToRepeat(receiver)) { + const local = lowerer.declareHiddenLocal("%captureTarget", DYN); + stmts.push({ kind: "varDecl", localId: local.id, init: receiver, loc }); + receiver = { kind: "varRef", localId: local.id, type: DYN, loc }; + } + stmts.push({ kind: "exprStmt", expr: evaluated, loc }); + } const capture: IrExpr = { kind: "libCall", fn: "error.captureStackTrace", args: [ - lowerer.coerceToExpected(target, DYN), + receiver, { kind: "strLit", value: through, type: STRING, loc }, { kind: "boolLit", value: skip, type: BOOL, loc }, ], type: VOID, loc, }; + stmts.push({ kind: "exprStmt", expr: capture, loc }); return { kind: "seqExpr", - stmts: [{ kind: "exprStmt", expr: capture, loc }], + stmts, result: dynUndefinedExpr(loc), type: DYN, loc, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 03e198cc8..ed3df36bc 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -16050,8 +16050,9 @@ static ScrDyn *scr_captured_stack_set_call(ScrClosure *closure, ScrDyn *const *a * whose text is `through` are left out (none remain when `through` is NULL or * not on the stack). An error keeps the frames as its own; another object * gets an own non-enumerable, configurable `stack` accessor. A primitive - * target throws `TypeError: invalid_argument` and a non-extensible object - * V8's define-property TypeError. */ + * target throws `TypeError: invalid_argument`. A non-extensible target (an + * error or another object), and an error whose `stack` is non-configurable, + * throw V8's define-property TypeErrors and keep their stack. */ void scr_error_capture_stack_trace(ScrDyn *target, const char *through, bool skip) { switch (target->kind) { case SCR_DYN_UNDEF: @@ -16071,6 +16072,22 @@ void scr_error_capture_stack_trace(ScrDyn *target, const char *through, bool ski ScrStr *frames = !skip ? scr_stack_capture() : through ? scr_stack_capture_after(through) : NULL; ScrError *error = scr_errdyn_err_of(target); if (error) { + /* V8 defines the error's own `stack`, so a frozen, sealed or + * non-extensible error throws, and so does one whose `stack` was made + * non-configurable; the saved stack stays. */ + bool extensible = scr_dyn_is_extensible(target); + const ScrDynEntry *own = target->kind == SCR_DYN_OBJ ? scr_dyn_find_entry(target, "stack", 5) : NULL; + if (scr_exc_pending() || !extensible || (own && !own->configurable)) { + static const char not_extensible[] = "Cannot define property stack, object is not extensible"; + static const char redefine[] = "Cannot redefine property: stack"; + if (!scr_exc_pending() && !extensible) + scr_throw_error_msg(SCR_ERR_TYPE, not_extensible, sizeof not_extensible - 1); + else if (!scr_exc_pending()) + scr_throw_error_msg(SCR_ERR_TYPE, redefine, sizeof redefine - 1); + scr_str_release(frames); + scr_error_release(error); + return; + } scr_str_release(error->stack_frames); error->stack_frames = frames; scr_str_release(error->stack); diff --git a/tests/corpus/6874-error-capture-stack-trace-objects.cjs b/tests/corpus/6874-error-capture-stack-trace-objects.cjs index 97d322659..94a47680c 100644 --- a/tests/corpus/6874-error-capture-stack-trace-objects.cjs +++ b/tests/corpus/6874-error-capture-stack-trace-objects.cjs @@ -2,7 +2,8 @@ // non-enumerable, configurable `stack` accessor giving the object's heading // (its name and message when first read, through Error.prototype.toString's // rules, "Error" by default) and the frames from the caller; TypeErrors for a -// primitive and a non-extensible target. +// primitive and a non-extensible target; and a constructorOpt getter or call +// that runs after the target and before the capture. 'use strict'; function frames(stack) { const lines = String(stack).split('\n'); @@ -90,3 +91,33 @@ try { } catch (error) { console.log('function value primitive', error.name, error.message); } +const order = []; +const holder = { + get ctor() { + order.push('getter'); + return inner; + }, +}; +function pick() { + order.push('call'); + return inner; +} +function note(label, value) { + order.push(label); + return value; +} +Error.captureStackTrace(note('target', {}), holder.ctor); +Error.captureStackTrace(note('target', {}), pick()); +console.log('constructorOpt evaluated', order.join(',')); +const throwing = { + get ctor() { + throw new RangeError('from constructorOpt'); + }, +}; +const untouched = {}; +try { + Error.captureStackTrace(untouched, throwing.ctor); + console.log('throwing constructorOpt', 'no error'); +} catch (caught) { + console.log('throwing constructorOpt', caught.name, caught.message, 'stack' in untouched); +} From 6c63d8cd10ca8e099bc93eb3665a34e54bf16055 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Mon, 26 Oct 2026 21:39:37 -0500 Subject: [PATCH 3/4] Run a constructor getter that this.constructor reads in Error.captureStackTrace Inside a class constructor, Error.captureStackTrace(this, this.constructor) now reads this.constructor before the capture when the class or a base class declares a constructor getter, as V8 evaluates the argument: the getter runs and its exception propagates. --- .../src/frontend/lowering/builtins/errors.ts | 16 ++++++++++ .../corpus/6873-error-capture-stack-trace.ts | 29 +++++++++++++++++++ ...6874-error-capture-stack-trace-objects.cjs | 29 +++++++++++++++++++ 3 files changed, 74 insertions(+) diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 1eb2686fd..7e2bb6d37 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -431,6 +431,16 @@ function enclosingConstructorClass(node: ts.Node): ts.ClassLikeDeclaration | und return undefined; } +/** Whether `access` (`this.constructor`) reads a `constructor` accessor + * declared on the class or a base class. */ +function readsConstructorAccessor(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean { + const property = lowerer.checker.getPropertyOfType( + lowerer.typeOf(access.expression), + "constructor", + ); + return property !== undefined && (property.flags & ts.SymbolFlags.GetAccessor) !== 0; +} + /** `Error.captureStackTrace(target, constructorOpt?)`, also through `?.`: * target.stack becomes the target's heading and the current stack frames, * as V8 does it (scr_error_capture_stack_trace). The target is an error, a @@ -504,6 +514,12 @@ export function lowerErrorCaptureStackTraceCall( const sig = resolved !== undefined ? lowerer.fnSigsBySymbol.get(resolved) : undefined; if (constructing && cls !== undefined) { skip = false; + // `this.constructor` runs a `constructor` getter of the class, whose + // calls and exceptions are observable. + if (ts.isPropertyAccessExpression(node) && readsConstructorAccessor(lowerer, node)) { + const value = lowerer.lowerExpr(ctor); + if (!isSafeToDiscard(value)) evaluated = value; + } } else if (sig !== undefined) { lowerer.noteEdge(sig.name); through = sig.name; diff --git a/tests/corpus/6873-error-capture-stack-trace.ts b/tests/corpus/6873-error-capture-stack-trace.ts index 62db5572d..922588870 100644 --- a/tests/corpus/6873-error-capture-stack-trace.ts +++ b/tests/corpus/6873-error-capture-stack-trace.ts @@ -85,3 +85,32 @@ console.log("limit", frames(outer(new Error("limited")).stack)); Error.stackTraceLimit = 0; console.log("limit 0", JSON.stringify(outer(new Error("none")).stack)); Error.stackTraceLimit = 10; + +// `this.constructor` in a constructor runs a `constructor` getter, and its +// exception propagates. +class Watched extends Error { + constructor() { + super("watched"); + Error.captureStackTrace(this, this.constructor); + } + get ["constructor"](): typeof Watched { + console.log("constructor getter ran"); + return Watched; + } +} +console.log("getter", typeof new Watched().stack); +class Throwing extends Error { + constructor() { + super("throwing"); + Error.captureStackTrace(this, this.constructor); + } + get ["constructor"](): typeof Throwing { + throw new RangeError("constructor getter threw"); + } +} +try { + new Throwing(); + console.log("getter did not throw"); +} catch (e: any) { + console.log("getter threw", e instanceof RangeError, e.message); +} diff --git a/tests/corpus/6874-error-capture-stack-trace-objects.cjs b/tests/corpus/6874-error-capture-stack-trace-objects.cjs index 94a47680c..456874b4e 100644 --- a/tests/corpus/6874-error-capture-stack-trace-objects.cjs +++ b/tests/corpus/6874-error-capture-stack-trace-objects.cjs @@ -121,3 +121,32 @@ try { } catch (caught) { console.log('throwing constructorOpt', caught.name, caught.message, 'stack' in untouched); } + +// `this.constructor` in a constructor runs a `constructor` getter, and its +// exception propagates. +class Watched extends Error { + constructor() { + super('watched'); + Error.captureStackTrace(this, this.constructor); + } + get ['constructor']() { + console.log('constructor getter ran'); + return Watched; + } +} +console.log('getter', typeof new Watched().stack); +class Throwing extends Error { + constructor() { + super('throwing'); + Error.captureStackTrace(this, this.constructor); + } + get ['constructor']() { + throw new RangeError('constructor getter threw'); + } +} +try { + new Throwing(); + console.log('getter did not throw'); +} catch (e) { + console.log('getter threw', e instanceof RangeError, e.message); +} From a897079be1b4282dd897cc4bb2fc817c6e010da7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 13:00:48 -0500 Subject: [PATCH 4/4] Capture onto the target evaluated before a constructorOpt that reassigns it When Error.captureStackTrace evaluates a JavaScript constructorOpt (a getter, a call, or this.constructor with a constructor getter), V8 has already evaluated the target. The lowering kept the target in a local only when its expression was not a plain read, so a constructorOpt that assigned the target variable made the capture install the stack on the new value. The target is now always kept in a local before the constructorOpt runs. --- .../src/frontend/lowering/builtins/errors.ts | 13 ++++++----- ...6874-error-capture-stack-trace-objects.cjs | 22 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/frontend/lowering/builtins/errors.ts b/packages/compiler/src/frontend/lowering/builtins/errors.ts index 7e2bb6d37..4cd72b2b2 100644 --- a/packages/compiler/src/frontend/lowering/builtins/errors.ts +++ b/packages/compiler/src/frontend/lowering/builtins/errors.ts @@ -2,7 +2,7 @@ import * as ts from "../../ts7/adapter.js"; import { type Lowerer } from "../lowerer.js"; import { isJsSourceFile, locOf } from "../../program.js"; import { dynUndefinedExpr } from "../../../ir/build.js"; -import { isSafeToDiscard, isSafeToRepeat } from "../expressions/evaluation-safety.js"; +import { isSafeToDiscard } from "../expressions/evaluation-safety.js"; import { BOOL, CAUGHT, @@ -541,11 +541,12 @@ export function lowerErrorCaptureStackTraceCall( const stmts: IrStmt[] = []; let receiver = lowerer.coerceToExpected(target, DYN); if (evaluated !== null) { - if (!isSafeToRepeat(receiver)) { - const local = lowerer.declareHiddenLocal("%captureTarget", DYN); - stmts.push({ kind: "varDecl", localId: local.id, init: receiver, loc }); - receiver = { kind: "varRef", localId: local.id, type: DYN, loc }; - } + // V8 captures onto the target value it evaluated first. The constructor + // argument may assign the variable or field the target was read from, so + // even a plain read is kept in a local before the argument runs. + const local = lowerer.declareHiddenLocal("%captureTarget", DYN); + stmts.push({ kind: "varDecl", localId: local.id, init: receiver, loc }); + receiver = { kind: "varRef", localId: local.id, type: DYN, loc }; stmts.push({ kind: "exprStmt", expr: evaluated, loc }); } const capture: IrExpr = { diff --git a/tests/corpus/6874-error-capture-stack-trace-objects.cjs b/tests/corpus/6874-error-capture-stack-trace-objects.cjs index 456874b4e..c575760c3 100644 --- a/tests/corpus/6874-error-capture-stack-trace-objects.cjs +++ b/tests/corpus/6874-error-capture-stack-trace-objects.cjs @@ -150,3 +150,25 @@ try { } catch (e) { console.log('getter threw', e instanceof RangeError, e.message); } + +// The stack goes on the target as evaluated before constructorOpt, even when +// constructorOpt assigns the variable or field the target was read from. +let target = { message: 'original' }; +const replacement = { message: 'replacement' }; +const swapping = { + get ctor() { + target = replacement; + return inner; + }, +}; +const original = target; +Error.captureStackTrace(target, swapping.ctor); +console.log('reassigned variable', 'stack' in original, 'stack' in replacement, target === replacement); +const box = { error: { message: 'boxed' } }; +const boxed = box.error; +function swapField() { + box.error = { message: 'swapped in' }; + return inner; +} +Error.captureStackTrace(box.error, swapField()); +console.log('reassigned field', 'stack' in boxed, 'stack' in box.error);