Files
rea/tests/process-global/ghidraLaunchRollback.test.ts
T
morluto 1e36e707b1 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
2026-10-17 12:27:08 +08:00

377 lines
12 KiB
TypeScript

import { createHash, randomUUID } from "node:crypto";
import { access, readFile } from "node:fs/promises";
import { join } from "node:path";
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<{
default: typeof import("write-file-atomic");
}>();
return {
default: async (...args: Parameters<typeof actual.default>) => {
if (
fault.failManifest &&
typeof args[0] === "string" &&
args[0].endsWith("/ownership.json")
)
throw fault.failure;
return actual.default(...args);
},
};
});
import {
GhidraHeadlessLauncher,
GhidraLaunchError,
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,
type SpawnedOwnedProviderProcess,
} from "../../src/process/ProviderProcess.js";
import {
cleanupOwnedProcessGroup,
type ProcessCleanupFailure,
} from "../../src/process/ProcessOwnership.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { ghidraFixturePath } from "../boundary/providers/ghidra/ghidraClient.fixture.js";
const owners: SpawnedOwnedProviderProcess[] = [];
const clients: GhidraClient[] = [];
let denyCleanup = true;
beforeEach(() => {
denyCleanup = true;
fault.failManifest = true;
});
afterEach(async () => {
denyCleanup = false;
for (const client of clients.splice(0)) await client.close();
for (const owner of owners.splice(0))
expect(await cleanupOwnedProcessGroup(owner.ownership)).toMatchObject({
cleaned: true,
});
});
const incomplete: ProcessCleanupFailure = {
cleaned: false,
reason: "mixed fixture outcomes",
failures: [
{
pid: 123,
reason: "process-identity-unavailable",
diagnostic: "controlled identity uncertainty",
},
],
unverified: [{ pid: 456, diagnostic: "controlled observation unavailable" }],
};
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",
environment: {},
platform: process.platform,
analyzeHeadlessPath: join(root, "support", "analyzeHeadless"),
javaHome: join(root, "jdk"),
bridgeScriptPath: join(root, "ReaGhidraBridge.java"),
javaLaunch: async () => ({
command: process.execPath,
arguments: ["-e", "setInterval(()=>{},1000)"],
environment: {},
}),
spawnProcess: async (options) => {
const spawned = await spawnOwnedProviderProcess(options);
owners.push(spawned);
afterSpawn?.();
return {
...spawned,
cleanup: () =>
denyCleanup
? rollback()
: cleanupOwnedProcessGroup(spawned.ownership),
};
},
});
const session: GhidraLaunchSession = {
runtimeRoot: root,
transport: "unix-socket",
endpointPath: join(root, "bridge.sock"),
token: "fixture-token",
runId: randomUUID(),
targetPath: join(root, "unused-target"),
targetSha256: "a".repeat(64),
providerVersion: "test",
profileDigest: "b".repeat(64),
};
return { launcher, session };
};
it
.skipIf(process.platform === "win32")
.each([undefined, new Error("rollback rejected")] as const)(
"retains the process owner after rollback rejects with %s",
async (rejection) => {
const { launcher, session } = await fixture(async () => {
throw rejection;
});
const result = await launcher.launch(session);
expect(result.ok).toBe(false);
if (result.ok || !(result.error instanceof GhidraLaunchError))
throw new Error("Expected launch failure");
expect(result.error.partialLaunch).toBeDefined();
expect(result.error).toMatchObject({
rollbackFailure: { kind: "rejected", cause: rejection },
});
expect(owners[0]?.process.exitCode).toBeNull();
denyCleanup = false;
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
cleaned: true,
});
},
);
it.skipIf(process.platform === "win32")(
"retains structured mixed-member rollback evidence through session projection",
async () => {
const { launcher, session } = await fixture(async () => incomplete);
const result = await launcher.launch(session);
if (result.ok || !(result.error instanceof GhidraLaunchError))
throw new Error("Expected launch failure");
expect(result.error.partialLaunch).toBeDefined();
expect(result.error).toMatchObject({
rollbackFailure: { kind: "incomplete", result: incomplete },
});
const projected = bindGhidraSessionFailure(() => ({}))(
"start",
result.error.message,
result.error,
);
expect(projected.diagnostics).toMatchObject({
failure_cause: {
rollback_failure: { kind: "incomplete", result: incomplete },
},
});
denyCleanup = false;
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
cleaned: true,
});
},
);
it.skipIf(process.platform === "win32")(
"client adopts a rejected rollback owner and retries before removing its runtime",
async () => {
const { launcher } = await fixture(async () => {
throw undefined;
});
const target = await readFile(ghidraFixturePath);
let runtimeRoot: string | undefined;
const client = new GhidraClient({
launcher: {
launch: (session, options) => {
runtimeRoot = session.runtimeRoot;
return launcher.launch(session, options);
},
},
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();
expect(result).toMatchObject({
ok: false,
error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } },
});
if (result.ok) throw new Error("Expected failed startup");
expect(owners[0]?.process.exitCode).toBeNull();
if (runtimeRoot === undefined)
throw new Error("Expected admitted runtime root");
await expect(access(runtimeRoot)).resolves.toBeUndefined();
denyCleanup = false;
expect(await client.close()).toMatchObject({ ok: true });
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
await expect(access(runtimeRoot)).rejects.toMatchObject({ code: "ENOENT" });
},
);
it.skipIf(process.platform === "win32")(
"retains synchronous rollback rejection and the earlier failure during cancellation",
async () => {
const controller = new AbortController();
const { launcher, session } = await fixture(() => {
controller.abort(null);
throw undefined;
});
const result = await launcher.launch(session, {
signal: controller.signal,
});
if (result.ok || !(result.error instanceof GhidraLaunchError))
throw new Error("Expected launch failure");
expect(result.error.rollbackFailure).toEqual({
kind: "rejected",
cause: undefined,
});
expect(result.error.cause).toBeInstanceOf(AggregateError);
if (!(result.error.cause instanceof AggregateError))
throw new Error("Expected composite failure");
expect(result.error.cause.errors[0]).toMatchObject({
cause: fault.failure,
});
expect(result.error.cause.errors[1]).toBeUndefined();
expect(result.error.partialLaunch).toBeDefined();
denyCleanup = false;
expect(await result.error.partialLaunch?.cleanup?.()).toMatchObject({
cleaned: true,
});
},
);
it("bounds and redacts rollback evidence with explicit truncation", () => {
const value = "sensitive-transport-token";
const result: ProcessCleanupFailure = {
cleaned: false,
reason: value + "x".repeat(4096),
failures: [
{
pid: 1,
reason: "run-token-mismatch",
diagnostic: value + "y".repeat(4096),
},
],
unverified: [{ pid: 2, diagnostic: value + "z".repeat(4096) }],
};
const launch = new GhidraLaunchError("primary", {
rollbackFailure: { kind: "incomplete", result },
});
const projected = bindGhidraSessionFailure(
() => ({}),
(text) => text.replaceAll(value, "[REDACTED]"),
)("start", "primary", launch);
expect(JSON.stringify(projected.diagnostics)).not.toContain(value);
expect(projected.diagnostics).toMatchObject({
failure_cause: {
rollback_failure: {
result: {
reason_truncated: true,
failures: [{ diagnostic_truncated: true }],
unverified: [{ diagnostic_truncated: true }],
},
},
},
});
});
it("bounds aggregate member evidence and retains complete producer facts", () => {
const rows = Array.from({ length: 500 }, (_, pid) => ({
pid,
diagnostic: "\u0001".repeat(2000),
}));
const result: ProcessCleanupFailure = {
cleaned: false,
reason: "many controlled members",
failures: rows.map((row) => ({
...row,
reason: "process-identity-unavailable" as const,
})),
unverified: rows,
};
const launch = new GhidraLaunchError("primary", {
rollbackFailure: { kind: "incomplete", result },
});
const projected = bindGhidraSessionFailure(() => ({}))(
"start",
"primary",
launch,
);
const cause = projected.diagnostics["failure_cause"] as {
rollback_failure: {
result: {
failures: unknown[];
unverified: unknown[];
failures_omitted?: number;
unverified_omitted?: number;
};
};
};
const output = cause.rollback_failure.result;
expect(output.failures.length + (output.failures_omitted ?? 0)).toBe(500);
expect(output.unverified.length + (output.unverified_omitted ?? 0)).toBe(500);
expect(Buffer.byteLength(JSON.stringify(projected.diagnostics))).toBeLessThan(
10 * 1024,
);
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);
},
);