aboutsummaryrefslogtreecommitdiff
path: root/internal/lock/lock.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/lock/lock.go')
-rw-r--r--internal/lock/lock.go181
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
+}