Documentation
¶
Overview ¶
Package fileutil — advisory file lock for cross-process serialization of read-modify-write sequences on shared config / manifest files.
We need this because several files in ox are written by both the daemon and the CLI (and sometimes by a second daemon in another worktree): `<ledger>/sessions/<name>/meta.json`, `.sageox/config.local.toml`, and the marker-managed AGENTS.md / CLAUDE.md / SOUL.md. AtomicWriteBytes stops a single writer from leaving a torn file on the floor; it does nothing about two writers each doing
cfg := Read() -+ cfg.X = ... | read-modify-write window AtomicWriteBytes(p, Marshal(cfg)) -+
concurrently. Whichever process writes second clobbers the other's in-memory copy. WithFileLock wraps the whole RMW window in an advisory flock so the two processes serialize at the file-system level.
Lock file shape: a sibling `.<basename>.lock` next to the target. The lock file is intentionally separate from the data file — locking the data file directly would race with our own atomic temp+rename (rename(2) replaces the inode the lock is held against).
Index ¶
Constants ¶
const LockPollInterval = 25 * time.Millisecond
LockPollInterval is how often WithFileLock retries while the lock is held by another process. Short enough that contention completes quickly; long enough not to spin the CPU.
const LockTimeout = 10 * time.Second
LockTimeout is the maximum time WithFileLock will wait to acquire an advisory lock before returning ErrLockTimeout. Generous because the expected hold time is a millisecond-scale read-modify-write; if we can't get the lock in this window, something is genuinely stuck.
Variables ¶
This section is empty.
Functions ¶
func AtomicWriteBytes ¶ added in v0.6.4
AtomicWriteBytes writes raw bytes to filePath atomically using temp+rename, fsync'd before rename and the parent directory fsync'd after rename so the new inode entry survives a hard crash. Intended for user-facing files where a crash-window partial write would destroy content — instruction files (AGENTS.md / CONVENTIONS.md), env files, etc.
If filePath is a symlink, the rename follows the symlink to its target so the link itself is preserved and the underlying file is updated. The CLAUDE.md → AGENTS.md pattern that ox init creates depends on this — a naive rename would replace the symlink with a regular file and break the link. Ordinary (non-symlink) paths are written directly as before.
func AtomicWriteJSON ¶
AtomicWriteJSON writes data as JSON to filePath atomically using temp+rename. This prevents partial/corrupt files from concurrent reads during write. The file is fsync'd before rename to ensure durability.
func LockPath ¶ added in v0.7.0
LockPath returns the canonical advisory-lock sidecar path for a target data file. Exposed so tests and callers that need to clean up stale locks know where to look.
Lock files live in the OS tmpdir under `sageox-locks/`, keyed by an absolute-path hash of the target. Why NOT alongside the target:
- `<ledger>/sessions/<name>/meta.json`'s sibling `.meta.json.lock` would live inside a git-tracked tree. A crashed writer leaves an empty lock file there; the next `git add .` (user) or any glob that didn't anticipate `.lock` files would commit it. We can't rely on every adjacent `.gitignore` covering `.*.lock`.
- User-visible: even with the dot prefix, `ls -a` shows the lock and prompts "what is this?" support questions.
- GC / blue-green reclone: any extra file inside a sparse-checkout tree is a state-divergence risk.
Storing in OS tmpdir sidesteps all three. The hash is sha256 of the abs target path truncated to 16 hex chars — collisions among the bounded set of lock targets in ox (one per session, plus a few per-project files) are statistically zero, and even if one occurred it'd just serialize two unrelated writers harmlessly.
Tradeoff accepted: locks don't survive a reboot. That's correct — after reboot every prior holder is dead and the lock should be gone. It also means the OS tmpdir cleaner reaps stale lock files for free, no per-session cleanup logic needed.
func WithFileLock ¶ added in v0.7.0
WithFileLock acquires an advisory exclusive lock on the sidecar lock file for `targetPath`, runs `fn` with the lock held, and releases it (always, even if `fn` panics).
Use this around any read-modify-write sequence on a file that may be touched concurrently by another process. Both readers and writers must use it (or readers may observe a torn intermediate state); for reads-only fast paths it's optional, since AtomicWriteBytes guarantees the readable bytes are a coherent snapshot of *some* committed write.
Implementation note: this is advisory only. Cooperating processes must all go through WithFileLock; a rogue process can ignore the lock and corrupt state. We accept that — every writer in ox is in the same codebase, and a rogue writer is a bug we'd notice anyway.
On platforms without flock (currently none we ship to, but keep portability): the function still serializes within the process via inProcessLocks, so two goroutines in the same binary are safe even if cross-process locking is a no-op.
Types ¶
type ErrLockTimeout ¶ added in v0.7.0
type ErrLockTimeout struct{ Path string }
ErrLockTimeout is returned by WithFileLock when LockTimeout elapses without acquiring the lock.
func (*ErrLockTimeout) Error ¶ added in v0.7.0
func (e *ErrLockTimeout) Error() string