use std::fmt;
use std::fs::{File, TryLockError};
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
const MAX_BACKOFF: Duration = Duration::from_millis(50);
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum LockMode {
Exclusive,
Shared,
}
impl fmt::Display for LockMode {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Exclusive => formatter.write_str("exclusive"),
Self::Shared => formatter.write_str("shared"),
}
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum LockError {
Timeout {
path: PathBuf,
mode: LockMode,
waited: Duration,
},
Io {
path: PathBuf,
mode: LockMode,
source: std::io::Error,
},
}
impl fmt::Display for LockError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Timeout { path, mode, waited } => write!(
formatter,
"timed out after {:.1}s waiting for the {mode} lock on {}; \
another process or task is holding it",
waited.as_secs_f64(),
path.display()
),
Self::Io { path, mode, source } => write!(
formatter,
"failed to take the {mode} lock on {}: {source}",
path.display()
),
}
}
}
impl std::error::Error for LockError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Timeout { .. } => None,
Self::Io { source, .. } => Some(source),
}
}
}
pub fn lock_with_deadline(
file: &File,
path: &Path,
mode: LockMode,
timeout: Duration,
) -> Result<(), LockError> {
let started = Instant::now();
let mut backoff = Duration::from_millis(1);
loop {
let attempt = match mode {
LockMode::Exclusive => file.try_lock(),
LockMode::Shared => file.try_lock_shared(),
};
match attempt {
Ok(()) => return Ok(()),
Err(TryLockError::WouldBlock) => {}
Err(TryLockError::Error(source)) => {
return Err(LockError::Io {
path: path.to_path_buf(),
mode,
source,
})
}
}
let waited = started.elapsed();
if waited >= timeout {
return Err(LockError::Timeout {
path: path.to_path_buf(),
mode,
waited,
});
}
let remaining = timeout.saturating_sub(waited);
std::thread::sleep(backoff.min(remaining));
backoff = (backoff * 2).min(MAX_BACKOFF);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn locked_pair() -> (tempfile::TempDir, PathBuf, File, File) {
let dir = tempfile::tempdir().expect("temp dir");
let path = dir.path().join("guard.lock");
let first = File::create(&path).expect("create lock file");
let second = File::open(&path).expect("reopen lock file");
(dir, path, first, second)
}
#[test]
fn an_uncontended_lock_is_taken_immediately() {
let (_dir, path, first, _second) = locked_pair();
lock_with_deadline(&first, &path, LockMode::Exclusive, Duration::from_secs(30))
.expect("uncontended exclusive lock");
}
#[test]
fn a_held_lock_times_out_and_names_itself() {
let (_dir, path, first, second) = locked_pair();
first.lock().expect("hold the lock");
let error = lock_with_deadline(
&second,
&path,
LockMode::Exclusive,
Duration::from_millis(50),
)
.expect_err("second acquisition must not succeed");
match error {
LockError::Timeout {
path: reported,
mode,
waited,
} => {
assert_eq!(reported, path);
assert_eq!(mode, LockMode::Exclusive);
assert!(waited >= Duration::from_millis(50), "waited {waited:?}");
}
other => panic!("expected a timeout, got {other:?}"),
}
assert!(format!(
"{}",
LockError::Timeout {
path: path.clone(),
mode: LockMode::Exclusive,
waited: Duration::from_millis(50),
}
)
.contains(&path.display().to_string()));
}
#[test]
fn shared_locks_admit_each_other() {
let (_dir, path, first, second) = locked_pair();
first.lock_shared().expect("first shared lock");
lock_with_deadline(&second, &path, LockMode::Shared, Duration::from_millis(50))
.expect("a second shared lock must be admitted");
}
#[test]
fn an_exclusive_holder_blocks_a_shared_waiter() {
let (_dir, path, first, second) = locked_pair();
first.lock().expect("hold exclusively");
let error = lock_with_deadline(&second, &path, LockMode::Shared, Duration::from_millis(50))
.expect_err("shared waiter must not pass an exclusive holder");
assert!(matches!(
error,
LockError::Timeout {
mode: LockMode::Shared,
..
}
));
}
}