mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(kuna): retain published exports through staging cleanup failures
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user