diff --git a/docs/providers/jeb-analysis.md b/docs/providers/jeb-analysis.md index 11ca35c42e..f72512075c 100644 --- a/docs/providers/jeb-analysis.md +++ b/docs/providers/jeb-analysis.md @@ -65,7 +65,7 @@ All four preserve target-file bytes. `open_jeb_project` selects or creates an en - The engine caps `list_units` pages at 100; a full page is reported as `coverage: "partial"` because more units may exist. An empty remainder is `complete` only when fewer units than requested were returned. - When `decompile_jeb_item` omits `unit_path`, the engine implicitly selects the project's first code unit; REA reports `unit_path: null` and a limitation instead of guessing the effective unit. - `item_address` is a JEB code-item reference, such as a value returned by the engine's `list_code_items`. Preserve that representation and select its code unit explicitly. For the audited CE SQLite unit, the exported method reference was `sqlite3_libversion_number`; using its raw ELF symbol address did not produce a successful decompilation. File offsets, ELF virtual addresses and engine item references have different semantics. REA currently exposes unit listing, while code-item discovery remains an upstream JEB operation. -- Valid engine refusals (`success: false` envelopes) return `execution_failure` with the engine's message when available. Diagnostics preserve the original envelope, including any recovery advice, and the inputs of a refused primary request. If client metadata fails after a primary reply, that reply and the caller's inputs remain in partial Evidence. REA does not infer an unsupported target from a refusal, invoke a recovery tool, or retry the request. +- Valid engine refusals (`success: false` envelopes) return `execution_failure` with the engine's message when available. Diagnostics preserve the original envelope, including any recovery advice, and the inputs of a refused primary request. If client metadata fails after a primary reply, that reply and the caller's inputs remain in partial Evidence. REA does not infer an unsupported target from a refusal, invoke a recovery tool, or retry the request. A refusal whose message carries the JVM's own `java.lang.OutOfMemoryError` report is a resource constraint instead (`capacity` for native-thread exhaustion), with recovery naming the JEB client's Java heap; the envelope stays in diagnostics. - Successfully parsed client information is reused within the current MCP session, including when the engine omits its version. Explicit client inspection and availability probes refresh it; reconnecting invalidates the cached information. - An unreachable endpoint is a provider-availability failure with recovery guidance; REA does not spawn the engine or automatically replay failed operations. - After a transport or RPC failure (including a request or catalog timeout), the next explicit call attempts a new MCP connection after cleanup succeeds. Engine version and GUI mode are refreshed for that connection. Caller cancellation and decoded engine refusal envelopes preserve the existing connection. diff --git a/scripts/lint/rules/provider-failure-classifier-baseline.json b/scripts/lint/rules/provider-failure-classifier-baseline.json index f9351c9ed2..7abef2d2fa 100644 --- a/scripts/lint/rules/provider-failure-classifier-baseline.json +++ b/scripts/lint/rules/provider-failure-classifier-baseline.json @@ -40,21 +40,12 @@ "src/frida/FridaInstrumentationManager.ts": 1, "src/ghidra/GhidraClient.ts": 1, "src/ghidra/GhidraFileArtifacts.ts": 1, - "src/ghidra/GhidraJavaProbe.ts": 1, - "src/ghidra/GhidraLoadImageAttest.ts": 1, - "src/ghidra/GhidraNativeAotSnapshotOwner.ts": 1, - "src/ghidra/GhidraPspExtension.ts": 1, "src/go/GoBinaryProvider.ts": 1, - "src/hopper/HopperFileOffset.ts": 1, "src/il2cpp/Il2cppMetadataProvider.ts": 1, "src/inspector/V8InspectorProvider.ts": 1, "src/javascript/analysis/JavaScriptAnalysisWorker.ts": 3, "src/javascript/sourceMaps/SourceMapCommand.ts": 1, - "src/jeb/JebProvider.ts": 1, "src/kuna/KunaCommand.ts": 1, - "src/kuna/KunaProjectExport.ts": 1, - "src/kuna/KunaSessionClient.ts": 1, - "src/kuna/KunaTargetCommands.ts": 1, "src/native/AppleDispatchMetadata.ts": 1, "src/native/NativeCallTarget.ts": 1, "src/native/NativeMacOSProvider.ts": 4, diff --git a/src/ghidra/GhidraClient.ts b/src/ghidra/GhidraClient.ts index 998fdfa3a6..cb75c3306d 100644 --- a/src/ghidra/GhidraClient.ts +++ b/src/ghidra/GhidraClient.ts @@ -1546,13 +1546,9 @@ export class GhidraClient { cause?: unknown, cancellation?: { readonly reason: unknown }, ): GhidraSessionError { - if ( - cancellation !== undefined && - !( - deadline.signal.aborted && - cancellation.reason === deadline.signal.reason - ) - ) + // A producer that cancelled while startup's deadline still ran reports + // its own cancellation; once the deadline aborted, the deadline reports. + if (cancellation !== undefined && !deadline.signal.aborted) return this.#failure("cancelled", "Ghidra startup was cancelled", cause, { cancellation, }); diff --git a/src/ghidra/GhidraJavaProbe.ts b/src/ghidra/GhidraJavaProbe.ts index 82ae36f1d0..fa14e09259 100644 --- a/src/ghidra/GhidraJavaProbe.ts +++ b/src/ghidra/GhidraJavaProbe.ts @@ -337,7 +337,7 @@ const systemCode = (cause: unknown): string | undefined => const probeFailureError = ( failure: GhidraJavaProbeFailure, cause: unknown, -): AnalysisError => { +): ProviderAdapterError => { const { output, ...facts } = failure; return new ProviderAdapterError("ghidra", "ghidra:java", { cause, diff --git a/src/ghidra/GhidraLoadImageAttest.ts b/src/ghidra/GhidraLoadImageAttest.ts index 77c104c62a..244169d8a3 100644 --- a/src/ghidra/GhidraLoadImageAttest.ts +++ b/src/ghidra/GhidraLoadImageAttest.ts @@ -67,7 +67,7 @@ const comparisonFailure = ( input: NativeLoadImageAttestationInput, reason: string, cause?: unknown, -): AnalysisError => +): ProviderAdapterError => new ProviderAdapterError("ghidra", input.operation, { diagnostics: { reason }, ...(cause === undefined ? {} : { cause }), diff --git a/src/ghidra/GhidraNativeAotSnapshotOwner.ts b/src/ghidra/GhidraNativeAotSnapshotOwner.ts index 74f941ee08..8556af764c 100644 --- a/src/ghidra/GhidraNativeAotSnapshotOwner.ts +++ b/src/ghidra/GhidraNativeAotSnapshotOwner.ts @@ -6,6 +6,7 @@ import type { import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { AnalysisArtifactChangedError, + type AnalysisCancelledError, cancelledBy, carriedReason, OwnerCancellation, @@ -360,7 +361,7 @@ export class GhidraNativeAotSnapshotOwner { reason: unknown, observations: JsonValue, partialObservation?: AnalysisPartialObservation, - ): AnalysisError { + ): AnalysisCancelledError { return cancelledBy(operation, carriedReason(reason), { diagnostics: { measured_observation: observations }, partialObservation, diff --git a/src/ghidra/GhidraPspExtension.ts b/src/ghidra/GhidraPspExtension.ts index ca3b471c31..47cde6fe0a 100644 --- a/src/ghidra/GhidraPspExtension.ts +++ b/src/ghidra/GhidraPspExtension.ts @@ -4,12 +4,12 @@ import { lstat, opendir, realpath } from "node:fs/promises"; import { isAbsolute, join } from "node:path"; import { z } from "zod"; import { sameRegularFileState } from "../filesystem/RegularFile.js"; -import { - AnalysisCapabilityUnavailableError, - cancelledBy, - carriedReason, -} from "../domain/analysisErrorCore.js"; +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { + classifyProviderFailure, + type ProviderFailureDialect, +} from "../providers/classifyProviderFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import type { GhidraInstallationInspection } from "./GhidraInstallation.js"; import { parseGhidraJavaProperties } from "./GhidraJavaProperties.js"; @@ -20,9 +20,7 @@ import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; import { OwnedDirectoryHandle } from "../filesystem/OwnedDirectoryHandle.js"; import { assertArtifactReadActive, - artifactFileCancellationCause, artifactFilePrimaryFacts, - bindArtifactFileCleanup, withArtifactRegularFile, } from "../artifacts/ArtifactFileOperation.js"; @@ -60,7 +58,7 @@ export const inspectGhidraPspExtension = async ( fingerprint(root, ghidraVersion, resources, signal), ); } catch (cause: unknown) { - throw ghidraPspInspectionError(cause); + throw classifyProviderFailure(cause, GHIDRA_PSP_INSPECTION_DIALECT); } }; @@ -227,46 +225,40 @@ export const resolveGhidraPspExtension = async ( ), ); } catch (cause: unknown) { - return err(ghidraPspInspectionError(cause)); + return err(classifyProviderFailure(cause, GHIDRA_PSP_INSPECTION_DIALECT)); } }; -/** Preserve the producer's primary failure and its live cleanup dependencies. */ -const ghidraPspInspectionError = (cause: unknown): AnalysisError => { - if (cause instanceof AnalysisError) return cause; - const cleanup = - cause instanceof ArtifactReaderFailure ? cause.cleanup : undefined; - const { cause: primary } = artifactFilePrimaryFacts(cause); - const admission = - primary instanceof GhidraAdmissionError ? primary : undefined; - const identity = - admission?.code ?? - (primary instanceof Error ? primary.message : String(primary)); - const reason = `PSP_EXTENSION_UNAVAILABLE: ${identity} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`; - const diagnostics = - admission === undefined - ? undefined - : { code: admission.code, ...admission.detail }; - const error = - cause instanceof ArtifactReaderFailure && cause.reason === "cancelled" - ? cancelledBy( - "resolve_analysis_profile", - carriedReason(artifactFileCancellationCause(cause)), - { cleanup }, - ) - : new AnalysisCapabilityUnavailableError( - "ghidra", - "resolve_analysis_profile", - reason, - { - cause, - userMessage: reason, - cleanup, - ...(diagnostics === undefined ? {} : { diagnostics }), - }, - ); - bindArtifactFileCleanup(cause, error); - return error; +/** + * How a failed extension inspection is reported. The shared classifier keeps + * typed failures, a cancelled read and the read's live cleanup; anything else + * means no compatible caller-installed extension was established. + */ +const GHIDRA_PSP_INSPECTION_DIALECT: ProviderFailureDialect = { + providerId: "ghidra", + operation: "resolve_analysis_profile", + unclassified: (cause, details) => { + const { cause: primary } = artifactFilePrimaryFacts(cause); + const admission = + primary instanceof GhidraAdmissionError ? primary : undefined; + const identity = + admission?.code ?? + (primary instanceof Error ? primary.message : String(primary)); + const reason = `PSP_EXTENSION_UNAVAILABLE: ${identity} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`; + return new AnalysisCapabilityUnavailableError( + "ghidra", + "resolve_analysis_profile", + reason, + { + ...details, + cause, + userMessage: reason, + ...(admission === undefined + ? {} + : { diagnostics: { code: admission.code, ...admission.detail } }), + }, + ); + }, }; /** Close before descending, retaining the exact directory owner on uncertainty. */ @@ -315,11 +307,12 @@ const readChildren = async ( { cause: signal.reason }, ) : attempt.cause; - const error = ghidraPspInspectionError( + const error = classifyProviderFailure( ArtifactReaderFailure.withCleanup( primary, ArtifactReaderFailure.cleanupObservation(attempt.cause, path), ), + GHIDRA_PSP_INSPECTION_DIALECT, ); lease.bindCleanupError(error); throw error; diff --git a/src/hopper/HopperFileOffset.ts b/src/hopper/HopperFileOffset.ts index e915ac7736..f7a4de82c3 100644 --- a/src/hopper/HopperFileOffset.ts +++ b/src/hopper/HopperFileOffset.ts @@ -36,6 +36,10 @@ import { validateFatSliceIdentity, } from "../domain/apple/machoContainer.js"; import { err, ok, type Result } from "../domain/result.js"; +import { + classifyProviderFailure, + type ProviderFailureDialect, +} from "../providers/classifyProviderFailure.js"; import type { HopperReply } from "./HopperReplyContracts.js"; const OPERATION = "address_to_file_offset"; @@ -127,7 +131,9 @@ export async function mapHopperFileOffset( ), ); } - return err(sourceAccessFailure(target.path, cause, partial)); + return err( + classifyProviderFailure(cause, sourceAccessDialect(target.path, partial)), + ); } } @@ -179,29 +185,37 @@ const artifactChanged = ( partialObservation, }); -/** Classify a failed read. A rejected close never reaches this helper. */ -const sourceAccessFailure = ( +/** + * How a failed original-source read is reported. A rejected close never + * reaches it; every report keeps the coordinate observation made so far. + */ +const sourceAccessDialect = ( path: string, - cause: unknown, partial: (reason: string) => FileOffsetPartialObservation, -): AnalysisError => { - if (cause instanceof RegularFileOperationCancelled) - return cancelled(partial, carriedReason(cause.reason)); - const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`; - const code = - typeof cause === "object" && cause !== null && "code" in cause - ? cause.code - : undefined; - if (code === "EACCES" || code === "EPERM") - return new AnalysisAccessDeniedError(OPERATION, path, code, { - cause, - partialObservation: partial(reason), - }); - return new BinaryTargetError(path, reason, { - cause, - partialObservation: partial(reason), - }); -}; +): ProviderFailureDialect => ({ + providerId: "hopper", + operation: OPERATION, + recognise: (cause) => + cause instanceof RegularFileOperationCancelled + ? cancelled(partial, carriedReason(cause.reason)) + : undefined, + unclassified: (cause) => { + const reason = `Cannot verify original source bytes: ${cause instanceof Error ? cause.message : String(cause)}`; + const code = + typeof cause === "object" && cause !== null && "code" in cause + ? cause.code + : undefined; + return code === "EACCES" || code === "EPERM" + ? new AnalysisAccessDeniedError(OPERATION, path, code, { + cause, + partialObservation: partial(reason), + }) + : new BinaryTargetError(path, reason, { + cause, + partialObservation: partial(reason), + }); + }, +}); const descriptorCleanup = ( failure: RegularFileCleanupFailure, @@ -239,7 +253,10 @@ const cleanupAfterSnapshot = ( if (failure.outcome.kind === "failed") return err( withCleanup( - sourceAccessFailure(target.path, failure.outcome.cause, partial), + classifyProviderFailure( + failure.outcome.cause, + sourceAccessDialect(target.path, partial), + ), marker, ), ); diff --git a/src/jeb/JebMcpConnection.ts b/src/jeb/JebMcpConnection.ts index 6b392ef691..245f34f140 100644 --- a/src/jeb/JebMcpConnection.ts +++ b/src/jeb/JebMcpConnection.ts @@ -1,4 +1,4 @@ -import type { Client } from "@modelcontextprotocol/client"; +import { SdkError, type Client } from "@modelcontextprotocol/client"; import { mcpToolResultSchema, mcpTextBlocks, @@ -178,20 +178,22 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( owner: JebMcpConnectionOwner, deadline: JebMcpDeadline, phase: "catalog" | "tool", - operation: string, cause: unknown, - signal?: AbortSignal, ): Promise => { - const failure = physicalFailure( - phase, - owner.responseFailure(deadline.failure(operation, cause)), - ); + const interruption = + cause instanceof SdkError ? deadline.interruption(cause) : undefined; + // A cancellation by the caller, or by closing, leaves the connection to + // its owner; any other failure invalidates it. if ( - failure instanceof AnalysisCancelledError && - (closed || (signal?.aborted === true && failure.cause === signal.reason)) + interruption instanceof AnalysisCancelledError && + (closed || deadline.cancelledBy === "caller") ) - return Promise.reject(failure); - return invalidate(owner, failure, operation); + return Promise.reject(interruption); + return invalidate( + owner, + interruption ?? physicalFailure(phase, owner.responseFailure(cause)), + deadline.operation, + ); }; const connect = async (signal?: AbortSignal): Promise => { @@ -217,10 +219,10 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( >(async (startupSignal) => { const selected = discoveryTimeout(); const deadline = new JebMcpDeadline( - selected.ms, - startupSignal, + selected, "discovery", - selected.source, + "jeb_mcp_catalog", + { owner: startupSignal }, ); try { await owner.client.connect(owner.transport, { @@ -233,10 +235,11 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( sessionGeneration += 1; return ok(tools); } catch (cause: unknown) { - const failure = physicalFailure( - "catalog", - owner.responseFailure(deadline.failure("jeb_mcp_catalog", cause)), - ); + const failure = + (cause instanceof SdkError + ? deadline.interruption(cause) + : undefined) ?? + physicalFailure("catalog", owner.responseFailure(cause)); owner.acquisitionFailure = { cause: failure }; owner.signal.abort(failure); try { @@ -275,30 +278,19 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( const owner = state; if (owner === undefined) throw new AnalysisProtocolError("JEB MCP client failed to connect"); - const discoverySignal = - signal === undefined - ? owner.signal.signal - : AbortSignal.any([signal, owner.signal.signal]); const selected = discoveryTimeout(); const deadline = new JebMcpDeadline( - selected.ms, - discoverySignal, + selected, "discovery", - selected.source, + "jeb_mcp_catalog", + { caller: signal, owner: owner.signal.signal }, ); try { const refreshed = await readToolNames(owner.client, deadline); owner.tools = refreshed; return refreshed; } catch (cause: unknown) { - return await requestFailure( - owner, - deadline, - "catalog", - "jeb_mcp_catalog", - cause, - signal, - ); + return await requestFailure(owner, deadline, "catalog", cause); } finally { deadline.dispose(); } @@ -308,17 +300,11 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( const owner = state; if (owner === undefined || owner.tools === undefined) throw new AnalysisProtocolError("JEB MCP client failed to connect"); - const requestSignal = - signal === undefined - ? owner.signal.signal - : AbortSignal.any([signal, owner.signal.signal]); const selected = requestTimeout(); - const deadline = new JebMcpDeadline( - selected.ms, - requestSignal, - "tools/call", - selected.source, - ); + const deadline = new JebMcpDeadline(selected, "tools/call", name, { + caller: signal, + owner: owner.signal.signal, + }); let result: unknown; try { result = await owner.client.callTool( @@ -326,14 +312,7 @@ export const createStreamableHttpJebMcpConnection: JebMcpConnectionFactory = ( { timeout: deadline.remainingMs(), signal: deadline.signal }, ); } catch (cause: unknown) { - return await requestFailure( - owner, - deadline, - "tool", - name, - cause, - signal, - ); + return await requestFailure(owner, deadline, "tool", cause); } finally { deadline.dispose(); } diff --git a/src/jeb/JebMcpDeadline.ts b/src/jeb/JebMcpDeadline.ts index 3ab2de7e26..f99d9d8d34 100644 --- a/src/jeb/JebMcpDeadline.ts +++ b/src/jeb/JebMcpDeadline.ts @@ -1,4 +1,5 @@ import { SdkError, SdkErrorCode } from "@modelcontextprotocol/client"; +import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisTimeoutError, cancelledBy, @@ -43,7 +44,16 @@ export const jebMcpTimeoutGuidance = ( }; }; -/** SDK 2.3.1 preserves an SdkError abort reason by identity. */ +/** Who interrupted a JEB MCP phase before its deadline elapsed. */ +export type JebMcpCancellation = "caller" | "owner"; + +/** + * One JEB MCP phase's deadline. It reports its own interruption when it + * aborts: a timeout when its budget elapsed, otherwise a cancellation by the + * caller's or the connection owner's signal. SDK 2.3.1 rejects an aborted + * request with an `SdkError` abort reason by identity, so that identity maps + * back to the report. + */ export class JebMcpDeadline { readonly #deadline: ProviderStartupDeadline; readonly #controller = new AbortController(); @@ -51,15 +61,44 @@ export class JebMcpDeadline { SdkErrorCode.RequestTimeout, "JEB MCP interrupted", ); - readonly #onAbort = (): void => this.#controller.abort(this.#interruption); + readonly #caller: AbortSignal | undefined; + #reported: AnalysisError | undefined; + #cancelledBy: JebMcpCancellation | undefined; + readonly #onAbort = (): void => { + if (this.#deadline.interruption === "timeout") + this.#reported = this.#timeout(this.#deadline.signal.reason); + else { + // The caller's own signal decides whose cancellation this is. + this.#cancelledBy = this.#caller?.aborted === true ? "caller" : "owner"; + this.#reported = cancelledBy(this.operation, this.#deadline.signal); + } + this.#controller.abort(this.#interruption); + }; + + readonly timeoutMs: number; + readonly source: JebMcpTimeoutSource; constructor( - readonly timeoutMs: number, - signal?: AbortSignal, - readonly phase: JebMcpDeadlinePhase = "tools/call", - readonly source: JebMcpTimeoutSource = "default", + selected: ReturnType, + readonly phase: JebMcpDeadlinePhase, + /** The phase operation its interruption is reported under. */ + readonly operation: string, + signals: { + /** The caller's cancellation, when the phase has its own caller. */ + readonly caller?: AbortSignal | undefined; + /** The connection owner's invalidation or close. */ + readonly owner: AbortSignal; + }, ) { - this.#deadline = new ProviderStartupDeadline(timeoutMs, signal); + this.timeoutMs = selected.ms; + this.source = selected.source; + this.#caller = signals.caller; + this.#deadline = new ProviderStartupDeadline( + selected.ms, + signals.caller === undefined + ? signals.owner + : AbortSignal.any([signals.caller, signals.owner]), + ); if (this.#deadline.signal.aborted) this.#onAbort(); else this.#deadline.signal.addEventListener("abort", this.#onAbort, { @@ -73,23 +112,28 @@ export class JebMcpDeadline { remainingMs(): number { return this.#deadline.remainingMs(); } + /** Whose signal cancelled this phase; undefined unless one did. */ + get cancelledBy(): JebMcpCancellation | undefined { + return this.#cancelledBy; + } - /** Classify only the rejection produced by this deadline's exact abort. */ - failure(operation: string, cause: unknown): unknown { - if (cause === this.#interruption) { - return this.#deadline.interruption === "timeout" - ? new AnalysisTimeoutError(operation, this.timeoutMs, { - cause: this.#deadline.signal.reason, - ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs, this.source), - }) - : cancelledBy(operation, this.#deadline.signal); - } - if (cause instanceof SdkError && cause.code === SdkErrorCode.RequestTimeout) - return new AnalysisTimeoutError(operation, this.timeoutMs, { - cause, - ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs, this.source), - }); - return cause; + #timeout(cause: unknown): AnalysisTimeoutError { + return new AnalysisTimeoutError(this.operation, this.timeoutMs, { + cause, + ...jebMcpTimeoutGuidance(this.phase, this.timeoutMs, this.source), + }); + } + + /** + * This deadline's own report for an SDK rejection it caused: its recorded + * interruption, or the SDK's request timeout under this deadline's budget. + * Undefined for any other SDK failure. + */ + interruption(rejection: SdkError): AnalysisError | undefined { + if (rejection === this.#interruption) return this.#reported; + if (rejection.code === SdkErrorCode.RequestTimeout) + return this.#timeout(rejection); + return undefined; } dispose(): void { diff --git a/src/jeb/JebProvider.ts b/src/jeb/JebProvider.ts index a89b7a88bc..247c237223 100644 --- a/src/jeb/JebProvider.ts +++ b/src/jeb/JebProvider.ts @@ -23,9 +23,19 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisProtocolError, + AnalysisResourceConstraintError, AnalysisTimeoutError, cancelledBy, } from "../domain/analysisErrorCore.js"; +import { + jvmExhaustionArea, + RUNTIME_EXHAUSTION_TOKENS, + type RuntimeExhaustionArea, +} from "../process/RuntimeExhaustion.js"; +import { + classifyProviderFailure, + type ProviderFailureDialect, +} from "../providers/classifyProviderFailure.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; @@ -106,6 +116,50 @@ const envelope = z.discriminatedUnion("success", [ failure, ]); +/** Recovery for the JVM memory area JEB reported; each area has its own limit. */ +const JEB_EXHAUSTION_REMEDIATION: Readonly< + Record +> = { + heap: "REA does not set JEB's Java heap. Restart the JEB client with a larger maximum heap (-Xmx), or select a smaller input.", + "gc-overhead": + "REA does not set JEB's Java heap. The engine JVM spent nearly all its time collecting garbage; restart the JEB client with a larger maximum heap (-Xmx), or select a smaller input.", + metaspace: + "REA does not set JEB's class metadata limit. Restart the JEB client with a larger -XX:MaxMetaspaceSize; a larger heap (-Xmx) does not raise it.", + "array-size": + "The engine requested an array larger than the JVM allows; a larger heap (-Xmx) does not lift this limit. Select a smaller input.", + "native-thread": + "The engine JVM could not create a native thread. Reduce concurrent work in the JEB client or review the engine host's thread and process limits; a larger heap (-Xmx) does not help.", + unknown: + "REA does not set the memory limits of JEB's JVM. Review the reported out-of-memory line and the JEB client's JVM memory settings, or select a smaller input.", +}; + +/** + * JEB's JVM out-of-memory report in a refusal it returned, read with the + * shared JVM marker. JEB runs in the user's own JVM, so its heap is the limit. + */ +const refusalExhaustion = ( + operation: JebOperation, + refusal: z.infer, + diagnostics: Readonly>, +): AnalysisResourceConstraintError | undefined => { + const line = [refusal.error?.message, refusal.message].find( + (value) => value?.includes(RUNTIME_EXHAUSTION_TOKENS.jvm) === true, + ); + if (line === undefined) return undefined; + const area = jvmExhaustionArea(line); + const threads = area === "native-thread"; + return new AnalysisResourceConstraintError( + operation, + threads ? "capacity" : "memory", + `JEB reported that its Java virtual machine ran out of memory${threads ? " (unable to create a native thread)" : ""}. This is the engine's report and does not prove the exact allocation failure.`, + null, + { + diagnostics: { ...diagnostics, jvm_exhaustion_area: area ?? null }, + remediationAction: JEB_EXHAUSTION_REMEDIATION[area ?? "unknown"], + }, + ); +}; + /** Parse success/refusal once; operation-specific success data has its own owner. */ const parseJebEnvelope = ( payload: JsonValue, @@ -138,6 +192,8 @@ const parseJebEnvelope = ( ); if (parsed.data.success) return ok(payload); const refusal = parsed.data; + const exhausted = refusalExhaustion(operation, refusal, diagnostics); + if (exhausted !== undefined) return err(exhausted); const reason = [ refusal.error?.message, refusal.message, @@ -218,7 +274,7 @@ export class JebProvider implements JebAnalysisPort { if (signal?.aborted === true) throw cause; if (cause instanceof AnalysisError) { if (cause.cleanupIncomplete) - throw this.#providerFailure("inspect_jeb_client", cause); + throw this.#attributed("inspect_jeb_client", cause); // The failure's owner, such as the discovery deadline, also owns the repair. const projected = projectAnalysisError(cause); return { @@ -260,7 +316,12 @@ export class JebProvider implements JebAnalysisPort { const execution = await this.#dispatch(request, options?.signal); return execution; } catch (cause) { - return err(this.#providerFailure(request.operation, cause)); + return err( + this.#attributed( + request.operation, + classifyProviderFailure(cause, this.#dialect(request.operation)), + ), + ); } } @@ -322,9 +383,9 @@ export class JebProvider implements JebAnalysisPort { try { return await this.#engine(request.operation, signal); } catch (cause: unknown) { - throw this.#providerFailure( + throw this.#attributed( request.operation, - cause, + classifyProviderFailure(cause, this.#dialect(request.operation)), ).retainPartialObservation( createAnalysisExecutionEvidence( request.operation, @@ -539,64 +600,84 @@ export class JebProvider implements JebAnalysisPort { return parsed.value; } - #providerFailure(operation: JebOperation, cause: unknown): AnalysisError { - if (cause instanceof JebMcpCleanupFailure) + /** How JEB's raw MCP failures are reported for one operation. */ + #dialect(operation: JebOperation): ProviderFailureDialect { + const diagnostics = { endpoint: this.#selectedEndpoint }; + return { + providerId: "jeb", + operation, + recognise: (cause) => + cause instanceof z.ZodError + ? new AnalysisProtocolError( + "JEB omitted or malformed required observation fields", + { cause, diagnostics }, + ) + : undefined, + unclassified: (cause) => { + const physical = + cause instanceof JebMcpPhysicalFailure ? cause.cause : cause; + return new AnalysisCapabilityUnavailableError( + "jeb", + operation, + physical instanceof Error ? physical.message : String(physical), + { cause, diagnostics, userMessage: JEB_CONNECTION_REMEDIATION }, + ); + }, + }; + } + + /** + * Report a classified failure under the REA operation that observed it. A + * shared MCP phase, such as catalog discovery or a tools/call, reports its + * own interruption under that phase; the operation and endpoint are added + * here, keeping the phase's cleanup ownership. + */ + #attributed(operation: JebOperation, failure: AnalysisError): AnalysisError { + if (failure instanceof JebMcpCleanupFailure) return analysisErrorWithCleanupFailure( - this.#providerFailure(operation, cause.primaryFailure), - cause.cleanupFailure, + this.#attributed( + operation, + classifyProviderFailure( + failure.primaryFailure, + this.#dialect(operation), + ), + ), + failure.cleanupFailure, operation, ) - .bindCleanupDependencies([cause.cleanupFailure]) - .bindCleanupResourceOwner(cause.cleanupFailure); + .bindCleanupDependencies([failure.cleanupFailure]) + .bindCleanupResourceOwner(failure.cleanupFailure); if ( - cause instanceof AnalysisCancelledError || - cause instanceof AnalysisTimeoutError + failure instanceof AnalysisCancelledError || + failure instanceof AnalysisTimeoutError ) { const options = { diagnostics: { - ...cause.diagnostics, + ...failure.diagnostics, endpoint: this.#selectedEndpoint, }, - capturedOutput: cause.capturedOutput, - cleanup: cause.cleanup, - partialObservation: cause.partialObservation, + capturedOutput: failure.capturedOutput, + cleanup: failure.cleanup, + partialObservation: failure.partialObservation, }; - const failure = - cause instanceof AnalysisTimeoutError - ? new AnalysisTimeoutError(operation, cause.timeoutMs, { + const attributed = + failure instanceof AnalysisTimeoutError + ? new AnalysisTimeoutError(operation, failure.timeoutMs, { ...options, - cause: cause.cause, - ...(cause.userMessage === undefined + cause: failure.cause, + ...(failure.userMessage === undefined ? {} - : { userMessage: cause.userMessage }), - ...(cause.remediationAction === undefined + : { userMessage: failure.userMessage }), + ...(failure.remediationAction === undefined ? {} - : { remediationAction: cause.remediationAction }), - ...(cause.limit === undefined ? {} : { limit: cause.limit }), + : { remediationAction: failure.remediationAction }), + ...(failure.limit === undefined ? {} : { limit: failure.limit }), }) - : cancelledBy(operation, cause, options); - return failure - .bindCleanupDependencies([cause]) - .bindCleanupResourceOwner(cause); + : cancelledBy(operation, failure, options); + return attributed + .bindCleanupDependencies([failure]) + .bindCleanupResourceOwner(failure); } - if (cause instanceof AnalysisError) - return cause.retainDiagnostics({ endpoint: this.#selectedEndpoint }); - if (cause instanceof z.ZodError) - return new AnalysisProtocolError( - "JEB omitted or malformed required observation fields", - { cause, diagnostics: { endpoint: this.#selectedEndpoint } }, - ); - const physical = - cause instanceof JebMcpPhysicalFailure ? cause.cause : cause; - return new AnalysisCapabilityUnavailableError( - "jeb", - operation, - physical instanceof Error ? physical.message : String(physical), - { - cause, - diagnostics: { endpoint: this.#selectedEndpoint }, - userMessage: JEB_CONNECTION_REMEDIATION, - }, - ); + return failure.retainDiagnostics({ endpoint: this.#selectedEndpoint }); } } diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index bb35f44f7f..5625d1ae83 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -3,6 +3,7 @@ import { sameRegularFileState, type RegularFileState, } from "../filesystem/RegularFile.js"; +import { classifyProviderFailure } from "../providers/classifyProviderFailure.js"; import type { Stats } from "node:fs"; import { assertKunaProjectState, @@ -57,7 +58,7 @@ import { kunaExportFileNames, exportKunaProject, KUNA_EXPORT_OPERATION, - kunaExportFailure, + kunaExportFailureDialect, readKunaExport, type KunaExport, type KunaExportStatus, @@ -524,7 +525,7 @@ export class KunaProjectCache { throw this.#replaceHashCloseError( key, null, - kunaExportFailure(cause, signal), + classifyProviderFailure(cause, kunaExportFailureDialect(signal)), cause, ); if (regularFileCancellation(cause) !== undefined) throw cause; @@ -997,7 +998,9 @@ export class KunaProjectCache { await this.#sweep(signal); staging = await mkdtemp(join(root, "staging", "build-")); } catch (cause: unknown) { - return err(kunaExportFailure(cause, signal)); + return err( + classifyProviderFailure(cause, kunaExportFailureDialect(signal)), + ); } const built = await this.#exportAndPublish( key, @@ -1006,7 +1009,9 @@ export class KunaProjectCache { signal, onStatus, ).catch((cause: unknown): StagedResult => ({ - result: err(kunaExportFailure(cause, signal)), + result: err( + classifyProviderFailure(cause, kunaExportFailureDialect(signal)), + ), retainStaging: false, ...(cause instanceof RegularFileCleanupFailure ? { fileCleanupFailure: cause } diff --git a/src/kuna/KunaProjectExport.ts b/src/kuna/KunaProjectExport.ts index 0a8848c3ae..f63646f21c 100644 --- a/src/kuna/KunaProjectExport.ts +++ b/src/kuna/KunaProjectExport.ts @@ -24,6 +24,7 @@ import { } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import type { ProviderFailureDialect } from "../providers/classifyProviderFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { hashStableFile, @@ -170,60 +171,72 @@ const isMissing = (cause: unknown): boolean => "code" in cause && (cause.code === "ENOENT" || cause.code === "ENOTDIR"); -/** Map an unexpected failure to a typed one, preferring cancellation. */ -export const kunaExportFailure = ( - cause: unknown, - signal: AbortSignal | undefined, -): AnalysisError => { - if (cause instanceof ArtifactReaderFailure) { - const { completion } = kunaCopyFailureFacts(cause); - const primary = - cause.reason === "cancelled" || completion?.cancellation !== undefined - ? cancelledBy( - KUNA_EXPORT_OPERATION, - carriedReason( - completion?.cancellation === undefined - ? artifactFileCancellationCause(cause) - : completion.cancellation.reason, - ), - ) - : new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { - cause, - userMessage: `Kuna project cache failed: ${message(cause)}`, - }); - if (cause.cleanup === undefined) return primary; - const cleanup = new ProviderCleanupError( - KUNA_PROVIDER_ID, - cause.cleanup.resources, - { reason: cause.cleanup.reason }, - { cause, operation: KUNA_EXPORT_OPERATION }, - ); - bindArtifactFileCleanup(cause, cleanup); - if (completion !== undefined && completion.cancellation === undefined) - return cleanup; - return analysisErrorWithCleanupFailure( - primary, - cleanup, - KUNA_EXPORT_OPERATION, - ).bindCleanupDependencies([cleanup]); - } - if (cause instanceof RegularFileCleanupFailure) - return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION); - const cancellation = regularFileCancellation(cause); - if (cancellation !== undefined) - return cancelledBy( - KUNA_EXPORT_OPERATION, - carriedReason(cancellation.reason), - ); - if (cause instanceof AnalysisError) return cause; - if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true) - return cancelledBy(KUNA_EXPORT_OPERATION, carriedReason(cause)); - return new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { +const projectCacheFailure = (cause: unknown) => + new ProviderAdapterError(KUNA_PROVIDER_ID, KUNA_EXPORT_OPERATION, { cause, userMessage: `Kuna project cache failed: ${message(cause)}`, }); + +/** A project-cache read's own report, apart from its cleanup. */ +const projectCacheReadFailure = (read: ArtifactReaderFailure) => { + const { completion } = kunaCopyFailureFacts(read); + return read.reason === "cancelled" || completion?.cancellation !== undefined + ? cancelledBy( + KUNA_EXPORT_OPERATION, + carriedReason( + completion?.cancellation === undefined + ? artifactFileCancellationCause(read) + : completion.cancellation.reason, + ), + ) + : projectCacheFailure(read); }; +/** + * How the project export reports a failure, preferring cancellation. The + * shared classifier keeps typed failures and a cancelled read; a failure + * while the caller's signal was aborted is that caller's cancellation. + */ +export const kunaExportFailureDialect = ( + signal: AbortSignal | undefined, +): ProviderFailureDialect => ({ + providerId: KUNA_PROVIDER_ID, + operation: KUNA_EXPORT_OPERATION, + recognise: (cause) => { + if (cause instanceof RegularFileCleanupFailure) + return kunaFileCleanupError(cause, KUNA_EXPORT_OPERATION); + const cancellation = regularFileCancellation(cause); + return cancellation === undefined + ? undefined + : cancelledBy(KUNA_EXPORT_OPERATION, carriedReason(cancellation.reason)); + }, + readCleanup: (read) => { + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + read.cleanup.resources, + { reason: read.cleanup.reason }, + { cause: read, operation: KUNA_EXPORT_OPERATION }, + ); + bindArtifactFileCleanup(read, cleanup); + // A completed read's cleanup is the only failure. + const { completion } = kunaCopyFailureFacts(read); + if (completion !== undefined && completion.cancellation === undefined) + return cleanup; + return analysisErrorWithCleanupFailure( + projectCacheReadFailure(read), + cleanup, + KUNA_EXPORT_OPERATION, + ).bindCleanupDependencies([cleanup]); + }, + unclassified: (cause) => { + if (cause instanceof ArtifactReaderFailure) + return projectCacheReadFailure(cause); + if (!(cause instanceof StableFileHashFailure) && signal?.aborted === true) + return cancelledBy(KUNA_EXPORT_OPERATION, carriedReason(cause)); + return projectCacheFailure(cause); + }, +}); + const unusable = (reason: string): KunaExportOutcome => ({ kind: "done", result: err( diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 91004b12b6..c0cc9b808b 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -53,6 +53,10 @@ import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; import type { RelationshipCoverage } from "../domain/relationshipCoverage.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { + classifyProviderFailure, + type ProviderFailureDialect, +} from "../providers/classifyProviderFailure.js"; import { err, ok, type Result } from "../domain/result.js"; import { AbortedWait, waitForAbortable } from "../process/AbortablePromise.js"; import { SharedStartupAttempt } from "../process/SharedStartupAttempt.js"; @@ -237,13 +241,16 @@ const isUnavailableProject = (cause: unknown): boolean => const message = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); -const failure = (operation: string, cause: unknown): AnalysisError => - cause instanceof AnalysisError - ? cause - : new ProviderAdapterError(KUNA_PROVIDER_ID, operation, { - cause, - userMessage: `Kuna ${operation} failed: ${message(cause)}`, - }); +/** How one Kuna session operation reports a failure nothing typed it as. */ +const kunaSessionDialect = (operation: string): ProviderFailureDialect => ({ + providerId: KUNA_PROVIDER_ID, + operation, + unclassified: (cause) => + new ProviderAdapterError(KUNA_PROVIDER_ID, operation, { + cause, + userMessage: `Kuna ${operation} failed: ${message(cause)}`, + }), +}); /** Bind a refused descriptor retry to the leases that still own those handles. */ const descriptorCleanupError = ( @@ -617,7 +624,7 @@ export class KunaSessionClient implements AnalysisClient { ), ); } - return err(failure(operation, cause)); + return err(classifyProviderFailure(cause, kunaSessionDialect(operation))); } } @@ -1567,7 +1574,9 @@ export class KunaSessionClient implements AnalysisClient { address, installation, AbortSignal.any([this.#session.signal, controller.signal]), - ).catch((cause: unknown) => err(failure(operation, cause))), + ).catch((cause: unknown) => + err(classifyProviderFailure(cause, kunaSessionDialect(operation))), + ), ), }; this.#singleFunctionRuns.set(address, created); @@ -1937,7 +1946,9 @@ export class KunaSessionClient implements AnalysisClient { const head = await this.#commands.inputHead("list_procedures"); return head.ok ? ok(await readKunaMachoLayout(head.value)) : head; } catch (cause: unknown) { - return err(failure("list_procedures", cause)); + return err( + classifyProviderFailure(cause, kunaSessionDialect("list_procedures")), + ); } } @@ -2067,7 +2078,9 @@ export class KunaSessionClient implements AnalysisClient { if (cancellation !== undefined) return err(cancellation); } if (!(cause instanceof RegularFileCleanupFailure)) - return err(failure("health", cause)); + return err( + classifyProviderFailure(cause, kunaSessionDialect("health")), + ); const error = kunaFileCleanupError(cause, "health"); kunaResources.bindFailure(cause, error); return err(error); diff --git a/src/kuna/KunaTargetCommands.ts b/src/kuna/KunaTargetCommands.ts index 0a44beffef..4e056b5764 100644 --- a/src/kuna/KunaTargetCommands.ts +++ b/src/kuna/KunaTargetCommands.ts @@ -1,5 +1,6 @@ import { - artifactFileCancellationCause, + ArtifactFileCleanupFailure, + artifactFilePrimaryFacts, bindArtifactFileCleanup, } from "../artifacts/ArtifactFileOperation.js"; import { analysisErrorWithCancellation } from "../domain/analysisErrorCancellation.js"; @@ -28,7 +29,6 @@ import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipOb import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js"; import { copyAndHash, - kunaCopyFailureFacts, runKunaCommand, type KunaInputPrefix, type KunaProcessLauncher, @@ -43,6 +43,10 @@ import { AnalysisCleanupLeaseGroup, } from "../artifacts/AnalysisCleanupLease.js"; import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + classifyProviderFailure, + type ProviderFailureDialect, +} from "../providers/classifyProviderFailure.js"; import type { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js"; interface WorkspaceRoot { @@ -81,6 +85,42 @@ export interface KunaTargetCommandRequest { } /** Own the digest-checked target copy and every command that can still use it. */ +const workspaceCopyFailure = (cause: unknown) => + new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { cause }); + +/** + * How a failed workspace copy is reported. The shared classifier keeps typed + * failures and the copy's cancellation; a copy whose descriptor cleanup failed + * reports that cleanup with the copy's own failure as its primary. + */ +const KUNA_WORKSPACE_COPY_DIALECT: ProviderFailureDialect = { + providerId: KUNA_PROVIDER_ID, + operation: "kuna:workspace", + readCleanup: (read, primary) => { + // A completed copy's cleanup is the only failure; an unfinished plain + // read keeps its own failure as the primary. + const reported = + primary ?? + (read instanceof ArtifactFileCleanupFailure + ? undefined + : workspaceCopyFailure(artifactFilePrimaryFacts(read).cause)); + const error = new ProviderCleanupError( + KUNA_PROVIDER_ID, + read.cleanup.resources, + { reason: read.cleanup.reason }, + { + operation: "kuna:workspace", + cause: read, + ...(reported === undefined ? {} : { cleanupPrimary: reported }), + }, + ); + bindArtifactFileCleanup(read, error); + return error; + }, + unclassified: (cause) => + workspaceCopyFailure(artifactFilePrimaryFacts(cause).cause), +}; + export class KunaTargetCommands { readonly #resources = new ArtifactResourceScope(); readonly #retained: { @@ -370,7 +410,10 @@ export class KunaTargetCommands { : { keep: this.lifetime.inputPrefix }), }); } catch (cause: unknown) { - return await this.#workspaceFailure(this.#copyFailure(cause), owned); + return await this.#workspaceFailure( + classifyProviderFailure(cause, KUNA_WORKSPACE_COPY_DIALECT), + owned, + ); } if (copied.sha256 !== this.target.sha256) return await this.#workspaceFailure( @@ -415,45 +458,6 @@ export class KunaTargetCommands { } } - #copyFailure(cause: unknown): AnalysisError { - const facts = kunaCopyFailureFacts(cause); - const completion = facts.completion; - const primaryCause = facts.cause; - const cancellation = - completion?.cancellation ?? - (facts.origin === "owned-or-classified" && - primaryCause instanceof ArtifactReaderFailure && - primaryCause.reason === "cancelled" - ? { reason: artifactFileCancellationCause(primaryCause) } - : undefined); - const primary = - cancellation !== undefined - ? cancelledBy("kuna:workspace", carriedReason(cancellation.reason)) - : completion === undefined - ? new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { - cause: primaryCause, - }) - : undefined; - if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) { - const error = new ProviderCleanupError( - KUNA_PROVIDER_ID, - cause.cleanup.resources, - { reason: cause.cleanup.reason }, - { - operation: "kuna:workspace", - cause, - ...(primary === undefined ? {} : { cleanupPrimary: primary }), - }, - ); - bindArtifactFileCleanup(cause, error); - return error; - } - return ( - primary ?? - new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { cause }) - ); - } - /** Rollback and the original outcome share the exact root cleanup lease. */ async #workspaceFailure( primary: AnalysisError, diff --git a/tests/boundary/providers/ghidra/ghidraClient.test.ts b/tests/boundary/providers/ghidra/ghidraClient.test.ts index fbf67f08d9..dc1f9f878e 100644 --- a/tests/boundary/providers/ghidra/ghidraClient.test.ts +++ b/tests/boundary/providers/ghidra/ghidraClient.test.ts @@ -3,7 +3,10 @@ import { GhidraProvider } from "../../../../src/ghidra/GhidraProvider.js"; import { fixtureDosLoadImage } from "../../../../src/ghidra/GhidraLoadImage.fixture.js"; import { jsonValueSchema } from "../../../../src/domain/jsonValue.js"; import { artifactFilePrimaryFacts } from "../../../../src/artifacts/ArtifactFileOperation.js"; -import { AnalysisCancelledError } from "../../../../src/domain/analysisErrorCore.js"; +import { + AnalysisCancelledError, + OwnerCancellation, +} from "../../../../src/domain/analysisErrorCore.js"; import { spawn, type ChildProcess } from "node:child_process"; import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; @@ -2643,6 +2646,49 @@ it.each([null, "helper owner stopped"])( }, ); +it.each([ + { interruption: "timeout", kind: "timeout" }, + { interruption: "owner close", kind: "process" }, +] as const)( + "reports the aborted startup deadline's $interruption over a producer cancellation with its own reason", + async ({ interruption, kind }) => { + const controller = new AbortController(); + const cancellation = new AnalysisCancelledError("open_binary", { + cause: "helper owner stopped", + }); + const client = clientFor( + { + launch: async (_session, options) => { + if (options?.signal === undefined) + throw new Error("Expected owned startup signal"); + if (interruption === "owner close") + controller.abort(OwnerCancellation.startupOwnerClosed); + if (!options.signal.aborted) + await new Promise((resolve) => + options.signal?.addEventListener("abort", () => resolve(), { + once: true, + }), + ); + return err( + new GhidraLaunchError("producer helper cancelled", { + primaryFailure: cancellation, + }), + ); + }, + }, + { startupTimeoutMs: interruption === "timeout" ? 50 : 60_000 }, + ); + const result = await client.start(controller.signal); + expect(result).toMatchObject({ ok: false, error: { kind } }); + if (result.ok) throw new Error("Expected startup failure"); + if (kind === "process") + expect(result.error.message).toContain( + "Ghidra session closed during startup", + ); + expect(result.error.cancellation?.reason).not.toBe("helper owner stopped"); + }, +); + it("preserves a producer failure while its result waits for the startup deadline", async () => { const physical = new GhidraLaunchError( "controlled physical failure before finalization", diff --git a/tests/boundary/providers/jeb/failureEnvelopes.test.ts b/tests/boundary/providers/jeb/failureEnvelopes.test.ts index 00342bc52c..7eed72335f 100644 --- a/tests/boundary/providers/jeb/failureEnvelopes.test.ts +++ b/tests/boundary/providers/jeb/failureEnvelopes.test.ts @@ -121,73 +121,164 @@ const failures: { name: string; payload: JsonValue; message: string }[] = [ }, ]; -it.each( - failures.flatMap((failure) => - (["structured", "json-text"] as const).map((encoding) => ({ - ...failure, - encoding, - })), - ), -)( - "projects $encoding $name as execution failure with the original envelope", - async ({ payload, message, encoding }) => { - const producer = await startProducer(payload, encoding); - const provider = new JebProvider({ - environment: { REA_JEB_MCP_URL: producer.endpoint }, +const input = { path: "./selected target/caller-secret.apk" }; + +/** Serve one refusal to the public open_jeb_project tool and project it. */ +const refuseOpenProject = async ( + payload: JsonValue, + encoding: "structured" | "json-text", +) => { + const producer = await startProducer(payload, encoding); + const provider = new JebProvider({ + environment: { REA_JEB_MCP_URL: producer.endpoint }, + }); + const session = createTestBinarySession(() => { + throw new Error("JEB must not start a deep provider"); + }); + const server = createServer( + { kind: "session", session }, + { jebAnalysis: provider, environment: {} }, + ); + const client = new Client({ name: "jeb-failure-contract", version: "1" }); + try { + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + const result = await client.callTool({ + name: "open_jeb_project", + arguments: input, }); - const session = createTestBinarySession(() => { - throw new Error("JEB must not start a deep provider"); - }); - const server = createServer( - { kind: "session", session }, - { jebAnalysis: provider, environment: {} }, - ); - const client = new Client({ name: "jeb-failure-contract", version: "1" }); - const input = { path: "./selected target/caller-secret.apk" }; + expect(result.isError).toBe(true); + const text = result.content + .map((block) => (block.type === "text" ? block.text : "")) + .join(""); + expect(producer.calls).toEqual([ + { name: "open_project", arguments: { file_path: input.path } }, + ]); + return { projected: JSON.parse(text), endpoint: producer.endpoint }; + } finally { try { - const [clientTransport, serverTransport] = - InMemoryTransport.createLinkedPair(); - await server.connect(serverTransport); - await client.connect(clientTransport); - const result = await client.callTool({ - name: "open_jeb_project", - arguments: input, - }); - expect(result.isError).toBe(true); - const text = result.content - .map((block) => (block.type === "text" ? block.text : "")) - .join(""); - const projected = JSON.parse(text); - expect(projected.error).toMatchObject({ - code: "execution_failure", - message, - }); - expect(projected.error.details.diagnostics).toEqual({ - endpoint: producer.endpoint, - upstream_response: payload, - input, - }); - expect(producer.calls).toEqual([ - { name: "open_project", arguments: { file_path: input.path } }, - ]); + await client.close(); } finally { try { - await client.close(); + await server.close(); } finally { try { - await server.close(); + await session.close(); } finally { try { - await session.close(); + await provider.close(); } finally { - try { - await provider.close(); - } finally { - await producer.close(); - } + await producer.close(); } } } } + } +}; + +const encodings = ["structured", "json-text"] as const; + +it.each( + failures.flatMap((failure) => + encodings.map((encoding) => ({ ...failure, encoding })), + ), +)( + "projects $encoding $name as execution failure with the original envelope", + async ({ payload, message, encoding }) => { + const { projected, endpoint } = await refuseOpenProject(payload, encoding); + expect(projected.error).toMatchObject({ + code: "execution_failure", + message, + }); + expect(projected.error.details.diagnostics).toEqual({ + endpoint, + upstream_response: payload, + input, + }); + }, +); + +const exhaustion: { + name: string; + payload: JsonValue; + resource: string; + area: string | null; + remediation: RegExp; +}[] = [ + { + name: "heap space in the nested error", + payload: { + success: false, + error: { + code: "ENGINE_FAILURE", + message: "java.lang.OutOfMemoryError: Java heap space", + }, + }, + resource: "memory", + area: "heap", + remediation: /larger maximum heap \(-Xmx\)/u, + }, + { + name: "metaspace in the nested error", + payload: { + success: false, + error: { message: "java.lang.OutOfMemoryError: Metaspace" }, + }, + resource: "memory", + area: "metaspace", + remediation: /-XX:MaxMetaspaceSize; a larger heap \(-Xmx\) does not/u, + }, + { + name: "an oversized array in the outer message", + payload: { + success: false, + message: + "java.lang.OutOfMemoryError: Requested array size exceeds VM limit", + }, + resource: "memory", + area: "array-size", + remediation: /does not lift this limit\. Select a smaller input/u, + }, + { + name: "an unnamed area in the outer message", + payload: { success: false, message: "java.lang.OutOfMemoryError" }, + resource: "memory", + area: null, + remediation: /Review the reported out-of-memory line/u, + }, + { + name: "native-thread exhaustion", + payload: { + success: false, + message: + "java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory", + }, + resource: "capacity", + area: "native-thread", + remediation: /could not create a native thread/u, + }, +]; + +it.each( + exhaustion.flatMap((failure) => + encodings.map((encoding) => ({ ...failure, encoding })), + ), +)( + "reports $encoding JVM exhaustion with $name as a resource constraint", + async ({ payload, resource, area, remediation, encoding }) => { + const { projected, endpoint } = await refuseOpenProject(payload, encoding); + expect(projected.error.code).toBe("resource_constraint"); + expect(projected.error.details).toMatchObject({ + resource, + diagnostics: { + endpoint, + upstream_response: payload, + input, + jvm_exhaustion_area: area, + }, + }); + expect(projected.error.remediation.action).toMatch(remediation); }, );