fix(kuna): name the setting that owns a timed-out Kuna run (#4343)

* fix(kuna): name the setting that owns a timed-out Kuna run

A Kuna run past its deadline surfaced the generic "Analysis took too
long. Try a smaller request" message. The request is already one
function or one whole-program scan, and the same run takes as long
again, so the advice was wrong and REA_KUNA_TIMEOUT_MS or
REA_KUNA_PROJECT_TIMEOUT_MS was never mentioned.

Each Kuna command now carries a KunaDeadline: the configured
milliseconds and the setting that owns them, from KunaConfiguration.
A timeout reports, for example, "kuna decompile did not finish within
REA_KUNA_TIMEOUT_MS (120000 ms)." Its remediation says to raise that
setting and restart REA, and for a single-function decompile that the
session's project export can still answer. AnalysisTimeoutError now
accepts a producer message and remediation, which projection uses and
per-waiter scan errors keep.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(kuna): scope the provider_timeout statement to direct Kuna runs

A timed-out project export is reported through the export failure as
capability_unavailable, not provider_timeout.

Refs #1957

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(kuna): verify native xrefs under the project deadline

The provider's xrefs scan runs under REA_KUNA_PROJECT_TIMEOUT_MS, but the
native oracle in verify:kuna aborted at REA_KUNA_TIMEOUT_MS. A scan that
fit the project deadline but not the function deadline failed
verification although the provider answered it. Use the same deadline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kaoru0822
2026-10-17 00:38:16 -07:00
committed by GitHub
parent 748ac7f0df
commit 9be493880c
16 changed files with 110 additions and 29 deletions
+2
View File
@@ -22,6 +22,8 @@ Select it explicitly with `--provider kuna`, MCP's `open_binary.provider_id: "ku
| `REA_KUNA_TIMEOUT_MS` | 120 s | Deadline for one single-function decompile, and for the inventory run REA makes when the export ends without one. |
| `REA_KUNA_JOBS` | 1 | `decompile-project --jobs`. 1 is Kuna's reference answer; more jobs finish sooner but lose callee-first prototypes, and change about 40–60% of function bodies on the measured binaries. |
A single-function decompile, inventory, string, graph or xref run that exceeds its deadline fails as `provider_timeout`, naming the setting that owns the deadline and that raising it takes a restart. A project export that exceeds `REA_KUNA_PROJECT_TIMEOUT_MS` is an export failure instead: requests that need the export report `capability_unavailable` with the export's own retry guidance.
Windows is not supported yet. Supported targets are ELF and thin Mach-O executables for x86-64, AArch64 and ARM; PE and universal Mach-O are rejected with a typed reason.
## What Kuna answers
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { realpath } from "node:fs/promises";
import { runKunaCommand } from "../../dist/kuna/KunaCommand.js";
import { kunaProjectDeadline } from "../../dist/kuna/KunaConfiguration.js";
import { inspectKunaInstallation } from "../../dist/kuna/KunaInstallation.js";
import { KUNA_XREF_OUTPUT_BYTES } from "../../dist/kuna/KunaReferenceFiles.js";
@@ -24,14 +25,15 @@ export const nativeKunaIncomingSites = async ({
);
assert.ok(installed.ok, "Native reference oracle could not inspect Kuna");
const input = await realpath(binary);
const timeoutMs = installed.value.configuration.functionTimeoutMs;
// Match the provider's own xrefs scan, which runs under the project deadline.
const deadline = kunaProjectDeadline(installed.value.configuration);
const outcome = await runKunaCommand({
installation: installed.value,
arguments: ["xrefs", input, "--to", address, "--json"],
workspace,
operation: "verify:kuna-native-xrefs",
timeoutMs,
reportedTimeoutMs: timeoutMs,
timeoutMs: deadline.ms,
deadline,
outputBytes: KUNA_XREF_OUTPUT_BYTES,
signal: new AbortController().signal,
retainFailedFileClose: (owner, resource) =>
+2 -1
View File
@@ -3,6 +3,7 @@ import { readdir, realpath } from "node:fs/promises";
import { join } from "node:path";
import { runKunaCommand } from "../../dist/kuna/KunaCommand.js";
import { kunaProjectDeadline } from "../../dist/kuna/KunaConfiguration.js";
import { inspectKunaInstallation } from "../../dist/kuna/KunaInstallation.js";
const entries = (path) =>
@@ -46,7 +47,7 @@ export const nativeKunaStrings = async ({
workspace,
operation: "verify:kuna-native-strings",
timeoutMs: installed.value.configuration.projectTimeoutMs,
reportedTimeoutMs: installed.value.configuration.projectTimeoutMs,
deadline: kunaProjectDeadline(installed.value.configuration),
outputBytes: 16 * 1024 * 1024,
signal: new AbortController().signal,
retainFailedFileClose: (owner, resource) =>
+17 -8
View File
@@ -33,6 +33,7 @@ import {
kunaChildEnvironment,
type KunaInstallation,
} from "./KunaInstallation.js";
import type { KunaDeadline } from "./KunaConfiguration.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
/**
@@ -54,9 +55,10 @@ export interface KunaCommandRequest {
readonly workspace: string;
/** Operation named by every failure this run reports. */
readonly operation: string;
/** Time left for this run; at most the configured deadline. */
readonly timeoutMs: number;
/** The deadline callers configured, reported when the run times out. */
readonly reportedTimeoutMs: number;
/** The configured deadline and its setting, reported when the run times out. */
readonly deadline: KunaDeadline;
/** Buffered stderr bytes, plus stdout bytes unless `stdout` consumes them. */
readonly outputBytes: number;
/** Parses stdout as it arrives instead of buffering it; see `runOwnedCommand`. */
@@ -302,12 +304,19 @@ const commandFailure = (
...details,
cause: cause.cancellation?.reason,
});
else if (cause.reason === "timeout")
primary = new AnalysisTimeoutError(
operation,
request.reportedTimeoutMs,
details,
);
else if (cause.reason === "timeout") {
const { setting, ms } = request.deadline;
primary = new AnalysisTimeoutError(operation, ms, {
...details,
userMessage: `kuna ${subcommand} did not finish within ${setting} (${String(ms)} ms).`,
// The same run takes as long again, so only a longer deadline helps.
remediationAction: `Raise ${setting} in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.${
subcommand === "decompile"
? " Within a session, pseudocode is also read from the project export once it finishes."
: ""
}`,
});
}
// The same input overflows the same fixed buffer; waiting cannot help.
else if (cause.reason === "output-limit")
primary = new AnalysisResourceConstraintError(
+22
View File
@@ -47,6 +47,28 @@ export interface KunaConfiguration {
readonly jobs: number;
}
/** One configured deadline and the setting that owns it. */
export interface KunaDeadline {
readonly setting: "REA_KUNA_TIMEOUT_MS" | "REA_KUNA_PROJECT_TIMEOUT_MS";
readonly ms: number;
}
/** Single-function decompiles and the standalone inventory. */
export const kunaFunctionDeadline = (
configuration: KunaConfiguration,
): KunaDeadline => ({
setting: "REA_KUNA_TIMEOUT_MS",
ms: configuration.functionTimeoutMs,
});
/** The project export and whole-program string, graph and xref scans. */
export const kunaProjectDeadline = (
configuration: KunaConfiguration,
): KunaDeadline => ({
setting: "REA_KUNA_PROJECT_TIMEOUT_MS",
ms: configuration.projectTimeoutMs,
});
/** Why the configured environment cannot select a Kuna installation. */
export interface KunaConfigurationFailure {
readonly code: "not_configured" | "open_options_invalid" | "unsupported_host";
+2 -1
View File
@@ -24,6 +24,7 @@ import {
runKunaCommand,
type KunaProcessLauncher,
} from "./KunaCommand.js";
import { kunaProjectDeadline } from "./KunaConfiguration.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import { KUNA_DIAGNOSTIC_BYTES } from "./KunaLimits.js";
import {
@@ -214,7 +215,7 @@ export const exportKunaProject = async (
workspace: request.workspace,
operation: KUNA_EXPORT_OPERATION,
timeoutMs: deadline - Date.now(),
reportedTimeoutMs: configuration.projectTimeoutMs,
deadline: kunaProjectDeadline(configuration),
outputBytes,
signal,
launcher: request.launcher,
+3 -2
View File
@@ -8,6 +8,7 @@ import type { FunctionDossier } from "../domain/hopperValues.js";
import type { RelationshipCoverage } from "../domain/relationshipCoverage.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { err, ok, type Result } from "../domain/result.js";
import { kunaProjectDeadline } from "./KunaConfiguration.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import {
KUNA_XREF_OUTPUT_BYTES,
@@ -120,7 +121,7 @@ export class KunaReferenceQueries {
"--functions",
"",
],
timeoutMs: installation.configuration.projectTimeoutMs,
deadline: kunaProjectDeadline(installation.configuration),
},
new KunaCallGraphReader(),
producerSignal,
@@ -253,7 +254,7 @@ export class KunaReferenceQueries {
installation,
operation: "xrefs",
arguments: (input) => ["xrefs", input, "--to", address, "--json"],
timeoutMs: installation.configuration.projectTimeoutMs,
deadline: kunaProjectDeadline(installation.configuration),
outputBytes: KUNA_XREF_OUTPUT_BYTES,
signal: producerSignal,
});
+3 -2
View File
@@ -39,6 +39,7 @@ import {
kunaFileCleanupError,
type KunaProcessLauncher,
} from "./KunaCommand.js";
import { kunaFunctionDeadline } from "./KunaConfiguration.js";
import {
createKunaFileCloseScope,
inspectKunaInstallation,
@@ -1117,7 +1118,7 @@ export class KunaSessionClient implements AnalysisClient {
installation,
arguments: (input) => ["decompile", input, address, "--addr"],
operation,
timeoutMs: installation.configuration.functionTimeoutMs,
deadline: kunaFunctionDeadline(installation.configuration),
outputBytes: KUNA_FUNCTION_TEXT_BYTES,
signal,
releaseRun: admitted,
@@ -1607,7 +1608,7 @@ export class KunaSessionClient implements AnalysisClient {
installation,
arguments: (input) => ["functions", input, "--json"],
operation: "list_procedures",
timeoutMs: installation.configuration.functionTimeoutMs,
deadline: kunaFunctionDeadline(installation.configuration),
outputBytes: KUNA_INVENTORY_OUTPUT_BYTES,
signal: this.#session.signal,
});
+2 -1
View File
@@ -11,6 +11,7 @@ import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"
import type { JsonValue } from "../domain/jsonValue.js";
import { err, ok, type Result } from "../domain/result.js";
import { normalizeKunaAddress } from "./KunaAddress.js";
import { kunaProjectDeadline } from "./KunaConfiguration.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js";
import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js";
@@ -148,7 +149,7 @@ export class KunaStringQueries {
"any",
"--no-xrefs",
],
timeoutMs: installation.configuration.projectTimeoutMs,
deadline: kunaProjectDeadline(installation.configuration),
},
new KunaStringInventoryReader(),
producerSignal,
+3 -2
View File
@@ -20,6 +20,7 @@ import {
runKunaCommand,
type KunaProcessLauncher,
} from "./KunaCommand.js";
import type { KunaDeadline } from "./KunaConfiguration.js";
import type { KunaInstallation } from "./KunaInstallation.js";
import { KUNA_PROVIDER_ID } from "./KunaRelease.js";
import type { KunaRunRelease } from "./KunaRunSlots.js";
@@ -51,7 +52,7 @@ export interface KunaTargetCommandRequest {
readonly outputBytes: number;
/** Parses stdout as it arrives instead of buffering it in the snapshot. */
readonly stdout?: Writable;
readonly timeoutMs: number;
readonly deadline: KunaDeadline;
readonly signal: AbortSignal;
/** Admission remains owned until the native process is proven stopped. */
readonly releaseRun?: KunaRunRelease;
@@ -119,7 +120,7 @@ export class KunaTargetCommands {
...options,
arguments: request.arguments(workspace.value.input),
workspace: workspace.value.root.path,
reportedTimeoutMs: request.timeoutMs,
timeoutMs: request.deadline.ms,
launcher: this.lifetime.launcher,
retainFailedFileClose: (owner, resource) => {
const lease = new AnalysisCleanupLease(
@@ -25,7 +25,7 @@ it("reports output beyond REA's fixed buffer as a deterministic memory bound", a
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 100,
signal: controller.signal,
});
@@ -24,7 +24,7 @@ const installation = await inspectKunaInstallation(
assert.equal(installation.ok, true);
const controller = new AbortController();
const commands = new KunaTargetCommands(fixture.target, { signal: controller.signal });
const request = () => commands.run({ installation: installation.value, operation: "retention-probe", arguments: input => ["functions", input, "--json"], timeoutMs: 2000, outputBytes: 16384, signal: controller.signal });
const request = () => commands.run({ installation: installation.value, operation: "retention-probe", arguments: input => ["functions", input, "--json"], deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 2000 }, outputBytes: 16384, signal: controller.signal });
try {
const capture = async () => {
const result = await request();
@@ -192,7 +192,7 @@ it.skipIf(process.platform === "win32")(
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 1024 * 1024,
signal: controller.signal,
});
@@ -274,7 +274,7 @@ it.skipIf(process.platform === "win32")(
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 1024 * 1024,
signal: controller.signal,
});
@@ -321,7 +321,7 @@ it.skipIf(process.platform === "win32")(
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 1024 * 1024,
signal: controller.signal,
});
@@ -372,7 +372,7 @@ it.skipIf(process.platform === "win32")(
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 1024 * 1024,
signal: controller.signal,
releaseRun: () => {
@@ -430,7 +430,7 @@ it.skipIf(process.platform === "win32")(
installation: installed.value,
operation: "list_procedures",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 1024 * 1024,
signal: controller.signal,
});
@@ -104,7 +104,7 @@ it("keeps an admission lease until retained native cleanup is proven", async ()
installation: installed.value,
operation: "lease-probe",
arguments: (input) => ["functions", input, "--json"],
timeoutMs: 10_000,
deadline: { setting: "REA_KUNA_TIMEOUT_MS", ms: 10_000 },
outputBytes: 16384,
signal: caller.signal,
releaseRun: lease!,
@@ -1,5 +1,7 @@
import { expect, it as test } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import {
fakeKunaCalls,
fakeKunaExits,
@@ -282,3 +284,31 @@ it("drops the least recently used text beyond the session's 16 MiB budget", asyn
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
it("names the setting that owns a single-function deadline", async () => {
const { open } = await session(
"rea-kuna-function-deadline-",
{ project: "hang", decompile: "hang" },
{ REA_KUNA_TIMEOUT_MS: "300" },
);
const client = open();
try {
const result = await client.execute("procedure_pseudo_code", {
procedure: "main",
});
if (result.ok) throw new Error("the hanging run answered");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "provider_timeout",
message:
"kuna decompile did not finish within REA_KUNA_TIMEOUT_MS (300 ms).",
remediation: {
action: expect.stringMatching(
/^Raise REA_KUNA_TIMEOUT_MS .* restart REA.*project export/u,
),
},
details: { timeout_ms: 300 },
});
} finally {
expect(await client.close()).toEqual({ ok: true, value: null });
}
});
@@ -417,8 +417,18 @@ it.each([
exit_code: 7,
stderr: expect.stringContaining("fixture string scan failed"),
});
if (mode === "hang")
if (mode === "hang") {
expect(result.error).toMatchObject({ timeoutMs: 500 });
// Each waiter keeps the producer's setting and recovery.
expect(projectAnalysisError(result.error)).toMatchObject({
message:
"kuna strings did not finish within REA_KUNA_PROJECT_TIMEOUT_MS (500 ms).",
remediation: {
action:
"Raise REA_KUNA_PROJECT_TIMEOUT_MS in the environment that starts REA, then restart REA; it reads Kuna settings when it starts.",
},
});
}
if (mode === "oversize") {
expect(projectAnalysisError(result.error)).toMatchObject({
code: "resource_constraint",