diff --git a/internal/hooks/config_lock_test.go b/internal/hooks/config_lock_test.go new file mode 100644 index 000000000..d177114c6 --- /dev/null +++ b/internal/hooks/config_lock_test.go @@ -0,0 +1,357 @@ +package hooks + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Twigpine/zero/internal/lockutil" +) + +// TestConfigStoreMutationWaitsForSiblingConfigLock pins the cross-process +// contract of ConfigStore: Upsert/Remove/SetEnabled are read-modify-write +// operations, so they must participate in the same .lock protocol as +// every other writer. The package-local mutex only serializes goroutines in +// this process; it cannot exclude a second `zero hooks` process. +// +// On the unfixed code Upsert ignores .lock and completes while the test +// holds it, which is exactly the window in which two CLI processes overwrite +// each other's hooks. +func TestConfigStoreMutationWaitsForSiblingConfigLock(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.json") + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + if _, err := store.Upsert(Definition{ID: "seed", Event: EventAfterTool, Command: "node"}); err != nil { + t.Fatalf("seed hook: %v", err) + } + + lock, err := lockutil.TryAcquireFileLock(path + ".lock") + if err != nil { + t.Fatalf("acquire sibling config lock: %v", err) + } + released := false + release := func() { + if released { + return + } + released = true + if err := lock.Release(); err != nil { + t.Errorf("release sibling config lock: %v", err) + } + } + defer release() + + started := make(chan struct{}) + done := make(chan error, 1) + go func() { + close(started) + _, err := store.Upsert(Definition{ID: "blocked", Event: EventAfterTool, Command: "node"}) + done <- err + }() + <-started + + select { + case err := <-done: + t.Fatalf("Upsert completed while %s was held elsewhere (err=%v); mutations are not excluded across processes", path+".lock", err) + case <-time.After(500 * time.Millisecond): + } + + release() + select { + case err := <-done: + if err != nil { + t.Fatalf("Upsert after releasing the config lock: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("Upsert did not resume after the config lock was released") + } + + config, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if findHook(config.Hooks, "seed") == nil { + t.Fatalf("seed hook was lost: %v", hookIDs(config.Hooks)) + } + if findHook(config.Hooks, "blocked") == nil { + t.Fatalf("blocked hook was lost: %v", hookIDs(config.Hooks)) + } +} + +// TestNoOpMutationsDoNotCreateConfigState pins the working-tree rule for the +// new lock: removing or disabling a hook that does not exist must not create +// .zero/ or hooks.json.lock. +func TestNoOpMutationsDoNotCreateConfigState(t *testing.T) { + configDir := filepath.Join(t.TempDir(), ".zero") + path := filepath.Join(configDir, "hooks.json") + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + + if removed, err := store.Remove("missing"); err != nil || removed { + t.Fatalf("Remove(missing) = %v, %v; want false, nil", removed, err) + } + if changed, err := store.SetEnabled("missing", false); err != nil || changed { + t.Fatalf("SetEnabled(missing) = %v, %v; want false, nil", changed, err) + } + if _, err := os.Lstat(configDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("no-op mutation created config state %s: %v", configDir, err) + } + if _, err := os.Lstat(path + ".lock"); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("no-op mutation created lock file: %v", err) + } +} + +// TestCrossProcessHooksMutationExcludesAndPreserves is the coordinated +// two-process regression for lost updates. The child announces itself and then +// mutates; the parent holds the config lock across that window and asserts the +// child's write cannot land, then does its own mutation and releases. Both +// updates must survive. +func TestCrossProcessHooksMutationExcludesAndPreserves(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.json") + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + if _, err := store.Upsert(Definition{ID: "seed", Event: EventAfterTool, Command: "node"}); err != nil { + t.Fatalf("seed hook: %v", err) + } + + parentLock, err := lockutil.TryAcquireFileLock(path + ".lock") + if err != nil { + t.Fatalf("acquire config lock: %v", err) + } + released := false + release := func() { + if released { + return + } + released = true + if err := parentLock.Release(); err != nil { + t.Errorf("release config lock: %v", err) + } + } + defer release() + + child := exec.Command(os.Args[0], "-test.run=^TestHooksConfigConcurrentHelperProcess$") + child.Env = append(os.Environ(), + "ZERO_HOOKS_CONFIG_CONCURRENT_HELPER=1", + "ZERO_HOOKS_CONFIG_CONCURRENT_PATH="+path, + ) + stdout, err := child.StdoutPipe() + if err != nil { + t.Fatalf("stdout pipe: %v", err) + } + if err := child.Start(); err != nil { + t.Fatalf("start helper: %v", err) + } + defer func() { + if child.ProcessState == nil { + _ = child.Process.Kill() + _ = child.Wait() + } + }() + + ready := make([]byte, len("ready\n")) + if _, err := io.ReadFull(stdout, ready); err != nil { + t.Fatalf("helper never signalled ready: %v", err) + } + + // The child is now live and contending for a lock this process holds. Its + // write must not appear while we hold it. + for range 20 { + config, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if findHook(config.Hooks, "child") != nil { + t.Fatalf("child wrote while %s was held elsewhere; mutations are not excluded across processes", path+".lock") + } + time.Sleep(5 * time.Millisecond) + } + + release() + if _, err := store.Upsert(Definition{ID: "parent", Event: EventAfterTool, Command: "node"}); err != nil { + t.Fatalf("parent mutation: %v", err) + } + if err := child.Wait(); err != nil { + t.Fatalf("helper process failed: %v", err) + } + + config, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + for _, id := range []string{"seed", "child", "parent"} { + if findHook(config.Hooks, id) == nil { + t.Fatalf("hook %q was lost: %v", id, hookIDs(config.Hooks)) + } + } +} + +// TestHooksConfigConcurrentHelperProcess is the child half of the cross-process +// test. It is skipped unless the parent selected it through the environment. +func TestHooksConfigConcurrentHelperProcess(t *testing.T) { + if os.Getenv("ZERO_HOOKS_CONFIG_CONCURRENT_HELPER") == "" { + t.Skip("helper process for TestCrossProcessHooksMutationExcludesAndPreserves") + } + path := os.Getenv("ZERO_HOOKS_CONFIG_CONCURRENT_PATH") + if path == "" { + t.Fatal("ZERO_HOOKS_CONFIG_CONCURRENT_PATH is required") + } + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + // Announce before contending, so the parent's exclusion window starts with + // this process already running rather than still being forked. + fmt.Println("ready") + if _, err := store.Upsert(Definition{ID: "child", Event: EventAfterTool, Command: "node"}); err != nil { + t.Fatalf("child mutation: %v", err) + } +} + +// TestWriteConfigSyncsFileAndDirectory pins the crash-safety barriers. The +// config directory already exists (t.TempDir), so the only directory sync this +// call performs is the one after the rename. +func TestWriteConfigSyncsFileAndDirectory(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.json") + origFile, origDir := syncHooksFileFn, syncHooksDirFn + t.Cleanup(func() { + syncHooksFileFn, syncHooksDirFn = origFile, origDir + }) + fileSynced, dirSynced := false, false + syncHooksFileFn = func(file *os.File) error { + fileSynced = true + return origFile(file) + } + syncHooksDirFn = func(dir string) error { + dirSynced = true + return origDir(dir) + } + + config := Config{Enabled: true, Hooks: []Definition{{ID: "a", Event: EventAfterTool, Command: "node"}}} + if err := WriteConfig(path, config); err != nil { + t.Fatalf("WriteConfig: %v", err) + } + if !fileSynced { + t.Fatal("WriteConfig returned without syncing the temp file") + } + if !dirSynced { + t.Fatal("WriteConfig returned without syncing the parent directory") + } +} + +// TestWriteConfigSyncFailurePreservesPreviousConfig makes the new file fail +// before it becomes visible. The old hooks must still load and the failed +// attempt must not leave its temp file behind. +func TestWriteConfigSyncFailurePreservesPreviousConfig(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "hooks.json") + oldConfig := Config{Enabled: true, Hooks: []Definition{{ID: "old", Event: EventAfterTool, Command: "node"}}} + if err := WriteConfig(path, oldConfig); err != nil { + t.Fatalf("seed WriteConfig: %v", err) + } + + origFile := syncHooksFileFn + t.Cleanup(func() { syncHooksFileFn = origFile }) + syncErr := errors.New("sync failed") + syncHooksFileFn = func(*os.File) error { return syncErr } + + newConfig := Config{Enabled: true, Hooks: []Definition{{ID: "new", Event: EventAfterTool, Command: "node"}}} + err := WriteConfig(path, newConfig) + if !errors.Is(err, syncErr) { + t.Fatalf("WriteConfig error = %v, want it to wrap %v", err, syncErr) + } + + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + config, err := store.List() + if err != nil { + t.Fatalf("List after failed write: %v", err) + } + if findHook(config.Hooks, "old") == nil { + t.Fatalf("previous hook was lost: %v", hookIDs(config.Hooks)) + } + if findHook(config.Hooks, "new") != nil { + t.Fatalf("failed write became visible: %v", hookIDs(config.Hooks)) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), ".hooks-") && strings.HasSuffix(entry.Name(), ".tmp") { + t.Fatalf("failed write left temp file %s", entry.Name()) + } + } +} + +// TestWriteConfigReportsUnlockFailure pins the AGENTS.md rule that a mutator +// must not report success when unlock failed. The write itself is already +// committed and must remain; the release failure annotates the result. +func TestWriteConfigReportsUnlockFailure(t *testing.T) { + path := filepath.Join(t.TempDir(), "hooks.json") + orig := configLockFileFn + t.Cleanup(func() { configLockFileFn = orig }) + releaseErr := errors.New("release exploded") + configLockFileFn = func(path string) (func() error, error) { + unlock, err := orig(path) + if err != nil { + return nil, err + } + return func() error { + _ = unlock() + return releaseErr + }, nil + } + + config := Config{Enabled: true, Hooks: []Definition{{ID: "a", Event: EventAfterTool, Command: "node"}}} + err := WriteConfig(path, config) + if !errors.Is(err, releaseErr) { + t.Fatalf("WriteConfig error = %v, want it to carry the release failure", err) + } + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + loaded, err := store.List() + if err != nil { + t.Fatalf("List: %v", err) + } + if findHook(loaded.Hooks, "a") == nil { + t.Fatalf("published config lost the hook: %v", hookIDs(loaded.Hooks)) + } +} + +// TestWriteConfigCreatesMissingDirectories covers the first-write path: the +// config directory and its parents may not exist yet. +func TestWriteConfigCreatesMissingDirectories(t *testing.T) { + path := filepath.Join(t.TempDir(), "a", "b", "hooks.json") + config := Config{Enabled: true, Hooks: []Definition{{ID: "a", Event: EventAfterTool, Command: "node"}}} + if err := WriteConfig(path, config); err != nil { + t.Fatalf("WriteConfig: %v", err) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("config was not created: %v", err) + } + store, err := NewConfigStore(StoreOptions{ConfigPath: path}) + if err != nil { + t.Fatalf("NewConfigStore: %v", err) + } + loaded, err := store.List() + if err != nil || findHook(loaded.Hooks, "a") == nil { + t.Fatalf("List = %#v err=%v", loaded, err) + } +} diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go index f7dd79cea..df931fece 100644 --- a/internal/hooks/hooks.go +++ b/internal/hooks/hooks.go @@ -8,10 +8,13 @@ import ( "os" "path/filepath" "regexp" + "runtime" "sort" "strings" "sync" "time" + + "github.com/Twigpine/zero/internal/fsutil" ) type Event string @@ -241,7 +244,44 @@ func LoadConfig(options LoadOptions) (LoadResult, error) { }, nil } -func WriteConfig(path string, config Config) error { +// WriteConfig durably publishes config at path. It takes the same +// .lock used by ConfigStore mutations, so a direct caller cannot +// interleave with a CLI mutation, and it replaces the file with a synced temp +// file, an atomic rename, and a parent-directory sync. +func WriteConfig(path string, config Config) (err error) { + resolved, err := filepath.Abs(path) + if err != nil { + return err + } + return withConfigLock(resolved, func() error { + return writeConfigLocked(resolved, config) + }) +} + +// withConfigLock serializes a path's read-modify-write across goroutines and +// processes. The in-process mutex is acquired first; the OS lock is held until +// fn returns. A release failure is joined into the result: reporting success +// while the lock is still held would let the next mutation fail or block +// unexpectedly. The kernel releases the lock on process exit, so the lock file +// is never removed. +func withConfigLock(path string, fn func() error) (err error) { + lock := lockForPath(path) + lock.Lock() + defer lock.Unlock() + + unlock, err := configLockFileFn(path) + if err != nil { + return err + } + defer func() { + err = errors.Join(err, unlock()) + }() + return fn() +} + +// writeConfigLocked normalizes and publishes config. The caller must hold the +// config lock for path. +func writeConfigLocked(path string, config Config) error { normalized, err := normalizeConfig(map[string]any{ "enabled": config.Enabled, "hooks": definitionsToRaw(config.Hooks), @@ -249,28 +289,146 @@ func WriteConfig(path string, config Config) error { if err != nil { return err } - resolved, err := filepath.Abs(path) + data, err := json.MarshalIndent(normalized, "", " ") if err != nil { return err } - if err := os.MkdirAll(filepath.Dir(resolved), 0o700); err != nil { - return err + return writeConfigData(path, append(data, '\n')) +} + +// Seams allow tests to inject lock and persistence failures at each barrier. +var ( + configLockFileFn = lockConfigFile + syncHooksFileFn = (*os.File).Sync + syncHooksDirFn = syncHooksDir +) + +// syncHooksDir fsyncs a directory so a rename/create within it is durable +// across a crash. Windows cannot sync directory handles, so the rename is +// best-effort durable there. A directory the user can write but not read +// cannot be opened for syncing either; that is also best effort, because the +// write it follows already succeeded. Every other failure is reported. +func syncHooksDir(dir string) error { + if runtime.GOOS == "windows" { + return nil } - data, err := json.MarshalIndent(normalized, "", " ") + d, err := os.Open(dir) if err != nil { + if errors.Is(err, os.ErrPermission) { + return nil + } return err } - tempPath := fmt.Sprintf("%s.tmp-%d-%d", resolved, os.Getpid(), time.Now().UnixNano()) - if err := os.WriteFile(tempPath, append(data, '\n'), 0o600); err != nil { + return errors.Join(d.Sync(), d.Close()) +} + +// writeConfigData writes a complete temp file, syncs it, atomically replaces +// path, and syncs the directory. An interrupted in-place write would leave the +// user's only hooks config truncated or corrupt; the rename makes readers see +// either the old document or the new one, never a partial file. +func writeConfigData(path string, data []byte) error { + dir := filepath.Dir(path) + if err := ensureConfigDir(dir); err != nil { return err } - if err := os.Rename(tempPath, resolved); err != nil { - _ = os.Remove(tempPath) - return err + if len(data) == 0 || data[len(data)-1] != '\n' { + data = append(data, '\n') + } + tmp, err := os.CreateTemp(dir, ".hooks-*.tmp") + if err != nil { + return fmt.Errorf("write hooks config %s: %w", path, err) + } + tmpPath := tmp.Name() + defer os.Remove(tmpPath) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("secure hooks config permissions %s: %w", path, err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("write hooks config %s: %w", path, err) + } + if err := syncHooksFileFn(tmp); err != nil { + return fmt.Errorf("sync hooks config %s: %w", path, errors.Join(err, tmp.Close())) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("write hooks config %s: %w", path, err) + } + if err := fsutil.RenameWithRetry(tmpPath, path, nil); err != nil { + return fmt.Errorf("replace hooks config %s: %w", path, err) + } + // The replacement is already visible if this fails; do not roll it back. + if err := syncHooksDirFn(dir); err != nil { + return fmt.Errorf("sync hooks config directory %s: %w", dir, err) + } + return nil +} + +// ensureConfigDir creates dir and each missing ancestor 0700, syncing every +// new entry into its parent so a crash cannot lose the directory (and with it +// the hooks config) after a successful write. Components that appeared between +// the lookup and the mkdir belong to another writer and are not rolled back. +// Only directories created by this call are removed if a barrier fails. +func ensureConfigDir(dir string) error { + if dir == "." || dir == "" { + return nil + } + missing := missingConfigDirs(dir) + if len(missing) == 0 { + return nil + } + var created []string + for i := len(missing) - 1; i >= 0; i-- { + d := missing[i] + err := os.Mkdir(d, 0o700) + if errors.Is(err, os.ErrExist) { + continue + } + if err != nil { + return errors.Join(fmt.Errorf("create hooks config directory %s: %w", d, err), removeConfigDirs(created)) + } + created = append(created, d) + } + for _, d := range missing { + parent := filepath.Dir(d) + if err := syncHooksDirFn(parent); err != nil { + return errors.Join(fmt.Errorf("sync hooks config directory %s: %w", parent, err), removeConfigDirs(created)) + } } return nil } +// missingConfigDirs lists dir and each ancestor that does not exist yet, +// deepest first, so ensureConfigDir can create them shallowest first and sync +// each new entry into its parent deepest first. +func missingConfigDirs(dir string) []string { + var missing []string + for d := dir; ; { + if _, err := os.Lstat(d); !errors.Is(err, os.ErrNotExist) { + return missing + } + missing = append(missing, d) + parent := filepath.Dir(d) + if parent == d { + return missing + } + d = parent + } +} + +// removeConfigDirs rolls back directories ensureConfigDir created in this call, +// deepest first. os.Remove refuses a non-empty directory, so an entry another +// process already populated survives and its error is reported. +func removeConfigDirs(created []string) error { + var errs []error + for i := len(created) - 1; i >= 0; i-- { + if err := os.Remove(created[i]); err != nil { + errs = append(errs, fmt.Errorf("roll back hooks config directory %s: %w", created[i], err)) + } + } + return errors.Join(errs...) +} + type ConfigStore struct { configPath string } @@ -296,84 +454,97 @@ func (store *ConfigStore) List() (Config, error) { } func (store *ConfigStore) Upsert(hook Definition) (Definition, error) { - lock := lockForPath(store.configPath) - lock.Lock() - defer lock.Unlock() - - config, err := store.List() - if err != nil { - return Definition{}, err - } - raw := definitionToRaw(hook) - // Upsert treats the zero-value Enabled field as omitted so new hooks default - // to enabled. Disable persisted hooks explicitly with SetEnabled. - if !hook.Enabled { - delete(raw, "enabled") - } - normalized, err := normalizeDefinition(raw, "hooks.0") - if err != nil { - return Definition{}, err - } - next := make([]Definition, 0, len(config.Hooks)+1) - for _, existing := range config.Hooks { - if existing.ID != normalized.ID { - next = append(next, existing) + var saved Definition + err := withConfigLock(store.configPath, func() error { + config, err := store.List() + if err != nil { + return err } - } - next = append(next, normalized) - sortDefinitions(next) - config.Hooks = next - if err := WriteConfig(store.configPath, config); err != nil { + raw := definitionToRaw(hook) + // Upsert treats the zero-value Enabled field as omitted so new hooks default + // to enabled. Disable persisted hooks explicitly with SetEnabled. + if !hook.Enabled { + delete(raw, "enabled") + } + normalized, err := normalizeDefinition(raw, "hooks.0") + if err != nil { + return err + } + next := make([]Definition, 0, len(config.Hooks)+1) + for _, existing := range config.Hooks { + if existing.ID != normalized.ID { + next = append(next, existing) + } + } + next = append(next, normalized) + sortDefinitions(next) + config.Hooks = next + if err := writeConfigLocked(store.configPath, config); err != nil { + return err + } + saved = normalized + return nil + }) + if err != nil { return Definition{}, err } - return normalized, nil + return saved, nil } func (store *ConfigStore) Remove(hookID string) (bool, error) { - lock := lockForPath(store.configPath) - lock.Lock() - defer lock.Unlock() - - config, err := store.List() - if err != nil { - return false, err + // A no-op must not create .zero/ or the lock file: removing an unknown hook + // from a project with no hooks config should leave the working tree alone. + // A concurrent Upsert that creates the file is simply ordered after this + // read. + if _, err := os.Lstat(store.configPath); errors.Is(err, os.ErrNotExist) { + return false, nil } - next := make([]Definition, 0, len(config.Hooks)) removed := false - for _, hook := range config.Hooks { - if hook.ID == hookID { - removed = true - continue + err := withConfigLock(store.configPath, func() error { + config, err := store.List() + if err != nil { + return err } - next = append(next, hook) - } - if !removed { - return false, nil - } - config.Hooks = next - return true, WriteConfig(store.configPath, config) + next := make([]Definition, 0, len(config.Hooks)) + for _, hook := range config.Hooks { + if hook.ID == hookID { + removed = true + continue + } + next = append(next, hook) + } + if !removed { + return nil + } + config.Hooks = next + return writeConfigLocked(store.configPath, config) + }) + return removed, err } func (store *ConfigStore) SetEnabled(hookID string, enabled bool) (bool, error) { - lock := lockForPath(store.configPath) - lock.Lock() - defer lock.Unlock() - - config, err := store.List() - if err != nil { - return false, err + // See Remove: changing an unknown hook must not create config state. + if _, err := os.Lstat(store.configPath); errors.Is(err, os.ErrNotExist) { + return false, nil } changed := false - for index := range config.Hooks { - if config.Hooks[index].ID == hookID { - config.Hooks[index].Enabled = enabled - changed = true + err := withConfigLock(store.configPath, func() error { + config, err := store.List() + if err != nil { + return err } - } - if !changed { - return false, nil - } - return true, WriteConfig(store.configPath, config) + for index := range config.Hooks { + if config.Hooks[index].ID == hookID { + config.Hooks[index].Enabled = enabled + changed = true + } + } + if !changed { + return nil + } + return writeConfigLocked(store.configPath, config) + }) + return changed, err } func Select(config Config, input SelectInput) []Definition { diff --git a/internal/hooks/lock.go b/internal/hooks/lock.go index 7871b0e8e..af7b9412e 100644 --- a/internal/hooks/lock.go +++ b/internal/hooks/lock.go @@ -14,10 +14,38 @@ import ( // single read-then-append (milliseconds), so the timeout is generous relative // to any real hold. const ( - auditLockTimeout = 10 * time.Second - auditLockRetryDelay = 20 * time.Millisecond + auditLockTimeout = 10 * time.Second + auditLockRetryDelay = 20 * time.Millisecond + configLockTimeout = 10 * time.Second + configLockRetryDelay = 20 * time.Millisecond ) +// lockConfigFile takes the cross-process advisory lock that serializes every +// mutation of a hooks config. The lock file is a stable sibling of the config +// (.lock): the kernel releases it when the holder exits, so a crash never +// leaves a lock that needs reclaiming and the path must never be renamed or +// removed. +func lockConfigFile(path string) (func() error, error) { + lockPath := path + ".lock" + if err := ensureConfigDir(filepath.Dir(lockPath)); err != nil { + return nil, err + } + deadline := time.Now().Add(configLockTimeout) + for { + lock, err := lockutil.TryAcquireFileLock(lockPath) + if err == nil { + return lock.Release, nil + } + if !errors.Is(err, lockutil.ErrLockHeld) { + return nil, fmt.Errorf("hooks: acquire config lock: %w", err) + } + if !time.Now().Before(deadline) { + return nil, fmt.Errorf("hooks: timed out acquiring config lock %s", lockPath) + } + time.Sleep(configLockRetryDelay) + } +} + // lockAudit takes a cross-process advisory lock on the audit log through a // stable sibling ".lock" file. It makes // the lastSequence()+append in append() atomic across processes; the audit log is