Files
rea/tests/boundary/process/ghidraJavaLifecycle.test.ts
morluto cc2e18a265 fix(ghidra): own Java prerequisite probes through final cleanup
Replace synchronous Java execution with bounded owned commands. Keep task-scoped readiness observations fresh across later tasks, defer target client prerequisites behind a closeable owner, and join doctor cancellation and cleanup.

Preserve completed physical failures across late cancellation, exact cleanup dependencies and partial observations. Admit target/profile inputs before probing and return typed factory failures instead of unavailable client wrappers.

Validation: 405 focused tests across 24 files, 2 explicit skips; 109 provider/doctor controls rerun after final admission refinement; check:fast and docs:check. Controlled macOS Java-shaped commands and public CLI/MCP workflows; no new JVM/Ghidra or Windows/Linux lifecycle verification.

Measured warm public doctor baseline/candidate: elapsed 0.447/1.223s, CPU 0.624/1.237s, sampled family RSS 379/529MB, reply tokens 3133/3133. Ownership adds cost; no performance improvement claimed. True cold, whole-agent and MCP costs unmeasured.

Refs #4561. Remaining installation file admission tracked in #4672.
2026-10-17 11:29:03 +08:00

399 lines
13 KiB
TypeScript

import { beforeAll, expect, it } from "vitest";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { DarwinProcessOwnershipInspectionError } from "../../../src/process/DarwinProcessRunTokenReader.js";
import { probeGhidraJava } from "../../../src/ghidra/GhidraJavaProbe.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
import {
observeOwnedProcessGroups,
prepareProcessOwnershipInspection,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Real, idle Node processes stand in for Java. These controls exercise POSIX
// process ownership and complete-output semantics, not a JVM or Ghidra.
beforeAll(() => prepareProcessOwnershipInspection());
const posix = it.skipIf(process.platform === "win32");
type Launched = Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
const expectEmpty = async (launched: Launched) => {
const { runId, processGroupId } = launched.ownership;
const observed = await observeOwnedProcessGroups(runId, [processGroupId]);
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
};
posix(
"cancels an active Java prerequisite with the exact opaque reason and joins cleanup",
async () => {
const resources = createInspectionResources();
const controller = new AbortController();
const reason = { request: "cancel Java", cleanup: "opaque caller value" };
let launched: Launched | undefined;
try {
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: controller.signal,
timeoutMs: 2500,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
launched.process.stdout?.on("data", () => controller.abort(reason));
return launched;
},
},
);
if (result.ok || launched === undefined)
throw new Error("Expected cancellation");
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
expect((result.error as AnalysisError).cause).toBe(reason);
await expectEmpty(launched);
} finally {
controller.abort(reason);
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
posix(
"bounds a SIGTERM-resistant prerequisite while the event loop remains responsive",
async () => {
const resources = createInspectionResources();
let launched: Launched | undefined;
let timerFired = false;
const timer = setTimeout(() => {
timerFired = true;
}, 50);
try {
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 750,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
'process.on("SIGTERM", () => {}); process.stdout.write("READY"); setInterval(() => {}, 1000)',
],
});
return launched;
},
},
);
if (result.ok || launched === undefined)
throw new Error("Expected deadline failure");
expect(result.error).toMatchObject({
_tag: "GhidraJavaProbeFailure",
processErrorCode: "ETIMEDOUT",
output: { stdout: "READY" },
});
expect(timerFired).toBe(true);
await expectEmpty(launched);
} finally {
clearTimeout(timer);
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
posix.each([true, false])(
"preserves completed Java output across final-cleanup cancellation (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
const controller = new AbortController();
const reason: { cycle?: unknown } = {};
reason.cycle = reason;
let launched: Launched | undefined;
try {
const output = valid
? "java.version = 21.0.1\njava.home = /controlled-java-home\n"
: "invalid Java report";
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: controller.signal,
timeoutMs: 2500,
launcher: async (input) => {
const acquired = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stderr.write(${JSON.stringify(output)})`,
],
});
launched = acquired;
return {
...acquired,
cleanup: async () => {
const cleaned = await cleanupOwnedProcessGroup(
acquired.ownership,
);
controller.abort(reason);
return cleaned;
},
};
},
},
);
if (result.ok || launched === undefined)
throw new Error("Expected finalization failure");
if (valid) {
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
diagnostics: { java_observation: { version: "21.0.1" } },
});
expect((result.error as AnalysisError).cause).toBe(reason);
} else {
expect(result.error).toMatchObject({
_tag: "GhidraJavaProbeFailure",
code: "version_unresolved",
cancellation: { reason },
});
expect(Object.keys(result.error)).not.toContain("cancellation");
expect(() => JSON.stringify(result.error)).not.toThrow();
}
await expectEmpty(launched);
} finally {
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);
posix(
"does not interpret a root's zero exit while a detached descendant holds its output open",
async () => {
const resources = createInspectionResources();
const root = await createTestTempDirectory("rea-java-descendant-");
const release = join(root, "release");
let launched: Launched | undefined;
let descendantGroup: number | undefined;
const child = `const fs = require("node:fs");
process.stdout.write("DESCENDANT_READY\\n");
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
setTimeout(() => process.exit(92), 10000);`;
try {
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
timeoutMs: 1000,
launcher: async (input) => {
launched = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`
const child = require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(child)}], {
detached: true, stdio: ["ignore", "inherit", "inherit"]
});
process.stdout.write("DESCENDANT_GROUP:" + child.pid + "\\n");
child.unref();
`,
],
});
return launched;
},
},
);
if (
result.ok ||
launched === undefined ||
result.error._tag !== "GhidraJavaProbeFailure"
)
throw new Error("Expected incomplete-output deadline failure");
expect(result.error).toMatchObject({
processErrorCode: "ETIMEDOUT",
output: { exit_code: 0 },
});
expect(result.error.output?.stdout).toContain("DESCENDANT_READY");
descendantGroup = Number(
/DESCENDANT_GROUP:(\d+)/u.exec(result.error.output?.stdout ?? "")?.[1],
);
expect(Number.isSafeInteger(descendantGroup)).toBe(true);
const observed = await observeOwnedProcessGroups(
launched.ownership.runId,
[descendantGroup],
);
expect(observed.get(descendantGroup)).toEqual({ state: "empty" });
await expectEmpty(launched);
} finally {
await writeFile(release, "release");
await resources.close();
if (launched !== undefined)
expect(
(
await (launched.cleanup?.() ??
cleanupOwnedProcessGroup(launched.ownership))
).cleaned,
).toBe(true);
}
},
);
it("retains only the preparation owner's exact failed root through cancellation and retry", async () => {
const resources = createInspectionResources();
const controller = new AbortController();
const reason = new ProviderCleanupError(
"foreign",
["caller-owned-resource"],
{ reason: "opaque caller error" },
);
const cleanup = new ProviderCleanupError(
"process-ownership",
["exact-helper-root"],
{ reason: "controlled cleanup refusal" },
);
let denied = true;
let closes = 0;
try {
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: controller.signal,
launcher: async () => {
controller.abort(reason);
throw new DarwinProcessOwnershipInspectionError(
"cancelled preparation with failed root cleanup",
{
cancellation: { reason },
cleanupFailure: cleanup,
cleanupOwner: {
close: async () => {
closes++;
if (denied) throw cleanup;
cleanup.resolveCleanup();
},
},
},
);
},
},
);
if (result.ok || !(result.error instanceof AnalysisError))
throw new Error("Expected retained preparation failure");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupResources).toEqual(["exact-helper-root"]);
expect(result.error.cleanupPrimary).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
});
await expect(resources.close()).rejects.toThrow();
denied = false;
await resources.close();
expect(closes).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
expect(reason.cleanupIncomplete).toBe(true);
} finally {
denied = false;
await resources.close();
}
});
posix.each([true, false])(
"retains exact cleanup authority and resolves prior errors after retry (valid: %s)",
async (valid) => {
const resources = createInspectionResources();
const controller = new AbortController();
const reason = { request: "cancel during refused cleanup" };
let denied = true;
let launched: Launched | undefined;
let attempts = 0;
try {
const output = valid
? "java.version = 21.0.1\njava.home = /controlled-java-home\n"
: "invalid Java report";
const result = await probeGhidraJava(
process.execPath,
{},
{
resourceOwner: resources.owner,
signal: controller.signal,
timeoutMs: 2500,
launcher: async (input) => {
const acquired = await spawnOwnedProviderProcess({
...input,
arguments: [
"-e",
`process.stderr.write(${JSON.stringify(output)})`,
],
});
launched = acquired;
return {
...acquired,
cleanup: async () => {
attempts++;
if (denied) {
controller.abort(reason);
return {
cleaned: false,
reason: "controlled verification refusal",
};
}
return cleanupOwnedProcessGroup(acquired.ownership);
},
};
},
},
);
if (
result.ok ||
!(result.error instanceof AnalysisError) ||
launched === undefined
)
throw new Error("Expected retained cleanup error");
expect(result.error.cleanupIncomplete).toBe(true);
expect(result.error.cleanupPrimary?.diagnostics).toMatchObject(
valid
? { java_observation: { version: "21.0.1" } }
: { java_probe_failure: { code: "version_unresolved" } },
);
denied = false;
await resources.close();
expect(attempts).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
await expectEmpty(launched);
} finally {
denied = false;
await resources.close();
if (launched !== undefined)
expect(
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
).toBe(true);
}
},
);