diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index bb5df5ef30..e224100a79 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -76,6 +76,9 @@ probe retries the original command. Failed acknowledgements remain pending and block new commands until they can be retried successfully. Status proof failures identify `cutter_command` and retain `status` in `details.diagnostics.auxiliary_operation`; malformed proof still blocks dispatch. +Descriptor failures likewise retain the requesting operation through cleanup. +Command admission reports typed cancellation and treats the caller's abort reason +as opaque metadata, without inferring cleanup ownership from it. ## Configuration and coverage diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 3de1be6db1..9e76f3c509 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -176,6 +176,13 @@ class CutterBridgeResponseFrames { } } +const assertCommandActive = (signal: AbortSignal | undefined): void => { + if (signal?.aborted) + throw new AnalysisCancelledError("cutter_command", { + cause: signal.reason, + }); +}; + const mapDiscovery = async ( inputs: readonly Input[], operation: (input: Input) => Promise, @@ -413,9 +420,9 @@ export class CutterBridgeClient { readonly json: boolean; readonly signal?: AbortSignal; }): Promise { - input.signal?.throwIfAborted(); + assertCommandActive(input.signal); const descriptor = await this.#find(input.sessionId, input.signal); - input.signal?.throwIfAborted(); + assertCommandActive(input.signal); const requestId = randomUUID(); const commandRequest = serializeRequest(descriptor, { kind: "command", @@ -426,7 +433,7 @@ export class CutterBridgeClient { }); await this.#recoverCompletedCommands(descriptor, input.signal); await this.#retryPendingAcknowledgements(descriptor); - input.signal?.throwIfAborted(); + assertCommandActive(input.signal); let completionToken: string | undefined; let rawResponse: unknown; try { diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index 4a77cf6fcf..55ac06adf5 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -748,7 +748,11 @@ describe("CutterBridgeClient: scenario group 4", () => { json: false, signal: controller.signal, }), - ).rejects.toMatchObject({ name: "AbortError" }); + ).rejects.toMatchObject({ + _tag: "AnalysisCancelledError", + operation: "cutter_command", + cause: controller.signal.reason, + }); }); }); diff --git a/tests/boundary/providers/CutterCommandCancellation.test.ts b/tests/boundary/providers/CutterCommandCancellation.test.ts new file mode 100644 index 0000000000..45d9e40feb --- /dev/null +++ b/tests/boundary/providers/CutterCommandCancellation.test.ts @@ -0,0 +1,79 @@ +import { expect, it, onTestFinished } from "vitest"; +import { + AnalysisInputError, + AnalysisOutputError, +} from "../../../src/domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js"; +import { CutterBridgeClient } from "../../../src/cutter/CutterBridgeClient.js"; +import { CutterBridgeService } from "../../../src/cutter/CutterBridgeService.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + new AnalysisInputError("opaque caller metadata"), + new ProviderCleanupError("caller-owned", ["opaque caller resource"], { + reason: "caller metadata", + }), +])( + "keeps a typed caller abort reason opaque at command admission: $_tag", + async (reason) => { + const directory = await createTestTempDirectory( + "rea-cutter-command-cancel-", + ); + const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: directory }); + const service = new CutterBridgeService(client); + onTestFinished(() => service.close()); + const controller = new AbortController(); + const executing = service.execute( + { + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 0, + command: "ij", + }, + { signal: controller.signal }, + ); + queueMicrotask(() => controller.abort(reason)); + const result = await executing; + expect(result).toMatchObject({ + ok: false, + error: { + _tag: "AnalysisCancelledError", + operation: "cutter_command", + cleanupIncomplete: false, + }, + }); + if (result.ok) throw new Error("Expected cancellation"); + expect(result.error.cause).toBe(reason); + expect(result.error.cleanupResources).toEqual([]); + }, +); + +it.each([ + new AnalysisOutputError("cutter_command", "physical provider failure"), + new ProviderCleanupError("cutter", ["actual producer resource"], { + reason: "physical producer close failure", + }), +])( + "preserves a genuine typed provider failure also selected as abort reason: $_tag", + async (physical) => { + const controller = new AbortController(); + const service = new CutterBridgeService({ + listSessions: async () => { + throw new Error("Unexpected discovery"); + }, + execute: async () => { + controller.abort(physical); + throw physical; + }, + }); + const result = await service.execute( + { + session_id: "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a", + expected_generation: 0, + command: "ij", + }, + { signal: controller.signal }, + ); + if (result.ok) throw new Error("Expected provider failure"); + expect(result.error).toBe(physical); + }, +);