From a897079be1b4282dd897cc4bb2fc817c6e010da7 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 13:00:48 -0500 Subject: [PATCH] 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);