diff --git a/src/application/javascript/JavaScriptAnalysisControl.test.ts b/src/application/javascript/JavaScriptAnalysisControl.test.ts index a15b474e50..a16892ef9a 100644 --- a/src/application/javascript/JavaScriptAnalysisControl.test.ts +++ b/src/application/javascript/JavaScriptAnalysisControl.test.ts @@ -5,6 +5,7 @@ import { completeJavaScriptAnalysisSteps } from "./JavaScriptAnalysisControl.js" describe("JavaScript cooperative computation lifecycle", () => { it("observes scheduler cancellation and closes abandoned work", async () => { const controller = new AbortController(); + const reason = { request: "caller cancelled computation" }; let released = false; function* work(): Generator { try { @@ -13,17 +14,18 @@ describe("JavaScript cooperative computation lifecycle", () => { released = true; } } - const cancellation = setImmediate().then(() => controller.abort()); + const cancellation = setImmediate().then(() => controller.abort(reason)); await expect( completeJavaScriptAnalysisSteps(work(), controller.signal), - ).rejects.toMatchObject({ reason: "cancelled" }); + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); await cancellation; expect(released).toBe(true); }); it("does not start pre-cancelled work and preserves completed values", async () => { const controller = new AbortController(); - controller.abort(); + const reason = { request: "caller cancelled computation" }; + controller.abort(reason); let started = false; function* work(): Generator { started = true; @@ -32,7 +34,7 @@ describe("JavaScript cooperative computation lifecycle", () => { } await expect( completeJavaScriptAnalysisSteps(work(), controller.signal), - ).rejects.toMatchObject({ reason: "cancelled" }); + ).rejects.toMatchObject({ reason: "cancelled", cause: reason }); expect(started).toBe(false); expect(await completeJavaScriptAnalysisSteps(work())).toBe(42); }); diff --git a/src/application/javascript/JavaScriptAnalysisControl.ts b/src/application/javascript/JavaScriptAnalysisControl.ts index d6dbaa0b60..64746c2261 100644 --- a/src/application/javascript/JavaScriptAnalysisControl.ts +++ b/src/application/javascript/JavaScriptAnalysisControl.ts @@ -7,6 +7,7 @@ export const assertJavaScriptAnalysisActive = (signal?: AbortSignal): void => { throw new ArtifactReaderFailure( "cancelled", "JavaScript application analysis cancelled", + { cause: signal.reason }, ); }; diff --git a/src/application/javascript/JavaScriptApplicationService.ts b/src/application/javascript/JavaScriptApplicationService.ts index 1ba489e52b..5feac1130e 100644 --- a/src/application/javascript/JavaScriptApplicationService.ts +++ b/src/application/javascript/JavaScriptApplicationService.ts @@ -5,6 +5,7 @@ import { import { z } from "zod"; import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { translateArtifactFailure } from "../../artifacts/ArtifactOperationFailure.js"; import type { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js"; import { analyzeJavaScriptApplicationInputSchema, @@ -144,7 +145,7 @@ export const analyzeJavaScriptApplicationValidated = async ( result, ); return err( - artifactFailureToOperationError(cause).retainPartialObservation( + translateArtifactFailure(OPERATION, cause).retainPartialObservation( evidence, ), ); @@ -157,7 +158,7 @@ export const analyzeJavaScriptApplicationValidated = async ( // Preserve execution constraints and their completed partial Evidence. if (cause instanceof AnalysisError) return err(cause); if (cause instanceof ArtifactReaderFailure) - return err(artifactFailureToOperationError(cause)); + return err(translateArtifactFailure(OPERATION, cause)); if (cause instanceof z.ZodError) return err( new AnalysisOutputError(OPERATION, describeResultSchemaFailure(cause), { @@ -189,24 +190,6 @@ export const analyzeJavaScriptApplicationValidated = async ( } }; -const artifactFailureToOperationError = ( - cause: ArtifactReaderFailure, -): ArtifactOperationError => - new ArtifactOperationError( - OPERATION, - cause.reason, - cause.details, - cause.message, - { - cause, - ...(cause.cleanup === undefined ? {} : { cleanup: cause.cleanup }), - ...(cause.partialObservation === undefined - ? {} - : { partialObservation: cause.partialObservation }), - ...(cause.admission === undefined ? {} : { admission: cause.admission }), - }, - ); - const describeResultSchemaFailure = (cause: z.ZodError): string => { const issue = cause.issues[0]; if (issue === undefined) return "Result schema rejected an unknown issue"; diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 919f7b044f..903c90b9d4 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -43,6 +43,8 @@ import { htmlArtifactReferences } from "../../domain/javascript/htmlArtifactRefe import type { JavaScriptSourceAnalysisPort } from "../../domain/javascript/javascriptSourceAnalysis.js"; import type { JavaScriptSemanticGraphProjection } from "./JavaScriptSemanticGraphBuilder.js"; import { AnalysisError } from "../../domain/analysisErrorBase.js"; +import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js"; +import { translateArtifactFailure } from "../../artifacts/ArtifactOperationFailure.js"; import { projectAnalysisError } from "../../domain/analysisErrorProjection.js"; import { AnalysisCancelledError, @@ -153,13 +155,16 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( if (signal?.aborted === true) { interruption = new AnalysisCancelledError( "analyze_javascript_application", + { cause: signal.reason }, ); break; } continue; } if (signal?.aborted === true) - throw new AnalysisCancelledError("analyze_javascript_application"); + throw new AnalysisCancelledError("analyze_javascript_application", { + cause: signal.reason, + }); await beforeFile(file, selectedFileIndex++, selectedFileCount); analysisStarted = true; if (file.kind !== "javascript" || !file.text.included) { @@ -245,39 +250,44 @@ export const analyzeAndAdoptJavaScriptArtifactFiles = async ( break; } } catch (cause: unknown) { - if (signal?.aborted !== true && !(cause instanceof AnalysisError)) - throw cause; - interruption = - signal?.aborted === true - ? new AnalysisCancelledError("analyze_javascript_application") - : cause instanceof AnalysisError - ? cause - : null; - if (interruption !== null) { - const scope = javascriptArtifactAnalysisScopes[file.kind]; - state.files.push({ - file, - javascript: null, - semantic: null, - ...(scope === null - ? {} - : { - analysis_failure: { - scope, - status: analysisStarted ? "interrupted" : "not-started", - reason: `${file.path}: ${interruption.message}`, - error: projectAnalysisError(interruption), - limits: null, - }, - }), - }); - projection.recordUnavailableFile( - file, - interruption.message, - null, - "incomplete-module", + if (cause instanceof AnalysisError) interruption = cause; + else if ( + cause instanceof ArtifactReaderFailure && + cause.reason === "cancelled" + ) + interruption = translateArtifactFailure( + "analyze_javascript_application", + cause, ); - } + else if (signal?.aborted === true && cause === signal.reason) + interruption = new AnalysisCancelledError( + "analyze_javascript_application", + { cause }, + ); + else throw cause; + const scope = javascriptArtifactAnalysisScopes[file.kind]; + state.files.push({ + file, + javascript: null, + semantic: null, + ...(scope === null + ? {} + : { + analysis_failure: { + scope, + status: analysisStarted ? "interrupted" : "not-started", + reason: `${file.path}: ${interruption.message}`, + error: projectAnalysisError(interruption), + limits: null, + }, + }), + }); + projection.recordUnavailableFile( + file, + interruption.message, + null, + "incomplete-module", + ); break; } } diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 4b47eebdd4..4f900965ec 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -227,7 +227,13 @@ export const reconstructJavaScriptArtifact = async ( const checkpointCompletedAnalysis = async (): Promise => { await setImmediate(); if (interruption == null && signal?.aborted === true) - interruption = new ArtifactOperationError(OPERATION, "cancelled"); + interruption = new ArtifactOperationError( + OPERATION, + "cancelled", + undefined, + undefined, + { cause: signal.reason }, + ); }; const reportCompletedPhase = async ( phase: string, diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts index af7b0777ca..97fc62b313 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationService.test.ts @@ -30,6 +30,7 @@ describe("JavaScript source selection cancellation boundary", () => { const snapshot = await scanCanonicalArtifactInventory(root, {}); const reader = createJavaScriptArtifactReader(root, "directory"); const controller = new AbortController(); + const reason = { request: "caller cancelled selected sources" }; const sourcePaths = ["main.mjs"]; try { const files = await readJavaScriptArtifactFiles( @@ -52,7 +53,7 @@ describe("JavaScript source selection cancellation boundary", () => { { analyze: async (...args) => { const result = await realWorker(...args); - controller.abort(); + controller.abort(reason); return result; }, }, @@ -64,6 +65,7 @@ describe("JavaScript source selection cancellation boundary", () => { await worker.close(); } expect(outcome.interruption).toBeInstanceOf(AnalysisCancelledError); + expect(outcome.interruption?.cause).toBe(reason); const unselected = outcome.analysis.files.filter( ({ file }) => !file.text.included && file.text.reason === "outside-analysis-scope", @@ -151,6 +153,7 @@ describe("Selected JavaScript source failure diagnostics", () => { await writeJavaScriptSourceSelectionFixture(root); await writeFile(join(root, "z-later.mjs"), "export const later = 1;\n"); const controller = new AbortController(); + const reason = { request: "caller cancelled selected sources" }; const source_paths = ["main.mjs", "z-later.mjs"]; const result = await analyzeJavaScriptApplication( { input_path: root, source_paths }, @@ -162,12 +165,13 @@ describe("Selected JavaScript source failure diagnostics", () => { event.phase === "parse_javascript_source" && event.message.includes("z-later.mjs") ) - controller.abort(); + controller.abort(reason); }, }, }, ); if (result.ok) throw new Error("Expected selected-source cancellation"); + expect(result.error.cause).toBe(reason); expect(projectAnalysisError(result.error).code).toBe("cancelled"); const evidence = parseEvidence(result.error.partialObservation); const partial = javascriptApplicationAnalysisResultSchema.parse( diff --git a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts index 2283006b1b..f488a4d551 100644 --- a/tests/boundary/javascript/javascriptApplicationCancellation.test.ts +++ b/tests/boundary/javascript/javascriptApplicationCancellation.test.ts @@ -1,5 +1,5 @@ import { setImmediate } from "node:timers"; -import { mkdir, realpath, symlink, writeFile } from "node:fs/promises"; +import { mkdir, open, realpath, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; @@ -11,6 +11,7 @@ import { analyzeJavaScriptApplication as analyzeOwnedApplication } from "../../. import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { AnalysisOutputError } from "../../../src/domain/analysisErrorCore.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; it.each(["during cleanup", "on the next control turn"] as const)( @@ -31,12 +32,13 @@ it.each(["during cleanup", "on the next control turn"] as const)( 'import { readFile } from "node:fs/promises"; export { readFile };\n', ); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; class CancellingCleanupScope extends ArtifactResourceScope { override async release(owner: ArtifactResourceOwner) { const result = await super.release(owner); if (owner.kind === "javascript-source-analysis") { - if (timing === "during cleanup") controller.abort(); - else setImmediate(() => controller.abort()); + if (timing === "during cleanup") controller.abort(reason); + else setImmediate(() => controller.abort(reason)); } return result; } @@ -49,6 +51,7 @@ it.each(["during cleanup", "on the next control turn"] as const)( { signal: controller.signal }, ); if (result.ok) throw new Error("Cancelled handoff must not succeed"); + expect(result.error.cause).toBe(reason); const projected = projectAnalysisError(result.error); expect(projected.code).toBe("cancelled"); expect(projected).toMatchObject({ @@ -111,6 +114,7 @@ it("preserves imported manifest status when cancelled before visiting manifests" ); await writeFile(join(root, "b.cjs"), "module.exports = 1;"); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; const visited: string[] = []; const result = await analyzeJavaScriptApplication( { input_path: root, format: "directory" }, @@ -120,12 +124,13 @@ it("preserves imported manifest status when cancelled before visiting manifests" async report(event) { if (event.phase !== "parse_javascript_source") return; visited.push(event.message); - if (event.message.includes("b.cjs")) controller.abort(); + if (event.message.includes("b.cjs")) controller.abort(reason); }, }, }, ); if (result.ok) throw new Error("Cancelled analysis must return a failure"); + expect(result.error.cause).toBe(reason); const failure = projectAnalysisError(result.error); expect(failure.code).toBe("cancelled"); expect(visited).toHaveLength(2); @@ -171,6 +176,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod ).map(([path, content]) => writeFile(join(root, path), content)), ); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; let reachedNativeMetadata = false; const result = await analyzeJavaScriptApplication( { input_path: root, format: "directory" }, @@ -183,7 +189,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod event.message.includes("b.node") ) { reachedNativeMetadata = true; - controller.abort(); + controller.abort(reason); } }, }, @@ -191,6 +197,7 @@ it("keeps cancellation before native metadata out of AST scopes and semantic mod ); expect(reachedNativeMetadata).toBe(true); if (result.ok) throw new Error("Cancelled analysis must return a failure"); + expect(result.error.cause).toBe(reason); const partial = z .object({ code: z.literal("cancelled"), @@ -261,6 +268,7 @@ it("interrupts semantic graph commitment before result validation", async () => ).join("\n"), ); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; const phases: string[] = []; const result = await analyzeJavaScriptApplication( { input_path: root, format: "directory" }, @@ -270,7 +278,7 @@ it("interrupts semantic graph commitment before result validation", async () => async report(event) { phases.push(event.phase); if (event.phase === "seal_javascript_semantic_graph") - setImmediate(() => controller.abort()); + setImmediate(() => controller.abort(reason)); }, }, }, @@ -298,13 +306,14 @@ it("honors control turns and cancellation while preparing one large source for o ).join("\n"), ); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; const phases: string[] = []; let turns = 0; // Count event-loop turns once the file starts; parsing, analysis and // projection of this one file must leave room for them. const tick = (): void => { turns += 1; - if (turns === 3) controller.abort(); + if (turns === 3) controller.abort(reason); else if (!controller.signal.aborted) setImmediate(tick); }; const result = await analyzeJavaScriptApplication( @@ -345,6 +354,7 @@ it.each([ "export function inspect(value) { return JSON.stringify(value); }\n", ); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; let requested = false; const terminal: string[] = []; const result = await analyzeJavaScriptApplication( @@ -356,7 +366,7 @@ it.each([ if (event.terminal) terminal.push(event.phase); if (event.phase === phase) { requested = true; - setImmediate(() => controller.abort()); + setImmediate(() => controller.abort(reason)); } }, }, @@ -364,6 +374,7 @@ it.each([ ); expect(requested).toBe(true); if (result.ok) throw new Error("Cancelled analysis must return a failure"); + expect(result.error.cause).toBe(reason); const failure = projectAnalysisError(result.error); expect(failure).toMatchObject({ code: "cancelled" }); expect(failure).toMatchObject({ @@ -390,18 +401,20 @@ it("honors cancellation observed during the terminal publication race", async () const root = await createTestTempDirectory("rea-js-cancel-terminal-"); await writeFile(join(root, "main.js"), "export const value = 1;\n"); const controller = new AbortController(); + const reason = { request: "caller cancelled JavaScript analysis" }; const result = await analyzeJavaScriptApplication( { input_path: root, format: "directory" }, { signal: controller.signal, progress: { async report(event) { - if (event.terminal) controller.abort(); + if (event.terminal) controller.abort(reason); }, }, }, ); if (result.ok) throw new Error("Expected cancellation before admission"); + expect(result.error.cause).toBe(reason); const failure = projectAnalysisError(result.error); expect(failure).toMatchObject({ details: { reason: "cancelled" }, @@ -416,3 +429,90 @@ it("honors cancellation observed during the terminal publication race", async () }, }); }); + +it("keeps the exact retained descriptor owner through application failure translation and retry", async () => { + const root = await createTestTempDirectory("rea-js-retained-cleanup-"); + const paths = [join(root, "first.js"), join(root, "second.js")]; + await Promise.all( + paths.map((path) => writeFile(path, "export const value = 1;")), + ); + const files = await Promise.all(paths.map((path) => open(path, "r"))); + const scope = new ArtifactResourceScope(); + let allowClose = false; + const attempts = [0, 0]; + files.forEach((file, index) => + scope.retain({ + kind: "lease", + resource: paths[index]!, + lease: { + async release() { + attempts[index]! += 1; + if (index === 0 && !allowClose) + throw new Error("injected descriptor close denial"); + await file.close(); + }, + }, + }), + ); + onTestFinished(async () => { + allowClose = true; + await scope.close(); + }); + const result = await analyzeOwnedApplication( + { input_path: root, format: "directory" }, + scope, + ); + if (result.ok) throw new Error("Expected retained cleanup to block analysis"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "cleanup_incomplete", + retryable: false, + }); + expect(attempts).toEqual([1, 1]); + allowClose = true; + await scope.close(); + expect(result.error.cleanupIncomplete).toBe(false); + expect(attempts).toEqual([2, 1]); +}); + +it.each(["native-error", "analysis-error", "abort-reason"] as const)( + "classifies the producer progress failure under late cancellation (%s)", + async (kind) => { + const root = await createTestTempDirectory("rea-js-progress-primary-"); + await writeFile(join(root, "main.js"), "export const observed = 1;"); + const controller = new AbortController(); + const reason = { request: "unrelated cancellation" }; + const failure = + kind === "abort-reason" + ? reason + : kind === "analysis-error" + ? new AnalysisOutputError( + "analyze_javascript_application", + "progress callback failed", + ) + : new Error("progress callback failed"); + const result = await analyzeJavaScriptApplication( + { input_path: root, format: "directory" }, + { + signal: controller.signal, + progress: { + async report(event) { + if (event.phase === "parse_javascript_source") { + controller.abort(reason); + throw failure; + } + }, + }, + }, + ); + if (result.ok) throw new Error("Expected progress failure"); + if (kind === "abort-reason") { + expect(projectAnalysisError(result.error).code).toBe("cancelled"); + expect(result.error.cause).toBe(reason); + } else if (kind === "analysis-error") expect(result.error).toBe(failure); + else { + expect(result.error).toMatchObject({ _tag: "ProviderAdapterError" }); + expect(result.error.cause).toBe(failure); + } + }, +);