From c0cfccb45fe43e7eeff18e56c64caa5b121988f9 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:10:15 +0800 Subject: [PATCH] fix(ida): preserve SDK request timeout identity and captured output --- docs/ida-provider.md | 2 +- src/ida/IdaMcpConnection.test.ts | 81 ++++++++++++++++++++++++++++++++ src/ida/IdaMcpConnection.ts | 15 ++++-- src/ida/IdaSessionClient.test.ts | 20 ++++++++ 4 files changed, 114 insertions(+), 4 deletions(-) diff --git a/docs/ida-provider.md b/docs/ida-provider.md index 4e44605168..846cfd743e 100644 --- a/docs/ida-provider.md +++ b/docs/ida-provider.md @@ -131,7 +131,7 @@ Use a package version that actually includes this adapter; repository main can b {"name":"close_binary","arguments":{}} ``` -Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Only expiry while the worker is still busy produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works. +Opening an MCP target starts its selected provider session; headless opening waits for auto-analysis. Set the agent's MCP request deadline long enough for the target. The registration's optional `timeoutMs` defaults to 300000 for upstream requests. An upstream SDK request that reaches this deadline returns `provider_timeout` with its operation and configured budget. A freshly opened headless worker can briefly report `status: "busy"` from `server_health`; REA re-reads the health every 100 ms until the worker answers, the caller cancels, or `timeoutMs` has elapsed since the first read. Expiry of this polling deadline while the worker is still busy also produces a retryable `provider_timeout`; no further read is started once the deadline has passed. That error names the busy tool of the last observation, or `unknown` when the worker reported none or a value that is not tool-name-shaped. Cancellation during the wait produces `cancelled`. Any other unexpected health payload, including a busy payload with an invalid shape, remains `unreadable_output`. Cancellation drains the current upstream request before cleanup; it does not force-stop GUI analysis. An open request that times out without a completed response retains the private workspace because worker inventory alone cannot prove the request has stopped. `binary_session` reports actual tool availability. Repeated doctor/setup calls are unnecessary when the registration already works. For guided registration, set `REA_IDA_MCP_CONFIG` before `rea setup --client `. Setup includes that file reference in its reviewed plan and preserves it after approval; it does not copy upstream credentials into the agent configuration. `rea doctor --provider ida --json` validates the registration without starting IDA and explicitly leaves runtime compatibility and target binding for analysis. diff --git a/src/ida/IdaMcpConnection.test.ts b/src/ida/IdaMcpConnection.test.ts index 139c377e24..9c13ac0f66 100644 --- a/src/ida/IdaMcpConnection.test.ts +++ b/src/ida/IdaMcpConnection.test.ts @@ -6,6 +6,7 @@ import { decodeIdaToolResult, } from "./IdaMcpConnection.js"; import { createIdaDiagnosticRedactor } from "./IdaDiagnosticRedactor.js"; +import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; describe("upstream IDA tool result decoding", () => { it("leaves successful legacy, structured, and native evidence unchanged", () => { @@ -143,6 +144,86 @@ const failingHttpConnection = ( ); }; +it("preserves the SDK request timeout as a typed IDA timeout", async () => { + const timeoutMs = 40; + const pendingCall = vi.fn(); + vi.stubGlobal( + "fetch", + vi.fn(async (_input: Parameters[0], init?: RequestInit) => { + const message = JSON.parse(String(init?.body ?? "")) as { + readonly id?: string | number; + readonly method: string; + readonly params?: Record; + }; + const json = (value: unknown) => + new Response(JSON.stringify(value), { + headers: { "Content-Type": "application/json" }, + }); + if (message.method === "initialize") + return json({ + jsonrpc: "2.0", + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion, + capabilities: { tools: {} }, + serverInfo: { name: "ida-timeout-fixture", version: "1" }, + }, + }); + if ( + message.method === "notifications/initialized" || + message.method === "notifications/cancelled" + ) + return new Response(null, { status: 202 }); + if (message.method === "tools/list") + return json({ + jsonrpc: "2.0", + id: message.id, + result: { tools: [] }, + }); + if (message.method !== "tools/call") + throw new Error(`Unexpected fixture request: ${message.method}`); + pendingCall(); + return new Promise((_resolve, reject) => { + const signal = init?.signal; + if (signal?.aborted === true) { + reject(signal.reason); + return; + } + signal?.addEventListener( + "abort", + () => + reject(new DOMException("The request was aborted", "AbortError")), + { once: true }, + ); + }); + }), + ); + const connection = createIdaMcpConnection( + { + url: "http://127.0.0.1:12345/mcp", + headers: { Authorization: "Bearer fixture-secret" }, + mode: "attached", + timeoutMs, + }, + {}, + ); + try { + await expect(connection.connect()).resolves.toEqual([]); + const failure = await connection + .call("decompile", {}) + .catch((cause: unknown) => cause); + expect(pendingCall).toHaveBeenCalledOnce(); + expect(failure).toBeInstanceOf(AnalysisTimeoutError); + expect(failure).toMatchObject({ operation: "decompile", timeoutMs }); + if (!(failure instanceof AnalysisTimeoutError)) + throw new Error("Expected a typed IDA request timeout"); + expect(failure.cause).toBeInstanceOf(Error); + expect(inspectRetainedError(failure)).not.toContain("fixture-secret"); + } finally { + await connection.close(); + } +}); + const inspectRetainedError = (error: unknown): string => inspect(error, { depth: null, maxStringLength: null, breakLength: Infinity }); diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index 210b80c678..a898e49771 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -9,6 +9,7 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js"; import { Client, ProtocolError, + SdkError, SdkErrorCode, SdkHttpError, StreamableHTTPClientTransport, @@ -22,6 +23,7 @@ import type { IdaConfiguration } from "./IdaConfiguration.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { err, ok, type Result } from "../domain/result.js"; import { + AnalysisTimeoutError, AnalysisProtocolError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; @@ -389,6 +391,11 @@ export const createIdaMcpConnection = ( stdout_bytes: 0, stderr_bytes: stderrBytes, }; + if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout) + throw new AnalysisTimeoutError(operation, config.timeoutMs, { + cause: failure, + capturedOutput, + }); if (bufferLimit !== null) throw new AnalysisResourceConstraintError( operation, @@ -443,12 +450,12 @@ export const createIdaMcpConnection = ( async connect() { await client .connect(transport, { timeout: config.timeoutMs }) - .catch(transportFailure); + .catch((cause: unknown) => transportFailure(cause, "ida_mcp_connect")); const catalog = await client .listTools(undefined, { timeout: config.timeoutMs, }) - .catch(transportFailure); + .catch((cause: unknown) => transportFailure(cause, "ida_mcp_catalog")); return catalog.tools.map(({ name }) => name); }, async call(name, args) { @@ -463,7 +470,9 @@ export const createIdaMcpConnection = ( async readResource(uri) { const response = await client .readResource({ uri }, { timeout: config.timeoutMs }) - .catch(transportFailure); + .catch((cause: unknown) => + transportFailure(cause, "ida_mcp_read_resource"), + ); const resource = z .object({ contents: z diff --git a/src/ida/IdaSessionClient.test.ts b/src/ida/IdaSessionClient.test.ts index c6bffd2e09..3e2ec1b6e2 100644 --- a/src/ida/IdaSessionClient.test.ts +++ b/src/ida/IdaSessionClient.test.ts @@ -7,6 +7,7 @@ import { RecordingIdaMcp, } from "../../tests/fixtures/idaMcp.js"; import { IdaSessionClient } from "./IdaSessionClient.js"; +import { AnalysisTimeoutError } from "../domain/analysisErrorCore.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -473,6 +474,25 @@ describe("attached IDA function observations", () => { }); }); +it("preserves a typed MCP request timeout through the public session result", async () => { + const { producer, client } = await fixture(); + expect((await client.execute("health", {})).ok).toBe(true); + producer.overrideCall = (name) => { + if (name === "decompile_function") + throw new AnalysisTimeoutError(name, 1_000); + return undefined; + }; + + const result = await client.execute("procedure_pseudo_code", { + procedure: "main", + }); + + if (result.ok) throw new Error("Expected the request timeout to surface"); + expect(result.error._tag).toBe("AnalysisTimeoutError"); + expect(projectAnalysisError(result.error).code).toBe("provider_timeout"); + await client.close(); +}); + const busyHealth = (busyTool?: JsonValue): JsonValue => ({ status: "busy", uptime_sec: 1.041,