fix(javascript): bound value evaluation through nested closure aliases (#1941)

* fix(javascript): bound value evaluation through nested closure aliases

A closure that aliases a binding from its enclosing scope evaluates that
binding once live and once at its own capture point. The capture cannot
order a binding declared in another function, so it fell back to a second
live evaluation. Only primitive results were kept, so every level of
nesting doubled the work: 20 nested closures over a non-constant value
took about 14 s, and a 443 KB generated wrapper reached the 300 s
analysis timeout.

Keep a live binding value when no caller could change it: no alias cycle
closes on or above the binding and the expression depth budget was not
reached. Bindings on a cycle are still re-evaluated, so their values and
reasons are unchanged.

Found while building a source-owned reproduction for #1847; that report's
worker heap exhaustion is a separate, already closed defect.

* fix(javascript): keep the expression depth budget for retained values

Reusing a retained composite value from any depth let a chain of wrappers
(s1 = {x: s0}, s2 = {x: s1}, ...) nest past the expression depth budget:
300 wrappers produced a 299-level value where the budget stops at 86, the
serialized IR grew quadratically, and 3,000 wrappers overflowed the stack
in JSON.stringify.

Record the expression depth at which each caller-independent value was
derived and reuse it only at that depth or shallower. A deeper reader
derives the value again and reaches the budget exactly as before. Closure
aliases read their outer binding at one depth on both paths, so nested
closures stay linear.
This commit is contained in:
Naema Mohmed Farah
2026-10-13 00:04:58 +03:00
committed by GitHub
parent 86fab11fbe
commit d59ab6d977
2 changed files with 211 additions and 42 deletions
@@ -2,42 +2,128 @@ import { expect, it, vi } from "vitest";
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
import { onlyBinding } from "./javascriptSemanticAnalysis.fixture.js";
import type { JavaScriptSemanticIr } from "./javascriptSemanticIr.js";
/** Count AST ancestry lookups made while analyzing one source. */
const ancestryReads = (
source: string,
inspect: (ir: JavaScriptSemanticIr) => void,
): number => {
const original = WeakMap.prototype.get;
let reads = 0;
const get = vi.spyOn(WeakMap.prototype, "get").mockImplementation(function (
this: WeakMap<object, unknown>,
key,
) {
const value: unknown = original.call(this, key);
if (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
)
reads++;
return value;
});
try {
inspect(analyzeJavaScriptSemantics(source));
return reads;
} finally {
get.mockRestore();
}
};
it("bounds ancestry reads for repeated deep lexical references", () => {
const ancestryReads = (depth: number): number => {
const original = WeakMap.prototype.get;
let reads = 0;
const get = vi.spyOn(WeakMap.prototype, "get").mockImplementation(function (
this: WeakMap<object, unknown>,
key,
) {
const value: unknown = original.call(this, key);
if (
typeof value === "object" &&
value !== null &&
"type" in value &&
typeof value.type === "string"
)
reads++;
return value;
});
try {
const ir = analyzeJavaScriptSemantics(
`const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`,
);
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
expect(onlyBinding(ir, "a").value).toEqual({
status: "literal",
value: 1,
});
return reads;
} finally {
get.mockRestore();
}
};
const reads = (depth: number): number =>
ancestryReads(
`const a=1; module.exports.value=${"a+(".repeat(depth)}a${")".repeat(depth)};`,
(ir) => {
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
expect(onlyBinding(ir, "a").value).toEqual({
status: "literal",
value: 1,
});
},
);
const smaller = ancestryReads(80);
const larger = ancestryReads(160);
const smaller = reads(80);
const larger = reads(160);
expect(smaller).toBeGreaterThan(0);
expect(larger).toBeLessThan(3 * smaller);
});
it.each([
[
"object",
(index: number) => `var s${String(index + 1)}={x:s${String(index)}};`,
],
[
"array",
(index: number) => `var s${String(index + 1)}=[s${String(index)}];`,
],
[
"sequence-assigned object",
(index: number) =>
`var s${String(index + 1)}; 0, s${String(index + 1)}={x:s${String(index)}};`,
],
])(
"keeps the expression depth budget across a chain of %s wrappers",
(_, wrap) => {
// Reusing each wrapper's value for the next would nest without limit.
const nesting = (length: number): number => {
const ir = analyzeJavaScriptSemantics(
`var s0={};${Array.from({ length: length - 1 }, (unused, index) => wrap(index)).join("")}`,
);
let value = onlyBinding(ir, `s${String(length - 1)}`).value;
let depth = 0;
while (value.status === "object" || value.status === "array") {
const [slot] =
value.status === "object" ? value.properties : value.items;
if (slot === undefined) return depth;
value = slot.value;
depth++;
}
expect(value).toMatchObject({ resourceLimit: "expression-depth" });
expect(ir.coverage.status).toBe("partial");
return depth;
};
expect(nesting(40)).toBe(39);
const bounded = nesting(300);
expect(bounded).toBeLessThan(256);
expect(nesting(600)).toBe(bounded);
},
);
it.each([
["an object", "{ value: 1 }", "object"],
["a self-referential namespace", "s0 || {}", "ambiguous"],
["an unknown parameter", "seed", "unknown"],
])(
"bounds nested closures that alias %s from the enclosing scope",
(_, root, status) => {
// Each closure reads its parent's local once live and once at its own
// capture point; re-deriving the parent each time doubles work per level.
const reads = (depth: number): number =>
ancestryReads(
`function f0(seed){ var s0=${root};${Array.from(
{ length: depth - 1 },
(unused, index) =>
` function f${String(index + 1)}(){ var s${String(index + 1)}=s${String(index)};`,
).join("")}${"}".repeat(depth)}`,
(ir) => {
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
expect(onlyBinding(ir, `s${String(depth - 1)}`).value).toEqual(
onlyBinding(ir, "s1").value,
);
expect(onlyBinding(ir, "s1").value.status).toBe(status);
},
);
const smaller = reads(8);
const larger = reads(16);
expect(smaller).toBeGreaterThan(0);
// Doubling per level would multiply the reads by 256.
expect(larger).toBeLessThan(8 * smaller);
},
);