// 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" "golang.org/x/sys/unix" ) // ErrAlreadyRunning indicates another vaultik instance is running. var ErrAlreadyRunning = errors.New("another vaultik instance is already running") // Lock represents an acquired PID lock: an flock(2) on the PID file, // held while the file stays open. The kernel drops it when the process // exits, however it exits, so a crashed run never leaves the lock held. type Lock struct { file *os.File } const ( // lockDirPerm is the mode for the lock directory (owner-only). lockDirPerm = 0o700 // pidFilePerm is the mode for the PID file (owner-only). pidFilePerm = 0o600 ) // Acquire attempts to acquire a PID lock in the specified directory. // If another process holds the lock, it returns ErrAlreadyRunning with // that process's PID. 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 err := os.MkdirAll(lockDir, lockDirPerm) if err != nil { return nil, fmt.Errorf("creating lock directory: %w", err) } lockPath := filepath.Join(lockDir, "vaultik.pid") // No O_TRUNC: the file may hold the PID of the process that has the // lock, which the error below reports. file, err := os.OpenFile( //nolint:gosec // G304: path is our own lock file lockPath, os.O_RDWR|os.O_CREATE, pidFilePerm) if err != nil { return nil, fmt.Errorf("opening PID file: %w", err) } err = unix.Flock(int(file.Fd()), unix.LOCK_EX|unix.LOCK_NB) if err != nil { _ = file.Close() if errors.Is(err, unix.EWOULDBLOCK) { return nil, alreadyRunningError(lockPath) } return nil, fmt.Errorf("locking PID file: %w", err) } err = writePID(file) if err != nil { _ = file.Close() return nil, err } return &Lock{file: file}, nil } // Release empties the PID file and closes it, which drops the lock. // It is safe to call Release multiple times. func (l *Lock) Release() error { if l == nil || l.file == nil { return nil } file := l.file l.file = nil // Do not remove the file here. A process that opened it a moment // earlier could then lock the removed file while another creates and // locks a new one, and both would run. truncateErr := file.Truncate(0) closeErr := file.Close() return errors.Join(truncateErr, closeErr) } // writePID replaces the contents of the locked PID file with the current // PID. func writePID(file *os.File) error { err := file.Truncate(0) if err != nil { return fmt.Errorf("truncating PID file: %w", err) } _, err = file.WriteAt([]byte(strconv.Itoa(os.Getpid())), 0) if err != nil { return fmt.Errorf("writing PID file: %w", err) } return nil } // alreadyRunningError reports that another process holds the lock, // naming its PID when the file holds one. The holder writes its PID just // after it locks, so the file can briefly be empty. func alreadyRunningError(lockPath string) error { pid, err := readPIDFile(lockPath) if err != nil { return ErrAlreadyRunning } return fmt.Errorf("%w (PID %d)", ErrAlreadyRunning, pid) } // readPIDFile reads and parses the PID from a lock file. func readPIDFile(path string) (int, error) { data, err := os.ReadFile(path) //nolint:gosec // G304: path is our own lock file 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 }