mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-04 21:17:30 +00:00
* fix(studio-server): undo and redo on Windows wait out a briefly held file * style(core): format the atomic write test
178 lines
6.1 KiB
TypeScript
178 lines
6.1 KiB
TypeScript
import * as fs from "node:fs";
|
|
import { createHash, randomBytes } from "node:crypto";
|
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
import { realpath } from "./safePath.js";
|
|
import { renameWithRetry } from "./renameWithRetry.js";
|
|
|
|
type SiblingFileSystem = Pick<typeof fs, "writeFileSync" | "chmodSync" | "unlinkSync">;
|
|
type DirectFileSystem = SiblingFileSystem & Pick<typeof fs, "openSync" | "closeSync">;
|
|
|
|
// 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"]);
|
|
const MAX_LINK_HOPS = 40;
|
|
const TEMP_NAME_TRIES = 3;
|
|
const TEMP_SUFFIX = /\.hf[0-9a-f]{6}\.tmp$/;
|
|
|
|
/** A fresh temp sibling for a write that publishes `filePath`. 13 bytes, so a 242-byte name still fits. */
|
|
export function atomicTempPath(filePath: string): string {
|
|
return `${filePath}.hf${randomBytes(3).toString("hex")}.tmp`;
|
|
}
|
|
|
|
export function isAtomicTempPath(path: string): boolean {
|
|
return TEMP_SUFFIX.test(path);
|
|
}
|
|
|
|
/** With HF_WRITE_TRACE naming a file, one JSON line per replaced .html: its hash, size and caller. */
|
|
function traceWrite(filePath: string, content: string | Uint8Array): void {
|
|
const out = process.env.HF_WRITE_TRACE;
|
|
if (!out || !filePath.endsWith(".html")) return;
|
|
const text = typeof content === "string" ? content : Buffer.from(content).toString("utf8");
|
|
const hash = createHash("sha256").update(text).digest("hex").slice(0, 8);
|
|
const stack = (new Error().stack ?? "")
|
|
.split("\n")
|
|
.slice(2, 7)
|
|
.map((line) => line.trim());
|
|
const file = basename(filePath);
|
|
fs.appendFileSync(
|
|
out,
|
|
`${JSON.stringify({ t: Date.now(), kind: "write", file, hash, size: text.length, stack })}\n`,
|
|
);
|
|
}
|
|
|
|
/** 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 {
|
|
traceWrite(filePath, content);
|
|
// Node fs.rename uses libuv uv_fs_rename; win32 calls MoveFileExW with MOVEFILE_REPLACE_EXISTING.
|
|
publishSibling(filePath, content, mode, operations, (tempPath) =>
|
|
renameWithRetry(tempPath, filePath, { rename: operations.renameSync }),
|
|
);
|
|
}
|
|
|
|
/** 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: DirectFileSystem & 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;
|
|
createDirectly(filePath, content, operations);
|
|
}
|
|
try {
|
|
operations.unlinkSync(tempPath);
|
|
} catch (error) {
|
|
console.warn(`[hyperframes] created ${filePath} but could not remove ${tempPath}: ${error}`);
|
|
}
|
|
});
|
|
}
|
|
|
|
/** Without hard links: an exclusive write; a partial file it made (a full disk) is removed, a taken name never. */
|
|
function createDirectly(
|
|
filePath: string,
|
|
content: string | Uint8Array,
|
|
operations: DirectFileSystem,
|
|
): void {
|
|
const fd = operations.openSync(filePath, "wx");
|
|
let open = true;
|
|
try {
|
|
operations.writeFileSync(fd, content);
|
|
open = false;
|
|
operations.closeSync(fd);
|
|
} catch (error) {
|
|
try {
|
|
if (open) operations.closeSync(fd);
|
|
} catch {
|
|
// Preserve the write error; cleanup is best effort.
|
|
}
|
|
try {
|
|
operations.unlinkSync(filePath);
|
|
} catch {
|
|
// Preserve the write error; cleanup is best effort.
|
|
}
|
|
throw 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++) {
|
|
const tempPath = atomicTempPath(filePath);
|
|
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 errorCode(error: unknown): string {
|
|
return String((error as NodeJS.ErrnoException)?.code);
|
|
}
|