diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index b6b4dd79bc..d4f9eee85d 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -40,7 +40,7 @@ Cross-references, callers, callees and basic blocks are not available from this The first analysis request in a session looks for this target's export in the cache. `list_documents` reads the selected path without initializing analysis. The export is keyed by the target's SHA-256, the Kuna executable's SHA-256, its version and `--jobs`. -- **Warm cache:** analysis reads reuse the verified export. A fresh server verifies its file digests; that cost scales with export size. +- **Warm cache:** analysis reads reuse the verified export. A fresh server verifies its file digests once, even for concurrent first requests; that cost scales with export size. - **Cold cache:** - REA starts `kuna decompile-project` in the background on a private copy of the target. - Meanwhile, pseudocode comes from one `kuna decompile --addr` run, and listings from `kuna functions --json`. diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index d62d039b94..d9f53d2e95 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -193,6 +193,8 @@ export class KunaSessionClient implements AnalysisClient { | Promise> | undefined; #project: Promise> | undefined; + /** The cache lookup that decides #project, shared by concurrent first requests. */ + #starting: Promise | undefined; #reader: KunaProjectReader | undefined; #inventoryIndex: InventoryIndex | undefined; #status: KunaExportStatus | undefined; @@ -998,8 +1000,17 @@ export class KunaSessionClient implements AnalysisClient { } /** Reuse a verified export, or start building one in the background. */ - async #startProject(installation: KunaInstallation): Promise { - if (this.#project !== undefined) return; + #startProject(installation: KunaInstallation): Promise { + if (this.#project !== undefined) return Promise.resolve(); + // Concurrent first requests share one lookup, which verifies every + // export file's digest on a fresh server. + this.#starting ??= this.#lookupOrBuild(installation).finally(() => { + this.#starting = undefined; + }); + return this.#starting; + } + + async #lookupOrBuild(installation: KunaInstallation): Promise { const key = kunaProjectKey({ targetSha256: this.#target.sha256, kunaSha256: installation.sha256, diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index 9c27530c24..c4d87290ff 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -10,13 +10,14 @@ import { } from "node:fs/promises"; import { join } from "node:path"; -import { expect, it as test } from "vitest"; +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 { KunaProvider } from "../../../../src/kuna/KunaProvider.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "../../../../src/kuna/KunaLimits.js"; +import { KunaProjectCache } from "../../../../src/kuna/KunaProjectCache.js"; import { cleanupOwnedProcessGroup } from "../../../../src/process/ProcessOwnership.js"; import { spawnOwnedProviderProcess } from "../../../../src/process/ProviderProcess.js"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; @@ -186,6 +187,36 @@ it("answers a cold request from one function, then serves the finished export", await again.close(); }); +it("verifies a warm export once for concurrent first requests on a fresh server", async () => { + const { kuna, target, cacheRoot, profile, open } = await session( + "rea-kuna-warm-concurrent-", + ); + const builder = open(); + await run(builder, "procedure_assembly", { procedure: "main" }); + expect(await builder.close()).toEqual({ ok: true, value: null }); + + const lookup = vi.spyOn(KunaProjectCache.prototype, "lookup"); + const fresh = new KunaProvider( + { REA_KUNA_COMMAND: kuna, REA_KUNA_CACHE_DIR: cacheRoot }, + { platform: process.platform }, + ).createClient(target, profile); + try { + const answers = await Promise.all( + ["main", "parse", "0x1000", "main"].map((procedure) => + run(fresh, "procedure_pseudo_code", { procedure }), + ), + ); + for (const answer of answers) + expect(answer.rawResult).toMatchObject({ + source: { kind: "project-export" }, + }); + expect(lookup).toHaveBeenCalledTimes(1); + } finally { + lookup.mockRestore(); + expect(await fresh.close()).toEqual({ ok: true, value: null }); + } +}); + it("lists, searches and resolves procedures from Kuna's inventory", async () => { const { open } = await session("rea-kuna-inventory-"); const client = open();