diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index d7b909052f..1fe675432a 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -47,7 +47,7 @@ The first text or assembly request in a session looks for this target's export i - REA starts `kuna decompile-project` in the background on a private copy of the target. - Meanwhile, pseudocode comes from one `kuna decompile --addr` run. Name and address lookups, and listings, use the inventory the export takes with `kuna functions --json` before it decompiles. The session runs `kuna functions` itself, under `REA_KUNA_TIMEOUT_MS`, only when no export is building (an inventory-only request does not start one) or the export ends without an inventory. - Requests for the same function share one run; it stops when its last waiting request is cancelled. Each run analyzes the whole image again in its own process, so one REA process runs at most as many at once as the host has cores beyond `REA_KUNA_JOBS` (at least one). Further requests wait in order, and a waiting request is answered from the export if it finishes first. A run whose stop REA cannot prove keeps its slot until `close_binary` proves it stopped. - - Assembly and data names wait for the export; progress notifications report the wait every ten seconds. + - Assembly and data names wait for the export; progress notifications report the wait every ten seconds. A session does not rebuild a failed export: requests that need it fail with `retryable: false`, the export's failure, its captured Kuna output and what to change before reopening the target. A setting such as `REA_KUNA_PROJECT_TIMEOUT_MS` or `REA_KUNA_JOBS` is read when REA starts, so changing it also needs a restart. Pseudocode keeps using single-function runs. - A single-function answer says so in its limitations. It lacks the export's whole-program prototypes, so a later request can return different text for the same function. Pseudocode, assembly, `analyze_function` and data-name answers record where their text came from in `raw_result.source`: `single-function`, or `project-export` with the cache directory. `procedure_pseudo_code` and `procedure_assembly` answers from the export also name the file, byte span and the file's SHA-256. Combined `analyze_function` answers retain both facet records in `raw_result.source.files`, each with its file, digest and byte span. Both texts come from one export; losing that project retries the complete report. Listings and procedure lookups carry no `raw_result`. Agents can open or search the export's `.c` and `.asm` there directly when that is cheaper than calling a tool per function. Text read that way is target-derived, like any decompiler output, but it bypasses the untrusted-content marker that REA's tool results carry. diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 60f07e213a..61545d660f 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -12,6 +12,8 @@ import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, AnalysisInputError, + AnalysisResourceConstraintError, + AnalysisTimeoutError, } from "../domain/analysisErrorCore.js"; import type { AnalysisProfileCommitment } from "../domain/analysisProfile.js"; import { @@ -23,6 +25,7 @@ import type { EvidenceLocation } from "../domain/evidence.js"; import { combineFunctionRelationshipCoverage } from "../domain/functionCallCoverage.js"; import { functionDossierSchema } from "../domain/hopperValues.js"; import type { JsonValue } from "../domain/jsonValue.js"; +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"; @@ -140,6 +143,51 @@ const failure = (operation: string, cause: unknown): AnalysisError => userMessage: `Kuna ${operation} failed: ${message(cause)}`, }); +/** + * How a request that needs the export reports the export's failure. A session + * never rebuilds a failed export, so repeating the request cannot succeed; the + * error says what to change before reopening the target. Kuna settings are read + * when REA starts, so changing one also needs a restart. Cancellation and + * unproven cleanup keep their own errors, which callers must still observe. + */ +const exportUnavailable = ( + operation: string, + failure: AnalysisError, +): AnalysisError => { + if (failure instanceof AnalysisCancelledError || failure.cleanupIncomplete) + return failure; + const reason = failure.userMessage ?? failure.message; + const exported = projectAnalysisError(failure); + const restart = + "REA reads Kuna settings when it starts, so restart REA, then reopen the target."; + const next = + failure instanceof AnalysisTimeoutError + ? `To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above ${String(failure.timeoutMs)} ms or raise REA_KUNA_JOBS in the environment that starts REA. ${restart}` + : failure instanceof AnalysisResourceConstraintError && + failure.remediationAction !== undefined + ? `${failure.remediationAction.replace(/\.?$/u, ".")} ${restart}` + : "To retry, resolve the reported failure, then reopen the target."; + return new AnalysisCapabilityUnavailableError( + KUNA_PROVIDER_ID, + operation, + reason, + { + cause: failure, + // Callers see only the top-level projection, so the export failure's + // own projected code and details (its limits, target path, captured + // output and diagnostics) travel whole rather than field by field. + diagnostics: { + export_error: failure._tag, + export_code: exported.code, + ...(exported.details === undefined + ? {} + : { export_details: exported.details }), + }, + userMessage: `Kuna's project export failed, so ${operation} is unavailable in this session: ${reason.replace(/\.?$/u, ".")} ${next}`, + }, + ); +}; + /** Kuna names a procedure by its inventory entry or by an exact entry address. */ const procedureSelectorSchema = z.string().min(1); @@ -1286,7 +1334,7 @@ export class KunaSessionClient implements AnalysisClient { }, WAIT_PROGRESS_INTERVAL_MS); try { const built = await waitFor(project, options.signal, operation); - if (!built.ok) return built; + if (!built.ok) return err(exportUnavailable(operation, built.error)); return ok(this.#readerFor(built.value)); } finally { clearInterval(timer); diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 160a0b15f6..ecd7c1051d 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -15,6 +15,9 @@ import { expect, it as test, vi } from "vitest"; import type { BinaryTarget } from "../../../../src/domain/binaryTargetTypes.js"; import type { JsonValue } from "../../../../src/domain/jsonValue.js"; import type { KunaProcessLauncher } from "../../../../src/kuna/KunaCommand.js"; +import type { AnalysisError } from "../../../../src/domain/analysisErrorBase.js"; +import { AnalysisResourceConstraintError } from "../../../../src/domain/analysisErrorCore.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; import { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js"; import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; @@ -754,6 +757,85 @@ it("reports a failed export as failed rather than building", async () => { await client.close(); }); +it.each([ + { + behavior: { project: "hang" } as const, + environment: { REA_KUNA_PROJECT_TIMEOUT_MS: "300" }, + cause: "AnalysisTimeoutError", + next: "To retry, set REA_KUNA_PROJECT_TIMEOUT_MS above 300 ms or raise REA_KUNA_JOBS in the environment that starts REA. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + }, + { + behavior: { project: "exit-failure" } as const, + environment: {}, + cause: "ProviderAdapterError", + next: "To retry, resolve the reported failure, then reopen the target.", + }, + { + behavior: { asmPadding: 2 * 1024 ** 2 }, + environment: { REA_KUNA_CACHE_MAX_BYTES: String(1024 ** 2) }, + cause: "AnalysisResourceConstraintError", + next: "Raise REA_KUNA_CACHE_MAX_BYTES above the export size, or analyze this target with another provider. REA reads Kuna settings when it starts, so restart REA, then reopen the target.", + }, +])( + "reports a failed export as not retryable in its session ($cause)", + async ({ behavior, environment, cause, next }) => { + const { open } = await session( + "rea-kuna-export-unavailable-", + behavior, + environment, + ); + const client = open(); + try { + for (const operation of [ + "procedure_assembly", + "analyze_function", + ] as const) { + const result = await client.execute(operation, { procedure: "main" }); + if (result.ok) throw new Error(`${operation} unexpectedly succeeded`); + expect(result.error).toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + retryable: false, + }); + expect(result.error.userMessage?.endsWith(next)).toBe(true); + const failure = result.error.cause as AnalysisError; + expect(failure._tag).toBe(cause); + // Callers see only the top-level projection; the export failure's own + // projected code and details travel whole inside it. + const exported = projectAnalysisError(failure); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "capability_unavailable", + retryable: false, + details: { + diagnostics: { + export_error: cause, + export_code: exported.code, + export_details: exported.details, + }, + }, + }); + expect( + failure instanceof AnalysisResourceConstraintError + ? exported.details + : exported.details?.["captured_output"], + ).toMatchObject( + failure instanceof AnalysisResourceConstraintError + ? { + resource: "capacity", + reported_limits: { + cache_max_bytes: 1024 ** 2, + export_bytes: expect.any(Number), + }, + } + : { truncated: expect.any(Boolean) }, + ); + } + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } + }, +); + it("lets one caller stop waiting for the shared inventory without failing another", async () => { const { open } = await session("rea-kuna-shared-inventory-", { inventoryDelayMs: 700,