fix(hopper): diagnose MCP provider failures with stage, state, and request context (#1044)

* fix(hopper): diagnose MCP provider failures with stage, state, and request context

* fix(hopper): retain health at the actual provider lifecycle boundary

* test(hopper): distinguish socket loss from later observed process exit

* test(hopper): cover observed exit separately from socket-first replies

---------

Co-authored-by: N0zoM1z0 <1705004398@qq.com>
This commit is contained in:
Alan Vo 2026-10-08 09:33:56 -07:00 committed by GitHub
parent b5c2954ad6
commit 5a67c7fc54
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
20 changed files with 990 additions and 62 deletions

View File

@ -296,6 +296,7 @@ try {
{ name: "list_documents", arguments: {} },
options,
);
requireSuccessfulTool(documents, "list_documents");
requireBridgeProgress(progressUpdates);
const rejectedProcedure = await client.callTool(
{
@ -381,9 +382,13 @@ try {
await client.callTool({ name: "list_documents", arguments: {} }, options),
"list_documents after target switch",
);
const expectedDocumentsAfterTargetSwitch =
process.platform === "linux"
? firstDocuments.length
: firstDocuments.length + 1;
if (
!Array.isArray(documentsAfterTargetSwitch) ||
documentsAfterTargetSwitch.length !== firstDocuments.length + 1
documentsAfterTargetSwitch.length !== expectedDocumentsAfterTargetSwitch
)
throw new Error("A distinct target did not receive one Hopper document");
const reopenedTarget = await client.callTool(

View File

@ -14,6 +14,7 @@ import { jsonValueSchema } from "../domain/jsonValue.js";
import type { ProgressReporter } from "./ProgressReporter.js";
import type { ProviderRejectionCode } from "../contracts/providerSelection.js";
import type { ProcessLineageObservation } from "../process/ProcessOwnership.js";
import type { ProviderOperationHealth } from "../domain/providerOperationHealth.js";
export interface ExecutionOptions {
readonly signal?: AbortSignal;
@ -99,6 +100,8 @@ export interface AnalysisClient extends AnalysisOperationPort {
runtimeLineageSnapshots?(): readonly ProviderRuntimeLineageSnapshot[];
/** Current provider work that may outlive a timed-out or cancelled caller. */
requestActivitySnapshots?(): readonly ProviderRequestActivitySnapshot[];
/** Current operational health for the active provider bridge. */
operationHealthSnapshot?(): ProviderOperationHealth | undefined;
/** Close with a typed result when the provider can verify cleanup. */
closeWithOutcome?(
options?: Pick<ExecutionOptions, "progress"> & {

View File

@ -339,6 +339,8 @@ export class BinarySession
this.#active?.client.runtimeLineageSnapshots?.() ?? [],
requestActivitySnapshots:
this.#active?.client.requestActivitySnapshots?.() ?? [],
providerOperationHealth:
this.#active?.client.operationHealthSnapshot?.() ?? null,
});
}

View File

@ -1,5 +1,6 @@
import type { BinaryTarget } from "../../domain/binaryTarget.js";
import type { JsonValue } from "../../domain/jsonValue.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
import type {
CapabilityDescriptor,
ProviderRequestActivitySnapshot,
@ -21,6 +22,7 @@ interface BinarySessionStatusInput {
readonly runId: string | undefined;
readonly runtimeLineageSnapshots: readonly ProviderRuntimeLineageSnapshot[];
readonly requestActivitySnapshots: readonly ProviderRequestActivitySnapshot[];
readonly providerOperationHealth?: ProviderOperationHealth | null;
}
/** Project internal provider routing state into the caller-visible session status. */
@ -32,6 +34,7 @@ export const binarySessionStatus = ({
runId,
runtimeLineageSnapshots,
requestActivitySnapshots,
providerOperationHealth,
}: BinarySessionStatusInput): JsonValue => {
const configuredProvider = router.configuredIdentity();
const provider = providerSummary(configuredProvider);
@ -85,6 +88,9 @@ export const binarySessionStatus = ({
process_lineage: processLineageStatus(runtimeLineageSnapshots),
},
analysis_activity: requestActivityStatus(requestActivitySnapshots),
provider_operation_health: providerOperationHealthStatus(
providerOperationHealth,
),
};
return target === undefined
? { open: false, ...common }
@ -203,3 +209,20 @@ const capabilityStatus = (
},
limitations: [...descriptor.limitations],
});
const providerOperationHealthStatus = (
health: ProviderOperationHealth | null | undefined,
): JsonValue => {
if (health === undefined || health === null) return null;
return {
state: health.state,
stage: health.stage,
retry_action: health.retryAction,
exit_code: health.exitCode,
requests: health.requests.map((request) => ({
request_id: request.requestId,
operation: request.operation,
stage: request.stage,
})),
};
};

View File

@ -152,6 +152,18 @@ export class CompositeProvider implements AnalysisProvider {
.sort((left, right) =>
left.provider.id.localeCompare(right.provider.id),
),
operationHealthSnapshot: () => {
for (const client of clients.values()) {
const snapshot = client.operationHealthSnapshot?.();
if (snapshot !== undefined && snapshot.state !== "idle")
return snapshot;
}
for (const client of clients.values()) {
const snapshot = client.operationHealthSnapshot?.();
if (snapshot !== undefined) return snapshot;
}
return undefined;
},
closeWithOutcome,
close: async () => {
await closeWithOutcome();

View File

@ -3,6 +3,7 @@ import type { BinaryTarget } from "../../domain/binaryTarget.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import { AnalysisError } from "../../domain/analysisErrorBase.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import type { ProviderOperationHealth } from "../../domain/providerOperationHealth.js";
import { err } from "../../domain/result.js";
import { ABORTED, waitForAbortable } from "./AbortablePromise.js";
import type {
@ -115,6 +116,10 @@ class LazyAnalysisClient implements AnalysisClient {
return this.#client?.requestActivitySnapshots?.() ?? [];
}
operationHealthSnapshot(): ProviderOperationHealth | undefined {
return this.#client?.operationHealthSnapshot?.();
}
async close(): Promise<void> {
this.#closed = true;
if (this.#loading === undefined) return;

View File

@ -14,6 +14,11 @@ import {
PROVIDER_REJECTION_CODES,
type ProviderRejectionCode,
} from "./providerSelection.js";
import {
PROVIDER_FAILURE_STAGES,
PROVIDER_OPERATION_STATES,
PROVIDER_RETRY_ACTIONS,
} from "../domain/providerOperationHealth.js";
import { analysisErrorProjectionSchema } from "./errorSchemas.js";
import { prefixedDigestSchema } from "./../domain/digests.js";
@ -169,6 +174,22 @@ export const analysisActivity = z.object({
),
});
const providerOperationRequest = z.object({
request_id: z.number().int().min(1),
operation: z.string().min(1),
stage: z.enum(PROVIDER_FAILURE_STAGES),
});
export const providerOperationHealth = z
.object({
state: z.enum(PROVIDER_OPERATION_STATES),
stage: z.enum(PROVIDER_FAILURE_STAGES).nullable(),
retry_action: z.enum(PROVIDER_RETRY_ACTIONS).nullable(),
exit_code: z.number().int().nullable(),
requests: z.array(providerOperationRequest),
})
.nullable();
const providerRejectionCode: z.ZodType<ProviderRejectionCode> = z.enum(
PROVIDER_REJECTION_CODES,
);
@ -315,6 +336,7 @@ export const sessionProvider = z
capabilities: z.array(providerCapability),
analysis_run: analysisRun,
analysis_activity: analysisActivity,
provider_operation_health: providerOperationHealth,
analysis_provider_binding: z
.object({
provider: providerIdentity,

View File

@ -25,6 +25,7 @@ import {
} from "./hopperErrors.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { providerRetryAction } from "./providerOperationHealth.js";
import {
type AnalysisError,
type AnalysisErrorTag,
@ -49,7 +50,7 @@ export const analysisErrorRemediationAction = (
? "Check binary_session.analysis_activity, wait for the active Hopper request to finish, then retry."
: "Check binary_session for Hopper health, then retry the operation.";
if (error instanceof HopperProcessError)
return "Check binary_session provider health. Restart the owned Hopper process only when it has stopped.";
return hopperProcessRemediation(error);
if (error instanceof HopperStartError)
return error.ownerRunId === undefined
? "Check the Hopper launcher and target details, then retry opening the target."
@ -191,8 +192,7 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
? `Hopper timed out during ${request} while the provider remained busy. Check binary_session.analysis_activity, wait for the active request to finish, then retry.`
: `Hopper timed out during ${request} before it started. Check binary_session for provider health, then retry.`;
}
if (error instanceof HopperProcessError)
return `Hopper stopped during ${error.operation ?? "connection"}${error.exitCode === null ? "; its exit was not observed" : ` with exit code ${String(error.exitCode)}`}.${error.userMessage === undefined ? "" : ` ${error.userMessage}`} Check binary_session provider health before retrying.`;
if (error instanceof HopperProcessError) return hopperProcessMessage(error);
if (error instanceof HopperStartError)
return (
error.userMessage ??
@ -203,6 +203,40 @@ const hopperErrorUserMessage = (error: AnalysisError): string | undefined => {
return undefined;
};
const RESTART_OWNED_PROVIDER =
"Restart the owned provider by calling close_binary, then open the target again and retry.";
const hopperProcessRemediation = (error: HopperProcessError): string => {
if (error.failureCode !== undefined && error.userMessage !== undefined)
return error.userMessage;
if (providerRetryAction(error.providerState) === "restart_provider")
return RESTART_OWNED_PROVIDER;
return "Read binary_session.provider_operation_health for this request before retrying or restarting the owned provider.";
};
const hopperProcessMessage = (error: HopperProcessError): string => {
const stage = error.stage;
const where = error.operation ?? stage;
const request =
error.requestId === undefined
? ""
: ` Request id ${String(error.requestId)}.`;
const startup =
error.userMessage === undefined ? "" : ` ${error.userMessage}`;
if (error.providerState === "exited") {
const exit =
error.exitCode === null
? "no exit code was observed"
: `exit code ${String(error.exitCode)}`;
const recovery =
error.failureCode === undefined ? ` ${RESTART_OWNED_PROVIDER}` : "";
return `Hopper exited during ${where} (${exit}).${startup}${recovery}${request}`;
}
if (error.providerState === "unreachable")
return `Hopper became unreachable during ${where}; its process exit was not observed. ${RESTART_OWNED_PROVIDER}${startup}${request}`;
return `Hopper failed during ${where} and REA could not determine whether the provider is busy, exited, or unreachable.${startup} Read binary_session.provider_operation_health for this request before retrying or restarting.${request}`;
};
const standardErrorMessage = (tag: AnalysisErrorTag): string | undefined => {
if (UNREADABLE_OUTPUT_TAGS.has(tag))
return "Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.";

View File

@ -390,3 +390,40 @@ describe("analysis error projection: caller contract", () => {
});
});
});
describe("analysis error projection: operational diagnostics", () => {
it("distinguishes an observed Hopper exit from an undetermined provider failure", () => {
const exited = projectAnalysisError(
new HopperProcessError(7, undefined, "search_strings", 4),
);
expect(exited).toMatchObject({
code: "provider_unavailable",
retryable: true,
details: {
stage: "analysis",
provider_state: "exited",
retry_action: "restart_provider",
operation: "search_strings",
request_id: 4,
exit_code: 7,
},
});
expect(exited.message).not.toContain("Run `rea doctor`, then try again.");
expect(exited.remediation.action).toContain("Restart the owned provider");
const unknown = projectAnalysisError(
new HopperProcessError(null, undefined, "search_strings", 5),
);
expect(unknown.details).toMatchObject({
stage: "analysis",
provider_state: "unknown",
retry_action: "unknown",
operation: "search_strings",
request_id: 5,
exit_code: null,
});
expect(unknown.message).toContain("could not determine");
expect(unknown.message).not.toContain("stopped");
expect(unknown.remediation.action).toContain("provider_operation_health");
});
});

View File

@ -25,11 +25,13 @@ import {
HopperCancelledError,
HopperProcessError,
HopperRemoteError,
HopperStartError,
HopperTimeoutError,
} from "./hopperErrors.js";
import { ProviderAdapterError } from "./providerAdapterError.js";
import { ProviderSelectionError } from "./providerSelectionError.js";
import { UnknownRegistryError } from "./unknownRegistryError.js";
import { providerRetryAction } from "./providerOperationHealth.js";
import {
type AnalysisError,
type AnalysisErrorTag,
@ -327,10 +329,16 @@ const providerErrorDetails = (
...(error.operation === undefined ? {} : { operation: error.operation }),
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
};
if (error instanceof HopperProcessError)
if (error instanceof HopperProcessError) {
const stage = error.stage;
return {
exit_code: error.exitCode,
stage: error.operation === undefined ? "connection" : "analysis",
stage,
provider_state: error.providerState,
retry_action: providerRetryAction(
error.providerState,
error.failureCode !== undefined,
),
...(error.failureCode === undefined
? {}
: { failure_code: error.failureCode }),
@ -340,6 +348,16 @@ const providerErrorDetails = (
? {}
: { diagnostics: { ...error.diagnostic } }),
};
}
if (error instanceof HopperStartError)
return {
stage: "launch",
provider_state: "unknown",
retry_action: error.ownerRunId === undefined ? "retry" : "unknown",
...(error.ownerRunId === undefined
? {}
: { owner_run_id: error.ownerRunId }),
};
return undefined;
};
@ -369,9 +387,10 @@ const lifecycleErrorDetails = (
return { operation: error.operation, timeout_ms: error.timeoutMs };
if (error instanceof HopperTimeoutError)
return {
stage: error.operation === undefined ? "startup" : "analysis",
stage: error.operation === undefined ? "startup" : error.stage,
timeout_ms: error.timeoutMs,
provider_state: error.providerState,
retry_action: error.providerState === "busy" ? "wait" : "retry",
...(error.operation === undefined ? {} : { operation: error.operation }),
...(error.requestId === undefined ? {} : { request_id: error.requestId }),
};

View File

@ -1,4 +1,8 @@
import { AnalysisError } from "./analysisErrorBase.js";
import type {
HopperProcessProviderState,
ProviderFailureStage,
} from "./providerOperationHealth.js";
import {
hopperStartupFailure,
type HopperStartupDiagnostic,
@ -18,6 +22,9 @@ export class HopperTimeoutError extends HopperError {
readonly operation?: string,
readonly requestId?: number,
readonly providerState: "busy" | "not_started" = "not_started",
readonly stage: ProviderFailureStage = operation === undefined
? "launch"
: "analysis",
) {
super(
`Hopper ${operation === undefined ? "startup" : operation} timed out after ${String(timeoutMs)}ms`,
@ -76,17 +83,30 @@ export class HopperProcessError extends HopperError {
readonly _tag = "HopperProcessError";
readonly failureCode: HopperStartupFailureCode | undefined;
override readonly userMessage: string | undefined;
readonly providerState: HopperProcessProviderState;
readonly stage: ProviderFailureStage;
constructor(
readonly exitCode: number | null,
readonly diagnostic?: HopperStartupFailureDiagnostic,
readonly operation?: string,
readonly requestId?: number,
providerState?: HopperProcessProviderState,
stage?: ProviderFailureStage,
) {
super(`Hopper bridge stopped unexpectedly with code ${String(exitCode)}`);
const failure = hopperStartupFailure(exitCode);
this.stage =
stage ??
(failure === undefined
? operation === undefined
? "connection"
: "analysis"
: "launch");
this.failureCode = failure?.code;
this.userMessage = failure?.message;
this.providerState =
providerState ?? (exitCode === null ? "unknown" : "exited");
}
}

View File

@ -0,0 +1,88 @@
/** Caller-visible operational state for one provider bridge. */
export const PROVIDER_OPERATION_STATES = [
"idle",
"busy",
"exited",
"unreachable",
"not_started",
"unknown",
] as const;
/** Operational state REA can support with evidence, or unknown. */
export type ProviderOperationState = (typeof PROVIDER_OPERATION_STATES)[number];
/** States a Hopper process failure can establish. */
export type HopperProcessProviderState = "exited" | "unreachable" | "unknown";
/** What the caller should do next for this provider state. */
export const PROVIDER_RETRY_ACTIONS = [
"retry",
"wait",
"restart_provider",
"unknown",
] as const;
/** Recovery choice for a provider operation failure. */
export type ProviderRetryAction = (typeof PROVIDER_RETRY_ACTIONS)[number];
/** Stage that was in progress when the provider operation failed. */
export const PROVIDER_FAILURE_STAGES = [
"launch",
"connection",
"analysis",
"decompilation",
"unknown",
] as const;
/** Known provider-operation stage, including an explicit unknown. */
export type ProviderFailureStage = (typeof PROVIDER_FAILURE_STAGES)[number];
/** One provider request retained with a failure or an in-flight operation. */
export interface ProviderOperationRequest {
readonly requestId: number;
readonly operation: string;
readonly stage: ProviderFailureStage;
}
/** Sanitized provider health a session can show after the request returns. */
export interface ProviderOperationHealth {
readonly state: ProviderOperationState;
readonly stage: ProviderFailureStage | null;
readonly retryAction: ProviderRetryAction | null;
readonly exitCode: number | null;
readonly requests: readonly ProviderOperationRequest[];
}
/**
* Choose a recovery action from the observed state.
* Startup failures keep their existing install or configuration guidance.
*/
export const providerRetryAction = (
state: ProviderOperationState,
startupFailure = false,
): ProviderRetryAction | null => {
if (state === "idle") return null;
if (startupFailure && state === "exited") return "retry";
switch (state) {
case "busy":
return "wait";
case "exited":
case "unreachable":
return "restart_provider";
case "not_started":
return "retry";
case "unknown":
return "unknown";
}
};
/** Use one stage when every retained request agrees, otherwise unknown. */
export const sharedProviderFailureStage = (
requests: readonly ProviderOperationRequest[],
): ProviderFailureStage | null => {
const first = requests[0]?.stage;
if (first === undefined) return null;
return requests.every((request) => request.stage === first)
? first
: "unknown";
};

View File

@ -19,6 +19,12 @@ import {
hopperStartupFailure,
type HopperStartupFailureDiagnostic,
} from "../domain/hopperStartupFailure.js";
import {
providerRetryAction,
sharedProviderFailureStage,
type ProviderOperationHealth,
type ProviderOperationRequest,
} from "../domain/providerOperationHealth.js";
import { err, ok, type Result } from "../domain/result.js";
import type { JsonValue } from "../domain/jsonValue.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
@ -31,6 +37,7 @@ import {
ProviderProcessSupervisor,
} from "../process/ProviderProcess.js";
import type { BridgeLaunch, BridgeLauncher } from "./BridgeLauncher.js";
import { hopperOperationStage } from "./HopperOperationStage.js";
import type { HopperDiagnostic } from "./HopperDiagnostics.js";
import { cleanupHopperSession } from "./HopperCleanup.js";
import {
@ -94,6 +101,7 @@ export class HopperClient {
#closing = false;
#launcherExitCode: number | null | undefined;
#launcherFailureDiagnostic: HopperStartupFailureDiagnostic | undefined;
#operationFailure: OperationFailureRecord | undefined;
#startupController: AbortController | undefined;
#startPromise: Promise<Result<HopperServerInfo, HopperError>> | undefined;
#closePromise: Promise<Result<null, AnalysisError>> | undefined;
@ -101,10 +109,46 @@ export class HopperClient {
this.#responses.push(chunk);
};
readonly #onSocketError = (): void => {
this.#failAll(new HopperProcessError(null));
const error = new HopperProcessError(
null,
undefined,
undefined,
undefined,
"unreachable",
);
this.#failAll(error);
this.#rememberProcessFailure(error, []);
};
readonly #onSocketClose = (): void => {
if (!this.#closing) this.#failAll(new HopperProcessError(null));
if (this.#closing) return;
const launch = this.#launch;
if (launch?.ownsProcessLifetime && this.#launcherExitCode !== undefined)
return;
if (launch?.ownsProcessLifetime) {
setImmediate(() => {
if (this.#closing || this.#launcherExitCode !== undefined) return;
const exitCode = launch.process.exitCode ?? null;
const error = new HopperProcessError(
exitCode,
this.#launcherFailureDiagnostic,
undefined,
undefined,
exitCode !== null ? "exited" : "unreachable",
);
this.#failAll(error);
this.#rememberProcessFailure(error, []);
});
return;
}
const error = new HopperProcessError(
null,
undefined,
undefined,
undefined,
"unreachable",
);
this.#failAll(error);
this.#rememberProcessFailure(error, []);
};
constructor(options: HopperClientOptions) {
@ -147,6 +191,59 @@ export class HopperClient {
return this.#requests.activity();
}
/**
* Latest operational health for this owned bridge.
* A recorded exit remains visible after request activity returns to idle.
*/
operationHealth(): ProviderOperationHealth {
const failure = this.#operationFailure;
if (failure !== undefined) {
const requests: readonly ProviderOperationRequest[] =
failure.requests.map((request) => ({
...request,
stage: failure.startupFailure
? "launch"
: hopperOperationStage(request.operation),
}));
return {
state: failure.state,
stage:
requests.length === 0
? failure.startupFailure
? "launch"
: "connection"
: sharedProviderFailureStage(requests),
retryAction: providerRetryAction(failure.state, failure.startupFailure),
exitCode: failure.exitCode,
requests,
};
}
const activity = this.requestActivity();
if (activity !== null) {
const stage = hopperOperationStage(activity.operation);
return {
state: "busy",
stage,
retryAction: "wait",
exitCode: null,
requests: [
{
requestId: activity.requestId,
operation: activity.operation,
stage,
},
],
};
}
return {
state: "idle",
stage: null,
retryAction: null,
exitCode: null,
requests: [],
};
}
/** Launch the bridge once and complete its authenticated health handshake. */
start(signal?: AbortSignal): Promise<Result<HopperServerInfo, HopperError>> {
if (this.#closePromise !== undefined) {
@ -175,7 +272,25 @@ export class HopperClient {
void started.then(
(result) => {
release();
if (!result.ok) reset();
if (!result.ok) {
if (result.error instanceof HopperProcessError)
this.#rememberProcessFailure(result.error, []);
else if (
this.#operationFailure === undefined &&
(result.error instanceof HopperTimeoutError ||
result.error instanceof HopperStartError)
)
this.#operationFailure = {
state:
result.error instanceof HopperTimeoutError
? "not_started"
: "unknown",
exitCode: null,
startupFailure: true,
requests: [],
};
reset();
}
},
(cause: unknown) => {
this.#logger.debug(
@ -193,6 +308,7 @@ export class HopperClient {
signal?: AbortSignal,
): Promise<Result<HopperServerInfo, HopperError>> {
if (isAborted(signal)) return err(new HopperCancelledError());
this.#operationFailure = undefined;
if (this.#socket !== undefined || this.#runtimeRoot !== undefined) {
return err(new HopperProtocolError("Hopper client is already started"));
}
@ -393,6 +509,9 @@ export class HopperClient {
new HopperProcessError(
this.#launcherExitCode,
this.#launcherFailureDiagnostic,
undefined,
undefined,
"exited",
),
);
try {
@ -432,7 +551,16 @@ export class HopperClient {
const socket = this.#socket;
const token = this.#token;
if (socket === undefined || socket.destroyed || token === undefined) {
return err(new HopperProcessError(null, undefined, method, id));
return this.#unavailableRequest(
new HopperProcessError(
null,
undefined,
method,
id,
"unreachable",
hopperOperationStage(method),
),
);
}
if (options.signal?.aborted === true)
return err(new HopperCancelledError());
@ -441,6 +569,7 @@ export class HopperClient {
...(options.signal === undefined ? {} : { signal: options.signal }),
...(options.progress !== undefined ? { progress: options.progress } : {}),
});
this.#retainRequestHealth(result, id, method);
this.#logger[result.ok ? "debug" : "warn"](
{
method,
@ -525,10 +654,18 @@ export class HopperClient {
type: "launcher-exit",
code: event.code,
});
if (launch.ownsProcessLifetime && !this.#closing)
if (launch.ownsProcessLifetime && !this.#closing) {
this.#rememberExit(event.code);
this.#failAll(
new HopperProcessError(event.code, this.#launcherFailureDiagnostic),
new HopperProcessError(
event.code,
this.#launcherFailureDiagnostic,
undefined,
undefined,
"exited",
),
);
}
}
#abortProtocol(message: string, cause?: Error): void {
@ -539,8 +676,119 @@ export class HopperClient {
#failAll(error: HopperError): void {
this.#requests.failAll(error);
}
#unavailableRequest(
error: HopperProcessError,
): Result<JsonValue, HopperProcessError> {
this.#rememberProcessFailure(
error,
error.requestId === undefined || error.operation === undefined
? []
: [{ requestId: error.requestId, operation: error.operation }],
);
return err(error);
}
#retainRequestHealth(
result: Result<JsonValue, HopperError>,
requestId: number,
operation: string,
): void {
if (this.#closing) return;
if (result.ok) return;
if (!(result.error instanceof HopperProcessError)) return;
this.#rememberProcessFailure(result.error, [
{
requestId: result.error.requestId ?? requestId,
operation: result.error.operation ?? operation,
},
]);
}
#rememberExit(code: number | null): void {
if (this.#closing) return;
const previous = this.#operationFailure;
this.#operationFailure = {
state: "exited",
exitCode: code ?? previous?.exitCode ?? null,
startupFailure:
hopperStartupFailure(code) !== undefined ||
previous?.startupFailure === true,
requests: previous?.requests ?? [],
};
}
#rememberProcessFailure(
error: HopperProcessError,
requests: readonly FailedProviderRequest[],
): void {
if (this.#closing) return;
const previous = this.#operationFailure;
const state = processFailureState(error);
const startupFailure =
error.failureCode !== undefined || previous?.startupFailure === true;
const merged = mergeFailedRequests(previous?.requests ?? [], requests);
if (
previous !== undefined &&
PROCESS_FAILURE_RANK[previous.state] > PROCESS_FAILURE_RANK[state]
) {
this.#operationFailure = {
...previous,
startupFailure,
requests: merged,
};
return;
}
this.#operationFailure = {
state,
exitCode:
state === "exited"
? (error.exitCode ?? previous?.exitCode ?? null)
: null,
startupFailure,
requests: merged,
};
}
}
interface FailedProviderRequest {
readonly requestId: number;
readonly operation: string;
}
interface OperationFailureRecord {
readonly state: "exited" | "unreachable" | "unknown" | "not_started";
readonly exitCode: number | null;
readonly startupFailure: boolean;
readonly requests: readonly FailedProviderRequest[];
}
const PROCESS_FAILURE_RANK = {
not_started: 0,
unknown: 0,
unreachable: 1,
exited: 2,
} as const;
const processFailureState = (
error: HopperProcessError,
): OperationFailureRecord["state"] =>
error.providerState === "exited" || error.exitCode !== null
? "exited"
: error.providerState;
const mergeFailedRequests = (
current: readonly FailedProviderRequest[],
added: readonly FailedProviderRequest[],
): readonly FailedProviderRequest[] => {
const byId = new Map<number, FailedProviderRequest>();
for (const request of [...current, ...added])
byId.set(request.requestId, request);
return [...byId.values()].sort(
(left, right) => left.requestId - right.requestId,
);
};
const isAborted = (signal?: AbortSignal): boolean => signal?.aborted === true;
const startupInterruption = (

View File

@ -0,0 +1,9 @@
import type { ProviderFailureStage } from "../domain/providerOperationHealth.js";
/** Classify Hopper wire operations at their provider boundary. */
export const hopperOperationStage = (
operation: string,
): ProviderFailureStage => {
if (operation === "health") return "connection";
return operation === "procedure_pseudo_code" ? "decompilation" : "analysis";
};

View File

@ -235,6 +235,7 @@ export class HopperProvider implements AnalysisProviderCandidate {
},
];
},
operationHealthSnapshot: () => client.operationHealth(),
closeWithOutcome: (options) => client.closeWithOutcome(options),
close: () => client.close(),
};

View File

@ -2,6 +2,7 @@ import type {
ProgressReporter,
ProgressUpdate,
} from "../application/ProgressReporter.js";
import { hopperOperationStage } from "./HopperOperationStage.js";
import type { HopperBridgeEvent } from "./protocol.js";
import {
HopperCancelledError,
@ -101,23 +102,12 @@ export class HopperRequestQueue {
const entry = this.#active;
if (entry === undefined || entry.id !== id) return false;
this.#releaseWire(entry);
if (!entry.callerSettled) {
const contextual =
!result.ok && result.error instanceof HopperRemoteError
? err(
new HopperRemoteError(
result.error.code,
result.error.safeMessage,
{
diagnosticType: result.error.diagnosticType,
operation: entry.method,
requestId: entry.id,
},
),
)
: result;
this.#settleCaller(entry, contextual, "waiting");
}
if (!entry.callerSettled)
this.#settleCaller(
entry,
result.ok ? result : err(withRequestContext(result.error, entry)),
"waiting",
);
this.#active = undefined;
this.#drain();
return true;
@ -216,7 +206,20 @@ export class HopperRequestQueue {
try {
this.send(
{ id: entry.id, method: entry.method, params: entry.params },
() => this.accept(entry.id, err(new HopperProcessError(null))),
() =>
this.accept(
entry.id,
err(
new HopperProcessError(
null,
undefined,
entry.method,
entry.id,
"unreachable",
hopperOperationStage(entry.method),
),
),
),
);
} catch (cause: unknown) {
this.accept(
@ -289,12 +292,23 @@ export class HopperRequestQueue {
const withRequestContext = (
error: HopperError,
entry: QueuedRequest,
): HopperError =>
error instanceof HopperProcessError
? new HopperProcessError(
error.exitCode,
error.diagnostic,
entry.method,
entry.id,
)
: error;
): HopperError => {
if (error instanceof HopperProcessError)
return new HopperProcessError(
error.exitCode,
error.diagnostic,
error.operation ?? entry.method,
error.requestId ?? entry.id,
error.providerState,
error.operation === undefined && error.stage === "connection"
? hopperOperationStage(entry.method)
: error.stage,
);
if (error instanceof HopperRemoteError)
return new HopperRemoteError(error.code, error.safeMessage, {
diagnosticType: error.diagnosticType,
operation: error.operation ?? entry.method,
requestId: error.requestId ?? entry.id,
});
return error;
};

View File

@ -0,0 +1,178 @@
import { EventEmitter } from "node:events";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { ok } from "../../../../src/domain/result.js";
import type {
BridgeLauncher,
BridgeSession,
} from "../../../../src/hopper/BridgeLauncher.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
import { HopperFixtureLauncher } from "./hopperClient.fixture.js";
class ObservedProcess extends EventEmitter {
readonly stdout = null;
readonly stderr = null;
exitCode: number | null = null;
readonly signalCode = null;
exit(code: number): void {
this.exitCode = code;
this.emit("exit", code, null);
this.emit("close", code, null);
}
kill(): boolean {
this.exit(0);
return true;
}
}
class LifecycleLauncher implements BridgeLauncher {
readonly fixture = new HopperFixtureLauncher();
observed = new ObservedProcess();
constructor(readonly owned: boolean) {}
async launch(session: BridgeSession) {
this.observed = new ObservedProcess();
await this.fixture.launch(session);
return ok({
process: this.observed,
ownsProcessLifetime: this.owned,
shutdownMode: "bridge-request" as const,
});
}
}
const start = async (owned: boolean) => {
const launcher = new LifecycleLauncher(owned);
const client = new HopperClient({ launcher, startupTimeoutMs: 10_000 });
onTestFinished(async () => {
await client.close();
for (const child of launcher.fixture.processes) child.kill();
});
expect((await client.start()).ok).toBe(true);
return { client, launcher };
};
describe("Hopper provider lifecycle health", () => {
it("keeps a live bridge healthy after a non-owning launcher helper exits", async () => {
const { client, launcher } = await start(false);
launcher.observed.exit(0);
expect(client.operationHealth().state).toBe("idle");
expect(await client.callTool("echo", { label: "GUI still alive" })).toEqual(
{
ok: true,
value: { label: "GUI still alive" },
},
);
expect(client.operationHealth().state).toBe("idle");
});
it("settles disconnected requests without attributing the helper exit to the GUI", async () => {
const { client, launcher } = await start(false);
launcher.observed.exit(0);
const pending = client.callTool("hang");
await launcher.fixture.waitForRequest("hang");
launcher.fixture.processes.at(-1)?.kill("SIGKILL");
await vi.waitFor(() =>
expect(client.operationHealth().state).toBe("unreachable"),
);
expect(await pending).toMatchObject({
ok: false,
error: {
providerState: "unreachable",
exitCode: null,
operation: "hang",
},
});
expect(client.operationHealth()).toMatchObject({
state: "unreachable",
exitCode: null,
});
expect(
await client.callTool("procedure_pseudo_code", { procedure: "main" }),
).toMatchObject({
ok: false,
error: {
providerState: "unreachable",
stage: "decompilation",
operation: "procedure_pseudo_code",
},
});
});
it("projects the exit code when the owned process exit is observed before disconnection", async () => {
const { client, launcher } = await start(true);
const pending = client.callTool("hang");
const request = await launcher.fixture.waitForRequest("hang");
launcher.observed.exit(7);
const result = await pending;
expect(result).toMatchObject({
ok: false,
error: {
_tag: "HopperProcessError",
exitCode: 7,
providerState: "exited",
requestId: request.id,
},
});
if (!result.ok)
expect(projectAnalysisError(result.error)).toMatchObject({
message: expect.stringContaining(
"Hopper exited during hang (exit code 7)",
),
details: {
provider_state: "exited",
exit_code: 7,
stage: "analysis",
operation: "hang",
request_id: request.id,
},
});
});
it("retains an observed exit when an earlier successful reply resumes afterward", async () => {
const { client, launcher } = await start(true);
const first = client.callTool("echo", { gate: "before-exit" });
const request = await launcher.fixture.waitForRequest(
"echo",
"before-exit",
);
const controller = new AbortController();
const second = client.callTool(
"hang",
{},
{
signal: controller.signal,
progress: {
report: (update) => {
if (update.message === "hang started on Hopper's serial bridge") {
controller.abort();
launcher.observed.exit(7);
}
return Promise.resolve();
},
},
},
);
await vi.waitFor(() =>
expect(client.requestActivity()?.queuedRequests).toBe(1),
);
await launcher.fixture.release(request);
expect((await first).ok).toBe(true);
expect(await second).toMatchObject({
ok: false,
error: { _tag: "HopperCancelledError" },
});
expect(client.operationHealth()).toMatchObject({
state: "exited",
exitCode: 7,
});
await client.close();
expect((await client.start()).ok).toBe(true);
expect(client.operationHealth().state).toBe("idle");
});
});

View File

@ -1,9 +1,13 @@
import { once } from "node:events";
import { describe, expect, it, onTestFinished, vi } from "vitest";
import { HopperProcessError } from "../../../../src/domain/hopperErrors.js";
import { HOPPER_OPERATIONS } from "../../../../src/hopper/HopperProvider.js";
import { HopperClient } from "../../../../src/hopper/HopperClient.js";
import type { HopperDiagnostic } from "../../../../src/hopper/HopperDiagnostics.js";
import { providerCleanupFailure } from "../../../../src/hopper/HopperDiagnostics.js";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import {
HopperFixtureLauncher as FixtureLauncher,
@ -192,3 +196,125 @@ describe("HopperClient response lifecycle", () => {
});
});
});
describe("HopperClient operational diagnostics", () => {
it("records process exit code, stage, and restart guidance on unexpected exit", async () => {
const launcher = new FixtureLauncher();
const client = await startClient(launcher);
const child = launcher.processes.at(-1);
if (child === undefined) throw new Error("Missing fixture process");
const exited = once(child, "exit");
const result = await client.callTool("exit");
await exited;
expect(result.ok).toBe(false);
if (!result.ok) {
if (!(result.error instanceof HopperProcessError))
throw new Error("Expected a provider process error");
expect(["unreachable", "exited"]).toContain(result.error.providerState);
expect(result.error.exitCode).toBe(
result.error.providerState === "exited" ? 7 : null,
);
expect(result.error).toMatchObject({
_tag: "HopperProcessError",
operation: "exit",
});
const projected = projectAnalysisError(result.error);
expect(projected).toMatchObject({
code: "provider_unavailable",
retryable: true,
details: {
stage: "analysis",
provider_state: result.error.providerState,
retry_action: "restart_provider",
operation: "exit",
exit_code: result.error.exitCode,
},
});
expect(projected.remediation.action).toContain("close_binary");
}
expect(client.operationHealth()).toMatchObject({
state: "exited",
stage: "analysis",
retryAction: "restart_provider",
exitCode: 7,
requests: [
expect.objectContaining({
operation: "exit",
stage: "analysis",
}),
],
});
});
it("retains request correlation and health across concurrent requests when the bridge process terminates", async () => {
const launcher = new FixtureLauncher();
const client = await startClient(launcher);
const hanging = client.callTool("hang");
const inFlight = await launcher.waitForRequest("hang");
const queued = client.callTool("echo", { label: "queued" });
const child = launcher.processes.at(-1);
if (child === undefined) throw new Error("Missing fixture process");
const exited = once(child, "exit");
child.kill("SIGKILL");
await exited;
const [hangingResult, queuedResult] = await Promise.all([hanging, queued]);
expect(hangingResult.ok).toBe(false);
expect(queuedResult.ok).toBe(false);
if (!hangingResult.ok && !queuedResult.ok) {
if (
!(hangingResult.error instanceof HopperProcessError) ||
!(queuedResult.error instanceof HopperProcessError)
)
throw new Error("Expected correlated provider process errors");
// Socket loss may be observed before the separate child-exit event.
// The immutable reply must preserve what was known when it settled.
expect(["unreachable", "exited"]).toContain(
hangingResult.error.providerState,
);
expect(["unreachable", "exited"]).toContain(
queuedResult.error.providerState,
);
expect(hangingResult.error).toMatchObject({
_tag: "HopperProcessError",
operation: "hang",
requestId: inFlight.id,
});
expect(queuedResult.error).toMatchObject({
_tag: "HopperProcessError",
operation: "echo",
requestId: inFlight.id + 1,
});
const hangingProjected = projectAnalysisError(hangingResult.error);
const queuedProjected = projectAnalysisError(queuedResult.error);
expect(hangingProjected.details).toMatchObject({
stage: "analysis",
provider_state: hangingResult.error.providerState,
retry_action: "restart_provider",
operation: "hang",
request_id: inFlight.id,
});
expect(queuedProjected.details).toMatchObject({
stage: "analysis",
provider_state: queuedResult.error.providerState,
retry_action: "restart_provider",
operation: "echo",
request_id: inFlight.id + 1,
});
expect(hangingProjected.remediation.action).toContain("close_binary");
expect(queuedProjected.remediation.action).toContain("close_binary");
}
expect(client.operationHealth()).toMatchObject({
state: "exited",
retryAction: "restart_provider",
});
expect(client.operationHealth().requests).toEqual([
expect.objectContaining({ requestId: inFlight.id, operation: "hang" }),
expect.objectContaining({
requestId: inFlight.id + 1,
operation: "echo",
}),
]);
});
});

View File

@ -122,6 +122,40 @@ afterEach(async () => {
await Promise.all(clients.splice(0).map((client) => client.close()));
});
describe("HopperClient startup deadline", () => {
it("applies one deadline to launcher, socket, and health startup phases", async () => {
const launcher = new LateSilentLauncher();
const client = new HopperClient({ launcher, startupTimeoutMs: 500 });
clients.push(client);
const result = await client.start();
expect(result).toMatchObject({
ok: false,
error: { _tag: "HopperTimeoutError", timeoutMs: 500 },
});
expect(client.operationHealth()).toMatchObject({
state: "not_started",
stage: "launch",
retryAction: "retry",
exitCode: null,
});
if (!result.ok)
expect(projectAnalysisError(result.error).details).toMatchObject({
stage: "startup",
});
expect(launcher.directories).toHaveLength(1);
await expect(access(launcher.directories[0] ?? "")).rejects.toMatchObject({
code: "ENOENT",
});
const process = launcher.processes[0];
expect(
process !== undefined &&
(process.exitCode !== null || process.signalCode !== null),
).toBe(true);
});
});
describe("HopperClient startup failures", () => {
it("cancels bridge startup without waiting for the startup timeout", async () => {
const launcher = new CancelThenFixtureLauncher();
@ -183,28 +217,6 @@ describe("HopperClient startup failures", () => {
});
});
it("applies one deadline to launcher, socket, and health startup phases", async () => {
const launcher = new LateSilentLauncher();
const client = new HopperClient({ launcher, startupTimeoutMs: 500 });
clients.push(client);
const result = await client.start();
expect(result).toMatchObject({
ok: false,
error: { _tag: "HopperTimeoutError", timeoutMs: 500 },
});
expect(launcher.directories).toHaveLength(1);
await expect(access(launcher.directories[0] ?? "")).rejects.toMatchObject({
code: "ENOENT",
});
const process = launcher.processes[0];
expect(
process !== undefined &&
(process.exitCode !== null || process.signalCode !== null),
).toBe(true);
});
it.each([70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80])(
"reports Linux adapter exit %i during bridge startup",
async (exitCode) => {

View File

@ -158,6 +158,76 @@ describe("detached provider and target metadata", () => {
await session.close();
});
it("exposes provider operational health in session status", async () => {
const [first] = await createBinarySessionTargets();
let healthState: "idle" | "busy" | "exited" = "busy";
const provider: AnalysisProvider = {
identity: () => ({ id: "fixture", name: "Fixture", version: "1" }),
capabilities: () => [],
createClient: () => ({
execute: () => Promise.resolve(ok("ok")),
operationHealthSnapshot: () => ({
state: healthState,
stage: "analysis",
retryAction: healthState === "exited" ? "restart_provider" : "wait",
exitCode: healthState === "exited" ? 7 : null,
requests: [
{
requestId: 1,
operation: "find_code_for_string",
stage: "analysis",
},
],
}),
close: () => Promise.resolve(),
}),
};
const session = createTestBinarySession(provider);
expect(session.status()).toMatchObject({
open: false,
provider_operation_health: null,
});
expect((await session.open(first)).ok).toBe(true);
expect(session.status()).toMatchObject({
open: true,
provider_operation_health: {
state: "busy",
stage: "analysis",
retry_action: "wait",
exit_code: null,
requests: [
{
request_id: 1,
operation: "find_code_for_string",
stage: "analysis",
},
],
},
});
healthState = "exited";
expect(session.status()).toMatchObject({
open: true,
provider_operation_health: {
state: "exited",
stage: "analysis",
retry_action: "restart_provider",
exit_code: 7,
requests: [
{
request_id: 1,
operation: "find_code_for_string",
stage: "analysis",
},
],
},
});
await session.close();
expect(session.status()).toMatchObject({
open: false,
provider_operation_health: null,
});
});
});
describe("opening previewed targets", () => {