Skip to main content

lean_ctx_fs/
file_lock.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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
19/// Takes an exclusive advisory lock, giving up after `timeout` rather than
20/// waiting for as long as the other holder cares to keep it.
21///
22/// `fs2::FileExt::lock_exclusive` has no deadline at all. On a thread whose only
23/// job is that one write, an unbounded wait is merely slow. Anywhere else it is
24/// a wedge: the waiter keeps everything it already owns — a `tokio` guard, an
25/// executor worker — for the entire wait, and nothing in the process can end it
26/// (#1783). A caller that cannot afford to wait forever could not express that
27/// before this; now the bound is the default and the failure is an ordinary
28/// `Err` the caller can retry on its own schedule.
29pub 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
50/// One thread's turn at a lock path within this process; the next waiter in
51/// line gets it when this drops.
52///
53/// An advisory file lock polled with a deadline is no queue: threads of one
54/// process that take the same lock back to back make a waiter's deadline count
55/// every critical section ahead of it, so eight writers whose writes each take
56/// a second (an `fsync` plus a Windows replace that waits out a scanner) fail
57/// the last one after five seconds although every holder made progress. The
58/// turn queues the threads of a process in arrival order, and a waiter's
59/// `timeout` counts only time in which no turn was handed over: a holder that
60/// never returns still fails every waiter after `timeout`, a queue that moves
61/// fails nobody. Other processes are still bounded by the file lock itself.
62pub 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    /// The next ticket handed out, and the ticket whose turn it is.
76    next: u64,
77    serving: u64,
78    /// Tickets whose waiters gave up before their turn came.
79    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/// Wait for this thread's turn at `path` among the threads of this process.
105/// `None` once `timeout` passed without any turn being handed over.
106///
107/// The queue is keyed on `path` exactly as spelled, not canonicalized: two
108/// spellings of one lock file (relative and absolute, through a symlink) get
109/// two queues, and their callers fall back to contending on the file lock
110/// alone, as before the turn existed. Callers pass one derived path per lock.
111#[must_use]
112pub fn take_turn(path: &Path, timeout: Duration) -> Option<Turn> {
113    // Lock order everywhere: the map, then a queue's line. The ticket is drawn
114    // under the map lock so a releasing holder never drops a queue in use.
115    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    /// The next ticket `take_turn` would hand out at `path`, 0 without a queue.
174    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    /// A waiter that gives up in the middle of the line leaves an abandoned
189    /// ticket. The holder's release must skip it and hand the turn to the
190    /// waiter behind it at once; without the skip that waiter would wait out
191    /// its own timeout. The idle queue is then removed from the map.
192    #[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}