PID lockfile with stale takeover for single-orchestrator CLIs
byob-runtime-directories.5
concurrencystate
Problem: a CLI whose long-running mode owns shared state (a work
loop, a sync, a migration) must ensure only one orchestrator runs
per repo/state-dir at a time. flock (byob-runtime-directories.3)
excludes correctly but is invisible: nothing on disk tells an
operator who holds the lock, sibling commands can't report "a run
is already active as PID N", and a lock held by a hung process looks
identical to no lock at all.
Idea: a PID lockfile with stale takeover. Acquire writes the
current PID to a temp file in the lock's directory and links it into
place with os.Link — the file appears in the namespace atomically,
so a reader never sees a half-written PID, and Link failing with
ErrExist is the contention signal. On contention, read the
recorded PID and probe liveness (kill(pid, 0); treat EPERM as
alive — better to refuse than to clobber). A dead PID means a stale
file: remove it and retry inside a bounded attempt loop (a racing
peer may win — losers either see a live PID or retry). A live PID
returns a sentinel ErrHeld whose message includes the PID. A
separate read-only ActivePID(path) helper lets status/gc commands
report or avoid the active run without taking the lock.
Tradeoffs:
- vs. flock: flock auto-releases on crash (no stale handling)
but is invisible to ops and other commands. The pidfile is
inspectable (
cat pid.lock) and powers "already running as PID N" UX, at the cost of the stale-detection code, the bounded retry loop, and a theoretical PID-reuse race (an unrelated process recycled the recorded PID and the lock reads as held — rare enough in practice; the failure mode is refusing to start, not corruption). - Single-host only, like flock. State on a network filesystem needs a different scheme entirely.
Releasedeletes the file and is idempotent; a crashed holder simply leaves a stale file for the nextAcquireto take over — no cleanup daemon needed.
Design
var ErrHeld = errors.New("lock: held by another process")
func Acquire(path string) (*Lock, error) {
const maxAttempts = 16
for attempt := 0; attempt < maxAttempts; attempt++ {
l, err := tryCreate(path) // temp-in-same-dir + os.Link → atomic appearance
if err == nil { return l, nil }
if !errors.Is(err, os.ErrExist) { return nil, err }
pid, readErr := readPID(path)
if errors.Is(readErr, os.ErrNotExist) { continue } // peer released; retry
if readErr != nil { return nil, readErr }
if processAlive(pid) {
return nil, fmt.Errorf("%w: pid %d", ErrHeld, pid)
}
// Stale — remove and retry.
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
return nil, fmt.Errorf("lock: could not acquire %s", path)
}
// ActivePID reports the recorded PID and its liveness without taking
// the lock. Missing file → (0, false, nil): nothing is running.
func ActivePID(path string) (pid int, alive bool, err error)
func processAlive(pid int) bool {
p, err := os.FindProcess(pid)
if err != nil { return false }
err = p.Signal(syscall.Signal(0))
return err == nil || errors.Is(err, syscall.EPERM)
}
Callers surface ErrHeld with a hint (byob-errors.2): "another run
is active as pid N; wait for it or stop it".