// 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 } } } // WithLock acquires the lock at lockPath, runs fn while holding it, and // releases the lock. If the lock cannot be acquired within timeout, fn is // NOT called and (skipped=true, err=nil) is returned — this matches the // ctask "warn and skip, never hang" contract for metadata writes. // Any error returned by fn is surfaced to the caller with skipped=false. func WithLock(lockPath string, timeout, staleAfter time.Duration, fn func() error) (skipped bool, err error) { release, err := Acquire(lockPath, timeout, staleAfter) if err != nil { if errors.Is(err, ErrTimeout) { return true, nil } return false, err } defer release() return false, fn() }