use std::fs;
use std::io;
use std::path::Path;
#[derive(Debug)]
pub struct AppLock {
#[cfg(unix)]
#[expect(dead_code, reason = "the open file is the lock; closing it releases it")]
file: fs::File,
}
impl AppLock {
pub fn acquire(path: &Path) -> io::Result<Option<Self>> {
acquire(path)
}
}
#[must_use]
pub fn holder_pid(path: &Path) -> Option<u32> {
fs::read_to_string(path).ok()?.trim().parse().ok()
}
#[cfg(unix)]
fn acquire(path: &Path) -> io::Result<Option<AppLock>> {
use rustix::fs::{FlockOperation, flock};
let file = fs::OpenOptions::new().read(true).write(true).create(true).truncate(false).open(path)?;
match flock(&file, FlockOperation::NonBlockingLockExclusive) {
Ok(()) => {}
Err(errno) => {
let error = io::Error::from(errno);
if error.kind() == io::ErrorKind::WouldBlock {
return Ok(None);
}
return Err(error);
}
}
let pid = format!("{}\n", std::process::id());
file.set_len(0)?;
io::Write::write_all(&mut &file, pid.as_bytes())?;
Ok(Some(AppLock { file }))
}
#[cfg(not(unix))]
fn acquire(_path: &Path) -> io::Result<Option<AppLock>> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"this platform has no advisory lock in this framework; see AppLock::acquire",
))
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("quvyta-lock-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("test directory");
dir
}
#[test]
fn the_second_attempt_is_told_the_lock_is_taken_and_the_drop_frees_it() {
if !cfg!(unix) {
return;
}
let dir = temp_dir("busy");
let path = dir.join("lock");
let held = AppLock::acquire(&path).expect("first attempt").expect("the lock is free");
assert_eq!(holder_pid(&path), Some(std::process::id()), "the file names the holder");
assert!(AppLock::acquire(&path).expect("second attempt").is_none(), "the lock is taken");
drop(held);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut again = AppLock::acquire(&path).expect("third attempt");
while again.is_none() && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
again = AppLock::acquire(&path).expect("another attempt");
}
assert!(again.is_some(), "dropping the holder released the lock");
drop(again);
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn a_lock_file_left_behind_is_not_a_lock() {
if !cfg!(unix) {
return;
}
let dir = temp_dir("stale");
let path = dir.join("lock");
fs::write(&path, "4242\n").expect("write a stale file");
assert_eq!(holder_pid(&path), Some(4242));
let lock = AppLock::acquire(&path).expect("attempt").expect("a leftover file holds nothing");
assert_eq!(holder_pid(&path), Some(std::process::id()), "the holder is now this process");
drop(lock);
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn another_process_is_kept_out_and_let_in_again() {
if !cfg!(target_os = "linux") {
return;
}
let dir = temp_dir("process");
let path = dir.join("lock");
let attempt = |path: &Path| {
std::process::Command::new("flock")
.args(["--nonblock", "--conflict-exit-code", "9"])
.arg(path)
.args(["--command", "true"])
.status()
};
let held = AppLock::acquire(&path).expect("attempt").expect("the lock is free");
let Ok(busy) = attempt(&path) else {
fs::remove_dir_all(&dir).expect("clean");
return;
};
assert_eq!(busy.code(), Some(9), "the other process was told the lock is taken");
drop(held);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
let mut code = attempt(&path).expect("run").code();
while code != Some(0) && std::time::Instant::now() < deadline {
std::thread::sleep(std::time::Duration::from_millis(5));
code = attempt(&path).expect("run").code();
}
assert_eq!(code, Some(0), "with the holder gone the lock is free");
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn a_file_that_cannot_be_opened_is_an_error() {
let dir = temp_dir("missing");
let error = AppLock::acquire(&dir.join("absent").join("lock")).expect_err("no directory");
let expected = if cfg!(unix) { io::ErrorKind::NotFound } else { io::ErrorKind::Unsupported };
assert_eq!(error.kind(), expected);
fs::remove_dir_all(&dir).expect("clean");
}
#[test]
fn a_file_without_a_number_names_nobody() {
let dir = temp_dir("pid");
let path = dir.join("lock");
assert_eq!(holder_pid(&path), None, "a missing file names nobody");
fs::write(&path, "").expect("empty");
assert_eq!(holder_pid(&path), None);
fs::write(&path, "qfocus\n").expect("text");
assert_eq!(holder_pid(&path), None);
fs::remove_dir_all(&dir).expect("clean");
}
}