mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-15 17:35:29 +02:00
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:
co-authored by
Claude Fable 5
parent
408ee77cde
commit
3023216b87
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Atomic file writes — the ONE implementation of tmp-write-then-rename.
|
||||
*
|
||||
* Before this module, the pattern was reimplemented ~20 times across lib/,
|
||||
* bin/, and browse/src with three different tmp-suffix conventions — one of
|
||||
* which (a bare `.tmp`) carries a real collision race that browse's
|
||||
* server.ts documented after hitting it in production: two writers (batch
|
||||
* subcommands, /tunnel/start handlers, or any combination) collide on the
|
||||
* rename when the tmp filename is deterministic. The suffix here includes
|
||||
* pid AND a random component so concurrent writers in the SAME process
|
||||
* (async interleavings) can't collide either.
|
||||
*
|
||||
* Contract:
|
||||
* - atomicWriteSync ALWAYS throws on failure, after best-effort tmp cleanup.
|
||||
* Callers own the error. Use it everywhere except shutdown paths.
|
||||
* - atomicWriteQuiet swallows everything (returns false on failure). ONLY
|
||||
* for shutdown/emergency-cleanup paths where a throw would abort the rest
|
||||
* of cleanup — same philosophy as browse's safeUnlinkQuiet.
|
||||
* - `mode` applies to the tmp file at creation (0600 for sensitive state),
|
||||
* so the final file never exists with looser permissions.
|
||||
* - The tmp file is created in the target's directory (same filesystem, so
|
||||
* rename stays atomic). Parent dirs are NOT created — callers that need
|
||||
* mkdir own that decision (and its mode).
|
||||
*/
|
||||
import * as fs from 'fs';
|
||||
import * as crypto from 'crypto';
|
||||
|
||||
export interface AtomicWriteOpts {
|
||||
/** File mode for the tmp file at creation (e.g. 0o600). Default: umask. */
|
||||
mode?: number;
|
||||
}
|
||||
|
||||
function tmpPathFor(target: string): string {
|
||||
return `${target}.tmp.${process.pid}.${crypto.randomBytes(4).toString('hex')}`;
|
||||
}
|
||||
|
||||
/** Atomic write. Throws on failure (after best-effort tmp cleanup). */
|
||||
export function atomicWriteSync(
|
||||
target: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
opts: AtomicWriteOpts = {},
|
||||
): void {
|
||||
const tmp = tmpPathFor(target);
|
||||
try {
|
||||
if (opts.mode !== undefined) {
|
||||
fs.writeFileSync(tmp, data, { mode: opts.mode });
|
||||
} else {
|
||||
fs.writeFileSync(tmp, data);
|
||||
}
|
||||
fs.renameSync(tmp, target);
|
||||
} catch (err) {
|
||||
try {
|
||||
fs.unlinkSync(tmp);
|
||||
} catch {
|
||||
// Best-effort cleanup; the original error is the one that matters.
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic write that swallows all errors. Returns true on success.
|
||||
* ONLY for shutdown/emergency paths — a throw there aborts remaining cleanup.
|
||||
*/
|
||||
export function atomicWriteQuiet(
|
||||
target: string,
|
||||
data: string | NodeJS.ArrayBufferView,
|
||||
opts: AtomicWriteOpts = {},
|
||||
): boolean {
|
||||
try {
|
||||
atomicWriteSync(target, data, opts);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user