fix(export): retain publication and cleanup failures

This commit is contained in:
morluto committed 2026-10-27 13:06:29 +08:00
1 parent ddb5337674
commit 47f8669c1e
2 files changed
+71 -3

No files matched your search

@@ -0,0 +1,60 @@
import { lstat, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import { OwnedStagedOutput } from "../../filesystem/OwnedStagedOutput.js";
import { ExportResources } from "./exportResources.js";
it.each([false, true])(
"retains publication outcome and staging cleanup authority (conflict: %s)",
async (conflict) => {
const root = await createTestTempDirectory("rea-export-resources-");
const destination = join(root, "published");
if (conflict) await writeFile(destination, "existing");
const lease = { release: vi.fn(async () => undefined) };
const resources = new ExportResources(
root,
await lstat(root, { bigint: true }),
lease,
undefined,
);
const cleanupFailure = new Error("staging removal denied");
const close = vi.spyOn(OwnedStagedOutput.prototype, "close");
close.mockRejectedValueOnce(cleanupFailure);
try {
const cause: unknown = await resources
.publish("new", destination, "link")
.catch((failure: unknown) => failure);
expect(cause).toBeInstanceOf(ArtifactReaderFailure);
const failure = cause as ArtifactReaderFailure;
expect(failure.cleanup?.reason).toContain("staging removal denied");
expect(failure.cleanupLeases).toHaveLength(1);
if (conflict) {
expect(failure.message).toContain("EEXIST");
expect(failure.cause).toBeInstanceOf(ArtifactReaderFailure);
} else expect(failure.message).toBe("staging removal denied");
expect(await readFile(destination, "utf8")).toBe(
conflict ? "existing" : "new",
);
expect(
(await readdir(root)).some((name) =>
name.startsWith(".decompile-stage-"),
),
).toBe(true);
expect(lease.release).not.toHaveBeenCalled();
await expect(resources.close()).resolves.toEqual({
ok: true,
value: undefined,
});
expect(await readdir(root)).toEqual(["published"]);
expect(failure.cleanupLeases?.[0]?.released).toBe(true);
expect(lease.release).toHaveBeenCalledOnce();
} finally {
close.mockRestore();
await resources.close();
await rm(root, { recursive: true, force: true });
}
},
);
@@ -123,6 +123,11 @@ export class ExportResources {
output: staging,
};
this.#files.retain(owner);
let outcome:
| { readonly kind: "completed" }
| { readonly kind: "failed"; readonly cause: unknown } = {
kind: "completed",
};
try {
const path = join(directory, "content");
const handle = await open(path, "wx", 0o600);
@@ -136,10 +141,13 @@ export class ExportResources {
await rename(path, destination);
staging.recordPublishedRename();
}
} finally {
const released = await this.#files.release(owner);
if (released.kind === "failed") throw released.cause;
} catch (cause: unknown) {
outcome = { kind: "failed", cause };
}
const released = await this.#files.release(owner);
if (released.kind === "failed")
throw this.#files.cleanupFailure(outcome, [released]);
if (outcome.kind === "failed") throw outcome.cause;
}
/** Keep the lease through all initialization cleanup, including any retry. */