mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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
@@ -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", {}),
|
||||
|
||||
Reference in New Issue
Block a user