mirror of
https://github.com/morluto/rea.git
synced 2026-11-15 19:23:24 +00:00
refactor(jeb): classify JEB failures through the shared provider classifier (#10831)
* fix(pcap): report a refused tool launch as an unavailable tool When a capture read's tool launch was refused with EPERM, pcap reported it as unreadable tool output (AnalysisProtocolError) instead of an unavailable tool. Pcap also never told "not executable" apart from "missing". Add classifyLaunchFailure in src/process as the one owner of which spawn errnos mean missing (ENOENT, ENOTDIR) and which mean present but not executable (EACCES, EPERM, ENOEXEC, EISDIR). Pcap, apktool and gdb used three different errno lists; all three now use it. * refactor(process): classify owned command outcomes through one owner Add ProviderOutcome, read once from the supervisor's facts, and classifyProviderOutcome, the one mapping from an outcome to a typed error. Migrate pcap and the shared artifact producer command failure (TShark, sigrok). Add the rea/provider-failure-classifier lint rule with a shrink-only baseline of per-site (cause: unknown) => AnalysisError functions. * refactor(process): classify Java and adb launch refusals with the shared owner (#10598) The Ghidra and JADX Java probes and adb's version probe now take missing-versus-not-executable from classifyLaunchFailure instead of their own errno checks. ELOOP and ENAMETOOLONG join the owner's missing set, so adb keeps its existing classification and both Java probes now treat a non-directory path component as a missing runtime. * fix(pcap): preserve command launch attribution * test(pcap): expect an unknown failing stage when a limited capture tool exits unsuccessfully * refactor(process): classify owned command outcomes through one owner Add ProviderOutcome, read once from the supervisor's facts, and classifyProviderOutcome, the one mapping from an outcome to a typed error. Migrate pcap and the shared artifact producer command failure (TShark, sigrok). Add the rea/provider-failure-classifier lint rule with a shrink-only baseline of per-site (cause: unknown) => AnalysisError functions. * test(lint): cover baselined files in the provider-failure classifier rule * fix(apktool): report the decoder JVM's heap exhaustion as memory, not a target refusal The supervisor now watches for a managed runtime's out-of-memory report while output streams (JVM java.lang.OutOfMemoryError, V8 'JavaScript heap out of memory'), so a retention budget cannot hide it. ProviderOutcome gains a runtime-exhausted kind and classifyProviderOutcome maps it once. Apktool decode opts in and moves to the shared classifier; Ghidra's single-purpose output token becomes the JVM runtime option and its area matching reuses the shared table. * fix(process): keep the runtime's out-of-memory report on the memory error it supports * fix(pcap): attribute a limited capture's exit through the prlimit wrapper * fix(process): keep a shortened out-of-memory line marked as shortened * fix(process): identify the refused executable for every launcher shape Name the executable on errnos Node throws synchronously from spawn (ENOEXEC, ENOTDIR, ...) and read the Windows native launcher's executable-launch constraint, so pcap attributes those refusals to the selected tool or limiter instead of an unknown stage. * test(pcap): prove synchronous launch naming with ENOTDIR on every POSIX host * fix(apktool): advise the JVM limit that governs the reported memory area * fix(ghidra): read the supervisor's parsed OOM area instead of reparsing lines * fix(sqlite,capture): read V8 heap exhaustion from streamed output, not retained stderr (#10724) The SQLite worker and the Node capture decoders now declare runtime: "v8", so the supervisor observes V8's heap report as output streams. Both classifiers read the runtime-exhausted outcome instead of matching retained stderr, so a report beyond the retention budget, or one whose process then overflowed its output budget, is still reported as memory exhaustion. * refactor(evm): classify EVMole worker outcomes through the shared provider classifier (#10716) * refactor(evm): classify EVMole worker outcomes through the shared provider classifier The EVMole worker's cancellation, deadline, output budget and CPU/file-size signals now go through classifyProviderOutcome; only the prlimit launch refusal and the reserved EFBIG exit remain in its dialect. The worker is opted into V8 exhaustion scanning so a heap report names its fixed 256 MiB heap. providerOutcomeOf now reads a launch refusal only from a spawn errno, so a private-storage or carrier read errno is no longer reported as a limiter launch failure. * fix(evm): keep a stream failure's exit status and update Linux worker expectations * refactor(process): read launch refusals through the launcher identity owner * refactor(wasm): classify WABT command outcomes through the shared provider classifier (#10727) * refactor(wasm): classify WABT command outcomes through the shared provider classifier WABT's cancellation, command deadline, output budget, and inherited-limit signals now go through classifyProviderOutcome; its dialect keeps only the configuration-probe and launch-refusal unavailability and the unclassified-exit output error. A tool ended by SIGXCPU/SIGXFSZ or the memory limit is now a resource constraint instead of an output error, and a refused launch in the producer phase is reported as unavailable. The shared timeout context gains the analysis setting that selected the deadline, and the output-limit context may name its exceeded budget. * fix(wasm): keep setup guidance when a WABT version probe's output cannot be collected * refactor(providers): classify raw provider failures through one owner * refactor(process): build classified cancellations with cancelledBy and the carried caller reason * chore(lint): count runOwned's close-failure mapper from main in the classifier baseline * fix(process): carry the runtime's exhaustion report on every memory error that claims it SQLite and historical-capture memory errors recognised a streamed V8 heap report but dropped it, so a report outside retained output reached callers without its source line or omission flags. One projection, runtimeExhaustionReport, now owns that shape for the shared classifier and both consumers. Also lowers the EVMole and WABT cancellation-constructor baselines. * fix(process): keep the provider's recovery for a native-thread exhaustion report The shared classifier replaced the provider-supplied recovery with generic capacity advice whenever the JVM reported it could not create a native thread, dropping Apktool's launcher guidance and flipping retryable to true. The provider's recovery now wins; the capacity advice remains the fallback for providers that supply none. * refactor(pwntools): classify pwntools failures through the shared provider classifier (#10782) * refactor(pwntools): classify pwntools failures through the shared provider classifier pwntools' per-site classifier is replaced by pwntoolsFailureDialect for classifyProviderFailure. Command outcomes go through classifyProviderOutcome, launch refusals through launcherRefusalOf instead of a 'spawn ' syscall prefix check, the input limit through the file dialect's fileSize, and the cleanup report through commandCleanup. The shared read cancellation now carries the reader's partial observation, and resource-signal reports accept provider detail. * fix(pwntools): report a failed command's output once and expect resource_constraint for overflow The shared outcome owner already attaches the command's output as capturedOutput, so the pwntools adapter fallback no longer copies the same text into diagnostics.stdout, diagnostics.stderr and diagnostics.captured_output; diagnostics keep the exit facts. Update the overflow expectations the dialect migration missed (layout provider cleanup and over-budget rows, pwntools cleanup) to resource_constraint. * refactor(wasm): leave WABT cancellation to the shared owners WabtFailures matched any Error named AbortError as a cancellation. No WABT path produces one: reads, path checks and owned commands report cancellation as ArtifactReaderFailure or OwnedCommandFailure, which classifyProviderFailure and classifyProviderOutcome already classify with the caller's reason. The provider's two signal checks now build the cancellation with cancelledBy. Lowers the AnalysisCancelledError constructor baseline for both WABT files to zero. * fix(wasm): report a cancelled snapshot write as the caller's cancellation The private snapshot write passed the caller's signal to Node's writeFile, whose abort envelope reached the dialect's unclassified output failure once the name-based AbortError guess was removed. The write now turns the envelope carrying its own signal's reason into cancelledBy at the producer. * refactor(firmware): classify firmware failures through the shared provider classifier (#10810) Firmware producers now report their own deadline expiry: the start check, the output inventory and launcher admission throw the phase timeout where they observe the abort, and a read the phase signal cancelled reports it through the dialect's interruption. The classifier no longer rereads a cancellation's reason as a timeout. A new readCleanup hook keeps firmware's cleanup report shape. * refactor(captures): classify TShark, sigrok and pcap failures through the shared provider classifier TsharkCaptureProvider and SigrokRecordingProvider replace #failure with a dialect, and PcapProvider replaces analysisFailure and the tool-identity classifier. ArtifactAnalysisResources exposes its command context and a readCleanupFailure composer so the readCleanup hook keeps each provider's cleanup report shape. * refactor(jeb): classify JEB failures through the shared provider classifier JEB's raw MCP failures go through classifyProviderFailure with a JEB dialect; a typed attribution step reports a shared phase's failure under the REA operation. Each MCP phase deadline now reports its own interruption when it aborts, deciding a cancellation's owner from the caller's own signal instead of comparing abort reasons. A refusal carrying the JVM's OutOfMemoryError report becomes a resource constraint naming JEB's Java heap. * fix(jeb): advise the JVM limit that governs the reported memory area * refactor(ghidra): classify Ghidra failures through the shared provider classifier (#10836) * refactor(ghidra): classify Ghidra failures through the shared provider classifier PSP extension inspection reports through classifyProviderFailure with a Ghidra dialect. The startup deadline decides at abort time whether the shared startup owner closed it, so Ghidra and Hopper no longer compare the abort reason with the owner's sentinel, and Ghidra's startup no longer compares a producer cancellation's reason with the deadline's. Three helpers that only construct one error kind now name that kind instead of AnalysisError. * refactor(hopper): classify original-source read failures through the shared provider classifier (#10838) * test(ghidra): pin the aborted startup deadline over a producer cancellation * refactor(kuna): classify Kuna failures through the shared provider classifier (#10843) Session operations, the workspace copy and the project export report raw failures through classifyProviderFailure with Kuna dialects; the copy and export keep their cleanup report shapes through the readCleanup hook. * chore(guards): shrink the cancellation construction baseline after merging the base
This commit is contained in:
1 parent
15de1d4dce
commit
607a7872bb
17 files changed
+647
-373
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -67,7 +67,7 @@ const comparisonFailure = (
|
||||
input: NativeLoadImageAttestationInput,
|
||||
reason: string,
|
||||
cause?: unknown,
|
||||
): AnalysisError =>
|
||||
): ProviderAdapterError =>
|
||||
new ProviderAdapterError("ghidra", input.operation, {
|
||||
diagnostics: { reason },
|
||||
...(cause === undefined ? {} : { cause }),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<unknown>,
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
+30
-51
@@ -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<never> => {
|
||||
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<readonly string[]> => {
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
+67
-23
@@ -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<typeof selectedJebMcpTimeout>,
|
||||
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 {
|
||||
|
||||
+129
-48
@@ -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<RuntimeExhaustionArea | "unknown", string>
|
||||
> = {
|
||||
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<typeof failure>,
|
||||
diagnostics: Readonly<Record<string, JsonValue>>,
|
||||
): 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>((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",
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user