Files
rea/src/application/binary/BinarySession.ts
T
gensokyo-dev c07ab4b994 feat(ghidra): checkpoint owned Programs across sessions
Add opt-in REA_GHIDRA_CHECKPOINT_DIR for owned Ghidra Program persistence through the shared CLI/MCP provider. Save completed initial analysis and metadata changes on normal close; verify and restore the last successful packed Program without auto-analysis.

Preserve generation identity, exclusive ownership, typed cancellation and cleanup retry. Refresh checkpoint-dependent profiles after retirement, including failed-switch restoration, while retaining caller selection provenance. Keep source executables and caller projects unchanged.

Validated with 720 focused tests (3 platform skips), 13 real Ghidra CLI/MCP scenarios, static/docs checks and CI. Review findings addressed. No version bump or release publication.

Refs #8165.
2026-11-07 14:52:39 +08:00

1755 lines
60 KiB
TypeScript

import { randomUUID } from "node:crypto";
import {
writeEvidenceBundle,
evidenceBundleExportReceipt,
} from "../EvidenceBundleFiles.js";
import type { ResetInvestigationReceipt } from "../../domain/investigationLifecycle.js";
import { retainSavedAnalysisSnapshot } from "../../domain/savedAnalysisSnapshot.js";
import type { BinaryTarget } from "../../domain/binaryTargetTypes.js";
import {
analysisProfilesEqual,
type AnalysisProfileCommitment,
} from "../../domain/analysisProfile.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisOutputError,
} from "../../domain/analysisErrorCore.js";
import {
AnalysisError,
primaryAnalysisError,
} from "../../domain/analysisErrorBase.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
import { err, ok, type Result } from "../../domain/result.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import { createAnalysisExecutionEvidence } from "../../domain/analysisObservation.js";
import type {
AnalysisClient,
AnalysisExecution,
ExecutionOptions,
AnalysisOperationPort,
ProviderIdentity,
AnalysisOperation,
} from "../AnalysisProvider.js";
import type {
BinarySessionPort,
BinaryOpenResult,
OpenSelectionReceipt,
SavedAnalysisSnapshot,
} from "./BinarySessionPort.js";
import { binaryTargetSummary } from "./BinaryTargetSummary.js";
import {
SessionProviderRouter,
type SessionProviderRoute,
} from "./SessionProviderRouter.js";
import {
routeAllowsSnapshotReplay,
routeProviderIdentity,
} from "./SessionProviderPolicy.js";
import { InvestigationRecords } from "../investigation/InvestigationRecords.js";
import { recordEvidenceSources } from "../investigation/recordEvidenceSources.js";
import { BinarySessionRecords } from "./BinarySessionRecords.js";
import { binarySessionStatus } from "./BinarySessionStatus.js";
import { SessionRuntimeAvailability } from "./SessionRuntimeAvailability.js";
import { isSourceEvidenceRetainable } from "./AnalysisSnapshotCache.js";
import type { BinarySessionDetail } from "../../contracts/sessionStatusContract.js";
import {
resolveSessionOpen,
resolveSessionTarget,
validateResolvedSessionTarget,
type BinarySessionOpenOptions,
type ResolvedSessionOpen,
} from "./BinarySessionOpen.js";
import {
bindExecutionTarget,
commitExecutionProfile,
prepareSessionExecution,
} from "./BinarySessionExecution.js";
import { closeAnalysisClient } from "../AnalysisClientCleanup.js";
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
import type { AnalysisCleanupRetry } from "../../domain/analysisErrorRecovery.js";
import { analysisErrorWithCancellation } from "../../domain/analysisErrorCancellation.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { profilelessNativeDispatchProfile } from "./ProfilelessNativeWorkflowSnapshot.js";
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
import { ResourceSnapshotMismatchError } from "../../domain/evidenceErrors.js";
import {
parseBinaryTarget,
type BinaryTargetResolverOptions,
} from "../BinaryTargetResolver.js";
import {
resolveAppBundleResourceRoot,
type ResolvedAppBundleResourceRoot,
} from "../AppBundleExecutable.js";
import {
BUNDLE_RESOURCE_OPERATIONS,
type ActiveBundleResourceSession,
type BundleResourceClientFactory,
} from "./BundleResourceSession.js";
interface SessionRun {
readonly client: AnalysisClient;
readonly route: SessionProviderRoute;
readonly runId: string;
}
interface SessionBinding extends SessionRun {
readonly target: BinaryTarget;
readonly profile: AnalysisProfileCommitment | null;
readonly profilelessWorkflowProfile?: AnalysisProfileCommitment;
nativeSourceAdmitted: boolean;
}
interface CleanupFailureTracker {
readonly error: AnalysisError | undefined;
readonly onFailure: (failure: AnalysisError) => void;
}
/** Compose live cleanup facts without treating an unowned rejection as completion. */
const sessionCleanupFailure = (
primary: AnalysisError,
cleanup: AnalysisError,
operation: string,
): AnalysisError => {
const failure = analysisErrorWithCleanupFailure(primary, cleanup, operation);
return cleanup.cleanupIncomplete
? failure.bindCleanupDependencies([primary, cleanup])
: failure;
};
const trackCleanupFailures = (
signal: AbortSignal | undefined,
operation: string,
): CleanupFailureTracker => {
let error: AnalysisError | undefined;
let cleanupFailure: AnalysisError | undefined;
let cancellation: AnalysisCancelledError | undefined;
const observed = new Set<AnalysisError>();
return {
get error() {
return error;
},
onFailure: (failure) => {
if (
observed.has(failure) ||
(failure.cleanupPrimary !== undefined &&
observed.has(failure.cleanupPrimary))
)
return;
observed.add(failure);
if (cleanupFailure === undefined) {
if (isAborted(signal))
cancellation = new AnalysisCancelledError(operation, {
cause: signal?.reason,
});
cleanupFailure = failure;
} else {
// Both operands are cleanup observations here. An earlier rejection
// without a live lifecycle must not disappear when a later owner resolves.
cleanupFailure = cleanupFailure.cleanupIncomplete
? sessionCleanupFailure(cleanupFailure, failure, operation)
: analysisErrorWithCleanupFailure(cleanupFailure, failure, operation);
}
error =
cancellation === undefined
? cleanupFailure
: sessionCleanupFailure(cancellation, cleanupFailure, operation);
},
};
};
/** Provider metadata cannot confer authority to an uncommitted Evidence ID. */
const unrecordedExecution = (
result: Result<AnalysisExecution, AnalysisError>,
): Result<AnalysisExecution, AnalysisError> => {
if (!result.ok || !("evidenceId" in result.value)) return result;
const observation = { ...result.value };
delete observation.evidenceId;
return ok(observation);
};
/**
* Owns the single active target shared by CLI and MCP adapters.
*
* Target transitions are serialized because switching targets tears down the
* active provider client. A failed switch recreates the previous target instead
* of retaining a client whose resources were already shut down.
*/
export class BinarySession
extends BinarySessionRecords
implements BinarySessionPort
{
#active: SessionBinding | undefined;
#externalMutationCleanupFailure: AnalysisError | undefined;
#lastCloseCleanupFailure: AnalysisError | undefined;
#pendingCleanup:
| {
readonly run: SessionRun;
readonly retainDocument: boolean;
readonly failures: Set<AnalysisError>;
}
| undefined;
#transition: Promise<void> = Promise.resolve();
#transitionGeneration = 0;
#transitionPending = 0;
/** Process shutdown has begun; later target acquisition must not outlive it. */
#shutdownStarted = false;
readonly #calls = new Map<Promise<unknown>, number>();
readonly #promptCompletionControllers = new Set<AbortController>();
readonly #providerRouter: SessionProviderRouter;
readonly #bundleResourceFactory: BundleResourceClientFactory | undefined;
#activeBundleResources: ActiveBundleResourceSession | undefined;
#pendingBundleResourceCleanup: ActiveBundleResourceSession[] = [];
readonly #runtimeAvailability = new SessionRuntimeAvailability();
readonly #availabilityListeners = new Set<() => void | Promise<void>>();
constructor(
providerRouter: SessionProviderRouter,
records: InvestigationRecords = new InvestigationRecords(),
bundleResourceFactory?: BundleResourceClientFactory,
) {
super(records);
this.#providerRouter = providerRouter;
this.#bundleResourceFactory = bundleResourceFactory;
}
/** Open an app resource root without requiring CFBundleExecutable. */
openBundleResources(
path: string,
options: Pick<ExecutionOptions, "signal"> = {},
): Promise<Result<ResolvedAppBundleResourceRoot, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
const refused = this.#targetAcquisitionRefused("open_bundle_resources");
if (refused !== undefined) return refused;
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (this.#bundleResourceFactory === undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
"open_bundle_resources",
"No app resource reader is configured.",
),
);
const resolved = await resolveAppBundleResourceRoot(path);
if (!resolved.ok) return resolved;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
}),
);
const runId = randomUUID();
let bundleClient: Awaited<
ReturnType<BundleResourceClientFactory["createClient"]>
>;
try {
bundleClient = await this.#bundleResourceFactory.createClient(
resolved.value,
);
} catch (cause: unknown) {
return err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(
"bundle-resources",
"open_bundle_resources",
{ cause },
),
);
}
const candidate: ActiveBundleResourceSession = {
root: resolved.value,
client: bundleClient.client,
capabilities: bundleClient.capabilities,
runId,
};
const started = await candidate.client.execute("health", {}, options);
if (!started.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? started.error
: sessionCleanupFailure(
started.error,
closed.error,
"open_bundle_resources",
),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const closed = await this.#retryPendingBundleResourceCleanup();
return err(
closed.ok
? cancelled
: sessionCleanupFailure(
cancelled,
closed.error,
"open_bundle_resources",
),
);
}
const cleanupFailures = trackCleanupFailures(
options.signal,
"open_bundle_resources",
);
const closed = await this.#closeActive({}, cleanupFailures.onFailure);
if (!closed.ok) {
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
const primary = cleanupFailures.error ?? closed.error;
return err(
cleanupFailures.error ??
(candidateClosed.ok
? primary
: analysisErrorWithCleanupFailure(
primary,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_bundle_resources", {
cause: options.signal?.reason,
});
this.#pendingBundleResourceCleanup.push(candidate);
const candidateClosed = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
return err(
cleanupFailures.error ??
(candidateClosed.ok
? cancelled
: sessionCleanupFailure(
cancelled,
candidateClosed.error,
"open_bundle_resources",
)),
);
}
this.#activeBundleResources = candidate;
this.resetSnapshotInvalidation();
return resolved;
});
}
/**
* Fast-path liveness gate (#1719): short-circuit only when the retained
* client is genuinely alive. The probe reads the child's operation health
* rather than the composite's unconditionally-ok `health`, so a dead child
* falls through and a real client is started; an aborted caller leaves the
* session untouched. Returns the fast-path result, or undefined to fall
* through to the slow path.
*/
async #tryRetainedFastPath(
retained: SessionBinding,
target: BinaryTarget,
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
): Promise<Result<BinaryTarget, AnalysisError> | undefined> {
const live = await this.#retainedClientLive(retained.client, options);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
if (!live) return undefined;
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) return imported;
}
return ok(target);
}
/** Identify the provider producing evidence for this session. */
providerIdentity(operation?: AnalysisOperation): ProviderIdentity {
return routeProviderIdentity(this.#currentRoute(), operation);
}
/** External live state cannot be replayed by a direct or composed CLI workflow. */
allowsSnapshotReplay(operation: AnalysisOperation): boolean {
if (operation === "decompile_all") return false;
if (this.#activeBundleResources !== undefined) return false;
return routeAllowsSnapshotReplay(this.#active?.route, operation);
}
invalidateSnapshotForExternalMutation(): void {
this.invalidateSnapshot();
}
/** Return the selected immutable profile, optionally scoped to an operation. */
analysisProfile(
operation?: AnalysisOperation,
): AnalysisProfileCommitment | undefined {
const profile = this.#active?.profile;
if (profile === null || profile === undefined) {
if (operation !== "inspect_native_dispatch_metadata") return undefined;
return this.#active?.profilelessWorkflowProfile === undefined
? undefined
: structuredClone(this.#active.profilelessWorkflowProfile);
}
if (
operation !== undefined &&
this.providerIdentity(operation).id !== profile.provider.id
)
return undefined;
return structuredClone(profile);
}
/** Observe runtime provider-health changes that affect discovery metadata. */
onAvailabilityChanged(listener: () => void | Promise<void>): () => void {
this.#availabilityListeners.add(listener);
return () => this.#availabilityListeners.delete(listener);
}
/** Resolve target identity under this session's file cleanup owner. */
resolveTarget(
path: string,
options: Omit<BinaryTargetResolverOptions, "resources"> = {},
) {
return parseBinaryTarget(path, {
...options,
resources: this.fileResources,
});
}
/** Resolve a target and provider profile without creating a provider client. */
previewTarget(
target: BinaryTarget,
options: BinarySessionOpenOptions = {},
): Promise<Result<ResolvedSessionOpen, AnalysisError>> {
if (isAborted(options.signal))
return Promise.resolve(
err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
return resolveSessionTarget({
router: this.#providerRouter,
current: this.#active,
target,
options,
stagedSnapshotMismatch: (target, profile) =>
this.snapshotMismatch(target, profile),
}).then((resolved) =>
resolved.ok && isAborted(options.signal)
? err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
)
: resolved,
);
}
/**
* Open or switch targets after draining calls against the current target.
* Returns the switch failure even if best-effort restoration also fails.
*/
open(
path: string,
options: BinarySessionOpenOptions = {},
): Promise<BinaryOpenResult> {
return this.#open(
(admitted) =>
resolveSessionOpen({
resources: this.fileResources,
router: this.#providerRouter,
current: this.#active,
path,
options: admitted,
stagedSnapshotMismatch: (target, profile) =>
this.snapshotMismatch(target, profile),
}),
options,
);
}
/** Open a previewed target without repeating parsing or provider profile discovery. */
openResolvedTarget(
resolved: Pick<ResolvedSessionOpen, "target" | "route">,
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot"> = {},
): Promise<BinaryOpenResult> {
return this.#open(
() =>
Promise.resolve(
validateResolvedSessionTarget({
...resolved,
current: this.#active,
options,
stagedSnapshotMismatch: (target, profile) =>
this.snapshotMismatch(target, profile),
}),
),
options,
);
}
#open(
resolve: (
admitted: BinarySessionOpenOptions,
) => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
options: BinarySessionOpenOptions,
): Promise<BinaryOpenResult> {
return this.#serialize(async (admittedThrough) => {
const attempt = async (): Promise<
Result<BinaryTarget, AnalysisError>
> => {
const refused = this.#targetAcquisitionRefused("open_binary");
if (refused !== undefined) return refused;
const admitted = await this.#withLoadedSnapshot(options);
if (!admitted.ok) return admitted;
return this.#openWithinTransition(
admittedThrough,
() => resolve(admitted.value),
admitted.value,
);
};
const result = await attempt();
if (result.ok) return result;
return {
...result,
selection: this.#openSelectionReceipt(),
targetAtCompletion:
this.#active === undefined
? null
: binaryTargetSummary(this.#active.target),
providerIdAtCompletion:
this.#active?.route.binding?.identity.id ?? null,
};
});
}
#openSelectionReceipt(): OpenSelectionReceipt {
if (this.#active !== undefined)
return {
kind: "binary",
path: this.#active.target.path,
sha256: this.#active.target.sha256,
};
if (this.#activeBundleResources !== undefined)
return {
kind: "resources",
path: this.#activeBundleResources.root.canonicalPath,
caller_path: this.#activeBundleResources.root.callerPath,
};
return { kind: "none" };
}
/** Read the staged snapshot only after earlier lifecycle transitions have settled. */
async #withLoadedSnapshot(
options: BinarySessionOpenOptions,
): Promise<Result<BinarySessionOpenOptions, AnalysisError>> {
if (options.loadSnapshot === undefined) return ok(options);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const loaded = await options.loadSnapshot();
if (!loaded.ok) return loaded;
const { loadSnapshot: _loaded, ...rest } = options;
return ok({ ...rest, snapshot: loaded.value });
}
async #openWithinTransition(
admittedThrough: number,
resolve: () => Promise<Result<ResolvedSessionOpen, AnalysisError>>,
options: Pick<BinarySessionOpenOptions, "signal" | "snapshot">,
): Promise<Result<BinaryTarget, AnalysisError>> {
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const resolved = await resolve();
if (!resolved.ok) return resolved;
const { target, sameTarget } = resolved.value;
let { route } = resolved.value;
let { profile } = route;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const activeProfile = this.#active?.profile;
const sameProfile =
activeProfile === null || activeProfile === undefined
? profile === null
: profile !== null && analysisProfilesEqual(activeProfile, profile);
await this.#drainCalls(admittedThrough);
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const pendingClosed = await this.#retryPendingCleanup();
if (!pendingClosed.ok) return pendingClosed;
const pendingResourcesClosed =
await this.#retryPendingBundleResourceCleanup();
if (!pendingResourcesClosed.ok) return pendingResourcesClosed;
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
);
const retained = sameTarget && sameProfile ? this.#active : undefined;
if (retained !== undefined) {
const fastPath = await this.#tryRetainedFastPath(
retained,
target,
options,
);
if (fastPath !== undefined) return fastPath;
}
const previous = this.#active;
this.#active = undefined;
if (previous !== undefined) {
const closed = await this.#retireClient(previous);
if (!closed.ok) {
// A failed open retires the provider cache, not the investigation.
// Preserve Evidence and Unknown history for export and recovery.
this.clearSnapshot();
this.#clearRuntimeAvailability();
if (isAborted(options.signal))
return err(
analysisErrorWithCleanupFailure(
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
closed.error,
"open_binary",
).bindCleanupDependencies([closed.error]),
);
return closed;
}
}
if (isAborted(options.signal))
return err(
await this.#restoreAfterFailedOpen(
previous,
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
if (previous !== undefined) {
const refreshed = await this.#providerRouter.refreshAfterRetirement(
route,
target,
options.signal,
);
const admitted = refreshed.ok
? validateResolvedSessionTarget({
target,
route: refreshed.value,
current: previous,
options,
stagedSnapshotMismatch: (target, profile) =>
this.snapshotMismatch(target, profile),
})
: refreshed;
if (!admitted.ok)
return err(
await this.#restoreAfterFailedOpen(previous, admitted.error),
);
route = admitted.value.route;
profile = route.profile;
}
const runId = randomUUID();
const client = route.createClient(target, {
runId,
});
const run: SessionRun = { client, route, runId };
const started = await client.execute("health", {}, options);
if (!started.ok) {
return err(await this.#failedOpen(run, previous, started.error));
}
if (isAborted(options.signal)) {
return err(
await this.#failedOpen(
run,
previous,
new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
}),
),
);
}
const cleanupFailures = trackCleanupFailures(options.signal, "open_binary");
if (this.#activeBundleResources !== undefined) {
this.#pendingBundleResourceCleanup.push(this.#activeBundleResources);
this.#activeBundleResources = undefined;
const retiredResources = await this.#retryPendingBundleResourceCleanup(
undefined,
cleanupFailures.onFailure,
);
if (!retiredResources.ok) {
const primary = cleanupFailures.error ?? retiredResources.error;
const failed = await this.#failedOpen(
run,
previous,
primary,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
}
if (isAborted(options.signal)) {
const cancelled = new AnalysisCancelledError("open_binary", {
cause: options.signal?.reason,
});
const failed = await this.#failedOpen(
run,
previous,
cancelled,
cleanupFailures.onFailure,
);
return err(cleanupFailures.error ?? failed);
}
const profilelessWorkflowProfile =
profile === null ? profilelessNativeDispatchProfile(route) : undefined;
this.#active = {
target,
profile,
...(profilelessWorkflowProfile === undefined
? {}
: { profilelessWorkflowProfile }),
...run,
nativeSourceAdmitted: false,
};
this.#clearRuntimeAvailability();
this.resetSnapshotInvalidation();
if (options.snapshot === undefined) {
if (profile === null) this.clearSnapshot();
else this.selectSnapshot(target, profile);
}
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) {
this.#active = undefined;
return err(await this.#failedOpen(run, previous, imported.error));
}
}
return ok(target);
}
/** Serialize an external mutation and retire the potentially stale analysis. */
withAdmittedExternalMutation<Value>(
_path: string | undefined,
signal: AbortSignal | undefined,
operation: () => Promise<Result<Value, AnalysisError>>,
completionFailure: (
value: Result<Value, AnalysisError>,
error: AnalysisError,
) => AnalysisError,
operationName = "execute_rizin_command",
cleanupRecovery?: (
failure: AnalysisError | undefined,
) => Promise<Result<null, AnalysisError>>,
): Promise<Result<Result<Value, AnalysisError>, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
if (this.#activeBundleResources !== undefined)
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
operationName,
"External mutation requires an active executable analysis target.",
),
);
if (isAborted(signal))
return err(
new AnalysisCancelledError(operationName, { cause: signal?.reason }),
);
await this.#drainCalls(admittedThrough);
if (isAborted(signal))
return err(
new AnalysisCancelledError(operationName, { cause: signal?.reason }),
);
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true) {
if (cleanupRecovery === undefined)
return err(this.#externalMutationCleanupFailure);
const recovered = await cleanupRecovery(
this.#externalMutationCleanupFailure,
);
if (!recovered.ok)
return err(
isAborted(signal)
? analysisErrorWithCancellation(
recovered.error,
new AnalysisCancelledError(operationName, {
cause: signal?.reason,
}),
)
: recovered.error,
);
if (this.#externalMutationCleanupFailure.cleanupIncomplete)
return err(
isAborted(signal)
? analysisErrorWithCancellation(
this.#externalMutationCleanupFailure,
new AnalysisCancelledError(operationName, {
cause: signal?.reason,
}),
)
: this.#externalMutationCleanupFailure,
);
this.#externalMutationCleanupFailure = undefined;
}
const pendingCleanup = await this.#retryPendingCleanup();
if (!pendingCleanup.ok)
return err(
isAborted(signal)
? analysisErrorWithCancellation(
pendingCleanup.error,
new AnalysisCancelledError(operationName, {
cause: signal?.reason,
}),
)
: pendingCleanup.error,
);
if (isAborted(signal))
return err(
new AnalysisCancelledError(operationName, { cause: signal?.reason }),
);
const activeAtDispatch = this.#active;
if (activeAtDispatch?.route.binding?.identity.id === "ghidra")
return err(
new AnalysisOutputError(
operationName,
"Close the active Ghidra session explicitly before an external mutation; rebinding would discard its ephemeral analysis edits.",
),
);
// A protocol reply does not prove that background writers have stopped.
// Retire the old analysis and require an explicit fresh open rather than
// silently binding a snapshot while an unrestricted command can still write.
const retireTarget = activeAtDispatch !== undefined;
this.invalidateSnapshot();
const value = await operation();
this.#externalMutationCleanupFailure =
!value.ok && value.error.cleanupIncomplete ? value.error : undefined;
if (retireTarget) {
const retired = await this.#closeActive({});
if (!retired.ok) {
const primary = completionFailure(value, retired.error);
const failure =
retired.error.cleanupIncomplete && primary !== retired.error
? analysisErrorWithCleanupFailure(
primary,
retired.error,
operationName,
).bindCleanupDependencies([primary, retired.error])
: primary;
return err(
isAborted(signal)
? analysisErrorWithCancellation(
failure,
new AnalysisCancelledError(operationName, {
cause: signal?.reason,
}),
)
: failure,
);
}
}
if (isAborted(signal))
return err(
completionFailure(
value,
new AnalysisCancelledError(operationName, {
cause: signal?.reason,
}),
),
);
return ok(value);
});
}
/**
* Reject target acquisition once process shutdown has begun.
* Acquisition already inside its transition still finishes; the shutdown
* close queued behind that transition retires it.
*/
beginShutdown(): void {
this.#shutdownStarted = true;
}
#targetAcquisitionRefused(
operation: "open_binary" | "open_bundle_resources",
): Result<never, AnalysisCancelledError> | undefined {
if (!this.#shutdownStarted) return undefined;
return err(new AnalysisCancelledError(operation));
}
/** Release the engine and replay state while retaining investigation history. */
close(
options: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
readonly cleanupRetry?: AnalysisCleanupRetry;
} = {},
): Promise<Result<null, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
return this.#offerCleanupRetry(
await this.#closeActive(options),
options.cleanupRetry,
);
});
}
/** Drain earlier requests, save an immutable snapshot, and close under one lifecycle lock. */
closeWithSnapshot(
path: string,
overwrite: boolean,
options: Pick<ExecutionOptions, "progress"> & {
readonly cleanupRetry?: AnalysisCleanupRetry;
} = {},
): Promise<Result<SavedAnalysisSnapshot, AnalysisError>> {
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
const snapshot = this.exportAnalysisSnapshot();
if (!snapshot.ok) return snapshot;
const written = await this.writeAnalysisSnapshot(
snapshot.value,
path,
overwrite,
);
if (!written.ok) return written;
const receipt: SavedAnalysisSnapshot = {
...written.value,
primitive_entries: snapshot.value.entries.length,
workflow_entries: snapshot.value.workflow_entries.length,
evidence_records: snapshot.value.evidence_bundle.records.length,
};
// A failed export or save returned above with the target still open, so
// only a failed retirement can offer the caller's cleanup retry.
const closed = this.#offerCleanupRetry(
await this.#closeActive(options),
options.cleanupRetry,
);
return closed.ok
? ok(receipt)
: err(retainSavedAnalysisSnapshot(closed.error, receipt));
});
}
/** Explicit whole-runtime disposal, separate from reusable engine cleanup. */
dispose(
options: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
} = {},
): Promise<Result<null, AnalysisError>> {
this.beginShutdown();
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
const closed = await this.#closeActive(options);
if (closed.ok) this.disposeInvestigationRecords();
return closed;
});
}
/** Save once at an explicit boundary, then end the investigation atomically. */
resetInvestigation(
options: {
readonly savePath?: string;
readonly overwrite?: boolean;
readonly signal?: AbortSignal;
readonly retryCleanup?: () => Promise<Result<null, AnalysisError>>;
} = {},
): Promise<Result<ResetInvestigationReceipt, AnalysisError>> {
if (this.#shutdownStarted)
return Promise.resolve(
err(new AnalysisCancelledError("reset_investigation")),
);
return this.#serialize(async (admittedThrough) => {
await this.#drainCalls(admittedThrough);
const cancelled = () =>
new AnalysisCancelledError("reset_investigation", {
cause: options.signal?.reason,
});
if (isAborted(options.signal)) return err(cancelled());
const adapterResources = await options.retryCleanup?.();
if (adapterResources !== undefined && !adapterResources.ok)
return adapterResources;
if (this.#externalMutationCleanupFailure?.cleanupIncomplete === true)
return err(this.#externalMutationCleanupFailure);
const pending = await this.#retryPendingCleanup();
if (!pending.ok) return pending;
const bundles = await this.#retryPendingBundleResourceCleanup();
if (!bundles.ok) return bundles;
const files = await this.closeFileResources();
if (!files.ok) return files;
if (this.#providerRouter.hasPendingOwnedResources()) {
const candidates = await this.#providerRouter.closeOwnedResources();
if (!candidates.ok) return candidates;
}
if (isAborted(options.signal)) return err(cancelled());
const statistics = this.investigationStatistics();
let saved: ResetInvestigationReceipt["saved_bundle"];
if (options.savePath !== undefined) {
const bundle = this.immutableEvidenceBundle();
const written = await writeEvidenceBundle(
bundle,
options.savePath,
options.overwrite ?? false,
this.fileResources,
options.signal,
"reset_investigation",
);
if (!written.ok) return written;
saved = evidenceBundleExportReceipt(bundle, written.value);
}
// Publication can win cancellation. Report the committed file but retain
// the investigation if cancellation arrived during publication/cleanup.
if (isAborted(options.signal))
return err(
new AnalysisCancelledError("reset_investigation", {
cause: options.signal?.reason,
...(saved === undefined
? {}
: { diagnostics: { saved_bundle: saved } }),
}),
);
this.clearInvestigationRecords();
return ok({
cleared_evidence_records: statistics.evidence_records,
cleared_unknown_revisions: statistics.unknown_revisions,
cleared_unknown_heads: statistics.unknown_heads,
retained_target:
this.#active !== undefined ||
this.#activeBundleResources !== undefined,
...(saved === undefined ? {} : { saved_bundle: saved }),
});
});
}
/** Offer the caller's retry only while this session retains the failed cleanup. */
#offerCleanupRetry(
closed: Result<null, AnalysisError>,
retry: AnalysisCleanupRetry | undefined,
): Result<null, AnalysisError> {
this.#lastCloseCleanupFailure = undefined;
if (
!closed.ok &&
retry !== undefined &&
closed.error.cleanupIncomplete &&
this.#retainsCloseCleanup()
) {
closed.error.bindCleanupRetry(retry);
this.#lastCloseCleanupFailure = closed.error;
}
return closed;
}
/** Only a retired target's known close owner can offer this status retry. */
#statusCleanupRetry(): AnalysisCleanupRetry | undefined {
if (
this.#active !== undefined ||
this.#activeBundleResources !== undefined ||
this.#externalMutationCleanupFailure?.cleanupIncomplete === true ||
!this.#retainsCloseCleanup()
)
return undefined;
return this.#lastCloseCleanupFailure?.cleanupRetry;
}
/** Owners the next close retries; external mutation owners retry elsewhere. */
#retainsCloseCleanup(): boolean {
return (
this.#pendingCleanup !== undefined ||
this.#pendingBundleResourceCleanup.length > 0 ||
this.hasPendingFileResources ||
this.#providerRouter.hasPendingOwnedResources()
);
}
async #closeActive(
options: Pick<ExecutionOptions, "progress"> & {
readonly retainProviderDocuments?: boolean;
},
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
const previous = this.#active;
const previousResources = this.#activeBundleResources;
this.#active = undefined;
this.#activeBundleResources = undefined;
if (previousResources !== undefined)
this.#pendingBundleResourceCleanup.push(previousResources);
const closing =
previous === undefined
? this.#retryPendingCleanup(
options.progress,
options.retainProviderDocuments,
onCleanupFailure,
)
: this.#retireClient(previous, {
...(options.progress === undefined
? {}
: { progress: options.progress }),
...(options.retainProviderDocuments === true
? { retainDocument: true }
: {}),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
});
// Register the retiring resource owner before notifying observers that
// the engine/cache is gone. Cleanup is still pending at this boundary.
this.clearEngineRecords();
this.#clearRuntimeAvailability();
const closed = await closing;
const fileResources = await this.closeFileResources();
if (!fileResources.ok) onCleanupFailure?.(fileResources.error);
const candidateResources = await this.#providerRouter.closeOwnedResources();
if (!candidateResources.ok) onCleanupFailure?.(candidateResources.error);
const bundleClosed = await this.#retryPendingBundleResourceCleanup(
options.progress,
onCleanupFailure,
);
const resourceFailures = [
...(bundleClosed.ok ? [] : [bundleClosed.error]),
...(fileResources.ok ? [] : [fileResources.error]),
...(candidateResources.ok ? [] : [candidateResources.error]),
];
let resourceFailure = resourceFailures[0];
for (const next of resourceFailures.slice(1))
if (resourceFailure !== undefined) {
const previous = resourceFailure;
resourceFailure = analysisErrorWithCleanupFailure(
previous,
next,
"close_binary",
);
if (previous.cleanupIncomplete && next.cleanupIncomplete)
resourceFailure.bindCleanupDependencies([previous, next]);
}
if (!closed.ok && resourceFailure !== undefined) {
const combined = analysisErrorWithCleanupFailure(
closed.error,
resourceFailure,
"close_binary",
);
if (closed.error.cleanupIncomplete && resourceFailure.cleanupIncomplete)
combined.bindCleanupDependencies([closed.error, resourceFailure]);
return err(combined);
}
if (!closed.ok) return closed;
return resourceFailure === undefined ? ok(null) : err(resourceFailure);
}
async #retryPendingBundleResourceCleanup(
progress?: ExecutionOptions["progress"],
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
if (this.#pendingBundleResourceCleanup.length === 0) return ok(null);
const pending = this.#pendingBundleResourceCleanup;
const retained: ActiveBundleResourceSession[] = [];
let failure: AnalysisError | undefined;
for (const session of pending) {
const closed = await closeAnalysisClient(
session.client,
"bundle-resources",
{
...(progress === undefined ? {} : { progress }),
...(onCleanupFailure === undefined ? {} : { onCleanupFailure }),
},
);
if (!closed.ok) {
retained.push(session);
const previous = failure;
failure =
previous === undefined
? closed.error
: analysisErrorWithCleanupFailure(
previous,
closed.error,
"close_binary",
);
if (
previous?.cleanupIncomplete === true &&
closed.error.cleanupIncomplete
)
failure.bindCleanupDependencies([previous, closed.error]);
}
}
this.#pendingBundleResourceCleanup = retained;
return failure === undefined ? ok(null) : err(failure);
}
async #retireClient(
run: SessionRun,
options: Pick<ExecutionOptions, "progress"> & {
readonly retainDocument?: boolean;
readonly onCleanupFailure?: (failure: AnalysisError) => void;
} = {},
): Promise<Result<null, AnalysisError>> {
this.#pendingCleanup = {
run,
retainDocument: options.retainDocument === true,
failures: new Set(),
};
return this.#retryPendingCleanup(
options.progress,
undefined,
options.onCleanupFailure,
);
}
async #retryPendingCleanup(
progress?: ExecutionOptions["progress"],
retainDocument?: boolean,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<Result<null, AnalysisError>> {
let pending = this.#pendingCleanup;
if (pending === undefined) return ok(null);
if (retainDocument !== undefined) {
pending = { ...pending, retainDocument };
this.#pendingCleanup = pending;
}
const closed = await closeAnalysisClient(
pending.run.client,
pending.run.route.identity.id,
{
...(progress === undefined ? {} : { progress }),
...(pending.retainDocument ? { retainDocument: true } : {}),
onCleanupFailure: (failure) => {
if (failure.cleanupIncomplete) pending.failures.add(failure);
onCleanupFailure?.(failure);
},
},
);
if (closed.ok) {
for (const failure of pending.failures)
if (!failure.hasCleanupOwner) failure.resolveCleanup();
this.#pendingCleanup = undefined;
} else if (closed.error.cleanupIncomplete) {
pending.failures.add(closed.error);
}
return closed;
}
async #failedOpen(
run: SessionRun,
previous: SessionBinding | undefined,
primary: AnalysisError,
onCleanupFailure?: (failure: AnalysisError) => void,
): Promise<AnalysisError> {
const closed = await this.#retireClient(
run,
onCleanupFailure === undefined ? {} : { onCleanupFailure },
);
if (!closed.ok)
return sessionCleanupFailure(primary, closed.error, "open_binary");
return this.#restoreAfterFailedOpen(previous, primary);
}
async #restoreAfterFailedOpen(
previous: SessionBinding | undefined,
primary: AnalysisError,
): Promise<AnalysisError> {
const restored = await this.#restore(previous);
if (restored.ok) return primary;
primary.retainSecondaryFailure(restored.error, {
restoration_error: projectAnalysisError(
primaryAnalysisError(restored.error),
),
});
return restored.error.cleanupIncomplete
? sessionCleanupFailure(primary, restored.error, "open_binary")
: primary;
}
/** Describe the active binary session. */
status(
options: {
readonly signal?: AbortSignal;
readonly detail?: BinarySessionDetail;
} = {},
): Promise<JsonValue> {
const resourceRun = this.#activeBundleResources;
const run =
this.#active ??
resourceRun ??
this.#pendingBundleResourceCleanup[0] ??
this.#pendingCleanup?.run;
const client = run?.client;
const cleanupRetry = this.#statusCleanupRetry();
return binarySessionStatus({
...options,
investigation: this.investigationStatistics(),
target: this.#active?.target,
...(resourceRun === undefined
? {}
: {
bundleResourceRoot: resourceRun.root,
resourceCapabilities: resourceRun.capabilities,
}),
route: this.#currentRoute(),
router: this.#providerRouter,
runtimeUnavailability: this.#runtimeAvailability.entries,
runId: run?.runId,
runtimeLineageSnapshots: client?.runtimeLineageSnapshots?.() ?? [],
requestActivitySnapshots: client?.requestActivitySnapshots?.() ?? [],
providerOperationHealth: client?.operationHealthSnapshot?.() ?? null,
...(cleanupRetry === undefined ? {} : { cleanupRetry }),
cleanupPending:
this.#externalMutationCleanupFailure?.cleanupIncomplete === true ||
this.#retainsCloseCleanup(),
});
}
/** Return the immutable artifact identity captured before its provider started. */
activeTarget(): BinaryTarget | undefined {
return this.#active === undefined
? undefined
: structuredClone(this.#active.target);
}
activeBundleResourceRoot(): ResolvedAppBundleResourceRoot | undefined {
const root = this.#activeBundleResources?.root;
return root === undefined ? undefined : structuredClone(root);
}
override exportAnalysisSnapshot() {
if (this.#activeBundleResources !== undefined)
return err(new ResourceSnapshotMismatchError("export"));
return super.exportAnalysisSnapshot();
}
override importAnalysisSnapshot(snapshot: AnalysisSnapshot) {
if (this.#activeBundleResources !== undefined)
return err(new ResourceSnapshotMismatchError("import"));
return super.importAnalysisSnapshot(snapshot);
}
protected activeAnalysisBinding() {
return this.#active;
}
/**
* Invoke a provider operation against the active target.
* Calls may overlap, but a pending target transition prevents new calls from
* entering until the transition has settled.
*/
execute(
name: Parameters<AnalysisOperationPort["execute"]>[0],
arguments_: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const generation = this.#transitionGeneration;
const call = this.#execute(name, arguments_, options, this.#transition);
this.#calls.set(call, generation);
return call.finally(() => this.#calls.delete(call));
}
/** Run a prompt lookup only against an already connected provider child. */
executeIfProviderReady(
name: Parameters<AnalysisOperationPort["execute"]>[0],
arguments_: Readonly<Record<string, JsonValue>>,
options?: Pick<ExecutionOptions, "signal" | "selectorOrigin" | "workScope">,
): Promise<Result<AnalysisExecution, AnalysisError> | undefined> {
const active = this.#active;
if (
this.#transitionPending > 0 ||
active === undefined ||
active.client.executeIfReady === undefined
)
return Promise.resolve(undefined);
const controller = new AbortController();
this.#promptCompletionControllers.add(controller);
const signal =
options?.signal === undefined
? controller.signal
: AbortSignal.any([controller.signal, options.signal]);
const generation = this.#transitionGeneration;
const call = active.client.executeIfReady(name, arguments_, {
...options,
signal,
});
this.#calls.set(call, generation);
return call.finally(() => {
this.#calls.delete(call);
this.#promptCompletionControllers.delete(controller);
});
}
/** Admit one composed request until its result and session bookkeeping settle. */
withAdmittedAnalysis<Value>(
operationName: string,
signal: AbortSignal | undefined,
operation: (analysis: AnalysisOperationPort) => Promise<Value>,
): Promise<Result<Value, AnalysisError>> {
if (this.#shutdownStarted)
return Promise.resolve(err(new AnalysisCancelledError(operationName)));
const generation = this.#transitionGeneration;
const transition = this.#transition;
const call = (async (): Promise<Result<Value, AnalysisError>> => {
const admission = await this.#waitForTransition(
operationName,
signal,
transition,
);
if (!admission.ok) return admission;
return ok(
await operation({
resourceScope: this.resourceScope,
execute: (name, arguments_, options) =>
this.#execute(name, arguments_, options, Promise.resolve()),
}),
);
})();
// Register synchronously before a lifecycle transition can snapshot calls.
this.#calls.set(call, generation);
return call.finally(() => this.#calls.delete(call));
}
async #execute(
name: Parameters<AnalysisOperationPort["execute"]>[0],
arguments_: Readonly<Record<string, JsonValue>>,
options: ExecutionOptions | undefined,
transition: Promise<void>,
): Promise<Result<AnalysisExecution, AnalysisError>> {
const transitioned = await this.#waitForTransition(
name,
options?.signal,
transition,
);
if (!transitioned.ok) return transitioned;
const resources = this.#activeBundleResources;
if (resources !== undefined) {
if (!BUNDLE_RESOURCE_OPERATIONS.has(name))
return err(
new AnalysisCapabilityUnavailableError(
"bundle-resources",
name,
"This operation requires an executable analysis target; the active session contains only app resource access.",
),
);
const result = unrecordedExecution(
await resources.client.execute(name, arguments_, options),
);
if (!result.ok) return result;
const execution = { ...result.value, subject: null };
return ok(execution);
}
const prepared = prepareSessionExecution({
active: this.#active,
operation: name,
parameters: arguments_,
snapshotContext: options?.snapshotContext,
nativeSourceAdmitted: this.#active?.nativeSourceAdmitted === true,
unboundOperationError: (operation, route) =>
this.#providerRouter.unboundOperationError(operation, route),
lookupSnapshot: (target, profile, operation, parameters) =>
this.lookupSnapshot(target, profile, operation, parameters),
});
if (!prepared.ok) return prepared;
const { active, capability, profile, cacheable, cached } = prepared.value;
if (cached !== undefined) return ok(cached);
// A failed reply cannot prove that a dispatched metadata write rolled back.
// Stop replay before dispatch, including reads overlapping the write.
if (capability.effects.mutatesArtifact) this.invalidateSnapshot();
const dispatch = this.#runtimeAvailability.dispatch();
const call = active.client.execute(name, arguments_, options);
const result = unrecordedExecution(await call);
const profiled = bindExecutionTarget(
commitExecutionProfile(name, result, profile),
name,
active.target,
);
const execution =
profiled.ok &&
(name === "procedure_callers" || name === "procedure_callees") &&
profiled.value.relationshipCoverage === undefined
? ok({
...profiled.value,
relationshipCoverage: {
status: "unknown" as const,
reason:
"Provider did not report relationship coverage for this observation.",
},
})
: profiled;
// A successful Ghidra operation proves this client's native source snapshot
// was admitted; a composite health response and parsed path do not.
if (
execution.ok &&
capability.provider.id === "ghidra" &&
this.#active === active
)
this.#active.nativeSourceAdmitted = true;
if (
this.#runtimeAvailability.observe(
dispatch,
capability,
active.route.capabilities.values(),
execution,
)
)
this.#emitAvailabilityChanged();
// Source facts belong to the observation, even when this query cannot be
// replayed (for example a Ghidra procedure read without a document selector).
if (execution.ok && execution.value.sourceEvidence !== undefined) {
const sourcesRecorded = recordEvidenceSources(
(source) => this.recordEvidence(source),
execution.value.sourceEvidence,
);
if (!sourcesRecorded.ok) return sourcesRecorded;
}
if (
execution.ok &&
isSourceEvidenceRetainable(
name,
capability,
arguments_,
options?.snapshotContext,
) &&
profile !== undefined
) {
const evidence = createAnalysisExecutionEvidence(
name,
arguments_,
{
...execution.value,
analysisProfile: profile,
subject:
execution.value.subject === undefined
? active.target
: execution.value.subject,
},
"observed",
);
const recorded = this.recordEvidence(evidence);
if (!recorded.ok) return recorded;
const retained = { ...execution.value, evidenceId: evidence.evidence_id };
if (cacheable && execution.value.cachePolicy !== "live")
this.recordSnapshot({
target: active.target,
profile,
operation: name,
parameters: arguments_,
execution: retained,
evidence: this.evidenceForAnalysis(evidence.evidence_id) ?? evidence,
});
return ok(retained);
}
return execution;
}
#clearRuntimeAvailability(): void {
if (this.#runtimeAvailability.clear()) this.#emitAvailabilityChanged();
}
#emitAvailabilityChanged(): void {
for (const listener of this.#availabilityListeners) {
try {
const notification = listener();
if (notification !== undefined) {
// best-effort cleanup: async observer notifications must not reject
// unhandled; state transitions and other listeners continue.
void notification.catch(() => undefined);
}
} catch (cause: unknown) {
// External observers are best-effort notifications. Contain only the
// callback failure so state transitions and other listeners continue.
void cause;
}
}
}
#currentRoute(): SessionProviderRoute {
return this.#active?.route ?? this.#providerRouter.initialRoute();
}
#serialize<T>(
operation: (admittedThrough: number) => Promise<T>,
): Promise<T> {
for (const controller of this.#promptCompletionControllers)
controller.abort();
this.#transitionPending += 1;
// Later calls wait for this transition and must not be included in its drain.
const admittedThrough = this.#transitionGeneration++;
const result = this.#transition.then(
() => operation(admittedThrough),
() => operation(admittedThrough),
);
this.#transition = result.then(
() => undefined,
() => undefined,
);
void result.then(
() => {
this.#transitionPending -= 1;
},
() => {
this.#transitionPending -= 1;
},
);
return result;
}
async #drainCalls(admittedThrough: number): Promise<void> {
await Promise.allSettled(
[...this.#calls]
.filter(([, generation]) => generation <= admittedThrough)
.map(([call]) => call),
);
}
/**
* Probe whether a retained provider client is still live.
*
* On production routes the client is a CompositeProvider whose `health`
* answers unconditionally ok without touching a child, so `health` alone
* cannot see a dead child. `operationHealthSnapshot` reaches the
* instantiated children instead. Only the known-live states allow the
* fast path: `exited`/`unreachable` are dead, `unknown` is never
* evidence of health (it is what a recorded but uncharacterized failure
* reports), and `not_started` is a startup that never completed — in the
* Hopper timeout case the client holds retained resources that block the
* next `start()` until `close()` retries cleanup, so treating it as live
* would skip the retire path and hand the wedged client to subsequent
* analysis. The gate fails closed: without a snapshot signal, fall back
* to the `health` admission probe.
*/
async #retainedClientLive(
client: AnalysisClient,
options: Pick<BinarySessionOpenOptions, "signal">,
): Promise<boolean> {
if (client.reuseReadiness?.() === "blocked") return false;
const snapshot = client.operationHealthSnapshot?.();
if (snapshot !== undefined)
return snapshot.state === "idle" || snapshot.state === "busy";
try {
return (await client.execute("health", {}, options)).ok;
} catch {
return false;
}
}
async #restore(
previous: SessionBinding | undefined,
): Promise<Result<null, AnalysisError>> {
if (previous === undefined) return ok(null);
const refreshed = await this.#providerRouter.refreshAfterRetirement(
previous.route,
previous.target,
);
if (!refreshed.ok) return refreshed;
const route = refreshed.value;
const runId = randomUUID();
const client = route.createClient(previous.target, {
runId,
});
const run: SessionRun = { client, route, runId };
const started = await client.execute("health", {});
if (started.ok) {
this.#active = {
...previous,
profile: route.profile,
...run,
nativeSourceAdmitted: false,
};
this.#clearRuntimeAvailability();
return ok(null);
}
const closed = await this.#retireClient(run);
return closed.ok
? err(started.error)
: err(sessionCleanupFailure(started.error, closed.error, "open_binary"));
}
async #waitForTransition(
operation: string,
signal: AbortSignal | undefined,
transition: Promise<void>,
): Promise<Result<undefined, AnalysisCancelledError>> {
if (signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, { cause: signal.reason }),
);
if (signal === undefined) {
await transition;
return ok(undefined);
}
return new Promise((resolve) => {
const onAbort = (): void => {
resolve(
err(new AnalysisCancelledError(operation, { cause: signal.reason })),
);
};
signal.addEventListener("abort", onAbort, { once: true });
transition.then(
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
)
: ok(undefined),
);
},
() => {
signal.removeEventListener("abort", onAbort);
resolve(
signal.aborted
? err(
new AnalysisCancelledError(operation, {
cause: signal.reason,
}),
)
: ok(undefined),
);
},
);
});
}
}
const isAborted = (signal: AbortSignal | undefined): boolean =>
signal?.aborted === true;