internal/agent/statelock/statelock.go
Ref: Size: 4.1 KiB History
// Package statelock enforces one running agent per identity.
//
// An agent's identity is its state directory: identity.json, the host
// credential in it, the epoch fence, and every VM record. Two agents sharing
// one of those are not two agents — they are one host answering the control
// plane twice, each reconciling the other's VMs from a half-observed view of
// the disk, and each connecting as the same host so that only one of them is
// ever heard. It happens by accident, most often by launching a second agent
// without repeating --state-dir and landing back on the default.
//
// So the state directory is the thing locked, and deliberately not the machine:
// one host may legitimately run several agents, each with its own directory and
// its own identity in the fleet. Locking anything global would forbid that.
package statelock
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"golang.org/x/sys/unix"
)
// lockName is the file inside the state directory whose flock IS the claim. It
// carries the holder's pid as its contents, which is diagnostic only — the lock
// itself is what the kernel enforces, so a stale pid can never wrongly refuse
// anyone.
const lockName = "agent.lock"
// mode matches every other file in the state directory: the agent's alone.
const mode = 0o600
// Lock is a held claim on one state directory. It owns an open file whose flock
// lives for as long as the file stays open, so the value must outlive every
// caller that relies on the claim.
type Lock struct{ f *os.File }
// Acquire claims dir for this process, and fails rather than waits when another
// agent already holds it — a second launch is a mistake to report, not a queue
// to join.
//
// The lock is released by exec, on purpose. flock ownership rides the open file
// description, and the agent replaces its own binary in place: it renames the
// new image over itself and re-execs, keeping its pid (KillMode=process in the
// unit exists to let that happen). An fd that survived exec would carry the
// claim into the new image, which would then be unable to take a lock it
// already holds — and could not tell that deadlock apart from a real second
// agent. Every fd Go opens is close-on-exec, which is what makes the re-exec'd
// binary land on a free lock and re-take it immediately; the gap is the width
// of an execve, with no other launcher racing for it. The flag is spelled out
// here so that this stays true if the open is ever rewritten.
func Acquire(dir string) (*Lock, error) {
path := filepath.Join(dir, lockName)
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR|unix.O_CLOEXEC, mode)
if err != nil {
return nil, fmt.Errorf("open agent lock: %w", err)
}
if err := unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB); err != nil {
held := holder(path)
f.Close()
if errors.Is(err, unix.EWOULDBLOCK) {
if held <= 0 {
return nil, errors.New("an agent is already running with this identity")
}
return nil, fmt.Errorf("an agent is already running with this identity (pid %d)", held)
}
return nil, fmt.Errorf("lock agent state dir: %w", err)
}
// Ours now — say whose. Truncate first: a shorter pid than the last holder's
// would otherwise leave that one's trailing digits behind.
if err := f.Truncate(0); err != nil {
f.Close()
return nil, fmt.Errorf("truncate agent lock: %w", err)
}
if _, err := f.WriteAt([]byte(strconv.Itoa(os.Getpid())+"\n"), 0); err != nil {
f.Close()
return nil, fmt.Errorf("write agent lock: %w", err)
}
return &Lock{f: f}, nil
}
// Release drops the claim. The agent calls it only on its way out — the lock is
// held for the whole run, not around any particular piece of work.
func (l *Lock) Release() error { return l.f.Close() }
// holder reads the pid the current holder wrote, or 0 when the file says
// nothing usable. Only ever used to make the refusal name a process; the
// refusal itself stands on the kernel's answer.
func holder(path string) int {
raw, err := os.ReadFile(path)
if err != nil {
return 0
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return 0
}
return pid
}