Skip to content

hooks: concurrent config mutations lose updates, and the file is published without fsync #1164

Open

Description

@Ayush7614

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 only lockForPath (internal/hooks/hooks.go:986-995), a sync.Mutex map local to one process.
  • WriteConfig (internal/hooks/hooks.go:244-272) uses os.WriteFile(tempPath, ...) at line 264 and os.Rename at line 267. os.WriteFile does not fsync, the parent directory is not synced after the rename, and a failed write leaves .tmp-* debris.
  • The exported WriteConfig has no lock at all.
  • The CLI comment in internal/cli/hooks_manage.go:13-14 says the store handles its own locking; it only handles goroutines in one process.

Expected behavior

  • Mutations take <hooks.json>.lock with the existing lockutil.TryAcquireFileLock retry protocol and hold it across List() through WriteConfig(), 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.
  • WriteConfig either 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

  1. Ayush7614 commented on Oct 20, 2026

    @Ayush7614
    ContributorAuthor

    Implemented in #1166. The branch takes <hooks.json>.lock across 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 on cd3e0b0a and pass on the PR branch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions