fix(kuna): attribute inventory failures to callers

Normalize shared installation and inventory failures at the session boundary, retaining producer operation metadata. Preserve exact cancellation reasons before admission and while waiting without adopting foreign cleanup authority.

Refs: #4689
This commit is contained in:
morluto committed 2026-10-17 14:13:25 +08:00
1 parent 6eb55be4e8
commit 54c71313ae
4 files changed
+145 -14

No files matched your search

+6
View File
@@ -82,6 +82,12 @@ REA parses the graph as Kuna writes it, like the string scan, so the graph has n
Evidence includes only selected addresses or dossier neighbors, the producing JSON's SHA-256/byte count, coverage and relevant function facts; unrequested program-wide JSON and instruction text are omitted. Reference caches are in memory and expire on close: a fresh CLI process or MCP server rebuilds them. A first call query therefore costs a whole-program reference walk even when a C export is warm. These operations do not establish full Ghidra/IDA feature parity.
Failures from shared installation or inventory work identify the operation the
caller requested. `diagnostics.producer_operation` retains the internal producer
name when it differs. Caller-specific failures keep the producer's typed outcome,
captured output, partial facts and live cleanup dependencies; cleanup retry
updates every projection rather than preserving an independent stale marker.
## Provenance and limitations
- Addresses and bytes are Kuna's observations of the image. Procedure entries and extents are its derivations: extents are upper bounds clipped at the next entry, and Kuna can split one function at an entry its own analysis infers. Pseudocode is its reconstruction, never original source.
+10 -13
View File
@@ -79,7 +79,7 @@ import {
} from "../filesystem/OwnedRegularFile.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
import { kunaQueryFailureForCaller } from "./KunaQueryErrors.js";
/** How often a request waiting on the project export reports that it still waits. */
const WAIT_PROGRESS_INTERVAL_MS = 10_000;
@@ -146,7 +146,7 @@ const waitFor = async <T>(
): Promise<Result<T, AnalysisError>> => {
const result = await waitForAbortable(work, signal);
return result === ABORTED
? err(new AnalysisCancelledError(operation))
? err(new AnalysisCancelledError(operation, { cause: signal?.reason }))
: result;
};
@@ -332,7 +332,11 @@ export class KunaSessionClient implements AnalysisClient {
options: ExecutionOptions,
): Promise<Result<AnalysisExecution, AnalysisError>> {
if (cancelled(options.signal))
return err(new AnalysisCancelledError(operation));
return err(
new AnalysisCancelledError(operation, {
cause: options.signal?.reason,
}),
);
if (operation !== "health" && !isKunaOperation(operation))
return err(
kunaUnavailable(
@@ -360,15 +364,7 @@ export class KunaSessionClient implements AnalysisClient {
);
const installation = await this.#ensureInstallation();
if (!installation.ok)
return operation === "health"
? installation
: err(
analysisClientFailureForOperation(
installation.error,
KUNA_PROVIDER_ID,
operation,
),
);
return err(kunaQueryFailureForCaller(installation.error, operation));
try {
if (operation === "health")
return ok(
@@ -398,7 +394,8 @@ export class KunaSessionClient implements AnalysisClient {
installation.value,
options,
);
if (!answer.ok) return answer;
if (!answer.ok)
return err(kunaQueryFailureForCaller(answer.error, operation));
if (cancelled(options.signal))
return err(new AnalysisCancelledError(operation));
return ok(
@@ -36,7 +36,10 @@ it
operation,
});
expect(result.error._tag).toBe(health.error._tag);
expect(result.error.diagnostics).toEqual(health.error.diagnostics);
expect(result.error.diagnostics).toEqual({
...health.error.diagnostics,
producer_operation: "health",
});
expect(result.error.cause).toBe(health.error);
expect(projectAnalysisError(result.error).retryable).toBe(
projectAnalysisError(health.error).retryable,
@@ -0,0 +1,125 @@
import { expect, it } from "vitest";
import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js";
import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js";
import { fakeKunaCalls } from "./fakeKuna.js";
import { session } from "./kunaSessionFixture.js";
it
.skipIf(process.platform === "win32")
.each([
null,
"caller stopped",
new ProviderCleanupError("foreign", ["foreign-owner"], {}),
])(
"retains the exact pre-admission reason without acquiring another installation: %j",
async (reason) => {
const fixture = await session("rea-kuna-inventory-pre-abort-");
const client = fixture.open();
const controller = new AbortController();
controller.abort(reason);
try {
const result = await client.execute(
"search_procedures",
{ pattern: "main", mode: "literal" },
{ signal: controller.signal },
);
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(false);
expect(
(await fakeKunaCalls(fixture.kuna)).map(({ argv }) => argv[0]),
).toEqual(["--version"]);
} finally {
expect((await client.close()).ok).toBe(true);
}
},
);
it.skipIf(process.platform === "win32").each([
{ inventory: "not-json" as const, expected: "execution_failure" },
{ inventory: "hang" as const, expected: "provider_timeout" },
{ inventory: "ok" as const, expected: "success" },
])(
"keeps search identity for a completed $inventory callable inventory",
async ({ inventory, expected }) => {
const fixture = await session(
"rea-kuna-inventory-attribution-",
{ inventory },
{ REA_KUNA_TIMEOUT_MS: "1000" },
);
const client = fixture.open();
try {
const result = await client.execute("search_procedures", {
pattern: "main",
mode: "literal",
});
if (expected === "success") {
if (!result.ok) throw result.error;
expect(result.value.result).toEqual([
{ address: "0x1000", value: "main" },
]);
} else {
if (result.ok) throw new Error("Expected inventory failure");
expect(projectAnalysisError(result.error)).toMatchObject({
code: expected,
details: {
operation: "search_procedures",
diagnostics: { producer_operation: "list_procedures" },
},
});
}
} finally {
expect((await client.close()).ok).toBe(true);
}
},
);
it.skipIf(process.platform === "win32")(
"cancels one search waiter with its exact reason while retaining its peer's shared inventory",
async () => {
const fixture = await session("rea-kuna-inventory-waiter-", {
inventory: "hang",
});
const client = fixture.open();
const controller = new AbortController();
const reason = new ProviderCleanupError("foreign", ["foreign-owner"], {});
const search = client.execute(
"search_procedures",
{ pattern: "main", mode: "literal" },
{ signal: controller.signal },
);
const peer = client.execute("list_procedures", {});
let peerSettled = false;
void peer.then(() => {
peerSettled = true;
});
try {
await expect
.poll(async () =>
(await fakeKunaCalls(fixture.kuna)).some(
({ argv }) => argv[0] === "functions",
),
)
.toBe(true);
controller.abort(reason);
const result = await search;
if (result.ok) throw new Error("Expected cancellation");
expect(result.error.cause).toBe(reason);
expect(result.error.cleanupIncomplete).toBe(false);
expect(projectAnalysisError(result.error)).toMatchObject({
code: "cancelled",
details: { operation: "search_procedures" },
});
expect(peerSettled).toBe(false);
expect(
(await fakeKunaCalls(fixture.kuna)).filter(
({ argv }) => argv[0] === "functions",
),
).toHaveLength(1);
} finally {
expect((await client.close()).ok).toBe(true);
await Promise.all([search, peer]);
}
expect(reason.cleanupIncomplete).toBe(true);
},
);