use std::collections::{BTreeSet, HashMap};
use std::fs::File;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
use std::time::{Duration, Instant};
const RETRY_INTERVAL: Duration = Duration::from_millis(10);
pub fn is_contended(error: &Error) -> bool {
error.kind() == ErrorKind::WouldBlock
|| error
.raw_os_error()
.zip(fs2::lock_contended_error().raw_os_error())
.is_some_and(|(error_code, contended_code)| error_code == contended_code)
}
pub fn acquire_exclusive_timeout(file: &File, timeout: Duration) -> Result<(), String> {
use fs2::FileExt;
let deadline = Instant::now() + timeout;
loop {
match file.try_lock_exclusive() {
Ok(()) => return Ok(()),
Err(error) if is_contended(&error) => {
if Instant::now() >= deadline {
return Err(format!(
"still held after {}ms — giving up rather than blocking",
timeout.as_millis()
));
}
std::thread::sleep(RETRY_INTERVAL);
}
Err(error) => return Err(error.to_string()),
}
}
}
pub struct Turn {
path: PathBuf,
queue: Arc<Queue>,
}
#[derive(Default)]
struct Queue {
line: Mutex<Line>,
moved: Condvar,
}
#[derive(Default)]
struct Line {
next: u64,
serving: u64,
abandoned: BTreeSet<u64>,
}
impl Line {
fn advance(&mut self) {
self.serving += 1;
while self.abandoned.remove(&self.serving) {
self.serving += 1;
}
}
fn idle(&self) -> bool {
self.serving == self.next
}
}
fn queues() -> &'static Mutex<HashMap<PathBuf, Arc<Queue>>> {
static QUEUES: OnceLock<Mutex<HashMap<PathBuf, Arc<Queue>>>> = OnceLock::new();
QUEUES.get_or_init(Mutex::default)
}
fn locked<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex.lock().unwrap_or_else(PoisonError::into_inner)
}
#[must_use]
pub fn take_turn(path: &Path, timeout: Duration) -> Option<Turn> {
let (queue, ticket) = {
let mut map = locked(queues());
let queue = Arc::clone(map.entry(path.to_path_buf()).or_default());
let mut line = locked(&queue.line);
let ticket = line.next;
line.next += 1;
drop(line);
drop(map);
(queue, ticket)
};
let mut line = locked(&queue.line);
let mut seen = line.serving;
let mut deadline = Instant::now() + timeout;
while line.serving != ticket {
let now = Instant::now();
if line.serving != seen {
seen = line.serving;
deadline = now + timeout;
}
let left = deadline.saturating_duration_since(now);
if left.is_zero() {
line.abandoned.insert(ticket);
return None;
}
line = queue
.moved
.wait_timeout(line, left)
.unwrap_or_else(PoisonError::into_inner)
.0;
}
drop(line);
Some(Turn {
path: path.to_path_buf(),
queue,
})
}
impl Drop for Turn {
fn drop(&mut self) {
let mut map = locked(queues());
let mut line = locked(&self.queue.line);
line.advance();
if line.idle() {
map.remove(&self.path);
}
drop(line);
drop(map);
self.queue.moved.notify_all();
}
}
#[cfg(test)]
mod tests {
use super::{acquire_exclusive_timeout, is_contended, locked, queues, take_turn};
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::time::{Duration, Instant};
fn tickets_drawn(path: &Path) -> u64 {
locked(queues())
.get(path)
.map_or(0, |queue| locked(&queue.line).next)
}
fn wait_for_tickets(path: &Path, drawn: u64) {
let deadline = Instant::now() + Duration::from_secs(10);
while tickets_drawn(path) < drawn {
assert!(Instant::now() < deadline, "ticket {drawn} never drawn");
std::thread::sleep(Duration::from_millis(1));
}
}
#[test]
fn an_abandoned_ticket_is_skipped_and_the_idle_queue_removed() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("abandoned.lock");
let holder = take_turn(&path, Duration::from_secs(1)).expect("free turn");
let quitter = {
let path = path.clone();
std::thread::spawn(move || take_turn(&path, Duration::from_millis(100)).is_none())
};
wait_for_tickets(&path, 2);
let patient = {
let path = path.clone();
std::thread::spawn(move || {
let turn = take_turn(&path, Duration::from_secs(30));
(turn.is_some(), Instant::now())
})
};
wait_for_tickets(&path, 3);
assert!(
quitter.join().expect("quitter"),
"the middle waiter must give up while the holder keeps its turn"
);
let released = Instant::now();
drop(holder);
let (got_turn, served) = patient.join().expect("patient");
assert!(
got_turn,
"the waiter behind an abandoned ticket gets its turn"
);
let handover = served.saturating_duration_since(released);
assert!(
handover < Duration::from_secs(5),
"the abandoned ticket must be skipped, handover took {handover:?}"
);
assert!(
!locked(queues()).contains_key(&path),
"an idle queue must leave the map"
);
}
#[test]
fn recognizes_fs2_contention_sentinel() {
assert!(is_contended(&fs2::lock_contended_error()));
}
#[test]
fn rejects_unrelated_errors() {
assert!(!is_contended(&Error::new(
ErrorKind::PermissionDenied,
"unrelated",
)));
}
#[test]
fn gives_up_instead_of_waiting_for_a_held_lock() {
use fs2::FileExt;
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("contended.lock");
let holder = std::fs::File::create(&path).expect("create holder");
holder.lock_exclusive().expect("hold the lock");
let waiter = std::fs::OpenOptions::new()
.write(true)
.open(&path)
.expect("open waiter");
let started = Instant::now();
let result = acquire_exclusive_timeout(&waiter, Duration::from_millis(120));
let waited = started.elapsed();
assert!(
result.is_err(),
"must not acquire a lock someone else holds"
);
assert!(
waited < Duration::from_secs(5),
"must return on its own deadline, waited {waited:?}"
);
FileExt::unlock(&holder).expect("release");
}
#[test]
fn takes_a_free_lock_immediately() {
use fs2::FileExt;
let dir = tempfile::tempdir().expect("tempdir");
let file = std::fs::File::create(dir.path().join("free.lock")).expect("create");
acquire_exclusive_timeout(&file, Duration::from_millis(100)).expect("uncontended");
FileExt::unlock(&file).expect("release");
}
}