use std::fs::{File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use rustix::fs::{FlockOperation, flock};
use rustix::io::Errno;
pub struct SingleInstanceLock {
_file: File,
}
pub enum LockOutcome {
Acquired(SingleInstanceLock),
HeldByAnother { holder_pid: Option<u32> },
}
fn lock_path(data_dir: &str) -> PathBuf {
Path::new(data_dir).join("pulpod.lock")
}
pub fn try_acquire(data_dir: &str) -> Result<LockOutcome> {
let lock_path = lock_path(data_dir);
let mut file = OpenOptions::new()
.create(true)
.truncate(false) .read(true)
.write(true)
.open(&lock_path)
.with_context(|| format!("failed to open lock file {}", lock_path.display()))?;
match flock(&file, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {
let _ = write_own_pid(&mut file);
Ok(LockOutcome::Acquired(SingleInstanceLock { _file: file }))
}
Err(errno) => match classify_flock_error(errno) {
None => Ok(LockOutcome::HeldByAnother {
holder_pid: read_holder_pid(&mut file),
}),
Some(io_err) => {
Err(io_err).with_context(|| format!("failed to lock {}", lock_path.display()))
}
},
}
}
fn classify_flock_error(errno: Errno) -> Option<std::io::Error> {
if errno == Errno::WOULDBLOCK || errno == Errno::AGAIN {
None
} else {
Some(std::io::Error::from(errno))
}
}
fn write_own_pid(file: &mut File) -> std::io::Result<()> {
file.set_len(0)?;
file.seek(SeekFrom::Start(0))?;
write!(file, "{}", std::process::id())?;
file.flush()
}
fn read_holder_pid(file: &mut File) -> Option<u32> {
let mut contents = String::new();
file.seek(SeekFrom::Start(0)).ok()?;
file.read_to_string(&mut contents).ok()?;
contents.trim().parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_try_acquire_succeeds_on_a_free_lock() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
match try_acquire(dir).unwrap() {
LockOutcome::Acquired(_lock) => {}
LockOutcome::HeldByAnother { .. } => panic!("expected the lock to be free"),
}
assert!(lock_path(dir).exists());
}
#[test]
fn test_try_acquire_reports_contention_while_held() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
let first = try_acquire(dir).unwrap();
let LockOutcome::Acquired(_held) = first else {
panic!("expected the first acquire to succeed");
};
match try_acquire(dir).unwrap() {
LockOutcome::HeldByAnother { holder_pid } => {
assert_eq!(holder_pid, Some(std::process::id()));
}
LockOutcome::Acquired(_) => {
panic!("a second acquire must not succeed while the first is held")
}
}
}
#[test]
fn test_try_acquire_succeeds_again_after_the_lock_is_dropped() {
let tmpdir = tempfile::tempdir().unwrap();
let dir = tmpdir.path().to_str().unwrap();
{
let LockOutcome::Acquired(_held) = try_acquire(dir).unwrap() else {
panic!("expected the first acquire to succeed");
};
}
match try_acquire(dir).unwrap() {
LockOutcome::Acquired(_) => {}
LockOutcome::HeldByAnother { .. } => {
panic!("expected the lock to be free again after the holder was dropped")
}
}
}
#[test]
fn test_try_acquire_errs_when_data_dir_missing() {
let tmpdir = tempfile::tempdir().unwrap();
let missing = tmpdir.path().join("does-not-exist");
let result = try_acquire(missing.to_str().unwrap());
assert!(result.is_err());
}
#[test]
fn test_read_holder_pid_none_for_empty_file() {
let tmpdir = tempfile::tempdir().unwrap();
let path = tmpdir.path().join("empty.lock");
let mut file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(&path)
.unwrap();
assert_eq!(read_holder_pid(&mut file), None);
}
#[test]
fn test_read_holder_pid_none_for_garbage_content() {
let tmpdir = tempfile::tempdir().unwrap();
let path = tmpdir.path().join("garbage.lock");
std::fs::write(&path, b"not-a-pid").unwrap();
let mut file = OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.unwrap();
assert_eq!(read_holder_pid(&mut file), None);
}
#[test]
fn test_classify_flock_error_wouldblock_is_contention() {
assert!(classify_flock_error(Errno::WOULDBLOCK).is_none());
}
#[test]
fn test_classify_flock_error_again_is_contention() {
assert!(classify_flock_error(Errno::AGAIN).is_none());
}
#[test]
fn test_classify_flock_error_other_errno_is_a_real_error() {
let err = classify_flock_error(Errno::PERM).expect("expected a real error, not contention");
assert_eq!(err.kind(), std::io::Error::from(Errno::PERM).kind());
}
}