fix(ghidra): retain snapshot outcomes and join reader cleanup

Use the shared artifact resource scope for snapshot reads. Preserve completed facts, exact cancellation reasons and descriptor cleanup leases through session and public error projection; keep parent cleanup pending until all retained resources release.

Validation: 22 selected snapshot lifecycle checks and 85 contract, projection and bounded-reader checks; typecheck, lint and runtime builds. Uses small authored Node/socket fixtures, not real Ghidra or investigated target execution.

Refs #4752, #3977
This commit is contained in:
morluto committed 2026-10-17 18:25:59 +08:00
1 parent 5e13e0a876
commit 73965f9431
10 files changed
+711 -115

No files matched your search

@@ -96,6 +96,7 @@ it("reports only owners whose cleanup failed during a mixed retry", async () =>
};
expect(await scope.release(first)).toMatchObject({ kind: "failed" });
expect(await scope.release(second)).toMatchObject({ kind: "failed" });
expect(scope.cleanupResources).toEqual(["first lease", "second lease"]);
await expect(scope.retryCleanup()).rejects.toMatchObject({
failedOwners: [second],
@@ -103,9 +104,11 @@ it("reports only owners whose cleanup failed during a mixed retry", async () =>
});
expect(firstAttempts).toBe(2);
expect(secondAttempts).toBe(2);
expect(scope.cleanupResources).toEqual(["second lease"]);
await scope.close();
expect(firstAttempts).toBe(2);
expect(secondAttempts).toBe(3);
expect(scope.cleanupResources).toEqual([]);
});
it("retains a failed source analyzer until shutdown can verify its release", async () => {
+15
View File
@@ -88,6 +88,21 @@ export class ArtifactResourceScope {
return this.#pending.size > 0;
}
/** Labels come from the exact retained owners, never from diagnostic text. */
get cleanupResources(): readonly string[] {
return [
...new Set(
[...this.#pending].flatMap((owner) =>
owner.kind === "lease" && owner.lease instanceof AnalysisCleanupLease
? owner.lease.released
? []
: (owner.lease.cleanupResources ?? [owner.resource])
: [owner.resource],
),
),
];
}
/** Admit a resource operation while the scope remains open. */
async run<T>(operation: () => Promise<T>): Promise<T> {
while (true) {
+3
View File
@@ -232,8 +232,11 @@ import type { WebPageInspection } from "./browserObservationSchemas.js";
import type { WebExecution } from "./webExecution.js";
import type { WebEventListeners } from "./webEventListeners.js";
import type { FileSnapshotPartialObservation } from "./fileSnapshotObservation.js";
/** Provider-neutral evidence collected before a typed analysis failure. */
export type AnalysisPartialObservation =
| FileSnapshotPartialObservation
| PartialProcessCaptureObservation
| NativeCallPartialObservation
| FileOffsetPartialObservation
+33
View File
@@ -0,0 +1,33 @@
import { z } from "zod";
const byteCount = z.number().int().nonnegative().safe();
const sha256 = z.string().regex(/^[a-f0-9]{64}$/u);
/** Completed immutable-file facts retained when read finalization fails. */
export const fileSnapshotPartialObservationSchema = z.discriminatedUnion(
"kind",
[
z.strictObject({
kind: z.literal("file-snapshot-hash"),
path: z.string().min(1),
sha256,
bytes: byteCount,
}),
z.strictObject({
kind: z.literal("file-snapshot-read"),
path: z.string().min(1),
admitted_sha256: sha256,
capture: z.discriminatedUnion("status", [
z.strictObject({ status: z.literal("captured"), bytes: byteCount }),
z.strictObject({
status: z.literal("over-capacity"),
source_bytes_at_least: byteCount,
maximum_bytes: byteCount,
}),
]),
}),
],
);
export type FileSnapshotPartialObservation = z.infer<
typeof fileSnapshotPartialObservationSchema
>;
+165 -112
View File
@@ -1,4 +1,7 @@
import { regularFileCancellation } from "../filesystem/OwnedRegularFile.js";
import {
fileSnapshotPartialObservationSchema,
type FileSnapshotPartialObservation,
} from "../domain/fileSnapshotObservation.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import type { Stats } from "node:fs";
import type { FileHandle } from "node:fs/promises";
@@ -61,17 +64,18 @@ import {
import { createGhidraTargetSnapshot } from "./GhidraTargetSnapshot.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { artifactFileCancellationCause } from "../artifacts/ArtifactFileOperation.js";
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
import {
artifactFileCancellationCause,
bindArtifactFileCleanup,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import { readArtifactFileChunks } from "../artifacts/readArtifactFileChunks.js";
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
import {
openRegularFile,
assertRegularFileActive,
RegularFileChangedError,
RegularFileAdmissionFailure,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
import { redactCapturedTransportCredential } from "../process/ProviderDiagnosticRedaction.js";
export type GhidraTargetSnapshot =
@@ -119,7 +123,6 @@ export class GhidraClient {
#socketRoot: PrivateRuntimeRoot | undefined;
#endpointPath: string | undefined;
#snapshotPath: string | undefined;
readonly #snapshotOwners = new Set<OwnedFileHandle>();
readonly #fileResources = new ArtifactResourceScope();
readonly #cleanupFailures = new Set<AnalysisError>();
#targetAdmission: JsonValue | undefined;
@@ -385,28 +388,49 @@ export class GhidraClient {
maximumBytes?: number,
signal?: AbortSignal,
): Promise<Result<GhidraTargetSnapshot, GhidraSessionError>> {
return this.#withTargetSnapshot(async (handle, initial, path) => {
if (
maximumBytes !== undefined &&
(!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
)
throw new RangeError("Snapshot byte limit must be a safe integer");
if (maximumBytes !== undefined && initial.size > maximumBytes)
return {
kind: "over-capacity",
sourceBytesAtLeast: initial.size,
maximumBytes,
};
const bytes = await readBoundedFileBytes(
handle,
maximumBytes ?? initial.size,
signal,
initial.size,
);
if (bytes === undefined || bytes.length !== initial.size)
throw new RegularFileChangedError(path);
return { kind: "captured", bytes };
}, signal);
return this.#withTargetSnapshot<GhidraTargetSnapshot>(
async (handle, initial, path) => {
if (
maximumBytes !== undefined &&
(!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
)
throw new RangeError("Snapshot byte limit must be a safe integer");
if (maximumBytes !== undefined && initial.size > maximumBytes)
return {
kind: "over-capacity",
sourceBytesAtLeast: initial.size,
maximumBytes,
};
const bytes = await readBoundedFileBytes(
handle,
maximumBytes ?? initial.size,
signal,
initial.size,
(reason) =>
new ArtifactReaderFailure("cancelled", "Snapshot read cancelled", {
cause: reason,
}),
);
if (bytes === undefined || bytes.length !== initial.size)
throw new RegularFileChangedError(path);
return { kind: "captured", bytes };
},
(value, path) =>
fileSnapshotPartialObservationSchema.parse({
kind: "file-snapshot-read",
path,
admitted_sha256: this.#options.targetSha256,
capture:
value.kind === "captured"
? { status: "captured", bytes: value.bytes.length }
: {
status: "over-capacity",
source_bytes_at_least: value.sourceBytesAtLeast,
maximum_bytes: value.maximumBytes,
},
}),
signal,
);
}
/** Measure the complete snapshot identity without retaining its bytes in memory. */
@@ -418,87 +442,104 @@ export class GhidraClient {
GhidraSessionError
>
> {
return this.#withTargetSnapshot(async (handle, initial, path) => {
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of readFileHandleChunks(handle, {
end: initial.size,
signal,
})) {
bytes += chunk.length;
if (bytes > initial.size) throw new RegularFileChangedError(path);
hash.update(chunk);
}
if (bytes !== initial.size) throw new RegularFileChangedError(path);
return { sha256: hash.digest("hex"), bytes };
}, signal);
return this.#withTargetSnapshot(
async (handle, initial, path) => {
const hash = createHash("sha256");
let bytes = 0;
for await (const chunk of readArtifactFileChunks(handle, {
end: initial.size,
signal,
})) {
bytes += chunk.length;
if (bytes > initial.size) throw new RegularFileChangedError(path);
hash.update(chunk);
}
if (bytes !== initial.size) throw new RegularFileChangedError(path);
return { sha256: hash.digest("hex"), bytes };
},
(value, path) =>
fileSnapshotPartialObservationSchema.parse({
kind: "file-snapshot-hash",
path,
...value,
}),
signal,
);
}
async #withTargetSnapshot<T>(
read: (handle: FileHandle, initial: Stats, path: string) => Promise<T>,
observe: (value: T, path: string) => FileSnapshotPartialObservation,
signal?: AbortSignal,
): Promise<Result<T, GhidraSessionError>> {
const path = this.#snapshotPath;
if (path === undefined)
return err(
this.#failure(
"protocol",
"Ghidra target snapshot is unavailable before startup or after close",
),
);
let completedObservation: FileSnapshotPartialObservation | undefined;
try {
const handle = await openRegularFile(path, {
symlinks: "reject",
signal,
});
const owner = new OwnedFileHandle(handle);
this.#snapshotOwners.add(owner);
let result: Result<T, unknown>;
try {
const initial = await handle.stat();
assertRegularFileActive(signal);
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError("Snapshot size must be a safe integer");
const value = await read(handle, initial, path);
assertRegularFileActive(signal);
const after = await handle.stat();
assertRegularFileActive(signal);
if (!sameRegularFileState(initial, after))
throw new RegularFileChangedError(path);
result = ok(value);
} catch (cause: unknown) {
result = err(cause);
}
try {
await owner.close();
this.#snapshotOwners.delete(owner);
} catch (closeFailure: unknown) {
throw new AggregateError(
result.ok ? [closeFailure] : [result.error, closeFailure],
"Ghidra target snapshot read or descriptor cleanup failed",
return await this.#fileResources.run(async () => {
const path = this.#snapshotPath;
if (
path === undefined ||
this.#closing ||
this.#closePromise !== undefined
)
return err(
this.#failure(
"protocol",
"Ghidra target snapshot is unavailable before startup or after close",
),
);
return ok(
await withArtifactRegularFile(
path,
this.#fileResources,
signal,
async (handle) => {
const initial = await handle.stat();
assertRegularFileActive(signal);
if (!Number.isSafeInteger(initial.size) || initial.size < 0)
throw new RangeError("Snapshot size must be a safe integer");
const value = await read(handle, initial, path);
assertRegularFileActive(signal);
const after = await handle.stat();
assertRegularFileActive(signal);
if (!sameRegularFileState(initial, after))
throw new RegularFileChangedError(path);
completedObservation = observe(value, path);
return value;
},
),
);
}
if (!result.ok) throw result.error;
return result;
});
} catch (cause: unknown) {
if (cause instanceof RegularFileAdmissionFailure)
this.#snapshotOwners.add(cause.owner);
const primary =
cause instanceof RegularFileAdmissionFailure ? cause.cause : cause;
const cancellation = regularFileCancellation(primary);
return err(
this.#failure(
cancellation !== undefined || signal?.aborted === true
? "cancelled"
: "protocol",
"Ghidra target snapshot could not be read for load-image verification",
cancellation === undefined ||
cause instanceof RegularFileAdmissionFailure
? cause
: cancellation.reason,
cancellation === undefined ? {} : { cancellation },
),
const cancellation =
cause instanceof ArtifactReaderFailure && cause.reason === "cancelled"
? { reason: artifactFileCancellationCause(cause) }
: undefined;
let failure = this.#failure(
cancellation === undefined ? "protocol" : "cancelled",
"Ghidra target snapshot read or finalization did not complete",
cause,
cancellation === undefined ? {} : { cancellation },
);
if (completedObservation !== undefined)
failure = failure.withPartialObservation(completedObservation);
if (
cause instanceof ArtifactReaderFailure &&
cause.cleanup !== undefined
) {
const cleanup = new ProviderCleanupError(
"ghidra",
cause.cleanup.resources,
{ reason: this.#redactAuthentication(cause.cleanup.reason) },
{
operation: "read_target_snapshot",
cause,
partialObservation: completedObservation,
},
);
bindArtifactFileCleanup(cause, cleanup);
failure = failure.withCleanupFailure(cleanup);
}
return err(failure);
}
}
@@ -542,7 +583,8 @@ export class GhidraClient {
}
#cleanupError(cause: unknown): AnalysisError {
if (cause instanceof AnalysisError && cause.cleanupIncomplete) return cause;
if (cause instanceof AnalysisError && cause.cleanupIncomplete)
return this.#retainCleanupFailure(cause);
const failure = new ProviderCleanupError(
"ghidra",
[
@@ -550,9 +592,7 @@ export class GhidraClient {
...(cause instanceof ArtifactReaderFailure
? (cause.cleanup?.resources ?? [])
: []),
...(this.#snapshotOwners.size === 0
? []
: [this.#snapshotPath ?? "Ghidra snapshot descriptor"]),
...this.#fileResources.cleanupResources,
...[this.#runtimeRoot, this.#socketRoot].flatMap((root) =>
root === undefined ? [] : [root.path],
),
@@ -563,8 +603,7 @@ export class GhidraClient {
},
{ cause },
);
this.#cleanupFailures.add(failure);
return failure;
return this.#retainCleanupFailure(failure);
}
async #startupFailure(
@@ -619,6 +658,25 @@ export class GhidraClient {
);
}
#retainCleanupFailure<Failure extends AnalysisError>(
failure: Failure,
): Failure {
const client = this;
failure.bindCleanupResourceOwner({
get cleanupResources() {
return [
...(client.#process === undefined ? [] : ["ghidra-process"]),
...client.#fileResources.cleanupResources,
...[client.#runtimeRoot, client.#socketRoot].flatMap((root) =>
root === undefined ? [] : [root.path],
),
];
},
});
this.#cleanupFailures.add(failure);
return failure;
}
/** Observed failures that prevent a retained session from being reused. */
operationHealthSnapshot(): ProviderOperationHealth | undefined {
const exit = this.#process?.observedExit();
@@ -688,7 +746,6 @@ export class GhidraClient {
this.#socket !== undefined ||
this.#runtimeRoot !== undefined ||
this.#socketRoot !== undefined ||
this.#snapshotOwners.size > 0 ||
this.#fileResources.hasPending
);
}
@@ -998,10 +1055,6 @@ export class GhidraClient {
this.#process = undefined;
this.#launch = undefined;
await this.#fileResources.retryCleanup();
for (const owner of this.#snapshotOwners) {
await owner.close();
this.#snapshotOwners.delete(owner);
}
try {
await this.#removeRuntimeRoots();
} catch (cause: unknown) {
@@ -1060,7 +1113,7 @@ export class GhidraClient {
},
{ cause: firstCause },
);
this.#cleanupFailures.add(failure);
this.#retainCleanupFailure(failure);
throw failure;
}
}
+7 -1
View File
@@ -1103,7 +1103,13 @@ const projectPrimarySessionError = (
startupTimeoutMs: number,
health?: ProviderOperationHealth,
): AnalysisError => {
const context = { cause: failure, diagnostics: failure.diagnostics };
const context = {
cause: failure,
diagnostics: failure.diagnostics,
...(failure.partialObservation === undefined
? {}
: { partialObservation: failure.partialObservation }),
};
const physicalCause = artifactFilePrimaryFailure(failure.cause);
if (physicalCause instanceof AnalysisAccessDeniedError)
return new AnalysisAccessDeniedError(
+12
View File
@@ -134,6 +134,12 @@ it.each([null, undefined, new Error("original cause")])(
remoteCode: "original",
cancellation: { reason: null },
cleanupFailure: cleanup,
partialObservation: {
kind: "file-snapshot-hash",
path: "/fixture/snapshot",
sha256: "a".repeat(64),
bytes: 17,
},
},
);
const refreshed = original.withDiagnostics(
@@ -149,6 +155,12 @@ it.each([null, undefined, new Error("original cause")])(
remoteCode: "original",
cancellation: { reason: null },
diagnostics: { stage: "last" },
partialObservation: {
kind: "file-snapshot-hash",
path: "/fixture/snapshot",
sha256: "a".repeat(64),
bytes: 17,
},
});
expect(refreshed.cleanupFailure).toBe(cleanup);
cleanup.resolveCleanup();
+21 -1
View File
@@ -1,5 +1,8 @@
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisError,
type AnalysisPartialObservation,
} from "../domain/analysisErrorBase.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
@@ -32,6 +35,7 @@ export const GHIDRA_STARTUP_DEADLINE_ELAPSED =
/** Additional structured context for a Ghidra session failure. */
export interface GhidraSessionErrorOptions extends ErrorOptions {
readonly cancellation?: { readonly reason: unknown };
readonly partialObservation?: AnalysisPartialObservation;
readonly timeoutMs?: number;
readonly remoteCode?: string;
readonly cleanupFailure?: AnalysisError;
@@ -44,6 +48,7 @@ export class GhidraSessionError extends Error {
readonly remoteCode: string | undefined;
readonly cleanupFailure: AnalysisError | undefined;
readonly cancellation: { readonly reason: unknown } | undefined;
readonly partialObservation: AnalysisPartialObservation | undefined;
constructor(
readonly kind: GhidraSessionFailureKind,
@@ -58,6 +63,7 @@ export class GhidraSessionError extends Error {
this.remoteCode = options.remoteCode;
this.cleanupFailure = options.cleanupFailure;
this.cancellation = options.cancellation;
this.partialObservation = options.partialObservation;
}
/** Refresh diagnostics without discarding owned failure or cleanup facts. */
@@ -83,10 +89,23 @@ export class GhidraSessionError extends Error {
return this.#copy(diagnostics, combined);
}
/** Retain caller-safe completed facts without serializing snapshot byte buffers. */
withPartialObservation(
observation: AnalysisPartialObservation,
): GhidraSessionError {
return this.#copy(
this.diagnostics,
this.cleanupFailure,
this.message,
observation,
);
}
#copy(
diagnostics: Readonly<Record<string, JsonValue>>,
cleanupFailure: AnalysisError | undefined,
message = this.message,
partialObservation = this.partialObservation,
): GhidraSessionError {
return new GhidraSessionError(this.kind, message, diagnostics, {
...(Object.hasOwn(this, "cause") ? { cause: this.cause } : {}),
@@ -96,6 +115,7 @@ export class GhidraSessionError extends Error {
...(this.timeoutMs === undefined ? {} : { timeoutMs: this.timeoutMs }),
...(this.remoteCode === undefined ? {} : { remoteCode: this.remoteCode }),
...(cleanupFailure === undefined ? {} : { cleanupFailure }),
...(partialObservation === undefined ? {} : { partialObservation }),
});
}
}
@@ -1,3 +1,16 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { AnalysisCleanupLease } from "../../../src/artifacts/AnalysisCleanupLease.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { fileSnapshotPartialObservationSchema } from "../../../src/domain/fileSnapshotObservation.js";
import { resolvedAnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { GhidraSessionError } from "../../../src/ghidra/GhidraSessionError.js";
import { err } from "../../../src/domain/result.js";
import {
nativeAotPeFixture,
nativeAotPeDigest,
} from "../../fixtures/nativeaotPe.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
import { expect, it } from "vitest";
@@ -80,3 +93,90 @@ it("preserves queue failure diagnostics through the provider, CLI adapter and SD
await harness.close();
}
}, 30_000);
it.each(["cancelled", "protocol"] as const)(
"retains completed snapshot facts and exact cleanup over provider/CLI/MCP after %s",
async (kind) => {
const directory = await createTestTempDirectory(
"rea-ghidra-snapshot-projection-",
);
const path = join(directory, "Inert.exe");
const bytes = nativeAotPeFixture();
await writeFile(path, bytes);
const partialObservation = fileSnapshotPartialObservationSchema.parse({
kind: "file-snapshot-hash",
path,
sha256: nativeAotPeDigest(bytes),
bytes: bytes.length,
});
const cleanup = new ProviderCleanupError("ghidra", [path], {
reason: "controlled snapshot descriptor remains",
});
const lease = new AnalysisCleanupLease(async () => {}, [path]);
lease.bindCleanupError(cleanup);
const reason = { caller: "snapshot-finalization" };
const failure = new GhidraSessionError(
kind,
"Controlled snapshot finalization failure",
{},
{
cause:
kind === "cancelled"
? reason
: new Error("controlled physical read failure"),
...(kind === "cancelled" ? { cancellation: { reason } } : {}),
partialObservation,
},
).withCleanupFailure(cleanup);
const harness = await connectGhidraMcp(
"ghidra-snapshot-finalization",
async () => err(failure),
{ targetPath: path },
);
const previousExitCode = process.exitCode;
try {
const providerResult = await harness.session.execute(
"list_procedures",
{},
);
expect(providerResult.ok).toBe(false);
if (providerResult.ok)
throw new Error("Expected retained snapshot cleanup");
expect(providerResult.error.partialObservation).toEqual(
partialObservation,
);
expect(providerResult.error.cleanupIncomplete).toBe(true);
const cli = analysisErrorProjectionSchema.parse(
await logCliCommand(silentLogger, "inspect", async () => {
throw providerResult.error;
}),
);
expect(cli).toMatchObject({
code: "cleanup_incomplete",
details: {
resources: [path],
partial_observation: partialObservation,
diagnostics: {
primary_error: {
code: kind === "cancelled" ? "cancelled" : "execution_failure",
},
},
},
});
const result = await harness.mcp.callTool({
name: "list_procedures",
arguments: {},
});
expect(parseMcpToolError(result)).toMatchObject({ error: cli });
await lease.release();
expect(providerResult.error.cleanupIncomplete).toBe(false);
expect(providerResult.error.cleanupResources).toEqual([]);
if (kind === "cancelled")
expect(resolvedAnalysisError(providerResult.error).cause).toBe(reason);
} finally {
process.exitCode = previousExitCode;
await lease.release();
await harness.close();
}
},
);
@@ -1,9 +1,17 @@
import { artifactFilePrimaryFailure } from "../../../../src/artifacts/ArtifactFileOperation.js";
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
import { spawn, type ChildProcess } from "node:child_process";
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import { readFileSync } from "node:fs";
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
import {
access,
chmod,
mkdir,
writeFile,
open,
type FileHandle,
} from "node:fs/promises";
import { basename, join } from "node:path";
import { PassThrough } from "node:stream";
@@ -232,6 +240,349 @@ it("keeps one shared startup despite a failed progress sink and stops reports af
expect(reports).toBe(1);
});
it("reports snapshot descriptor cleanup immediately and resolves its exact error on retry", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
let allowClose = false;
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
return allowClose
? original.call(this)
: Promise.reject(new Error("controlled snapshot close failure"));
});
onTestFinished(async () => {
allowClose = true;
close.mockRestore();
await client.close();
});
const result = await client.hashTargetSnapshot();
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected snapshot final close failure");
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect(result.error.partialObservation).toMatchObject({
sha256: TARGET_SHA256,
bytes: readFileSync(fixturePath).length,
});
const blocked = await client.hashTargetSnapshot();
expect(blocked.ok).toBe(false);
if (blocked.ok)
throw new Error("Expected cleanup to block snapshot admission");
expect(blocked.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect(new Set(close.mock.contexts).size).toBe(1);
const retry = await client.close();
expect(retry.ok).toBe(false);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
expect(result.error.cleanupFailure?.cleanupResources).toEqual([]);
expect(blocked.error.cleanupFailure?.cleanupIncomplete).toBe(false);
if (!retry.ok) expect(retry.error.cleanupIncomplete).toBe(false);
});
it("preserves completed snapshot hashing and exact cancellation during descriptor close", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const controller = new AbortController();
const reason = { phase: "snapshot-final-close" };
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async function (this: OwnedFileHandle) {
await original.call(this);
controller.abort(reason);
});
onTestFinished(() => close.mockRestore());
const result = await client.hashTargetSnapshot(controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected final-close cancellation");
expect(result.error.kind).toBe("cancelled");
expect(result.error.cancellation?.reason).toBe(reason);
expect(result.error).toMatchObject({
partialObservation: {
sha256: TARGET_SHA256,
bytes: readFileSync(fixturePath).length,
},
});
});
it.each([undefined, 0])(
"retains completed snapshot capture facts through failed close (limit=%s)",
async (limit) => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockRejectedValueOnce(new Error("controlled capture close failure"));
onTestFinished(async () => {
close.mockRestore();
await client.close();
});
const result = await client.readTargetSnapshot(limit);
if (result.ok) throw new Error("Expected capture cleanup failure");
expect(result.error.partialObservation).toEqual({
kind: "file-snapshot-read",
path: expect.any(String),
admitted_sha256: TARGET_SHA256,
capture:
limit === undefined
? { status: "captured", bytes: readFileSync(fixturePath).length }
: {
status: "over-capacity",
source_bytes_at_least: readFileSync(fixturePath).length,
maximum_bytes: limit,
},
});
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
},
);
it("joins a snapshot read before closing its descriptor and runtime", async () => {
const launcher = new FixtureLauncher();
const client = clientFor(launcher);
const started = await client.start();
if (!started.ok) throw started.error;
const child = launcher.processes[0];
if (child === undefined) throw new Error("Missing bounded fixture child");
const childClosed = new Promise<void>((resolve) =>
child.once("close", () => resolve()),
);
const probe = await open(fixturePath, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
let markRead!: () => void, resume!: () => void;
const reading = new Promise<void>((resolve) => {
markRead = resolve;
});
const gate = new Promise<void>((resolve) => {
resume = resolve;
});
let captured: FileHandle | undefined;
const original = prototype.read;
const read = vi
.spyOn(prototype, "read")
.mockImplementationOnce(async function (
this: FileHandle,
...args: Parameters<FileHandle["read"]>
) {
captured = this;
markRead();
await gate;
return Reflect.apply(original, this, args);
});
let pending: ReturnType<GhidraClient["hashTargetSnapshot"]> | undefined;
onTestFinished(async () => {
resume();
await pending;
read.mockRestore();
await client.close();
});
pending = client.hashTargetSnapshot();
await reading;
const closing = client.close();
await childClosed;
// Cross the event-loop phase after the owned child closed, rather than
// guessing shutdown progress from an elapsed dispatch delay.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(captured?.fd).toBeGreaterThanOrEqual(0);
const root = launcher.runtimeRoots[0];
if (root === undefined) throw new Error("Missing runtime root");
await expect(access(root)).resolves.toBeUndefined();
resume();
expect((await pending).ok).toBe(true);
expect((await closing).ok).toBe(true);
expect(captured?.fd).toBe(-1);
await expect(access(root)).rejects.toMatchObject({ code: "ENOENT" });
});
it("preserves snapshot cancellation and completed hash while final close also fails", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const controller = new AbortController();
const reason = { phase: "snapshot-cancel-and-failed-close" };
let allowClose = false;
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
if (allowClose) return original.call(this);
controller.abort(reason);
return Promise.reject(new Error("controlled cancellation close failure"));
});
onTestFinished(async () => {
allowClose = true;
await client.close();
close.mockRestore();
});
const result = await client.hashTargetSnapshot(controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected cancellation and cleanup failure");
expect(result.error.kind).toBe("cancelled");
expect(result.error.cancellation?.reason).toBe(reason);
expect(result.error.partialObservation).toMatchObject({
sha256: TARGET_SHA256,
bytes: readFileSync(fixturePath).length,
});
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
});
it.each(
(["hash", "capture"] as const).flatMap((operation) =>
[null, "caller stopped", new Error("caller stopped")].flatMap((reason) =>
[false, true].map((failClose) => ({ operation, reason, failClose })),
),
),
)(
"preserves active snapshot cancellation: $operation, $reason, failed close=$failClose",
async ({ operation, reason, failClose }) => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const controller = new AbortController();
const probe = await open(fixturePath, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const originalRead = prototype.read;
const read = vi
.spyOn(prototype, "read")
.mockImplementationOnce(async function (
this: FileHandle,
...args: Parameters<FileHandle["read"]>
) {
// Fulfill a real short read, then abort before the next checkpoint.
const result = await Reflect.apply(originalRead, this, [
args[0],
0,
1,
null,
]);
controller.abort(reason);
return result;
});
let allowClose = !failClose;
const originalClose = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
return allowClose
? originalClose.call(this)
: Promise.reject(new Error("controlled active-read close failure"));
});
onTestFinished(async () => {
allowClose = true;
read.mockRestore();
await client.close();
close.mockRestore();
});
const result =
operation === "hash"
? await client.hashTargetSnapshot(controller.signal)
: await client.readTargetSnapshot(undefined, controller.signal);
if (result.ok) throw new Error("Expected active read cancellation");
expect(result.error.kind).toBe("cancelled");
expect(result.error.cancellation?.reason).toBe(reason);
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe(
failClose,
);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe(false);
},
);
it("keeps a snapshot read failure primary across late cancellation and failed close", async () => {
const client = clientFor(new FixtureLauncher());
const started = await client.start();
if (!started.ok) throw started.error;
const controller = new AbortController();
const physical = new Error("controlled snapshot physical read failure");
const probe = await open(fixturePath, "r");
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const read = vi.spyOn(prototype, "read").mockRejectedValueOnce(physical);
let allowClose = false;
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
if (allowClose) return original.call(this);
controller.abort({ phase: "late-snapshot-close" });
return Promise.reject(
new Error("controlled close failure after read failure"),
);
});
onTestFinished(async () => {
allowClose = true;
read.mockRestore();
await client.close();
close.mockRestore();
});
const result = await client.hashTargetSnapshot(controller.signal);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected read and cleanup failure");
expect(result.error.kind).toBe("protocol");
expect(result.error.cancellation).toBeUndefined();
expect(artifactFilePrimaryFailure(result.error.cause)).toBe(physical);
expect(result.error.partialObservation).toBeUndefined();
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
allowClose = true;
expect((await client.close()).ok).toBe(true);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
});
it("keeps runtime cleanup pending after a snapshot descriptor releases and shrinks live resources", async () => {
const launcher = new FixtureLauncher();
const client = clientFor(launcher);
const started = await client.start();
if (!started.ok) throw started.error;
let allowClose = false;
const original = OwnedFileHandle.prototype.close;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(function (this: OwnedFileHandle) {
return allowClose
? original.call(this)
: Promise.reject(new Error("controlled snapshot close failure"));
});
let restoreRoot: (() => void) | undefined;
onTestFinished(async () => {
allowClose = true;
close.mockRestore();
restoreRoot?.();
await client.close();
});
const result = await client.hashTargetSnapshot();
if (result.ok) throw new Error("Expected file cleanup failure");
const first = await client.close();
if (first.ok) throw new Error("Expected pending descriptor cleanup");
allowClose = true;
const rootClose = vi
.spyOn(PrivateRuntimeRoot.prototype, "close")
.mockRejectedValueOnce(new Error("controlled root removal failure"));
restoreRoot = () => rootClose.mockRestore();
const second = await client.close();
if (second.ok) throw new Error("Expected pending root cleanup");
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
expect(result.error.cleanupFailure?.cleanupResources).toEqual([]);
expect(first.error.cleanupIncomplete).toBe(true);
expect(first.error.cleanupResources).toEqual([launcher.runtimeRoots[0]]);
expect(second.error.cleanupResources).toEqual([launcher.runtimeRoots[0]]);
expect((await client.close()).ok).toBe(true);
expect(first.error.cleanupIncomplete).toBe(false);
expect(second.error.cleanupIncomplete).toBe(false);
});
describe("GhidraClient captured source snapshot", () => {
it("retains a snapshot descriptor whose close failed until session cleanup succeeds", async () => {
const client = clientFor(new FixtureLauncher());