a73x

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
}