hooks: concurrent config mutations lose updates, and the file is published without fsync #1164
Description
Summary
zero hooks add/remove/enable/disable perform a read-modify-write of hooks.json. The mutation is serialized only by a process-local mutex (lockForPath), and the publish path (WriteConfig) writes a temp file with os.WriteFile, renames it, and returns without File.Sync() or a parent-directory sync.
Two processes can therefore read the same revision and both publish a full document; the second rename silently drops the first process's hook. A crash or power loss after the CLI reports success can also leave a truncated/empty hooks.json or lose the rename.
Reproduction
Commit: cd3e0b0a.
I added a test that holds <hooks.json>.lock with lockutil.TryAcquireFileLock and then calls ConfigStore.Upsert. The mutation must wait for that lock. On current main it ignores the lock and completes immediately:
=== RUN TestConfigStoreMutationWaitsForSiblingConfigLock
config_lock_test.go:57: Upsert completed while .../hooks.json.lock was held elsewhere (err=<nil>); mutations are not excluded across processes
--- FAIL: TestConfigStoreMutationWaitsForSiblingConfigLock (0.00s)
FAIL github.com/Twigpine/zero/internal/hooks 0.485s
The two-process race is the same window: two zero hooks add ... invocations both call ConfigStore.List() before either WriteConfig() lands, and the last full-document rename wins. A test that starts multiple zero hooks child processes adding unique hooks loses some of them on current main.
Root cause
ConfigStore.Upsert/Remove/SetEnabled(internal/hooks/hooks.go:298-377) hold onlylockForPath(internal/hooks/hooks.go:986-995), async.Mutexmap local to one process.WriteConfig(internal/hooks/hooks.go:244-272) usesos.WriteFile(tempPath, ...)at line 264 andos.Renameat line 267.os.WriteFiledoes not fsync, the parent directory is not synced after the rename, and a failed write leaves.tmp-*debris.- The exported
WriteConfighas no lock at all. - The CLI comment in
internal/cli/hooks_manage.go:13-14says the store handles its own locking; it only handles goroutines in one process.
Expected behavior
- Mutations take
<hooks.json>.lockwith the existinglockutil.TryAcquireFileLockretry protocol and hold it acrossList()throughWriteConfig(), in addition to the in-process mutex. - Writes publish a complete temp file,
Sync, close, rename, and sync the parent directory. Failures preserve the previous file and clean up only the temp file created by that call. WriteConfigeither owns the lock itself or is clearly an unlocked primitive used only under it; preferring an owning API avoids future unlocked callers.- Lock release errors are returned, not swallowed.
Scope
internal/hooks/hooks.go, internal/hooks/lock.go, and focused tests. No CLI API changes. I can implement this with deterministic same-process lock-exclusion, a two-process helper, durability-seam, and failure-preserves-old-config tests.
Activity
Ayush7614 commentedon Oct 20, 2026
Implemented in #1166. The branch takes
<hooks.json>.lockacross the full read-modify-write, publishes through a synced 0600 temp file + atomic replace + directory sync, and adds same-process and two-process regressions. Both exclusion tests fail oncd3e0b0aand pass on the PR branch.