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.
This commit is contained in:
morluto
2026-10-12 04:40:41 +08:00
parent dc303a8ee8
commit 12fa8fd9f9
3 changed files with 129 additions and 18 deletions
+5
View File
@@ -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();
+97
View File
@@ -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");
+27 -18
View File
@@ -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<Result<null, AnalysisError>> | 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<void> {
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<Result<null, AnalysisError>> {
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", {}),