diff --git a/src/application/reverse/ReverseEngineeringService.ts b/src/application/reverse/ReverseEngineeringService.ts index 19e2d515ea..5886c0456e 100644 --- a/src/application/reverse/ReverseEngineeringService.ts +++ b/src/application/reverse/ReverseEngineeringService.ts @@ -65,6 +65,7 @@ import { } from "../../windows/WindowsOwnedProcess.js"; import { absoluteCallerPath } from "../../filesystem/callerPath.js"; import { parseAnalysisInput } from "../AnalysisInput.js"; +import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js"; export type ReverseEngineeringOperation = | "inspect_with_objdump" @@ -441,11 +442,24 @@ export class ReverseEngineeringService { ...request, signal: operationSignal, }); - const tracked = running.finally(() => { - signal?.removeEventListener("abort", relayAbort); - this.#activeOperations.delete(controller); - this.#reservedOutputBytes -= outputBudgetBytes; - }); + const tracked = running + .then((result) => + !result.ok && operationSignal?.aborted + ? err( + analysisErrorWithCancellation( + result.error, + new AnalysisCancelledError(operation, { + cause: operationSignal.reason, + }), + ), + ) + : result, + ) + .finally(() => { + signal?.removeEventListener("abort", relayAbort); + this.#activeOperations.delete(controller); + this.#reservedOutputBytes -= outputBudgetBytes; + }); this.#activeOperations.set(controller, tracked); return tracked; } @@ -1499,7 +1513,14 @@ const commandCancelled = ( signal: AbortSignal | undefined, cause: unknown, code: string | number | undefined, -): boolean => code === "ABORT_ERR" || preflightCancelled(signal, cause, code); +): boolean => { + if ( + cause instanceof OwnedOutputFailure && + cause.cause instanceof OwnedCommandFailure + ) + return cause.cause.reason === "cancelled"; + return code === "ABORT_ERR" || preflightCancelled(signal, cause, code); +}; const callerCancellationCause = ( signal: AbortSignal | undefined, diff --git a/tests/composition/reverseEngineeringUnknownFailures.test.ts b/tests/composition/reverseEngineeringUnknownFailures.test.ts index f6db780be1..592fb280e1 100644 --- a/tests/composition/reverseEngineeringUnknownFailures.test.ts +++ b/tests/composition/reverseEngineeringUnknownFailures.test.ts @@ -6,8 +6,87 @@ import { expect, it, vi } from "vitest"; import { ReverseEngineeringService } from "../../src/application/reverse/ReverseEngineeringService.js"; import { analysisErrorProjectionSchema } from "../../src/contracts/errorSchemas.js"; import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; +import { OwnedCommandFailure } from "../../src/process/OwnedCommand.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; +it.each([false, true])( + "keeps owned output collection failure primary after late cancellation=%s", + async (cancel) => { + const controller = new AbortController(); + const abortReason = { caller: "cancelled after collection failure" }; + const physical = Object.assign(new Error("output pipe read failed"), { + code: "EIO", + }); + const owned = new OwnedCommandFailure( + "process", + "owned output collection failed", + { + stdout: { text: "retained", bytes: 8, observedBytes: 8 }, + stderr: { text: "", bytes: 0, observedBytes: 0 }, + exitCode: 0, + signal: null, + }, + null, + { cause: physical, collectionFailure: true }, + ); + const profile = { + executable: "unused", + arguments: [], + identity: { id: "fixture", name: "fixture", version: null }, + }; + const service = new ReverseEngineeringService({ + commands: { objdump: () => profile, rizin: () => profile }, + runOwned: async () => { + if (cancel) controller.abort(abortReason); + throw owned; + }, + }); + const result = await service.inspectWithObjdump( + { operation: "architectures" }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected output collection failure"); + expect(result.error._tag).toBe("AnalysisOutputError"); + expect(result.error.cause).toMatchObject({ cause: owned }); + expect(owned.cause).toBe(physical); + expect(result.error.capturedOutput).toMatchObject({ + stdout: "retained", + stdout_bytes: 8, + exit_code: null, + }); + expect(result.error.secondaryFailures).toHaveLength(cancel ? 1 : 0); + if (cancel) + expect(result.error.secondaryFailures[0]?.cause).toBe(abortReason); + await service.close(); + }, +); + +it("keeps the owned producer cancellation reason authoritative", async () => { + const controller = new AbortController(); + const abortReason = { caller: "owned process cancelled" }; + const profile = { + executable: "unused", + arguments: [], + identity: { id: "fixture", name: "fixture", version: null }, + }; + const service = new ReverseEngineeringService({ + commands: { objdump: () => profile, rizin: () => profile }, + runOwned: async () => { + controller.abort(abortReason); + throw new OwnedCommandFailure("cancelled", "owned process cancelled"); + }, + }); + const result = await service.inspectWithObjdump( + { operation: "architectures" }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected producer cancellation"); + expect(result.error._tag).toBe("AnalysisCancelledError"); + expect(result.error.cause).toBe(abortReason); + expect(result.error.secondaryFailures).toEqual([]); + await service.close(); +}); + it.each([ { stage: "stat", cause: null }, { stage: "stat", cause: undefined },