/ internal / daemon / pidfile.go
pidfile.go
  1  package daemon
  2  
  3  import (
  4  	"fmt"
  5  	"os"
  6  	"strconv"
  7  	"strings"
  8  	"syscall"
  9  )
 10  
 11  // PIDFile manages a flock-guarded PID file for daemon single-instance enforcement.
 12  // The flock is held for the entire daemon lifetime. On crash, the OS releases
 13  // the lock automatically — no stale PID file problem.
 14  type PIDFile struct {
 15  	path string
 16  	file *os.File
 17  }
 18  
 19  // AcquirePIDFile attempts to acquire an exclusive flock on the PID file at path.
 20  // If another daemon holds the lock, it returns an error with the existing PID.
 21  // On success, the current process PID is written to the file and the lock is held
 22  // until Close() is called (or the process exits).
 23  func AcquirePIDFile(path string) (*PIDFile, error) {
 24  	f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600)
 25  	if err != nil {
 26  		return nil, fmt.Errorf("open pid file: %w", err)
 27  	}
 28  
 29  	// Non-blocking exclusive lock.
 30  	if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
 31  		// Lock held by another process — read existing PID for the error message.
 32  		existingPID := readPIDFromFile(f)
 33  		f.Close()
 34  		if existingPID > 0 {
 35  			return nil, fmt.Errorf("daemon already running (PID %d)", existingPID)
 36  		}
 37  		return nil, fmt.Errorf("daemon already running (could not read PID)")
 38  	}
 39  
 40  	// Lock acquired — write our PID.
 41  	if err := f.Truncate(0); err != nil {
 42  		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
 43  		f.Close()
 44  		return nil, fmt.Errorf("truncate pid file: %w", err)
 45  	}
 46  	if _, err := f.Seek(0, 0); err != nil {
 47  		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
 48  		f.Close()
 49  		return nil, fmt.Errorf("seek pid file: %w", err)
 50  	}
 51  	if _, err := fmt.Fprintf(f, "%d\n", os.Getpid()); err != nil {
 52  		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
 53  		f.Close()
 54  		return nil, fmt.Errorf("write pid: %w", err)
 55  	}
 56  	if err := f.Sync(); err != nil {
 57  		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
 58  		f.Close()
 59  		return nil, fmt.Errorf("sync pid file: %w", err)
 60  	}
 61  
 62  	return &PIDFile{path: path, file: f}, nil
 63  }
 64  
 65  // Close removes the PID file, releases the flock, and closes the file descriptor.
 66  // File is removed while lock is still held to prevent a window where the file
 67  // exists but is unlocked.
 68  func (p *PIDFile) Close() {
 69  	if p.file == nil {
 70  		return
 71  	}
 72  	os.Remove(p.path)
 73  	syscall.Flock(int(p.file.Fd()), syscall.LOCK_UN)
 74  	p.file.Close()
 75  	p.file = nil
 76  }
 77  
 78  // ReadPID reads the PID from a PID file without acquiring a lock.
 79  // Returns 0 and an error if the file doesn't exist or contains invalid data.
 80  func ReadPID(path string) (int, error) {
 81  	data, err := os.ReadFile(path)
 82  	if err != nil {
 83  		return 0, err
 84  	}
 85  	pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
 86  	if err != nil {
 87  		return 0, fmt.Errorf("invalid pid file content: %w", err)
 88  	}
 89  	if pid <= 0 {
 90  		return 0, fmt.Errorf("invalid pid: %d", pid)
 91  	}
 92  	return pid, nil
 93  }
 94  
 95  // IsLocked checks whether the PID file at path is currently locked by another process.
 96  // Returns the PID if locked, 0 if not locked or file doesn't exist.
 97  func IsLocked(path string) (int, bool) {
 98  	f, err := os.OpenFile(path, os.O_RDONLY, 0)
 99  	if err != nil {
100  		return 0, false
101  	}
102  	defer f.Close()
103  
104  	// Try non-blocking lock — if we get it, no one else holds it.
105  	if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err == nil {
106  		syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
107  		return 0, false
108  	}
109  
110  	pid := readPIDFromFile(f)
111  	return pid, true
112  }
113  
114  func readPIDFromFile(f *os.File) int {
115  	if _, err := f.Seek(0, 0); err != nil {
116  		return 0
117  	}
118  	buf := make([]byte, 32)
119  	n, err := f.Read(buf)
120  	if err != nil || n == 0 {
121  		return 0
122  	}
123  	pid, err := strconv.Atoi(strings.TrimSpace(string(buf[:n])))
124  	if err != nil || pid <= 0 {
125  		return 0
126  	}
127  	return pid
128  }