mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(ghidra): preserve launch outcomes through cancellation and rollback
Carry producer cancellation separately from physical launch errors and diagnostic causes. Retain exact opaque reasons, LaunchSupport warnings, and rollback owners; consume the authored outcome without reclassifying failed launches from a later deadline. Validation: focused client/provider/rollback and helper/MCP tests, check:fast, docs:check. Controlled native process ownership and Node bridge only; real Ghidra/JVM and Windows/Linux fault behavior unverified. Refs #4517
This commit is contained in:
1 parent
e31aa72c9d
commit
1e36e707b1
7 files changed
+385
-58
No files matched your search
@@ -925,6 +925,10 @@ ownership inspection, so retaining those wrappers would prevent verified
|
||||
process-group cancellation during startup.
|
||||
If ownership remains unverifiable, it reports the reason and retains the process
|
||||
supervisor and private runtime instead of removing files beneath a live provider.
|
||||
Startup preserves an observed launch failure when cancellation arrives during
|
||||
rollback. Producer cancellation retains the caller's exact reason separately
|
||||
from LaunchSupport warnings and cleanup failures. Failed rollback keeps the
|
||||
original process owner for a later cleanup retry.
|
||||
The experimental Windows transport uses authenticated IPv4 loopback with a
|
||||
private native-owned bearer descriptor and Job Object process ownership.
|
||||
|
||||
|
||||
+23
-11
@@ -792,18 +792,19 @@ export class GhidraClient {
|
||||
this.#startupOutputCheckpoint = this.#process.checkpointOutput();
|
||||
}
|
||||
const producerFailure =
|
||||
launched.error instanceof GhidraLaunchError
|
||||
? launched.error.primaryFailure
|
||||
: undefined;
|
||||
const interrupted =
|
||||
launched.error instanceof AnalysisCancelledError ||
|
||||
producerFailure instanceof AnalysisCancelledError ||
|
||||
(producerFailure === undefined && deadline.signal.aborted);
|
||||
const failure = interrupted
|
||||
? this.#interruptionFailure(deadline, launched.error)
|
||||
: launched.error instanceof GhidraSessionError
|
||||
launched.error instanceof AnalysisCancelledError
|
||||
? launched.error
|
||||
: this.#failure("start", launched.error.message, launched.error);
|
||||
: launched.error instanceof GhidraLaunchError
|
||||
? launched.error.primaryFailure
|
||||
: undefined;
|
||||
const failure =
|
||||
producerFailure instanceof AnalysisCancelledError
|
||||
? this.#interruptionFailure(deadline, launched.error, {
|
||||
reason: producerFailure.cause,
|
||||
})
|
||||
: launched.error instanceof GhidraSessionError
|
||||
? launched.error
|
||||
: this.#failure("start", launched.error.message, launched.error);
|
||||
return this.#startupFailure(failure, deadline);
|
||||
}
|
||||
this.#launch = launched.value;
|
||||
@@ -1122,7 +1123,18 @@ export class GhidraClient {
|
||||
#interruptionFailure(
|
||||
deadline: ProviderStartupDeadline,
|
||||
cause?: unknown,
|
||||
cancellation?: { readonly reason: unknown },
|
||||
): GhidraSessionError {
|
||||
if (
|
||||
cancellation !== undefined &&
|
||||
!(
|
||||
deadline.signal.aborted &&
|
||||
cancellation.reason === deadline.signal.reason
|
||||
)
|
||||
)
|
||||
return this.#failure("cancelled", "Ghidra startup was cancelled", cause, {
|
||||
cancellation,
|
||||
});
|
||||
if (deadline.signal.reason === STARTUP_OWNER_CLOSED)
|
||||
return this.#failure(
|
||||
"process",
|
||||
|
||||
@@ -39,7 +39,10 @@ import {
|
||||
spawnOwnedProviderProcess,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { ghidraJavaEnvironment } from "./GhidraInstallation.js";
|
||||
import { ghidraJavaLaunch } from "./GhidraJavaLaunch.js";
|
||||
import {
|
||||
ghidraJavaLaunch,
|
||||
GhidraLaunchSupportError,
|
||||
} from "./GhidraJavaLaunch.js";
|
||||
import type { GhidraTransportKind } from "./GhidraTransport.js";
|
||||
import {
|
||||
snapshotGhidraExtensions,
|
||||
@@ -117,6 +120,7 @@ export class GhidraLaunchError extends Error {
|
||||
readonly partialLaunch?: GhidraLaunch;
|
||||
readonly rollbackFailure?: GhidraLaunchRollbackFailure;
|
||||
readonly cleanupFailure?: AnalysisError;
|
||||
readonly primaryFailure?: AnalysisError;
|
||||
readonly capturedOutput?: AnalysisCapturedOutput;
|
||||
readonly launchSupport?: readonly GhidraLaunchSupportDiagnostic[];
|
||||
},
|
||||
@@ -127,6 +131,7 @@ export class GhidraLaunchError extends Error {
|
||||
launchSupport,
|
||||
rollbackFailure,
|
||||
cleanupFailure,
|
||||
primaryFailure,
|
||||
...errorOptions
|
||||
} = options ?? {};
|
||||
super(message, errorOptions);
|
||||
@@ -134,11 +139,12 @@ export class GhidraLaunchError extends Error {
|
||||
this.#partialLaunch = partialLaunch;
|
||||
this.rollbackFailure = rollbackFailure;
|
||||
this.primaryFailure =
|
||||
errorOptions.cause instanceof AnalysisError
|
||||
primaryFailure ??
|
||||
(errorOptions.cause instanceof AnalysisError
|
||||
? errorOptions.cause
|
||||
: errorOptions.cause instanceof GhidraLaunchError
|
||||
? errorOptions.cause.primaryFailure
|
||||
: undefined;
|
||||
: undefined);
|
||||
const inherited =
|
||||
errorOptions.cause instanceof DarwinProcessOwnershipInspectionError ||
|
||||
errorOptions.cause instanceof GhidraLaunchError
|
||||
@@ -350,7 +356,11 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
platform,
|
||||
);
|
||||
if (isAborted(options.signal))
|
||||
return err(new AnalysisCancelledError("open_binary"));
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", {
|
||||
cause: options.signal?.reason,
|
||||
}),
|
||||
);
|
||||
const headlessArguments = ghidraHeadlessArguments({
|
||||
platform,
|
||||
...(psp === undefined ? {} : { psp: true as const }),
|
||||
@@ -457,6 +467,12 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
env: command.environment,
|
||||
hostEnvironment: {},
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
}).catch((cause: unknown) => {
|
||||
// The spawn boundary throws the exact opaque reason at preparation
|
||||
// checkpoints. Wrap it before interpreting any typed provider facts.
|
||||
if (options.signal?.aborted === true && cause === options.signal.reason)
|
||||
throw new AnalysisCancelledError("open_binary", { cause });
|
||||
throw cause;
|
||||
});
|
||||
const partialLaunch = ownedGhidraLaunch(
|
||||
started,
|
||||
@@ -483,41 +499,35 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
platform,
|
||||
);
|
||||
if (isAborted(options.signal)) {
|
||||
throw new AnalysisCancelledError("open_binary");
|
||||
throw new AnalysisCancelledError("open_binary", {
|
||||
cause: options.signal?.reason,
|
||||
});
|
||||
}
|
||||
return ok(partialLaunch);
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisCancelledError && started === undefined)
|
||||
return err(cause);
|
||||
const cancellation =
|
||||
cause instanceof AnalysisCancelledError
|
||||
? cause
|
||||
: (cause instanceof GhidraLaunchSupportError ||
|
||||
cause instanceof DarwinProcessOwnershipInspectionError) &&
|
||||
cause.cancellation !== undefined
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: cause.cancellation.reason,
|
||||
})
|
||||
: undefined;
|
||||
const capturedOutput = ghidraLaunchCommandOutput(cause);
|
||||
const launchFailure = new GhidraLaunchError(
|
||||
"Ghidra headless launch failed",
|
||||
{
|
||||
cause,
|
||||
...(cancellation === undefined
|
||||
? {}
|
||||
: { primaryFailure: cancellation }),
|
||||
launchSupport,
|
||||
...(capturedOutput === undefined ? {} : { capturedOutput }),
|
||||
},
|
||||
);
|
||||
const primary =
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
(!(cause instanceof AnalysisError) && isAborted(options.signal))
|
||||
? new AnalysisCancelledError("open_binary", {
|
||||
cause: launchFailure,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
: {
|
||||
cleanup: {
|
||||
reason: launchFailure.cleanupFailure.message,
|
||||
resources: launchFailure.cleanupFailure.cleanupResources,
|
||||
},
|
||||
}),
|
||||
}).bindCleanupDependencies(
|
||||
launchFailure.cleanupFailure === undefined
|
||||
? []
|
||||
: [launchFailure.cleanupFailure],
|
||||
)
|
||||
: launchFailure;
|
||||
if (started === undefined) return err(primary);
|
||||
if (started === undefined) return err(launchFailure);
|
||||
|
||||
const cleanupOwner = started;
|
||||
const rollback = await Promise.resolve()
|
||||
@@ -536,24 +546,28 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
launchSupport,
|
||||
configuredMaxHeap,
|
||||
);
|
||||
const primaryMessage = isAborted(options.signal)
|
||||
? primary.message
|
||||
: `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
const primaryMessage =
|
||||
cancellation !== undefined
|
||||
? cancellation.message
|
||||
: `Ghidra headless launch failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
return err(
|
||||
new GhidraLaunchError(
|
||||
`${primaryMessage}; process cleanup remains incomplete`,
|
||||
{
|
||||
cause: new AggregateError(
|
||||
[
|
||||
primary,
|
||||
launchFailure,
|
||||
rollback.error.kind === "rejected"
|
||||
? rollback.error.cause
|
||||
: rollback.error.result,
|
||||
],
|
||||
"Ghidra launch rollback did not release its process",
|
||||
{ cause: primary },
|
||||
{ cause: launchFailure },
|
||||
),
|
||||
partialLaunch,
|
||||
...(launchFailure.primaryFailure === undefined
|
||||
? {}
|
||||
: { primaryFailure: launchFailure.primaryFailure }),
|
||||
rollbackFailure: rollback.error,
|
||||
...(launchFailure.cleanupFailure === undefined
|
||||
? {}
|
||||
@@ -564,7 +578,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
|
||||
),
|
||||
);
|
||||
}
|
||||
return err(primary);
|
||||
return err(launchFailure);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -1766,3 +1767,74 @@ it("redacts the transport credential when refreshing startup diagnostics", async
|
||||
expect(result.error.message).not.toContain(token);
|
||||
expect(JSON.stringify(result.error.diagnostics)).not.toContain(token);
|
||||
});
|
||||
|
||||
it("preserves a failed launcher result when the final caller aborts before consumption", async () => {
|
||||
const controller = new AbortController();
|
||||
const physical = new GhidraLaunchError("controlled earlier launch failure", {
|
||||
cause: new Error("physical failure"),
|
||||
});
|
||||
const client = clientFor({
|
||||
launch: async (_session, options) => {
|
||||
controller.abort(null);
|
||||
if (options?.signal?.aborted !== true)
|
||||
await new Promise<void>((resolve) =>
|
||||
options?.signal?.addEventListener("abort", () => resolve(), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
return err(physical);
|
||||
},
|
||||
});
|
||||
const result = await client.start(controller.signal);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "start", cause: physical },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, "helper owner stopped"])(
|
||||
"consumes a producer cancellation without an aborted client deadline %j",
|
||||
async (reason) => {
|
||||
const cancellation = new AnalysisCancelledError("open_binary", {
|
||||
cause: reason,
|
||||
});
|
||||
const client = clientFor({
|
||||
launch: async () =>
|
||||
err(
|
||||
new GhidraLaunchError("producer helper cancelled", {
|
||||
primaryFailure: cancellation,
|
||||
}),
|
||||
),
|
||||
});
|
||||
const result = await client.start();
|
||||
expect(result).toMatchObject({ ok: false, error: { kind: "cancelled" } });
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error.cancellation?.reason).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a producer failure while its result waits for the startup deadline", async () => {
|
||||
const physical = new GhidraLaunchError(
|
||||
"controlled physical failure before finalization",
|
||||
);
|
||||
const client = clientFor(
|
||||
{
|
||||
launch: async (_session, options) => {
|
||||
if (options?.signal === undefined)
|
||||
throw new Error("Expected owned startup signal");
|
||||
if (!options.signal.aborted)
|
||||
await new Promise<void>((resolve) =>
|
||||
options.signal?.addEventListener("abort", () => resolve(), {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
return err(physical);
|
||||
},
|
||||
},
|
||||
{ startupTimeoutMs: 500 },
|
||||
);
|
||||
expect(await client.start()).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "start", cause: physical },
|
||||
});
|
||||
});
|
||||
@@ -488,18 +488,16 @@ describe.skipIf(process.platform === "win32")(
|
||||
details: {
|
||||
diagnostics: {
|
||||
failure_cause: {
|
||||
cause: {
|
||||
launch_support: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mode: "-envvars",
|
||||
stderr: `-envvars: ${warning}\n`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: "-vmargs",
|
||||
stderr: `-vmargs: ${warning}\n`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
launch_support: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
mode: "-envvars",
|
||||
stderr: `-envvars: ${warning}\n`,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
mode: "-vmargs",
|
||||
stderr: `-vmargs: ${warning}\n`,
|
||||
}),
|
||||
]),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
|
||||
import {
|
||||
GhidraHeadlessLauncher,
|
||||
GhidraLaunchError,
|
||||
} from "../../../../src/ghidra/GhidraLauncher.js";
|
||||
import { GhidraLaunchSupportError } from "../../../../src/ghidra/GhidraJavaLaunch.js";
|
||||
import { ExecFileOutputCancelledError } from "../../../../src/process/ExecFileOutput.js";
|
||||
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const launch = async (
|
||||
javaLaunch: NonNullable<
|
||||
ConstructorParameters<typeof GhidraHeadlessLauncher>[0]["javaLaunch"]
|
||||
>,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-launch-outcome-");
|
||||
const spawnProcess = vi.fn(async () => {
|
||||
throw new Error("Unexpected spawn after Java failure");
|
||||
});
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
platform: "linux",
|
||||
analyzeHeadlessPath: join(root, "support", "analyzeHeadless"),
|
||||
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
||||
javaHome: join(root, "jdk"),
|
||||
bridgeScriptPath: join(root, "bridge.java"),
|
||||
javaLaunch,
|
||||
spawnProcess,
|
||||
});
|
||||
const result = await launcher.launch(
|
||||
{
|
||||
runtimeRoot: root,
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(root, "bridge.sock"),
|
||||
token: "fixture-token",
|
||||
runId: "fixture-launch-outcome",
|
||||
targetPath: join(root, "unused-target"),
|
||||
targetSha256: "a".repeat(64),
|
||||
providerVersion: "test",
|
||||
profileDigest: "b".repeat(64),
|
||||
},
|
||||
{ signal },
|
||||
);
|
||||
expect(spawnProcess).not.toHaveBeenCalled();
|
||||
return result;
|
||||
};
|
||||
|
||||
it.each(["physical", "support"] as const)(
|
||||
"preserves %s Java launch failure when cancellation arrives before catch",
|
||||
async (kind) => {
|
||||
const controller = new AbortController();
|
||||
const physical = new Error("controlled earlier physical failure");
|
||||
const failure =
|
||||
kind === "support"
|
||||
? new GhidraLaunchSupportError("-envvars", physical)
|
||||
: physical;
|
||||
const result = await launch(async () => {
|
||||
controller.abort(null);
|
||||
throw failure;
|
||||
}, controller.signal);
|
||||
if (result.ok) throw new Error("Expected launch failure");
|
||||
expect(result.error).toBeInstanceOf(GhidraLaunchError);
|
||||
expect(result.error.cause).toBe(failure);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([null, "caller stopped helper", new Error("opaque caller reason")])(
|
||||
"preserves the LaunchSupport producer's cancellation reason %j",
|
||||
async (reason) => {
|
||||
const result = await launch(async () => {
|
||||
throw new GhidraLaunchSupportError(
|
||||
"-envvars",
|
||||
new ExecFileOutputCancelledError(reason),
|
||||
);
|
||||
}, new AbortController().signal);
|
||||
if (result.ok) throw new Error("Expected helper cancellation");
|
||||
const primary =
|
||||
result.error instanceof GhidraLaunchError
|
||||
? result.error.primaryFailure
|
||||
: result.error;
|
||||
expect(primary).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(primary?.cause).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it("does not adopt a caller error's cleanup resources at initial cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new ProviderCleanupError("foreign", ["caller-resource"], {
|
||||
reason: "caller-owned",
|
||||
});
|
||||
controller.abort(reason);
|
||||
const result = await launch(async () => {
|
||||
throw new Error("Unexpected Java launch");
|
||||
}, controller.signal);
|
||||
if (result.ok) throw new Error("Expected cancellation");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
cleanupIncomplete: false,
|
||||
});
|
||||
expect(reason.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps an opaque typed caller reason at the real spawn preparation checkpoint", async () => {
|
||||
const root = await createTestTempDirectory("rea-ghidra-preparation-cancel-");
|
||||
const controller = new AbortController();
|
||||
const reason = new ProviderCleanupError("foreign", ["foreign-resource"], {
|
||||
reason: "caller-owned",
|
||||
});
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
environment: {},
|
||||
platform: process.platform,
|
||||
analyzeHeadlessPath: join(root, "support", "analyzeHeadless"),
|
||||
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
||||
javaHome: join(root, "jdk"),
|
||||
bridgeScriptPath: join(root, "bridge.java"),
|
||||
javaLaunch: async () => {
|
||||
controller.abort(reason);
|
||||
return {
|
||||
command: join(root, "must-not-spawn"),
|
||||
arguments: [],
|
||||
environment: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
const result = await launcher.launch(
|
||||
{
|
||||
runtimeRoot: root,
|
||||
transport: "unix-socket",
|
||||
endpointPath: join(root, "bridge.sock"),
|
||||
token: "fixture-token",
|
||||
runId: "fixture-preparation-cancel",
|
||||
targetPath: join(root, "unused-target"),
|
||||
targetSha256: "a".repeat(64),
|
||||
providerVersion: "test",
|
||||
profileDigest: "b".repeat(64),
|
||||
},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
primaryFailure: {
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
cleanupIncomplete: false,
|
||||
},
|
||||
cleanupFailure: undefined,
|
||||
},
|
||||
});
|
||||
expect(reason.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const fault = vi.hoisted(() => ({
|
||||
failure: new Error("controlled manifest failure"),
|
||||
failManifest: true,
|
||||
}));
|
||||
vi.mock("write-file-atomic", async (importOriginal) => {
|
||||
const actual = await importOriginal<{
|
||||
@@ -12,7 +13,11 @@ vi.mock("write-file-atomic", async (importOriginal) => {
|
||||
}>();
|
||||
return {
|
||||
default: async (...args: Parameters<typeof actual.default>) => {
|
||||
if (typeof args[0] === "string" && args[0].endsWith("/ownership.json"))
|
||||
if (
|
||||
fault.failManifest &&
|
||||
typeof args[0] === "string" &&
|
||||
args[0].endsWith("/ownership.json")
|
||||
)
|
||||
throw fault.failure;
|
||||
return actual.default(...args);
|
||||
},
|
||||
@@ -25,6 +30,7 @@ import {
|
||||
type GhidraLaunchSession,
|
||||
} from "../../src/ghidra/GhidraLauncher.js";
|
||||
import { bindGhidraSessionFailure } from "../../src/ghidra/GhidraSessionError.js";
|
||||
import { AnalysisCancelledError } from "../../src/domain/analysisErrorCore.js";
|
||||
import { GhidraClient } from "../../src/ghidra/GhidraClient.js";
|
||||
import {
|
||||
spawnOwnedProviderProcess,
|
||||
@@ -42,6 +48,7 @@ const clients: GhidraClient[] = [];
|
||||
let denyCleanup = true;
|
||||
beforeEach(() => {
|
||||
denyCleanup = true;
|
||||
fault.failManifest = true;
|
||||
});
|
||||
afterEach(async () => {
|
||||
denyCleanup = false;
|
||||
@@ -64,7 +71,10 @@ const incomplete: ProcessCleanupFailure = {
|
||||
],
|
||||
unverified: [{ pid: 456, diagnostic: "controlled observation unavailable" }],
|
||||
};
|
||||
const fixture = async (rollback: () => Promise<ProcessCleanupFailure>) => {
|
||||
const fixture = async (
|
||||
rollback: () => Promise<ProcessCleanupFailure>,
|
||||
afterSpawn?: () => void,
|
||||
) => {
|
||||
const root = await createTestTempDirectory("rea-launch-rollback-");
|
||||
const launcher = new GhidraHeadlessLauncher({
|
||||
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
||||
@@ -81,6 +91,7 @@ const fixture = async (rollback: () => Promise<ProcessCleanupFailure>) => {
|
||||
spawnProcess: async (options) => {
|
||||
const spawned = await spawnOwnedProviderProcess(options);
|
||||
owners.push(spawned);
|
||||
afterSpawn?.();
|
||||
return {
|
||||
...spawned,
|
||||
cleanup: () =>
|
||||
@@ -302,3 +313,64 @@ it("bounds aggregate member evidence and retains complete producer facts", () =>
|
||||
);
|
||||
expect(launch.rollbackFailure).toEqual({ kind: "incomplete", result });
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32").each([null, "stop after spawn"])(
|
||||
"retains producer cancellation and exact process cleanup through failed rollback %j",
|
||||
async (reason) => {
|
||||
fault.failManifest = false;
|
||||
const controller = new AbortController();
|
||||
const { launcher, session } = await fixture(
|
||||
async () => incomplete,
|
||||
() => controller.abort(reason),
|
||||
);
|
||||
const result = await launcher.launch(session, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (result.ok || !(result.error instanceof GhidraLaunchError))
|
||||
throw new Error("Expected incomplete rollback");
|
||||
expect(result.error.primaryFailure).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.primaryFailure?.cause).toBe(reason);
|
||||
expect(result.error.rollbackFailure).toEqual({
|
||||
kind: "incomplete",
|
||||
result: incomplete,
|
||||
});
|
||||
expect(result.error.partialLaunch).toBeDefined();
|
||||
denyCleanup = false;
|
||||
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
|
||||
cleaned: true,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"client preserves an earlier manifest failure when the final caller cancels during rollback",
|
||||
async () => {
|
||||
const controller = new AbortController();
|
||||
const { launcher } = await fixture(async () => {
|
||||
controller.abort(null);
|
||||
return incomplete;
|
||||
});
|
||||
const target = await readFile(ghidraFixturePath);
|
||||
const client = new GhidraClient({
|
||||
launcher,
|
||||
targetPath: ghidraFixturePath,
|
||||
targetSha256: createHash("sha256").update(target).digest("hex"),
|
||||
providerVersion: "test",
|
||||
profileDigest: "b".repeat(64),
|
||||
startupTimeoutMs: 5000,
|
||||
});
|
||||
clients.push(client);
|
||||
const result = await client.start(controller.signal);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } },
|
||||
});
|
||||
if (result.ok) throw new Error("Expected original startup failure");
|
||||
expect(JSON.stringify(result.error.diagnostics)).toContain(
|
||||
fault.failure.message,
|
||||
);
|
||||
denyCleanup = false;
|
||||
expect(await client.close()).toMatchObject({ ok: true });
|
||||
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user