mirror of
https://github.com/morluto/rea
synced 2026-10-26 22:28:27 -04:00
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:
committed by
GitHub
parent
86fab11fbe
commit
d59ab6d977
@@ -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);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user