feat(v0.4): add internal/lockfile primitive with atomic exclusive acquire

This commit is contained in:
2026-04-21 17:00:57 -04:00
parent 6532cba94f
commit c64f9ac88c
2 changed files with 77 additions and 0 deletions
+54
View File
@@ -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
}
}
}
+23
View File
@@ -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)
}
}