Files
hyperframes/packages/core/src/atomicFile.ts
T
Miguel Ángel 4e118824b3 fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo (#4982)
* fix(studio-server): freeze still appears whole, so a slow extraction cannot break its undo

The freeze route reserved the still's final name with an empty file before
ffmpeg ran. When extraction outlasted the history's quiet time, history
committed that empty file as an outside change, and Cmd+Z then restored a
0-byte PNG. ffmpeg now writes under an atomic temp name the history skips,
and the still is published with createFileAtomically (EEXIST still refuses
a taken name).

* fix(core): a direct exclusive write that fails midway leaves no partial file

Without hard links, createFileAtomically writes the destination directly.
A full disk then left a partial file behind (a corrupt upload or freeze
still); it is now removed, and a name another writer holds is never
touched. Freeze tests: the slow-extraction test leaves a half-written temp
file while history commits, and concurrent extractions meet at a barrier
instead of a real timer.

* fix(core): a quota reported only at close still removes the partial file

* test(core): the direct write closes once and keeps its write error when cleanup fails

* test(core): a failed direct write closes its descriptor exactly once

* refactor(studio-server): freeze names carry what their comments said
2026-10-04 04:16:02 +00:00

179 lines
5.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">;
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"]);
// 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;
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);
}
/** 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: 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 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);
}