diff --git a/docs/architecture.mermaid b/docs/architecture.mermaid
index 90dd41ab17..94b31723e0 100644
--- a/docs/architecture.mermaid
+++ b/docs/architecture.mermaid
@@ -26,6 +26,7 @@ graph TD
end
subgraph "Provider Layers"
+ PROVIDER_OWNERS["src/providers/
shared deferred client and inspection owners
exact cleanup retry and attributed failures"]
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/
BYO offline ELF/core adapters
owned snapshots/Python and optional core-only GDB
unchanged upstream parsers, ABI structures and mapping API"]
WASM["src/wasm/
caller-supplied WABT; immutable artifact inspection
owned process supervision and retryable workspace cleanup"]
EVM["src/evm/
unchanged EVMole/WASM in bounded owned worker
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
diff --git a/src/application/AnalysisProvider.ts b/src/application/AnalysisProvider.ts
index 7105bfbe24..7157e7a246 100644
--- a/src/application/AnalysisProvider.ts
+++ b/src/application/AnalysisProvider.ts
@@ -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>;
/** Await prerequisite observations and finalization under the supplied owner signal. */
inspectAvailability(
- signal?: AbortSignal,
+ options?: AnalysisProviderInspectionOptions,
): ProviderAvailability | Promise;
inspectReadinessSnapshot?(
- signal?: AbortSignal,
+ options?: AnalysisProviderInspectionOptions,
):
| AnalysisProviderReadinessSnapshot
| Promise;
diff --git a/src/application/Doctor.lifecycle.test.ts b/src/application/Doctor.lifecycle.test.ts
new file mode 100644
index 0000000000..f37fa4c847
--- /dev/null
+++ b/src/application/Doctor.lifecycle.test.ts
@@ -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((resolve) => {
+ enter = resolve;
+ });
+ let handOff!: () => void;
+ const handoff = new Promise((resolve) => {
+ handOff = resolve;
+ });
+ let finish!: () => void;
+ const finishing = new Promise((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();
+});
diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts
index fcfc9c1f7e..538abf427b 100644
--- a/src/application/Doctor.ts
+++ b/src/application/Doctor.ts
@@ -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 => {
+ 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 => {
+): Promise => {
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 providerRegistrationEnvironment?: () => Readonly<
Record
@@ -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: () =>
diff --git a/src/application/binary/AnalysisProviderEvaluation.ts b/src/application/binary/AnalysisProviderEvaluation.ts
index b47b7caf62..c1a0453669 100644
--- a/src/application/binary/AnalysisProviderEvaluation.ts
+++ b/src/application/binary/AnalysisProviderEvaluation.ts
@@ -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
> => {
+ const { signal } = options;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
let resolved: Awaited>;
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
diff --git a/src/application/binary/AnalysisProviderRegistry.test.ts b/src/application/binary/AnalysisProviderRegistry.test.ts
index d053c3bd7c..60089a9362 100644
--- a/src/application/binary/AnalysisProviderRegistry.test.ts
+++ b/src/application/binary/AnalysisProviderRegistry.test.ts
@@ -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();
diff --git a/src/application/binary/AnalysisProviderRegistry.ts b/src/application/binary/AnalysisProviderRegistry.ts
index 22647d73ac..cc6f67feba 100644
--- a/src/application/binary/AnalysisProviderRegistry.ts
+++ b/src/application/binary/AnalysisProviderRegistry.ts
@@ -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;
@@ -279,11 +281,18 @@ export class AnalysisProviderRegistry {
capabilities: provider.capabilities(),
}),
);
+ const scope = new ProviderInspectionScope();
const discovery = new SharedStartupAttempt<
Result
>(
(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
> {
+ 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 {
- 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
- : "",
- 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
+ : "",
+ 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> {
- 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> {
- 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 => {
+ 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);
};
diff --git a/src/cli/setupCommands.ts b/src/cli/setupCommands.ts
index 092b27141e..5fc9243977 100644
--- a/src/cli/setupCommands.ts
+++ b/src/cli/setupCommands.ts
@@ -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),
+ );
+ }),
+ ),
});
};
diff --git a/src/doctorRuntime.ts b/src/doctorRuntime.ts
index dffa46f970..37e16b61dc 100644
--- a/src/doctorRuntime.ts
+++ b/src/doctorRuntime.ts
@@ -16,6 +16,7 @@ import { ok } from "./domain/result.js";
/** Compose provider diagnostics at the outer CLI adapter boundary. */
export const createSystemDoctorHost = (
environment: Readonly = process.env,
+ signal?: AbortSignal,
): DoctorHost => {
const selectedJebEndpoint = environment.REA_JEB_MCP_URL;
const selectedJebRegistrationEnvironment: Readonly> =
@@ -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, {
diff --git a/src/ghidra/GhidraDoctor.test.ts b/src/ghidra/GhidraDoctor.test.ts
index 5f741989e1..ae7dcb458c 100644
--- a/src/ghidra/GhidraDoctor.test.ts
+++ b/src/ghidra/GhidraDoctor.test.ts
@@ -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 = {},
@@ -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 = {},
-): GhidraInstallationInspection =>
- inspectGhidraInstallation(
+): Promise =>
+ 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",
diff --git a/src/ghidra/GhidraDoctor.ts b/src/ghidra/GhidraDoctor.ts
index ac24e53175..1bd8307b09 100644
--- a/src/ghidra/GhidraDoctor.ts
+++ b/src/ghidra/GhidraDoctor.ts
@@ -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,
-): Promise =>
- Promise.resolve(
- projectGhidraDoctorInspection(
- inspectGhidraInstallation({
+ resourceOwner: FileCloseResourceOwner,
+ signal?: AbortSignal,
+): Promise => {
+ 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,
diff --git a/src/ghidra/GhidraInstallation.test.ts b/src/ghidra/GhidraInstallation.test.ts
index 2dcec5b2b3..cf288015fd 100644
--- a/src/ghidra/GhidraInstallation.test.ts
+++ b/src/ghidra/GhidraInstallation.test.ts
@@ -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();
- 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({
@@ -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,
diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts
index d9a3b3b059..0b351da5b6 100644
--- a/src/ghidra/GhidraInstallation.ts
+++ b/src/ghidra/GhidraInstallation.ts
@@ -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;
+ 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;
+ options: GhidraJavaProbeOptions,
+ ):
+ | Result
+ | Promise<
+ Result
+ >;
/** Resolve a PATH launcher to a validated installation without executing it. */
discoverInstallDirOnPath?(
environment: Readonly,
@@ -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 => {
+ 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 => {
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
diff --git a/src/ghidra/GhidraJavaProbe.test.ts b/src/ghidra/GhidraJavaProbe.test.ts
index 85a8965362..282f2b6e6a 100644
--- a/src/ghidra/GhidraJavaProbe.test.ts
+++ b/src/ghidra/GhidraJavaProbe.test.ts
@@ -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()),
- spawnSync: vi.fn(),
-}));
-afterEach(() => vi.mocked(spawnSync).mockReset());
-
-const probe = (values: Partial>) => {
- 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({
diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts
index 1637eb2a8d..63c2b124bb 100644
--- a/src/ghidra/GhidraJavaProbe.ts
+++ b/src/ghidra/GhidraJavaProbe.ts
@@ -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,
+ 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[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 => {
- 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
+> => {
+ 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
+> => {
+ let interpreted:
+ | Result
+ | 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 => {
+ 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");
diff --git a/src/ghidra/GhidraPowerPcProfile.test.ts b/src/ghidra/GhidraPowerPcProfile.test.ts
index 0be7aa898e..a830f4aee4 100644
--- a/src/ghidra/GhidraPowerPcProfile.test.ts
+++ b/src/ghidra/GhidraPowerPcProfile.test.ts
@@ -51,7 +51,9 @@ const target = (change: Partial = {}): 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;
diff --git a/src/ghidra/GhidraProvider.behavior.test.ts b/src/ghidra/GhidraProvider.behavior.test.ts
index bc4bb927f9..a93d261c46 100644
--- a/src/ghidra/GhidraProvider.behavior.test.ts
+++ b/src/ghidra/GhidraProvider.behavior.test.ts
@@ -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();
+ }
+});
diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts
index 4c447b818d..9f174ca665 100644
--- a/src/ghidra/GhidraProvider.ts
+++ b/src/ghidra/GhidraProvider.ts
@@ -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>
+ readonly #inspectionWork = new Set>();
+ #lifetime = new AbortController();
+ readonly #observations = new WeakMap<
+ ProviderInspectionScope,
+ {
+ readonly lifetime: AbortSignal;
+ readonly installation: GhidraInstallationInspection;
+ }
>();
+ #inspectionAttempt:
+ | SharedStartupAttempt>
+ | 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> | 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 {
+ return (await this.inspectReadinessSnapshot(options)).availability;
}
- inspectReadinessSnapshot(): AnalysisProviderReadinessSnapshot {
- const installation = this.#inspectInstallation();
+ inspectReadinessSnapshot(
+ options: AnalysisProviderInspectionOptions = {},
+ ): Promise {
+ return this.#runInspectionWork(
+ (signal) => this.#readinessSnapshot(signal, options.scope),
+ options.signal,
+ );
+ }
+
+ async #readinessSnapshot(
+ signal: AbortSignal,
+ scope?: ProviderInspectionScope,
+ ): Promise {
+ 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> {
- 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(
+ operation: (signal: AbortSignal) => Promise,
+ callerSignal?: AbortSignal,
+ ): Promise {
+ 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 {
+ 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 {
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);
}
}
diff --git a/src/ghidra/GhidraProviderClient.ts b/src/ghidra/GhidraProviderClient.ts
index cb47d5ba9c..69c9b774cb 100644
--- a/src/ghidra/GhidraProviderClient.ts
+++ b/src/ghidra/GhidraProviderClient.ts
@@ -373,19 +373,15 @@ export const createGhidraProviderClient = (input: {
readonly environment: Readonly;
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 => {
+ 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({
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;
+ readonly profile: AnalysisProfileCommitment;
+}
+
+/** Admit caller inputs and native authority before Java work starts. */
+export const admitGhidraClient = (
target: BinaryTarget,
profile: AnalysisProfileCommitment | undefined,
- installation: GhidraInstallationInspection,
-): Result => {
+ platform: NodeJS.Platform,
+): Result => {
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 => {
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,
diff --git a/src/providers/ProviderInspectionScope.ts b/src/providers/ProviderInspectionScope.ts
new file mode 100644
index 0000000000..a897337492
--- /dev/null
+++ b/src/providers/ProviderInspectionScope.ts
@@ -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;
+ }
+}
diff --git a/tests/boundary/cli/ghidraJavaDoctor.test.ts b/tests/boundary/cli/ghidraJavaDoctor.test.ts
index c29bcb3773..916a74dc53 100644
--- a/tests/boundary/cli/ghidraJavaDoctor.test.ts
+++ b/tests/boundary/cli/ghidraJavaDoctor.test.ts
@@ -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" });
+ }
+ }
+ },
+);
diff --git a/tests/boundary/filesystem/ghidraJavaProbe.test.ts b/tests/boundary/filesystem/ghidraJavaProbe.test.ts
index 821598f565..3320388154 100644
--- a/tests/boundary/filesystem/ghidraJavaProbe.test.ts
+++ b/tests/boundary/filesystem/ghidraJavaProbe.test.ts
@@ -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()),
- 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 => {
- 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[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,
},
diff --git a/tests/boundary/mcp/providerSelectionMcp.test.ts b/tests/boundary/mcp/providerSelectionMcp.test.ts
index b955534078..520bcd6717 100644
--- a/tests/boundary/mcp/providerSelectionMcp.test.ts
+++ b/tests/boundary/mcp/providerSelectionMcp.test.ts
@@ -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")
diff --git a/tests/boundary/process/ghidraJavaLifecycle.test.ts b/tests/boundary/process/ghidraJavaLifecycle.test.ts
new file mode 100644
index 0000000000..ccf8c53233
--- /dev/null
+++ b/tests/boundary/process/ghidraJavaLifecycle.test.ts
@@ -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>;
+
+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);
+ }
+ },
+);
diff --git a/tests/fixtures/inspectionResources.ts b/tests/fixtures/inspectionResources.ts
new file mode 100644
index 0000000000..cf56b912e0
--- /dev/null
+++ b/tests/fixtures/inspectionResources.ts
@@ -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() };
+};