diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 38836f40fd..85b1d379ad 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -145,6 +145,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( total: number, ) => Promise, signal?: AbortSignal, + onPhase?: (file: JavaScriptArtifactFile, phase: string) => Promise, ): Promise<{ readonly analysis: JavaScriptModuleArtifactAnalysis; readonly interruption: AnalysisError | null; @@ -189,6 +190,7 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( file, projection.remainingFileCapacity(), signal, + onPhase === undefined ? undefined : (phase) => onPhase(file, phase), ); const facts = result.ok ? result.value : result.error; const applicationProjectionFailure = facts.applicationProjectionFailure; diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 4f900965ec..ad2076c950 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -206,6 +206,16 @@ export const reconstructJavaScriptArtifact = async ( abortIfNeeded(signal); }, signal, + async (file, phase) => { + abortIfNeeded(signal); + await progress?.report({ + phase: `javascript_worker_${phase}`, + completed: 0, + total: 1, + message: `JavaScript worker ${phase}: ${file.path}`, + }); + abortIfNeeded(signal); + }, ); } finally { try { diff --git a/src/domain/javascript/javascriptSourceAnalysis.ts b/src/domain/javascript/javascriptSourceAnalysis.ts index 6ee47c9b09..77da666486 100644 --- a/src/domain/javascript/javascriptSourceAnalysis.ts +++ b/src/domain/javascript/javascriptSourceAnalysis.ts @@ -36,6 +36,7 @@ export interface JavaScriptSourceAnalysisPort { file: JavaScriptArtifactFile, capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, + onPhase?: (phase: string) => Promise, ): Promise>; close(): Promise; } diff --git a/src/javascript/analysis/JavaScriptAnalysisWorker.ts b/src/javascript/analysis/JavaScriptAnalysisWorker.ts index 9f749ecb35..6b11b1edde 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorker.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorker.ts @@ -257,6 +257,7 @@ export class JavaScriptAnalysisWorker { file: JavaScriptArtifactFile, capacity: JavaScriptSemanticProjectionCapacity, signal?: AbortSignal, + onPhase?: (phase: string) => Promise, ): Promise< Result > { @@ -343,6 +344,7 @@ export class JavaScriptAnalysisWorker { ); if (response.kind === "phase") { this.#phase = response.phase; + await onPhase?.(response.phase); continue; } if (response.kind === "static") { @@ -362,6 +364,9 @@ export class JavaScriptAnalysisWorker { parent_result_budget_bytes: this.#resultBudgetBytes, parent_result_budget_exhausted: true, }, + { + remediationAction: JAVASCRIPT_PARENT_RESULT_BUDGET_REMEDIATION, + }, ); offeredResultBudget = this.#applicationProjectionFailure !== undefined && diff --git a/tests/boundary/cli/javascriptApplicationProgress.test.ts b/tests/boundary/cli/javascriptApplicationProgress.test.ts index 9c79a309bf..f991baa23c 100644 --- a/tests/boundary/cli/javascriptApplicationProgress.test.ts +++ b/tests/boundary/cli/javascriptApplicationProgress.test.ts @@ -43,6 +43,17 @@ for (const command of ["analyze-javascript-application", "analyze"]) { expect( updates.some(({ phase }) => phase === "parse_javascript_sources"), ).toBe(true); + expect( + updates + .filter(({ phase }) => phase.startsWith("javascript_worker_")) + .map(({ phase }) => phase), + ).toEqual([ + "javascript_worker_parse", + "javascript_worker_static", + "javascript_worker_semantic", + "javascript_worker_projection", + "javascript_worker_transfer", + ]); expect(updates.at(-1)).toMatchObject({ completed: 1, terminal: true }); expect( updates.slice(0, -1).every(({ terminal }) => terminal !== true), diff --git a/tests/boundary/javascript/javascriptParentResultAdmission.test.ts b/tests/boundary/javascript/javascriptParentResultAdmission.test.ts index 76835777e7..490e829824 100644 --- a/tests/boundary/javascript/javascriptParentResultAdmission.test.ts +++ b/tests/boundary/javascript/javascriptParentResultAdmission.test.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; import { afterAll, expect, it, vi } from "vitest"; -const heapObservation = vi.hoisted(() => ({ reclaimedBytes: 0 })); +const heapObservation = vi.hoisted(() => ({ + reclaimedBytes: 0, + remainingBytes: 320 * 1024 * 1024, +})); // Give the parent a stable 64 MiB result envelope after its transfer reserve. // The child analyzer remains a real separately owned Node process with its @@ -18,7 +21,7 @@ vi.mock("node:v8", async (importOriginal) => { return { ...snapshot, used_heap_size: used, - heap_size_limit: used + 320 * 1024 * 1024, + heap_size_limit: used + heapObservation.remainingBytes, }; }, }; @@ -26,6 +29,7 @@ vi.mock("node:v8", async (importOriginal) => { import { JavaScriptAnalysisWorker } from "../../../src/javascript/analysis/JavaScriptAnalysisWorker.js"; import { AnalysisResourceConstraintError } from "../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; afterAll(() => { @@ -34,6 +38,7 @@ afterAll(() => { }); async function verifyParentAdmission(reclaimedBytes: number) { + heapObservation.remainingBytes = 320 * 1024 * 1024; heapObservation.reclaimedBytes = reclaimedBytes; const methodsCount = 600; const text = Array.from( @@ -110,3 +115,51 @@ it.each([0, 1024 * 1024])( "retains module facts and declines only the graph with %i bytes reclaimed between heap observations", verifyParentAdmission, ); + +it("keeps parent-budget recovery when capacity is exhausted after a static checkpoint", async () => { + heapObservation.remainingBytes = 320 * 1024 * 1024; + const text = "export const observed = 7;"; + const file: JavaScriptArtifactFile = { + path: "checkpoint.mjs", + sha256: createHash("sha256").update(text).digest("hex"), + container_sha256: "1".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: "parent-checkpoint-fixture", + kind: "javascript", + unpacked: false, + text: { included: true, value: text }, + }; + const worker = new JavaScriptAnalysisWorker({ + heapMb: 128, + timeoutMs: 30_000, + }); + try { + const result = await worker.analyze( + file, + { nodes: 100, unknowns: 100 }, + undefined, + async (phase) => { + // A smaller live parent envelope after the request was admitted still + // permits decoding this tiny checkpoint, but leaves no result capacity. + if (phase === "static") + heapObservation.remainingBytes = 128 * 1024 * 1024; + }, + ); + if (result.ok) + throw new Error("Expected a parent checkpoint capacity failure"); + expect(result.error.javascript?.parse_status).toBe("complete"); + expect(result.error.error).toMatchObject({ + _tag: "AnalysisResourceConstraintError", + reportedLimits: { parent_result_budget_exhausted: true }, + }); + expect( + projectAnalysisError(result.error.error).remediation.action, + ).toContain("This limit belongs to the parent result budget"); + expect( + projectAnalysisError(result.error.error).remediation.action, + ).toContain("Increasing max_heap_mb does not raise it"); + } finally { + heapObservation.remainingBytes = 320 * 1024 * 1024; + await worker.close(); + } +}); diff --git a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts index 70c92ca397..062a9b70fa 100644 --- a/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts +++ b/tests/boundary/javascript/javascriptSourceAnalysisWorker.test.ts @@ -55,8 +55,23 @@ it("reuses one real owned analyzer while retaining distinct source identities an "export function next() { return { observed: 8 }; }", ); for (const [index, file] of [first, second].entries()) { - const result = await worker.analyze(file, { nodes: 100, unknowns: 100 }); + const phases: string[] = []; + const result = await worker.analyze( + file, + { nodes: 100, unknowns: 100 }, + undefined, + async (phase) => { + phases.push(phase); + }, + ); if (!result.ok) throw result.error.error; + expect(phases).toEqual([ + "parse", + "static", + "semantic", + "projection", + "transfer", + ]); expect(result.value.javascript.parse_status).toBe("complete"); expect( result.value.module?.callables[0]?.returnSites[0]?.value,