check / check (push) Successful in 10m54s
The default config backs up the home directory, which holds vaultik's local index and PID file, so a backup stored a torn copy of the index it was writing. The scan now skips the index, its SQLite side files and the PID file whatever the config says, including when index_path or its directory is a symlink. database purge removes the same list of index files. Model: opus-5-5
150 lines
4.0 KiB
Go
150 lines
4.0 KiB
Go
// 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"
|
|
|
|
"github.com/adrg/xdg"
|
|
"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
|
|
// pidFileName is the name of the PID file in the lock directory.
|
|
pidFileName = "vaultik.pid"
|
|
)
|
|
|
|
// Dir returns the directory that holds vaultik's PID file.
|
|
func Dir() string {
|
|
return filepath.Join(xdg.DataHome, "vaultik")
|
|
}
|
|
|
|
// Path returns the path of the PID file that Acquire uses in lockDir.
|
|
func Path(lockDir string) string {
|
|
return filepath.Join(lockDir, pidFileName)
|
|
}
|
|
|
|
// 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 := Path(lockDir)
|
|
|
|
// 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
|
|
}
|