Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 62 additions & 6 deletions src/application/binary/BinarySession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ export class BinarySession
this.#providerRouter = providerRouter;
}

/**
* 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"));
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 {
const route = this.#currentRoute();
Expand Down Expand Up @@ -247,12 +271,14 @@ export class BinarySession
if (!pendingClosed.ok) return pendingClosed;
if (isAborted(options.signal))
return err(new AnalysisCancelledError("open_binary"));
if (sameTarget && sameProfile) {
if (options.snapshot !== undefined) {
const imported = this.importAnalysisSnapshot(options.snapshot);
if (!imported.ok) return imported;
}
return ok(target);
const retained = sameTarget && sameProfile ? this.#active : undefined;
if (retained !== undefined) {
const fastPath = await this.#tryRetainedFastPath(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recheck cancellation after the liveness await

If the caller aborts after #tryRetainedFastPath performs its internal signal check but before this awaiting continuation resumes (for example, a provider hook schedules the abort in a following promise reaction), there is no post-await check here: a live result is returned as success, while a dead result immediately clears and closes the active binding and only notices cancellation after cleanup. Recheck options.signal at this async boundary before consuming fastPath so a cancelled same-target open neither succeeds nor mutates the session.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

retained,
target,
options,
);
if (fastPath !== undefined) return fastPath;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve records when dead-client cleanup fails

When the retained client reports a dead state and its close() cannot confirm cleanup (for example, Hopper retains a document or runtime), this new fallthrough reaches the existing failure branch at lines 290–293, where clearSessionRecords() erases every accumulated Evidence/Unknown and the analysis snapshot before open_binary returns the cleanup error. A same-target reopen should retain those local records alongside the pending cleanup owner so a provider crash plus cleanup failure does not destroy the investigation.

AGENTS.md reference: AGENTS.md:L11-L11

Useful? React with 👍 / 👎.

}
const previous = this.#active;
this.#active = undefined;
Expand Down Expand Up @@ -703,6 +729,36 @@ export class BinarySession
);
}

/**
* 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> {
const snapshot = client.operationHealthSnapshot?.();
if (snapshot !== undefined)
return snapshot.state === "idle" || snapshot.state === "busy";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve failed startup state through a blocked retry

For a Hopper startup timeout with incomplete cleanup, one intervening analysis retry makes this test incorrectly pass: HopperClient.#start clears #operationFailure before rejecting the retained resources with HopperProtocolError (src/hopper/HopperClient.ts:314-320), and #rememberStartupFailure does not restore that error (:437-450), so operationHealth() reports idle even though another start() is still blocked. The same-target open_binary then fast-paths the wedged client instead of retrying cleanup; preserve the producer's failed-startup state until cleanup succeeds and cover timeout → retry → reopen. Fresh evidence beyond the earlier not_started finding is this retry-induced transition to the accepted idle state.

AGENTS.md reference: AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

try {
return (await client.execute("health", {}, options)).ok;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revalidate the actual retained provider before fast-pathing

In the production CLI/MCP composition (src/composition/binary.ts:28-35), every route includes auxiliary providers, so selectableRoute wraps the selected deep provider in a CompositeProvider (SessionProviderRouter.ts:117-126); that client's health branch returns ok unconditionally without dispatching to an instantiated child (CompositeProvider.ts:95-107). Consequently, after a Hopper/Ghidra child dies this line still evaluates to true and retains the dead binding; even forwarding health would still miss a dead Ghidra bridge because its health implementation reuses cached start() state while GhidraClient.ping() performs the live request. The direct fake fixture explicitly avoids the production composite, so this needs a production-composed regression and a genuine child-liveness probe.

AGENTS.md reference: AGENTS.md:L44-L44

Useful? React with 👍 / 👎.

} catch {
return false;
}
}

async #restore(
previous: SessionBinding | undefined,
): Promise<Result<null, AnalysisError>> {
Expand Down
Loading
Loading