commit d9a69726a8532bdb47151d10e2ece13de239a8d5
parent 6f2d48322fbb80c12c42cdd03040f6784a49d62c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 18:51:37 -0400
common: writeFileAtomic under writeJsonAtomic
One tmp + rename idiom for text and binary files as well as JSON:
writeFileAtomic(file, data, {mkdir, mode}) on the same per-path chain on
globalThis and the same unique temp name, with writeJsonAtomic now
jsonText → writeFileAtomic. mode is applied as the temp is created, so a
0o600 file is never wider for an instant. copyFileAtomic is the copy
sibling on the same chain. Tests: bytes (string, Buffer, empty), mode,
one chain shared by the two writers on one path, copy.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 144 insertions(+), 27 deletions(-)
diff --git a/common/lib/jsonFile-server.test.ts b/common/lib/jsonFile-server.test.ts
@@ -5,6 +5,7 @@ import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import {
+ copyFileAtomic,
jsonFileState,
jsonText,
pendingJsonWrites,
@@ -12,6 +13,7 @@ import {
readJsonFileSync,
tmpPathFor,
writeJsonAtomic,
+ writeFileAtomic,
writeJsonAtomicSync,
} from "./jsonFile-server";
@@ -123,3 +125,71 @@ test("writeJsonAtomicSync: same bytes, mkdir, no temp left", async () => {
assert.equal(fs.readFileSync(file, "utf8"), '{\n "k": 1\n}');
assert.deepEqual(await readdir(path.dirname(file)), ["b.json"]);
});
+
+test("writeFileAtomic: exact bytes for a string and a Buffer, mkdir, no temp left", async () => {
+ const dir = await scratch();
+ const txt = path.join(dir, "d", "playlist");
+ await writeFileAtomic(txt, "a\nb\n", { mkdir: true });
+ assert.equal(await readFile(txt, "utf8"), "a\nb\n");
+ const bin = path.join(dir, "d", "blob");
+ const bytes = Buffer.from([0, 255, 10, 13, 128]);
+ await writeFileAtomic(bin, bytes);
+ assert.deepEqual(await readFile(bin), bytes);
+ // An empty string is a real write (clearFailedTranscriptions relies on it).
+ await writeFileAtomic(txt, "");
+ assert.equal(await readFile(txt, "utf8"), "");
+ assert.deepEqual((await readdir(path.join(dir, "d"))).sort(), ["blob", "playlist"]);
+});
+
+test("writeFileAtomic: mode is on the file from creation (0o600 cookie jar)", async () => {
+ const dir = await scratch();
+ const file = path.join(dir, "cookies.txt");
+ await writeFileAtomic(file, "secret\n", { mode: 0o600 });
+ assert.equal(fs.statSync(file).mode & 0o777, 0o600);
+ // The temp itself is created with the mode: watch every entry while a write
+ // is in flight behind a held one.
+ const seen: number[] = [];
+ const hold = writeFileAtomic(file, "x".repeat(1 << 20), { mode: 0o600 });
+ const second = writeFileAtomic(file, "second\n", { mode: 0o600 });
+ const watcher = fs.watch(dir, (_e, name) => {
+ if (!name || !name.includes(".tmp-")) return;
+ try {
+ seen.push(fs.statSync(path.join(dir, name)).mode & 0o777);
+ } catch {
+ // renamed away already
+ }
+ });
+ await Promise.all([hold, second]);
+ watcher.close();
+ for (const m of seen) assert.equal(m, 0o600);
+ assert.equal(await readFile(file, "utf8"), "second\n");
+});
+
+test("writeFileAtomic and writeJsonAtomic share one chain on one path", async () => {
+ const dir = await scratch();
+ const file = path.join(dir, "shared.json");
+ const writes: Promise<void>[] = [];
+ for (let i = 0; i < 40; i++) {
+ writes.push(
+ i % 2 === 0
+ ? writeJsonAtomic(file, { i })
+ : writeFileAtomic(file, `{"i":${i}}`),
+ );
+ }
+ assert.equal(pendingJsonWrites(), 1, "one chain entry for the path, not two");
+ await Promise.all(writes);
+ assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { i: 39 });
+ assert.deepEqual(await readdir(dir), ["shared.json"]);
+});
+
+test("copyFileAtomic: dest gets src's bytes, src stays, no temp left", async () => {
+ const dir = await scratch();
+ const src = path.join(dir, "src.bin");
+ await writeFile(src, Buffer.from([1, 2, 3]));
+ const dest = path.join(dir, "out", "dest.bin");
+ await copyFileAtomic(src, dest, { mkdir: true });
+ assert.deepEqual(await readFile(dest), Buffer.from([1, 2, 3]));
+ assert.deepEqual(await readdir(path.join(dir, "out")), ["dest.bin"]);
+ await assert.rejects(copyFileAtomic(path.join(dir, "nope"), dest));
+ assert.deepEqual(await readdir(path.join(dir, "out")), ["dest.bin"]);
+});
diff --git a/common/lib/jsonFile-server.ts b/common/lib/jsonFile-server.ts
@@ -1,6 +1,8 @@
-// ONE JSON READER AND ONE ATOMIC JSON WRITER for the config files and sidecars.
-// (Not yet every JSON writer: the slice 4b record lists the ones still on the
-// per-pid temp name.)
+// ONE JSON READER AND ONE ATOMIC WRITER — for the config files and sidecars,
+// and (since release 4 slice W) for every tmp + rename write in common/ and
+// editor/: JSON through `writeJsonAtomic`, text and binary through
+// `writeFileAtomic`, copies through `copyFileAtomic`. What is left on its own
+// temp name is listed in plans/one-core-phase-3.md, "Slice W, as shipped".
//
// one-core phase 3 slice 4b. Before this module the repo had seven private
// copies of `writeJsonAtomic` and some twenty inline `tmp + rename` writes, all
@@ -43,7 +45,7 @@
import { randomBytes } from "node:crypto";
import fs from "node:fs";
-import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
+import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
export type ReadJsonResult =
@@ -134,15 +136,42 @@ export function tmpPathFor(file: string): string {
return `${file}.tmp-${process.pid}-${seq}-${randomBytes(4).toString("hex")}`;
}
-async function writeNow(
+export type WriteFileOptions = {
+ // Create the parent directory first (`mkdir -p`).
+ mkdir?: boolean;
+ // Permission bits for the NEW file, applied as the temp file is CREATED
+ // (`writeFile(tmp, data, { mode })`, under the umask), so the bytes are never
+ // readable more widely than `mode` — not even for the instant before the
+ // rename. The X cookie jar writes 0o600 through this.
+ mode?: number;
+};
+
+// Run `op` for `file` after every earlier chained operation on the same
+// absolute path this process issued has settled. A failed earlier op does not
+// block a later one; each caller sees only its own op's error.
+function chained(key: string, op: () => Promise<void>): Promise<void> {
+ const { chains } = jsonFileState();
+ const prev = chains.get(key) ?? Promise.resolve();
+ const next = prev.then(op, op);
+ chains.set(key, next);
+ const release = () => {
+ if (chains.get(key) === next) chains.delete(key);
+ };
+ next.then(release, release);
+ return next;
+}
+
+// Put the temp file in place with `fill`, then rename it over `file`; on any
+// failure remove the temp and rethrow.
+async function replaceNow(
file: string,
- text: string,
makeDir: boolean,
+ fill: (tmp: string) => Promise<void>,
): Promise<void> {
if (makeDir) await mkdir(path.dirname(file), { recursive: true });
const tmp = tmpPathFor(file);
try {
- await writeFile(tmp, text);
+ await fill(tmp);
await rename(tmp, file);
} catch (err) {
await rm(tmp, { force: true }).catch(() => {});
@@ -150,30 +179,47 @@ async function writeNow(
}
}
-// Write `value` as JSON to `file` atomically (tmp + rename), after every write
-// to the same absolute path this process issued earlier has settled. The value
-// is serialised NOW, at the call, so a caller that goes on mutating its object
-// cannot change what lands. A failed earlier write does not block a later one;
-// each caller sees only its own write's error.
+// THE ONE ATOMIC WRITE (tmp + rename) for text and binary files: the playlist,
+// failed-transcriptions, the cookie jar, a CHANGELOG, a VTT — and every JSON
+// file, through `writeJsonAtomic` below. Same chain, same unique temp name.
+// `data` is not copied: a Buffer must not change before the promise settles.
+export function writeFileAtomic(
+ file: string,
+ data: string | Buffer,
+ opts: WriteFileOptions = {},
+): Promise<void> {
+ const key = path.resolve(file);
+ const { mode } = opts;
+ return chained(key, () =>
+ replaceNow(key, opts.mkdir === true, (tmp) =>
+ mode === undefined ? writeFile(tmp, data) : writeFile(tmp, data, { mode }),
+ ),
+ );
+}
+
+// Copy `src` to `dest` atomically: copy into a temp beside `dest`, then rename,
+// so a crash mid-copy never leaves a partial file under the final name. On the
+// same chain as the writers above (keyed on `dest`).
+export function copyFileAtomic(
+ src: string,
+ dest: string,
+ opts: { mkdir?: boolean } = {},
+): Promise<void> {
+ const key = path.resolve(dest);
+ return chained(key, () =>
+ replaceNow(key, opts.mkdir === true, (tmp) => copyFile(src, tmp)),
+ );
+}
+
+// Write `value` as JSON to `file` atomically: `jsonText` → `writeFileAtomic`.
+// The value is serialised NOW, at the call, so a caller that goes on mutating
+// its object cannot change what lands.
export function writeJsonAtomic(
file: string,
value: unknown,
opts: WriteJsonOptions = {},
): Promise<void> {
- const key = path.resolve(file);
- const text = jsonText(value, opts);
- const { chains } = jsonFileState();
- const prev = chains.get(key) ?? Promise.resolve();
- const next = prev.then(
- () => writeNow(key, text, opts.mkdir === true),
- () => writeNow(key, text, opts.mkdir === true),
- );
- chains.set(key, next);
- const release = () => {
- if (chains.get(key) === next) chains.delete(key);
- };
- next.then(release, release);
- return next;
+ return writeFileAtomic(file, jsonText(value, opts), { mkdir: opts.mkdir });
}
// The synchronous twin, for the three stores whose API is synchronous
@@ -215,7 +261,8 @@ export function withJsonFileLock<T>(file: string, fn: () => Promise<T>): Promise
return next;
}
-// For tests: how many paths currently have a write in flight.
+// For tests: how many paths currently have a write (of any of the three
+// kinds) in flight.
export function pendingJsonWrites(): number {
return jsonFileState().chains.size;
}