use std::fs::{File, OpenOptions};
use std::io::ErrorKind;
use std::marker::PhantomData;
use std::path::Path;
use fs4::FileExt;
use crate::error::{Error, Result};
use crate::paths::{reject_symlink, RunPaths};
pub enum Exclusive {}
pub enum Shared {}
pub struct LockedRun<'a> {
_lock: PhantomData<*const &'a ()>,
}
pub struct RunLock<Mode = Exclusive> {
file: Option<File>,
_mode: PhantomData<Mode>,
}
impl RunLock<Exclusive> {
pub fn acquire(lock_path: &Path) -> Result<Self> {
#[cfg(test)]
ACQUIRE_COUNT.with(|c| c.set(c.get() + 1));
if let Some(p) = lock_path.parent() {
std::fs::create_dir_all(p).map_err(|e| Error::io(p, e))?;
}
reject_symlink(lock_path, || Error::SymlinkStateFile {
name: "lock",
path: lock_path.to_path_buf(),
})?;
let mut opts = OpenOptions::new();
opts.create(true).read(true).write(true).truncate(false);
crate::paths::nofollow(&mut opts);
let file = opts.open(lock_path).map_err(|e| Error::io(lock_path, e))?;
<File as FileExt>::lock(&file).map_err(|e| Error::io(lock_path, e))?;
Ok(Self {
file: Some(file),
_mode: PhantomData,
})
}
#[allow(clippy::unused_self)]
pub fn witness(&self) -> LockedRun<'_> {
LockedRun { _lock: PhantomData }
}
pub fn with_lock<R>(paths: &RunPaths, f: impl FnOnce(&LockedRun) -> Result<R>) -> Result<R> {
let guard = Self::acquire(&paths.lock())?;
let r = f(&guard.witness());
drop(guard);
r
}
}
impl RunLock<Shared> {
pub fn acquire_shared(lock_path: &Path) -> Result<Self> {
reject_symlink(lock_path, || Error::SymlinkStateFile {
name: "lock",
path: lock_path.to_path_buf(),
})?;
let mut opts = OpenOptions::new();
opts.read(true);
crate::paths::nofollow(&mut opts);
let file = match opts.open(lock_path) {
Ok(f) => f,
Err(e) if e.kind() == ErrorKind::NotFound => {
return Ok(Self {
file: None,
_mode: PhantomData,
});
}
Err(e) => return Err(Error::io(lock_path, e)),
};
<File as FileExt>::lock_shared(&file).map_err(|e| Error::io(lock_path, e))?;
Ok(Self {
file: Some(file),
_mode: PhantomData,
})
}
pub fn with_shared_lock<T>(lock_path: &Path, f: impl FnOnce() -> Result<T>) -> Result<T> {
let guard = Self::acquire_shared(lock_path)?;
let r = f();
drop(guard);
r
}
}
impl<Mode> Drop for RunLock<Mode> {
fn drop(&mut self) {
if let Some(f) = self.file.take() {
let _ = <File as FileExt>::unlock(&f);
}
}
}
#[cfg(test)]
thread_local! {
pub(crate) static ACQUIRE_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn acquire_succeeds_on_a_regular_lock_file() {
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
drop(RunLock::acquire(&lock).unwrap());
assert!(RunLock::acquire(&lock).is_ok());
}
fn fresh_paths(tmp: &TempDir) -> RunPaths {
let run_id = "01jxsnap000000000000000000";
let dir = tmp.path().join(run_id);
std::fs::create_dir_all(&dir).unwrap();
RunPaths::new(dir, run_id).unwrap()
}
#[test]
fn with_lock_passes_a_witness_and_returns_the_closure_value() {
let tmp = TempDir::new().unwrap();
let paths = fresh_paths(&tmp);
let got = RunLock::with_lock(&paths, |_witness: &LockedRun| Ok(7u8)).unwrap();
assert_eq!(got, 7);
}
#[test]
fn manually_acquired_exclusive_guard_mints_a_witness() {
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
let guard = RunLock::acquire(&lock).unwrap();
let _witness: LockedRun<'_> = guard.witness();
}
#[test]
fn acquire_shared_on_missing_lock_file_is_a_noop_guard() {
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
let guard = RunLock::acquire_shared(&lock).expect("missing lock file is fine");
assert!(guard.file.is_none(), "no lock file ⇒ guard holds nothing");
assert!(!lock.exists(), "a reader must never author the lock file");
}
#[test]
fn exclusive_writer_blocks_shared_reader_until_release() {
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
let writer = RunLock::acquire(&lock).unwrap();
let (tx, rx) = mpsc::channel();
let lock2 = lock.clone();
let reader = thread::spawn(move || {
let _g = RunLock::acquire_shared(&lock2).unwrap();
tx.send(()).unwrap();
});
assert!(
rx.recv_timeout(Duration::from_millis(250)).is_err(),
"shared reader must block while the exclusive lock is held"
);
drop(writer);
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"shared reader must proceed after the exclusive lock is released"
);
reader.join().unwrap();
}
#[test]
fn two_shared_readers_proceed_concurrently() {
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
drop(RunLock::acquire(&lock).unwrap());
let r1 = RunLock::acquire_shared(&lock).unwrap();
assert!(
r1.file.is_some(),
"lock file exists ⇒ real shared lock held"
);
let (tx, rx) = mpsc::channel();
let lock2 = lock.clone();
let r2 = thread::spawn(move || {
let _g = RunLock::acquire_shared(&lock2).unwrap();
tx.send(()).unwrap();
});
assert!(
rx.recv_timeout(Duration::from_secs(5)).is_ok(),
"a second shared reader must not block on the first"
);
r2.join().unwrap();
}
#[test]
fn nested_shared_locks_do_not_deadlock() {
let tmp = TempDir::new().unwrap();
let lock = tmp.path().join(".lock");
drop(RunLock::acquire(&lock).unwrap());
let r = RunLock::with_shared_lock(&lock, || RunLock::with_shared_lock(&lock, || Ok(42)))
.unwrap();
assert_eq!(r, 42);
}
#[cfg(unix)]
#[test]
fn acquire_rejects_a_symlinked_lock_file() {
use std::os::unix::fs::symlink;
let tmp = TempDir::new().unwrap();
let target = tmp.path().join("outside.lock");
let lock = tmp.path().join(".lock");
symlink(&target, &lock).unwrap();
assert!(matches!(
RunLock::acquire(&lock),
Err(Error::SymlinkStateFile { name: "lock", .. })
));
assert!(!target.exists());
}
}