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.
This commit is contained in:
Chris Tate
2026-10-27 13:00:48 -05:00
parent 6c63d8cd10
commit a897079be1
2 changed files with 29 additions and 6 deletions
@@ -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 = {
@@ -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);