mirror of
https://github.com/morluto/rea
synced 2026-10-27 06:33:32 -04:00
2582c267af
Return the canonical owner and lease from failed releases. Compose initial cleanup failures from explicit operation outcomes and failed attempts, and carry their authority through the shared artifact failure contract. Keep independent unknown cleanup unresolved. Distinguish operation failures from completed work whose close rejected. Let the scope own current child resource labels without replacing its canonical retry lease. Cover mixed owners, partial release, cancellation provenance, typed close rejections and independent unknown cleanup through the producing boundaries.
150 lines
4.9 KiB
TypeScript
150 lines
4.9 KiB
TypeScript
import type { FileHandle } from "node:fs/promises";
|
|
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
|
|
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
|
import { MachOSliceArtifactReader } from "../../../src/artifacts/MachOSliceArtifactReader.js";
|
|
import { ZipArtifactReader } from "../../../src/artifacts/ZipArtifactReader.js";
|
|
import { ok } from "../../../src/domain/result.js";
|
|
import type { NativeCommandRunner } from "../../../src/native/CommandRunner.js";
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
const injected = vi.hoisted(() => ({
|
|
opened: undefined as
|
|
| ((path: unknown, handle: FileHandle) => void)
|
|
| undefined,
|
|
}));
|
|
|
|
vi.mock("node:fs/promises", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
|
return {
|
|
...actual,
|
|
open: async (...args: Parameters<typeof actual.open>) => {
|
|
const handle = await actual.open(...args);
|
|
injected.opened?.(args[0], handle);
|
|
return handle;
|
|
},
|
|
};
|
|
});
|
|
|
|
const runner: NativeCommandRunner = {
|
|
run: async () =>
|
|
ok({
|
|
tool: "lipo",
|
|
executable: "/usr/bin/lipo",
|
|
executableSha256: "1".repeat(64),
|
|
toolVersion: null,
|
|
versionReason: "fixture",
|
|
arguments: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
stdoutBytes: 0,
|
|
stderrBytes: 0,
|
|
exitCode: 0,
|
|
signal: null,
|
|
}),
|
|
};
|
|
|
|
const readers = [
|
|
["ZIP", (path: string) => new ZipArtifactReader(path, "zip")],
|
|
[
|
|
"Mach-O slice",
|
|
(path: string) => new MachOSliceArtifactReader(path, {}, runner),
|
|
],
|
|
] as const;
|
|
|
|
it.each(readers)(
|
|
"retains %s reader admission and stat owners",
|
|
async (_, createReader) => {
|
|
const root = await createTestTempDirectory("rea-owned-reader-cleanup-");
|
|
const path = join(root, "source");
|
|
await writeFile(path, "fixture source");
|
|
|
|
for (const stage of ["admission", "post-open-stat"] as const) {
|
|
const statFailure = Object.assign(
|
|
new Error(`primary ${stage} stat failure`),
|
|
{ code: "EIO", errno: -5, syscall: "fstat" },
|
|
);
|
|
const closeFailure = new Error(
|
|
`close failed after invalidation during ${stage}`,
|
|
);
|
|
let descriptor: FileHandle | undefined;
|
|
let statCalls = 0;
|
|
let closeAttempts = 0;
|
|
let failOnStat = stage === "admission" ? 1 : 2;
|
|
injected.opened = (openedPath, handle) => {
|
|
if (openedPath !== path || descriptor !== undefined) return;
|
|
descriptor = handle;
|
|
const stat = handle.stat.bind(handle);
|
|
const close = handle.close.bind(handle);
|
|
vi.spyOn(handle, "stat").mockImplementation(async (...args) => {
|
|
statCalls += 1;
|
|
if (statCalls === failOnStat) {
|
|
failOnStat = -1;
|
|
throw statFailure;
|
|
}
|
|
return stat(...args);
|
|
});
|
|
vi.spyOn(handle, "close").mockImplementation(async () => {
|
|
closeAttempts += 1;
|
|
await close();
|
|
throw closeFailure;
|
|
});
|
|
};
|
|
|
|
const reader = createReader(path);
|
|
const scope = new ArtifactResourceScope();
|
|
const owner = { kind: "reader" as const, resource: path, reader };
|
|
onTestFinished(async () => {
|
|
injected.opened = undefined;
|
|
await scope.close().catch(() => undefined);
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
const primary = await (async () => {
|
|
try {
|
|
for await (const _entry of reader.entries()) {
|
|
// Both injected failures happen before the reader can yield an entry.
|
|
}
|
|
} catch (cause: unknown) {
|
|
return cause;
|
|
}
|
|
throw new Error("Expected reader initialization to fail");
|
|
})();
|
|
expect(primary).toBeInstanceOf(Error);
|
|
expect(causeChainContains(primary, statFailure)).toBe(true);
|
|
|
|
const release = await scope.release(owner);
|
|
expect(release).toMatchObject({
|
|
kind: "failed",
|
|
cause: closeFailure,
|
|
owner,
|
|
});
|
|
if (release.kind !== "failed")
|
|
throw new Error("Expected owned cleanup failure");
|
|
expect(release.cause).toBe(closeFailure);
|
|
expect(release.lease.released).toBe(false);
|
|
expect(descriptor?.fd).toBe(-1);
|
|
await expect(scope.close()).rejects.toMatchObject({
|
|
reason: "unavailable",
|
|
cleanup: { reason: closeFailure.message, resources: [path] },
|
|
cleanupLeases: [release.lease],
|
|
});
|
|
// Native close invalidated the fd before rejecting. Retrying the same owner
|
|
// must remain failed without another syscall or a false successful release.
|
|
expect(closeAttempts).toBe(1);
|
|
}
|
|
},
|
|
);
|
|
|
|
const causeChainContains = (cause: unknown, expected: unknown): boolean => {
|
|
let current: unknown = cause;
|
|
while (current instanceof Error) {
|
|
if (current === expected) return true;
|
|
current = current.cause;
|
|
}
|
|
return false;
|
|
};
|