// 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 } } }