diff options
Diffstat (limited to 'internal/lock/lock.go')
| -rw-r--r-- | internal/lock/lock.go | 181 |
1 files changed, 181 insertions, 0 deletions
diff --git a/internal/lock/lock.go b/internal/lock/lock.go new file mode 100644 index 0000000..462b26e --- /dev/null +++ b/internal/lock/lock.go @@ -0,0 +1,181 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Package lock keeps two krino runs from acting on the same directory at +// once: Acquire takes an exclusive lock file, waiting or failing depending +// on the caller, and Release lets it go. See docs/design.md §3, §11. +package lock + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +// ErrHeld is returned by Acquire when the lock is already held by a running +// krino and wait is false. +var ErrHeld = errors.New("another krino is working in this directory") + +// pollInterval is how often a waiting Acquire retries the lock. +const pollInterval = 100 * time.Millisecond + +// Lock is a held lock file. The zero Lock holds nothing; Release on it, or +// on a nil *Lock, is a no-op. +type Lock struct { + // Path is where the lock file lives. + Path string + + // TookOverStale reports whether Acquire found a lock naming a pid that + // was no longer running, and took the lock over. The caller should + // mention this rather than stay silent about it. + TookOverStale bool + + held bool +} + +// Acquire takes the lock at path: an O_CREATE|O_EXCL file naming the +// holder's pid and start time, so a human can see who holds it. Parent +// directories are created as needed. +// +// When wait is false, Acquire fails immediately with ErrHeld if the lock is +// already held, so a cron job never piles up behind a stuck run. When wait +// is true, Acquire polls every 100ms, with no fixed timeout - but it does +// not poll forever regardless of ctx: a cancelled or expired ctx makes a +// waiting Acquire return ctx.Err() promptly instead of ignoring it (fix +// round 2026-09-12/item 3 - a run blocked waiting for a held lock must +// still notice Ctrl-C). ctx is not consulted at all when wait is false or +// the lock is free on the first try, so -y's non-waiting callers are +// unaffected. +// +// A lock naming a pid that is not running is stale — the machine may have +// lost power mid-run. Acquire removes a stale lock and retries the O_EXCL +// create once; if that retry also loses, another process has reached the +// same conclusion first and Acquire treats the lock as held. A takeover is +// reported via the returned Lock's TookOverStale field. +func Acquire(ctx context.Context, path string, wait bool) (*Lock, error) { + for { + l, err := tryAcquire(path) + if err == nil { + return l, nil + } + if !errors.Is(err, ErrHeld) || !wait { + return nil, err + } + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(pollInterval): + } + } +} + +// tryAcquire makes one attempt at the lock: create it, or if it is held, +// decide whether the holder is stale and take it over. +func tryAcquire(path string) (*Lock, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, fmt.Errorf("lock %s: %w", path, err) + } + + if err := create(path); err == nil { + return &Lock{Path: path, held: true}, nil + } else if !errors.Is(err, fs.ErrExist) { + return nil, fmt.Errorf("lock %s: %w", path, err) + } + + pid, ok := readHolderPid(path) + if !ok || running(pid) { + return nil, ErrHeld + } + + // Stale: the recorded pid is not running. Take the lock over by + // removing it and retrying the create once. If that retry also loses, + // another process beat us to the same conclusion — treat it as held. + os.Remove(path) + if err := create(path); err != nil { + if errors.Is(err, fs.ErrExist) { + return nil, ErrHeld + } + return nil, fmt.Errorf("lock %s: %w", path, err) + } + return &Lock{Path: path, held: true, TookOverStale: true}, nil +} + +// create makes path with O_CREATE|O_EXCL and writes the holder's pid and +// start time into it. If the write or close fails after the file was +// created, the file is removed so no half-written lock is left behind. +func create(path string) error { + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err != nil { + return err + } + _, werr := fmt.Fprintf(f, "pid %d\nstarted %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)) + cerr := f.Close() + if werr != nil || cerr != nil { + os.Remove(path) + if werr != nil { + return werr + } + return cerr + } + return nil +} + +// readHolderPid reads the pid recorded in the lock file at path. ok is +// false when the file cannot be read or does not name a pid — in which +// case the caller must not treat the lock as stale. +func readHolderPid(path string) (pid int, ok bool) { + b, err := os.ReadFile(path) + if err != nil { + return 0, false + } + return parsePid(string(b)) +} + +// parsePid extracts the pid from a lock file's "pid N" line. +func parsePid(s string) (int, bool) { + const prefix = "pid " + i := strings.Index(s, prefix) + if i < 0 { + return 0, false + } + s = s[i+len(prefix):] + if j := strings.IndexAny(s, "\n\r \t"); j >= 0 { + s = s[:j] + } + n, err := strconv.Atoi(s) + if err != nil || n <= 0 { + return 0, false + } + return n, true +} + +// running reports whether pid names a process that is currently running. +func running(pid int) bool { + if pid <= 0 { + return false + } + proc, err := os.FindProcess(pid) + if err != nil { + return false + } + return proc.Signal(syscall.Signal(0)) == nil +} + +// Release removes the lock file. Release on a Lock that was never acquired +// (the zero Lock, a nil *Lock, or one already released) is harmless. +func (l *Lock) Release() error { + if l == nil || !l.held { + return nil + } + l.held = false + if err := os.Remove(l.Path); err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("release lock %s: %w", l.Path, err) + } + return nil +} |
