Files
rea/tests/process-global/ghidraEndpointLifetime.test.ts
T
morluto 9d940c2f28 fix(ghidra): preserve lifecycle ownership and failure provenance
Refs #4502, #4506, #4508, #4518, #4522, #4526.

Retain snapshot, endpoint, launch rollback and compiler cleanup owners. Join request finalization and cleanup cancellation. Preserve bounded compiler output and original byte counts through capture and Ghidra failures. Normalize execFile cancellation once at its producer.

Validation includes real macOS Ghidra TCP MCP success workflows, controlled Node compiler/process fault fixtures, focused regressions and static checks. Native Swift compiler faults, Windows execution and stdio transport were not verified. Broader startup primary classification in #4517 and native adapter propagation remain unfinished.
2026-10-17 00:44:02 +08:00

360 lines
12 KiB
TypeScript

import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { promisify } from "node:util";
import { pathToFileURL } from "node:url";
import {
access,
readFile,
rename,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const fault = vi.hoisted(() => ({
path: "",
matchEndpoints: false,
reads: 0,
handles: [] as FileHandle[],
blocked: false,
abortClose: undefined as AbortController | undefined,
readFailure: undefined as Error | undefined,
abortRead: undefined as AbortController | undefined,
readGate: undefined as (() => Promise<void>) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
if (
args[0] !== fault.path &&
!(
fault.matchEndpoints &&
typeof args[0] === "string" &&
basename(args[0]) === "bridge-endpoint.json"
)
)
return handle;
fault.handles.push(handle);
const close = handle.close.bind(handle);
handle.close = async () => {
fault.abortClose?.abort(null);
if (fault.blocked) throw new Error("controlled endpoint close failure");
await close();
};
const read = handle.read.bind(handle);
handle.read = (async (...values: unknown[]) => {
fault.reads += 1;
await fault.readGate?.();
if (fault.readFailure !== undefined) {
fault.abortRead?.abort(null);
throw fault.readFailure;
}
return await Reflect.apply(read, handle, values);
}) as FileHandle["read"];
const readFile = handle.readFile.bind(handle);
handle.readFile = (async (...values: unknown[]) => {
fault.reads += 1;
await fault.readGate?.();
if (fault.readFailure !== undefined) {
fault.abortRead?.abort(null);
throw fault.readFailure;
}
return await Reflect.apply(readFile, handle, values);
}) as FileHandle["readFile"];
return handle;
},
};
});
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { observeGhidraEndpoint } from "../../src/ghidra/GhidraTransport.js";
import { GhidraClient } from "../../src/ghidra/GhidraClient.js";
import {
GhidraFixtureLauncher,
ghidraFixturePath,
} from "../boundary/providers/ghidra/ghidraClient.fixture.js";
import { waitForExit } from "../support/process/processFixture.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
const clients: GhidraClient[] = [];
const operations: Promise<unknown>[] = [];
let releaseRead: (() => void) | undefined;
beforeEach(() => {
fault.path = "";
fault.matchEndpoints = false;
fault.reads = 0;
fault.handles = [];
fault.blocked = false;
fault.abortClose = undefined;
fault.readFailure = undefined;
fault.abortRead = undefined;
fault.readGate = undefined;
});
afterEach(async () => {
fault.blocked = false;
fault.abortClose = undefined;
fault.readFailure = undefined;
fault.abortRead = undefined;
releaseRead?.();
releaseRead = undefined;
await Promise.allSettled(operations.splice(0));
for (const client of clients.splice(0)) await client.close();
for (const handle of fault.handles)
if (handle.fd !== -1) await handle.close();
});
const fixture = async (text = '{"host":"127.0.0.1","port":49152}\n') => {
const root = await createTestTempDirectory("rea-endpoint-lifetime-");
fault.path = join(root, "bridge-endpoint.json");
await writeFile(fault.path, text);
return {
endpoint: {
transport: "authenticated-loopback-tcp" as const,
path: fault.path,
},
resources: new ArtifactResourceScope(),
};
};
it.skipIf(process.platform === "win32").each(["valid", "invalid"] as const)(
"retains the exact descriptor and %s endpoint outcome after close failure",
async (kind) => {
const { endpoint, resources } = await fixture(
kind === "valid" ? undefined : "{",
);
fault.blocked = true;
const result = await observeGhidraEndpoint(endpoint, {
resourceScope: resources,
});
expect(result).toMatchObject({
ok: false,
error: { kind: "start", cleanupFailure: { cleanupIncomplete: true } },
});
if (result.ok) throw new Error("Expected typed endpoint failure");
const failure = result.error;
if (kind === "invalid")
expect(failure.message).toBe("Ghidra TCP endpoint is invalid");
expect(fault.handles[0]?.fd).not.toBe(-1);
expect(resources.hasPending).toBe(true);
fault.blocked = false;
await resources.retryCleanup();
expect(fault.handles[0]?.fd).toBe(-1);
expect(failure.cleanupFailure?.cleanupIncomplete).toBe(false);
},
);
it.skipIf(process.platform === "win32")(
"observes exact null cancellation during the endpoint's final close",
async () => {
const { endpoint, resources } = await fixture();
const controller = new AbortController();
fault.abortClose = controller;
const result = await observeGhidraEndpoint(endpoint, {
resourceScope: resources,
signal: controller.signal,
});
expect(result).toMatchObject({
ok: false,
error: { kind: "cancelled", cancellation: { reason: null } },
});
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it.skipIf(process.platform === "win32").each(["released", "failed"] as const)(
"preserves endpoint IO failure during cancellation and %s close",
async (closing) => {
const { endpoint, resources } = await fixture();
const controller = new AbortController();
const cause = Object.assign(new Error("controlled endpoint EIO"), {
code: "EIO",
});
fault.readFailure = cause;
fault.abortRead = controller;
fault.blocked = closing === "failed";
const result = await observeGhidraEndpoint(endpoint, {
resourceScope: resources,
signal: controller.signal,
});
if (result.ok) throw new Error("Expected IO failure");
expect(result.error.kind).toBe("start");
expect(result.error.cause).toBe(cause);
if (closing === "failed")
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
fault.blocked = false;
await resources.retryCleanup();
expect(result.error.cleanupFailure?.cleanupIncomplete ?? false).toBe(false);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"keeps endpoint observation admitted until its read and close finish",
async () => {
const { endpoint, resources } = await fixture();
let entered: (() => void) | undefined;
const observed = new Promise<void>((resolve) => {
entered = resolve;
});
const gate = new Promise<void>((resolve) => {
releaseRead = resolve;
});
fault.readGate = async () => {
entered?.();
await gate;
};
const pending = observeGhidraEndpoint(endpoint, {
resourceScope: resources,
});
operations.push(pending);
await observed;
let closed = false;
const closing = resources.close().then(() => {
closed = true;
});
operations.push(closing);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(closed).toBe(false);
releaseRead?.();
await expect(pending).resolves.toMatchObject({ ok: true });
await closing;
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it
.skipIf(process.platform === "win32")
.each(["completed", "cancelled"] as const)(
"preserves endpoint and provider cleanup owners through %s startup and retry",
async (kind) => {
const launcher = new GhidraFixtureLauncher();
const controller = new AbortController();
fault.matchEndpoints = true;
fault.blocked = true;
if (kind === "cancelled") fault.abortClose = controller;
const client = new GhidraClient({
launcher,
targetPath: ghidraFixturePath,
targetSha256: createHash("sha256")
.update(await readFile(ghidraFixturePath))
.digest("hex"),
transport: "authenticated-loopback-tcp",
providerVersion: "12.1.4",
profileDigest: "a".repeat(64),
startupTimeoutMs: 5000,
});
clients.push(client);
const result = await client.start(controller.signal);
if (result.ok) throw new Error("Expected endpoint finalization failure");
expect(result.error.kind).toBe(
kind === "cancelled" ? "cancelled" : "start",
);
if (kind === "cancelled")
expect(result.error.cancellation?.reason).toBe(null);
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(true);
expect(result.error.cleanupFailure?.cleanupResources).toContain(
launcher.endpointPaths[0],
);
expect(fault.handles.some((handle) => handle.fd !== -1)).toBe(true);
fault.blocked = false;
fault.abortClose = undefined;
await expect(client.close()).resolves.toEqual({ ok: true, value: null });
expect(result.error.cleanupFailure?.cleanupIncomplete).toBe(false);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
const child = launcher.processes[0];
if (child === undefined)
throw new Error("Expected admitted fixture process");
expect(await waitForExit(child, 5000)).toBe(true);
await expect(access(launcher.runtimeRoots[0] ?? "")).rejects.toMatchObject({
code: "ENOENT",
});
},
);
it.skipIf(process.platform === "win32")(
"rejects endpoint pathname replacement instead of reporting readiness",
async () => {
const { endpoint, resources } = await fixture();
fault.readGate = async () => {
fault.readGate = undefined;
await rename(endpoint.path, endpoint.path + ".old");
await writeFile(endpoint.path, '{"host":"127.0.0.1","port":49153}\n');
};
await expect(
observeGhidraEndpoint(endpoint, { resourceScope: resources }),
).resolves.toMatchObject({
ok: false,
error: {
kind: "start",
message: "Ghidra TCP endpoint changed during observation",
},
});
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"rejects an oversized endpoint before reading its contents",
async () => {
const { endpoint, resources } = await fixture(" ".repeat(65537));
await expect(
observeGhidraEndpoint(endpoint, { resourceScope: resources }),
).resolves.toMatchObject({
ok: false,
error: { kind: "start", cause: { reason: "limit" } },
});
expect(fault.reads).toBe(0);
expect(fault.handles.every((handle) => handle.fd === -1)).toBe(true);
},
);
it.skipIf(process.platform === "win32")(
"rejects a FIFO endpoint through the compiled observer without a writer",
async () => {
const { endpoint } = await fixture();
await rename(endpoint.path, endpoint.path + ".old");
await promisify(execFile)("mkfifo", [endpoint.path]);
const module = pathToFileURL(
join(process.cwd(), "dist/ghidra/GhidraTransport.js"),
).href;
const script = `
const { observeGhidraEndpoint } = await import(${JSON.stringify(module)});
const result = await observeGhidraEndpoint({ transport: "authenticated-loopback-tcp", path: process.argv[1] });
if (result.ok) throw new Error("Unexpected FIFO readiness");
process.stdout.write(JSON.stringify({ kind: result.error.kind, code: result.error.cause.code }));
`;
const result = await promisify(execFile)(
process.execPath,
["--input-type=module", "--eval", script, endpoint.path],
{ timeout: 5000 },
);
expect(JSON.parse(result.stdout)).toEqual({
kind: "start",
code: "ENOTFILE",
});
},
);
it
.skipIf(process.platform === "win32")
.each([null, "caller stopped", new Error("caller stopped")])(
"rejects pre-aborted endpoint admission with exact reason %j",
async (reason) => {
const { endpoint, resources } = await fixture();
const controller = new AbortController();
controller.abort(reason);
const result = await observeGhidraEndpoint(endpoint, {
resourceScope: resources,
signal: controller.signal,
});
if (result.ok) throw new Error("Expected pre-admission cancellation");
expect(result.error.kind).toBe("cancelled");
expect(result.error.cancellation?.reason).toBe(reason);
expect(fault.handles).toEqual([]);
},
);