Files
reverse-engineering-anything/tests/composition/analysis-sessions/binarySessionCleanupRetry.test.ts
T
morluto b98fbdc442 refactor(ida): let IDA close failures read the session's later close (#10811)
* refactor(errors): read cleanup completion from dependencies instead of pushing it to dependents

AnalysisError.cleanupIncomplete becomes a getter over the error's own unresolved state and its bound dependencies. The WeakRef dependents graph and resolveCleanup's push propagation are removed, and subclasses mark their initial state through markCleanupIncomplete instead of overriding a public mutable field.

* fix(errors): settle bound dependencies before binding more

A composite whose dependencies had released but whose status was never read
stayed incomplete when a later dependency was bound.

* refactor(errors): let errors read a lease group's release instead of resolving them (#10759)

AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error.
The error holds the group as a release claim and reads cleanupReleased when
asked, so completion follows the lease owner without a push.

* chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink (#10763)

* refactor(errors): let errors read a lease group's release instead of resolving them

AnalysisCleanupLeaseGroup no longer calls resolveCleanup() on its error.
The error holds the group as a release claim and reads cleanupReleased when
asked, so completion follows the lease owner without a push.

* chore(verify): count resolveCleanup and bindCleanupDependencies calls so they only shrink

* refactor(process): let worker cleanup errors read their root epoch's release

OwnedWorkerRuntime's root epoch is now an AnalysisCleanupReleaseClaim. Errors bound to it read its release when asked instead of the epoch pushing resolveCleanup() through weak references, and a runtime without a retained root binds an already-released owner. Removes the OwnedWorkerCleanupFailure structural type and four resolveCleanup() pushes; the counted baseline shrinks accordingly.

* refactor(pcap): let pcap cleanup errors read command and snapshot release (#10792)

Retained pcap commands, retained snapshot failures and snapshot completion descriptors now own their release and errors read it: commands and snapshot failures implement AnalysisCleanupReleaseClaim, and a failed completion-descriptor close is retried through an AnalysisCleanupLease bound to its error. Removes four resolveCleanup() pushes; the counted baseline shrinks accordingly.

* refactor(process): let Darwin helper cleanup errors read their owner's release

Darwin helper cache and run-token reader root cleanup now record an AnalysisCleanupRelease that every failure reports; a successful retry confirms it. Replaces the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes two resolveCleanup() and two bindCleanupDependencies() calls; both counted baselines shrink.

* refactor(ghidra): let Ghidra cleanup errors read the client's and checkpoint's release

GhidraClient and GhidraCheckpoint bind each cleanup failure to an AnalysisCleanupRelease and confirm it when a later close or retry releases everything, instead of keeping a set of failures and pushing resolveCleanup() to each. Removes two resolveCleanup() calls; the counted baseline shrinks.

* refactor(process): let debugger session cleanup errors read a confirmed stop

OwnedDebuggerSessionRegistry binds each retained gdb or rizin cleanup failure to the session's AnalysisCleanupRelease and confirms it when a stop is confirmed, keeping the latest failure for retry reporting, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(process): drop the debugger registry's unused closeOperation option

Returning the latest retained failure removed the last reader of closeOperation; remove the option and the values gdb and rizin supplied.

* refactor(server): let environment shutdown errors read a successful close

serveEnvironment binds each shutdown failure to an AnalysisCleanupRelease that a later successful close confirms, instead of pushing resolveCleanup() to a set of failures. The state-file retry test now also requires the earlier report to read the release. Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(desktop): release retained screen-judge cleanup through its lease

Each retained judge or screenshot cleanup owner is wrapped in an AnalysisCleanupLease bound to its failure; close() releases the lease, which records the release the failure reads, instead of closing the owner and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(ida): let IDA close failures read the session's later close

IdaSessionClient's close failure binds an owner that is both its resource owner and its release claim, read from the client's closed state, instead of keeping weak references and pushing resolveCleanup() after a successful retry. Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(javascript): let source-map codec cleanup errors read a later close (#10814)

* refactor(javascript): let source-map codec cleanup errors read a later close

OwnedCodec binds each close failure to an AnalysisCleanupRelease that a later successful close confirms, replacing the first-failure anchor that later failures depended on and the resolveCleanup() push. Removes one resolveCleanup() and one bindCleanupDependencies() call; both counted baselines shrink.

* refactor(cutter): let pending Cutter command errors read the completion receipt (#10815)

* refactor(cutter): let pending Cutter command errors read the completion receipt

Each pending Cutter command keeps an AnalysisCleanupRelease bound to its timeout failure; the producer's terminal completion receipt confirms it instead of pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(application): let provider candidate cleanup errors read the candidate's close (#10817)

* refactor(application): let provider candidate cleanup errors read the candidate's close

AnalysisProviderRegistry keeps one AnalysisCleanupRelease per candidate whose resource close is unconfirmed; failures bind to it and a later confirmed close confirms it, instead of pushing resolveCleanup() to a set of failures. Removes one resolveCleanup() call; the counted baseline shrinks.

* fix(errors): bind a cleanup release claim to an error at most once

AnalysisError kept release claims in an array, so a provider whose retried close returned the same error bound the same claim again on every retry and each cleanupIncomplete read scanned the duplicates. Keep the claims in a set.

* refactor(application): let reverse-tool cleanup errors read a verified stop (#10819)

* refactor(application): let reverse-tool cleanup errors read a verified stop

Each retained provider process group in ReverseEngineeringService keeps an AnalysisCleanupRelease; its command and retry failures bind to it and a later verified stop confirms it, instead of collecting failures and pushing resolveCleanup(). Removes one resolveCleanup() call; the counted baseline shrinks.

* refactor(sqlite): let SQLite worker and root cleanup errors read their release (#10821)

Each retained SQLite snapshot cleanup records an AnalysisCleanupRelease for its worker and for its runtime root; the resource failures bind to them and a verified stop or root removal confirms them, instead of pushing resolveCleanup(). Composite failures keep reading those resource failures. Removes two resolveCleanup() calls; the counted baseline shrinks.

* refactor(application): let JADX and session close failures read their owner's release (#10854)

* refactor(android): let JADX cleanup errors read their retained cleanup's release

Each retained JADX cleanup records an AnalysisCleanupRelease for the admission failure's descriptor owners and one for the whole retained cleanup. The admission failure and every error the cleanup reports bind to them, and a confirmed close confirms them, instead of pushing resolveCleanup() through WeakRefs to reported errors. Removes two resolveCleanup() calls; the counted baseline shrinks.

* refactor(application): let session close failures read the client's later close

A retained session cleanup records an AnalysisCleanupRelease; unowned cleanup failures the client's close reported bind to it and the successful close confirms it, instead of the session collecting them and pushing resolveCleanup(). Failures that already follow their own owner keep reading it. Adds a regression for an unowned close failure. Removes one resolveCleanup() call; the counted baseline shrinks.
2026-11-15 19:53:53 +08:00

556 lines
19 KiB
TypeScript

import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import {
AnalysisCleanupLease,
AnalysisCleanupLeaseGroup,
} from "../../../src/artifacts/AnalysisCleanupLease.js";
import type {
AnalysisClient,
AnalysisProvider,
} from "../../../src/application/AnalysisProvider.js";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.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,
createCacheProvider,
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { awaitAcknowledgement } from "../../fixtures/lifecycleAcknowledgement.js";
const CLOSE_RETRY = { operation: "close_binary" } as const;
const retryFixture = async (
execute: AnalysisClient["execute"] = () => Promise.resolve(ok(null)),
onClose?: () => void,
) => {
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 cleanupLease = new AnalysisCleanupLease(
() => rm(runtime, { recursive: true, force: true }),
[runtime],
);
new AnalysisCleanupLeaseGroup(cleanupError, [cleanupLease]);
const client: AnalysisClient = {
execute,
operationHealthSnapshot: () => ({
state: "unreachable",
stage: "connection",
retryAction: "restart_provider",
exitCode: null,
requests: [],
}),
close: async (options) => {
onClose?.();
if (!allowed) return err(cleanupError);
await cleanupLease.release();
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(
transition === "switch"
? {
ok: false,
error: cleanupError,
targetAtCompletion: null,
providerIdAtCompletion: null,
selection: { kind: "none" },
}
: 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(
transition === "close"
? err(cleanupError)
: {
ok: false,
error: cleanupError,
targetAtCompletion: null,
providerIdAtCompletion: null,
selection: { kind: "none" },
},
);
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("offers a caller's close retry only while the session retains its cleanup", async () => {
const fixture = await retryFixture();
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
// One-shot callers name no retry and therefore receive none.
const oneShot = await fixture.session.close();
if (oneShot.ok) throw new Error("Expected retained cleanup");
expect(projectAnalysisError(oneShot.error)).not.toHaveProperty("recovery");
const offered = await fixture.session.close({ cleanupRetry: CLOSE_RETRY });
if (offered.ok) throw new Error("Expected retained cleanup");
expect(projectAnalysisError(offered.error)).toMatchObject({
code: "cleanup_incomplete",
recovery: { kind: "retry_cleanup", operation: "close_binary" },
details: { resources: [fixture.runtime] },
});
fixture.allowCleanup();
expect((await fixture.session.close({ cleanupRetry: CLOSE_RETRY })).ok).toBe(
true,
);
expect(projectAnalysisError(fixture.cleanupError)).not.toHaveProperty(
"recovery",
);
});
it("offers a close retry after a saved snapshot but not after a failed save", async () => {
const [first] = await createBinarySessionTargets();
const parent = await createTestTempDirectory("rea-snapshot-close-retry-");
const failedSave = join(parent, "failed-save.json");
const savedPath = join(parent, "saved.json");
const cache = createCacheProvider([]);
let allowed = false;
const cleanupError = new ProviderCleanupError("fixture", ["runtime"], {
reason: "runtime removal denied",
});
const provider: AnalysisProvider = {
...cache,
createClient: (...arguments_) => ({
...cache.createClient(...arguments_),
close: async () => (allowed ? resultOk(null) : err(cleanupError)),
}),
};
const session = createTestBinarySession(provider);
onTestFinished(async () => {
allowed = true;
await session.close();
});
expect((await session.open(first)).ok).toBe(true);
// The composite route creates the provider client on first use.
expect((await session.execute("address_name", {})).ok).toBe(true);
// A retained session file owner shows the offer does not follow pending
// cleanup alone: the failed save keeps the target open.
let releaseFile = false;
session.resourceScope.retain(
{
close: async () => {
if (!releaseFile) throw new Error("retained descriptor close denied");
},
cleanupResources: [failedSave],
},
failedSave,
);
const saveFailure = new ProviderCleanupError("rea", [failedSave], {
reason: "snapshot descriptor close denied",
});
const write = vi
.spyOn(session, "writeAnalysisSnapshot")
.mockResolvedValueOnce(err(saveFailure));
onTestFinished(() => write.mockRestore());
expect(
await session.closeWithSnapshot(failedSave, false, {
cleanupRetry: CLOSE_RETRY,
}),
).toEqual(err(saveFailure));
expect(projectAnalysisError(saveFailure)).not.toHaveProperty("recovery");
expect(await session.status()).toMatchObject({
open: true,
cleanup_pending: true,
});
releaseFile = true;
const closed = await session.closeWithSnapshot(savedPath, false, {
cleanupRetry: CLOSE_RETRY,
});
if (closed.ok) throw new Error("Expected retained provider cleanup");
expect(projectAnalysisError(closed.error)).toMatchObject({
code: "cleanup_incomplete",
recovery: { kind: "retry_cleanup", operation: "close_binary" },
details: {
resources: ["runtime"],
partial_observation: { saved_snapshot: { bytes: expect.any(Number) } },
},
});
await access(savedPath);
allowed = true;
expect((await session.close({ cleanupRetry: CLOSE_RETRY })).ok).toBe(true);
expect(await session.status()).toMatchObject({ cleanup_pending: false });
});
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 reason = { stage: "startup health" };
const fixture = await retryFixture(() => {
controller.abort(reason);
return Promise.resolve(ok(null));
});
const result = await fixture.session.open(fixture.targets[0], {
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: {
cleanupIncomplete: true,
cleanupResources: [fixture.runtime],
diagnostics: { primary_error: { code: "cancelled" } },
},
});
if (result.ok) throw new Error("Expected startup cancellation");
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
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",
});
expect(result.error.cleanupIncomplete).toBe(false);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(projectAnalysisError(result.error).code).toBe("cancelled");
});
it("keeps cancellation as the primary when previous-client retirement fails after abort", async () => {
const controller = new AbortController();
const reason = { phase: "binary-retirement" };
const fixture = await retryFixture(undefined, () => controller.abort(reason));
expect((await fixture.session.open(fixture.targets[0])).ok).toBe(true);
const result = await fixture.session.open(fixture.targets[1], {
signal: controller.signal,
});
if (result.ok) throw new Error("Expected cancelled retirement");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary).toMatchObject({
_tag: "AnalysisCancelledError",
});
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(await fixture.session.status()).toMatchObject({
open: false,
cleanup_pending: true,
});
fixture.allowCleanup();
expect((await fixture.session.close()).ok).toBe(true);
expect(fixture.cleanupError.cleanupIncomplete).toBe(false);
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
});
expect(result.error.cleanupPrimary?.cause).toBe(reason);
});
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 awaitAcknowledgement(entered.promise, opening, "session cleanup retry");
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" });
});
it("lets an unowned close failure read the later successful close", async () => {
const [first] = await createBinarySessionTargets();
let allowed = false;
const session = createTestBinarySession(() => ({
execute: () => Promise.resolve(ok(null)),
close: () =>
Promise.resolve(
allowed
? resultOk(null)
: err(
new ProviderCleanupError("fixture", ["owned-runtime"], {
reason: "cleanup denied",
}),
),
),
}));
onTestFinished(async () => {
allowed = true;
await session.close();
});
expect((await session.open(first)).ok).toBe(true);
const failed = await session.close();
if (failed.ok) throw new Error("Expected retained close failure");
expect(failed.error.hasCleanupOwner).toBe(false);
expect(failed.error.cleanupIncomplete).toBe(true);
allowed = true;
expect((await session.close()).ok).toBe(true);
expect(failed.error.cleanupIncomplete).toBe(false);
});