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",