From 8013a204d123c8ea13dd134e275956d3ebb654ab Mon Sep 17 00:00:00 2001 From: Mike Date: Thu, 14 May 2026 19:50:42 +0100 Subject: [PATCH] fix: build internal/config on Windows internal/config/config.go used syscall.Flock and syscall.LOCK_EX/LOCK_UN directly, so `go install github.com/marcus/td@latest` failed on Windows with `undefined: syscall.Flock`. Split withConfigLock into platform-specific files, mirroring the existing pattern in internal/db/lock_{unix,windows}.go and internal/serve/portfile_{unix,windows}.go: - lock_unix.go -> flock-based (unchanged behavior) - lock_windows.go -> LockFileEx with LOCKFILE_EXCLUSIVE_LOCK (blocking, matching the Unix LOCK_EX semantics) All locking-relevant config tests pass on Windows, including the TestEdgeCases/concurrent_operations stress test that drives ten goroutines through SetFocus/SetActiveWorkSession concurrently. The two TestPermissionErrors failures are pre-existing (the test uses POSIX chmod 0000/0555, which don't map to Windows ACLs) and unrelated to this change. --- internal/config/config.go | 26 +++---------------- internal/config/lock_unix.go | 31 ++++++++++++++++++++++ internal/config/lock_windows.go | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 23 deletions(-) create mode 100644 internal/config/lock_unix.go create mode 100644 internal/config/lock_windows.go 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() +}