mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-27 03:32:55 +00:00
A receipt could remain pending after provider delivery when Windows rejected the completed-state rename with EPERM. Retry transient replace errors within a one-second budget while retaining the original file and cleanup semantics.
148 lines
4.9 KiB
TypeScript
148 lines
4.9 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, expect, it } from "vitest";
|
|
import { writeFileAtomic, writeJsonFileAtomic } from "./atomic-file.js";
|
|
|
|
let directory: string;
|
|
beforeEach(async () => {
|
|
directory = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-atomic-file-"));
|
|
});
|
|
afterEach(async () => {
|
|
await fs.rm(directory, { recursive: true, force: true });
|
|
});
|
|
|
|
it.each(["EPERM", "EACCES", "EBUSY"])(
|
|
"commits a completed receipt after transient Windows %s replace failures",
|
|
async (code) => {
|
|
const file = path.join(directory, "receipt.json");
|
|
await writeJsonFileAtomic(file, { state: "pending" });
|
|
let failures = 3;
|
|
let elapsed = 0;
|
|
await writeJsonFileAtomic(
|
|
file,
|
|
{ state: "completed" },
|
|
{
|
|
platform: "win32",
|
|
now: () => elapsed,
|
|
sleep: async (milliseconds) => {
|
|
elapsed += milliseconds;
|
|
},
|
|
rename: async (source, destination) => {
|
|
if (failures-- > 0) {
|
|
expect(JSON.parse(await fs.readFile(destination, "utf8"))).toEqual({
|
|
state: "pending",
|
|
});
|
|
expect(JSON.parse(await fs.readFile(source, "utf8"))).toEqual({ state: "completed" });
|
|
throw Object.assign(new Error("destination held open"), { code });
|
|
}
|
|
await fs.rename(source, destination);
|
|
},
|
|
},
|
|
);
|
|
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "completed" });
|
|
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
|
expect(elapsed).toBeGreaterThan(0);
|
|
},
|
|
);
|
|
|
|
it.each(["EPERM", "EACCES", "EBUSY"])(
|
|
"preserves the previous file and removes the temp file when Windows %s persists",
|
|
async (code) => {
|
|
const file = path.join(directory, "receipt.json");
|
|
await writeJsonFileAtomic(file, { state: "pending" });
|
|
const error = Object.assign(new Error("destination held open"), { code });
|
|
const waits: number[] = [];
|
|
let elapsed = 0;
|
|
const attempts: number[] = [];
|
|
await expect(
|
|
writeJsonFileAtomic(
|
|
file,
|
|
{ state: "completed" },
|
|
{
|
|
platform: "win32",
|
|
now: () => elapsed,
|
|
sleep: async (milliseconds) => {
|
|
waits.push(milliseconds);
|
|
elapsed += milliseconds;
|
|
},
|
|
rename: async () => {
|
|
attempts.push(elapsed);
|
|
throw error;
|
|
},
|
|
},
|
|
),
|
|
).rejects.toBe(error);
|
|
expect(elapsed).toBe(1000);
|
|
expect(waits.slice(0, 5)).toEqual([10, 20, 40, 80, 100]);
|
|
expect(Math.max(...waits)).toBe(100);
|
|
expect(attempts.every((time) => time < 1000)).toBe(true);
|
|
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
|
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["linux", "EPERM"],
|
|
["darwin", "EACCES"],
|
|
["linux", "EBUSY"],
|
|
["win32", "ENOENT"],
|
|
["win32", "ENOSPC"],
|
|
["win32", "EXDEV"],
|
|
] as const)("does not retry %s %s failures", async (platform, code) => {
|
|
const file = path.join(directory, "receipt.json");
|
|
await writeJsonFileAtomic(file, { state: "pending" });
|
|
const error = Object.assign(new Error("rename failed"), { code });
|
|
let attempts = 0;
|
|
const waits: number[] = [];
|
|
await expect(
|
|
writeFileAtomic(file, "replacement", {
|
|
platform,
|
|
now: () => 0,
|
|
sleep: async (milliseconds) => {
|
|
waits.push(milliseconds);
|
|
},
|
|
rename: async () => {
|
|
attempts++;
|
|
throw error;
|
|
},
|
|
}),
|
|
).rejects.toBe(error);
|
|
expect(attempts).toBe(1);
|
|
expect(waits).toEqual([]);
|
|
expect(JSON.parse(await fs.readFile(file, "utf8"))).toEqual({ state: "pending" });
|
|
expect(await fs.readdir(directory)).toEqual(["receipt.json"]);
|
|
});
|
|
|
|
it("does not retry after a rename consumes the Windows retry budget", async () => {
|
|
const file = path.join(directory, "receipt.json");
|
|
const error = Object.assign(new Error("slow rename failed"), { code: "EPERM" });
|
|
let elapsed = 0;
|
|
const waits: number[] = [];
|
|
await expect(
|
|
writeFileAtomic(file, "replacement", {
|
|
platform: "win32",
|
|
now: () => elapsed,
|
|
sleep: async (milliseconds) => {
|
|
waits.push(milliseconds);
|
|
},
|
|
rename: async () => {
|
|
elapsed += 1000;
|
|
throw error;
|
|
},
|
|
}),
|
|
).rejects.toBe(error);
|
|
expect(waits).toEqual([]);
|
|
expect(await fs.readdir(directory)).toEqual([]);
|
|
});
|
|
|
|
it("creates parent directories and replaces binary and JSON files through the default runtime", async () => {
|
|
const file = path.join(directory, "nested", "file");
|
|
const bytes = Buffer.from([0, 255, 128, 1]);
|
|
await writeFileAtomic(file, bytes);
|
|
expect(await fs.readFile(file)).toEqual(bytes);
|
|
await writeJsonFileAtomic(file, { state: "completed" });
|
|
expect(await fs.readFile(file, "utf8")).toBe('{\n "state": "completed"\n}');
|
|
expect(await fs.readdir(path.dirname(file))).toEqual(["file"]);
|
|
});
|