diff --git a/src/application/decompileDirectory/exportResources.test.ts b/src/application/decompileDirectory/exportResources.test.ts new file mode 100644 index 0000000000..36e83e7763 --- /dev/null +++ b/src/application/decompileDirectory/exportResources.test.ts @@ -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 }); + } + }, +); diff --git a/src/application/decompileDirectory/exportResources.ts b/src/application/decompileDirectory/exportResources.ts index 2d83f38548..77474e4b0e 100644 --- a/src/application/decompileDirectory/exportResources.ts +++ b/src/application/decompileDirectory/exportResources.ts @@ -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. */