// Package pidlock provides process-level locking using PID files. // It prevents multiple instances of vaultik from running simultaneously, // which would cause database locking conflicts. package pidlock import ( "errors" "fmt" "os" "path/filepath" "strconv" "strings" "syscall" ) // ErrAlreadyRunning indicates another vaultik instance is running. var ErrAlreadyRunning = errors.New("another vaultik instance is already running") // Lock represents an acquired PID lock. type Lock struct { path string } // Acquire attempts to acquire a PID lock in the specified directory. // If the lock file exists and the process is still running, it returns // ErrAlreadyRunning with details about the existing process. // On success, it writes the current PID to the lock file and returns // a Lock that must be released with Release(). func Acquire(lockDir string) (*Lock, error) { // Ensure lock directory exists if err := os.MkdirAll(lockDir, 0700); err != nil { return nil, fmt.Errorf("creating lock directory: %w", err) } lockPath := filepath.Join(lockDir, "vaultik.pid") // Check for existing lock existingPID, err := readPIDFile(lockPath) if err == nil { // Lock file exists, check if process is running if isProcessRunning(existingPID) { return nil, fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, existingPID) } // Process is not running, stale lock file - we can take over } // Write our PID pid := os.Getpid() if err := os.WriteFile(lockPath, []byte(strconv.Itoa(pid)), 0600); err != nil { return nil, fmt.Errorf("writing PID file: %w", err) } return &Lock{path: lockPath}, nil } // Release removes the PID lock file. // It is safe to call Release multiple times. func (l *Lock) Release() error { if l == nil || l.path == "" { return nil } // Verify we still own the lock (our PID is in the file) existingPID, err := readPIDFile(l.path) if err != nil { // File already gone or unreadable - that's fine return nil } if existingPID != os.Getpid() { // Someone else wrote to our lock file - don't remove it return nil } if err := os.Remove(l.path); err != nil && !os.IsNotExist(err) { return fmt.Errorf("removing PID file: %w", err) } l.path = "" // Prevent double-release return nil } // readPIDFile reads and parses the PID from a lock file. func readPIDFile(path string) (int, error) { data, err := os.ReadFile(path) if err != nil { return 0, err } pid, err := strconv.Atoi(strings.TrimSpace(string(data))) if err != nil { return 0, fmt.Errorf("parsing PID: %w", err) } return pid, nil } // isProcessRunning checks if a process with the given PID is running. func isProcessRunning(pid int) bool { process, err := os.FindProcess(pid) if err != nil { return false } // On Unix, FindProcess always succeeds. We need to send signal 0 to check. err = process.Signal(syscall.Signal(0)) return err == nil }