26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
98 lines
3.4 KiB
Go
98 lines
3.4 KiB
Go
package daemon
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"time"
|
|
|
|
"github.com/kunchenguid/no-mistakes/internal/paths"
|
|
)
|
|
|
|
// ErrSingletonLockHeld is returned by acquireSingletonLock when another live
|
|
// process already holds the lock for the same NM_HOME.
|
|
var ErrSingletonLockHeld = errors.New("a no-mistakes daemon is already running for this NM_HOME")
|
|
|
|
// singletonLock guards NM_HOME against more than one live daemon process.
|
|
// It must be acquired before any global, destructive startup operation
|
|
// (stale-run recovery, orphan worktree cleanup) and before the IPC socket is
|
|
// bound, and held for the lifetime of the process.
|
|
//
|
|
// The underlying lock (tryLockFile/unlockFile, platform-specific in
|
|
// lock_unix.go / lock_windows.go) is the OS's native file lock, which the
|
|
// kernel releases automatically when the owning process exits or dies for
|
|
// any reason (including SIGKILL) - even without an explicit unlock. That
|
|
// self-cleaning property is why this lock needs no separate "is the holder
|
|
// still alive" staleness check the way the PID file does: the lock can only
|
|
// be held by a process that is actually still running.
|
|
type singletonLock struct {
|
|
file *os.File
|
|
}
|
|
|
|
// acquireSingletonLock takes an exclusive, non-blocking OS lock on
|
|
// p.LockFile(). If another live daemon already holds it, it returns
|
|
// ErrSingletonLockHeld wrapped with whatever diagnostic info (pid, start
|
|
// time) the holder recorded, so the caller can report the existing daemon
|
|
// instead of silently proceeding.
|
|
func acquireSingletonLock(p *paths.Paths) (*singletonLock, error) {
|
|
path := p.LockFile()
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open daemon lock %s: %w", path, err)
|
|
}
|
|
if lockErr := tryLockFile(f); lockErr != nil {
|
|
holder := readLockHolder(f)
|
|
f.Close()
|
|
if holder != "" {
|
|
return nil, fmt.Errorf("%w (%s): %w", ErrSingletonLockHeld, holder, lockErr)
|
|
}
|
|
return nil, fmt.Errorf("%w: %s: %w", ErrSingletonLockHeld, path, lockErr)
|
|
}
|
|
|
|
// Best-effort diagnostics: record who holds the lock so a rejected
|
|
// second daemon (or an operator) can identify the live owner. This write
|
|
// is not part of the safety mechanism itself - the OS lock already
|
|
// provides that - so a failure here is non-fatal.
|
|
record := lockHolderRecord{PID: os.Getpid(), StartedAt: time.Now().UTC()}
|
|
if data, err := json.Marshal(record); err == nil {
|
|
_ = f.Truncate(0)
|
|
_, _ = f.WriteAt(data, 0)
|
|
_ = f.Sync()
|
|
}
|
|
|
|
return &singletonLock{file: f}, nil
|
|
}
|
|
|
|
// Release drops the lock and closes the underlying file. Safe to call on a
|
|
// nil receiver so callers can defer it unconditionally after a failed
|
|
// acquire.
|
|
func (l *singletonLock) Release() {
|
|
if l == nil || l.file == nil {
|
|
return
|
|
}
|
|
_ = unlockFile(l.file)
|
|
_ = l.file.Close()
|
|
}
|
|
|
|
type lockHolderRecord struct {
|
|
PID int `json:"pid"`
|
|
StartedAt time.Time `json:"started_at"`
|
|
}
|
|
|
|
// readLockHolder best-effort reads the diagnostic record left by whichever
|
|
// process holds the lock. Returns "" if unavailable or unparsable - the
|
|
// caller falls back to a generic message in that case.
|
|
func readLockHolder(f *os.File) string {
|
|
data := make([]byte, 4096)
|
|
n, err := f.ReadAt(data, 0)
|
|
if err != nil && n == 0 {
|
|
return ""
|
|
}
|
|
var record lockHolderRecord
|
|
if err := json.Unmarshal(data[:n], &record); err != nil || record.PID <= 0 {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("pid %d, started %s", record.PID, record.StartedAt.Format(time.RFC3339))
|
|
}
|