From 12fa8fd9f986dbd4ff58165c3ec71a46dc8a8a3b Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Mon, 12 Oct 2026 04:40:41 +0800 Subject: [PATCH] fix(ida): recover cleanup after worker sessions are reaped Accept the supervisor absent-session response only for the owned session, then independently verify session and private-input absence before releasing the workspace. Keep failed inventory verification retryable without resending a completed close. Validation: 59 focused source tests passed, including live private-input peers, unavailable inventory, and mismatched session IDs. Real IDA engine behavior remains unverified. Fixes #1710. --- src/ida/IdaProtocolValues.ts | 5 ++ src/ida/IdaSessionClient.test.ts | 97 ++++++++++++++++++++++++++++++++ src/ida/IdaSessionClient.ts | 45 +++++++++------ 3 files changed, 129 insertions(+), 18 deletions(-) diff --git a/src/ida/IdaProtocolValues.ts b/src/ida/IdaProtocolValues.ts index e786bed7c..93ddbef88 100644 --- a/src/ida/IdaProtocolValues.ts +++ b/src/ida/IdaProtocolValues.ts @@ -242,3 +242,8 @@ export const closeDatabaseSchema = z backend: z.enum(["worker", "gui"]), }) .passthrough(); +/** Supervisor response when the requested worker was already reaped. */ +export const absentDatabaseCloseSchema = (sessionId: string) => + z + .object({ error: z.literal(`Session not found: ${sessionId}`) }) + .passthrough(); diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index d190ecc72..a7d7be083 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -722,6 +722,103 @@ describe("headless IDA session ownership and cleanup", () => { }); }); +describe("headless IDA reaped worker cleanup", () => { + it("recovers a reaped worker only after inventory confirms its session and private input are absent", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const sessionId = producer.database; + producer.beforeCall = async (name) => { + if (name === "idb_close") producer.database = undefined; + }; + producer.overrideCall = (name) => + name === "idb_close" + ? { error: `Session not found: ${sessionId}` } + : undefined; + + expect((await client.close()).ok).toBe(true); + expect(producer.calls.map(({ name }) => name).slice(-2)).toEqual([ + "idb_close", + "idb_list", + ]); + expect(producer.closes).toBe(1); + expect(await readdir(root)).toEqual(["target.elf"]); + }); + + it("retains resources when an absent-session close is followed by a live private-input peer", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const sessionId = producer.database; + producer.beforeCall = async (name) => { + if (name === "idb_close") producer.database = undefined; + if (name === "idb_list" && producer.database === undefined) { + producer.database = "unrelated-live-peer"; + producer.owned = false; + } + }; + producer.overrideCall = (name) => + name === "idb_close" + ? { error: `Session not found: ${sessionId}` } + : undefined; + + const result = await client.close(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + expect(producer.closes).toBe(0); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); + }); + + it("retries independent inventory verification after an absent-session response", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + const sessionId = producer.database; + let inventoryUnavailable = true; + producer.beforeCall = async (name) => { + if (name === "idb_close") producer.database = undefined; + if (name === "idb_list" && inventoryUnavailable) { + inventoryUnavailable = false; + throw new Error("Inventory temporarily unavailable"); + } + }; + producer.overrideCall = (name) => + name === "idb_close" + ? { error: `Session not found: ${sessionId}` } + : undefined; + + expect((await client.close()).ok).toBe(false); + expect(producer.closes).toBe(0); + expect((await client.close()).ok).toBe(true); + expect( + producer.calls.filter(({ name }) => name === "idb_close"), + ).toHaveLength(1); + expect(producer.closes).toBe(1); + expect(await readdir(root)).toEqual(["target.elf"]); + }); + + it("does not treat a different missing session identity as proof of release", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + producer.beforeCall = async (name) => { + if (name === "idb_close") producer.database = undefined; + }; + producer.overrideCall = (name) => + name === "idb_close" + ? { error: "Session not found: another-session" } + : undefined; + + const result = await client.close(); + expect(result.ok).toBe(false); + expect( + producer.calls.filter(({ name }) => name === "idb_list"), + ).toHaveLength(1); + expect(producer.closes).toBe(0); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); + }); +}); + describe("headless IDA cleanup verification", () => { it("retries failed worker release through the retained connection before removing its workspace", async () => { const { producer, client, root } = await fixture("headless"); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 9b502e246..ff2257752 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -30,6 +30,7 @@ import { type IdaOperation, } from "./IdaProviderCapabilities.js"; import { + absentDatabaseCloseSchema, closeDatabaseSchema, databaseListSchema, legacyMetadataSchema, @@ -101,7 +102,7 @@ export class IdaSessionClient implements AnalysisClient { #closePromise: Promise> | undefined; #workspace: IdaWorkspace | undefined; #database: string | undefined; - #databaseCloseAcknowledged = false; + #databaseCloseCompleted = false; #openAttempted = false; #openCompleted = false; #callGraphAdvertised = false; @@ -418,6 +419,28 @@ export class IdaSessionClient implements AnalysisClient { return shared; } + async #closeDatabase(database: string): Promise { + const response = await this.connection.call("idb_close", { + database, + save: false, + }); + const closed = closeDatabaseSchema.safeParse(response); + if (!closed.success) { + if (!absentDatabaseCloseSchema(database).safeParse(response).success) + throw closed.error; + return; + } + if ( + closed.data.session_id !== database || + closed.data.saved === true || + closed.data.owned !== true || + closed.data.backend !== "worker" + ) + throw new AnalysisProtocolError( + "IDA database close did not confirm the requested owned, unsaved session release.", + ); + } + async #closeOnce(): Promise> { this.#closing = true; await this.#tail; @@ -447,23 +470,9 @@ export class IdaSessionClient implements AnalysisClient { ); } if (this.#database !== undefined) { - if (!this.#databaseCloseAcknowledged) { - const closed = closeDatabaseSchema.parse( - await this.connection.call("idb_close", { - database: this.#database, - save: false, - }), - ); - if ( - closed.session_id !== this.#database || - closed.saved === true || - closed.owned !== true || - closed.backend !== "worker" - ) - throw new AnalysisProtocolError( - "IDA database close did not confirm the requested owned, unsaved session release.", - ); - this.#databaseCloseAcknowledged = true; + if (!this.#databaseCloseCompleted) { + await this.#closeDatabase(this.#database); + this.#databaseCloseCompleted = true; } const inventory = databaseListSchema.parse( await this.connection.call("idb_list", {}),