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:
morluto committed 2026-10-17 12:27:08 +08:00
1 parent e31aa72c9d
commit 1e36e707b1
7 files changed
+385 -58

No files matched your search

+4
View File
@@ -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
View File
@@ -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",
+47 -33
View File
@@ -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);
},
);