#![cfg(unix)]
use std::path::{Path, PathBuf};
use rustix::fd::OwnedFd;
use rustix::fs::{FlockOperation, Mode, OFlags};
#[derive(Debug, thiserror::Error)]
pub enum PidLockError {
#[error("another process holds the lock (pid {pid})")]
AlreadyRunning {
pid: u32,
},
#[error(transparent)]
Io(#[from] std::io::Error),
}
#[derive(Debug)]
pub struct PidLockGuard {
#[allow(dead_code)] fd: OwnedFd,
path: PathBuf,
}
impl PidLockGuard {
pub fn acquire(path: &Path) -> Result<Self, PidLockError> {
if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
std::fs::create_dir_all(parent)?;
}
let fd = rustix::fs::open(
path,
OFlags::RDWR | OFlags::CREATE | OFlags::CLOEXEC,
Mode::from_raw_mode(0o644),
)
.map_err(std::io::Error::from)?;
rustix::fs::flock(&fd, FlockOperation::NonBlockingLockExclusive).map_err(|e| {
if e == rustix::io::Errno::WOULDBLOCK {
let pid = read_pid_lenient(path).unwrap_or(0);
PidLockError::AlreadyRunning { pid }
} else {
PidLockError::Io(e.into())
}
})?;
rustix::fs::ftruncate(&fd, 0).map_err(std::io::Error::from)?;
rustix::io::write(&fd, std::process::id().to_string().as_bytes())
.map_err(std::io::Error::from)?;
Ok(Self {
fd,
path: path.to_owned(),
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl Drop for PidLockGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.path);
}
}
#[must_use]
pub fn read_pid_lenient(path: &Path) -> Option<u32> {
std::fs::read_to_string(path).ok()?.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use std::sync::atomic::{AtomicU32, Ordering};
use tempfile::TempDir;
use super::*;
static COUNTER: AtomicU32 = AtomicU32::new(0);
fn unique_pid_path(dir: &TempDir) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
dir.path().join(format!("zeph-{n}.pid"))
}
#[test]
fn acquire_creates_file_with_pid() {
let dir = TempDir::new().unwrap();
let path = unique_pid_path(&dir);
let guard = PidLockGuard::acquire(&path).expect("acquire should succeed");
let content = std::fs::read_to_string(&path).expect("pid file must exist");
assert_eq!(
content.trim().parse::<u32>().unwrap(),
std::process::id(),
"pid file must contain current process pid"
);
drop(guard);
assert!(!path.exists(), "pid file must be removed on drop");
}
#[test]
fn second_acquire_fails_with_already_running() {
let dir = TempDir::new().unwrap();
let path = unique_pid_path(&dir);
let _guard = PidLockGuard::acquire(&path).expect("first acquire must succeed");
let err = PidLockGuard::acquire(&path).expect_err("second acquire must fail");
assert!(
matches!(err, PidLockError::AlreadyRunning { .. }),
"expected AlreadyRunning, got {err:?}"
);
}
#[test]
fn read_pid_lenient_returns_none_for_nonexistent_file() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent.pid");
assert!(read_pid_lenient(&path).is_none());
}
#[test]
fn read_pid_lenient_returns_none_for_invalid_content() {
let dir = TempDir::new().unwrap();
let path = unique_pid_path(&dir);
std::fs::write(&path, "not_a_number").unwrap();
assert!(read_pid_lenient(&path).is_none());
}
}