Files
rea/tests/boundary/cli/ghidraJavaDoctor.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

233 lines
8.2 KiB
TypeScript

import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { expect } from "vitest";
import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
import {
observeOwnedProcessGroups,
systemProcessOwnershipHost,
} from "../../../src/process/ProcessOwnershipObservation.js";
cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([
["missing", "missing_dependency", "runtime_missing"],
["nonzero", "runtime_failure", "runtime_unavailable"],
["properties", "config_drift", "version_unresolved"],
] as const)(
"preserves the %s Java prerequisite result through doctor",
async ([failure, classification, code], { cli }) => {
const root = await createTestTempDirectory("rea-java-doctor-");
const jdk = join(root, "jdk");
const install = join(root, "ghidra");
const platform = process.platform === "darwin" ? "mac" : "linux";
const architecture = process.arch === "arm64" ? "arm_64" : "x86_64";
const native = join(
install,
"Ghidra/Features/Decompiler/os",
`${platform}_${architecture}`,
"decompile",
);
for (const path of [
join(jdk, "bin"),
join(install, "support"),
join(install, "Ghidra"),
join(native, ".."),
])
await mkdir(path, { recursive: true });
await writeFile(
join(install, "Ghidra/application.properties"),
"application.version=12.1.4\napplication.java.min=21\n",
);
await writeFile(
join(install, "support/analyzeHeadless"),
"#!/bin/sh\nexit 99\n",
);
await chmod(join(install, "support/analyzeHeadless"), 0o700);
await writeFile(
native,
process.platform === "darwin"
? thinMach(
0xfeedfacf,
process.arch === "arm64" ? 0x0100000c : 0x01000007,
)
: elf(2, 1, process.arch === "arm64" ? 183 : 62),
);
await chmod(native, 0o700);
if (failure !== "missing") {
const body =
failure === "nonzero"
? 'process.stderr.write("controlled runtime failure\\n"); process.exit(7);'
: `process.stdout.write(${JSON.stringify(`java.home = ${jdk}\nos.arch = aarch64\n`)});`;
await writeFile(
join(jdk, "bin/java"),
`#!${process.execPath}\n${body}\n`,
);
await chmod(join(jdk, "bin/java"), 0o700);
}
const result = await cli.run({
arguments: ["doctor", "--provider", "ghidra", "--json"],
environment: {
JAVA_HOME: jdk,
GHIDRA_INSTALL_DIR: install,
REA_LOG_LEVEL: "silent",
},
});
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
expect(result.json).toMatchObject({
checks: expect.arrayContaining([
expect.objectContaining({
name: "ghidra-java",
classification,
ok: false,
}),
]),
providerInspections: expect.arrayContaining([
expect.objectContaining({
id: "ghidra",
checks: expect.arrayContaining([
expect.objectContaining({ name: "java", code }),
]),
}),
]),
});
if (failure === "nonzero")
expect(result.json).toMatchObject({
checks: expect.arrayContaining([
expect.objectContaining({
name: "ghidra-java",
detail: expect.stringContaining("Java exited 7"),
details: expect.objectContaining({
output: expect.objectContaining({
stderr: "controlled runtime failure\n",
exit_code: 7,
stderr_bytes: 27,
}),
}),
}),
]),
});
if (failure === "properties")
expect(result.json).toMatchObject({
checks: expect.arrayContaining([
expect.objectContaining({
name: "ghidra-java",
details: expect.objectContaining({
properties: expect.objectContaining({ home: jdk, version: null }),
}),
}),
]),
});
},
);
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
"doctor returns its own deadline result for a SIGTERM-resistant selected Java command",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-java-doctor-deadline-");
const jdk = join(root, "jdk");
const install = join(root, "ghidra");
const started = join(root, "java-started.json");
const release = join(root, "release-java");
await mkdir(join(jdk, "bin"), { recursive: true });
await mkdir(join(install, "Ghidra"), { recursive: true });
await writeFile(
join(install, "Ghidra/application.properties"),
"application.version=12.1.4\napplication.java.min=21\n",
);
await writeFile(
join(jdk, "bin/java"),
`#!${process.execPath}
const fs = require("node:fs");
process.on("SIGTERM", () => {});
fs.writeFileSync(${JSON.stringify(`${started}.tmp`)}, JSON.stringify({ pid: process.pid, runId: process.env.REA_PROCESS_RUN_ID }));
fs.renameSync(${JSON.stringify(`${started}.tmp`)}, ${JSON.stringify(started)});
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
setTimeout(() => process.exit(92), 12000);
`,
{ mode: 0o700 },
);
const pending = cli.run({
arguments: ["doctor", "--provider", "ghidra", "--json"],
environment: {
JAVA_HOME: jdk,
GHIDRA_INSTALL_DIR: install,
REA_LOG_LEVEL: "silent",
},
timeoutMs: 15000,
});
// Capture the actual group while the selected process is alive; the PID
// alone is never used as cleanup authority. Release is a private protocol.
let identity: { pid: number; runId: string } | undefined;
let processGroupId: number | undefined;
try {
for (let attempt = 0; attempt < 80; attempt++) {
try {
identity = JSON.parse(
await readFile(started, "utf8"),
) as typeof identity;
} catch (cause: unknown) {
if (
!(
cause instanceof Error &&
"code" in cause &&
cause.code === "ENOENT"
)
)
throw cause;
}
if (identity !== undefined) break;
await delay(50);
}
if (identity === undefined)
throw new Error("Selected Java command did not start");
expect(identity.runId).toEqual(expect.any(String));
processGroupId = (await systemProcessOwnershipHost.listProcesses()).find(
(entry) => entry.pid === identity?.pid,
)?.processGroupId;
expect(processGroupId).toBeDefined();
const result = await pending;
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
expect(result.signal).toBeNull();
expect(result.json).toMatchObject({
providerInspections: expect.arrayContaining([
expect.objectContaining({
id: "ghidra",
checks: expect.arrayContaining([
expect.objectContaining({
name: "java",
code: "runtime_unavailable",
details: expect.objectContaining({
process_error_code: "ETIMEDOUT",
}),
}),
]),
}),
]),
});
if (processGroupId !== undefined) {
const observed = await observeOwnedProcessGroups(identity.runId, [
processGroupId,
]);
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
}
} finally {
await writeFile(release, "release");
await pending;
if (identity !== undefined && processGroupId !== undefined) {
for (let attempt = 0; attempt < 40; attempt++) {
const observed = await observeOwnedProcessGroups(identity.runId, [
processGroupId,
]);
if (observed.get(processGroupId)?.state === "empty") break;
await delay(50);
}
const observed = await observeOwnedProcessGroups(identity.runId, [
processGroupId,
]);
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
}
}
},
);