diff --git a/internal/config/config.go b/internal/config/config.go index b257239c..88940747 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,7 +6,6 @@ import ( "encoding/json" "os" "path/filepath" - "syscall" "github.com/marcus/td/internal/models" ) @@ -14,6 +13,9 @@ import ( const configFile = ".todos/config.json" const lockFile = ".todos/config.json.lock" +// withConfigLock serializes access to config.json using an OS file lock. +// Implemented per-platform in lock_unix.go (flock) and lock_windows.go (LockFileEx). + // Title validation defaults const ( DefaultTitleMinLength = 15 @@ -75,28 +77,6 @@ func Save(baseDir string, cfg *models.Config) error { return os.Rename(tmpName, configPath) } -// withConfigLock serializes access to config.json using flock -func withConfigLock(baseDir string, fn func() error) error { - lockPath := filepath.Join(baseDir, lockFile) - - if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { - return err - } - - f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) - if err != nil { - return err - } - defer f.Close() - - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { - return err - } - defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() - - return fn() -} - // SetFocus sets the focused issue ID func SetFocus(baseDir string, issueID string) error { return withConfigLock(baseDir, func() error { diff --git a/internal/config/lock_unix.go b/internal/config/lock_unix.go new file mode 100644 index 00000000..fd8d9261 --- /dev/null +++ b/internal/config/lock_unix.go @@ -0,0 +1,31 @@ +//go:build unix + +package config + +import ( + "os" + "path/filepath" + "syscall" +) + +// withConfigLock serializes access to config.json using flock. +func withConfigLock(baseDir string, fn func() error) error { + lockPath := filepath.Join(baseDir, lockFile) + + if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { + return err + } + + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return err + } + defer f.Close() + + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { + return err + } + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() + + return fn() +} diff --git a/internal/config/lock_windows.go b/internal/config/lock_windows.go new file mode 100644 index 00000000..6c709882 --- /dev/null +++ b/internal/config/lock_windows.go @@ -0,0 +1,46 @@ +//go:build windows + +package config + +import ( + "os" + "path/filepath" + + "golang.org/x/sys/windows" +) + +// withConfigLock serializes access to config.json using LockFileEx. +func withConfigLock(baseDir string, fn func() error) error { + lockPath := filepath.Join(baseDir, lockFile) + + if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { + return err + } + + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_RDWR, 0644) + if err != nil { + return err + } + defer f.Close() + + // LockFileEx with LOCKFILE_EXCLUSIVE_LOCK (blocking) locks the first byte + // of the file, which is sufficient for whole-file mutual exclusion since + // every contender uses the same offset/length. + ol := new(windows.Overlapped) + if err := windows.LockFileEx( + windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK, + 0, // reserved + 1, // lock 1 byte + 0, // high bits of length + ol, + ); err != nil { + return err + } + defer func() { + ul := new(windows.Overlapped) + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, ul) + }() + + return fn() +}