Files
rea/tests/boundary/filesystem/regularFileCancellation.test.ts
morluto 746a044e42 fix(filesystem): preserve opaque caller reasons through cleanup
Do not interpret exact caller cancellation reasons as descriptor ownership records. Preserve checkpoint-shaped reasons through successful close retry and supply cancellation context in direct signal-aware cleanup consumers.

Validation: 156 focused tests across 14 files passed; no native provider execution. Refs #4610
2026-10-17 08:25:01 +08:00

335 lines
12 KiB
TypeScript

import type { FileHandle } from "node:fs/promises";
import { open, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, vi } from "vitest";
import {
withRegularFile,
retryRegularFileCleanup,
} from "../../../src/application/RegularFileRead.js";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
import { RegularFileOperationCancelled } from "../../../src/filesystem/RegularFile.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import {
RegularFileCleanupFailure,
RegularFileCancelled,
withOwnedRegularFile,
} from "../../../src/filesystem/OwnedRegularFile.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
it.each(
(["checkpoint", "completion", "cleanup"] as const).flatMap((kind) =>
["before admission", "during read"].flatMap((stage) =>
[
{ scoped: false, retryClose: false },
{ scoped: true, retryClose: false },
...(stage === "during read"
? [{ scoped: true, retryClose: true }]
: []),
].map((scenario) => ({ kind, stage, ...scenario })),
),
),
)(
"keeps a $kind caller reason opaque $stage with scope=$scoped, close retry=$retryClose",
async ({ kind, stage, scoped, retryClose }) => {
const root = await createTestTempDirectory("rea-file-caller-reason-");
const path = join(root, "input.txt");
await writeFile(path, "fixture bytes");
const callerOwner = new OwnedFileHandle(await open(path, "r"));
const close = vi.spyOn(callerOwner, "close");
const nestedReason = new Error("nested reason");
const outcome = { kind: "completed", value: "caller-owned value" } as const;
const reason =
kind === "checkpoint"
? new RegularFileOperationCancelled(nestedReason)
: kind === "completion"
? new RegularFileCancelled("/caller-selected-path", callerOwner, {
outcome,
reason: nestedReason,
})
: new RegularFileCleanupFailure(
"/caller-selected-path",
callerOwner,
outcome,
nestedReason,
);
const controller = new AbortController();
if (stage === "before admission") controller.abort(reason);
const resources = scoped ? new ArtifactResourceScope() : undefined;
let admitted: FileHandle | undefined;
const read = vi.fn(async (handle: FileHandle) => {
admitted = handle;
await handle.readFile();
if (retryClose)
vi.spyOn(handle, "close").mockRejectedValueOnce(
new Error("transient descriptor close failure"),
);
controller.abort(reason);
controller.signal.throwIfAborted();
return "unexpected";
});
try {
await expect(
withRegularFile(path, read, {
signal: controller.signal,
...(resources === undefined ? {} : { resources }),
}),
).rejects.toBe(reason);
expect(read).toHaveBeenCalledTimes(stage === "during read" ? 1 : 0);
if (admitted !== undefined) expect(admitted.fd).toBe(-1);
expect(close).not.toHaveBeenCalled();
expect(callerOwner.handle.fd).toBeGreaterThanOrEqual(0);
expect(resources?.hasPending ?? false).toBe(false);
} finally {
await resources?.close();
close.mockRestore();
await callerOwner.close();
}
},
);
it.each([false, true])(
"preserves a nested read cancellation with the same signal=%s",
async (sameSignal) => {
const root = await createTestTempDirectory("rea-nested-file-cancel-");
const path = join(root, "input.txt");
const bytes = Buffer.from("nested read bytes");
await writeFile(path, bytes);
const outer = new AbortController();
const inner = sameSignal ? outer : new AbortController();
const reason = new Error("nested caller cancellation");
let outerHandle: FileHandle | undefined;
let innerHandle: FileHandle | undefined;
try {
const failure: unknown = await withRegularFile(
path,
async (handle) => {
outerHandle = handle;
return withOwnedRegularFile(
path,
{ signal: inner.signal },
async (nested) => {
innerHandle = nested;
const value = await nested.readFile();
const close = nested.close.bind(nested);
vi.spyOn(nested, "close").mockImplementation(async () => {
await close();
inner.abort(reason);
});
return value;
},
);
},
{ signal: outer.signal },
).catch((cause: unknown) => cause);
expect(failure).toBeInstanceOf(RegularFileCancelled);
if (!(failure instanceof RegularFileCancelled))
throw new Error(
"Expected the nested operation's completed observations",
);
expect(failure.path).toBe(path);
expect(failure.reason).toBe(reason);
expect(failure.outcome).toEqual({ kind: "completed", value: bytes });
expect(failure.owner.handle).toBe(innerHandle);
expect(outerHandle?.fd).toBe(-1);
expect(innerHandle?.fd).toBe(-1);
} finally {
vi.restoreAllMocks();
}
},
);
it.each(
(["metadata", "close"] as const).flatMap((stage) =>
[false, true].map((scoped) => ({ stage, scoped })),
),
)(
"observes cancellation after $stage with scope=$scoped",
async ({ stage, scoped }) => {
const root = await createTestTempDirectory("rea-file-finalization-");
const path = join(root, "input.txt");
const bytes = Buffer.from("unchanged fixture bytes");
await writeFile(path, bytes);
const controller = new AbortController();
const reason = new Error("cancel during file finalization");
const resources = scoped ? new ArtifactResourceScope() : undefined;
let admitted: FileHandle | undefined;
try {
await expect(
withRegularFile(
path,
async (handle) => {
admitted = handle;
const value = await handle.readFile();
if (stage === "metadata") {
const stat = handle.stat.bind(handle);
vi.spyOn(handle, "stat").mockImplementation(async () => {
const metadata = await stat();
controller.abort(reason);
return metadata;
});
} else {
const close = handle.close.bind(handle);
vi.spyOn(handle, "close").mockImplementation(async () => {
await close();
controller.abort(reason);
});
}
return value;
},
{ signal: controller.signal, ...(resources ? { resources } : {}) },
),
).rejects.toBe(reason);
expect(admitted?.fd).toBe(-1);
expect(resources?.hasPending ?? false).toBe(false);
await resources?.retryCleanup();
expect(await readFile(path)).toEqual(bytes);
} finally {
vi.restoreAllMocks();
await resources?.close();
}
},
);
it("observes cancellation during a recovered descriptor close", async () => {
const root = await createTestTempDirectory("rea-file-retry-cancel-");
const path = join(root, "input.txt");
await writeFile(path, "fixture bytes");
const controller = new AbortController();
const reason = new Error("cancel during close retry");
const resources = new ArtifactResourceScope();
const nativeClose = OwnedFileHandle.prototype.close;
let attempts = 0;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (++attempts === 1) throw new Error("transient close failure");
await nativeClose.call(this);
controller.abort(reason);
});
try {
await expect(
withRegularFile(path, (handle) => handle.readFile(), {
signal: controller.signal,
resources,
}),
).rejects.toBe(reason);
expect(attempts).toBe(2);
expect(resources.hasPending).toBe(false);
await resources.retryCleanup();
} finally {
close.mockRestore();
await resources.close();
}
});
it.each(["completed", "failed"] as const)(
"retains the physical read outcome when close fails after cancellation: %s",
async (outcome) => {
const root = await createTestTempDirectory("rea-file-close-cancel-");
const path = join(root, "input.txt");
await writeFile(path, "fixture bytes");
const controller = new AbortController();
const reason = new Error("cancel during failed close");
const primary = new Error("existing read failure");
const cleanupCause = new Error("injected close failure");
const retained: OwnedFileHandle[] = [];
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementationOnce(async () => {
controller.abort(reason);
throw cleanupCause;
});
try {
let failure: unknown;
try {
await withOwnedRegularFile(
path,
{
signal: controller.signal,
retainFailedClose: (owner) => retained.push(owner),
},
async (handle) => {
const bytes = await handle.readFile();
if (outcome === "failed") throw primary;
return bytes;
},
);
} catch (cause: unknown) {
failure = cause;
}
expect(failure).toBeInstanceOf(RegularFileCleanupFailure);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Expected the retained close owner");
expect(failure.outcome).toEqual(
outcome === "failed"
? { kind: "failed", cause: primary }
: { kind: "completed", value: Buffer.from("fixture bytes") },
);
expect(controller.signal.reason).toBe(reason);
expect(failure.cleanupCause).toBe(cleanupCause);
expect(retained).toEqual([failure.owner]);
expect(failure.owner.handle.fd).toBeGreaterThanOrEqual(0);
} finally {
close.mockRestore();
await retained[0]?.close();
}
expect(retained[0]?.handle.fd).toBe(-1);
},
);
it.each(
[
null,
"caller stopped",
new Error("caller stopped"),
new RegularFileOperationCancelled(new Error("nested reason")),
].flatMap((reason) =>
[false, true].map((retryFails) => ({ reason, retryFails })),
),
)(
"projects the exact checkpoint reason after application cleanup retry fails=$retryFails ($reason)",
async ({ reason, retryFails }) => {
const root = await createTestTempDirectory("rea-file-retry-reason-");
const path = join(root, "input.bin");
await writeFile(path, "file bytes");
const controller = new AbortController();
const resources = new ArtifactResourceScope();
const nativeClose = OwnedFileHandle.prototype.close;
let allowClose = false;
const close = vi
.spyOn(OwnedFileHandle.prototype, "close")
.mockImplementation(async function (this: OwnedFileHandle) {
if (!allowClose) throw new Error("close unavailable");
await nativeClose.call(this);
});
let failure: unknown;
try {
failure = await withRegularFile(
path,
async () => {
controller.abort(reason);
return "completed callback";
},
{ signal: controller.signal },
).catch((cause: unknown) => cause);
if (!(failure instanceof RegularFileCleanupFailure))
throw new Error("Missing checkpoint cleanup owner");
allowClose = !retryFails;
const retried = await retryRegularFileCleanup(failure, resources);
expect(retried.outcome.kind).toBe("failed");
if (retried.outcome.kind !== "failed")
throw new Error("Lost cancellation outcome");
expect(retried.outcome.cause).toBe(reason);
expect(retried.cleanup !== undefined).toBe(retryFails);
expect(resources.hasPending).toBe(retryFails);
} finally {
allowClose = true;
await resources.close();
if (failure instanceof RegularFileCleanupFailure)
await failure.owner.close();
close.mockRestore();
}
},
);