Files
hyperframes/packages/core/src/atomicFile.ts
T
Miguel Ángel 567525a8c2 fix: readers never see an empty or half-written project file (#4777)
* fix: readers never see an empty or half-written project file

* test: pin the create routes and block install to whole-file writes

* fix(sdk): a write through a link to a missing file creates that file

* fix: saves through a linked folder reach the real file, atomic writes handle long names

* test(sdk): check for the stray file, not the folder listing

* fix(core): creates work on Windows FAT drives, and links through folders resolve as the system does

* fix: agree the helper move in the deletion check, and match temp paths without a path regex

* test: check POSIX file modes only off Windows
2026-09-30 19:17:59 +00:00

143 lines
4.9 KiB
TypeScript

import * as fs from "node:fs";
import { randomBytes } from "node:crypto";
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
import { realpath } from "./safePath.js";
type SiblingFileSystem = Pick<typeof fs, "writeFileSync" | "chmodSync" | "unlinkSync">;
// Codes a volume without hard links (FAT, exFAT, some network shares) answers link() with.
// EISDIR: libuv maps Windows ERROR_INVALID_FUNCTION (FAT/exFAT refusing a link) to it; nodejs/node#65817.
const NO_HARD_LINKS = new Set(["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EISDIR"]);
// Windows refuses a rename while another process briefly holds the target open.
const BUSY_RENAME = new Set(["EPERM", "EBUSY", "EACCES"]);
const RENAME_RETRY_DELAYS_MS = [10, 20, 30, 40];
const MAX_LINK_HOPS = 40;
const TEMP_NAME_TRIES = 3;
/** Replace a file only after the complete sibling temp file is written. No mode: the default one. */
export function replaceFileAtomically(
filePath: string,
content: string | Uint8Array,
mode?: number,
operations: SiblingFileSystem & Pick<typeof fs, "renameSync"> = fs,
): void {
// Node fs.rename uses libuv uv_fs_rename; win32 calls MoveFileExW with MOVEFILE_REPLACE_EXISTING.
publishSibling(filePath, content, mode, operations, (tempPath) =>
renameWithRetry(operations, tempPath, filePath),
);
}
/** Create a file that must not exist yet (EEXIST otherwise); readers never see it empty or partial. */
export function createFileAtomically(
filePath: string,
content: string | Uint8Array,
operations: SiblingFileSystem & Pick<typeof fs, "linkSync"> = fs,
): void {
publishSibling(filePath, content, undefined, operations, (tempPath) => {
try {
operations.linkSync(tempPath, filePath);
} catch (error) {
if (!NO_HARD_LINKS.has(errorCode(error))) throw error;
// Without hard links, keep today's direct exclusive write.
operations.writeFileSync(filePath, content, { flag: "wx" });
}
try {
operations.unlinkSync(tempPath);
} catch (error) {
console.warn(`[hyperframes] created ${filePath} but could not remove ${tempPath}: ${error}`);
}
});
}
/** The file a write to `filePath` lands on: folder links and file links followed as the system does. */
export function resolveWritePath(filePath: string): string {
let path = resolve(filePath);
for (let hop = 0; hop < MAX_LINK_HOPS; hop++) {
try {
return realpath(path);
} catch (error) {
if (errorCode(error) !== "ENOENT") throw error;
}
// Missing: a new file, or a dangling link to the file a write through it creates.
const parent = realpath(dirname(path));
const leaf = join(parent, basename(path));
if (!fs.lstatSync(leaf, { throwIfNoEntry: false })?.isSymbolicLink()) return leaf;
// Not normalized: `x/..` must climb out of where link `x` points, as the system reads it.
const target = fs.readlinkSync(leaf);
path = isAbsolute(target) ? target : `${parent}${sep}${target}`;
}
throw Object.assign(new Error(`ELOOP: too many symbolic links, '${filePath}'`), {
code: "ELOOP",
});
}
function publishSibling(
filePath: string,
content: string | Uint8Array,
mode: number | undefined,
operations: SiblingFileSystem,
publish: (tempPath: string) => void,
): void {
const tempPath = writeTempSibling(filePath, content, mode, operations);
try {
if (mode !== undefined) operations.chmodSync(tempPath, mode);
publish(tempPath);
} catch (error) {
try {
operations.unlinkSync(tempPath);
} catch {
// Preserve the write error; cleanup is best effort.
}
throw error;
}
}
/** A new temp file beside `filePath`; a name another writer already holds is never touched. */
function writeTempSibling(
filePath: string,
content: string | Uint8Array,
mode: number | undefined,
operations: SiblingFileSystem,
): string {
for (let attempt = 1; ; attempt++) {
// Short, so a name near the filesystem's limit still fits.
const tempPath = `${filePath}.${randomBytes(4).toString("hex")}.tmp`;
try {
operations.writeFileSync(tempPath, content, { encoding: "utf-8", mode, flag: "wx" });
return tempPath;
} catch (error) {
if (errorCode(error) === "EEXIST") {
if (attempt < TEMP_NAME_TRIES) continue;
throw error;
}
try {
operations.unlinkSync(tempPath);
} catch {
// Preserve the write error; cleanup is best effort.
}
throw error;
}
}
}
function renameWithRetry(
operations: Pick<typeof fs, "renameSync">,
from: string,
to: string,
): void {
for (let attempt = 0; ; attempt++) {
try {
operations.renameSync(from, to);
return;
} catch (error) {
const delay = RENAME_RETRY_DELAYS_MS[attempt];
if (delay === undefined || !BUSY_RENAME.has(errorCode(error))) throw error;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay);
}
}
}
function errorCode(error: unknown): string {
return String((error as NodeJS.ErrnoException)?.code);
}