From 2aa5ba836d8ec752c5949f2f59fead6c830afdbc Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 11 Nov 2026 06:22:17 -0500 Subject: [PATCH] fix(kuna): retain failed inventory admission as result --- src/kuna/KunaSessionClient.ts | 32 +++++-- .../kuna/kunaDeadlineRecovery.test.ts | 86 +++++++++++++++++++ 2 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 tests/boundary/providers/kuna/kunaDeadlineRecovery.test.ts diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 591f8483b0..92d469e58b 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -24,6 +24,7 @@ import { type RetainedResourceScope, } from "../application/AnalysisProvider.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; +import { ConfigurationError } from "../domain/configurationErrors.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, @@ -2405,10 +2406,18 @@ export class KunaSessionClient implements AnalysisClient { // The session owns the shared run; each caller only stops waiting. const run = this.#listInventory(installation); this.#inventory = run; - void run.then((listed) => { - // A failed listing is retried by the next request. - if (!listed.ok && this.#inventory === run) this.#inventory = undefined; - }); + void run.then( + (listed) => { + // A failed listing is retried by the next request. + if (!listed.ok && this.#inventory === run) + this.#inventory = undefined; + }, + () => { + // Unexpected rejections remain visible to waiters but cannot poison + // the shared cache or create a second unhandled rejection. + if (this.#inventory === run) this.#inventory = undefined; + }, + ); } return waitFor(this.#inventory, options.signal, "list_procedures"); } @@ -2427,11 +2436,24 @@ export class KunaSessionClient implements AnalysisClient { if (this.#reader !== undefined) return ok(this.#reader.project.inventory); const exported = await this.#exportInventory; if (exported !== undefined) return ok(exported); + if (this.#session.signal.aborted) + return err( + new AnalysisCancelledError("list_procedures", { + cause: this.#session.signal.reason, + }), + ); + let deadline: KunaDeadline; + try { + deadline = kunaFunctionDeadline(installation.configuration); + } catch (cause: unknown) { + if (cause instanceof ConfigurationError) return err(cause); + throw cause; + } const run = await this.#commands.run({ installation, arguments: (input) => ["functions", input, "--json"], operation: "list_procedures", - deadline: kunaFunctionDeadline(installation.configuration), + deadline, outputBytes: KUNA_INVENTORY_OUTPUT_BYTES, signal: this.#session.signal, }); diff --git a/tests/boundary/providers/kuna/kunaDeadlineRecovery.test.ts b/tests/boundary/providers/kuna/kunaDeadlineRecovery.test.ts new file mode 100644 index 0000000000..464a74e967 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaDeadlineRecovery.test.ts @@ -0,0 +1,86 @@ +import { expect, it as test } from "vitest"; +import { runWithAnalysisOperationSnapshot } from "../../../../src/domain/analysisOperationSnapshot.js"; +import { session } from "./kunaSessionFixture.js"; + +const it = test.skipIf(process.platform === "win32"); + +it("retries inventory after a reset exposes an invalid function deadline", async () => { + const snapshot = { + revision: 1, + overrides: { "kuna.function_timeout_ms": 1000 }, + } as const; + const fixture = await runWithAnalysisOperationSnapshot(snapshot, () => + session( + "rea-kuna-deadline-inventory-", + {}, + { REA_KUNA_TIMEOUT_MS: "invalid" }, + ), + ); + const client = runWithAnalysisOperationSnapshot(snapshot, () => + fixture.open(), + ); + try { + const ready = await runWithAnalysisOperationSnapshot(snapshot, () => + client.execute("health", {}), + ); + expect(ready.ok).toBe(true); + const refused = await client.execute("list_procedures", {}); + expect(refused).toMatchObject({ + ok: false, + error: { _tag: "ConfigurationError" }, + }); + const retried = await runWithAnalysisOperationSnapshot(snapshot, () => + client.execute( + "list_procedures", + {}, + { signal: AbortSignal.timeout(3000) }, + ), + ); + expect(retried).toMatchObject({ ok: true }); + } finally { + await client.close(); + } +}); + +it.each([ + ["list_strings", {}], + ["procedure_callers", { procedure: "main" }], + ["xrefs", { address: "0x1000" }], +] as const)( + "retries %s after a reset exposes an invalid project deadline", + async (operation, parameters) => { + const snapshot = { + revision: 1, + overrides: { "kuna.project_timeout_ms": 1000 }, + } as const; + const fixture = await runWithAnalysisOperationSnapshot(snapshot, () => + session( + "rea-kuna-deadline-query-", + {}, + { REA_KUNA_PROJECT_TIMEOUT_MS: "invalid" }, + ), + ); + const client = runWithAnalysisOperationSnapshot(snapshot, () => + fixture.open(), + ); + try { + const ready = await runWithAnalysisOperationSnapshot(snapshot, () => + client.execute("health", {}), + ); + expect(ready.ok).toBe(true); + const refused = await client.execute(operation, parameters); + expect(refused).toMatchObject({ + ok: false, + error: { _tag: "ConfigurationError" }, + }); + const retried = await runWithAnalysisOperationSnapshot(snapshot, () => + client.execute(operation, parameters, { + signal: AbortSignal.timeout(3000), + }), + ); + expect(retried).toMatchObject({ ok: true }); + } finally { + await client.close(); + } + }, +);