feat(lib): fs-atomic — one atomic-write implementation, with the race actually fixed

Atomic tmp-write-then-rename was reimplemented ~20 times across lib/, bin/,
and browse/src with three tmp-suffix conventions. One of them was a latent
bug this commit closes: lib/worktree.ts used a bare '.tmp' suffix — the
deterministic-tmp collision race browse/src/server.ts documents having hit
in production (its fix, pid+random, was trapped in a comment at one site).

lib/fs-atomic.ts: atomicWriteSync (always throws, best-effort tmp cleanup,
pid+random suffix, optional mode applied at tmp creation so the file never
exists with looser permissions) + atomicWriteQuiet (shutdown paths only).
Unit tests pin the throw/quiet contracts, 0600 mode, tmp-name uniqueness
(captured via the read-only-dir failure path — Bun's fs exports are
readonly, no monkeypatching), and no-stray-tmp cleanup.

Migrated: lib/worktree.ts (the bare-.tmp bug), lib/gstack-decision.ts
(snapshot + compact log), lib/gbrain-local-status.ts (probe cache). browse
sites follow separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-08-14 21:12:21 -07:00
co-authored by Claude Fable 5
parent 408ee77cde
commit 3023216b87
5 changed files with 191 additions and 15 deletions
+4 -7
View File
@@ -16,7 +16,8 @@
import { join } from "path";
import { homedir } from "os";
import { randomUUID } from "crypto";
import { writeFileSync, renameSync, existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs";
import { existsSync, readFileSync, appendFileSync, statSync, openSync, closeSync, unlinkSync } from "fs";
import { atomicWriteSync } from "./fs-atomic";
import { appendJsonl, readJsonl, hasInjection } from "./jsonl-store";
import { scan } from "./redact-engine";
@@ -224,9 +225,7 @@ export function readEvents(paths: DecisionPaths): DecisionEvent[] {
* O(active), not O(history).
*/
export function writeSnapshot(paths: DecisionPaths, active: ActiveDecision[]): void {
const tmp = `${paths.snapshot}.tmp.${process.pid}`;
writeFileSync(tmp, JSON.stringify(active), "utf-8");
renameSync(tmp, paths.snapshot);
atomicWriteSync(paths.snapshot, JSON.stringify(active));
}
/** Read the bounded active snapshot. Returns [] if missing/corrupt (caller may rebuild). */
@@ -308,9 +307,7 @@ export function compact(paths: DecisionPaths): CompactResult {
appendFileSync(paths.archive, superseded.map((e) => JSON.stringify(e)).join("\n") + "\n", "utf-8");
}
const tmp = `${paths.log}.tmp.${process.pid}`;
writeFileSync(tmp, active.map((d) => JSON.stringify(d)).join("\n") + (active.length ? "\n" : ""), "utf-8");
renameSync(tmp, paths.log);
atomicWriteSync(paths.log, active.map((d) => JSON.stringify(d)).join("\n") + (active.length ? "\n" : ""));
writeSnapshot(paths, active);
return { activeCount: active.length, archivedCount: superseded.length, expungedCount: redactedIds.size };