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.
This commit is contained in:
morluto
2026-10-17 11:29:03 +08:00
parent fb146bf40b
commit cc2e18a265
25 changed files with 2143 additions and 536 deletions
+6
View File
@@ -26,6 +26,7 @@ graph TD
end
subgraph "Provider Layers"
PROVIDER_OWNERS["src/providers/<br/>shared deferred client and inspection owners<br/>exact cleanup retry and attributed failures"]
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/<br/>BYO offline ELF/core adapters<br/>owned snapshots/Python and optional core-only GDB<br/>unchanged upstream parsers, ABI structures and mapping API"]
WASM["src/wasm/<br/>caller-supplied WABT; immutable artifact inspection<br/>owned process supervision and retryable workspace cleanup"]
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
@@ -138,6 +139,11 @@ graph TD
GHIDRA --> GHIDRA_BRIDGE
HOPPER --> PROCESS
GHIDRA --> PROCESS
GHIDRA --> PROVIDER_OWNERS
APP --> PROVIDER_OWNERS
PROVIDER_OWNERS --> PROCESS
PROVIDER_OWNERS --> AUX
PROVIDER_OWNERS --> DOMAIN
GHIDRA --> WINDOWS
PROCESS --> WINDOWS
WINDOWS --> WINDOWS_NATIVE
+8 -3
View File
@@ -1,3 +1,4 @@
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
@@ -193,8 +194,12 @@ export interface AnalysisProfileResolution {
}
/** Cancellation context for side-effect-free provider profile resolution. */
export interface AnalysisProfileResolutionOptions {
export interface AnalysisProviderInspectionOptions {
readonly signal?: AbortSignal;
readonly scope?: ProviderInspectionScope;
}
export interface AnalysisProfileResolutionOptions extends AnalysisProviderInspectionOptions {
readonly fileCloseResources?: FileCloseResourceOwner;
}
@@ -304,10 +309,10 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
/** Await prerequisite observations and finalization under the supplied owner signal. */
inspectAvailability(
signal?: AbortSignal,
options?: AnalysisProviderInspectionOptions,
): ProviderAvailability | Promise<ProviderAvailability>;
inspectReadinessSnapshot?(
signal?: AbortSignal,
options?: AnalysisProviderInspectionOptions,
):
| AnalysisProviderReadinessSnapshot
| Promise<AnalysisProviderReadinessSnapshot>;
+133
View File
@@ -0,0 +1,133 @@
import { expect, it } from "vitest";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { createDoctorHostFixture } from "./Doctor.fixture.js";
import { runDoctorOwned, systemDoctorHost } from "./Doctor.js";
it("joins a late provider inspection and retries only its unresolved cleanup owners", async () => {
let enter!: (signal: AbortSignal) => void;
const entered = new Promise<AbortSignal>((resolve) => {
enter = resolve;
});
let handOff!: () => void;
const handoff = new Promise<void>((resolve) => {
handOff = resolve;
});
let finish!: () => void;
const finishing = new Promise<void>((resolve) => {
finish = resolve;
});
let denied = true;
let closed = false;
const closes = { successful: 0, refused: 0 };
const host = systemDoctorHost({
environment: {},
providerInspections: async (owner, signal) => {
enter(signal);
await handoff;
owner.retain(
{
close: async () => {
closes.successful++;
await finishing;
},
},
"successful-prerequisite",
);
owner.retain(
{
close: async () => {
closes.refused++;
if (denied) throw new Error("controlled cleanup refusal");
},
},
"refused-prerequisite",
);
return [];
},
});
const inspection = host.providerInspections!();
const signal = await entered;
const closing = host.close!().then(
() => {
closed = true;
return undefined;
},
(cause: unknown) => {
closed = true;
return cause;
},
);
try {
expect(signal.aborted).toBe(true);
expect(closed).toBe(false);
handOff();
await inspection;
expect(closed).toBe(false);
finish();
const failure = await closing;
if (!(failure instanceof AnalysisError))
throw new Error("Expected attributed cleanup failure");
expect(failure.cleanupResources).toEqual(["refused-prerequisite"]);
expect(failure.cleanupIncomplete).toBe(true);
expect(closes).toEqual({ successful: 1, refused: 1 });
denied = false;
await host.close!();
expect(failure.cleanupIncomplete).toBe(false);
expect(closes).toEqual({ successful: 1, refused: 2 });
await expect(host.providerInspections!()).rejects.toThrow(
"scope is closed",
);
} finally {
denied = false;
handOff();
finish();
await inspection;
await closing;
await host.close!();
}
});
it("retains a completed healthy report when cancellation arrives during final cleanup", async () => {
const controller = new AbortController();
const reason = { request: "cancel final doctor cleanup" };
await expect(
runDoctorOwned(
undefined,
createDoctorHostFixture({
signal: controller.signal,
close: async () => {
controller.abort(reason);
return undefined;
},
}),
),
).rejects.toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
diagnostics: { doctor_report: { healthy: true } },
});
});
it("preserves an inspection-authored physical failure when final cleanup also observes cancellation", async () => {
const controller = new AbortController();
const physical = new ProviderAdapterError("ghidra", "doctor", {
diagnostics: { reason: "controlled inspection fault" },
});
await expect(
runDoctorOwned(
undefined,
createDoctorHostFixture({
signal: controller.signal,
providerInspections: async () => {
throw physical;
},
close: async () => {
controller.abort(physical);
return undefined;
},
}),
),
).rejects.toBe(physical);
expect(physical.cause).toBeUndefined();
});
+77 -9
View File
@@ -1,3 +1,12 @@
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
import { constants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
@@ -9,7 +18,7 @@ import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentat
import { BinaryTargetError } from "../domain/configurationErrors.js";
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
import { execFileOutput } from "../process/ExecFileOutput.js";
import type { JsonValue } from "../domain/jsonValue.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
javaScriptRuntime,
type JavaScriptRuntime,
@@ -93,6 +102,7 @@ export interface DoctorProviderInspection {
}
/** Read-only host capabilities required by diagnostics. */
export interface DoctorHost {
readonly signal?: AbortSignal;
readonly platform: NodeJS.Platform;
readonly architecture: NodeJS.Architecture;
readonly runtime: JavaScriptRuntime;
@@ -203,15 +213,36 @@ export const runDoctorOwned = async (
report = await inspectDoctor(target, host, requestedScope);
} catch (cause: unknown) {
const cleanup = await closeDoctorHost(host);
if (cleanup !== undefined && cause instanceof AnalysisError) {
const cleanupError =
typeof cleanup === "string"
? new ProviderCleanupError(
"rea",
[],
{ reason: cleanup },
{ operation: "doctor" },
)
: cleanup;
throw analysisErrorWithCleanupFailure(
cause,
cleanupError,
"doctor",
).bindCleanupDependencies([cause, cleanupError]);
}
if (cleanup !== undefined)
throw new AggregateError(
[cause, new Error(cleanup)],
[cause, typeof cleanup === "string" ? new Error(cleanup) : cleanup],
"Doctor failed and its resource cleanup remains incomplete.",
{ cause },
);
throw cause;
}
const cleanup = await closeDoctorHost(host);
if (cleanup === undefined && report.healthy && host.signal?.aborted)
throw new AnalysisCancelledError("doctor", {
cause: host.signal.reason,
diagnostics: { doctor_report: jsonValueSchema.parse(report) },
});
return cleanup === undefined
? report
: doctorReportWithCleanup(report, cleanup);
@@ -222,6 +253,8 @@ const inspectDoctor = async (
host: DoctorHost,
requestedScope?: DoctorScope,
): Promise<DoctorReport> => {
if (host.signal?.aborted)
throw new AnalysisCancelledError("doctor", { cause: host.signal.reason });
const scope = normalizeDoctorScope(requestedScope, target);
const {
checks: diagnosticChecks,
@@ -283,23 +316,30 @@ const registrationAdvisoryChecks = (
const closeDoctorHost = async (
host: DoctorHost,
): Promise<string | undefined> => {
): Promise<string | AnalysisError | undefined> => {
try {
return await host.close?.();
} catch (cause: unknown) {
if (cause instanceof AnalysisError) return cause;
return `Doctor host cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
}
};
const doctorReportWithCleanup = (
report: DoctorReport,
detail: string,
cleanup: string | AnalysisError,
): DoctorReport => {
const check: DoctorCheck = {
name: "doctor-resource-cleanup",
ok: false,
classification: "config_drift",
detail,
detail:
typeof cleanup === "string"
? cleanup
: `${cleanup.message} ${cleanup.cleanupResources.join(", ")}`,
...(typeof cleanup === "string"
? {}
: { details: { cleanup_failure: projectAnalysisError(cleanup) } }),
remediation: "Resolve the reported cleanup failure, then rerun rea doctor.",
};
return {
@@ -340,7 +380,10 @@ const collectDoctorIdentity = async (
cli_package_version: PRODUCT_IDENTITY.packageVersion,
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
sdk: SDK_IDENTITY,
catalog: CATALOG_IDENTITY,
catalog: {
counts: CATALOG_IDENTITY.counts,
digests: CATALOG_IDENTITY.digests,
},
live_server: {
state: "unknown",
remediation:
@@ -430,12 +473,14 @@ const installationState = (
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
export interface SystemDoctorHostOptions {
readonly signal?: AbortSignal;
readonly platform?: NodeJS.Platform;
readonly architecture?: NodeJS.Architecture;
readonly environment?: NodeJS.ProcessEnv;
readonly execFileOutput?: typeof execFileOutput;
readonly providerInspections?: (
fileCloseResources: FileCloseResourceOwner,
signal: AbortSignal,
) => Promise<readonly DoctorProviderInspection[]>;
readonly providerRegistrationEnvironment?: () => Readonly<
Record<string, string>
@@ -449,6 +494,11 @@ export const systemDoctorHost = (
options: SystemDoctorHostOptions = {},
): DoctorHost => {
const resources = new ArtifactResourceScope();
const controller = new AbortController();
const inspectionSignal =
options.signal === undefined
? controller.signal
: AbortSignal.any([options.signal, controller.signal]);
const platform = options.platform ?? process.platform;
const architecture = options.architecture ?? process.arch;
const environment = options.environment ?? process.env;
@@ -460,11 +510,12 @@ export const systemDoctorHost = (
resources.retain({
kind: "lease",
resource,
lease: { release: () => owner.close() },
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
}),
run: (operation) => resources.run(operation),
};
return {
...(options.signal === undefined ? {} : { signal: options.signal }),
platform,
architecture,
runtime: javaScriptRuntime(process.versions),
@@ -561,7 +612,12 @@ export const systemDoctorHost = (
? {}
: {
providerInspections: () =>
options.providerInspections!(fileCloseResources),
resources.run(() =>
options.providerInspections!(
fileCloseResources,
inspectionSignal,
),
),
}),
...(options.providerRegistrationEnvironment === undefined
? {}
@@ -612,11 +668,23 @@ export const systemDoctorHost = (
resources,
),
close: async () => {
controller.abort(STARTUP_OWNER_CLOSED);
try {
await resources.close();
return undefined;
} catch (cause: unknown) {
return `Doctor resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
"doctor resources",
);
const failure = new ProviderCleanupError(
"rea",
observation.resources,
{ reason: observation.reason },
{ cause, operation: "doctor" },
);
bindArtifactFileCleanup(cause, failure);
throw failure;
}
},
clientRegistrations: () =>
@@ -10,10 +10,12 @@ import {
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
import type {
AnalysisProfileResolutionOptions,
AnalysisProviderCandidate,
} from "../AnalysisProvider.js";
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
/** Profile-resolution truth for one discovered provider candidate. */
export interface AnalysisProviderCandidateEvaluation {
@@ -28,21 +30,18 @@ export const evaluateAnalysisProviderCandidate = async (
candidate: AnalysisProviderCandidate,
status: AnalysisProviderCandidateStatus,
target: BinaryTarget,
signal: AbortSignal | undefined,
fileCloseResources?: FileCloseResourceOwner,
options: AnalysisProfileResolutionOptions = {},
): Promise<
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
> => {
const { signal } = options;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
try {
resolved = await candidate.resolveAnalysisProfile(target, {
...(signal === undefined ? {} : { signal }),
...(fileCloseResources === undefined ? {} : { fileCloseResources }),
});
resolved = await candidate.resolveAnalysisProfile(target, options);
} catch (cause: unknown) {
resolved = err(
cause instanceof AnalysisError
@@ -779,7 +779,7 @@ it("joins cancelled readiness finalization and shutdown before admitting another
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
inspectAvailability: async ({ signal } = {}) => {
calls++;
if (calls === 1) {
observed = signal;
@@ -936,7 +936,7 @@ it("registers selection before async readiness can race shutdown", async () => {
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
inspectAvailability: async ({ signal } = {}) => {
calls++;
observed = signal;
return base.inspectAvailability();
@@ -1,3 +1,4 @@
import { ProviderInspectionScope } from "../../providers/ProviderInspectionScope.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
@@ -84,7 +85,8 @@ export interface AnalysisProviderSelection {
interface TargetSelectionContext {
readonly target: BinaryTarget;
readonly baseline: readonly AnalysisProviderCandidateStatus[];
readonly signal: AbortSignal | undefined;
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
}
type SelectionResult = Result<AnalysisProviderSelection, AnalysisError>;
@@ -279,11 +281,18 @@ export class AnalysisProviderRegistry {
capabilities: provider.capabilities(),
}),
);
const scope = new ProviderInspectionScope();
const discovery = new SharedStartupAttempt<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
>(
(signal) =>
Promise.resolve().then(() => this.#discoverCandidates(target, signal)),
Promise.resolve().then(() =>
this.#discoverCandidates(target, {
signal,
scope,
operation: "binary_session",
}),
),
options.signal,
);
this.#discoveries.add(discovery);
@@ -299,24 +308,29 @@ export class AnalysisProviderRegistry {
});
return result.value;
} finally {
scope.close();
this.#discoveries.delete(discovery);
}
}
async #discoverCandidates(
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary" = "binary_session",
context: {
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
readonly operation: "binary_session" | "open_binary";
},
): Promise<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
> {
const { signal, operation } = context;
if (signal.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
const results = await Promise.allSettled(
this.#providers.map((provider) =>
candidateStatus(provider, target, signal, operation),
candidateStatus(provider, target, context),
),
);
const statuses: AnalysisProviderCandidateStatus[] = [];
@@ -430,71 +444,78 @@ export class AnalysisProviderRegistry {
requestedSelector: AnalysisProviderSelector | undefined,
signal: AbortSignal,
): Promise<SelectionResult> {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const discovery = await this.#discoverCandidates(
target,
signal,
"open_binary",
);
if (!discovery.ok) return discovery;
const baseline = discovery.value;
const parsedRequest =
requestedSelector === undefined
? undefined
: analysisProviderSelectorSchema.safeParse(requestedSelector);
if (parsedRequest !== undefined && !parsedRequest.success)
return err(
selectionError(
"invalid_options",
typeof requestedSelector === "string"
? requestedSelector
: "<invalid>",
baseline,
),
);
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
const scope = new ProviderInspectionScope();
try {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const discovery = await this.#discoverCandidates(target, {
signal,
scope,
operation: "open_binary",
});
if (
request === undefined &&
this.#byId.has(selector) &&
baseline.length > 0 &&
baseline.every(
({ targetSupport }) => targetSupport.status === "unsupported",
if (!discovery.ok) return discovery;
const baseline = discovery.value;
const parsedRequest =
requestedSelector === undefined
? undefined
: analysisProviderSelectorSchema.safeParse(requestedSelector);
if (parsedRequest !== undefined && !parsedRequest.success)
return err(
selectionError(
"invalid_options",
typeof requestedSelector === "string"
? requestedSelector
: "<invalid>",
baseline,
),
);
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
});
if (
request === undefined &&
this.#byId.has(selector) &&
baseline.length > 0 &&
baseline.every(
({ targetSupport }) => targetSupport.status === "unsupported",
)
)
)
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
});
if (selector !== "auto")
return await this.#selectConcrete(
{
target,
baseline,
signal: signal,
scope,
},
selector,
request === undefined ? "environment" : "request",
);
return await this.#selectAutomatically({
target,
baseline,
signal: signal,
scope,
});
if (selector !== "auto")
return this.#selectConcrete(
{
target,
baseline,
signal: signal,
},
selector,
request === undefined ? "environment" : "request",
);
return this.#selectAutomatically({
target,
baseline,
signal: signal,
});
} finally {
scope.close();
}
}
/** Explain why a deep operation is unavailable on an unbound target. */
@@ -522,7 +543,7 @@ export class AnalysisProviderRegistry {
providerId: string,
source: AnalysisProviderBinding["selectionSource"],
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const { baseline, signal, target, scope } = context;
const provider = this.#byId.get(providerId);
if (provider === undefined)
return err(selectionError("unknown_provider", providerId, baseline));
@@ -541,8 +562,7 @@ export class AnalysisProviderRegistry {
provider,
initial,
target,
signal,
this.#profileFileOwner,
{ signal, fileCloseResources: this.#profileFileOwner, scope },
);
if (!evaluation.ok) return evaluation;
const evaluated = evaluation.value;
@@ -567,7 +587,7 @@ export class AnalysisProviderRegistry {
async #selectAutomatically(
context: TargetSelectionContext,
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const { baseline, signal, target, scope } = context;
const results = await Promise.all(
this.#providers.map(
async (
@@ -584,13 +604,11 @@ export class AnalysisProviderRegistry {
);
return status.availability.status === "available" &&
status.targetSupport.status === "supported"
? evaluateAnalysisProviderCandidate(
provider,
status,
target,
? evaluateAnalysisProviderCandidate(provider, status, target, {
signal,
this.#profileFileOwner,
)
fileCloseResources: this.#profileFileOwner,
scope,
})
: ok({
candidate: provider,
status,
@@ -647,18 +665,22 @@ export class AnalysisProviderRegistry {
const candidateStatus = async (
provider: AnalysisProviderCandidate,
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary",
context: {
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
readonly operation: "binary_session" | "open_binary";
},
): Promise<AnalysisProviderCandidateStatus> => {
const { signal, scope, operation } = context;
if (signal.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const readiness =
provider.inspectReadinessSnapshot === undefined
? {
availability: await provider.inspectAvailability(signal),
availability: await provider.inspectAvailability({ signal, scope }),
capabilities: provider.capabilities(),
}
: await provider.inspectReadinessSnapshot(signal);
: await provider.inspectReadinessSnapshot({ signal, scope });
return projectCandidateStatus(provider, target, readiness);
};
+15 -12
View File
@@ -1,3 +1,4 @@
import { withCommandCancellation } from "./commandCancellation.js";
import { z } from "incur";
import type { DoctorScope } from "../application/Doctor.js";
@@ -120,18 +121,20 @@ const registerDoctorCommand = (
.describe("Require the installed REA skill identity to be aligned"),
}),
run: ({ options }) =>
logCliCommand(logger, "doctor", async () => {
const [{ runDoctorOwned }, { createSystemDoctorHost }] =
await Promise.all([
import("../application/Doctor.js"),
import("../doctorRuntime.js"),
]);
return runDoctorOwned(
options.target,
createSystemDoctorHost(environment),
doctorScope(options),
);
}),
logCliCommand(logger, "doctor", () =>
withCommandCancellation(async (signal) => {
const [{ runDoctorOwned }, { createSystemDoctorHost }] =
await Promise.all([
import("../application/Doctor.js"),
import("../doctorRuntime.js"),
]);
return runDoctorOwned(
options.target,
createSystemDoctorHost(environment, signal),
doctorScope(options),
);
}),
),
});
};
+8 -2
View File
@@ -16,6 +16,7 @@ import { ok } from "./domain/result.js";
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
environment: Readonly<NodeJS.ProcessEnv> = process.env,
signal?: AbortSignal,
): DoctorHost => {
const selectedJebEndpoint = environment.REA_JEB_MCP_URL;
const selectedJebRegistrationEnvironment: Readonly<Record<string, string>> =
@@ -26,6 +27,7 @@ export const createSystemDoctorHost = (
: {};
return systemDoctorHost({
environment,
...(signal === undefined ? {} : { signal }),
providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment,
resolveProviderRequestBudget: (
analysisProvider,
@@ -42,8 +44,12 @@ export const createSystemDoctorHost = (
// parser-owned error when IDA itself was explicitly selected.
return analysisProvider === "ida" ? registration : ok(undefined);
},
providerInspections: async (fileCloseResources) => [
await inspectSystemGhidraProvider(environment),
providerInspections: async (fileCloseResources, inspectionSignal) => [
await inspectSystemGhidraProvider(
environment,
fileCloseResources,
inspectionSignal,
),
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
await inspectJadxProvider(environment),
await inspectKunaProvider(environment, process.platform, {
+33 -19
View File
@@ -1,3 +1,5 @@
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
import { ok as javaProbeOk } from "../domain/result.js";
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
@@ -15,6 +17,12 @@ import {
} from "./GhidraInstallation.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
const installationHost = (
overrides: Partial<GhidraInstallationHost> = {},
@@ -35,16 +43,17 @@ const installationHost = (
...overrides,
});
const inspection = (
const inspection = async (
options: {
readonly installDir?: string;
readonly architecture?: NodeJS.Architecture;
readonly platform?: NodeJS.Platform;
} = { installDir: INSTALL },
overrides: Partial<GhidraInstallationHost> = {},
): GhidraInstallationInspection =>
inspectGhidraInstallation(
): Promise<GhidraInstallationInspection> =>
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
platform: options.platform ?? "linux",
architecture: options.architecture ?? "x64",
@@ -85,9 +94,9 @@ const doctorHost = (
Promise.resolve([projectGhidraDoctorInspection(ghidra)]),
});
describe("Ghidra doctor integration", () => {
describe("Ghidra doctor integration", async () => {
it("accepts a valid BYO Ghidra engine when Hopper is absent", async () => {
const result = await runDoctor(undefined, doctorHost(inspection()));
const result = await runDoctor(undefined, doctorHost(await inspection()));
expect(result.healthy).toBe(true);
expect(result.hopperPath).toBeUndefined();
@@ -111,12 +120,12 @@ describe("Ghidra doctor integration", () => {
it.each([
[
"bad installation",
inspection({ installDir: INSTALL }, { readText: () => undefined }),
await inspection({ installDir: INSTALL }, { readText: () => undefined }),
"ghidra-installation",
],
[
"wrong version",
inspection(
await inspection(
{ installDir: INSTALL },
{
readText: () => "application.version=11.4\napplication.java.min=21\n",
@@ -126,12 +135,12 @@ describe("Ghidra doctor integration", () => {
],
[
"missing analyzeHeadless",
inspection({ installDir: INSTALL }, { executable: () => false }),
await inspection({ installDir: INSTALL }, { executable: () => false }),
"ghidra-headless",
],
[
"wrong Java",
inspection(
await inspection(
{ installDir: INSTALL },
{
probeJava: () =>
@@ -150,7 +159,7 @@ describe("Ghidra doctor integration", () => {
],
[
"missing Java minimum",
inspection(
await inspection(
{ installDir: INSTALL },
{ readText: () => "application.version=12.1.4\n" },
),
@@ -158,7 +167,7 @@ describe("Ghidra doctor integration", () => {
],
[
"unsupported architecture",
inspection({ installDir: INSTALL, architecture: "arm" }),
await inspection({ installDir: INSTALL, architecture: "arm" }),
"ghidra-architecture",
],
] as const)("distinguishes %s", async (_label, ghidra, checkName) => {
@@ -172,7 +181,10 @@ describe("Ghidra doctor integration", () => {
});
it("treats unconfigured Ghidra as optional when Hopper is healthy", async () => {
const result = await runDoctor(undefined, doctorHost(inspection({}), true));
const result = await runDoctor(
undefined,
doctorHost(await inspection({}), true),
);
expect(result.healthy).toBe(true);
expect(result.checks.find(({ name }) => name === "ghidra")).toMatchObject({
@@ -192,7 +204,7 @@ describe("Windows Ghidra doctor authority", () => {
});
afterEach(() => vi.restoreAllMocks());
it("distinguishes a valid Windows installation from unavailable native authority", async () => {
const ghidra = inspection({
const ghidra = await inspection({
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
platform: "win32",
});
@@ -237,7 +249,7 @@ describe("Windows Ghidra doctor authority", () => {
});
it("fails provider-scoped Windows readiness without blaming installation", async () => {
const ghidra = inspection({
const ghidra = await inspection({
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
platform: "win32",
});
@@ -301,7 +313,9 @@ describe("Windows native doctor failure diagnostics", () => {
const result = await runDoctor(
undefined,
{
...doctorHost(inspection({ installDir: INSTALL, platform: "win32" })),
...doctorHost(
await inspection({ installDir: INSTALL, platform: "win32" }),
),
platform: "win32",
linuxDistribution: () => Promise.resolve(undefined),
},
@@ -332,7 +346,7 @@ describe("Windows native doctor failure diagnostics", () => {
},
);
it("keeps one failure observation even when the next query would differ", () => {
it("keeps one failure observation even when the next query would differ", async () => {
const load = vi.mocked(nativeLoader.systemWindowsNativeAuthority);
load.mockReturnValueOnce({
available: false,
@@ -340,7 +354,7 @@ describe("Windows native doctor failure diagnostics", () => {
retryable: true,
reason: "cleanup failed (145)",
});
const ghidra = inspection({ installDir: INSTALL, platform: "win32" });
const ghidra = await inspection({ installDir: INSTALL, platform: "win32" });
const failed = projectGhidraDoctorInspection(ghidra);
expect(load).toHaveBeenCalledTimes(1);
expect(failed.checks).toContainEqual(
@@ -362,9 +376,9 @@ describe("Windows native doctor failure diagnostics", () => {
);
});
it("keeps missing Windows Java actionable as a dependency failure", () => {
it("keeps missing Windows Java actionable as a dependency failure", async () => {
const projected = projectGhidraDoctorInspection(
inspection(
await inspection(
{
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
platform: "win32",
+16 -7
View File
@@ -1,3 +1,5 @@
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import type {
DoctorProviderCheck,
DoctorProviderInspection,
@@ -63,13 +65,17 @@ export const projectGhidraDoctorInspection = (
};
/** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */
export const inspectSystemGhidraProvider = (
export const inspectSystemGhidraProvider = async (
environment: Readonly<NodeJS.ProcessEnv>,
): Promise<DoctorProviderInspection> =>
Promise.resolve(
projectGhidraDoctorInspection(
inspectGhidraInstallation({
resourceOwner: FileCloseResourceOwner,
signal?: AbortSignal,
): Promise<DoctorProviderInspection> => {
try {
return projectGhidraDoctorInspection(
await inspectGhidraInstallation({
environment,
resourceOwner,
...(signal === undefined ? {} : { signal }),
...(environment.GHIDRA_INSTALL_DIR === undefined
? {}
: { installDir: environment.GHIDRA_INSTALL_DIR }),
@@ -77,8 +83,11 @@ export const inspectSystemGhidraProvider = (
? {}
: { javaHome: environment.JAVA_HOME }),
}),
),
);
);
} catch (cause: unknown) {
throw analysisClientFailureForOperation(cause, "ghidra", "doctor");
}
};
const selectedChecks = (
inspection: GhidraInstallationInspection,
+189 -140
View File
@@ -1,10 +1,14 @@
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
import { ok as javaProbeOk } from "../domain/result.js";
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
import {
parseGhidraJavaOutput,
type GhidraJavaObservation,
} from "./GhidraJavaProbe.js";
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { spawnSync } from "node:child_process";
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
@@ -26,12 +30,10 @@ import {
} from "./GhidraReleasePolicy.js";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
// The real Java probe shells out via spawnSync; stub it so one test can run
// the real probeJava against captured `-XshowSettings:properties` output.
// Every other test overrides probeJava, so the stub never fires for them.
vi.mock("node:child_process", async (importOriginal) => {
const original = await importOriginal<typeof import("node:child_process")>();
return { ...original, spawnSync: vi.fn() };
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
const INSTALL = "/opt/ghidra";
@@ -151,9 +153,10 @@ it.skipIf(process.platform === "win32")(
},
);
it("does not fall back to PATH when an explicit Ghidra installation path is malformed", () => {
const inspection = inspectGhidraInstallation(
it("does not fall back to PATH when an explicit Ghidra installation path is malformed", async () => {
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: { PATH: "/valid-ghidra-bin" },
installDir: "relative/ghidra",
platform: "linux",
@@ -170,11 +173,12 @@ it("does not fall back to PATH when an explicit Ghidra installation path is malf
});
});
it("probes the selected Java environment without reading conflicting ambient settings", () => {
it("probes the selected Java environment without reading conflicting ambient settings", async () => {
vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient");
vi.stubEnv("REA_GHIDRA_AMBIENT", "ambient-only");
const inspection = inspectGhidraInstallation(
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: { REA_GHIDRA_CONTEXT: "selected", PATH: "/selected-bin" },
installDir: INSTALL,
platform: "linux",
@@ -216,10 +220,11 @@ describe("Ghidra installation inspection", () => {
});
});
it("accepts the verified Linux x64 Ghidra release and JDK", () => {
it("accepts the verified Linux x64 Ghidra release and JDK", async () => {
expect(
inspectGhidraInstallation(
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -242,9 +247,10 @@ describe("Ghidra installation inspection", () => {
[SUPPORTED_GHIDRA_VERSION, "25.0.1", 25],
])(
"accepts Ghidra %s with JDK %s on the verified release line",
(providerVersion, javaVersion, major) => {
const result = inspectGhidraInstallation(
async (providerVersion, javaVersion, major) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -281,12 +287,18 @@ describe("Ghidra installation inspection", () => {
["linux", "arm64", "linux_arm_64"],
] as const)(
"accepts %s/%s with the matching distribution or locally built native tool",
(platform, architecture, nativeDirectory) => {
async (platform, architecture, nativeDirectory) => {
for (const location of ["os", "build/os"]) {
const decompiler = `${INSTALL}/Ghidra/Features/Decompiler/${location}/${nativeDirectory}/decompile`;
expect(
inspectGhidraInstallation(
{ environment: {}, installDir: INSTALL, platform, architecture },
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform,
architecture,
},
host({
executable: (path) => path === HEADLESS || path === decompiler,
probeJava: () =>
@@ -309,7 +321,7 @@ describe("Ghidra installation inspection", () => {
});
describe("Ghidra Windows installation commitment", () => {
it("accepts the exact Windows x64 batch launcher and JDK commitment", () => {
it("accepts the exact Windows x64 batch launcher and JDK commitment", async () => {
const windowsHost: GhidraInstallationHost = {
platform: "win32",
architecture: "x64",
@@ -331,8 +343,9 @@ describe("Ghidra Windows installation commitment", () => {
};
expect(
inspectGhidraInstallation(
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: WINDOWS_INSTALL,
javaHome: "C:\\Java\\jdk-21",
@@ -475,9 +488,9 @@ describe("Ghidra installation rejection diagnostics", () => {
failed: "java",
code: "unsupported_version",
},
])("distinguishes $name", ({ options, override, failed, code }) => {
const result = inspectGhidraInstallation(
{ ...options, environment: {} },
])("distinguishes $name", async ({ options, override, failed, code }) => {
const result = await inspectGhidraInstallation(
{ resourceOwner: inspectionResources.owner, ...options, environment: {} },
host(override),
);
expect(result.status).toBe("unavailable");
@@ -551,9 +564,10 @@ describe("Ghidra configuration directory identity", () => {
["application.version=12.1.4\napplication.java.min=21\n", null],
])(
"preserves the inspected identity independently of the installation basename",
(content, expected) => {
const inspection = inspectGhidraInstallation(
async (content, expected) => {
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -687,9 +701,10 @@ describe("Ghidra release line and JDK bounds", () => {
});
describe("Ghidra malformed property admission", () => {
it("fails installation and doctor admission closed on malformed Unicode escapes", () => {
const result = inspectGhidraInstallation(
it("fails installation and doctor admission closed on malformed Unicode escapes", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -775,9 +790,10 @@ describe("Ghidra installation version admission", () => {
failed: "version",
code: "unsupported_version",
},
])("distinguishes $name", ({ readText, probeJava, failed, code }) => {
const result = inspectGhidraInstallation(
])("distinguishes $name", async ({ readText, probeJava, failed, code }) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -791,9 +807,10 @@ describe("Ghidra installation version admission", () => {
).toMatchObject({ name: failed, code });
});
it("names the accepted Ghidra line and JDK range in failure remediation", () => {
const result = inspectGhidraInstallation(
it("names the accepted Ghidra line and JDK range in failure remediation", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
javaHome: "/usr/lib/jvm/java-17-openjdk",
@@ -821,10 +838,11 @@ describe("Ghidra installation version admission", () => {
});
});
it("accepts a lower Java minimum declared by the installation", () => {
it("accepts a lower Java minimum declared by the installation", async () => {
expect(
inspectGhidraInstallation(
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -855,9 +873,15 @@ describe("Ghidra configuration paths", () => {
],
])(
"rejects relative paths that REA's configuration parser rejects (%o)",
(paths, detail) => {
const result = inspectGhidraInstallation(
{ environment: {}, ...paths, platform: "linux", architecture: "x64" },
async (paths, detail) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
...paths,
platform: "linux",
architecture: "x64",
},
host(),
);
expect(result).toMatchObject({
@@ -884,10 +908,11 @@ describe("Ghidra configuration paths", () => {
["win32", "jdk"],
] as const)(
"does not run Java from a relative JAVA_HOME (%s %o)",
(platform, javaHome) => {
async (platform, javaHome) => {
const probed: string[] = [];
const result = inspectGhidraInstallation(
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: platform === "win32" ? WINDOWS_INSTALL : INSTALL,
javaHome,
@@ -909,9 +934,10 @@ describe("Ghidra configuration paths", () => {
},
);
it("judges absolute paths by the inspected platform", () => {
const result = inspectGhidraInstallation(
it("judges absolute paths by the inspected platform", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: "C:\\ghidra",
platform: "win32",
@@ -945,9 +971,10 @@ describe("Ghidra FAT native decompiler admission", () => {
status: "available",
detail: "could not be established",
},
])("distinguishes $name", ({ bytes, status, detail }) => {
const result = inspectGhidraInstallation(
])("distinguishes $name", async ({ bytes, status, detail }) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -969,11 +996,12 @@ describe("Ghidra FAT native decompiler admission", () => {
).toContain(detail);
});
it("keeps a truncated host slice unknown despite a readable foreign slice", () => {
it("keeps a truncated host slice unknown despite a readable foreign slice", async () => {
const bytes = fat([0x01000007, 0x0100000c]);
bytes.writeUInt32BE(bytes.length + 1, 8 + 20 + 12);
const result = inspectGhidraInstallation(
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1033,9 +1061,9 @@ describe("Ghidra native decompiler admission", () => {
? headerProbe(thinMach(0xcffaedfe, 0x0100000c))
: undefined,
},
])("distinguishes $name", ({ options, executableHeader }) => {
const result = inspectGhidraInstallation(
options,
])("distinguishes $name", async ({ options, executableHeader }) => {
const result = await inspectGhidraInstallation(
{ ...options, resourceOwner: inspectionResources.owner },
host({
probeJava: () =>
javaProbeOk<GhidraJavaObservation>({
@@ -1052,9 +1080,10 @@ describe("Ghidra native decompiler admission", () => {
).toMatchObject({ name: "native_decompiler", code: "executable_missing" });
});
it("explains an accepted decompiler by its header, not only its path", () => {
const result = inspectGhidraInstallation(
it("explains an accepted decompiler by its header, not only its path", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -1077,9 +1106,10 @@ describe("Ghidra native decompiler admission", () => {
});
describe("Ghidra native decompiler compatibility", () => {
it("keeps an unreadable header unknown instead of failing the install", () => {
const result = inspectGhidraInstallation(
it("keeps an unreadable header unknown instead of failing the install", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -1097,10 +1127,11 @@ describe("Ghidra native decompiler compatibility", () => {
});
});
it("accepts a one-slice universal-format Darwin native decompiler", () => {
it("accepts a one-slice universal-format Darwin native decompiler", async () => {
const bytes = fat([0x0100000c]);
const result = inspectGhidraInstallation(
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1127,9 +1158,10 @@ describe("Ghidra native decompiler compatibility", () => {
});
});
it("names the observed and the required executable in the failure", () => {
const result = inspectGhidraInstallation(
it("names the observed and the required executable in the failure", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1161,9 +1193,10 @@ describe("Ghidra native decompiler compatibility", () => {
});
describe("Ghidra JVM native directory admission", () => {
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
const result = inspectGhidraInstallation(
it("rejects a JDK whose os.arch selects a different native decompiler directory", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1192,9 +1225,10 @@ describe("Ghidra JVM native directory admission", () => {
});
});
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
const result = inspectGhidraInstallation(
it("accepts a JDK whose os.arch selects the validated native decompiler directory", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1212,10 +1246,11 @@ describe("Ghidra JVM native directory admission", () => {
expect(result.status).toBe("available");
});
it("rejects a successful Java probe that omits os.arch", () => {
it("rejects a successful Java probe that omits os.arch", async () => {
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
const result = inspectGhidraInstallation(
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1238,9 +1273,10 @@ describe("Ghidra JVM native directory admission", () => {
it.each(["riscv64", "sparcv9", ""])(
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
(osArch) => {
const result = inspectGhidraInstallation(
async (osArch) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1262,9 +1298,10 @@ describe("Ghidra JVM native directory admission", () => {
},
);
it("rejects a JDK whose os.name selects a different host OS", () => {
const result = inspectGhidraInstallation(
it("rejects a JDK whose os.name selects a different host OS", async () => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1293,10 +1330,11 @@ describe("Ghidra JVM native directory admission", () => {
});
describe("Ghidra JVM OS admission", () => {
it("rejects a successful Java probe that omits os.name", () => {
it("rejects a successful Java probe that omits os.name", async () => {
const { osName: _observedOsName, ...withoutOsName } = JAVA;
const result = inspectGhidraInstallation(
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -1318,9 +1356,10 @@ describe("Ghidra JVM OS admission", () => {
it.each(["SunOS", "FreeBSD", ""])(
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
(osName) => {
const result = inspectGhidraInstallation(
async (osName) => {
const result = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "linux",
@@ -1344,49 +1383,52 @@ describe("Ghidra JVM OS admission", () => {
});
describe("Ghidra Java probe os.arch extraction", () => {
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
vi.mocked(spawnSync).mockReturnValue({
status: 0,
stdout: Buffer.alloc(0),
signal: null,
stderr: Buffer.from(
[
"Property settings:",
" file.encoding = UTF-8",
" java.home = /usr/lib/jvm/jdk-21",
" java.version = 21.0.11",
" java.vm.name = OpenJDK 64-Bit Server VM",
" os.arch = x86_64",
" os.name = Linux",
" sun.arch.data.model = 64",
"",
].join("\n"),
),
} as never);
try {
// No host override: the real system host runs the real probeJava
// against the stubbed spawnSync.
const inspection = inspectGhidraInstallation({
it("extracts os.arch through the real Java probe into the installation diagnostics", async () => {
const stderr = [
"Property settings:",
" java.home = /usr/lib/jvm/jdk-21",
" java.version = 21.0.11",
" java.vm.name = OpenJDK 64-Bit Server VM",
" os.arch = x86_64",
" os.name = Linux",
" sun.arch.data.model = 64",
"",
].join("\n");
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
javaHome: "/usr/lib/jvm/jdk-21",
platform: "linux",
architecture: "x64",
});
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
java_os_arch: "x86_64",
java_os_name: "Linux",
});
} finally {
vi.mocked(spawnSync).mockReset();
}
},
host({
probeJava: (command) =>
parseGhidraJavaOutput(command, {
exitCode: 0,
signal: null,
stdout: { text: "", bytes: 0, observedBytes: 0 },
stderr: {
text: stderr,
bytes: Buffer.byteLength(stderr),
observedBytes: Buffer.byteLength(stderr),
},
}),
}),
);
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
java_os_arch: "x86_64",
java_os_name: "Linux",
});
});
});
it("explains the macOS keg-only JDK case without probing an unselected installation", () => {
it("explains the macOS keg-only JDK case without probing an unselected installation", async () => {
const probed: string[] = [];
const inspection = inspectGhidraInstallation(
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir: INSTALL,
platform: "darwin",
@@ -1414,43 +1456,49 @@ it("explains the macOS keg-only JDK case without probing an unselected installat
it.each([
["arm64", "/opt/homebrew"],
["x64", "/usr/local"],
] as const)("discovers keg-only Java for macOS %s", (architecture, prefix) => {
const probed: string[] = [];
const checked: string[] = [];
const inspection = inspectGhidraInstallation(
{
environment: { PATH: ":relative-bin:/empty-bin" },
installDir: INSTALL,
platform: "darwin",
architecture,
},
host({
executable: (candidate) => {
checked.push(candidate);
return (
candidate.includes("/opt/openjdk/") || host().executable(candidate)
);
] as const)(
"discovers keg-only Java for macOS %s",
async (architecture, prefix) => {
const probed: string[] = [];
const checked: string[] = [];
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: { PATH: ":relative-bin:/empty-bin" },
installDir: INSTALL,
platform: "darwin",
architecture,
},
probeJava: (command) => {
probed.push(command);
return javaProbeOk({
...JAVA,
home: `${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home`,
osArch: architecture === "arm64" ? "aarch64" : "x86_64",
osName: "Mac OS X",
});
host({
executable: (candidate) => {
checked.push(candidate);
return (
candidate.includes("/opt/openjdk/") || host().executable(candidate)
);
},
probeJava: (command) => {
probed.push(command);
return javaProbeOk({
...JAVA,
home: `${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home`,
osArch: architecture === "arm64" ? "aarch64" : "x86_64",
osName: "Mac OS X",
});
},
}),
);
expect(probed).toEqual([
`${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java`,
]);
expect(checked).not.toContain("java");
expect(checked).not.toContain("relative-bin/java");
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject(
{
status: "passed",
},
}),
);
expect(probed).toEqual([
`${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java`,
]);
expect(checked).not.toContain("java");
expect(checked).not.toContain("relative-bin/java");
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
status: "passed",
});
});
);
},
);
it.each([
["darwin", undefined, "/selected-bin/java", "java"],
@@ -1459,11 +1507,12 @@ it.each([
["win32", undefined, undefined, "java.exe"],
] as const)(
"preserves selected Java on %s with JAVA_HOME %s",
(platform, javaHome, pathJava, expected) => {
async (platform, javaHome, pathJava, expected) => {
const checked: string[] = [];
const probed: string[] = [];
inspectGhidraInstallation(
await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: { PATH: "/selected-bin" },
installDir: INSTALL,
platform,
+49 -10
View File
@@ -1,7 +1,11 @@
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import {
probeGhidraJava,
type GhidraJavaObservation,
type GhidraJavaProbeFailure,
type GhidraJavaProbeOptions,
} from "./GhidraJavaProbe.js";
import type { Result } from "../domain/result.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
@@ -162,6 +166,8 @@ const assessNativeDecompiler = (
/** Caller-owned paths and host coordinates used for one installation probe. */
export interface GhidraInstallationOptions {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly resourceOwner: FileCloseResourceOwner;
readonly signal?: AbortSignal;
readonly installDir?: string;
readonly javaHome?: string;
readonly platform?: NodeJS.Platform;
@@ -257,7 +263,7 @@ export interface GhidraExecutableHeaderProbe {
readonly size: number;
}
/** Narrow synchronous seam used by provider discovery without launching Ghidra. */
/** Host observations and owned Java prerequisite work; never launches Ghidra. */
export interface GhidraInstallationHost {
readonly platform?: NodeJS.Platform;
readonly architecture?: NodeJS.Architecture;
@@ -273,7 +279,12 @@ export interface GhidraInstallationHost {
probeJava(
command: string,
environment: NodeJS.ProcessEnv,
): Result<GhidraJavaObservation, GhidraJavaProbeFailure>;
options: GhidraJavaProbeOptions,
):
| Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
| Promise<
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
>;
/** Resolve a PATH launcher to a validated installation without executing it. */
discoverInstallDirOnPath?(
environment: Readonly<NodeJS.ProcessEnv>,
@@ -301,13 +312,17 @@ interface GhidraInstallationCheckContext {
}
/** Inspect an installation without importing a target or modifying Ghidra. */
export const inspectGhidraInstallation = (
export const inspectGhidraInstallation = async (
options: GhidraInstallationOptions,
host: GhidraInstallationHost = systemGhidraInstallationHost(),
): GhidraInstallationInspection => {
): Promise<GhidraInstallationInspection> => {
if (options.signal?.aborted)
throw new AnalysisCancelledError("ghidra:installation", {
cause: options.signal.reason,
});
const platform = options.platform ?? process.platform;
const architecture = options.architecture ?? process.arch;
const coordinates = installationCoordinates(
const coordinates = await installationCoordinates(
options,
platform,
architecture,
@@ -359,6 +374,13 @@ export const inspectGhidraInstallation = (
javaVersion: javaObservation?.version ?? null,
rejection: { code: failed.code, reason: failed.remediation },
};
if (options.signal?.aborted)
throw new AnalysisCancelledError("ghidra:installation", {
cause: options.signal.reason,
diagnostics: ghidraInstallationDiagnostics(
availableInstallation(coordinates, observation),
),
});
return availableInstallation(coordinates, observation);
};
@@ -390,12 +412,12 @@ const availableInstallation = (
};
};
const installationCoordinates = (
const installationCoordinates = async (
options: GhidraInstallationOptions,
platform: NodeJS.Platform,
architecture: NodeJS.Architecture,
host: GhidraInstallationHost,
): GhidraInstallationCoordinates => {
): Promise<GhidraInstallationCoordinates> => {
const path = platform === "win32" ? win32 : posix;
// An explicit setting remains authoritative even when malformed or stale;
// automatic discovery is only a fallback when the caller supplied none.
@@ -481,15 +503,25 @@ const installationCoordinates = (
// Relative JAVA_HOME is refused by configuration validation without execution.
const javaProbe =
options.javaHome === undefined || path.isAbsolute(options.javaHome)
? host.probeJava(
? await host.probeJava(
javaCommand,
ghidraJavaEnvironment(
options.javaHome,
options.environment,
platform,
),
{
resourceOwner: options.resourceOwner,
...(options.signal === undefined ? {} : { signal: options.signal }),
},
)
: undefined;
if (
javaProbe !== undefined &&
!javaProbe.ok &&
javaProbe.error instanceof AnalysisError
)
throw javaProbe.error;
return {
installDir,
applicationPropertiesPath,
@@ -502,7 +534,11 @@ const installationCoordinates = (
javaCommand,
java: javaProbe?.ok === true ? javaProbe.value : undefined,
javaProbeFailure:
javaProbe !== undefined && !javaProbe.ok ? javaProbe.error : undefined,
javaProbe !== undefined &&
!javaProbe.ok &&
!(javaProbe.error instanceof AnalysisError)
? javaProbe.error
: undefined,
};
};
@@ -617,6 +653,9 @@ export const ghidraInstallationDiagnostics = (
inspection.javaProbeFailure.processErrorCode,
}),
detail: inspection.javaProbeFailure.detail,
...(inspection.javaProbeFailure.cancellation === undefined
? {}
: { cancellation_observed: true }),
...(inspection.javaProbeFailure.properties === undefined
? {}
: { properties: { ...inspection.javaProbeFailure.properties } }),
@@ -902,7 +941,7 @@ const javaRemediation = (
: `Update JAVA_HOME to the root of ${range} installation.`;
};
const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
readText(path) {
try {
// Ghidra loads this file with Properties.load(InputStream), which maps
+56 -34
View File
@@ -1,24 +1,33 @@
import { spawnSync } from "node:child_process";
import { afterEach, describe, expect, it, vi } from "vitest";
import { probeGhidraJava } from "./GhidraJavaProbe.js";
vi.mock("node:child_process", async (original) => ({
...(await original<typeof import("node:child_process")>()),
spawnSync: vi.fn(),
}));
afterEach(() => vi.mocked(spawnSync).mockReset());
const probe = (values: Partial<ReturnType<typeof spawnSync>>) => {
vi.mocked(spawnSync).mockReturnValue({
pid: 1,
status: 0,
signal: null,
stdout: Buffer.alloc(0),
stderr: Buffer.alloc(0),
output: [],
...values,
} as never);
return probeGhidraJava("/selected/bin/java", {});
import { describe, expect, it, afterEach } from "vitest";
import { probeGhidraJava, parseGhidraJavaOutput } from "./GhidraJavaProbe.js";
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
let resources = createInspectionResources();
afterEach(async () => {
await resources.close();
resources = createInspectionResources();
});
const probe = (values: {
readonly stdout?: Buffer;
readonly stderr?: Buffer;
readonly status?: number | null;
readonly signal?: NodeJS.Signals | null;
}) => {
const stdout = values.stdout ?? Buffer.alloc(0),
stderr = values.stderr ?? Buffer.alloc(0);
return parseGhidraJavaOutput("/selected/bin/java", {
stdout: {
text: stdout.toString("utf8"),
bytes: stdout.length,
observedBytes: stdout.length,
},
stderr: {
text: stderr.toString("utf8"),
bytes: stderr.length,
observedBytes: stderr.length,
},
exitCode: values.status === undefined ? 0 : values.status,
signal: values.signal ?? null,
});
};
describe("Ghidra Java prerequisite process facts", () => {
@@ -27,19 +36,32 @@ describe("Ghidra Java prerequisite process facts", () => {
["EACCES", "runtime_unavailable"],
["ETIMEDOUT", "runtime_unavailable"],
["ENOBUFS", "runtime_unavailable"],
])("keeps %s distinct from other runtime failures", (code, expected) => {
const error = Object.assign(new Error(`controlled ${code}`), { code });
expect(probe({ error, status: null })).toMatchObject({
ok: false,
error: {
_tag: "GhidraJavaProbeFailure",
code: expected,
processErrorCode: code,
detail: expect.stringContaining(code),
output: { exit_code: null, truncated: code === "ENOBUFS" },
},
});
});
])(
"keeps %s distinct from other runtime failures",
async (code, expected) => {
const error = Object.assign(new Error(`controlled ${code}`), { code });
const result = await probeGhidraJava(
"/selected/bin/java",
{},
{
resourceOwner: resources.owner,
launcher: async () => {
throw error;
},
},
);
expect(result).toMatchObject({
ok: false,
error: {
_tag: "GhidraJavaProbeFailure",
code: expected,
processErrorCode: code,
detail: expect.stringContaining(code),
},
});
expect(result.ok ? null : result.error).not.toHaveProperty("output");
},
);
it("keeps raw byte counts and both streams on a nonzero exit", () => {
const result = probe({
+296 -46
View File
@@ -1,4 +1,20 @@
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { randomUUID } from "node:crypto";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
import {
OwnedCommandFailure,
runOwnedCommand,
} from "../process/OwnedCommand.js";
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
import { constants, accessSync, statSync } from "node:fs";
import { posix, win32 } from "node:path";
import { err, ok, type Result } from "../domain/result.js";
@@ -44,6 +60,8 @@ export interface GhidraJavaProbeFailure {
| "runtime_unavailable"
| "version_unresolved";
readonly detail: string;
/** Non-enumerable caller reason observed during finalization; the probe failure stays primary. */
readonly cancellation?: { readonly reason: unknown };
readonly processErrorCode?: string;
readonly output?: AnalysisCapturedOutput;
readonly properties?: {
@@ -58,23 +76,28 @@ export interface GhidraJavaProbeFailure {
const JAVA_DIAGNOSTIC_STREAM_BYTES = 8192;
const capturedProbeOutput = (
observed: SpawnSyncReturns<Buffer>,
observed: ProviderProcessSnapshot,
): AnalysisCapturedOutput => {
const stdout = observed.stdout ?? Buffer.alloc(0);
const stderr = observed.stderr ?? Buffer.alloc(0);
const stdout = boundedUtf8Text(
observed.stdout.text,
JAVA_DIAGNOSTIC_STREAM_BYTES,
);
const stderr = boundedUtf8Text(
observed.stderr.text,
JAVA_DIAGNOSTIC_STREAM_BYTES,
);
return {
stdout: stdout.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
stderr: stderr.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
stdout: stdout.text,
stderr: stderr.text,
truncated:
stdout.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
stderr.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
(observed.error !== undefined &&
"code" in observed.error &&
observed.error.code === "ENOBUFS"),
stdout_bytes: stdout.length,
stderr_bytes: stderr.length,
exit_code: observed.status,
signal: observed.signal,
stdout.truncated ||
stderr.truncated ||
observed.diagnosticTruncated === true ||
observed.outputIncomplete === true,
stdout_bytes: observed.stdout.observedBytes,
stderr_bytes: observed.stderr.observedBytes,
exit_code: observed.exitCode ?? null,
signal: observed.signal ?? null,
};
};
@@ -86,44 +109,271 @@ const executablePath = (path: string): boolean => {
return false;
}
};
export const probeGhidraJava = (
export interface GhidraJavaProbeOptions {
readonly resourceOwner: FileCloseResourceOwner;
readonly signal?: AbortSignal;
readonly launcher?: NonNullable<
Parameters<typeof runOwnedCommand>[2]
>["launcher"];
/** Tests can shorten the production five-second deadline. */
readonly timeoutMs?: number;
}
/** Admit one complete probe into its caller's durable cleanup scope. */
export const probeGhidraJava = async (
command: string,
environment: NodeJS.ProcessEnv,
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
const observed = spawnSync(
command,
[
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
// JDK 17/18 use the legacy names; JDK 19+ use the names above.
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-XX:-UsePerfData",
"-XshowSettings:properties",
"-version",
],
{
encoding: "buffer",
env: environment,
timeout: 5_000,
maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES,
windowsHide: true,
options: GhidraJavaProbeOptions,
): Promise<
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
> => {
try {
return await options.resourceOwner.run(() =>
observeGhidraJava(command, environment, options),
);
} catch (cause: unknown) {
return err(
analysisClientFailureForOperation(cause, "ghidra", "ghidra:java"),
);
}
};
/** Own a bounded prerequisite process, its interpretation, and exact cleanup retries. */
const observeGhidraJava = async (
command: string,
environment: NodeJS.ProcessEnv,
options: GhidraJavaProbeOptions,
): Promise<
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
> => {
let interpreted:
| Result<GhidraJavaObservation, GhidraJavaProbeFailure>
| undefined;
try {
await runOwnedCommand(
{
command,
arguments: [
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
"-XX:-UsePerfData",
"-XshowSettings:properties",
"-version",
],
runId: randomUUID(),
expectedCommand: null,
hostEnvironment: environment,
sweepTokenOwnedProcesses: true,
},
{
timeoutMs: options.timeoutMs ?? 5000,
diagnosticBytes: JAVA_PROBE_MAX_OUTPUT_BYTES,
acceptNonZeroExit: true,
observeCompleted: (snapshot) => {
interpreted = parseGhidraJavaOutput(command, snapshot);
},
},
{
signal: options.signal,
...(options.launcher === undefined
? {}
: { launcher: options.launcher }),
},
);
if (interpreted === undefined)
throw new Error("Java command completed without an output observation");
return interpreted;
} catch (cause: unknown) {
const commandFailure =
cause instanceof OwnedCommandFailure ? cause : undefined;
const preparation =
commandFailure?.preparationFailure ??
(cause instanceof DarwinProcessOwnershipInspectionError
? cause
: undefined);
const output =
commandFailure?.snapshot === undefined || commandFailure.snapshot === null
? preparation?.capturedOutput
: capturedProbeOutput(commandFailure.snapshot);
if (
interpreted !== undefined &&
!interpreted.ok &&
commandFailure?.cancellation !== undefined
)
Object.defineProperty(interpreted.error, "cancellation", {
value: commandFailure.cancellation,
});
let primary: AnalysisError | undefined;
if (interpreted !== undefined && !interpreted.ok) {
// A completed, invalid report remains the primary fact across cleanup-time cancellation.
if (
commandFailure?.cleanupOwner === undefined &&
preparation?.cleanupFailure === undefined
)
return interpreted;
primary = probeFailureError(interpreted.error, cause);
} else if (
commandFailure?.reason === "cancelled" &&
commandFailure.cancellation !== undefined
) {
primary = new AnalysisCancelledError("ghidra:java", {
cause: commandFailure.cancellation.reason,
...(output === undefined ? {} : { capturedOutput: output }),
...(interpreted?.ok === true
? {
diagnostics: {
java_observation: jsonValueSchema.parse(interpreted.value),
},
}
: {}),
});
} else if (
interpreted?.ok === true &&
commandFailure?.cleanupOwner !== undefined
) {
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
java_observation: jsonValueSchema.parse(interpreted.value),
stage: "cleanup",
...(commandFailure.cancellation === undefined
? {}
: { cancellation_observed: true }),
},
});
} else if (preparation !== undefined) {
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
stage: "ownership_preparation",
...(preparation.compilerOutcome === undefined
? {}
: {
compiler_outcome: jsonValueSchema.parse(
preparation.compilerOutcome,
),
}),
},
});
} else if (cause instanceof AnalysisError) {
primary = cause;
}
const cleanups: AnalysisError[] = [];
if (preparation?.cleanupFailure !== undefined) {
const cleanup = preparation.cleanupFailure;
const lease = new AnalysisCleanupLease(async () => {
if (!cleanup.cleanupIncomplete) return;
if (preparation.cleanupOwner === undefined) throw cleanup;
await preparation.cleanupOwner.close();
if (cleanup.cleanupIncomplete) throw cleanup;
}, cleanup.cleanupResources);
options.resourceOwner.retain(
{ close: () => lease.release() },
cleanup.cleanupResources[0] ??
"Java prerequisite ownership preparation",
);
cleanups.push(cleanup);
}
if (commandFailure?.cleanupOwner !== undefined) {
const supervisor = commandFailure.cleanupOwner;
const cleanup = new ProviderCleanupError(
"ghidra",
commandFailure.resources,
{
reason:
commandFailure.cleanupFailure ??
"Java prerequisite cleanup remains incomplete",
...(commandFailure.cleanupResult === undefined
? {}
: {
process_cleanup: jsonValueSchema.parse(
commandFailure.cleanupResult,
),
}),
},
{ cause, operation: "ghidra:java" },
);
const lease = new AnalysisCleanupLease(async () => {
const stopped = await supervisor.stop();
if (stopped.status === "incomplete") throw cleanup;
}, commandFailure.resources);
cleanup.bindCleanupResourceOwner(lease);
lease.bindCleanupError(cleanup);
options.resourceOwner.retain(
{ close: () => lease.release() },
commandFailure.resources[0] ?? "Java prerequisite process",
);
cleanups.push(cleanup);
}
if (primary === undefined) {
const code =
commandFailure?.reason === "timeout"
? "ETIMEDOUT"
: commandFailure?.reason === "output-limit"
? "ENOBUFS"
: systemCode(commandFailure?.processError ?? cause);
const failed: GhidraJavaProbeFailure = {
_tag: "GhidraJavaProbeFailure",
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
detail: `${command}: ${cause instanceof Error ? cause.message : String(cause)}`,
...(code === undefined ? {} : { processErrorCode: code }),
...(output === undefined ? {} : { output }),
};
if (commandFailure?.cancellation !== undefined)
Object.defineProperty(failed, "cancellation", {
value: commandFailure.cancellation,
});
if (cleanups.length === 0) return err(failed);
primary = probeFailureError(failed, cause);
}
for (const cleanup of cleanups)
primary = analysisErrorWithCleanupFailure(
primary,
cleanup,
"ghidra:java",
).bindCleanupDependencies([primary, cleanup]);
return err(primary);
}
};
const systemCode = (cause: unknown): string | undefined =>
cause instanceof Error && "code" in cause && typeof cause.code === "string"
? cause.code
: undefined;
const probeFailureError = (
failure: GhidraJavaProbeFailure,
cause: unknown,
): AnalysisError => {
const { output, ...facts } = failure;
return new ProviderAdapterError("ghidra", "ghidra:java", {
cause,
...(output === undefined ? {} : { capturedOutput: output }),
diagnostics: {
java_probe_failure: jsonValueSchema.parse(facts),
...(failure.cancellation === undefined
? {}
: { cancellation_observed: true }),
},
);
if (observed.error !== undefined || observed.status !== 0) {
const code =
observed.error !== undefined && "code" in observed.error
? observed.error.code
: undefined;
});
};
/** Parse only complete command output; callers retain interruption separately. */
export const parseGhidraJavaOutput = (
command: string,
observed: ProviderProcessSnapshot,
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
if (observed.exitCode !== 0 || observed.signal !== null)
return err({
_tag: "GhidraJavaProbeFailure",
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
...(typeof code === "string" ? { processErrorCode: code } : {}),
detail: `${command}: ${observed.error?.message ?? `Java exited ${String(observed.status)}${observed.signal === null ? "" : ` (${observed.signal})`}`}`,
code: "runtime_unavailable",
detail: `${command}: Java exited ${String(observed.exitCode)}${observed.signal == null ? "" : ` (${observed.signal})`}`,
output: capturedProbeOutput(observed),
});
}
const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`;
const output = `${observed.stdout.text}\n${observed.stderr.text}`;
const version = javaProperty(output, "java.version") || null;
const home = javaProperty(output, "java.home") || null;
const vmName = javaProperty(output, "java.vm.name");
+3 -1
View File
@@ -51,7 +51,9 @@ const target = (change: Partial<PowerPcElfMetadata> = {}): BinaryTarget => ({
describe("bounded Ghidra PowerPC admission", () => {
it("binds the generic language instead of the loader's e500 choice", async () => {
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "available",
});
expect(ghidra.inspectTargetSupport(target()).status).toBe("supported");
const resolved = await ghidra.resolveAnalysisProfile(target());
if (!resolved.ok) throw resolved.error;
+185 -8
View File
@@ -1,3 +1,4 @@
import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
import { ok as javaProbeOk } from "../domain/result.js";
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js";
@@ -89,6 +90,69 @@ const createElfClient = async (ghidra: GhidraProvider) => {
return ghidra.createClient(target, resolved.value.profile);
};
it.each(["readiness", "client"] as const)(
"keeps completed Java prerequisite failure primary across late cancellation in %s",
async (consumer) => {
const controller = new AbortController();
const reason = { request: "late Java cancellation" };
let armed = false;
let nativeCreates = 0;
const base = installationHost();
const ghidra = provider(
{
...base,
probeJava: (command, environment, options) => {
if (!armed) return base.probeJava(command, environment, options);
controller.abort(reason);
return err({
_tag: "GhidraJavaProbeFailure",
code: "version_unresolved",
detail: "completed invalid Java report",
});
},
},
() => {
nativeCreates++;
throw new Error("A failed prerequisite cannot create a native client");
},
);
const client =
consumer === "client" ? await createElfClient(ghidra) : undefined;
armed = true;
try {
if (client === undefined) {
const availability = await ghidra.inspectAvailability({
signal: controller.signal,
});
expect(availability).toMatchObject({
status: "unavailable",
diagnostics: { java_probe_failure: { code: "version_unresolved" } },
});
} else {
const result = await client.execute(
"analyze_function",
{},
{ signal: controller.signal },
);
if (result.ok)
throw new Error("Expected physical prerequisite failure");
expect(result.error).toMatchObject({
_tag: "ProviderAdapterError",
operation: "analyze_function",
diagnostics: { java_probe_failure: { code: "version_unresolved" } },
});
expect(projectAnalysisError(result.error).code).toBe(
"execution_failure",
);
}
expect(nativeCreates).toBe(0);
} finally {
await client?.close();
await ghidra.closeOwnedResources();
}
},
);
it.each([
"program-info-v1",
"program-info-v2",
@@ -170,6 +234,8 @@ it("carries the inspected release identity into the production launcher", async
},
);
const client = await createElfClient(ghidra);
expect(launchers).toHaveLength(0);
expect((await client.execute("health", {})).ok).toBe(true);
expect(launchers[0]?.options).toMatchObject({
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
});
@@ -205,7 +271,7 @@ describe("Ghidra provider", () => {
const windows = provider(installationHost("win32"));
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
});
it("discovers the exact installation once without launching Ghidra", () => {
it("re-observes the exact installation for each fresh task without launching Ghidra", async () => {
let probeCount = 0;
const host: GhidraInstallationHost = {
...installationHost(),
@@ -244,7 +310,7 @@ describe("Ghidra provider", () => {
}),
]),
);
expect(ghidra.inspectAvailability()).toMatchObject({
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "available",
diagnostics: {
install_dir: INSTALL,
@@ -252,8 +318,10 @@ describe("Ghidra provider", () => {
java_version: "21.0.11",
},
});
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
expect(probeCount).toBe(1);
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "available",
});
expect(probeCount).toBe(2);
});
it("separates target kind, format, and concrete architecture", () => {
@@ -566,7 +634,7 @@ describe("Ghidra platform support", () => {
});
describe("Ghidra Windows platform support", () => {
it("keeps Windows P0 unavailable until native isolation authority exists", () => {
it("keeps Windows P0 unavailable until native isolation authority exists", async () => {
const native = vi
.spyOn(nativeLoader, "systemWindowsNativeAuthority")
.mockReturnValue({
@@ -589,7 +657,7 @@ describe("Ghidra Windows platform support", () => {
},
},
);
expect(ghidra.inspectAvailability()).toMatchObject({
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "unavailable",
code: "runtime_missing",
reason: expect.stringContaining(
@@ -694,7 +762,7 @@ describe("Windows Ghidra native failure diagnostics", () => {
managed: false,
},
});
expect(ghidra.inspectAvailability()).toMatchObject({
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "unavailable",
code,
reason: expect.stringContaining(
@@ -2342,7 +2410,7 @@ describe("compatible Ghidra builds", () => {
);
const health = await client.execute("health", {});
const procedures = await client.execute("list_procedures", {});
expect(ghidra.inspectAvailability()).toMatchObject({
expect(await ghidra.inspectAvailability()).toMatchObject({
status: "available",
diagnostics: { provider_version: "12.1.2", java_version: "27" },
});
@@ -2708,6 +2776,8 @@ describe("Ghidra disabled analyzer options", () => {
analysis_options: { disabled_analyzers: ["Decompiler Switch Analysis"] },
});
const client = ghidra.createClient(target, resolved.value.profile);
expect(launchers).toHaveLength(0);
expect((await client.execute("health", {})).ok).toBe(true);
expect(launchers).toHaveLength(1);
expect(launchers[0]?.options).toMatchObject({
disabledAnalyzers: ["Decompiler Switch Analysis"],
@@ -3060,3 +3130,110 @@ describe("Ghidra OOME error projection", () => {
expect(projected.code).not.toBe("resource_constraint");
});
});
it("reuses readiness only inside its active task and provider lifetime", async () => {
let probes = 0;
let version = "21.0.11";
const base = installationHost();
const ghidra = provider({
...base,
probeJava: async (command, environment, options) => {
probes++;
const observed = await base.probeJava(command, environment, options);
return observed.ok
? javaProbeOk({ ...observed.value, version })
: observed;
},
});
const scope = new ProviderInspectionScope();
try {
expect((await ghidra.inspectAvailability({ scope })).status).toBe(
"available",
);
expect(
(
await ghidra.resolveAnalysisProfile(executableTarget("elf", "x86_64"), {
scope,
})
).ok,
).toBe(true);
expect(probes).toBe(1);
version = "21.0.12";
expect(await ghidra.closeOwnedResources()).toMatchObject({ ok: true });
expect(
(await ghidra.inspectAvailability({ scope })).diagnostics.java_version,
).toBe(version);
expect(probes).toBe(2);
scope.close();
version = "21.0.13";
expect(
(await ghidra.inspectAvailability({ scope })).diagnostics.java_version,
).toBe(version);
expect(probes).toBe(3);
} finally {
scope.close();
await ghidra.closeOwnedResources();
}
});
it("refuses a changed installation before creating a target-bound native client", async () => {
let release = "12.1.4",
nativeCreates = 0;
const ghidra = provider(
{
...installationHost(),
readText: () =>
`application.version=${release}\napplication.java.min=21\n`,
},
() => {
nativeCreates++;
throw new Error("Changed installation must not create a native client");
},
);
const target = executableTarget("elf", "x86_64");
const resolved = await ghidra.resolveAnalysisProfile(target);
if (!resolved.ok || resolved.value.profile === null)
throw new Error("Expected initial profile");
const client = ghidra.createClient(target, resolved.value.profile);
release = "12.1.5";
try {
const result = await client.execute("analyze_function", {});
if (result.ok) throw new Error("Changed installation was admitted");
expect(result.error).toMatchObject({
operation: "analyze_function",
diagnostics: {
selected_provider_version: "12.1.4",
current_installation: { provider_version: "12.1.5" },
},
});
expect(client.reuseReadiness?.()).toBe("blocked");
expect(nativeCreates).toBe(0);
} finally {
await client.close();
await ghidra.closeOwnedResources();
}
});
it("rejects an absent profile before probing Java and attributes it to the requested operation", async () => {
let probes = 0;
const base = installationHost();
const ghidra = provider({
...base,
probeJava: (...args) => {
probes++;
return base.probeJava(...args);
},
});
const client = ghidra.createClient(executableTarget("elf", "x86_64"));
try {
const result = await client.execute("analyze_function", {});
expect(result).toMatchObject({
ok: false,
error: { _tag: "ProviderAdapterError", operation: "analyze_function" },
});
expect(probes).toBe(0);
} finally {
await client.close();
await ghidra.closeOwnedResources();
}
});
+239 -39
View File
@@ -1,3 +1,11 @@
import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js";
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
import {
SharedStartupAttempt,
STARTUP_OWNER_CLOSED,
} from "../process/SharedStartupAttempt.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
@@ -5,6 +13,8 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import type {
AnalysisClient,
AnalysisClientContext,
AnalysisProviderInspectionOptions,
FileCloseResourceOwner,
AnalysisProfileResolutionOptions,
AnalysisProfileResolution,
AnalysisProviderCandidate,
@@ -29,7 +39,7 @@ import {
type JsonValue,
} from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import type { Logger } from "pino";
@@ -61,6 +71,7 @@ import {
} from "./GhidraProviderCapabilities.js";
import {
createGhidraProviderClient,
admitGhidraClient,
type GhidraProviderClientFactory,
} from "./GhidraProviderClient.js";
import {
@@ -84,11 +95,28 @@ const SUPPORTED_ARCHITECTURES = new Set([
/** Ghidra candidate backed by an isolated ephemeral headless import. */
export class GhidraProvider implements AnalysisProviderCandidate {
readonly #inspectionResources = new ArtifactResourceScope();
readonly #profileWork = new Set<
Promise<Result<AnalysisProfileResolution, AnalysisError>>
readonly #inspectionWork = new Set<Promise<unknown>>();
#lifetime = new AbortController();
readonly #observations = new WeakMap<
ProviderInspectionScope,
{
readonly lifetime: AbortSignal;
readonly installation: GhidraInstallationInspection;
}
>();
#inspectionAttempt:
| SharedStartupAttempt<Result<GhidraInstallationInspection, AnalysisError>>
| undefined;
readonly #inspectionOwner: FileCloseResourceOwner = {
retain: (owner, resource) =>
this.#inspectionResources.retain({
kind: "lease",
resource,
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
}),
run: (operation) => this.#inspectionResources.run(operation),
};
#closing: Promise<Result<null, AnalysisError>> | undefined;
#installation: GhidraInstallationInspection | undefined;
private readonly environment: NodeJS.ProcessEnv;
constructor(
@@ -115,18 +143,42 @@ export class GhidraProvider implements AnalysisProviderCandidate {
: CAPABILITIES;
}
inspectAvailability(): ProviderAvailability {
return this.inspectReadinessSnapshot().availability;
async inspectAvailability(
options: AnalysisProviderInspectionOptions = {},
): Promise<ProviderAvailability> {
return (await this.inspectReadinessSnapshot(options)).availability;
}
inspectReadinessSnapshot(): AnalysisProviderReadinessSnapshot {
const installation = this.#inspectInstallation();
inspectReadinessSnapshot(
options: AnalysisProviderInspectionOptions = {},
): Promise<AnalysisProviderReadinessSnapshot> {
return this.#runInspectionWork(
(signal) => this.#readinessSnapshot(signal, options.scope),
options.signal,
);
}
async #readinessSnapshot(
signal: AbortSignal,
scope?: ProviderInspectionScope,
): Promise<AnalysisProviderReadinessSnapshot> {
const installation = await this.#inspectInstallation(
signal,
"binary_session",
scope,
);
const readiness =
installation.platform === "win32"
? inspectWindowsNativeReadiness(installation.platform)
: undefined;
const availability = this.#availability(installation, readiness);
if (availability.status === "available" && signal.aborted)
throw new AnalysisCancelledError("binary_session", {
cause: signal.reason,
diagnostics: availability.diagnostics,
});
return {
availability: this.#availability(installation, readiness),
availability,
capabilities:
readiness === undefined
? CAPABILITIES
@@ -234,16 +286,28 @@ export class GhidraProvider implements AnalysisProviderCandidate {
}
hasPendingOwnedResources(): boolean {
return this.#profileWork.size > 0 || this.#inspectionResources.hasPending;
return (
this.#inspectionWork.size > 0 ||
this.#inspectionAttempt !== undefined ||
this.#inspectionResources.hasPending
);
}
closeOwnedResources() {
this.#closing ??= Promise.resolve()
.then(async () => {
await Promise.allSettled(this.#profileWork);
this.#lifetime.abort(STARTUP_OWNER_CLOSED);
this.#inspectionAttempt?.close();
await Promise.allSettled([
...this.#inspectionWork,
...(this.#inspectionAttempt === undefined
? []
: [this.#inspectionAttempt.promise]),
]);
return this.#closeInspectionResources();
})
.finally(() => {
this.#lifetime = new AbortController();
this.#closing = undefined;
});
return this.#closing;
@@ -271,18 +335,33 @@ export class GhidraProvider implements AnalysisProviderCandidate {
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
): Promise<Result<AnalysisProfileResolution, AnalysisError>> {
if (this.#closing !== undefined) {
const closed = await this.#closing;
if (!closed.ok) return closed;
}
const work = Promise.resolve().then(() =>
this.#resolveProfile(target, options),
);
this.#profileWork.add(work);
try {
return await work;
} finally {
this.#profileWork.delete(work);
return await this.#runInspectionWork(async (signal) => {
const result = await this.#resolveProfile(target, {
...options,
signal,
});
if (result.ok && signal.aborted)
return err(
new AnalysisCancelledError("open_binary", {
cause: signal.reason,
diagnostics: {
completed_profile_digest: result.value.profile?.digest ?? null,
},
}),
);
return result;
}, options?.signal);
} catch (cause: unknown) {
return err(
analysisClientFailureForOperation(
cause,
"ghidra",
cause instanceof AnalysisCancelledError
? "open_binary"
: "resolve_analysis_profile",
),
);
}
}
@@ -290,7 +369,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
target: BinaryTarget,
options?: AnalysisProfileResolutionOptions,
) {
const installation = this.#inspectInstallation();
const installation = await this.#inspectInstallation(
options?.signal,
"resolve_analysis_profile",
options?.scope,
);
const resolved = await resolveGhidraAnalysisProfile(
target,
GHIDRA_PROVIDER_IDENTITY,
@@ -381,20 +464,139 @@ export class GhidraProvider implements AnalysisProviderCandidate {
profile?: AnalysisProfileCommitment,
context?: AnalysisClientContext,
): AnalysisClient {
return createGhidraProviderClient({
config: this.config,
environment: this.environment,
logger: this.logger,
clientFactory: this.clientFactory,
target,
...(profile === undefined ? {} : { profile }),
...(context === undefined ? {} : { context }),
installation: this.#inspectInstallation(),
});
return new DeferredAnalysisClient(async (signal, resourceOwner) => {
const admitted = admitGhidraClient(
target,
profile,
this.installationHost?.platform ?? process.platform,
);
if (!admitted.ok) throw admitted.error;
const installation = await this.#observeInstallation(
signal,
resourceOwner,
);
const created = createGhidraProviderClient({
config: this.config,
environment: this.environment,
logger: this.logger,
clientFactory: this.clientFactory,
admission: admitted.value,
...(context === undefined ? {} : { context }),
installation,
signal,
});
if (!created.ok) throw created.error;
return created.value;
}, "ghidra");
}
#inspectInstallation(): GhidraInstallationInspection {
async #runInspectionWork<T>(
operation: (signal: AbortSignal) => Promise<T>,
callerSignal?: AbortSignal,
): Promise<T> {
if (this.#closing !== undefined) {
const closed = await this.#closing;
if (!closed.ok) throw closed.error;
}
const signal =
callerSignal === undefined
? this.#lifetime.signal
: AbortSignal.any([callerSignal, this.#lifetime.signal]);
if (signal.aborted)
throw new AnalysisCancelledError("ghidra:inspection", {
cause: signal.reason,
});
const work = Promise.resolve().then(() => operation(signal));
this.#inspectionWork.add(work);
try {
return await work;
} finally {
this.#inspectionWork.delete(work);
}
}
async #inspectInstallation(
callerSignal: AbortSignal | undefined,
operation: string,
scope?: ProviderInspectionScope,
): Promise<GhidraInstallationInspection> {
if (callerSignal?.aborted)
throw new AnalysisCancelledError(operation, {
cause: callerSignal.reason,
});
if (scope?.active) {
const observed = this.#observations.get(scope);
if (observed?.lifetime === this.#lifetime.signal)
return observed.installation;
}
let attempt = this.#inspectionAttempt;
if (attempt === undefined) {
attempt = new SharedStartupAttempt(
(signal) =>
Promise.resolve().then(async () => {
try {
return ok(await this.#observeInstallation(signal));
} catch (cause: unknown) {
return err(
analysisClientFailureForOperation(
cause,
"ghidra",
"ghidra:inspection",
),
);
}
}),
this.#lifetime.signal,
);
const owned = attempt;
this.#inspectionAttempt = owned;
void owned.promise.then(
() => {
if (this.#inspectionAttempt === owned)
this.#inspectionAttempt = undefined;
},
() => {
if (this.#inspectionAttempt === owned)
this.#inspectionAttempt = undefined;
},
);
}
const waited = await attempt.wait(callerSignal);
if (waited.status === "cancelled")
throw new AnalysisCancelledError(operation, {
cause: callerSignal?.reason,
});
if (!waited.value.ok)
throw analysisClientFailureForOperation(
waited.value.error,
"ghidra",
operation,
);
if (
waited.value.value.status === "available" &&
(callerSignal?.aborted || attempt.signal.aborted)
)
throw new AnalysisCancelledError(operation, {
cause: callerSignal?.aborted
? callerSignal.reason
: attempt.signal.reason,
diagnostics: ghidraInstallationDiagnostics(waited.value.value),
});
if (scope?.active && !callerSignal?.aborted && !attempt.signal.aborted)
this.#observations.set(scope, {
lifetime: this.#lifetime.signal,
installation: waited.value.value,
});
return waited.value.value;
}
#observeInstallation(
signal: AbortSignal,
resourceOwner: FileCloseResourceOwner = this.#inspectionOwner,
): Promise<GhidraInstallationInspection> {
const options = {
resourceOwner,
signal,
environment: this.environment,
...(this.config.ghidraInstallDir === undefined
? {}
@@ -409,11 +611,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
? {}
: { architecture: this.installationHost.architecture }),
};
this.#installation ??=
this.installationHost === undefined
? inspectGhidraInstallation(options)
: inspectGhidraInstallation(options, this.installationHost);
return this.#installation;
return this.installationHost === undefined
? inspectGhidraInstallation(options)
: inspectGhidraInstallation(options, this.installationHost);
}
}
+66 -35
View File
@@ -373,19 +373,15 @@ export const createGhidraProviderClient = (input: {
readonly environment: Readonly<NodeJS.ProcessEnv>;
readonly logger: Logger;
readonly clientFactory: GhidraProviderClientFactory;
readonly target: BinaryTarget;
readonly profile?: AnalysisProfileCommitment;
readonly admission: GhidraClientAdmission;
readonly context?: AnalysisClientContext;
readonly installation: GhidraInstallationInspection;
}): AnalysisClient => {
const { config, logger, clientFactory, target, context, installation } =
input;
const prerequisites = ghidraClientPrerequisites(
target,
input.profile,
installation,
);
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
readonly signal: AbortSignal;
}): Result<AnalysisClient, AnalysisError> => {
const { config, logger, clientFactory, context, installation } = input;
const { target, profile } = input.admission;
const prerequisites = ghidraClientPrerequisites(profile, installation);
if (!prerequisites.ok) return err(prerequisites.error);
const committedProfile = prerequisites.value.profile;
const psp = isGhidraPspTarget(target);
const pspExtension = psp
@@ -399,7 +395,7 @@ export const createGhidraProviderClient = (input: {
committedProfile.parameters.language_id !== "Allegrex:LE:32:default" ||
committedProfile.parameters.loader !== "PspElfLoader")
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -413,7 +409,7 @@ export const createGhidraProviderClient = (input: {
(committedProfile.parameters.mips_soft_float_storage ===
GHIDRA_MIPS_SOFT_FLOAT_STORAGE)
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -429,7 +425,7 @@ export const createGhidraProviderClient = (input: {
(config.ghidraNativeAotJar !== undefined &&
extensionProfile.data.length === 0)
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -445,7 +441,7 @@ export const createGhidraProviderClient = (input: {
!seedProfile.success ||
(config.ghidraSeedFile === undefined) !== (seedProfile.data === undefined)
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -460,7 +456,7 @@ export const createGhidraProviderClient = (input: {
const pinnedLanguage = ghidraPinnedMipsLanguage(committedProfile.parameters);
const targetLanguage = ghidraPinnedLanguage(target);
if (pinnedLanguage !== undefined && targetLanguage === undefined)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -475,7 +471,7 @@ export const createGhidraProviderClient = (input: {
pinnedLanguage !== undefined &&
config.ghidraLanguageOverride !== undefined
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -489,7 +485,7 @@ export const createGhidraProviderClient = (input: {
config.ghidraLanguageOverride ?? targetLanguage,
)
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -509,7 +505,7 @@ export const createGhidraProviderClient = (input: {
config.ghidraDisabledAnalyzers,
)
)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
@@ -525,7 +521,7 @@ export const createGhidraProviderClient = (input: {
installation.platform,
);
if (invalidProfile !== null)
return unavailableClient(
return err(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: { reason: invalidProfile },
}),
@@ -556,9 +552,7 @@ export const createGhidraProviderClient = (input: {
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
]
: []),
...(target.kind === "executable" && target.architecture === "mips"
? (target.mips.limitations ?? [])
: []),
...(target.architecture === "mips" ? (target.mips.limitations ?? []) : []),
...(committedProfile.parameters.mips_abi_basis ===
"inferred-o32-assumption-v1"
? [
@@ -578,6 +572,13 @@ export const createGhidraProviderClient = (input: {
"macOS sessions require a matching executable Ghidra native decompiler; REA checks for it but does not build native components or change Gatekeeper quarantine state.",
]
: [];
if (input.signal.aborted)
return err(
new AnalysisCancelledError("create_analysis_client", {
cause: input.signal.reason,
diagnostics: ghidraInstallationDiagnostics(installation),
}),
);
const client = clientFactory({
startupTimeoutMs: config.ghidraStartupTimeoutMs,
platform: installation.platform,
@@ -774,7 +775,7 @@ export const createGhidraProviderClient = (input: {
if (!parsed.ok) return parsed;
return ok(projectExecution(operation, parsed.value, called.value));
};
return {
return ok<AnalysisClient>({
execute: async (operation, parameters, options) => {
if (extensionFailure !== undefined) return err(extensionFailure);
if (
@@ -987,7 +988,7 @@ export const createGhidraProviderClient = (input: {
nativeAotOwner?.close();
return client.close();
},
};
});
};
interface GhidraClientCoordinates {
@@ -998,11 +999,17 @@ interface GhidraClientCoordinates {
readonly profile: AnalysisProfileCommitment;
}
const ghidraClientPrerequisites = (
export interface GhidraClientAdmission {
readonly target: Extract<BinaryTarget, { readonly kind: "executable" }>;
readonly profile: AnalysisProfileCommitment;
}
/** Admit caller inputs and native authority before Java work starts. */
export const admitGhidraClient = (
target: BinaryTarget,
profile: AnalysisProfileCommitment | undefined,
installation: GhidraInstallationInspection,
): Result<GhidraClientCoordinates, AnalysisError> => {
platform: NodeJS.Platform,
): Result<GhidraClientAdmission, AnalysisError> => {
if (target.kind !== "executable")
return err(
new AnalysisCapabilityUnavailableError(
@@ -1011,6 +1018,26 @@ const ghidraClientPrerequisites = (
`Ghidra cannot import ${target.kind} targets through this adapter.`,
),
);
if (platform === "win32") {
const readiness = inspectWindowsNativeReadiness(platform);
if (!readiness.available)
return err(
new AnalysisCapabilityUnavailableError(
"ghidra",
"health",
readiness.reason,
),
);
}
if (profile === undefined || profile.provider.id !== "ghidra")
return err(new ProviderAdapterError("ghidra", "health"));
return ok({ target, profile });
};
const ghidraClientPrerequisites = (
profile: AnalysisProfileCommitment,
installation: GhidraInstallationInspection,
): Result<GhidraClientCoordinates, AnalysisError> => {
if (installation.status === "unavailable")
return err(
new ProviderAdapterError("ghidra", "health", {
@@ -1029,8 +1056,17 @@ const ghidraClientPrerequisites = (
readiness.reason,
),
);
if (profile === undefined || profile.provider.id !== "ghidra")
return err(new ProviderAdapterError("ghidra", "health"));
if (profile.provider.version !== installation.providerVersion)
return err(
new ProviderAdapterError("ghidra", "health", {
diagnostics: {
reason:
"The current installation version differs from the selected analysis profile; resolve the profile again.",
selected_provider_version: profile.provider.version,
current_installation: ghidraInstallationDiagnostics(installation),
},
}),
);
return ok({
analyzeHeadlessPath: installation.analyzeHeadlessPath,
configurationDirectoryName: installation.configurationDirectoryName,
@@ -1040,11 +1076,6 @@ const ghidraClientPrerequisites = (
});
};
const unavailableClient = (failure: AnalysisError): AnalysisClient => ({
execute: () => Promise.resolve(err(failure)),
close: () => Promise.resolve(ok(null)),
});
const projectSessionError = (
operation: AnalysisOperation,
failure: GhidraSessionError,
+10
View File
@@ -0,0 +1,10 @@
/** Lifetime of one task's prerequisite observations, distinct from resource ownership. */
export class ProviderInspectionScope {
#active = true;
get active(): boolean {
return this.#active;
}
close(): void {
this.#active = false;
}
}
+116 -1
View File
@@ -1,9 +1,14 @@
import { chmod, mkdir, writeFile } from "node:fs/promises";
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"],
@@ -115,3 +120,113 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([
});
},
);
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" });
}
}
},
);
+106 -78
View File
@@ -1,42 +1,67 @@
import type { SpawnSyncReturns } from "node:child_process";
import { mkdir, writeFile } from "node:fs/promises";
import { probeGhidraJava } from "../../../src/ghidra/GhidraJavaProbe.js";
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { mkdir, writeFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { afterEach, expect, it } from "vitest";
import {
inspectGhidraInstallation,
systemGhidraInstallationHost,
ghidraInstallationDiagnostics,
} from "../../../src/ghidra/GhidraInstallation.js";
import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraReleasePolicy.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() }));
let inspectionResources = createInspectionResources();
afterEach(async () => {
await inspectionResources.close();
inspectionResources = createInspectionResources();
});
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof import("node:child_process")>()),
spawnSync: mockSpawnSync,
}));
afterEach(() => mockSpawnSync.mockReset());
/** Match the production probe's raw-byte spawn contract at this fixture boundary. */
const probeOutput = (
/** Controlled Java-shaped command exercising the real process owner, not a JVM. */
const writeJavaProbe = async (
home: string,
stderrText: string,
error?: Error,
): SpawnSyncReturns<Buffer> => {
const stdout = Buffer.alloc(0);
const stderr = Buffer.from(stderrText, "utf8");
return {
pid: 1,
output: [null, stdout, stderr],
stdout,
stderr,
status: error === undefined ? 0 : null,
signal: null,
...(error === undefined ? {} : { error }),
};
outputBytes?: number,
) => {
const executable = join(
home,
"bin",
process.platform === "win32" ? "java.exe" : "java",
);
await mkdir(join(home, "bin"), { recursive: true });
await writeFile(
process.platform === "win32" ? `${executable}.cjs` : executable,
`#!${process.execPath}\nconst fs = require('node:fs');\nfs.writeFileSync(${JSON.stringify(join(home, "probe-arguments.json"))}, JSON.stringify(process.argv.slice(2)));\nconst prefix = ${JSON.stringify(stderrText)};\nprocess.stderr.write(prefix${outputBytes === undefined ? "" : ` + 'X'.repeat(${outputBytes} - Buffer.byteLength(prefix))`});\n`,
{ mode: 0o700 },
);
};
// Windows executes the controlled JS prerequisite with the real owned Node exe;
// POSIX executes its selected shebang path. Neither fixture is a real JVM.
const probeHost =
process.platform !== "win32"
? undefined
: {
...systemGhidraInstallationHost(),
probeJava: (
command: string,
environment: NodeJS.ProcessEnv,
options: Parameters<typeof probeGhidraJava>[2],
) =>
probeGhidraJava(command, environment, {
...options,
launcher: (spawn) =>
spawnOwnedProviderProcess({
...spawn,
command: process.execPath,
arguments: [`${command}.cjs`, ...spawn.arguments],
}),
}),
};
it.each([
{ major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" },
{ major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" },
@@ -66,17 +91,20 @@ it.each([
);
}
const version = `${String(major)}.0.11`;
mockSpawnSync.mockReturnValue(
probeOutput(
` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
),
await writeJavaProbe(
configuredHome,
` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
);
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir,
javaHome: configuredHome,
},
probeHost,
);
expect(inspection.javaHome).toBe(reportedHome);
expect(inspection.javaVersion).toBe(version);
@@ -90,19 +118,17 @@ it.each([
detail: expect.stringContaining(fullJdk ? "; JDK;" : "; runtime only;"),
},
);
expect(mockSpawnSync).toHaveBeenCalledExactlyOnceWith(
join(
configuredHome,
"bin",
process.platform === "win32" ? "java.exe" : "java",
expect(
JSON.parse(
await readFile(join(configuredHome, "probe-arguments.json"), "utf8"),
),
).toEqual(
expect.arrayContaining([
"-Dstdout.encoding=UTF-8",
"-Dstderr.encoding=UTF-8",
"-Dsun.stdout.encoding=UTF-8",
"-Dsun.stderr.encoding=UTF-8",
]),
expect.objectContaining({ encoding: "buffer" }),
);
},
);
@@ -146,17 +172,20 @@ it.each([
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
mockSpawnSync.mockReturnValue(
probeOutput(
` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
),
await writeJavaProbe(
configuredHome,
` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
);
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir,
javaHome: configuredHome,
},
probeHost,
);
expect(inspection.status).toBe("unavailable");
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
@@ -191,17 +220,20 @@ it("keeps a known 32-bit Java report classified as unsupported", async () => {
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
mockSpawnSync.mockReturnValue(
probeOutput(
` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
),
await writeJavaProbe(
configuredHome,
` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
);
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir,
javaHome: configuredHome,
},
probeHost,
);
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
status: "failed",
@@ -219,27 +251,23 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
join(installDir, "Ghidra", "application.properties"),
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
);
// A chatty or hostile JVM that overruns the capture budget: spawnSync reports
// ENOBUFS, which the probe already treats as a failed observation.
mockSpawnSync.mockReturnValue(
probeOutput(
"java.version = 21.0.11\n",
Object.assign(new Error("spawnSync /java ENOBUFS"), { code: "ENOBUFS" }),
),
// The real command owner observes output beyond its one-MiB aggregate budget.
await writeJavaProbe(
configuredHome,
"java.version = 21.0.11\n",
1024 * 1024 + 1,
);
const inspection = inspectGhidraInstallation({
environment: {},
installDir,
javaHome: configuredHome,
});
const inspection = await inspectGhidraInstallation(
{
resourceOwner: inspectionResources.owner,
environment: {},
installDir,
javaHome: configuredHome,
},
probeHost,
);
const [, , options] = mockSpawnSync.mock.calls[0] as [
string,
readonly string[],
{ readonly maxBuffer?: number },
];
expect(options.maxBuffer).toBe(1024 * 1024);
expect(inspection.javaVersion).toBeNull();
expect(inspection.javaHome).toBe(configuredHome);
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
@@ -252,7 +280,7 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
code: "runtime_unavailable",
process_error_code: "ENOBUFS",
output: {
stderr: "java.version = 21.0.11\n",
stderr: expect.stringContaining("java.version = 21.0.11\n"),
truncated: true,
signal: null,
},
@@ -334,7 +334,7 @@ it.each(["ready", "cancelled", "physical"] as const)(
new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
inspectAvailability: async ({ signal } = {}) => {
enter();
await gate;
if (mode === "physical")
@@ -0,0 +1,398 @@
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);
}
},
);
+21
View File
@@ -0,0 +1,21 @@
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
import type { FileCloseResourceOwner } from "../../src/application/AnalysisProvider.js";
/** Test-scoped real inspection leases; every retained owner is closed by the fixture. */
export const createInspectionResources = () => {
const scope = new ArtifactResourceScope();
const owner: FileCloseResourceOwner = {
retain: (resourceOwner, resource) =>
scope.retain({
kind: "lease",
resource,
lease: new AnalysisCleanupLease(
() => resourceOwner.close(),
[resource],
),
}),
run: (operation) => scope.run(operation),
};
return { owner, close: () => scope.close() };
};