// 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" "math" "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 - 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. // A pid outside the kernel's 32-bit range names no process: kill(2) would // cut it to its low bits and ask about another one. func running(pid int) bool { if pid <= 0 || pid > math.MaxInt32 { 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 }