diff --git a/docs/kuna-provider.md b/docs/kuna-provider.md index 5451bd84a..e4214502d 100644 --- a/docs/kuna-provider.md +++ b/docs/kuna-provider.md @@ -96,6 +96,9 @@ join the final departing caller's command cleanup. A command cleanup error keeps its typed primary failure and captured output. Its live resource list follows both process and workspace ownership: proving process exit does not prove that the private workspace was removed, and retry updates earlier caller errors. +If workspace setup fails, an unsuccessful rollback retains the original target +or read failure together with its cleanup owner. Replacement stays blocked until +that rollback is resolved; a clean rollback permits another setup attempt. ## Provenance and limitations diff --git a/src/kuna/KunaTargetCommands.ts b/src/kuna/KunaTargetCommands.ts index c10650ce8..cb8cdbf3a 100644 --- a/src/kuna/KunaTargetCommands.ts +++ b/src/kuna/KunaTargetCommands.ts @@ -10,6 +10,7 @@ import { import type { BinaryTarget } from "../domain/binaryTargetTypes.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js"; import { err, ok, type Result } from "../domain/result.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { prepareProcessOwnershipInspection } from "../process/ProcessOwnershipObservation.js"; @@ -263,53 +264,80 @@ export class KunaTargetCommands { } async #ensureWorkspace(): Promise> { - this.#workspace ??= (async () => { - const root = await PrivateRuntimeRoot.create({ prefix: "rea-kuna-" }); - const owned = { - root, - cleanup: new AnalysisCleanupLease(() => root.close(), [root.path]), - }; - try { - const input = join(root.path, basename(this.target.path)); - const digest = await copyAndHash( - this.target.path, - input, - this.lifetime.signal, - ); - if (digest !== this.target.sha256) { - await this.#release(owned); - return err( - new AnalysisArtifactChangedError( - "kuna:workspace", - this.target.path, - "the target no longer matches the digest selected for analysis", - ), - ); - } - await prepareProcessOwnershipInspection(this.lifetime.signal); - return ok({ ...owned, input: await realpath(input) }); - } catch (cause: unknown) { - await this.#release(owned); - throw cause; - } - })().catch((cause: unknown) => - err( - new ProviderAdapterError(KUNA_PROVIDER_ID, "kuna:workspace", { - cause, - userMessage: `Kuna workspace failed: ${cause instanceof Error ? cause.message : String(cause)}`, - }), - ), - ); - const result = await this.#workspace; - if (!result.ok) this.#workspace = undefined; + const pending = (this.#workspace ??= this.#createWorkspace()); + const result = await pending; + if ( + this.#workspace === pending && + !result.ok && + !result.error.cleanupIncomplete + ) + this.#workspace = undefined; return result; } - async #release(owned: WorkspaceRoot): Promise { + async #createWorkspace(): Promise> { + let owned: WorkspaceRoot | undefined; + try { + const root = await PrivateRuntimeRoot.create({ prefix: "rea-kuna-" }); + owned = { + root, + cleanup: new AnalysisCleanupLease(() => root.close(), [root.path]), + }; + const input = join(root.path, basename(this.target.path)); + const digest = await copyAndHash( + this.target.path, + input, + this.lifetime.signal, + ); + if (digest !== this.target.sha256) + return this.#workspaceFailure( + new AnalysisArtifactChangedError( + "kuna:workspace", + this.target.path, + "the target no longer matches the digest selected for analysis", + ), + owned, + ); + await prepareProcessOwnershipInspection(this.lifetime.signal); + return ok({ ...owned, input: await realpath(input) }); + } catch (cause: unknown) { + const primary = new ProviderAdapterError( + KUNA_PROVIDER_ID, + "kuna:workspace", + { + cause, + userMessage: `Kuna workspace failed: ${cause instanceof Error ? cause.message : String(cause)}`, + }, + ); + return this.#workspaceFailure(primary, owned); + } + } + + /** Rollback and the original outcome share the exact root cleanup lease. */ + async #workspaceFailure( + primary: AnalysisError, + owned: WorkspaceRoot | undefined, + ): Promise> { + if (owned === undefined) return err(primary); try { await owned.cleanup.release(); - } catch { + return err(primary); + } catch (cause: unknown) { this.#roots.push(owned); + const cleanup = new ProviderCleanupError( + KUNA_PROVIDER_ID, + [owned.root.path], + { reason: cause instanceof Error ? cause.message : String(cause) }, + { cause, operation: "kuna:workspace" }, + ); + owned.cleanup.bindCleanupError(cleanup); + return err( + analysisErrorWithCleanupFailure( + primary, + cleanup, + "kuna:workspace", + ).bindCleanupDependencies([primary, cleanup]), + ); } } } diff --git a/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts b/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts new file mode 100644 index 000000000..038b652f8 --- /dev/null +++ b/tests/boundary/providers/kuna/kunaWorkspaceRollback.test.ts @@ -0,0 +1,243 @@ +import { unlink, writeFile } from "node:fs/promises"; +import { expect, it as test, vi } from "vitest"; +import { KunaTargetCommands } from "../../../../src/kuna/KunaTargetCommands.js"; +import { PrivateRuntimeRoot } from "../../../../src/process/PrivateRuntimeRoot.js"; +import { ProviderCleanupError } from "../../../../src/domain/providerCleanupError.js"; +import { projectAnalysisError } from "../../../../src/domain/analysisErrorProjection.js"; +import { session } from "./kunaSessionFixture.js"; + +const it = test.skipIf(process.platform === "win32"); + +it.each(["changed", "missing"] as const)( + "retains a %s target's failed workspace rollback and blocks replacement until retry", + async (mode) => { + const fixture = await session("rea-kuna-workspace-rollback-"); + if (mode === "changed") + await writeFile(fixture.target.path, "changed bytes"); + else await unlink(fixture.target.path); + const lifetime = new AbortController(); + const commands = new KunaTargetCommands(fixture.target, { + signal: lifetime.signal, + }); + const create = PrivateRuntimeRoot.create; + let allocations = 0; + let owned: PrivateRuntimeRoot | undefined; + const closeSpies: ReturnType[] = []; + const roots = vi + .spyOn(PrivateRuntimeRoot, "create") + .mockImplementation(async (options) => { + const root = await create(options); + owned = root; + allocations += 1; + closeSpies.push( + vi + .spyOn(root, "close") + .mockRejectedValue(new Error("controlled rollback refusal")), + ); + return root; + }); + try { + const result = await commands.prepare(); + if (result.ok) throw new Error("Expected workspace failure"); + expect(result.error.cleanupIncomplete).toBe(true); + expect(result.error.cleanupResources).toEqual([owned?.path]); + expect(result.error.cleanupPrimary?._tag).toBe( + mode === "changed" + ? "AnalysisArtifactChangedError" + : "ProviderAdapterError", + ); + const again = await commands.prepare(); + if (again.ok) throw new Error("Expected retained rollback failure"); + expect(again.error).toBe(result.error); + expect(allocations).toBe(1); + const failedClose = await commands.close(); + if (failedClose.ok) throw new Error("Expected cleanup refusal"); + expect(result.error.cleanupIncomplete).toBe(true); + for (const spy of closeSpies) spy.mockRestore(); + expect((await commands.close()).ok).toBe(true); + expect(result.error.cleanupIncomplete).toBe(false); + expect(failedClose.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe( + mode === "changed" ? "artifact_changed" : "execution_failure", + ); + } finally { + roots.mockRestore(); + for (const spy of closeSpies) spy.mockRestore(); + expect((await commands.close()).ok).toBe(true); + } + }, +); + +it.each(["clean", "refuse"] as const)( + "joins rollback finalization without replacing the primary failure when close cancels: %s", + async (mode) => { + const fixture = await session("rea-kuna-workspace-finalization-"); + await writeFile(fixture.target.path, "changed bytes"); + const lifetime = new AbortController(); + const commands = new KunaTargetCommands(fixture.target, { + signal: lifetime.signal, + }); + let entered!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + entered = resolve; + }); + const gate = new Promise((resolve) => { + release = resolve; + }); + const create = PrivateRuntimeRoot.create; + let holdFailures = mode === "refuse"; + let rootClose: ReturnType | undefined; + const roots = vi + .spyOn(PrivateRuntimeRoot, "create") + .mockImplementation(async (options) => { + const root = await create(options); + const close = root.close.bind(root); + rootClose = vi.spyOn(root, "close").mockImplementation(async () => { + entered(); + await gate; + if (holdFailures) throw new Error("controlled finalization refusal"); + await close(); + }); + return root; + }); + const pending = commands.prepare(); + let settled = false; + void pending.then(() => { + settled = true; + }); + let closing: ReturnType | undefined; + try { + await started; + lifetime.abort({ caller: "shutdown during rollback" }); + closing = commands.close(); + let closed = false; + void closing.then(() => { + closed = true; + }); + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + expect(closed).toBe(false); + release(); + const result = await pending; + if (result.ok) throw new Error("Expected changed-target failure"); + expect(result.error.cleanupIncomplete).toBe(mode === "refuse"); + const outcome = await closing; + expect(outcome.ok).toBe(mode === "clean"); + if (!outcome.ok) { + holdFailures = false; + expect((await commands.close()).ok).toBe(true); + expect(outcome.error.cleanupIncomplete).toBe(false); + } + expect(result.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(result.error).code).toBe("artifact_changed"); + } finally { + holdFailures = false; + release(); + await Promise.all([pending, closing]); + rootClose?.mockRestore(); + roots.mockRestore(); + expect((await commands.close()).ok).toBe(true); + } + }, +); + +it.each(["changed", "missing"] as const)( + "permits a fresh workspace after a %s target rolls back cleanly", + async (mode) => { + const fixture = await session("rea-kuna-workspace-clean-retry-"); + if (mode === "changed") + await writeFile(fixture.target.path, "changed bytes"); + else await unlink(fixture.target.path); + const commands = new KunaTargetCommands(fixture.target, { + signal: new AbortController().signal, + }); + try { + const failed = await commands.prepare(); + if (failed.ok) throw new Error("Expected target failure"); + expect(failed.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(failed.error).code).toBe( + mode === "changed" ? "artifact_changed" : "execution_failure", + ); + await writeFile(fixture.target.path, "program bytes"); + expect((await commands.prepare()).ok).toBe(true); + } finally { + expect((await commands.close()).ok).toBe(true); + } + }, +); + +it.each([ + new Error("controlled allocation failure"), + null, + new ProviderCleanupError("foreign", ["foreign-owner"], {}), +])( + "returns an allocation failure without inventing an owner and permits retry: %j", + async (reason) => { + const fixture = await session("rea-kuna-workspace-allocation-"); + const commands = new KunaTargetCommands(fixture.target, { + signal: new AbortController().signal, + }); + const roots = vi + .spyOn(PrivateRuntimeRoot, "create") + .mockRejectedValue(reason); + try { + const failed = await commands.prepare(); + if (failed.ok) throw new Error("Expected allocation failure"); + expect(failed.error.cause).toBe(reason); + expect(failed.error.cleanupIncomplete).toBe(false); + expect(failed.error.cleanupResources).toEqual([]); + roots.mockRestore(); + expect((await commands.prepare()).ok).toBe(true); + } finally { + roots.mockRestore(); + expect((await commands.close()).ok).toBe(true); + } + }, +); + +it("keeps caller attribution and the selected path through workspace rollback retry", async () => { + const fixture = await session("rea-kuna-workspace-caller-"); + await writeFile(fixture.target.path, "changed bytes"); + const client = fixture.open(); + const create = PrivateRuntimeRoot.create; + let refuseClose: ReturnType | undefined; + const roots = vi + .spyOn(PrivateRuntimeRoot, "create") + .mockImplementation(async (options) => { + const root = await create(options); + refuseClose = vi + .spyOn(root, "close") + .mockRejectedValue(new Error("controlled rollback refusal")); + return root; + }); + try { + const failed = await client.execute("search_procedures", { + pattern: "main", + mode: "literal", + }); + if (failed.ok) + throw new Error("Expected changed target with retained rollback"); + expect(failed.error.cleanupIncomplete).toBe(true); + expect(failed.error.cleanupPrimary).toMatchObject({ + _tag: "AnalysisArtifactChangedError", + operation: "search_procedures", + path: fixture.target.path, + }); + expect(projectAnalysisError(failed.error).details).toMatchObject({ + operation: "search_procedures", + }); + expect((await client.close()).ok).toBe(false); + refuseClose?.mockRestore(); + expect((await client.close()).ok).toBe(true); + expect(failed.error.cleanupIncomplete).toBe(false); + expect(projectAnalysisError(failed.error)).toMatchObject({ + code: "artifact_changed", + details: { operation: "search_procedures", path: fixture.target.path }, + }); + } finally { + refuseClose?.mockRestore(); + roots.mockRestore(); + expect((await client.close()).ok).toBe(true); + } +});