diff --git a/src/application/binaryDiagnostics/PeResourcesService.test.ts b/src/application/binaryDiagnostics/PeResourcesService.test.ts index 8274b1086..ab7a1a468 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.test.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.test.ts @@ -80,10 +80,11 @@ it.each([ it("retains a completed observation on late cancellation and keeps output errors primary", async () => { const report = await observedReport(); const controller = new AbortController(); + const reason = { request: "late PE cancellation" }; const service = new PeResourcesService({ identity: PE_RESOURCES_PROVIDER, inspect: () => { - controller.abort(); + controller.abort(reason); return Promise.resolve(ok(report)); }, }); @@ -96,6 +97,7 @@ it("retains a completed observation on late cancellation and keeps output errors error: { _tag: "AnalysisCancelledError" }, }); if (!late.ok) { + expect(late.error.cause).toBe(reason); expect(parseEvidence(late.error.partialObservation)).toMatchObject({ operation: "inspect_pe_resources", subject: { architecture: "x86" }, @@ -119,3 +121,40 @@ it("retains a completed observation on late cancellation and keeps output errors expect(invalid.ok).toBe(false); if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); }); + +it.each([ + null, + "caller cancellation", + { request: "opaque caller value" }, + new Error("caller failure"), + new AnalysisOutputError("caller_operation", "caller-owned cleanup", { + cleanup: { + reason: "unresolved caller resource", + resources: ["caller resource"], + }, + }), +])( + "preserves an opaque admission cancellation reason without invoking the reader: %j", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + let calls = 0; + const service = new PeResourcesService({ + identity: PE_RESOURCES_PROVIDER, + inspect: async () => { + calls += 1; + throw new Error("cancelled admission must not invoke the reader"); + }, + }); + const result = await service.inspect(null, { signal: controller.signal }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancelled admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.partialObservation).toBeUndefined(); + expect(calls).toBe(0); + if (reason instanceof AnalysisOutputError) + expect(reason.cleanupIncomplete).toBe(true); + }, +); diff --git a/src/application/binaryDiagnostics/PeResourcesService.ts b/src/application/binaryDiagnostics/PeResourcesService.ts index 457ac5f33..c818ff157 100644 --- a/src/application/binaryDiagnostics/PeResourcesService.ts +++ b/src/application/binaryDiagnostics/PeResourcesService.ts @@ -37,7 +37,9 @@ export class PeResourcesService { ): Promise> { const operation = "inspect_pe_resources"; if (options?.signal?.aborted) - return err(new AnalysisCancelledError(operation)); + return err( + new AnalysisCancelledError(operation, { cause: options.signal.reason }), + ); const input = inspectPeResourcesInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/go/GoBinaryService.test.ts b/src/application/go/GoBinaryService.test.ts index 1c6aa41f6..a8369c5ca 100644 --- a/src/application/go/GoBinaryService.test.ts +++ b/src/application/go/GoBinaryService.test.ts @@ -153,12 +153,13 @@ it("refuses reader identity changes and invalid original source ranges", async ( it("honors cancellation before reading and after a completed observation", async () => { const controller = new AbortController(); + const reason = { request: "late Go cancellation" }; let calls = 0; const service = new GoBinaryService({ identity, inspect: () => { calls += 1; - controller.abort(); + controller.abort(reason); return Promise.resolve(ok(report)); }, }); @@ -169,6 +170,7 @@ it("honors cancellation before reading and after a completed observation", async expect(completed.ok).toBe(false); if (!completed.ok) { expect(completed.error).toBeInstanceOf(AnalysisCancelledError); + expect(completed.error.cause).toBe(reason); expect(parseEvidence(completed.error.partialObservation)).toMatchObject({ operation: "inspect_go_binary", normalized_result: report, @@ -199,3 +201,40 @@ it("keeps malformed provider output ahead of a late cancellation", async () => { expect(result.ok).toBe(false); if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); }); + +it.each([ + null, + "caller cancellation", + { request: "opaque caller value" }, + new Error("caller failure"), + new AnalysisOutputError("caller_operation", "caller-owned cleanup", { + cleanup: { + reason: "unresolved caller resource", + resources: ["caller resource"], + }, + }), +])( + "preserves an opaque admission cancellation reason without invoking the reader: %j", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + let calls = 0; + const service = new GoBinaryService({ + identity, + inspect: async () => { + calls += 1; + throw new Error("cancelled admission must not invoke the reader"); + }, + }); + const result = await service.inspect(null, { signal: controller.signal }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancelled admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.partialObservation).toBeUndefined(); + expect(calls).toBe(0); + if (reason instanceof AnalysisOutputError) + expect(reason.cleanupIncomplete).toBe(true); + }, +); diff --git a/src/application/go/GoBinaryService.ts b/src/application/go/GoBinaryService.ts index 92c288c33..470d3690b 100644 --- a/src/application/go/GoBinaryService.ts +++ b/src/application/go/GoBinaryService.ts @@ -45,7 +45,9 @@ export class GoBinaryService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectGoBinaryInputSchema.safeParse(rawInput); if (!input.success) return err( diff --git a/src/application/rust/RustBinaryService.test.ts b/src/application/rust/RustBinaryService.test.ts index c6c47df0f..69260975e 100644 --- a/src/application/rust/RustBinaryService.test.ts +++ b/src/application/rust/RustBinaryService.test.ts @@ -115,10 +115,11 @@ it("rejects a cycle claim the package dependencies do not contain", async () => it("retains completed Rust Evidence on late cancellation and keeps output failure precedence", async () => { const controller = new AbortController(); + const reason = { request: "late Rust cancellation" }; const service = new RustBinaryService({ identity, inspect: () => { - controller.abort(); + controller.abort(reason); return Promise.resolve(ok(report)); }, }); @@ -154,8 +155,14 @@ it("retains completed Rust Evidence on late cancellation and keeps output failur error: { _tag: "AnalysisOutputError" }, }); expect(cancelled.ok).toBe(false); - if (!cancelled.ok) + if (!cancelled.ok) { expect(cancelled.error).toBeInstanceOf(AnalysisCancelledError); + expect(cancelled.error.cause).toBe(reason); + expect(parseEvidence(cancelled.error.partialObservation)).toMatchObject({ + operation: "inspect_rust_binary", + normalized_result: report, + }); + } }); it.each([ @@ -178,39 +185,39 @@ it.each([ if (!result.ok) expect(result.error).toBeInstanceOf(AnalysisOutputError); }); -it("retains a completed report on late cancellation and keeps output errors primary", async () => { - const controller = new AbortController(); - const completed = await new RustBinaryService({ - identity, - inspect: () => { - controller.abort(); - return Promise.resolve(ok(report)); +it.each([ + null, + "caller cancellation", + { request: "opaque caller value" }, + new Error("caller failure"), + new AnalysisOutputError("caller_operation", "caller-owned cleanup", { + cleanup: { + reason: "unresolved caller resource", + resources: ["caller resource"], }, - }).inspect({ path: report.artifact.path }, { signal: controller.signal }); - expect(completed.ok).toBe(false); - if (!completed.ok) { - expect(completed.error).toBeInstanceOf(AnalysisCancelledError); - expect(parseEvidence(completed.error.partialObservation)).toMatchObject({ - operation: "inspect_rust_binary", - normalized_result: report, + }), +])( + "preserves an opaque admission cancellation reason without invoking the reader: %j", + async (reason) => { + const controller = new AbortController(); + controller.abort(reason); + let calls = 0; + const service = new RustBinaryService({ + identity, + inspect: async () => { + calls += 1; + throw new Error("cancelled admission must not invoke the reader"); + }, }); - } - - const malformed = { - ...report, - artifact: { ...report.artifact, path: "/artifacts/other" }, - }; - const outputController = new AbortController(); - const invalid = await new RustBinaryService({ - identity, - inspect: () => { - outputController.abort(); - return Promise.resolve(ok(malformed)); - }, - }).inspect( - { path: report.artifact.path }, - { signal: outputController.signal }, - ); - expect(invalid.ok).toBe(false); - if (!invalid.ok) expect(invalid.error).toBeInstanceOf(AnalysisOutputError); -}); + const result = await service.inspect(null, { signal: controller.signal }); + expect(result.ok).toBe(false); + if (result.ok) throw new Error("expected cancelled admission"); + expect(result.error).toBeInstanceOf(AnalysisCancelledError); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.partialObservation).toBeUndefined(); + expect(calls).toBe(0); + if (reason instanceof AnalysisOutputError) + expect(reason.cleanupIncomplete).toBe(true); + }, +); diff --git a/src/application/rust/RustBinaryService.ts b/src/application/rust/RustBinaryService.ts index 50ad8047f..0c999958c 100644 --- a/src/application/rust/RustBinaryService.ts +++ b/src/application/rust/RustBinaryService.ts @@ -46,7 +46,9 @@ export class RustBinaryService { options?: ExecutionOptions, ): Promise> { if (options?.signal?.aborted) - return err(new AnalysisCancelledError(OPERATION)); + return err( + new AnalysisCancelledError(OPERATION, { cause: options.signal.reason }), + ); const input = inspectRustBinaryInputSchema.safeParse(rawInput); if (!input.success) return err(