From 7ea523ee7b78fe0d962bf9660cb707cc25374d33 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 9 Mar 2026 13:53:58 +0000 Subject: [PATCH] feat: add PIDFile for single-instance enforcement PIDFile manages a process ID lock file with Acquire/Release semantics. Detects stale PIDs via signal(0) probe, creates parent directories automatically. Includes standalone ReadPID helper for checking if a PID file's process is still alive. Co-Authored-By: Virgil --- pidfile.go | 92 +++++++++++++++++++++++++++++++++++++++++++++++++ pidfile_test.go | 70 +++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 pidfile.go create mode 100644 pidfile_test.go diff --git a/pidfile.go b/pidfile.go new file mode 100644 index 0000000..3a2083e --- /dev/null +++ b/pidfile.go @@ -0,0 +1,92 @@ +package process + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" +) + +// 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 := os.ReadFile(p.path); err == nil { + pid, err := strconv.Atoi(strings.TrimSpace(string(data))) + if err == nil && pid > 0 { + if proc, err := os.FindProcess(pid); err == nil { + if err := proc.Signal(syscall.Signal(0)); err == nil { + return fmt.Errorf("another instance is running (PID %d)", pid) + } + } + } + _ = os.Remove(p.path) + } + + if dir := filepath.Dir(p.path); dir != "." { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create PID directory: %w", err) + } + } + + pid := os.Getpid() + if err := os.WriteFile(p.path, []byte(strconv.Itoa(pid)), 0644); err != nil { + return fmt.Errorf("failed to write PID file: %w", err) + } + + return nil +} + +// Release removes the PID file. +func (p *PIDFile) Release() error { + p.mu.Lock() + defer p.mu.Unlock() + return os.Remove(p.path) +} + +// 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 := os.ReadFile(path) + if err != nil { + return 0, false + } + + pid, err := strconv.Atoi(strings.TrimSpace(string(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 +} diff --git a/pidfile_test.go b/pidfile_test.go new file mode 100644 index 0000000..97eb147 --- /dev/null +++ b/pidfile_test.go @@ -0,0 +1,70 @@ +package process + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPIDFile_AcquireAndRelease(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "test.pid") + pid := NewPIDFile(pidPath) + err := pid.Acquire() + require.NoError(t, err) + data, err := os.ReadFile(pidPath) + require.NoError(t, err) + assert.NotEmpty(t, data) + err = pid.Release() + require.NoError(t, err) + _, err = os.Stat(pidPath) + assert.True(t, os.IsNotExist(err)) +} + +func TestPIDFile_StalePID(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "stale.pid") + require.NoError(t, os.WriteFile(pidPath, []byte("999999999"), 0644)) + pid := NewPIDFile(pidPath) + err := pid.Acquire() + require.NoError(t, err) + err = pid.Release() + require.NoError(t, err) +} + +func TestPIDFile_CreatesParentDirectory(t *testing.T) { + pidPath := filepath.Join(t.TempDir(), "subdir", "nested", "test.pid") + pid := NewPIDFile(pidPath) + err := pid.Acquire() + require.NoError(t, err) + err = pid.Release() + require.NoError(t, err) +} + +func TestPIDFile_Path(t *testing.T) { + pid := NewPIDFile("/tmp/test.pid") + assert.Equal(t, "/tmp/test.pid", pid.Path()) +} + +func TestReadPID_Missing(t *testing.T) { + pid, running := ReadPID("/nonexistent/path.pid") + assert.Equal(t, 0, pid) + assert.False(t, running) +} + +func TestReadPID_InvalidContent(t *testing.T) { + path := filepath.Join(t.TempDir(), "bad.pid") + require.NoError(t, os.WriteFile(path, []byte("notanumber"), 0644)) + pid, running := ReadPID(path) + assert.Equal(t, 0, pid) + assert.False(t, running) +} + +func TestReadPID_StalePID(t *testing.T) { + path := filepath.Join(t.TempDir(), "stale.pid") + require.NoError(t, os.WriteFile(path, []byte("999999999"), 0644)) + pid, running := ReadPID(path) + assert.Equal(t, 999999999, pid) + assert.False(t, running) +}