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.
233 lines
8.2 KiB
TypeScript
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" });
|
|
}
|
|
}
|
|
},
|
|
);
|