Keep error stacks through computed keys, clones and throwing getters

A read through a computed key now counts as a stack read when the program
contains the string "stack", so `e[key]` captures frames like `e.stack`
does. A key the program assembles from other strings or from data it
reads keeps the static answer.

structuredClone of an error held as `any` copies a stack that was already
formatted as a non-enumerable data property, as it did before the stack
became an accessor.

When a name or message getter throws while the stack is formatted, the
stack accessor propagates the exception instead of reading the missing
string.
This commit is contained in:
Chris Tate
2026-10-20 02:00:21 -05:00
parent 6bf934d4cd
commit 0c3a7ae73e
5 changed files with 101 additions and 9 deletions
@@ -67,6 +67,23 @@ test("a stack read on a checked-dynamic value is observed", () => {
expect(observesStacks(mod([fn("%main", [read("message")])]))).toBe(false);
});
test("a computed key reads the stack when the program spells it", () => {
const value: IrExpr = { loc, kind: "varRef", localId: "d", type: DYN };
const key: IrExpr = { loc, kind: "varRef", localId: "k", type: STRING };
const computed: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "dynKeyGet", value, key, type: DYN },
};
const spelled: IrStmt = {
loc,
kind: "exprStmt",
expr: { loc, kind: "strLit", value: "stack", type: STRING },
};
expect(observesStacks(mod([fn("%main", [computed, spelled])]))).toBe(true);
expect(observesStacks(mod([fn("%main", [computed])]))).toBe(false);
});
test("programs without stack reads are not", () => {
expect(observesStacks(mod([fn("%main", [call("helper")]), fn("helper", [ret])]))).toBe(false);
});
@@ -13,15 +13,34 @@ import { writesPublished } from "./publication.js";
/** Lib calls that read a captured stack trace. */
const STACK_READS: ReadonlySet<string> = new Set(["error.stack"]);
/** A typed `error.stack` read, or a `stack` read on a checked-dynamic
* value, which reaches an error's own `stack` accessor. */
function readsStack(expr: IrExpr): boolean {
/** A typed `error.stack` read, or a read on a checked-dynamic value that
* can reach an error's own `stack` accessor: the constant key `stack`, or
* a computed key when `computedKeys` (the program spells "stack", so a
* computed key can name it). A key the program assembles from other
* strings or from data it reads keeps the static answer. */
function readsStack(expr: IrExpr, computedKeys: boolean): boolean {
if (expr.kind === "libCall") return STACK_READS.has(expr.fn);
return expr.kind === "dynKeyGet" && expr.key.kind === "strLit" && expr.key.value === "stack";
if (expr.kind !== "dynKeyGet") return false;
if (expr.key.kind === "strLit") return expr.key.value === "stack";
return computedKeys;
}
function containsStackRead(fn: IrFunction): boolean {
return !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !readsStack(expr) });
/** Whether the module contains the string literal "stack". */
function spellsStack(mod: IrModule): boolean {
return mod.functions.some(
(fn) =>
!everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !(expr.kind === "strLit" && expr.value === "stack"),
}),
);
}
function containsStackRead(fn: IrFunction, computedKeys: boolean): boolean {
return !everyStmtList(fn.body, {
stmt: () => true,
expr: (expr) => !readsStack(expr, computedKeys),
});
}
/** Visit every expression of `body` that execution can reach: statements
@@ -46,7 +65,8 @@ function visitLiveExprs(body: IrStmt[], visit: (expr: IrExpr) => void): void {
* 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 computedKeys = spellsStack(mod);
if (!mod.functions.some((fn) => containsStackRead(fn, computedKeys))) 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)];
@@ -70,7 +90,7 @@ export function observesStacks(mod: IrModule): boolean {
const fn = byName.get(name);
if (fn === undefined) continue;
visitLiveExprs(fn.body, (expr) => {
if (readsStack(expr)) observed = true;
if (readsStack(expr, computedKeys)) 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);
+13 -1
View File
@@ -6754,6 +6754,8 @@ static ScrDyn *scr_error_stack_get_call(ScrClosure *closure, ScrDyn *const *args
if (!error) return scr_dyn_retain(scr_dyn_undefined());
ScrStr *stack = scr_error_stack(error);
scr_error_release(error);
/* A name or message getter that throws leaves its exception pending. */
if (!stack) return NULL;
ScrDyn *out = scr_dyn_new_str(stack);
scr_str_release(stack);
return out;
@@ -10954,7 +10956,17 @@ static ScrDyn *scr_sc_clone(const ScrDyn *v, const ScrScParent *up, ScrDynRegion
ScrDyn *out = scr_dyn_new_obj();
for (size_t i = 0; i < v->v.obj.len; i++) {
const ScrDynEntry *e = &v->v.obj.entries[i];
if (scr_dyn_entry_is_error_stack(e)) continue;
if (scr_dyn_entry_is_error_stack(e)) {
/* A stack that was already formatted copies as a non-enumerable
* data property; an unformatted one has nothing to copy. */
ScrError *error = scr_errdyn_err_of(v);
if (error && error->stack) {
scr_dyn_obj_set(out, "stack", 5, scr_dyn_new_str(error->stack));
out->v.obj.entries[out->v.obj.len - 1].enumerable = false;
}
scr_error_release(error);
continue;
}
ScrDyn *c = scr_sc_clone(e->value, &self, region);
if (c == NULL) {
scr_dyn_release(out);
@@ -124,3 +124,24 @@ function makeSubclass(): any {
return dyn(new PlainSubclass("deep"));
}
console.log("subclass frames", (makeSubclass().stack as string).includes("at makeSubclass"));
const formatted = dyn(new Error("formatted"));
console.log("formatted before clone", firstLine(formatted.stack));
console.log("clone keeps a formatted stack", firstLine(dyn(structuredClone(formatted)).stack));
class GrumpyError extends Error {
override get name(): string {
throw new Error("no name today");
}
}
try {
console.log("throwing name getter", firstLine(dyn(new GrumpyError("grumpy")).stack));
} catch (error) {
console.log("throwing name getter threw", (error as Error).message);
}
function readKey(target: any, key: string): unknown {
return target[key];
}
const computed = readKey(inner(), ["sta", "ck"].join("")) as string;
console.log("computed key", firstLine(computed), computed.includes("at inner"));
@@ -124,3 +124,25 @@ function makeSubclass() {
return dyn(new PlainSubclass("deep"));
}
console.log("subclass frames", makeSubclass().stack.includes("at makeSubclass"));
const formatted = dyn(new Error("formatted"));
console.log("formatted before clone", firstLine(formatted.stack));
console.log("clone keeps a formatted stack", firstLine(dyn(structuredClone(formatted)).stack));
class GrumpyError extends Error {
/** @returns {string} */
get name() {
throw new Error("no name today");
}
}
try {
console.log("throwing name getter", firstLine(dyn(new GrumpyError("grumpy")).stack));
} catch (error) {
console.log("throwing name getter threw", error.message);
}
function readKey(target, key) {
return target[key];
}
const computed = readKey(inner(), ["sta", "ck"].join(""));
console.log("computed key", firstLine(computed), computed.includes("at inner"));