// 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 on a lock file, waiting or failing // depending on the caller, and Release lets it go. See docs/design.md §3, // §11. // // The lock is the kernel's, taken with flock(2) on an open file descriptor, // not a pid written into a file and believed. The difference is what // happens when a run dies: the kernel drops the lock when the last // descriptor closes, however the process ended, so there is no such thing // as a stale krino lock and nothing has to guess whether a recorded pid is // still the run that wrote it. A pid whose number has since been reused // would make that guess wrong for ever, and two runs reaching the same // "this one is stale" conclusion at the same moment could both take the // lock over. // // The file itself still names the holder, for a human reading it. package lock import ( "context" "errors" "fmt" "io/fs" "os" "path/filepath" "strconv" "strings" "syscall" "time" ) // ErrHeld is returned by Acquire when another krino holds the lock 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: the open descriptor whose flock the kernel is // keeping for us. 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 f *os.File } // Acquire takes the lock at path. Parent directories are created as needed. // // When wait is false, Acquire fails immediately with ErrHeld if another // process holds it, 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, so a run blocked on a lock // still notices Ctrl-C. ctx is not consulted when wait is false or the lock // is free on the first try. 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: open the lock file, take the kernel's lock // on it, and make sure the file we locked is still the one at path. // // That last check is the one subtlety. Release unlinks the file before // closing it, so a process that opened the file just beforehand can end up // holding a lock on an inode that no longer has a name, while another // process creates a fresh file at path and locks that. Comparing the // descriptor's identity with what path now names catches exactly that case, // and the retry then races for the new file like everyone else. 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) } for { f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644) if err != nil { return nil, fmt.Errorf("lock %s: %w", path, err) } if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { f.Close() if errors.Is(err, syscall.EWOULDBLOCK) { return nil, heldBy(path) } return nil, fmt.Errorf("lock %s: %w", path, err) } same, err := stillAtPath(f, path) if err != nil { f.Close() return nil, fmt.Errorf("lock %s: %w", path, err) } if !same { // The holder unlinked it between our open and our flock. f.Close() continue } if err := writeHolder(f); err != nil { f.Close() return nil, fmt.Errorf("lock %s: %w", path, err) } return &Lock{Path: path, f: f}, nil } } // stillAtPath reports whether f is the file path names now. A path that has // gone missing is not an error here: it means the previous holder unlinked // it, which the caller handles by retrying. func stillAtPath(f *os.File, path string) (bool, error) { fi, err := f.Stat() if err != nil { return false, err } on, err := os.Stat(path) if err != nil { if errors.Is(err, fs.ErrNotExist) { return false, nil } return false, err } return os.SameFile(fi, on), nil } // writeHolder records who holds the lock, for a human who finds the file // and wants to know which process to look at. Nothing reads it back: the // kernel, not this text, is what keeps two runs apart. func writeHolder(f *os.File) error { if err := f.Truncate(0); err != nil { return err } if _, err := f.Seek(0, 0); err != nil { return err } _, err := fmt.Fprintf(f, "pid %d\nstarted %s\n", os.Getpid(), time.Now().UTC().Format(time.RFC3339)) return err } // readHolderPid reads the pid recorded in the lock file at path, for the // message only. ok is false when the file cannot be read or names none. 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 } // heldBy is ErrHeld naming the file, so a user who wants to know what is // holding a directory has somewhere to look. func heldBy(path string) error { if pid, ok := readHolderPid(path); ok { return fmt.Errorf("%w (pid %d; lock %s)", ErrHeld, pid, path) } return fmt.Errorf("%w (lock %s)", ErrHeld, path) } // Release unlinks the lock file and closes the descriptor, which is what // drops the kernel's lock. Unlinking first means a process waiting on this // file sees it disappear and retries for the new one rather than holding a // lock on an inode with no name. 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.f == nil { return nil } f := l.f l.f = nil rmErr := os.Remove(l.Path) if rmErr != nil && errors.Is(rmErr, fs.ErrNotExist) { rmErr = nil } closeErr := f.Close() if rmErr != nil { return fmt.Errorf("release lock %s: %w", l.Path, rmErr) } if closeErr != nil { return fmt.Errorf("release lock %s: %w", l.Path, closeErr) } return nil }