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() }; +};