fix(kuna): retain published exports through staging cleanup failures

This commit is contained in:
morluto
2026-10-24 04:21:12 +08:00
parent 75dfd1c747
commit bbf7943ba6
4 changed files with 258 additions and 8 deletions
+1 -1
View File
@@ -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
+40 -1
View File
@@ -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);
+33 -6
View File
@@ -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<never>(() => 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;
}
@@ -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<typeof import("node:fs/promises")>();
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<typeof actual.rename>) => {
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<typeof actual.rm>) => {
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();
}
});