Files
rea/tests/boundary/process/ghidraJavaProbePreparation.test.ts
morlutoandJ0UH 376eeca770 fix(ghidra): prepare ownership before Java probe deadline
Allow independently bounded Darwin helper compilation to finish before the five-second Java deadline starts. Keep Linux process-table preparation under command supervision.

Share preparation failure translation between both stages so physical compiler outcomes, exact cancellation reasons and cleanup capabilities retain one owner. Adapt contributor PR #4795 and extend preparation handoff and retry controls.

Fixes #4793

Co-authored-by: J0UH <217747670+J0UH@users.noreply.github.com>
2026-10-17 22:06:16 +08:00

245 lines
8.5 KiB
TypeScript

import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { access, mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { afterEach, beforeAll, expect, it, vi } from "vitest";
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
import { probeGhidraJava } from "../../../src/ghidra/GhidraJavaProbe.js";
import { DarwinProcessOwnershipInspectionError } from "../../../src/process/DarwinProcessRunTokenReader.js";
import {
prepareProcessOwnershipInspection,
systemProcessOwnershipHost,
} from "../../../src/process/ProcessOwnershipObservation.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
// Long enough for an ordinary owned launch, short enough to keep the cold case quick.
const PROBE_TIMEOUT_MS = 2_000;
let inspectionResources = createInspectionResources();
afterEach(async () => {
vi.restoreAllMocks();
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
// Prepare the real inspector once; each case models its own first-time preparation.
beforeAll(() => prepareProcessOwnershipInspection());
/** Java-shaped Node command that records whether it was launched; not a JVM. */
const writeJava = async () => {
const home = await createTestTempDirectory("rea-java-preparation-");
const launched = join(home, "launched");
const command = join(home, "bin", "java");
await mkdir(join(home, "bin"));
await writeFile(
command,
`#!${process.execPath}\nrequire("node:fs").writeFileSync(${JSON.stringify(launched)}, "");\nprocess.stderr.write(${JSON.stringify(` java.version = 21.0.11\n java.home = ${home}\n sun.arch.data.model = 64\n`)});\n`,
{ mode: 0o700 },
);
return { command, launched };
};
const probe = (command: string, signal?: AbortSignal) =>
probeGhidraJava(
command,
{},
{
resourceOwner: inspectionResources.owner,
timeoutMs: PROBE_TIMEOUT_MS,
...(signal === undefined ? {} : { signal }),
},
);
it.runIf(process.platform === "darwin")(
"spends a cold macOS ownership preparation outside the Java probe deadline",
async () => {
const java = await writeJava();
const prepare = systemProcessOwnershipHost.prepare;
let cold = true;
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
async (signal) => {
// A cold Swift SDK cache makes the first preparation outlast the probe.
if (cold) {
await delay(PROBE_TIMEOUT_MS + 1_000, undefined, { signal });
cold = false;
}
await prepare?.(signal);
},
);
const result = await probe(java.command);
expect(result).toMatchObject({
ok: true,
value: { version: "21.0.11", major: 21 },
});
await expect(access(java.launched)).resolves.toBeUndefined();
},
);
it.runIf(process.platform === "linux")(
"keeps Linux ownership preparation inside the Java probe deadline",
async () => {
const java = await writeJava();
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
// A stalled `ps` check has no time limit of its own.
async (signal) => {
await delay(60_000, undefined, { signal });
},
);
const result = await probe(java.command);
expect(result).toMatchObject({
ok: false,
error: {
processErrorCode: "ETIMEDOUT",
detail: expect.stringContaining(
"Command deadline elapsed during ownership preparation.",
),
},
});
await expect(access(java.launched)).rejects.toThrow();
},
);
it.skipIf(process.platform === "win32")(
"reports cancellation during ownership preparation without launching Java",
async () => {
const java = await writeJava();
const controller = new AbortController();
let entered = (): void => undefined;
const preparing = new Promise<void>((resolve) => {
entered = resolve;
});
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
async (signal) => {
entered();
await delay(60_000, undefined, { signal });
},
);
const outcome = probe(java.command, controller.signal);
await preparing;
controller.abort();
const result = await outcome;
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a cancelled Java probe");
expect(result.error).toBeInstanceOf(AnalysisCancelledError);
await expect(access(java.launched)).rejects.toThrow();
},
);
it.skipIf(process.platform === "win32")(
"reports a failed ownership preparation as its own stage without launching Java",
async () => {
const java = await writeJava();
vi.spyOn(systemProcessOwnershipHost, "prepare").mockRejectedValue(
new DarwinProcessOwnershipInspectionError("helper compilation failed", {
compilerOutcome: {
outcome: "exited",
code: 1,
signal: null,
stderr: "error: failed to build module 'Swift'",
stderrTruncated: false,
outputTruncated: false,
killed: false,
},
}),
);
const result = await probe(java.command);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Expected a failed Java probe");
expect(result.error).toBeInstanceOf(ProviderAdapterError);
expect(result.error).toMatchObject({
diagnostics: {
stage: "ownership_preparation",
compiler_outcome: { outcome: "exited", code: 1 },
},
});
await expect(access(java.launched)).rejects.toThrow();
},
);
it.runIf(process.platform === "darwin")(
"retains preparation cleanup through cancellation and clears only the confirmed helper owner",
async () => {
const java = await writeJava();
const controller = new AbortController();
const reason = new ProviderCleanupError(
"caller",
["caller-owned-resource"],
{},
);
const cleanup = new ProviderCleanupError(
"process-ownership",
["controlled-helper-root"],
{},
);
let denied = true;
const close = vi.fn(async () => {
if (denied) throw cleanup;
cleanup.resolveCleanup();
});
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
async () => {
controller.abort(reason);
throw new DarwinProcessOwnershipInspectionError(
"cancelled helper with retained cleanup",
{
cancellation: { reason },
cleanupFailure: cleanup,
cleanupOwner: { close },
},
);
},
);
try {
const result = await probe(java.command, controller.signal);
if (result.ok || !(result.error instanceof AnalysisError))
throw new Error("Expected an owned cleanup failure");
expect(result.error.cleanupResources).toEqual(["controlled-helper-root"]);
expect(result.error.cleanupPrimary?.cause).toBe(reason);
await expect(inspectionResources.close()).rejects.toThrow();
denied = false;
await inspectionResources.close();
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error).code).toBe("cancelled");
expect(result.error.cleanupPrimary?.cause).toBe(reason);
expect(reason.cleanupIncomplete).toBe(true);
expect(close).toHaveBeenCalledTimes(2);
await expect(access(java.launched)).rejects.toThrow();
} finally {
denied = false;
await inspectionResources.close();
}
},
);
it.runIf(process.platform === "darwin")(
"refuses launch when cancellation arrives at the completed preparation handoff",
async () => {
const java = await writeJava();
const controller = new AbortController();
const reason = { caller: "after ownership preparation" };
vi.spyOn(systemProcessOwnershipHost, "prepare").mockImplementation(
async () => {
controller.abort(reason);
},
);
const result = await probe(java.command, controller.signal);
if (result.ok || !(result.error instanceof AnalysisCancelledError))
throw new Error("Expected cancelled handoff");
expect(result.error.cause).toBe(reason);
await expect(access(java.launched)).rejects.toThrow();
},
);