From d07ab79f9b6e2d0c10fecd9f24e6e7b4ebb985c9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 21:34:13 +0800 Subject: [PATCH] fix(javascript): refuse member-mutation work beyond a fixed allowance (#11144) * fix(javascript): refuse member-mutation work beyond a fixed allowance Member-mutation indexing retained every scheduled alias state until its query finished, with no bound on the phase. Conditional self-selections before one member write (#8294) multiplied those states until the worker heap was exhausted. Charge each scheduled alias state to a per-phase work meter. Past 524,288 states the phase is refused with a resource_constraint that names the fixed work allowance. The application and web bundle workers carry the reported limit to the parent, which rebuilds the refusal with that allowance instead of heap advice. Refs #8294 #6127 #6344 #7170 #7510 #8298 * test(javascript): prove the member-mutation meter spans the phase --- .../javascript-artifact-reconstruction.md | 8 +++ src/browser/WebBundleAnalysisWorker.test.ts | 55 ++++++++++++++--- src/browser/WebBundleAnalysisWorker.ts | 49 ++++++++++----- .../WebBundleAnalysisWorkerProtocol.ts | 3 + .../WebBundleAnalysisWorkerServer.test.ts | 41 ++++++++++++- src/browser/WebBundleAnalysisWorkerServer.ts | 14 ++++- .../javascriptSemanticMemberMutations.ts | 14 ++++- ...ascriptSemanticWorkAllowance.phase.test.ts | 51 ++++++++++++++++ .../javascriptSemanticWorkAllowance.test.ts | 53 ++++++++++++++++ .../javascriptSemanticWorkAllowance.ts | 60 +++++++++++++++++++ .../analysis/JavaScriptAnalysisWorker.ts | 7 ++- .../javascriptSourceAnalysisWorker.test.ts | 50 +++++++++++++++- 12 files changed, 371 insertions(+), 34 deletions(-) create mode 100644 src/domain/javascript/javascriptSemanticWorkAllowance.phase.test.ts create mode 100644 src/domain/javascript/javascriptSemanticWorkAllowance.test.ts create mode 100644 src/domain/javascript/javascriptSemanticWorkAllowance.ts diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index c6f8d96c51..eb2c8d9d02 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -831,6 +831,14 @@ caller can change the resource actually exhausted. Finite inline literal object spreads use iterative syntax traversal and no longer inherit a duplicate semantic-depth cut. +Member-mutation analysis also has a fixed work allowance of 524,288 alias +states per source. It is not a `semantic_precision` field. A source whose alias +histories exceed it is refused with a `resource_constraint` that names the +fixed work allowance, instead of exhausting the worker heap. Static facts the +worker had not yet reported are not kept, because a partial mutation index +would let later reads keep stale values. Raising `max_heap_mb`, +`analysis_timeout_ms` or `semantic_precision` does not admit the same source. + Byte, entry, path, and graph-shape bounds are hard limits. The parse deadline is checked before and between bounded parsing and traversal phases; the synchronous Babel and JSON parser calls cannot be preempted mid-call, so their input byte diff --git a/src/browser/WebBundleAnalysisWorker.test.ts b/src/browser/WebBundleAnalysisWorker.test.ts index 2a6626eb36..4b2f61a2d1 100644 --- a/src/browser/WebBundleAnalysisWorker.test.ts +++ b/src/browser/WebBundleAnalysisWorker.test.ts @@ -37,6 +37,10 @@ import { WebBundleWorkerCleanup, } from "./WebBundleAnalysisWorker.js"; import { WEB_BUNDLE_WORKER_LIMITS } from "./WebBundleAnalysisWorkerProtocol.js"; +import { + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, +} from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { releaseOwnedCleanup } from "../../tests/support/cleanupRelease.js"; const makeScript = (text: string): IncludedScript => ({ @@ -112,6 +116,7 @@ type TestMode = | "malformed" | "wrong-job-failure" | "resource-failure" + | "work-refusal" | "final-frame-held-pipe" | "zero-exit-held-pipe" | "heap-exit"; @@ -130,6 +135,7 @@ const protocolWorkerProgram = ( | "malformed" | "wrong-job-failure" | "resource-failure" + | "work-refusal" | "final-frame-held-pipe" | "zero-exit-held-pipe" | "heap-exit", @@ -167,19 +173,22 @@ const protocolWorkerProgram = ( : failure === "resource-failure" ? `send({ kind: "failure", id: request.id, reason: "resource", message: "bounded result capacity" }); setInterval(() => {}, 1_000);` - : failure === "final-frame-held-pipe" - ? `const frame = JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "final frame from descendant" }); + : failure === "work-refusal" + ? `send({ kind: "failure", id: request.id, reason: "resource", limits: ${JSON.stringify({ [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE })}, message: "work allowance" }); + setInterval(() => {}, 1_000);` + : failure === "final-frame-held-pipe" + ? `const frame = JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "final frame from descendant" }); const program = "setTimeout(() => { process.stdout.write(" + JSON.stringify(frame + "\\n") + "); setInterval(() => {}, 1000); }, 100);"; spawn(process.execPath, ["-e", program], { stdio: ["ignore", "inherit", "inherit"] }); process.exit(7);` - : failure === "late-failure" - ? `process.stdout.write(JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "failure frame before abort" }) + "\\n"); + : failure === "late-failure" + ? `process.stdout.write(JSON.stringify({ kind: "failure", id: request.id, reason: "output", message: "failure frame before abort" }) + "\\n"); setInterval(() => {}, 1_000);` - : failure === "wrong-id" - ? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });` - : failure === "oversized-control" - ? `process.stdout.write("x".repeat(70 * 1024) + "\\n");` - : `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });` + : failure === "wrong-id" + ? `send({ kind: "phase", id: ${JSON.stringify(randomUUID())}, phase: "analyze" });` + : failure === "oversized-control" + ? `process.stdout.write("x".repeat(70 * 1024) + "\\n");` + : `send({ kind: "complete", id: request.id, result: { bytes: request.result_budget_bytes + 1, sha256: "${"0".repeat(64)}" } });` } }); `; @@ -825,6 +834,34 @@ it("does not let a failure frame for another job replace cancellation", async () } }); +it("names the fixed work allowance a worker refusal reports", async () => { + const worker = new WebBundleAnalysisWorker( + 128, + launcherFor({ kind: "protocol", failure: "work-refusal" }), + ); + try { + const error = failureOf( + await worker.analyze(makeScript("fetch('/work');"), 5_000), + ); + if (!(error instanceof AnalysisResourceConstraintError)) + throw new Error("Expected a worker resource constraint failure"); + expect(error).toMatchObject({ + resource: "cpu", + retryable: false, + fixedAllowance: { + kind: "work", + limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + }, + }); + expect(error.reportedLimits).toMatchObject({ + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + }); + } finally { + await worker.close(); + } +}); + it("keeps the resource budget on an observed worker resource frame", async () => { const reason = { opaque: "resource frame before abort" }; const controller = new AbortController(); diff --git a/src/browser/WebBundleAnalysisWorker.ts b/src/browser/WebBundleAnalysisWorker.ts index fc2b8c3a2d..4d211bbc1b 100644 --- a/src/browser/WebBundleAnalysisWorker.ts +++ b/src/browser/WebBundleAnalysisWorker.ts @@ -1,4 +1,5 @@ import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; +import { javaScriptSemanticWorkAllowance } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; import { retainProcessInspectionCleanup, processInspectionCancellationError, @@ -32,7 +33,10 @@ import { readStableArtifact, } from "../artifacts/readStableArtifact.js"; import type { StableArtifactSnapshot } from "../artifacts/readStableArtifact.js"; -import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + AnalysisError, + type AnalysisErrorOptions, +} from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, AnalysisTimeoutError, @@ -84,6 +88,29 @@ import { } from "./WebBundleAnalysisWorkerProtocol.js"; const OPERATION = "analyze_web_bundle"; + +/** + * A worker resource refusal. A reported fixed work allowance is deterministic + * for the same script; any other refusal is measured memory exhaustion. + */ +const webBundleResourceFailure = ( + frame: Extract, + resultBudgetBytes: number, + capturedOutput?: AnalysisErrorOptions["capturedOutput"], +): AnalysisResourceConstraintError => { + const limits = { ...frame.limits, result_budget_bytes: resultBudgetBytes }; + const fixed = javaScriptSemanticWorkAllowance(frame.limits); + return new AnalysisResourceConstraintError( + OPERATION, + fixed === undefined ? "memory" : "cpu", + frame.message, + limits, + { + ...(capturedOutput === undefined ? {} : { capturedOutput }), + ...(fixed ?? { measuredExhaustion: true }), + }, + ); +}; const PROVIDER = "web-bundle-static-analysis"; const scriptTimeout = ( @@ -384,13 +411,7 @@ export class WebBundleAnalysisWorker { } while (response.kind === "phase"); if (response.kind === "failure") { if (response.reason === "resource") - throw new AnalysisResourceConstraintError( - OPERATION, - "memory", - response.message, - { result_budget_bytes: resultBudget }, - { measuredExhaustion: true }, - ); + throw webBundleResourceFailure(response, resultBudget); throw new AnalysisOutputError( OPERATION, `Web script analysis failed (${response.reason}): ${response.message}`, @@ -816,14 +837,10 @@ export class WebBundleAnalysisWorker { ); case "frame": return failure.frame.reason === "resource" - ? new AnalysisResourceConstraintError( - OPERATION, - "memory", - failure.frame.message, - { - result_budget_bytes: failure.resultBudgetBytes, - }, - { capturedOutput, measuredExhaustion: true }, + ? webBundleResourceFailure( + failure.frame, + failure.resultBudgetBytes, + capturedOutput, ) : new AnalysisOutputError( OPERATION, diff --git a/src/browser/WebBundleAnalysisWorkerProtocol.ts b/src/browser/WebBundleAnalysisWorkerProtocol.ts index 6a098e8759..b4765e55e6 100644 --- a/src/browser/WebBundleAnalysisWorkerProtocol.ts +++ b/src/browser/WebBundleAnalysisWorkerProtocol.ts @@ -2,6 +2,7 @@ import { javaScriptSemanticPrecisionSchema } from "../domain/javascript/javascri import { z } from "zod"; import { JAVASCRIPT_STATIC_FACT_BYTES } from "../domain/javascript/javascriptAnalysisResourceControls.js"; import { digestSchema } from "../domain/digests.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; import { WEB_RUNTIME_LIMITS } from "../domain/webRuntime.js"; import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js"; import { webTextArtifactSchema } from "../domain/webContentArtifact.js"; @@ -74,6 +75,8 @@ export const webBundleWorkerMessageSchema = z.discriminatedUnion("kind", [ kind: z.literal("failure"), ...identity, reason: z.enum(["input", "resource", "output", "io"]), + /** Limits a typed resource refusal reported, such as a fixed work allowance. */ + limits: z.record(z.string(), jsonValueSchema).exactOptional(), message: z.string(), }), ]); diff --git a/src/browser/WebBundleAnalysisWorkerServer.test.ts b/src/browser/WebBundleAnalysisWorkerServer.test.ts index 4d2463dfa5..00f747e08e 100644 --- a/src/browser/WebBundleAnalysisWorkerServer.test.ts +++ b/src/browser/WebBundleAnalysisWorkerServer.test.ts @@ -15,6 +15,7 @@ import { type WebBundleWorkerMessage, WEB_BUNDLE_WORKER_LIMITS, } from "./WebBundleAnalysisWorkerProtocol.js"; +import { JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT } from "../domain/javascript/javascriptSemanticWorkAllowance.js"; const makeScript = (text: string): IncludedScript => ({ script_key: `scr_${createHash("sha256").update(text).digest("hex")}`, @@ -60,6 +61,7 @@ const writeSource = async ( const startServer = async ( root: string, + analyzer = "analyzeCapturedWebScript", ): Promise<{ readonly process: ChildProcessWithoutNullStreams; readonly next: () => Promise; @@ -71,10 +73,15 @@ const startServer = async ( ).href; const analyzerUrl = new URL("../domain/webBundleAnalyzer.ts", import.meta.url) .href; + const workUrl = new URL( + "../domain/javascript/javascriptSemanticWorkAllowance.ts", + import.meta.url, + ).href; const program = ` const { serveWebBundleAnalysisWorker } = await import(${JSON.stringify(serverUrl)}); const { analyzeCapturedWebScript } = await import(${JSON.stringify(analyzerUrl)}); - await serveWebBundleAnalysisWorker(analyzeCapturedWebScript); + const { JavaScriptSemanticWorkMeter } = await import(${JSON.stringify(workUrl)}); + await serveWebBundleAnalysisWorker(${analyzer}); `; const child = spawn( process.execPath, @@ -138,6 +145,38 @@ const closeServer = async ( } }; +it("reports a fixed work refusal as a resource failure with its limit", async () => { + const root = await mkdtemp(join(tmpdir(), "rea-web-worker-work-")); + let worker: ChildProcessWithoutNullStreams | undefined; + onTestFinished(async () => { + if (worker !== undefined) await closeServer(worker); + await rm(root, { recursive: true, force: true }); + }); + const server = await startServer( + root, + "() => new JavaScriptSemanticWorkMeter(0).charge()", + ); + worker = server.process; + expect(await server.next()).toMatchObject({ kind: "ready" }); + const input = sourceDescriptor(makeScript("fetch('/work');")); + const id = randomUUID(); + await writeSource(root, id, input.bytes); + await send(worker, { + kind: "analyze", + id, + source: input.descriptor, + result_budget_bytes: WEB_BUNDLE_WORKER_LIMITS.resultBytes, + }); + expect(await server.next()).toMatchObject({ kind: "phase", id }); + expect(await server.next()).toEqual({ + kind: "failure", + id, + reason: "resource", + limits: { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: 0 }, + message: expect.stringMatching(/more than 0 alias states/u), + }); +}); + const framingServer = async () => { const root = await mkdtemp(join(tmpdir(), "rea-web-worker-framing-")); let worker: ChildProcessWithoutNullStreams | undefined; diff --git a/src/browser/WebBundleAnalysisWorkerServer.ts b/src/browser/WebBundleAnalysisWorkerServer.ts index cc6ce5c895..fc04811377 100644 --- a/src/browser/WebBundleAnalysisWorkerServer.ts +++ b/src/browser/WebBundleAnalysisWorkerServer.ts @@ -26,6 +26,7 @@ import { type WebBundleWorkerMessage, } from "./WebBundleAnalysisWorkerProtocol.js"; import { decodeStrictUtf8 } from "../domain/strictUtf8.js"; +import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; export interface WebBundleScriptAnalyzer { ( @@ -136,6 +137,7 @@ const analyzeJob = async ( analyze(script, request.semantic_precision), ); } catch (cause) { + if (cause instanceof AnalysisResourceConstraintError) throw cause; throw new WebBundleWorkerOutputError( `Web bundle analyzer returned an invalid result: ${cause instanceof Error ? cause.message : String(cause)}`, ); @@ -200,13 +202,18 @@ const analyzeJob = async ( }); return true; } catch (cause: unknown) { - const resource = cause instanceof RangeError; + const refusal = + cause instanceof AnalysisResourceConstraintError ? cause : undefined; + const resource = cause instanceof RangeError || refusal !== undefined; const input = cause instanceof WebBundleWorkerInputError; const fatalCleanupFailure = cause instanceof ArtifactReaderFailure && cause.cleanupLeases !== undefined && cause.cleanupLeases.length > 0; - const rawMessage = cause instanceof Error ? cause.message : String(cause); + // The parent owns resource-error formatting; send the canonical reason. + const rawMessage = + refusal?.reason ?? + (cause instanceof Error ? cause.message : String(cause)); await send({ kind: "failure", id: request.id, @@ -218,6 +225,9 @@ const analyzeJob = async ( cause instanceof TypeError ? "output" : "io", + ...(refusal?.reportedLimits == null + ? {} + : { limits: { ...refusal.reportedLimits } }), message: rawMessage.length > 2048 ? `${rawMessage.slice(0, 2048)}…` : rawMessage, }); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index d12576008f..702c575664 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -6,6 +6,7 @@ import { retainSemanticAmbientEffect, } from "./javascriptSemanticAmbientWrites.js"; import { isIntrinsicMapLookup } from "./javascriptSemanticMapOperations.js"; +import { JavaScriptSemanticWorkMeter } from "./javascriptSemanticWorkAllowance.js"; import { isIntrinsicTypedArrayConstruction, isTypedArrayNumericIndex, @@ -515,6 +516,9 @@ export function* collectSemanticMemberMutationsSteps( const deferredMapReceivers = new Set< t.CallExpression | t.OptionalCallExpression >(); + // Alias states are retained until their query finishes; bound the phase + // before a pathological alias history exhausts the worker heap. + const work = new JavaScriptSemanticWorkMeter(); // The binding pass already retained callable nodes. Ordinary data-only code // must not pay an alias walk for every member read or assignment. const accessorKeys = { @@ -923,6 +927,10 @@ export function* collectSemanticMemberMutationsSteps( }; }; const pending: PendingValue[] = []; + const schedule = (value: PendingValue): void => { + work.charge(); + pending.push(value); + }; // The effect, mutation and assigned sources belong to this invocation. // Alias histories do not select a value: the binding/capture/projection do. // Keep small alternatives exact. Bound additional states across this whole @@ -949,7 +957,7 @@ export function* collectSemanticMemberMutationsSteps( ? resolveBinding(expression, expression.name) : undefined; if (binding === undefined) { - pending.push(value); + schedule(value); return; } const observablePath = observablePaths.normalize( @@ -1014,12 +1022,12 @@ export function* collectSemanticMemberMutationsSteps( : { path, definiteReceiver, scheduled: false }; joinedProjections.set(key, joined); if (!projectionsWidened) { - pending.push({ ...value, exactKey: key }); + schedule({ ...value, exactKey: key }); return; } if (joined.scheduled) return; joined.scheduled = true; - pending.push({ + schedule({ ...value, path, definiteReceiver, diff --git a/src/domain/javascript/javascriptSemanticWorkAllowance.phase.test.ts b/src/domain/javascript/javascriptSemanticWorkAllowance.phase.test.ts new file mode 100644 index 0000000000..3b95ea05e7 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticWorkAllowance.phase.test.ts @@ -0,0 +1,51 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { JavaScriptSemanticWorkMeter } from "./javascriptSemanticWorkAllowance.js"; + +afterEach(() => { + vi.restoreAllMocks(); +}); + +/** One member write after a few self-selections: one alias query. */ +const query = (name: string): string => + [ + `function ${name}(input, env) { var m; if (env.first) m = env.first.info;`, + ...Array.from( + { length: 3 }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; return m; }", + ].join(" "); + +const source = (count: number): string => + `${Array.from({ length: count }, (_, index) => query(`probe${String(index)}`)).join("\n")}\nmodule.exports = probe0;`; + +/** The meters charged while analyzing each source, in order. */ +const chargedMeters = (sources: readonly string[]) => { + const meters: JavaScriptSemanticWorkMeter[] = []; + const charge = JavaScriptSemanticWorkMeter.prototype.charge; + vi.spyOn(JavaScriptSemanticWorkMeter.prototype, "charge").mockImplementation( + function (this: JavaScriptSemanticWorkMeter, units?: number) { + if (!meters.includes(this)) meters.push(this); + charge.call(this, units); + }, + ); + return sources.map((text) => { + const before = meters.length; + analyzeJavaScriptSemantics(text); + return meters.slice(before); + }); +}; + +it("charges separate queries in one source against one meter", () => { + const [meters] = chargedMeters([source(3)]); + expect(meters).toHaveLength(1); +}); + +it("gives each source a fresh meter", () => { + const [first, second] = chargedMeters([source(1), source(1)]); + expect(first).toHaveLength(1); + expect(second).toHaveLength(1); + expect(second?.[0]).not.toBe(first?.[0]); +}); diff --git a/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts b/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts new file mode 100644 index 0000000000..dae0583a06 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticWorkAllowance.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, +} from "./javascriptSemanticWorkAllowance.js"; + +/** + * Conditional self-selections before one member write (#8294). Each selection + * multiplies the alias histories the write must follow: 8 selections exhausted + * a 1 GiB heap before the phase had a work allowance. + */ +const selections = (count: number): string => + [ + "function probe(input, env) { var m; if (env.first) m = env.first.info;", + ...Array.from( + { length: count }, + (_, index) => + `if (/case${String(index)}/.test(input)) m = m[${String(index % 2)}];`, + ), + "m[1] = input.length; return m; } module.exports = probe;", + ].join(" "); + +it("refuses member-mutation work beyond the fixed allowance", () => { + let refusal: unknown; + try { + analyzeJavaScriptSemantics(selections(8)); + } catch (cause) { + refusal = cause; + } + if (!(refusal instanceof AnalysisResourceConstraintError)) + throw new Error("Expected a work allowance refusal", { cause: refusal }); + expect(refusal).toMatchObject({ + resource: "cpu", + retryable: false, + reportedLimits: { + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + }, + fixedAllowance: { + kind: "work", + limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + unit: "alias states", + }, + }); +}); + +it("completes the same shape within the allowance", () => { + expect(analyzeJavaScriptSemantics(selections(4)).coverage.status).toBe( + "complete", + ); +}); diff --git a/src/domain/javascript/javascriptSemanticWorkAllowance.ts b/src/domain/javascript/javascriptSemanticWorkAllowance.ts new file mode 100644 index 0000000000..a5bd7a09f5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticWorkAllowance.ts @@ -0,0 +1,60 @@ +import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { + fixedAllowanceOptions, + type FixedAllowance, +} from "../fixedAllowanceRemediation.js"; + +/** + * Alias states one source's member-mutation phase may schedule. Each state is + * retained until its query finishes, at roughly 1–2 KiB, so the allowance stays + * well below the default 1 GiB worker heap. Published bundles up to 395 KB + * schedule at most about 123,000 states. + */ +export const JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE = 524_288; + +/** Reported-limit key that names this allowance across worker boundaries. */ +export const JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT = + "javascript_member_mutation_alias_state_limit"; + +const ALLOWANCE_NAME = "The JavaScript member-mutation work allowance"; + +/** Fixed allowance facts for a refusal rebuilt from reported limits. */ +export const javaScriptSemanticWorkAllowance = ( + limits: Readonly> | null | undefined, +): + | { readonly retryable: false; readonly fixedAllowance: FixedAllowance } + | undefined => { + const limit = limits?.[JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]; + return typeof limit === "number" + ? fixedAllowanceOptions(ALLOWANCE_NAME, "work", { + limit, + unit: "alias states", + }) + : undefined; +}; + +/** + * Work meter for one analysis phase. Charging past the fixed allowance refuses + * the phase before its retained state can exhaust the worker heap; a partial + * mutation index would let later reads keep stale values. + */ +export class JavaScriptSemanticWorkMeter { + #used = 0; + + constructor( + readonly allowance: number = JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + ) {} + + charge(units = 1): void { + this.#used += units; + if (this.#used <= this.allowance) return; + const limits = { [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: this.allowance }; + throw new AnalysisResourceConstraintError( + "analyze_javascript_semantics", + "cpu", + `JavaScript member-mutation analysis scheduled more than ${String(this.allowance)} alias states for one source`, + limits, + javaScriptSemanticWorkAllowance(limits), + ); + } +} diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 56409a4ff6..a5e7a89746 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -1,3 +1,4 @@ +import { javaScriptSemanticWorkAllowance } from "../../domain/javascript/javascriptSemanticWorkAllowance.js"; import { MAX_NODE_TIMER_DELAY_MS } from "../../domain/analysisResourceLimits.js"; import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; @@ -1323,12 +1324,16 @@ export class JavaScriptAnalysisWorker { (parentBudget ? JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION : undefined); + // A fixed work allowance refuses the same source again; worker limits + // and other remediation do not raise it. + const fixed = javaScriptSemanticWorkAllowance(reported); return new AnalysisResourceConstraintError( OPERATION, response.resource ?? "memory", response.message, limits, - remediationAction === undefined ? undefined : { remediationAction }, + fixed ?? + (remediationAction === undefined ? undefined : { remediationAction }), ); } return new AnalysisOutputError( diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 120efbeb61..a0a47517e2 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -14,6 +14,11 @@ import { ProcessOwnershipEvidenceOwner } from "../../../src/process/ProcessOwner import type { OwnedProcessGroup } from "../../../src/process/ProcessOwnership.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import { + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT, +} from "../../../src/domain/javascript/javascriptSemanticWorkAllowance.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js"; import { javaScriptWorkerMemoryLimitBytes } from "../../../src/process/JavaScriptWorkerMemoryLimit.js"; @@ -701,7 +706,13 @@ it("formats a real worker's declined semantic budget once", async () => { }); // The handshake mirrors the real server: only Node.js reports an enforced V8 heap limit. -const resourceFailureWorker = (heapLimit: string) => +const resourceFailureWorker = ( + heapLimit: string, + failure: { readonly resource: string; readonly limits: unknown } = { + resource: "memory", + limits: null, + }, +) => new JavaScriptAnalysisWorker({ heapMb: 128, timeoutMs: 30_000 }, (request) => spawnOwnedProviderProcess({ ...request, @@ -711,7 +722,7 @@ const resourceFailureWorker = (heapLimit: string) => process.stdout.write(JSON.stringify({ kind: "ready", heap_limit_bytes: heap }) + "\\n"); require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => { const { id } = JSON.parse(line); - process.stdout.write(JSON.stringify({ kind: "failure", id, reason: "resource", resource: "memory", limits: null, message: "fixture" }) + "\\n"); + process.stdout.write(JSON.stringify({ kind: "failure", id, reason: "resource", ...${JSON.stringify(failure)}, message: "fixture" }) + "\\n"); });`, ], }), @@ -749,6 +760,41 @@ it("reports the worker heap limit only when the runtime enforces V8 heap flags", } }); +it("names the fixed work allowance a worker refusal reports", async () => { + const worker = resourceFailureWorker( + 'process.versions.bun === undefined ? require("node:v8").getHeapStatistics().heap_size_limit : null', + { + resource: "cpu", + limits: { + [JAVASCRIPT_MEMBER_MUTATION_WORK_LIMIT]: + JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + }, + }, + ); + try { + const result = await worker.analyze( + source("app.mjs", "export const observed = 7;"), + { nodes: 100, unknowns: 100 }, + ); + if (result.ok) throw new Error("expected a reported resource failure"); + const error = result.error.error; + if (!(error instanceof AnalysisResourceConstraintError)) + throw new Error("expected a resource constraint"); + expect(error).toMatchObject({ + resource: "cpu", + retryable: false, + remediationAction: undefined, + fixedAllowance: { + kind: "work", + limit: JAVASCRIPT_MEMBER_MUTATION_WORK_ALLOWANCE, + unit: "alias states", + }, + }); + } finally { + await worker.close(); + } +}); + it("rejects a worker heap report that contradicts the launching runtime", async () => { const worker = resourceFailureWorker( "process.versions.bun === undefined ? null : 1",