mirror of
https://github.com/morluto/rea.git
synced 2026-10-23 14:22:30 -05:00
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
377 lines
12 KiB
TypeScript
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);
|
|
},
|
|
);
|