diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 8ca252510a..3de1be6db1 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -338,7 +338,7 @@ export class CutterBridgeClient { options: CutterDiscoveryOptions = {}, ): Promise { const { signal } = options; - const loaded = await this.#readDescriptors(signal); + const loaded = await this.#readDescriptors("list_cutter_sessions", signal); if (loaded.error !== undefined) return { sessions: [], @@ -653,7 +653,7 @@ export class CutterBridgeClient { sessionId: string, signal?: AbortSignal, ): Promise> { - const loaded = await this.#readDescriptors(signal); + const loaded = await this.#readDescriptors("cutter_command", signal); if (loaded.error === "bridge_directory_capacity_exceeded") throw new AnalysisResourceConstraintError( "cutter_command", @@ -681,17 +681,20 @@ export class CutterBridgeClient { async listDescriptors(): Promise< readonly z.infer[] > { - const loaded = await this.#readDescriptors(); + const loaded = await this.#readDescriptors("list_cutter_sessions"); return loaded.descriptors; } - async #readDescriptors(signal?: AbortSignal) { + async #readDescriptors( + operation: "list_cutter_sessions" | "cutter_command", + signal?: AbortSignal, + ) { try { return await this.#descriptorResources.run(() => this.#loadDescriptors(signal), ); } catch (cause: unknown) { - throw descriptorFailure(cause, "list_cutter_sessions"); + throw descriptorFailure(cause, operation); } } diff --git a/tests/process-global/cutterDescriptorLifetime.test.ts b/tests/process-global/cutterDescriptorLifetime.test.ts index 39250cca7e..99f5b4cd51 100644 --- a/tests/process-global/cutterDescriptorLifetime.test.ts +++ b/tests/process-global/cutterDescriptorLifetime.test.ts @@ -36,6 +36,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { import { CutterBridgeClient } from "../../src/cutter/CutterBridgeClient.js"; import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js"; import { AnalysisError } from "../../src/domain/analysisErrorBase.js"; +import { projectAnalysisError } from "../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { runCliJson } from "../fixtures/cliJsonProcess.js"; @@ -263,3 +264,55 @@ it("does not assign a peer's later cancellation to a descriptor that already fai await client.close(); } }); + +it.each(["command", "completion"] as const)( + "attributes retained descriptor cleanup to the requesting %s", + async (operation) => { + const root = await createTestTempDirectory( + "rea-cutter-command-descriptor-", + ); + await descriptor(root, 1); + fault.root = root; + const client = new CutterBridgeClient({ REA_CUTTER_BRIDGE_DIR: root }); + try { + const original = + operation === "command" + ? await client + .execute({ + sessionId: "00000000-0000-4000-8000-000000000001", + expectedGeneration: 0, + command: "ij", + json: false, + }) + .catch((cause: unknown) => cause) + : await client + .confirmCommandCompletion( + "00000000-0000-4000-8000-000000000001", + "4b18ce4d-bf31-456e-ae31-e8c547b2f9db", + ) + .then((result) => { + if (result.ok) throw new Error("Expected descriptor failure"); + return result.error; + }); + if (!(original instanceof AnalysisError)) + throw new Error("Expected typed descriptor cleanup"); + expect(projectAnalysisError(original)).toMatchObject({ + code: "cleanup_incomplete", + details: { operation: "cutter_command" }, + }); + expect(fault.handles).toHaveLength(1); + expect(fault.handles[0]?.fd).toBeGreaterThanOrEqual(0); + await expect(client.retryDescriptorCleanup()).rejects.toMatchObject({ + cleanupIncomplete: true, + }); + expect(original.cleanupIncomplete).toBe(true); + fault.blocked.clear(); + await client.close(); + expect(fault.handles[0]?.fd).toBe(-1); + expect(original.cleanupIncomplete).toBe(false); + } finally { + fault.blocked.clear(); + await client.close(); + } + }, +);