From 71929ed37cdc560b6b8aa62b5f7182e417c8a850 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:24:43 +0800 Subject: [PATCH] fix(cutter): normalize command checkpoint cancellation Produce typed cancellation at each command checkpoint while retaining the exact opaque reason as cause. Preserve independent typed physical failures without downstream equality guesses. Validated 72 focused tests across nine files, static checks, docs and five conformance controls, including real client/service admission races and matching physical-error controls. No native Cutter engine or new public-transport reproduction claimed. Refs: #4635 --- docs/reverse-engineering-tools.md | 3 + src/cutter/CutterBridgeClient.ts | 13 ++- .../providers/CutterBridgeClient.test.ts | 6 +- .../CutterCommandCancellation.test.ts | 79 +++++++++++++++++++ 4 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/providers/CutterCommandCancellation.test.ts 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); + }, +);