Files
rea/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts
morluto 150e996a86 refactor(providers)!: await owned readiness observations
Carry asynchronous readiness through provider selection, session status, CLI and MCP. Join observations during cancellation and shutdown, keep status readable during finalization, and retain mixed provider errors and exact cleanup dependencies through the shared inspection failure owner.

Validation: 255 focused tests across 18 files; 21 registry tests rerun with primary restoration assertion; check:fast; docs:check and 5 README conformance tests. Registered MCP and SDK schema checks use controlled providers; no real Ghidra/JVM verification.

This is a prerequisite for #4561. The synchronous Ghidra Java producer and its doctor/client ownership migration remain unfinished.

BREAKING CHANGE: binary_session candidate availability can be unknown during owner finalization. Consumers must handle this state; the internal session status API is now asynchronous.

Refs: #4561
2026-10-17 09:57:43 +08:00

353 lines
12 KiB
TypeScript

import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished } from "vitest";
import type { AnalysisClient } from "../../../src/application/AnalysisProvider.js";
import { HopperStartError } from "../../../src/domain/hopperErrors.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { err, ok as resultOk } from "../../../src/domain/result.js";
import { observed as ok } from "../../fixtures/analysisExecution.js";
import {
createBinarySessionTargets,
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const retryFixture = async (
execute: AnalysisClient["execute"] = () => Promise.resolve(ok(null)),
) => {
const parent = await createTestTempDirectory("rea-session-cleanup-retry-");
const runtime = join(parent, "owned-runtime");
const document = join(parent, "retained-document");
await mkdir(runtime);
await writeFile(join(runtime, "observation"), "owned observation");
await writeFile(document, "caller-retained document");
const targets = await createBinarySessionTargets();
let allowed = false;
let created = 0;
const runIds: string[] = [];
const cleanupError = new ProviderCleanupError("fixture", [runtime], {
reason: "owned runtime removal denied",
leftover_paths: [runtime],
});
const client: AnalysisClient = {
execute,
operationHealthSnapshot: () => ({
state: "unreachable",
stage: "connection",
retryAction: "restart_provider",
exitCode: null,
requests: [],
}),
close: async (options) => {
if (!allowed) return err(cleanupError);
await rm(runtime, { recursive: true, force: true });
if (options?.retainDocument !== true) await rm(document, { force: true });
return resultOk(null);
},
};
const session = createTestBinarySession((_target, _profile, context) => {
if (context === undefined) throw new Error("missing analysis run context");
runIds.push(context.runId);
created += 1;
return created === 1
? {
...client,
runtimeLineageSnapshots: () => [
{
provider: {
id: "fixture",
name: "Fixture analysis provider",
version: "1",
},
observation: {
status: "unavailable" as const,
observedAt: "2026-10-16T00:00:00Z",
runId: context.runId,
launcherPid: 123,
processGroupId: 123,
reason: "fixture lineage unavailable",
},
},
],
}
: {
execute: () => Promise.resolve(ok(null)),
close: () => Promise.resolve(resultOk(null)),
};
});
onTestFinished(async () => {
allowed = true;
await session.close();
});
return {
session,
targets,
runtime,
document,
cleanupError,
runIds,
allowCleanup: () => {
allowed = true;
},
created: () => created,
};
};
it.each(["close", "switch", "open after close"] as const)(
"retries owned cleanup before completing %s",
async (transition) => {
const fixture = await retryFixture();
const {
session,
targets: [first, second],
runtime,
cleanupError,
} = fixture;
expect((await session.open(first)).ok).toBe(true);
const attempt = () =>
transition === "close" ? session.close() : session.open(second);
const initial =
transition === "switch" ? await attempt() : await session.close();
expect(initial).toEqual(err(cleanupError));
expect(session.activeTarget()).toBeUndefined();
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
run_id: fixture.runIds[0],
process_lineage: {
status: "snapshots",
snapshots: [
{ observation: { reason: "fixture lineage unavailable" } },
],
},
},
provider_operation_health: { state: "unreachable", stage: "connection" },
});
expect(await session.execute("address_name", {})).toMatchObject({
ok: false,
});
await access(runtime);
expect(await attempt()).toEqual(err(cleanupError));
expect(await session.status()).toMatchObject({
analysis_run: { run_id: fixture.runIds[0] },
});
expect(fixture.created()).toBe(1);
await access(runtime);
fixture.allowCleanup();
expect((await attempt()).ok).toBe(true);
expect(await session.status()).toMatchObject({ cleanup_pending: false });
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
expect(fixture.created()).toBe(transition === "close" ? 1 : 2);
expect(await session.status()).toMatchObject({
analysis_run:
transition === "close"
? null
: {
run_id: fixture.runIds[1],
process_lineage: { status: "not_observed" },
},
});
expect(new Set(fixture.runIds).size).toBe(fixture.created());
},
);
it("keeps a failed candidate for cleanup and preserves its startup failure", async () => {
const primary = new HopperStartError();
const fixture = await retryFixture(() => Promise.resolve(err(primary)));
const result = await fixture.session.open(fixture.targets[0]);
expect(result).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [fixture.runtime],
diagnostics: {
primary_error: projectAnalysisError(primary),
cleanup_error: projectAnalysisError(fixture.cleanupError),
},
},
});
expect(fixture.session.activeTarget()).toBeUndefined();
expect(await fixture.session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
run_id: fixture.runIds[0],
process_lineage: { status: "snapshots" },
},
});
fixture.allowCleanup();
expect((await fixture.session.close()).ok).toBe(true);
await expect(access(fixture.runtime)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("retains cancelled startup cleanup and the cancellation reason", async () => {
const controller = new AbortController();
const fixture = await retryFixture(() => {
controller.abort();
return Promise.resolve(ok(null));
});
expect(
await fixture.session.open(fixture.targets[0], {
signal: controller.signal,
}),
).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [fixture.runtime],
diagnostics: { primary_error: { code: "cancelled" } },
},
});
expect(await fixture.session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: {
run_id: fixture.runIds[0],
process_lineage: { status: "snapshots" },
},
});
fixture.allowCleanup();
expect((await fixture.session.close()).ok).toBe(true);
await expect(access(fixture.runtime)).rejects.toMatchObject({
code: "ENOENT",
});
});
it("preserves caller-selected document retention when retrying cleanup", async () => {
const fixture = await retryFixture();
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
expect(
await fixture.session.close({ retainProviderDocuments: true }),
).toEqual(err(fixture.cleanupError));
fixture.allowCleanup();
expect((await fixture.session.close()).ok).toBe(true);
await expect(access(fixture.runtime)).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(fixture.document, "utf8")).toBe(
"caller-retained document",
);
});
it("honors an explicit retention choice on a cleanup retry", async () => {
const fixture = await retryFixture();
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
expect(await fixture.session.close()).toEqual(err(fixture.cleanupError));
fixture.allowCleanup();
expect(
(await fixture.session.close({ retainProviderDocuments: true })).ok,
).toBe(true);
await expect(access(fixture.runtime)).rejects.toMatchObject({
code: "ENOENT",
});
expect(await readFile(fixture.document, "utf8")).toBe(
"caller-retained document",
);
});
it("keeps failed restoration resources available for a close retry", async () => {
const parent = await createTestTempDirectory("rea-restore-cleanup-retry-");
const runtime = join(parent, "restore-runtime");
await mkdir(runtime);
await writeFile(join(runtime, "observation"), "restore observation");
const [first, second] = await createBinarySessionTargets();
let created = 0;
let allowed = false;
const runIds: string[] = [];
const session = createTestBinarySession((_target, _profile, context) => {
if (context === undefined) throw new Error("missing analysis run context");
runIds.push(context.runId);
const index = ++created;
return {
execute: () =>
Promise.resolve(index === 1 ? ok(null) : err(new HopperStartError())),
close: async () => {
if (index !== 3) return resultOk(null);
if (!allowed)
throw new ProviderCleanupError("fixture", [runtime], {
reason: "restoration cleanup denied",
});
await rm(runtime, { recursive: true, force: true });
return resultOk(null);
},
};
});
onTestFinished(async () => {
allowed = true;
await session.close();
});
expect((await session.open(first)).ok).toBe(true);
expect(await session.open(second)).toMatchObject({
ok: false,
error: { cleanupIncomplete: true, cleanupResources: [runtime] },
});
expect(session.activeTarget()).toBeUndefined();
expect(await session.status()).toMatchObject({
open: false,
cleanup_pending: true,
analysis_run: { run_id: runIds[2] },
});
expect(new Set(runIds).size).toBe(3);
allowed = true;
expect((await session.close()).ok).toBe(true);
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
});
it("does not start a replacement cancelled while pending cleanup completes", async () => {
const parent = await createTestTempDirectory("rea-cancel-cleanup-retry-");
const runtime = join(parent, "owned-runtime");
await mkdir(runtime);
await writeFile(join(runtime, "observation"), "owned observation");
const [first, second] = await createBinarySessionTargets();
const entered = createDeferred<void>();
const release = createDeferred<void>();
let allowed = false;
let created = 0;
const session = createTestBinarySession(() => {
const index = ++created;
return {
execute: () => Promise.resolve(ok(null)),
close: async () => {
if (index !== 1) return resultOk(null);
if (!allowed)
return err(
new ProviderCleanupError("fixture", [runtime], {
reason: "cleanup denied",
}),
);
entered.resolve();
await release.promise;
await rm(runtime, { recursive: true, force: true });
return resultOk(null);
},
};
});
onTestFinished(async () => {
allowed = true;
release.resolve();
await session.close();
});
expect((await session.open(first)).ok).toBe(true);
expect((await session.close()).ok).toBe(false);
allowed = true;
const controller = new AbortController();
const opening = session.open(second, { signal: controller.signal });
await entered.promise;
controller.abort();
release.resolve();
expect(await opening).toMatchObject({
ok: false,
error: { _tag: "AnalysisCancelledError" },
});
expect(created).toBe(1);
await expect(access(runtime)).rejects.toMatchObject({ code: "ENOENT" });
});