- Update CLAUDE.md: document Detach, DisableCapture, ShutdownTimeout, auto-registration, graceful shutdown, and error handling conventions; add missing go-log and go-io dependencies - Replace ServiceError type in process_global.go with coreerr.E() sentinel errors for consistency with the rest of the package - Wrap raw error returns in Registry.Register, Registry.Unregister, and PIDFile.Release with coreerr.E() for proper context - Add tests for Service.Kill, Service.Output, Service.OnShutdown, Service.OnStartup, Service.RunWithOptions, Service.Running, Process.Signal, Daemon.Run (context cancellation), Daemon.Stop (idempotent), DisableCapture, Detach, env vars, exec.WithDir, exec.WithEnv, exec.WithStdin/Stdout/Stderr, exec.RunQuiet - Coverage: root 82.7% → 88.3%, exec/ 61.9% → 87.3% Co-Authored-By: Virgil <virgil@lethean.io>
98 lines
2.2 KiB
Go
98 lines
2.2 KiB
Go
package process
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
|
|
coreio "forge.lthn.ai/core/go-io"
|
|
coreerr "forge.lthn.ai/core/go-log"
|
|
)
|
|
|
|
// PIDFile manages a process ID file for single-instance enforcement.
|
|
type PIDFile struct {
|
|
path string
|
|
mu sync.Mutex
|
|
}
|
|
|
|
// NewPIDFile creates a PID file manager.
|
|
func NewPIDFile(path string) *PIDFile {
|
|
return &PIDFile{path: path}
|
|
}
|
|
|
|
// Acquire writes the current PID to the file.
|
|
// Returns error if another instance is running.
|
|
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 := os.FindProcess(pid); err == nil {
|
|
if err := proc.Signal(syscall.Signal(0)); err == nil {
|
|
return coreerr.E("PIDFile.Acquire", fmt.Sprintf("another instance is running (PID %d)", pid), nil)
|
|
}
|
|
}
|
|
}
|
|
_ = coreio.Local.Delete(p.path)
|
|
}
|
|
|
|
if dir := filepath.Dir(p.path); dir != "." {
|
|
if err := coreio.Local.EnsureDir(dir); err != nil {
|
|
return coreerr.E("PIDFile.Acquire", "failed to create PID directory", err)
|
|
}
|
|
}
|
|
|
|
pid := os.Getpid()
|
|
if err := coreio.Local.Write(p.path, strconv.Itoa(pid)); err != nil {
|
|
return coreerr.E("PIDFile.Acquire", "failed to write PID file", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Release removes the PID file.
|
|
func (p *PIDFile) Release() error {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
if err := coreio.Local.Delete(p.path); err != nil {
|
|
return coreerr.E("PIDFile.Release", "failed to remove PID file", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Path returns the PID file 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.
|
|
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 := os.FindProcess(pid)
|
|
if err != nil {
|
|
return pid, false
|
|
}
|
|
|
|
if err := proc.Signal(syscall.Signal(0)); err != nil {
|
|
return pid, false
|
|
}
|
|
|
|
return pid, true
|
|
}
|