diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 67827ae8b1..9d653287d8 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -48,6 +48,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. + - The session keeps each completed single-function text, up to 16 MiB in all with the least recently used dropped first, and answers repeats from it until the export can. After a failed export that lasts for the session. - 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. diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index e8a44a7627..add5794952 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -100,6 +100,20 @@ interface SingleFunctionRun { waiters: number; } +/** + * Completed single-function texts a session keeps while it has no export + * reader. Each repeat would otherwise analyze the whole image again; after a + * failed export that holds for the rest of the session. + */ +const SINGLE_FUNCTION_TEXT_CACHE_BYTES = 16 * 1024 * 1024; +/** Charged per kept text beside its UTF-8 bytes. */ +const SINGLE_FUNCTION_TEXT_ENTRY_BYTES = 256; + +interface SingleFunctionText { + readonly text: string; + readonly bytes: number; +} + interface Answer { readonly result: JsonValue; readonly source: AnswerSource | null; @@ -213,6 +227,9 @@ export class KunaSessionClient implements AnalysisClient { readonly #runSlots: KunaRunSlots; /** Single-function runs in flight, by entry address. */ readonly #singleFunctionRuns = new Map(); + /** Completed single-function texts by address, least recently used first. */ + readonly #singleFunctionTexts = new Map(); + #singleFunctionTextBytes = 0; /** Owns background work: aborting it cancels this session's export interest. */ readonly #session = new AbortController(); /** Requests and shared readers retain their resources until their work settles. */ @@ -394,6 +411,7 @@ export class KunaSessionClient implements AnalysisClient { while (this.#active.size > 0) await Promise.allSettled(this.#active); this.#references.clear(); this.#strings.clear(); + this.#dropSingleFunctionTexts(); await this.#project; await this.#inventory; const failures: ProviderCleanupError[] = []; @@ -933,7 +951,8 @@ export class KunaSessionClient implements AnalysisClient { * Wait for this address's shared single-function run, starting it when none * is in flight. A caller's cancellation stops only its own wait while others * still wait; the last waiter stops the run and receives how it ended, - * including cleanup it could not prove. + * including cleanup it could not prove. A completed text answers repeats + * until the export can. */ #joinSingleFunction( operation: string, @@ -943,6 +962,12 @@ export class KunaSessionClient implements AnalysisClient { ): Promise { if (cancelled(signal)) return Promise.resolve(err(new AnalysisCancelledError(operation))); + const kept = this.#singleFunctionTexts.get(address); + if (kept !== undefined) { + this.#singleFunctionTexts.delete(address); + this.#singleFunctionTexts.set(address, kept); + return Promise.resolve(ok(kept.text)); + } let run = this.#singleFunctionRuns.get(address); if (run === undefined) { const controller = new AbortController(); @@ -959,9 +984,12 @@ export class KunaSessionClient implements AnalysisClient { ), }; this.#singleFunctionRuns.set(address, created); - void created.outcome.then(() => { - if (this.#singleFunctionRuns.get(address) === created) - this.#singleFunctionRuns.delete(address); + void created.outcome.then((outcome) => { + // A run whose waiters all left was already replaced or removed. + if (this.#singleFunctionRuns.get(address) !== created) return; + this.#singleFunctionRuns.delete(address); + if (outcome !== EXPORT_READY && outcome.ok) + this.#keepSingleFunctionText(address, outcome.value); }); run = created; } @@ -994,6 +1022,27 @@ export class KunaSessionClient implements AnalysisClient { }); } + /** Keep a completed text within budget, dropping the least recently used. */ + #keepSingleFunctionText(address: string, text: string): void { + // The export's text supersedes it; a closing session keeps nothing. + if (this.#reader !== undefined || this.#closing) return; + const bytes = Buffer.byteLength(text) + SINGLE_FUNCTION_TEXT_ENTRY_BYTES; + if (bytes > SINGLE_FUNCTION_TEXT_CACHE_BYTES) return; + this.#singleFunctionTexts.set(address, { text, bytes }); + this.#singleFunctionTextBytes += bytes; + while (this.#singleFunctionTextBytes > SINGLE_FUNCTION_TEXT_CACHE_BYTES) { + const oldest = this.#singleFunctionTexts.entries().next().value; + if (oldest === undefined) break; + this.#singleFunctionTexts.delete(oldest[0]); + this.#singleFunctionTextBytes -= oldest[1].bytes; + } + } + + #dropSingleFunctionTexts(): void { + this.#singleFunctionTexts.clear(); + this.#singleFunctionTextBytes = 0; + } + /** One `kuna decompile --addr` run, admitted by the provider's run slots. */ async #singleFunction( operation: string, @@ -1327,6 +1376,7 @@ export class KunaSessionClient implements AnalysisClient { #adopt(project: KunaProject): void { this.#reader = this.#readerFor(project); + this.#dropSingleFunctionTexts(); this.#releasePin ??= this.#cache.pin(project.key); } diff --git a/tests/boundary/providers/kuna/fakeKuna.ts b/tests/boundary/providers/kuna/fakeKuna.ts index 21f766e483..082abfe2e8 100644 --- a/tests/boundary/providers/kuna/fakeKuna.ts +++ b/tests/boundary/providers/kuna/fakeKuna.ts @@ -41,6 +41,8 @@ export interface FakeKunaBehavior { readonly decompile?: "ok" | "hang"; /** Milliseconds one `decompile --addr` run takes before printing. */ readonly decompileDelayMs?: number; + /** Extra comment bytes in each `decompile --addr` text, for retention budgets. */ + readonly decompilePadding?: number; /** Milliseconds `functions --json` takes before printing. */ readonly inventoryDelayMs?: number; /** Milliseconds the export takes before writing its files. */ @@ -160,7 +162,8 @@ if (args[0] === "--version") { if (behavior.decompile === "hang") hang(); else if (fn === undefined) { process.stderr.write("error: no function at " + args[2] + "\n"); process.exit(1); } else setTimeout(() => { - process.stdout.write("int " + fn.name + "(void) // single-function\n{\n return 0;\n}\n"); + const padding = behavior.decompilePadding === undefined ? "" : "/*" + "x".repeat(behavior.decompilePadding) + "*/\n"; + process.stdout.write("int " + fn.name + "(void) // single-function\n" + padding + "{\n return 0;\n}\n"); fs.appendFileSync(__filename + ".exits", JSON.stringify({ pid: process.pid, at: Date.now() }) + "\n"); }, behavior.decompileDelayMs ?? 0); } else { diff --git a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts index 33f02dd721..030f525ede 100644 --- a/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts +++ b/tests/boundary/providers/kuna/kunaSingleFunctionRuns.test.ts @@ -186,3 +186,99 @@ it("keeps the slot of a run whose stop is unproven until close proves it", async await client.close(); } }); + +it("answers a repeated request from the completed run while the export builds", async () => { + const { kuna, open } = await session("rea-kuna-single-kept-", { + project: "hang", + }); + const client = open(); + try { + const first = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + // A repeat by name or by address reuses the text; no run re-analyzes. + for (const procedure of ["main", "0x1000"]) + expect( + await run(client, "procedure_pseudo_code", { procedure }), + ).toMatchObject({ + result: first.result, + rawResult: { source: { kind: "single-function" } }, + }); + expect(await decompilesOf(kuna)).toHaveLength(1); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + +it("keeps answering repeats from the completed run after the export fails", async () => { + const { kuna, open } = await session("rea-kuna-single-kept-failed-", { + project: "exit-failure", + }); + const client = open(); + try { + expect( + await client.execute("procedure_assembly", { procedure: "main" }), + ).toMatchObject({ ok: false, error: { retryable: false } }); + for (let repeat = 0; repeat < 2; repeat += 1) { + const answer = await run(client, "procedure_pseudo_code", { + procedure: "main", + }); + expect(answer.rawResult).toMatchObject({ + source: { kind: "single-function" }, + }); + expect(answer.limitations).toContainEqual( + expect.stringContaining("The project export failed"), + ); + } + expect(await decompilesOf(kuna)).toHaveLength(1); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + +it("does not keep a failed run, so a repeat runs again", async () => { + const { kuna, open } = await session( + "rea-kuna-single-failed-not-kept-", + { project: "hang", decompile: "hang" }, + { REA_KUNA_TIMEOUT_MS: "200" }, + ); + const client = open(); + try { + for (let repeat = 0; repeat < 2; repeat += 1) + expect( + await client.execute("procedure_pseudo_code", { procedure: "main" }), + ).toMatchObject({ ok: false }); + expect(await decompilesOf(kuna)).toHaveLength(2); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); + +it("drops the least recently used text beyond the session's 16 MiB budget", async () => { + // Two 6 MiB texts fit the budget and a third does not. Reading main again + // makes parse the least recently used, so keeping broken drops parse. + const { kuna, open } = await session("rea-kuna-single-kept-budget-", { + project: "hang", + decompilePadding: 6 * 1024 * 1024, + }); + const client = open(); + try { + for (const procedure of [ + "main", + "parse", + "main", + "broken", + "main", + "parse", + ]) + await run(client, "procedure_pseudo_code", { procedure }); + expect((await decompilesOf(kuna)).map(({ argv }) => argv[2])).toEqual([ + "0x1000", + "0x1040", + "0x1080", + "0x1040", + ]); + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +});