1use std::collections::{BTreeSet, HashMap};
3use std::fs::File;
4use std::io::{Error, ErrorKind};
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, Condvar, Mutex, MutexGuard, OnceLock, PoisonError};
7use std::time::{Duration, Instant};
8
9const RETRY_INTERVAL: Duration = Duration::from_millis(10);
10
11pub fn is_contended(error: &Error) -> bool {
12 error.kind() == ErrorKind::WouldBlock
13 || error
14 .raw_os_error()
15 .zip(fs2::lock_contended_error().raw_os_error())
16 .is_some_and(|(error_code, contended_code)| error_code == contended_code)
17}
18
19pub fn acquire_exclusive_timeout(file: &File, timeout: Duration) -> Result<(), String> {
30 use fs2::FileExt;
31
32 let deadline = Instant::now() + timeout;
33 loop {
34 match file.try_lock_exclusive() {
35 Ok(()) => return Ok(()),
36 Err(error) if is_contended(&error) => {
37 if Instant::now() >= deadline {
38 return Err(format!(
39 "still held after {}ms — giving up rather than blocking",
40 timeout.as_millis()
41 ));
42 }
43 std::thread::sleep(RETRY_INTERVAL);
44 }
45 Err(error) => return Err(error.to_string()),
46 }
47 }
48}
49
50pub struct Turn {
63 path: PathBuf,
64 queue: Arc<Queue>,
65}
66
67#[derive(Default)]
68struct Queue {
69 line: Mutex<Line>,
70 moved: Condvar,
71}
72
73#[derive(Default)]
74struct Line {
75 next: u64,
77 serving: u64,
78 abandoned: BTreeSet<u64>,
80}
81
82impl Line {
83 fn advance(&mut self) {
84 self.serving += 1;
85 while self.abandoned.remove(&self.serving) {
86 self.serving += 1;
87 }
88 }
89
90 fn idle(&self) -> bool {
91 self.serving == self.next
92 }
93}
94
95fn queues() -> &'static Mutex<HashMap<PathBuf, Arc<Queue>>> {
96 static QUEUES: OnceLock<Mutex<HashMap<PathBuf, Arc<Queue>>>> = OnceLock::new();
97 QUEUES.get_or_init(Mutex::default)
98}
99
100fn locked<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
101 mutex.lock().unwrap_or_else(PoisonError::into_inner)
102}
103
104#[must_use]
112pub fn take_turn(path: &Path, timeout: Duration) -> Option<Turn> {
113 let (queue, ticket) = {
116 let mut map = locked(queues());
117 let queue = Arc::clone(map.entry(path.to_path_buf()).or_default());
118 let mut line = locked(&queue.line);
119 let ticket = line.next;
120 line.next += 1;
121 drop(line);
122 drop(map);
123 (queue, ticket)
124 };
125 let mut line = locked(&queue.line);
126 let mut seen = line.serving;
127 let mut deadline = Instant::now() + timeout;
128 while line.serving != ticket {
129 let now = Instant::now();
130 if line.serving != seen {
131 seen = line.serving;
132 deadline = now + timeout;
133 }
134 let left = deadline.saturating_duration_since(now);
135 if left.is_zero() {
136 line.abandoned.insert(ticket);
137 return None;
138 }
139 line = queue
140 .moved
141 .wait_timeout(line, left)
142 .unwrap_or_else(PoisonError::into_inner)
143 .0;
144 }
145 drop(line);
146 Some(Turn {
147 path: path.to_path_buf(),
148 queue,
149 })
150}
151
152impl Drop for Turn {
153 fn drop(&mut self) {
154 let mut map = locked(queues());
155 let mut line = locked(&self.queue.line);
156 line.advance();
157 if line.idle() {
158 map.remove(&self.path);
159 }
160 drop(line);
161 drop(map);
162 self.queue.moved.notify_all();
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::{acquire_exclusive_timeout, is_contended, locked, queues, take_turn};
169 use std::io::{Error, ErrorKind};
170 use std::path::Path;
171 use std::time::{Duration, Instant};
172
173 fn tickets_drawn(path: &Path) -> u64 {
175 locked(queues())
176 .get(path)
177 .map_or(0, |queue| locked(&queue.line).next)
178 }
179
180 fn wait_for_tickets(path: &Path, drawn: u64) {
181 let deadline = Instant::now() + Duration::from_secs(10);
182 while tickets_drawn(path) < drawn {
183 assert!(Instant::now() < deadline, "ticket {drawn} never drawn");
184 std::thread::sleep(Duration::from_millis(1));
185 }
186 }
187
188 #[test]
193 fn an_abandoned_ticket_is_skipped_and_the_idle_queue_removed() {
194 let dir = tempfile::tempdir().expect("tempdir");
195 let path = dir.path().join("abandoned.lock");
196
197 let holder = take_turn(&path, Duration::from_secs(1)).expect("free turn");
198 let quitter = {
199 let path = path.clone();
200 std::thread::spawn(move || take_turn(&path, Duration::from_millis(100)).is_none())
201 };
202 wait_for_tickets(&path, 2);
203 let patient = {
204 let path = path.clone();
205 std::thread::spawn(move || {
206 let turn = take_turn(&path, Duration::from_secs(30));
207 (turn.is_some(), Instant::now())
208 })
209 };
210 wait_for_tickets(&path, 3);
211
212 assert!(
213 quitter.join().expect("quitter"),
214 "the middle waiter must give up while the holder keeps its turn"
215 );
216 let released = Instant::now();
217 drop(holder);
218 let (got_turn, served) = patient.join().expect("patient");
219
220 assert!(
221 got_turn,
222 "the waiter behind an abandoned ticket gets its turn"
223 );
224 let handover = served.saturating_duration_since(released);
225 assert!(
226 handover < Duration::from_secs(5),
227 "the abandoned ticket must be skipped, handover took {handover:?}"
228 );
229 assert!(
230 !locked(queues()).contains_key(&path),
231 "an idle queue must leave the map"
232 );
233 }
234
235 #[test]
236 fn recognizes_fs2_contention_sentinel() {
237 assert!(is_contended(&fs2::lock_contended_error()));
238 }
239
240 #[test]
241 fn rejects_unrelated_errors() {
242 assert!(!is_contended(&Error::new(
243 ErrorKind::PermissionDenied,
244 "unrelated",
245 )));
246 }
247
248 #[test]
249 fn gives_up_instead_of_waiting_for_a_held_lock() {
250 use fs2::FileExt;
251
252 let dir = tempfile::tempdir().expect("tempdir");
253 let path = dir.path().join("contended.lock");
254 let holder = std::fs::File::create(&path).expect("create holder");
255 holder.lock_exclusive().expect("hold the lock");
256
257 let waiter = std::fs::OpenOptions::new()
258 .write(true)
259 .open(&path)
260 .expect("open waiter");
261 let started = Instant::now();
262 let result = acquire_exclusive_timeout(&waiter, Duration::from_millis(120));
263 let waited = started.elapsed();
264
265 assert!(
266 result.is_err(),
267 "must not acquire a lock someone else holds"
268 );
269 assert!(
270 waited < Duration::from_secs(5),
271 "must return on its own deadline, waited {waited:?}"
272 );
273 FileExt::unlock(&holder).expect("release");
274 }
275
276 #[test]
277 fn takes_a_free_lock_immediately() {
278 use fs2::FileExt;
279
280 let dir = tempfile::tempdir().expect("tempdir");
281 let file = std::fs::File::create(dir.path().join("free.lock")).expect("create");
282
283 acquire_exclusive_timeout(&file, Duration::from_millis(100)).expect("uncontended");
284 FileExt::unlock(&file).expect("release");
285 }
286}