fix(kuna): retain workspace rollback failures
Compose failed rollback with the original target or read failure, bind it to the exact workspace lease, and retain the failed attempt while cleanup remains unresolved. Remove the silent release wrapper and duplicate exception wrapping; keep clean failures retryable and unknown causes opaque. Refs: #4704
This commit is contained in:
1 parent
5b24399387
commit
264799b0ff
3 files changed
+315
-41
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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<Result<Workspace, AnalysisError>> {
|
||||
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<void> {
|
||||
async #createWorkspace(): Promise<Result<Workspace, AnalysisError>> {
|
||||
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<Result<never, AnalysisError>> {
|
||||
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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<typeof vi.spyOn>[] = [];
|
||||
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<void>((resolve) => {
|
||||
entered = resolve;
|
||||
});
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const create = PrivateRuntimeRoot.create;
|
||||
let holdFailures = mode === "refuse";
|
||||
let rootClose: ReturnType<typeof vi.spyOn> | 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<typeof commands.close> | undefined;
|
||||
try {
|
||||
await started;
|
||||
lifetime.abort({ caller: "shutdown during rollback" });
|
||||
closing = commands.close();
|
||||
let closed = false;
|
||||
void closing.then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await new Promise<void>((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<typeof vi.spyOn> | 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);
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user