Files
reverse-engineering-anything/tests/boundary/filesystem/stableFileHashOwnership.test.ts
T
morluto 131fd5ddcd fix(filesystem): retain stable hash cleanup owners across callers
Preserve completed digests and primary failures when descriptor close fails.
Carry the exact handle owner through provider, readiness, cache, doctor and
verifier boundaries, and retry cleanup without rehashing or retrying a raw fd.
Close candidate-owned resources even when no target client was opened.
Keep verifier workspaces when provider shutdown remains incomplete.

Refs #4266
2026-10-16 06:46:25 +08:00

178 lines
5.3 KiB
TypeScript

import {
mkdtemp,
open,
rm,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { openRegularFile } from "../../../src/filesystem/RegularFile.js";
import { hashStableFile } from "../../../src/filesystem/StableFileHash.js";
vi.mock("../../../src/filesystem/RegularFile.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../../src/filesystem/RegularFile.js")
>()),
openRegularFile: vi.fn(),
}));
interface TestOwner {
readonly handle: FileHandle;
close(): Promise<void>;
}
interface OwnedHashFailure extends Error {
readonly cause?: unknown;
readonly cleanupCause?: unknown;
readonly owner: TestOwner;
}
const roots: string[] = [];
const rawHandles: FileHandle[] = [];
afterEach(async () => {
vi.mocked(openRegularFile).mockReset();
for (const handle of rawHandles.splice(0)) {
if (handle.fd >= 0) await handle.close();
}
for (const root of roots.splice(0))
await rm(root, { recursive: true, force: true });
});
const makeFile = async (options: {
readonly closeFailure?: Error;
readonly invalidateFdOnCloseFailure?: boolean;
readonly readFailure?: Error;
readonly abortAfterRead?: AbortController;
readonly abortReason?: unknown;
}) => {
const root = await mkdtemp(join(tmpdir(), "rea-stable-hash-"));
roots.push(root);
const path = join(root, "input.bin");
await writeFile(path, "stable hash input");
const raw = await open(path, "r");
rawHandles.push(raw);
let failedClose = false;
let invalidated = false;
const close = vi.fn(async () => {
if (options.closeFailure !== undefined && !failedClose) {
failedClose = true;
invalidated = options.invalidateFdOnCloseFailure === true;
throw options.closeFailure;
}
await raw.close();
});
const handle = {
get fd() {
return invalidated ? -1 : raw.fd;
},
stat: () => raw.stat(),
read: async (
buffer: Buffer,
offset: number,
length: number,
position: number | null,
) => {
if (options.readFailure !== undefined) throw options.readFailure;
const result = await raw.read(buffer, offset, length, position);
options.abortAfterRead?.abort(options.abortReason);
return result;
},
close,
} as unknown as FileHandle;
vi.mocked(openRegularFile).mockResolvedValueOnce(handle);
return { path, handle, close };
};
const rejectHash = async (
path: string,
signal?: AbortSignal,
): Promise<OwnedHashFailure> => {
const result = await hashStableFile(path, signal).then(
() => undefined,
(cause: unknown) => cause,
);
expect(result).toBeInstanceOf(Error);
expect(result).toHaveProperty("owner");
expect(result).toHaveProperty("cleanupCause");
return result as OwnedHashFailure;
};
it("hashes and closes normally when close succeeds", async () => {
const { path, close } = await makeFile({});
const result = await hashStableFile(path);
expect(result.bytes).toBe(Buffer.byteLength("stable hash input"));
expect(result.sha256).toMatch(/^[a-f0-9]{64}$/u);
expect(close).toHaveBeenCalledTimes(1);
});
it("retains a completed hash's live file owner after close fails and retries it", async () => {
const closeFailure = new Error("injected close failure");
const { path, handle, close } = await makeFile({ closeFailure });
const failure = await rejectHash(path);
expect(failure.cleanupCause).toBe(closeFailure);
expect(failure.owner.handle).toBe(handle);
expect(failure).toHaveProperty(
"completedHash.bytes",
Buffer.byteLength("stable hash input"),
);
await expect(failure.owner.close()).resolves.toBeUndefined();
expect(close).toHaveBeenCalledTimes(2);
});
it("preserves a read failure as primary when closing the owned file also fails", async () => {
const readFailure = new Error("injected read failure");
const closeFailure = new Error("injected close failure");
const { path, handle } = await makeFile({ closeFailure, readFailure });
const failure = await rejectHash(path);
expect(failure.cause).toBe(readFailure);
expect(failure.cleanupCause).toBe(closeFailure);
expect(failure.owner.handle).toBe(handle);
});
it("preserves cancellation as primary when closing the owned file also fails", async () => {
const cancellation = new AbortController();
const cancelCause = new Error("injected cancellation");
const closeFailure = new Error("injected close failure");
const { path, handle } = await makeFile({
closeFailure,
abortAfterRead: cancellation,
abortReason: cancelCause,
});
const failure = await rejectHash(path, cancellation.signal);
expect(failure.cause).toBe(cancelCause);
expect(failure.cleanupCause).toBe(closeFailure);
expect(failure.owner.handle).toBe(handle);
});
it("keeps an invalidated descriptor's close failure sticky without retrying raw close", async () => {
const closeFailure = new Error(
"injected close failure after fd invalidation",
);
const { path, handle, close } = await makeFile({
closeFailure,
invalidateFdOnCloseFailure: true,
});
const failure = await rejectHash(path);
expect(failure.cleanupCause).toBe(closeFailure);
expect(failure.owner.handle).toBe(handle);
await expect(failure.owner.close()).rejects.toBe(closeFailure);
expect(close).toHaveBeenCalledTimes(1);
});