Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/lib/jsonFile-server.test.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/jsonFile-server.ts | 101++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
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; }