fix(ghidra): own Java prerequisite probes through final cleanup

Replace synchronous Java execution with bounded owned commands. Keep task-scoped readiness observations fresh across later tasks, defer target client prerequisites behind a closeable owner, and join doctor cancellation and cleanup.

Preserve completed physical failures across late cancellation, exact cleanup dependencies and partial observations. Admit target/profile inputs before probing and return typed factory failures instead of unavailable client wrappers.

Validation: 405 focused tests across 24 files, 2 explicit skips; 109 provider/doctor controls rerun after final admission refinement; check:fast and docs:check. Controlled macOS Java-shaped commands and public CLI/MCP workflows; no new JVM/Ghidra or Windows/Linux lifecycle verification.

Measured warm public doctor baseline/candidate: elapsed 0.447/1.223s, CPU 0.624/1.237s, sampled family RSS 379/529MB, reply tokens 3133/3133. Ownership adds cost; no performance improvement claimed. True cold, whole-agent and MCP costs unmeasured.

Refs #4561. Remaining installation file admission tracked in #4672.
This commit is contained in:
morluto committed 2026-10-17 11:29:03 +08:00
1 parent fb146bf40b
commit cc2e18a265
25 files changed
+2143 -536

No files matched your search

@@ -10,10 +10,12 @@ import {
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
import type {
AnalysisProfileResolutionOptions,
AnalysisProviderCandidate,
} from "../AnalysisProvider.js";
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
/** Profile-resolution truth for one discovered provider candidate. */
export interface AnalysisProviderCandidateEvaluation {
@@ -28,21 +30,18 @@ export const evaluateAnalysisProviderCandidate = async (
candidate: AnalysisProviderCandidate,
status: AnalysisProviderCandidateStatus,
target: BinaryTarget,
signal: AbortSignal | undefined,
fileCloseResources?: FileCloseResourceOwner,
options: AnalysisProfileResolutionOptions = {},
): Promise<
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
> => {
const { signal } = options;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
);
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
try {
resolved = await candidate.resolveAnalysisProfile(target, {
...(signal === undefined ? {} : { signal }),
...(fileCloseResources === undefined ? {} : { fileCloseResources }),
});
resolved = await candidate.resolveAnalysisProfile(target, options);
} catch (cause: unknown) {
resolved = err(
cause instanceof AnalysisError
@@ -779,7 +779,7 @@ it("joins cancelled readiness finalization and shutdown before admitting another
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
inspectAvailability: async ({ signal } = {}) => {
calls++;
if (calls === 1) {
observed = signal;
@@ -936,7 +936,7 @@ it("registers selection before async readiness can race shutdown", async () => {
const registry = new AnalysisProviderRegistry([
{
...base,
inspectAvailability: async (signal) => {
inspectAvailability: async ({ signal } = {}) => {
calls++;
observed = signal;
return base.inspectAvailability();
@@ -1,3 +1,4 @@
import { ProviderInspectionScope } from "../../providers/ProviderInspectionScope.js";
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
@@ -84,7 +85,8 @@ export interface AnalysisProviderSelection {
interface TargetSelectionContext {
readonly target: BinaryTarget;
readonly baseline: readonly AnalysisProviderCandidateStatus[];
readonly signal: AbortSignal | undefined;
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
}
type SelectionResult = Result<AnalysisProviderSelection, AnalysisError>;
@@ -279,11 +281,18 @@ export class AnalysisProviderRegistry {
capabilities: provider.capabilities(),
}),
);
const scope = new ProviderInspectionScope();
const discovery = new SharedStartupAttempt<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
>(
(signal) =>
Promise.resolve().then(() => this.#discoverCandidates(target, signal)),
Promise.resolve().then(() =>
this.#discoverCandidates(target, {
signal,
scope,
operation: "binary_session",
}),
),
options.signal,
);
this.#discoveries.add(discovery);
@@ -299,24 +308,29 @@ export class AnalysisProviderRegistry {
});
return result.value;
} finally {
scope.close();
this.#discoveries.delete(discovery);
}
}
async #discoverCandidates(
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary" = "binary_session",
context: {
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
readonly operation: "binary_session" | "open_binary";
},
): Promise<
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
> {
const { signal, operation } = context;
if (signal.aborted)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
const results = await Promise.allSettled(
this.#providers.map((provider) =>
candidateStatus(provider, target, signal, operation),
candidateStatus(provider, target, context),
),
);
const statuses: AnalysisProviderCandidateStatus[] = [];
@@ -430,71 +444,78 @@ export class AnalysisProviderRegistry {
requestedSelector: AnalysisProviderSelector | undefined,
signal: AbortSignal,
): Promise<SelectionResult> {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const discovery = await this.#discoverCandidates(
target,
signal,
"open_binary",
);
if (!discovery.ok) return discovery;
const baseline = discovery.value;
const parsedRequest =
requestedSelector === undefined
? undefined
: analysisProviderSelectorSchema.safeParse(requestedSelector);
if (parsedRequest !== undefined && !parsedRequest.success)
return err(
selectionError(
"invalid_options",
typeof requestedSelector === "string"
? requestedSelector
: "<invalid>",
baseline,
),
);
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
const scope = new ProviderInspectionScope();
try {
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
const discovery = await this.#discoverCandidates(target, {
signal,
scope,
operation: "open_binary",
});
if (
request === undefined &&
this.#byId.has(selector) &&
baseline.length > 0 &&
baseline.every(
({ targetSupport }) => targetSupport.status === "unsupported",
if (!discovery.ok) return discovery;
const baseline = discovery.value;
const parsedRequest =
requestedSelector === undefined
? undefined
: analysisProviderSelectorSchema.safeParse(requestedSelector);
if (parsedRequest !== undefined && !parsedRequest.success)
return err(
selectionError(
"invalid_options",
typeof requestedSelector === "string"
? requestedSelector
: "<invalid>",
baseline,
),
);
const request = parsedRequest?.data;
const selector = request ?? this.#defaultSelector;
if (signalIsAborted(signal))
return err(
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
);
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
});
if (
request === undefined &&
this.#byId.has(selector) &&
baseline.length > 0 &&
baseline.every(
({ targetSupport }) => targetSupport.status === "unsupported",
)
)
)
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
return ok({
requestedProviderId: selector,
binding: null,
candidates: baseline,
});
if (selector !== "auto")
return await this.#selectConcrete(
{
target,
baseline,
signal: signal,
scope,
},
selector,
request === undefined ? "environment" : "request",
);
return await this.#selectAutomatically({
target,
baseline,
signal: signal,
scope,
});
if (selector !== "auto")
return this.#selectConcrete(
{
target,
baseline,
signal: signal,
},
selector,
request === undefined ? "environment" : "request",
);
return this.#selectAutomatically({
target,
baseline,
signal: signal,
});
} finally {
scope.close();
}
}
/** Explain why a deep operation is unavailable on an unbound target. */
@@ -522,7 +543,7 @@ export class AnalysisProviderRegistry {
providerId: string,
source: AnalysisProviderBinding["selectionSource"],
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const { baseline, signal, target, scope } = context;
const provider = this.#byId.get(providerId);
if (provider === undefined)
return err(selectionError("unknown_provider", providerId, baseline));
@@ -541,8 +562,7 @@ export class AnalysisProviderRegistry {
provider,
initial,
target,
signal,
this.#profileFileOwner,
{ signal, fileCloseResources: this.#profileFileOwner, scope },
);
if (!evaluation.ok) return evaluation;
const evaluated = evaluation.value;
@@ -567,7 +587,7 @@ export class AnalysisProviderRegistry {
async #selectAutomatically(
context: TargetSelectionContext,
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
const { baseline, signal, target } = context;
const { baseline, signal, target, scope } = context;
const results = await Promise.all(
this.#providers.map(
async (
@@ -584,13 +604,11 @@ export class AnalysisProviderRegistry {
);
return status.availability.status === "available" &&
status.targetSupport.status === "supported"
? evaluateAnalysisProviderCandidate(
provider,
status,
target,
? evaluateAnalysisProviderCandidate(provider, status, target, {
signal,
this.#profileFileOwner,
)
fileCloseResources: this.#profileFileOwner,
scope,
})
: ok({
candidate: provider,
status,
@@ -647,18 +665,22 @@ export class AnalysisProviderRegistry {
const candidateStatus = async (
provider: AnalysisProviderCandidate,
target: BinaryTarget | undefined,
signal: AbortSignal,
operation: "binary_session" | "open_binary",
context: {
readonly signal: AbortSignal;
readonly scope: ProviderInspectionScope;
readonly operation: "binary_session" | "open_binary";
},
): Promise<AnalysisProviderCandidateStatus> => {
const { signal, scope, operation } = context;
if (signal.aborted)
throw new AnalysisCancelledError(operation, { cause: signal.reason });
const readiness =
provider.inspectReadinessSnapshot === undefined
? {
availability: await provider.inspectAvailability(signal),
availability: await provider.inspectAvailability({ signal, scope }),
capabilities: provider.capabilities(),
}
: await provider.inspectReadinessSnapshot(signal);
: await provider.inspectReadinessSnapshot({ signal, scope });
return projectCandidateStatus(provider, target, readiness);
};