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 <virgil@lethean.io>
This commit is contained in:
parent
4fc5c3b0e5
commit
7ea523ee7b
2 changed files with 162 additions and 0 deletions
92
pidfile.go
Normal file
92
pidfile.go
Normal file
|
|
@ -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
|
||||
}
|
||||
70
pidfile_test.go
Normal file
70
pidfile_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue