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 }