go-process/pidfile.go
Snider 6db0ad26e3 fix(registry): idempotent Unregister/Release when file missing
The coreio.Local.Delete error wraps the underlying os.ErrNotExist
through core.E, so the prior os.IsNotExist check on the registry
Unregister path never matched. Same wrapping broke the daemon
Stop path that relied on pidfile.Release being a no-op for absent
files.

Switch both to coreio.Local.Exists before Delete, which is the
idempotent pattern the callers already assume. Adds coverage for
TestPIDFile_Release_MissingIsNoop and fixes TestRegistry_Unregister
MissingIsNoop.

Co-Authored-By: Virgil <virgil@lethean.io>
2026-04-14 19:44:06 +01:00

124 lines
2.7 KiB
Go

package process
import (
"path/filepath"
"strconv"
"strings"
"sync"
"syscall"
"dappco.re/go/core"
coreio "dappco.re/go/core/io"
)
// PIDFile manages a process ID file for single-instance enforcement.
//
// Example:
//
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
type PIDFile struct {
path string
mu sync.Mutex
}
// NewPIDFile creates a PID file manager.
//
// Example:
//
// pidFile := process.NewPIDFile("/var/run/myapp.pid")
func NewPIDFile(path string) *PIDFile {
return &PIDFile{path: path}
}
// Acquire writes the current PID to the file.
// Returns error if another instance is running.
//
// Example:
//
// if err := pidFile.Acquire(); err != nil { return err }
func (p *PIDFile) Acquire() error {
p.mu.Lock()
defer p.mu.Unlock()
if data, err := coreio.Local.Read(p.path); err == nil {
pid, err := strconv.Atoi(strings.TrimSpace(data))
if err == nil && pid > 0 {
if proc, err := processHandle(pid); err == nil {
if err := proc.Signal(syscall.Signal(0)); err == nil {
return core.E("pidfile.acquire", core.Concat("another instance is running (PID ", strconv.Itoa(pid), ")"), nil)
}
}
}
_ = coreio.Local.Delete(p.path)
}
if dir := filepath.Dir(p.path); dir != "." {
if err := coreio.Local.EnsureDir(dir); err != nil {
return core.E("pidfile.acquire", "failed to create PID directory", err)
}
}
pid := currentPID()
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
return core.E("pidfile.acquire", "failed to write PID file", err)
}
return nil
}
// Release removes the PID file.
// Returns nil if the PID file does not exist.
//
// Example:
//
// _ = pidFile.Release()
func (p *PIDFile) Release() error {
p.mu.Lock()
defer p.mu.Unlock()
if !coreio.Local.Exists(p.path) {
return nil
}
if err := coreio.Local.Delete(p.path); err != nil {
return core.E("pidfile.release", "failed to remove PID file", err)
}
return nil
}
// Path returns the PID file path.
//
// Example:
//
// path := pidFile.Path()
func (p *PIDFile) Path() string {
return p.path
}
// ReadPID reads a PID file and checks if the process is still running.
// Returns (pid, true) if the process is alive, (pid, false) if dead/stale,
// or (0, false) if the file doesn't exist or is invalid.
//
// Example:
//
// pid, running := process.ReadPID("/var/run/myapp.pid")
func ReadPID(path string) (int, bool) {
data, err := coreio.Local.Read(path)
if err != nil {
return 0, false
}
pid, err := strconv.Atoi(strings.TrimSpace(data))
if err != nil || pid <= 0 {
return 0, false
}
proc, err := processHandle(pid)
if err != nil {
return pid, false
}
if err := proc.Signal(syscall.Signal(0)); err != nil {
return pid, false
}
return pid, true
}