diff --git a/internal/lockfile/writelock.go b/internal/lockfile/writelock.go new file mode 100644 index 0000000..aea3acd --- /dev/null +++ b/internal/lockfile/writelock.go @@ -0,0 +1,54 @@ +// Package lockfile provides a simple file-based exclusive lock primitive. +// It is used to serialize writes to ctask metadata files across cooperating +// ctask processes running in the same workspace. +package lockfile + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// ErrTimeout is returned when Acquire cannot obtain the lock within its timeout. +var ErrTimeout = errors.New("lockfile: acquire timeout") + +// Acquire attempts to create lockPath exclusively. If the file already exists +// and is older than staleAfter, it is removed and creation is retried. +// Retries with short backoff for up to timeout. On success, returns a +// release function that removes the lock file. +func Acquire(lockPath string, timeout, staleAfter time.Duration) (func(), error) { + if err := os.MkdirAll(filepath.Dir(lockPath), 0755); err != nil { + return nil, fmt.Errorf("preparing lock dir: %w", err) + } + + deadline := time.Now().Add(timeout) + backoff := 25 * time.Millisecond + + for { + f, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644) + if err == nil { + f.Close() + return func() { os.Remove(lockPath) }, nil + } + if !errors.Is(err, os.ErrExist) { + return nil, fmt.Errorf("creating lock: %w", err) + } + + if info, statErr := os.Stat(lockPath); statErr == nil { + if time.Since(info.ModTime()) > staleAfter { + os.Remove(lockPath) + continue + } + } + + if time.Now().After(deadline) { + return nil, ErrTimeout + } + time.Sleep(backoff) + if backoff < 200*time.Millisecond { + backoff *= 2 + } + } +} diff --git a/internal/lockfile/writelock_test.go b/internal/lockfile/writelock_test.go new file mode 100644 index 0000000..9fd069e --- /dev/null +++ b/internal/lockfile/writelock_test.go @@ -0,0 +1,23 @@ +package lockfile + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestAcquireCreatesLockFile(t *testing.T) { + dir := t.TempDir() + lockPath := filepath.Join(dir, "write.lock") + + release, err := Acquire(lockPath, 2*time.Second, 10*time.Second) + if err != nil { + t.Fatalf("Acquire: %v", err) + } + defer release() + + if _, err := os.Stat(lockPath); err != nil { + t.Errorf("lock file should exist after Acquire: %v", err) + } +}