diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 36c844b8c9..6fee2d9ad8 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -125,7 +125,7 @@ REA writes Kuna's exports only inside `REA_KUNA_CACHE_DIR`: - `trash/` holds retired builds and evicted exports awaiting removal. - `home/` is Kuna's HOME. -Builds publish by one rename, so a reader sees a complete export or none. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it. +Builds publish by one rename, so a reader sees a complete export or none. If later staging retirement fails, the typed cleanup error retains the completed export's key, directory and manifest in `published_export`. The session adopts that verified project; later queries reuse its immutable files while `project_export.cleanup_incomplete` reports the retained owner's live state. `close_binary` retries only the remaining cleanup, and a successful retry resolves prior cleanup errors. A failed export followed by failed staging cleanup retains both failures; it does not invent a publication receipt. A session keeps the export it uses from eviction only within its own REA process. If another REA process sharing the cache evicts it, the session rebuilds it; meanwhile pseudocode falls back to single-function runs. A damaged or edited export is removed and rebuilt. Staging moves to `trash/` for removal only after REA proves its Kuna process stopped. Staging of a process it cannot prove stopped is never swept; REA keeps it owned, retries the stop on `close_binary`, and reports it until the stop is proven. Failed file removal also stays bound to its export and is retried by `close_binary`; closing does not report success while that cleanup remains. `rea doctor --provider kuna` reports the cache directory without creating it. ## Verify with a real Kuna diff --git a/src/kuna/KunaProjectCache.ts b/src/kuna/KunaProjectCache.ts index 99b4cfbdc9..d25002865a 100644 --- a/src/kuna/KunaProjectCache.ts +++ b/src/kuna/KunaProjectCache.ts @@ -16,6 +16,7 @@ import writeFileAtomic from "write-file-atomic"; import { z } from "zod"; import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, @@ -129,6 +130,35 @@ export interface KunaProject { readonly inventory: readonly KunaInventoryFunction[]; } +/** Publication succeeded; only its stopped staging still needs cleanup. */ +export class KunaPublishedProjectCleanupError extends ProviderCleanupError { + constructor( + readonly project: KunaProject, + cleanup: ProviderCleanupError, + ) { + super( + KUNA_PROVIDER_ID, + cleanup.cleanupResources, + { + ...cleanup.diagnostics, + published_export: { + kind: "project-export", + key: project.key, + directory: project.directory, + manifest: project.manifest, + }, + }, + { + operation: KUNA_EXPORT_OPERATION, + cause: cleanup, + remediationAction: + "The immutable project export is published and remains available. Retry only the retained staging cleanup with close_binary; do not rerun the export to recover it.", + }, + ); + this.bindCleanupDependencies([cleanup]); + } +} + /** One request to produce or reuse the export for a target. */ export interface KunaProjectRequest { readonly installation: KunaInstallation; @@ -776,7 +806,16 @@ export class KunaProjectCache { // Only a proven-stopped build may enter trash. A failed write, crash or // lost cleanup identity therefore never makes live staging reclaimable. const retired = await this.#retireStaging(staging, key); - if (retired !== undefined) return err(retired); + if (retired !== undefined) + return err( + built.result.ok + ? new KunaPublishedProjectCleanupError(built.result.value, retired) + : analysisErrorWithCleanupFailure( + built.result.error, + retired, + KUNA_EXPORT_OPERATION, + ).bindCleanupDependencies([built.result.error, retired]), + ); if (built.result.ok) // The entry is published either way; a failed eviction retries on the next publish. await this.#evict(key).catch(() => undefined); diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 4be16c916a..050c33fca2 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -53,6 +53,7 @@ import { } from "./KunaInstallation.js"; import { kunaProjectKey, + KunaPublishedProjectCleanupError, type KunaProject, type KunaProjectCache, } from "./KunaProjectCache.js"; @@ -316,6 +317,8 @@ export class KunaSessionClient implements AnalysisClient { #status: KunaExportStatus | undefined; #key: string | undefined; #exportFailure: string | undefined; + /** Completed publication and its independently retained staging cleanup owner. */ + #publicationCleanup: KunaPublishedProjectCleanupError | undefined; #releasePin: (() => void) | undefined; /** Set once close starts; new requests are refused from then on. */ #closing = false; @@ -623,7 +626,13 @@ export class KunaSessionClient implements AnalysisClient { /** Where the background export stands, for callers deciding whether to wait. */ #exportState(): JsonValue { if (this.#reader !== undefined) - return { state: "ready", key: this.#reader.project.key }; + return { + state: "ready", + key: this.#reader.project.key, + ...(this.#publicationCleanup === undefined + ? {} + : { cleanup_incomplete: this.#publicationCleanup.cleanupIncomplete }), + }; if (this.#project === undefined) return { state: "not-started" }; if (this.#exportFailure !== undefined) return { state: "failed", reason: this.#exportFailure }; @@ -1197,7 +1206,9 @@ export class KunaSessionClient implements AnalysisClient { project === undefined ? undefined : project.then((built) => - built.ok && this.#reader !== undefined + (built.ok || + built.error instanceof KunaPublishedProjectCleanupError) && + this.#reader !== undefined ? EXPORT_READY : new Promise(() => undefined), ); @@ -1548,9 +1559,20 @@ export class KunaSessionClient implements AnalysisClient { }, }) .then((result) => { - listed(result.ok ? result.value.inventory : undefined); - if (result.ok && !this.#closing) this.#adopt(result.value); - else if (!result.ok && !this.#closing) + const published = result.ok + ? result.value + : result.error instanceof KunaPublishedProjectCleanupError + ? result.error.project + : undefined; + listed(published?.inventory); + if (published !== undefined && !this.#closing) { + this.#adopt(published); + if ( + !result.ok && + result.error instanceof KunaPublishedProjectCleanupError + ) + this.#publicationCleanup = result.error; + } else if (!result.ok && !this.#closing) this.#exportFailure = result.error.userMessage ?? result.error.message; return result; @@ -1728,7 +1750,12 @@ export class KunaSessionClient implements AnalysisClient { this.#project, Promise.resolve(undefined), ]); - if (settled === undefined || settled.ok) return undefined; + if ( + settled === undefined || + settled.ok || + settled.error instanceof KunaPublishedProjectCleanupError + ) + return undefined; return settled.error.userMessage ?? settled.error.message; } diff --git a/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts b/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts new file mode 100644 index 0000000000..ffbaae6f48 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaPublicationCleanup.test.ts @@ -0,0 +1,184 @@ +import { createHash } from "node:crypto"; +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, expect, it as test, vi } from "vitest"; +import { + KunaProjectCache, + KunaPublishedProjectCleanupError, +} from "../../../../src/kuna/KunaProjectCache.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { inspectKunaInstallationOwned } from "./kunaResourceScope.js"; +import { exportsOf, run, session } from "./kunaSessionFixture.js"; +import { + fakeKunaCalls, + writeFakeKuna, + type FakeKunaBehavior, +} from "./fakeKuna.js"; + +const fault = vi.hoisted(() => ({ root: "", blocked: false })); +vi.mock("node:fs/promises", async (importOriginal) => { + const actual = await importOriginal(); + const refused = (path: unknown) => + fault.blocked && + fault.root !== "" && + String(path).startsWith(fault.root) && + /[/\\](?:staging|trash)[/\\]build-/u.test(String(path)); + return { + ...actual, + rename: async (...args: Parameters) => { + if (refused(args[0]) && /[/\\]trash[/\\]build-/u.test(String(args[1]))) + throw Object.assign( + new Error("controlled staging retirement refusal"), + { code: "EACCES" }, + ); + return actual.rename(...args); + }, + rm: async (...args: Parameters) => { + if (refused(args[0])) + throw Object.assign(new Error("controlled staging removal refusal"), { + code: "EACCES", + }); + return actual.rm(...args); + }, + }; +}); +afterEach(() => { + fault.blocked = false; + fault.root = ""; +}); +const it = test.skipIf(process.platform === "win32"); + +const fixture = async (behavior: FakeKunaBehavior = {}) => { + const root = await createTestTempDirectory("rea-kuna-published-cleanup-"); + const kuna = await writeFakeKuna(root, behavior); + const cacheRoot = join(root, "cache"); + const installed = await inspectKunaInstallationOwned({ + REA_KUNA_COMMAND: kuna, + REA_KUNA_CACHE_DIR: cacheRoot, + }); + if (!installed.ok) throw new Error(installed.error.reason); + const targetPath = join(root, "program"); + await writeFile(targetPath, "program bytes"); + const cache = new KunaProjectCache(cacheRoot, { maxBytes: 64 * 1024 ** 2 }); + fault.root = cacheRoot; + fault.blocked = true; + return { + kuna, + cache, + cacheRoot, + request: { + installation: installed.value, + targetPath, + targetSha256: createHash("sha256").update("program bytes").digest("hex"), + }, + }; +}; + +it("retains the completed publication receipt while retrying only stopped staging cleanup", async () => { + const { cache, request, kuna } = await fixture(); + try { + const built = await cache.build(request); + if (built.ok) throw new Error("Expected retained staging cleanup failure"); + expect(built.error).toBeInstanceOf(KunaPublishedProjectCleanupError); + if (!(built.error instanceof KunaPublishedProjectCleanupError)) + throw built.error; + const error = built.error; + expect(error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(error).details).toMatchObject({ + diagnostics: { + published_export: { + kind: "project-export", + key: error.project.key, + directory: error.project.directory, + manifest: error.project.manifest, + }, + }, + }); + expect( + await readFile( + join(error.project.directory, error.project.manifest.files.asm.path), + "utf8", + ), + ).toContain("RET"); + const firstClose = await cache.close(); + expect(firstClose.ok).toBe(false); + expect(error.cleanupIncomplete).toBe(true); + fault.blocked = false; + expect(await cache.close()).toEqual({ ok: true, value: null }); + expect(error.cleanupIncomplete).toBe(false); + if (!firstClose.ok) expect(firstClose.error.cleanupIncomplete).toBe(false); + expect( + (await fakeKunaCalls(kuna)).filter( + ({ argv }) => argv[0] === "decompile-project", + ), + ).toHaveLength(1); + expect(await cache.lookup(error.project.key)).toBeDefined(); + } finally { + fault.blocked = false; + await cache.close(); + } +}); + +it("reuses the published export in the same session without hiding pending cleanup", async () => { + const { cacheRoot, kuna, open } = await session( + "rea-kuna-session-published-cleanup-", + ); + const client = open(); + fault.root = cacheRoot; + fault.blocked = true; + try { + const first = await client.execute( + "procedure_assembly", + { procedure: "main" }, + {}, + ); + if (!first.ok) { + expect(first.error.cleanupIncomplete).toBe(true); + expect(first.error.diagnostics).toMatchObject({ + published_export: { kind: "project-export" }, + }); + } else + expect(first.value.rawResult).toMatchObject({ + source: { kind: "project-export" }, + project_export: { state: "ready", cleanup_incomplete: true }, + }); + const repeated = await run(client, "procedure_assembly", { + procedure: "main", + }); + expect(repeated.rawResult).toMatchObject({ + source: { kind: "project-export" }, + project_export: { state: "ready", cleanup_incomplete: true }, + }); + expect(await exportsOf(kuna)).toHaveLength(1); + expect((await client.close()).ok).toBe(false); + fault.blocked = false; + expect(await client.close()).toEqual({ ok: true, value: null }); + if (!first.ok) expect(first.error.cleanupIncomplete).toBe(false); + } finally { + fault.blocked = false; + await client.close(); + } +}); + +it("preserves the original failed export when staging cleanup also fails", async () => { + const { cache, request } = await fixture({ project: "exit-failure" }); + try { + const built = await cache.build(request); + if (built.ok) throw new Error("Expected export and cleanup failure"); + expect(built.error).not.toBeInstanceOf(KunaPublishedProjectCleanupError); + expect(built.error.cleanupIncomplete).toBe(true); + expect(projectAnalysisError(built.error)).toMatchObject({ + message: expect.stringContaining("not in recognized object file format"), + }); + expect(JSON.stringify(built.error.diagnostics)).not.toContain( + "published_export", + ); + fault.blocked = false; + expect(await cache.close()).toEqual({ ok: true, value: null }); + expect(built.error.cleanupIncomplete).toBe(false); + } finally { + fault.blocked = false; + await cache.close(); + } +});