diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index 2b0e6fb835..0de23b309e 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -1140,6 +1140,100 @@ describe("headless IDA session ownership and cleanup", () => { }); }); +describe("headless IDA blanked-identity inventory rows", () => { + it("releases cleanly when the closed worker lingers in inventory without an identity", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + let released = false; + producer.beforeCall = async (name) => { + if (name === "idb_close") released = true; + }; + // A worker observed mid-teardown: identity cleared, private input retained. + producer.overrideCall = (name) => + name === "idb_list" && released + ? { + sessions: [ + { + session_id: "", + input_path: producer.inputPath, + owned: false, + backend: "worker", + is_active: false, + }, + ], + count: 1, + } + : undefined; + + expect((await client.close()).ok).toBe(true); + expect(await readdir(root)).toEqual(["target.elf"]); + }); + + it("still reports a same-input gui row whose identity is blank", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + let released = false; + producer.beforeCall = async (name) => { + if (name === "idb_close") released = true; + }; + // A gui binding belongs to an IDA instance REA does not control, so the + // blanked-identity exception must not reach it. + producer.overrideCall = (name) => + name === "idb_list" && released + ? { + sessions: [ + { + session_id: "", + input_path: producer.inputPath, + owned: false, + backend: "gui", + is_active: false, + }, + ], + count: 1, + } + : undefined; + + const result = await client.close(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); + }); + + it("still reports a same-input peer that keeps a session identity", async () => { + const { producer, client, root } = await fixture("headless"); + expect((await client.execute("health", {})).ok).toBe(true); + let released = false; + producer.beforeCall = async (name) => { + if (name === "idb_close") released = true; + }; + producer.overrideCall = (name) => + name === "idb_list" && released + ? { + sessions: [ + { + session_id: "unrelated-live-peer", + input_path: producer.inputPath, + owned: false, + backend: "worker", + is_active: false, + }, + ], + count: 1, + } + : undefined; + + const result = await client.close(); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.cleanupIncomplete).toBe(true); + expect( + (await readdir(root)).some((name) => name.startsWith("rea-ida-")), + ).toBe(true); + }); +}); + 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"); diff --git a/src/ida/IdaSessionClient.ts b/src/ida/IdaSessionClient.ts index 5c00179906..06a418e763 100644 --- a/src/ida/IdaSessionClient.ts +++ b/src/ida/IdaSessionClient.ts @@ -152,6 +152,29 @@ class IdaCleanupError extends ProviderAdapterError { } } +/** + * A worker that is still tearing down can remain in inventory with its identity + * already cleared: an empty `session_id`, `owned: false` and `is_active: false`, + * while `input_path` still names the private copy. Such a row establishes no + * live session, so the private-input arm must not report it as a blocker. The + * owning worker is matched by its own session ID separately, and a genuine peer + * on the same input keeps a session ID, so neither case is weakened. + * + * Limited to `backend: "worker"`: a gui row belongs to an IDA instance REA does + * not control, and the observed teardown shape covers workers only, so a gui + * row keeps blocking release whatever its flags say. + */ +const isReleasedWorkerRow = (entry: { + readonly session_id: string; + readonly owned: boolean; + readonly is_active: boolean; + readonly backend: "worker" | "gui"; +}): boolean => + entry.backend === "worker" && + entry.session_id === "" && + entry.owned === false && + entry.is_active === false; + /** Serial read-only session whose lifecycle authority excludes an attached GUI. */ export class IdaSessionClient implements AnalysisClient { #tail: Promise = Promise.resolve(); @@ -873,11 +896,12 @@ export class IdaSessionClient implements AnalysisClient { ); const remaining = inventory.sessions.filter( (entry) => - entry.session_id === this.#database || - sameIdaHostPath( - entry.input_path, - this.#workspace?.inputPath ?? "", - ), + !isReleasedWorkerRow(entry) && + (entry.session_id === this.#database || + sameIdaHostPath( + entry.input_path, + this.#workspace?.inputPath ?? "", + )), ); if (this.#recordBlockingSessions(remaining)) throw new AnalysisProtocolError(