From d74e72406ad6b962160cc93c2ce44134a93b987e Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 06:29:28 +0800 Subject: [PATCH] fix(jeb): attribute transport timeouts to requested operations Project canonical timeout and cancellation facts at the provider boundary while preserving causes, partial observations, live cleanup dependencies and provider-authored deadline guidance. Validation: four real SDK/HTTP catalog and tool timeout controls fail before the fix. After rebasing onto the Ghidra timeout guidance change, 189 tests across ten files pass, including public CLI/MCP workflows and affected Ghidra tests. Typecheck/lint, docs, format and discovery pass. No native JEB engine or target verification. Refs #4597. --- src/jeb/JebProvider.test.ts | 27 +++++++++ src/jeb/JebProvider.ts | 24 +++++++- .../boundary/providers/jebMcpTimeout.test.ts | 59 +++++++++++++++++++ 3 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 tests/boundary/providers/jebMcpTimeout.test.ts diff --git a/src/jeb/JebProvider.test.ts b/src/jeb/JebProvider.test.ts index 2f7dfe9d38..553e5773bd 100644 --- a/src/jeb/JebProvider.test.ts +++ b/src/jeb/JebProvider.test.ts @@ -11,6 +11,7 @@ import { AnalysisInputError, AnalysisProtocolError, AnalysisCancelledError, + AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; import { jebResultSchemas } from "../domain/jeb/jebAnalysis.js"; @@ -490,3 +491,29 @@ it("keeps concurrent client inspections bound to their own parsed metadata", asy await provider.close(); } }); + +it("preserves provider-authored timeout guidance when projecting the requested operation", async () => { + const reason = new Error("selected deadline elapsed"); + const timeout = new AnalysisTimeoutError("list_units", 123, { + cause: reason, + userMessage: "The selected MCP request budget elapsed.", + remediationAction: "Increase the selected MCP request budget.", + }); + const provider = providerWith(scriptedConnection({ list_units: timeout })); + try { + const result = await provider.execute({ + operation: "list_jeb_units", + input: { count: 2, index: 0 }, + }); + if (result.ok) throw new Error("Timed-out request passed"); + expect(result.error).toMatchObject({ + operation: "list_jeb_units", + timeoutMs: 123, + userMessage: timeout.userMessage, + remediationAction: timeout.remediationAction, + }); + expect(result.error.cause).toBe(reason); + } finally { + await provider.close(); + } +}); diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index 84da1ee697..4d64ba7c4a 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -20,6 +20,7 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisProtocolError, + AnalysisTimeoutError, AnalysisUnsupportedTargetError, } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; @@ -425,16 +426,33 @@ export class JebProvider implements JebAnalysisPort { ) .bindCleanupDependencies([cause.cleanupFailure]) .bindCleanupResourceOwner(cause.cleanupFailure); - if (cause instanceof AnalysisCancelledError) - return new AnalysisCancelledError(operation, { + if ( + cause instanceof AnalysisCancelledError || + cause instanceof AnalysisTimeoutError + ) { + const options = { cause: cause.cause, diagnostics: cause.diagnostics, capturedOutput: cause.capturedOutput, cleanup: cause.cleanup, partialObservation: cause.partialObservation, - }) + }; + const failure = + cause instanceof AnalysisTimeoutError + ? new AnalysisTimeoutError(operation, cause.timeoutMs, { + ...options, + ...(cause.userMessage === undefined + ? {} + : { userMessage: cause.userMessage }), + ...(cause.remediationAction === undefined + ? {} + : { remediationAction: cause.remediationAction }), + }) + : new AnalysisCancelledError(operation, options); + return failure .bindCleanupDependencies([cause]) .bindCleanupResourceOwner(cause); + } if (cause instanceof AnalysisError) return cause; if (cause instanceof z.ZodError) return new AnalysisProtocolError( diff --git a/tests/boundary/providers/jebMcpTimeout.test.ts b/tests/boundary/providers/jebMcpTimeout.test.ts new file mode 100644 index 0000000000..41e783759e --- /dev/null +++ b/tests/boundary/providers/jebMcpTimeout.test.ts @@ -0,0 +1,59 @@ +import { expect, it } from "vitest"; +import { JebProvider } from "../../../src/jeb/JebProvider.js"; +import { createStreamableHttpJebMcpConnection } from "../../../src/jeb/JebMcpConnection.js"; +import { AnalysisTimeoutError } from "../../../src/domain/analysisErrorCore.js"; +import { + startJebMcpFixture, + jebPrimaryResponses, +} from "../../fixtures/jebMcp.js"; + +it.each([ + { operation: "inspect_jeb_client", stage: "catalog" }, + { operation: "inspect_jeb_client", stage: "tool" }, + { operation: "list_jeb_units", stage: "catalog" }, + { operation: "list_jeb_units", stage: "tool" }, +] as const)( + "attributes a real SDK $stage timeout to $operation", + async ({ operation, stage }) => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const fixture = await startJebMcpFixture( + async (tool) => { + await gate; + return jebPrimaryResponses[tool]!; + }, + stage === "catalog" + ? async () => { + await gate; + return { tools: [] }; + } + : undefined, + ); + const provider = new JebProvider({ + environment: { REA_JEB_MCP_URL: fixture.endpoint }, + connectionFactory: (endpoint) => + createStreamableHttpJebMcpConnection(endpoint, { + discoveryTimeoutMs: 100, + requestTimeoutMs: 100, + }), + }); + try { + const result = await provider.execute( + operation === "inspect_jeb_client" + ? { operation, input: {} } + : { operation, input: { count: 2, index: 0 } }, + ); + if (result.ok) throw new Error("Timed-out MCP request passed"); + expect(result.error).toBeInstanceOf(AnalysisTimeoutError); + expect(result.error).toMatchObject({ operation, timeoutMs: 100 }); + expect(result.error.cause).toBeDefined(); + expect(result.error.cleanupIncomplete).toBe(false); + } finally { + release(); + await provider.close(); + await fixture.close(); + } + }, +);