diff --git a/src/application/analysisView/AnalysisViewService.test.ts b/src/application/analysisView/AnalysisViewService.test.ts index e40ffea2ed..5e76a87e06 100644 --- a/src/application/analysisView/AnalysisViewService.test.ts +++ b/src/application/analysisView/AnalysisViewService.test.ts @@ -1023,27 +1023,32 @@ describe("inspectAnalysisViewWithFiles", () => { it("returns typed cancellation for a cancelled file request", async () => { const controller = new AbortController(); - controller.abort(); + const reason = Object.freeze({ requested: "view-stop" }); + controller.abort(reason); const parent = analysisViewCaptureEvidence( environmentManifestCaptureFixture(), ); - expect( - await inspectAnalysisViewWithFiles( - { - source: { - kind: "evidence-file", - path: await writeEvidenceFile(parent), - }, - view, + const result = await inspectAnalysisViewWithFiles( + { + source: { + kind: "evidence-file", + path: await writeEvidenceFile(parent), }, - undefined, - { signal: controller.signal }, - ), - ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + view, + }, + undefined, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); }); it("returns typed cancellation when a running file request is aborted", async () => { const controller = new AbortController(); + const reason = Object.freeze({ requested: "running-view-stop" }); const parent = analysisViewCaptureEvidence( environmentManifestCaptureFixture(), ); @@ -1054,11 +1059,13 @@ describe("inspectAnalysisViewWithFiles", () => { { signal: controller.signal }, ); // Queued before the request yields after its synchronous work, so it is seen even if the read already finished. - setImmediate(() => controller.abort()); - expect(await running).toMatchObject({ + setImmediate(() => controller.abort(reason)); + const result = await running; + expect(result).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); }); it("leaves file sources to the file-aware entry point", () => { diff --git a/src/application/analysisView/AnalysisViewService.ts b/src/application/analysisView/AnalysisViewService.ts index 6203f1d21b..2e5cb772d7 100644 --- a/src/application/analysisView/AnalysisViewService.ts +++ b/src/application/analysisView/AnalysisViewService.ts @@ -97,7 +97,11 @@ export const inspectAnalysisViewValidatedWithFiles = async ( }; // A request cancelled during authentication reports cancellation, even when authentication failed. if (await cancelled()) - return err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION)); + return err( + new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, { + cause: options.signal?.reason, + }), + ); if (!loaded.ok) return loaded; const id = loaded.value.evidence_id; const projected = inspectAnalysisViewValidated( @@ -105,9 +109,13 @@ export const inspectAnalysisViewValidatedWithFiles = async ( (evidenceId) => (evidenceId === id ? loaded.value : lookup?.(evidenceId)), ); // A request cancelled during projection delivers and records nothing. - return (await cancelled()) - ? err(new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION)) - : projected; + if (await cancelled()) + return err( + new AnalysisCancelledError(INSPECT_ANALYSIS_VIEW_OPERATION, { + cause: options.signal?.reason, + }), + ); + return projected; }; /** Authenticate borrowed inline Evidence after adapter envelope validation. */ diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts index 76532d5e28..3ab12cfe26 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.test.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.test.ts @@ -64,6 +64,33 @@ it("preserves a provider's actionable unsupported reason", async () => { expect(await service.inspect({ path: "/source.elf" })).toEqual(err(failure)); }); +it("preserves the caller's exact abort reason before invocation", async () => { + const controller = new AbortController(); + const reason = Object.freeze({ requested: "layout-stop" }); + controller.abort(reason); + let calls = 0; + const service = new BinaryLayoutService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: BINARY_LAYOUT_TEST_PROVIDER, + inspect: () => { + calls++; + return Promise.resolve(ok(binaryLayoutFixture())); + }, + }); + const result = await service.inspect( + { path: "/artifacts/source-owned.elf" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + expect(calls).toBe(0); +}); + it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new BinaryLayoutService({ diff --git a/src/application/binaryDiagnostics/BinaryLayoutService.ts b/src/application/binaryDiagnostics/BinaryLayoutService.ts index 53078bb948..d3315309a6 100644 --- a/src/application/binaryDiagnostics/BinaryLayoutService.ts +++ b/src/application/binaryDiagnostics/BinaryLayoutService.ts @@ -54,7 +54,11 @@ export class BinaryLayoutService { ) => ReturnType, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); const input = inspectBinaryLayoutInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/binaryDiagnostics/RecordedCrashService.test.ts b/src/application/binaryDiagnostics/RecordedCrashService.test.ts index 7e225b795e..9c807a2822 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.test.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.test.ts @@ -93,6 +93,33 @@ it("preserves a provider's actionable unsupported reason", async () => { expect(await service.inspect({ path: "/source.elf" })).toEqual(err(failure)); }); +it("preserves the caller's exact abort reason before invocation", async () => { + const controller = new AbortController(); + const reason = Object.freeze({ requested: "crash-stop" }); + controller.abort(reason); + let calls = 0; + const service = new RecordedCrashService({ + inspectAndClose(input, options) { + return this.inspect(input, options); + }, + identity: RECORDED_CRASH_TEST_PROVIDER, + inspect: () => { + calls++; + return Promise.resolve(ok(recordedCrashFixture())); + }, + }); + const result = await service.inspect( + { path: "/artifacts/source-owned.core" }, + { signal: controller.signal }, + ); + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + expect(calls).toBe(0); +}); + it("cancels before invocation and after an acquired observation", async () => { const controller = new AbortController(); const service = new RecordedCrashService({ diff --git a/src/application/binaryDiagnostics/RecordedCrashService.ts b/src/application/binaryDiagnostics/RecordedCrashService.ts index 7a0ebd0846..0aa9bbcf7a 100644 --- a/src/application/binaryDiagnostics/RecordedCrashService.ts +++ b/src/application/binaryDiagnostics/RecordedCrashService.ts @@ -55,7 +55,11 @@ export class RecordedCrashService { ) => ReturnType, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); const input = inspectRecordedCrashInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/flutter/DartAotInspection.ts b/src/application/flutter/DartAotInspection.ts index c11bbe1f43..df49188388 100644 --- a/src/application/flutter/DartAotInspection.ts +++ b/src/application/flutter/DartAotInspection.ts @@ -21,7 +21,11 @@ export const inspectDartAot = async ( ): Promise> => { const operation = "inspect_dart_aot"; if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { + cause: options.signal.reason, + }), + ); const parsed = dartAotInspectionInputSchema.safeParse(rawInput); if (!parsed.success) return err( diff --git a/src/application/javascript/JavaScriptArtifactReconstruction.ts b/src/application/javascript/JavaScriptArtifactReconstruction.ts index 2252e5a6b1..999bdbf57c 100644 --- a/src/application/javascript/JavaScriptArtifactReconstruction.ts +++ b/src/application/javascript/JavaScriptArtifactReconstruction.ts @@ -426,5 +426,6 @@ const abortIfNeeded = (signal?: AbortSignal): void => { throw new ArtifactReaderFailure( "cancelled", "JavaScript artifact reconstruction cancelled", + { cause: signal.reason }, ); }; diff --git a/src/application/wasm/WasmArtifactService.test.ts b/src/application/wasm/WasmArtifactService.test.ts index 4933fa9168..e79b07b4a5 100644 --- a/src/application/wasm/WasmArtifactService.test.ts +++ b/src/application/wasm/WasmArtifactService.test.ts @@ -101,13 +101,17 @@ it("cancels before provider work and delivers observed Evidence on success", asy }, }); const controller = new AbortController(); - controller.abort(); - expect( - await service.inspect( - { path: value.artifact.path }, - { signal: controller.signal }, - ), - ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + const reason = Object.freeze({ requested: "wasm-stop" }); + controller.abort(reason); + const cancelled = await service.inspect( + { path: value.artifact.path }, + { signal: controller.signal }, + ); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(cancelled.ok ? undefined : cancelled.error.cause).toBe(reason); expect(calls).toBe(0); expect(await service.inspect({ path: value.artifact.path })).toMatchObject({ ok: true, diff --git a/src/application/wasm/WasmArtifactService.ts b/src/application/wasm/WasmArtifactService.ts index 10927acf0e..99d3eb9482 100644 --- a/src/application/wasm/WasmArtifactService.ts +++ b/src/application/wasm/WasmArtifactService.ts @@ -36,7 +36,11 @@ export class WasmArtifactService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { + cause: options.signal.reason, + }), + ); const input = inspectWasmArtifactInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/artifacts/AsarListing.ts b/src/artifacts/AsarListing.ts index 60875e93bc..f8973dc952 100644 --- a/src/artifacts/AsarListing.ts +++ b/src/artifacts/AsarListing.ts @@ -52,6 +52,7 @@ export const readAsarListing = async ( throw new ArtifactReaderFailure( "cancelled", "ASAR operation cancelled", + { cause: signal.reason }, ); } const frame = pending.at(-1); diff --git a/src/artifacts/javascript/JavaScriptArtifactFiles.ts b/src/artifacts/javascript/JavaScriptArtifactFiles.ts index 562235eed7..1714abaae2 100644 --- a/src/artifacts/javascript/JavaScriptArtifactFiles.ts +++ b/src/artifacts/javascript/JavaScriptArtifactFiles.ts @@ -628,5 +628,6 @@ const abortIfNeeded = (signal?: AbortSignal): void => { throw new ArtifactReaderFailure( "cancelled", "JavaScript artifact reconstruction cancelled", + { cause: signal.reason }, ); }; diff --git a/src/flutter/FlutterBuildProvider.ts b/src/flutter/FlutterBuildProvider.ts index e67367e965..605998c5dc 100644 --- a/src/flutter/FlutterBuildProvider.ts +++ b/src/flutter/FlutterBuildProvider.ts @@ -594,7 +594,11 @@ export class FlutterBuildProvider { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted === true) - return err(new AnalysisCancelledError(request.operation)); + return err( + new AnalysisCancelledError(request.operation, { + cause: options.signal.reason, + }), + ); const { path } = request.input; const signal = options?.signal; let snapshot: OwnedSnapshot | undefined; diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index 6b534ef6e0..605461ec17 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -182,7 +182,10 @@ export class KunaProjectReader { ), ); const index = await waitForAbortable(this.#assembly, signal); - if (index === ABORTED) return err(new AnalysisCancelledError(operation)); + if (index === ABORTED) + return err( + new AnalysisCancelledError(operation, { cause: signal?.reason }), + ); return index.ok ? index : err( diff --git a/src/process/DetachedPgpSignature.test.ts b/src/process/DetachedPgpSignature.test.ts index ee240089fa..8be5489e0e 100644 --- a/src/process/DetachedPgpSignature.test.ts +++ b/src/process/DetachedPgpSignature.test.ts @@ -110,6 +110,26 @@ describe("detached binary PGP publisher verification", () => { ).toMatchObject({ ok: false, error: { reason: "cancelled" } }); }); + it.each([null, "caller stop", { phase: "publisher-key" }])( + "preserves the caller's exact abort reason %j as the cancellation cause", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + const result = await verifyDetachedPgpSignature( + bytes, + signatures[0]!, + publicKey, + fingerprint, + controller.signal, + ); + expect(result).toMatchObject({ + ok: false, + error: { reason: "cancelled" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + }, + ); + it("preserves cancellation while loading the publisher key, before classifying its identity", async () => { const controller = new AbortController(); const verification = verifyDetachedPgpSignature( diff --git a/src/process/DetachedPgpSignature.ts b/src/process/DetachedPgpSignature.ts index 8d1a215cd1..19ab39615a 100644 --- a/src/process/DetachedPgpSignature.ts +++ b/src/process/DetachedPgpSignature.ts @@ -33,6 +33,7 @@ export const verifyDetachedPgpSignature = async ( new DetachedPgpSignatureError( "cancelled", "Package verification was cancelled.", + { cause: signal?.reason }, ), ); const isAborted = () => signal?.aborted === true; diff --git a/src/server/runDerivedOperation.test.ts b/src/server/runDerivedOperation.test.ts index dabec6cf14..b2ac6258b7 100644 --- a/src/server/runDerivedOperation.test.ts +++ b/src/server/runDerivedOperation.test.ts @@ -9,12 +9,16 @@ import { EvidenceIntegrityError } from "../domain/evidenceErrors.js"; const context = ( controller: AbortController, notifications: unknown[] = [], + onMessage: (message: string) => void = () => undefined, ) => ({ mcpReq: { signal: controller.signal, _meta: { progressToken: "derived-test" }, notify: (notification: unknown) => { notifications.push(notification); + const message = (notification as { params?: { message?: unknown } }) + .params?.message; + if (typeof message === "string") onMessage(message); return Promise.resolve(); }, }, @@ -23,7 +27,8 @@ const context = ( describe("derived MCP operation boundary", () => { it("cancels before computation and never evaluates the operation", async () => { const controller = new AbortController(); - controller.abort(); + const reason = Object.freeze({ requested: "pre-compute-stop" }); + controller.abort(reason); let computed = false; const result = await runDerivedOperation( context(controller), @@ -38,9 +43,47 @@ describe("derived MCP operation boundary", () => { ok: false, error: { _tag: "AnalysisCancelledError" }, }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); expect(computed).toBe(false); }); + it("preserves the request's abort reason when cancellation wins after computation", async () => { + const controller = new AbortController(); + const reason = Object.freeze({ requested: "post-compute-stop" }); + const result = await runDerivedOperation( + context(controller), + "compare", + () => { + controller.abort(reason); + return "must-not-publish"; + }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + }); + + it("preserves the request's abort reason when cancellation wins at terminal delivery", async () => { + const controller = new AbortController(); + const reason = Object.freeze({ requested: "terminal-stop" }); + const result = await runDerivedOperation( + context(controller, [], (message) => { + if (message.startsWith("complete:")) controller.abort(reason); + }), + "compare", + () => "must-not-publish", + ); + + expect(result).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(result.ok ? undefined : result.error.cause).toBe(reason); + }); + it("lets cancellation win immediately before result publication", async () => { const controller = new AbortController(); const result = await runDerivedOperation( diff --git a/src/server/runDerivedOperation.ts b/src/server/runDerivedOperation.ts index f34a2b21e3..6d2eaffd8e 100644 --- a/src/server/runDerivedOperation.ts +++ b/src/server/runDerivedOperation.ts @@ -31,7 +31,11 @@ export const runDerivedOperation = async ( }); await setImmediate(); if (context.mcpReq.signal.aborted) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { + cause: context.mcpReq.signal.reason, + }), + ); let value: Value; try { value = compute(); @@ -62,7 +66,11 @@ export const runDerivedOperation = async ( }); await setImmediate(); if (context.mcpReq.signal.aborted) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { + cause: context.mcpReq.signal.reason, + }), + ); await progress.report({ phase: "complete", completed: 2, @@ -71,6 +79,10 @@ export const runDerivedOperation = async ( terminal: true, }); if (context.mcpReq.signal.aborted) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { + cause: context.mcpReq.signal.reason, + }), + ); return ok(value); }; diff --git a/tests/boundary/filesystem/asarListingAdmission.test.ts b/tests/boundary/filesystem/asarListingAdmission.test.ts index f5ebaa870d..9762adff9f 100644 --- a/tests/boundary/filesystem/asarListingAdmission.test.ts +++ b/tests/boundary/filesystem/asarListingAdmission.test.ts @@ -22,6 +22,7 @@ const archiveWith = async (bytes: Buffer) => { it("interrupts expanded listing admission between traversal batches", async () => { const controller = new AbortController(); + const reason = Object.freeze({ requested: "listing-stop" }); const header = { files: Object.fromEntries( Array.from({ length: 4000 }, (_, index) => [ @@ -32,8 +33,10 @@ it("interrupts expanded listing admission between traversal batches", async () = }; const listing = readAsarListing(header, controller.signal); await setImmediate(); - controller.abort(); - await expect(listing).rejects.toMatchObject({ reason: "cancelled" }); + controller.abort(reason); + const error: unknown = await listing.catch((caught: unknown) => caught); + expect(error).toMatchObject({ reason: "cancelled" }); + expect((error as { cause?: unknown }).cause).toBe(reason); }); it("rejects path expansion before publishing any inventory, preserving the input", async () => { diff --git a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts index d5e5c67a0b..f5db813439 100644 --- a/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactReconstruction.test.ts @@ -404,10 +404,14 @@ it("rejects traversal from a production reader seam and malformed ASAR container it("preserves cancellation and explicit format diagnostics", async () => { const root = await fixtureDirectory(); const controller = new AbortController(); - controller.abort(); - await expect( - reconstructJavaScriptArtifact({ input_path: root }, controller.signal), - ).rejects.toMatchObject({ reason: "cancelled" }); + const reason = Object.freeze({ requested: "reconstruction-stop" }); + controller.abort(reason); + const cancelled: unknown = await reconstructJavaScriptArtifact( + { input_path: root }, + controller.signal, + ).catch((caught: unknown) => caught); + expect(cancelled).toMatchObject({ reason: "cancelled" }); + expect((cancelled as { cause?: unknown }).cause).toBe(reason); await expect( reconstructJavaScriptArtifact({ input_path: root, format: "asar" }), ).rejects.toMatchObject({ diff --git a/tests/boundary/mcp/dartAotSummaryMcp.test.ts b/tests/boundary/mcp/dartAotSummaryMcp.test.ts index be6a970d3d..9962bb74f3 100644 --- a/tests/boundary/mcp/dartAotSummaryMcp.test.ts +++ b/tests/boundary/mcp/dartAotSummaryMcp.test.ts @@ -273,13 +273,27 @@ it("rejects unavailable retention, invalid delivery and cancellation before prod retention, ), ).toMatchObject({ ok: false, error: { _tag: "AnalysisInputError" } }); - expect( - await inspectDartAot( - service, - { path: "/missing.apk", detail: "summary" }, - retention, - { signal: AbortSignal.abort() }, - ), - ).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" } }); + const reason = Object.freeze({ requested: "dart-stop" }); + const cancelled = await inspectDartAot( + service, + { path: "/missing.apk", detail: "summary" }, + retention, + { signal: AbortSignal.abort(reason) }, + ); + expect(cancelled).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(cancelled.ok ? undefined : cancelled.error.cause).toBe(reason); + const executed = await service.execute( + "inspect_dart_aot", + { path: "/missing.apk", abi: "x86_64" }, + { signal: AbortSignal.abort(reason) }, + ); + expect(executed).toMatchObject({ + ok: false, + error: { _tag: "AnalysisCancelledError" }, + }); + expect(executed.ok ? undefined : executed.error.cause).toBe(reason); expect(execute).not.toHaveBeenCalled(); }); diff --git a/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts b/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts index 0b86384e6a..dce88a0bd9 100644 --- a/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts +++ b/tests/boundary/providers/kuna/kunaReaderCancellation.test.ts @@ -84,12 +84,17 @@ it.each([ const other = run(client, "procedure_assembly", { procedure: "parse" }); try { await expect.poll(() => held.streams.length).toBe(1); - controller.abort(); + const reason = Object.freeze({ requested: "reader-stop", operation }); + controller.abort(reason); await expect.poll(() => finished, { timeout: 1000 }).toBe(true); - expect(await cancelled).toMatchObject({ + const cancelledResult = await cancelled; + expect(cancelledResult).toMatchObject({ ok: false, error: { _tag: "AnalysisCancelledError" }, }); + expect(cancelledResult.ok ? undefined : cancelledResult.error.cause).toBe( + reason, + ); expect(held.streams[0]?.destroyed).toBe(false); expect((await run(client, "health")).result).toBeDefined(); held.streams[0]?.end(bytes);