mirror of
https://github.com/morluto/rea
synced 2026-10-25 14:01:13 -04:00
fix(javascript): close member-path cycles through self stores (#7131)
A write below a slot that stores a value read from the same slot, such as `s.c = s.c.p; s.c.i = 1` on a parameter, revisits the binding with one more segment after the stored key on every turn. The cycle closure only recognized segments added at the front of the path (`node = node[key]`), so the work-list never terminated and exhausted the owned worker heap (CodeMirror 5 `mode/stylus/stylus.js`). Recognize a contiguous segment inserted after a stable base, check that repeating it after that base can never be pruned, and escape through it once, as the self-alias rule already does. Front insertions keep their existing order and result. Fixes #7130 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -998,25 +998,37 @@ export function* collectSemanticMemberMutationsSteps(
|
||||
// An active normalized prefix owns its closure before nested traversals.
|
||||
if (!covering.retain(path, captureContexts)) continue;
|
||||
// A self alias such as `node = node[key]` revisits its capture with the
|
||||
// same prefix added on every cycle, so exact paths never repeat. When
|
||||
// that prefix can never be pruned, escaping it once covers every longer
|
||||
// effect, including writes below shared children.
|
||||
// Only a capture already on this alias chain can repeat a prefix.
|
||||
// same prefix added on every cycle, so exact paths never repeat. A self
|
||||
// store such as `s.c = s.c.p` adds its segment after the stored key
|
||||
// instead. When that segment can never be pruned, escaping through it
|
||||
// once covers every longer effect, including writes below shared
|
||||
// children. Only a capture already on this alias chain can repeat.
|
||||
const revisited = current.bindings.has(captureKey);
|
||||
let repeatedPrefix: PropertyPath | undefined;
|
||||
for (
|
||||
let start = 1;
|
||||
revisited && start <= current.path.length;
|
||||
start++
|
||||
) {
|
||||
const suffix = current.path.slice(start);
|
||||
if (!current.bindings.has(lifetimeKey(captureKey, suffix))) continue;
|
||||
repeatedPrefix = current.path.slice(0, start);
|
||||
break;
|
||||
}
|
||||
let repeatedBase = 0;
|
||||
repeat: for (
|
||||
let base = 0;
|
||||
revisited && base < current.path.length;
|
||||
base++
|
||||
)
|
||||
for (let end = base + 1; end <= current.path.length; end++) {
|
||||
const shorter =
|
||||
base === 0
|
||||
? current.path.slice(end)
|
||||
: [...current.path.slice(0, base), ...current.path.slice(end)];
|
||||
if (!current.bindings.has(lifetimeKey(captureKey, shorter)))
|
||||
continue;
|
||||
repeatedPrefix = current.path.slice(0, end);
|
||||
repeatedBase = base;
|
||||
break repeat;
|
||||
}
|
||||
if (
|
||||
repeatedPrefix !== undefined &&
|
||||
observablePaths.repeatsUnpruned(binding.bindingId, repeatedPrefix)
|
||||
observablePaths.repeatsUnpruned(
|
||||
binding.bindingId,
|
||||
repeatedPrefix.slice(0, repeatedBase),
|
||||
repeatedPrefix.slice(repeatedBase),
|
||||
)
|
||||
) {
|
||||
markValue(
|
||||
current.node,
|
||||
@@ -1974,7 +1986,8 @@ const observableMutationPaths = (
|
||||
) => PropertyPath | null;
|
||||
readonly repeatsUnpruned: (
|
||||
bindingId: string,
|
||||
prefix: PropertyPath,
|
||||
base: PropertyPath,
|
||||
segment: PropertyPath,
|
||||
) => boolean;
|
||||
} => {
|
||||
const ambientPaths = createAmbientPathResolver(state, false);
|
||||
@@ -2265,22 +2278,28 @@ const observableMutationPaths = (
|
||||
return path;
|
||||
};
|
||||
// A projection's origins depend only on its source origins and key. Once
|
||||
// repeating a prefix revisits an origin set, every longer repetition stays
|
||||
// observable and normalization can never prune it. The answer depends only
|
||||
// on the binding and prefix, so every revisit of a long chain shares it.
|
||||
// repeating a segment after its base revisits an origin set, every longer
|
||||
// repetition stays observable and normalization can never prune it. The
|
||||
// answer depends only on the binding, base and segment, so every revisit of
|
||||
// a long chain shares it.
|
||||
const repeated = new Map<string, boolean>();
|
||||
const repeatsUnpruned = (
|
||||
bindingId: string,
|
||||
prefix: PropertyPath,
|
||||
base: PropertyPath,
|
||||
segment: PropertyPath,
|
||||
): boolean => {
|
||||
const key = JSON.stringify([bindingId, prefix]);
|
||||
const key = JSON.stringify([bindingId, base, segment]);
|
||||
const cached = repeated.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
let observer = bindingNodes.get(bindingId);
|
||||
if (observer !== undefined && base.length > 0) {
|
||||
observer = project(observer, base);
|
||||
settle();
|
||||
}
|
||||
const signatures = new Set<string>();
|
||||
let result = false;
|
||||
while (observer !== undefined && prefix.length > 0) {
|
||||
observer = project(observer, prefix);
|
||||
while (observer !== undefined && segment.length > 0) {
|
||||
observer = project(observer, segment);
|
||||
settle();
|
||||
if ((observer.origins?.size ?? 0) === 0) break;
|
||||
const signature = JSON.stringify(
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import * as t from "@babel/types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
import {
|
||||
analyzeJavaScriptSemantics,
|
||||
analyzeParsedJavaScriptSemantics,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { onlyCallable } from "./javascriptSemanticAnalysis.fixture.js";
|
||||
import type { JavaScriptSemanticIr } from "./javascriptSemanticIr.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
|
||||
const returnedValue = (body: string, expression: string) =>
|
||||
onlyCallable(
|
||||
@@ -109,3 +116,88 @@ describe("member-path cycles and captured aliases", () => {
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("member-path cycles through self stores", () => {
|
||||
/**
|
||||
* Analyze a source while counting member-object reads. An unclosed cycle
|
||||
* grows its path on every revisit; failing at a fixed count keeps that
|
||||
* regression fast instead of exhausting the test worker's heap.
|
||||
*/
|
||||
const analyzeWithBoundedMemberReads = (
|
||||
source: string,
|
||||
limit: number,
|
||||
): { readonly ir: JavaScriptSemanticIr; readonly reads: number } => {
|
||||
const file = parseJavaScriptSource(source);
|
||||
if (file === null) throw new Error("fixture must parse");
|
||||
let reads = 0;
|
||||
traverseJavaScriptAst(file, {
|
||||
enter: (node) => {
|
||||
if (!t.isMemberExpression(node)) return;
|
||||
const object = node.object;
|
||||
Object.defineProperty(node, "object", {
|
||||
enumerable: true,
|
||||
configurable: true,
|
||||
get: () => {
|
||||
if (++reads > limit)
|
||||
throw new Error(`member-object reads exceeded ${String(limit)}`);
|
||||
return object;
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
reads = 0;
|
||||
const ir = analyzeParsedJavaScriptSemantics(file);
|
||||
return { ir, reads };
|
||||
};
|
||||
|
||||
it.each([
|
||||
["one key", "s.c = s.c.p; s.c.i = 1;"],
|
||||
["a nested key", "s.a.b = s.a.b.next; s.a.b.x = 1;"],
|
||||
["a computed key", "s[k] = s[k].p; s[k].i = 1;"],
|
||||
["a two-step segment", "s.c = s.c.p.q; s.c.i = 1;"],
|
||||
[
|
||||
"two alternating slots",
|
||||
"s.c = s.d.p; s.d = s.c.q; s.c.i = 1; s.d.j = 2;",
|
||||
],
|
||||
[
|
||||
"a CodeMirror-style context stack",
|
||||
`s.context = new Context(s.context);
|
||||
s.context = s.context.prev;
|
||||
s.context.indent = 0;
|
||||
s.context.line.firstWord = "x";`,
|
||||
],
|
||||
])(
|
||||
"closes a parameter slot that stores a value read from itself through %s",
|
||||
(_shape, body) => {
|
||||
// Each call can see the previous call's store, so the written receiver
|
||||
// may be any number of segments below the slot's original value.
|
||||
const { ir, reads } = analyzeWithBoundedMemberReads(
|
||||
`function Context(prev) { this.prev = prev; this.line = {}; }
|
||||
function update(s, k) { ${body} }
|
||||
module.exports = update;`,
|
||||
5_000,
|
||||
);
|
||||
expect(ir.coverage).toEqual({ status: "complete", omittedCount: 0 });
|
||||
expect(reads).toBeGreaterThan(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps unrelated slots exact when a local stack pops in a loop", () => {
|
||||
const body = `
|
||||
const seed = {
|
||||
context: { prev: { prev: { value: 1 }, value: 1 }, value: 1 },
|
||||
keep: 3,
|
||||
};
|
||||
const state = seed;
|
||||
while (state.flag) state.context = state.context.prev;
|
||||
state.context.value = 2;
|
||||
`;
|
||||
expect(returnedValue(body, "seed.context.prev.prev.value")?.status).toBe(
|
||||
"unknown",
|
||||
);
|
||||
expect(returnedValue(body, "seed.keep")).toEqual({
|
||||
status: "literal",
|
||||
value: 3,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
// CodeMirror 5 modes keep a parser context stack on a state parameter. Popping
|
||||
// it stores a value read from the same slot, so each call can select one more
|
||||
// `prev` below the slot's original value. An unclosed member-path cycle there
|
||||
// exhausted the owned worker heap on `mode/stylus/stylus.js`.
|
||||
const contextStack = `
|
||||
function Context(type, prev) {
|
||||
this.type = type;
|
||||
this.prev = prev;
|
||||
this.line = { firstWord: "", indent: 0 };
|
||||
}
|
||||
function pushContext(state, type) {
|
||||
state.context = new Context(type, state.context);
|
||||
return type;
|
||||
}
|
||||
function popContext(state, currentIndent) {
|
||||
var contextIndent = state.context.indent - 2;
|
||||
state.context = state.context.prev;
|
||||
if (currentIndent) state.context.indent = contextIndent;
|
||||
return state.context.type;
|
||||
}
|
||||
function tokenBase(stream, state) {
|
||||
state.context.line.firstWord = stream.current();
|
||||
state.context.line.indent = stream.indentation();
|
||||
}
|
||||
module.exports = { pushContext, popContext, tokenBase };
|
||||
`;
|
||||
|
||||
cliTest(
|
||||
"analyzes a parser context stack that pops through its own slot",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-js-self-store-");
|
||||
await writeFile(join(root, "mode.js"), contextStack);
|
||||
const output = await cli.run({
|
||||
arguments: [
|
||||
"analyze-javascript-application",
|
||||
root,
|
||||
"--max-heap-mb",
|
||||
"256",
|
||||
"--json",
|
||||
],
|
||||
});
|
||||
expect(output.exitCode).toBe(0);
|
||||
const result = javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(output.json).normalized_result,
|
||||
);
|
||||
expect(result.statistics).toMatchObject({
|
||||
parsed_javascript_files: 1,
|
||||
skipped_javascript_files: 0,
|
||||
parse_failures: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user