Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:34:16 +08:00

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))
}