Skip to main content

harn_flock/
lib.rs

1//! Advisory file locks that cannot wait forever.
2//!
3//! [`std::fs::File::lock`] has no timed form. A caller blocked on a lock nobody
4//! will release produces no error, no log line, and no stack — the process
5//! simply stops, and whatever supervises it can report only that it stopped.
6//! Recovering the reason means reproducing the wedge, and a rare interleaving
7//! can cost days of runner time to hit twice.
8//!
9//! Every acquisition here carries a deadline and names the path it waited on,
10//! so a wedge surfaces as an error that says which lock and for how long.
11//!
12//! The deadline is a *diagnostic* bound, not a coordination mechanism. Callers
13//! pick one from how long the critical section legitimately runs — schema
14//! initialization is milliseconds, a package install can be minutes — and set
15//! it far enough above that ceiling that expiry means something is wrong rather
16//! than merely slow.
17
18use std::fmt;
19use std::fs::{File, TryLockError};
20use std::path::{Path, PathBuf};
21use std::time::{Duration, Instant};
22
23/// Longest gap between attempts. Short enough that a freed lock is picked up
24/// promptly, long enough that a minutes-long wait is not a spin.
25const MAX_BACKOFF: Duration = Duration::from_millis(50);
26
27/// Whether a waiter needs the file to itself.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum LockMode {
30    /// One holder at a time.
31    Exclusive,
32    /// Any number of concurrent holders, excluded by an [`LockMode::Exclusive`]
33    /// holder.
34    Shared,
35}
36
37impl fmt::Display for LockMode {
38    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
39        match self {
40            Self::Exclusive => formatter.write_str("exclusive"),
41            Self::Shared => formatter.write_str("shared"),
42        }
43    }
44}
45
46/// A lock that was not acquired.
47#[derive(Debug)]
48#[non_exhaustive]
49pub enum LockError {
50    /// The deadline passed while another holder kept the lock.
51    ///
52    /// The holder is not named: advisory locks record no owner, and asking the
53    /// operating system who holds one is neither portable nor race-free. The
54    /// path and the elapsed wait are what a reader needs to find it.
55    Timeout {
56        /// The lock file waited on.
57        path: PathBuf,
58        /// The access that was requested.
59        mode: LockMode,
60        /// How long the caller waited before giving up.
61        waited: Duration,
62    },
63    /// The operating system refused the lock outright.
64    Io {
65        /// The lock file waited on.
66        path: PathBuf,
67        /// The access that was requested.
68        mode: LockMode,
69        /// The refusal.
70        source: std::io::Error,
71    },
72}
73
74impl fmt::Display for LockError {
75    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
76        match self {
77            Self::Timeout { path, mode, waited } => write!(
78                formatter,
79                "timed out after {:.1}s waiting for the {mode} lock on {}; \
80                 another process or task is holding it",
81                waited.as_secs_f64(),
82                path.display()
83            ),
84            Self::Io { path, mode, source } => write!(
85                formatter,
86                "failed to take the {mode} lock on {}: {source}",
87                path.display()
88            ),
89        }
90    }
91}
92
93impl std::error::Error for LockError {
94    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
95        match self {
96            Self::Timeout { .. } => None,
97            Self::Io { source, .. } => Some(source),
98        }
99    }
100}
101
102/// Take an advisory lock on `file`, giving up after `timeout`.
103///
104/// `path` names the file only so failures can report it; the lock is taken on
105/// the supplied handle. Blocks the calling thread, so an async caller belongs
106/// on a blocking-capable executor — on a single-threaded runtime this stalls
107/// every other task on that thread, which is precisely how a lock wait becomes
108/// an unbreakable deadlock.
109///
110/// # Errors
111///
112/// [`LockError::Timeout`] if another holder still owns the lock when the
113/// deadline passes, or [`LockError::Io`] if the operating system refuses.
114pub fn lock_with_deadline(
115    file: &File,
116    path: &Path,
117    mode: LockMode,
118    timeout: Duration,
119) -> Result<(), LockError> {
120    let started = Instant::now();
121    let mut backoff = Duration::from_millis(1);
122    loop {
123        let attempt = match mode {
124            LockMode::Exclusive => file.try_lock(),
125            LockMode::Shared => file.try_lock_shared(),
126        };
127        match attempt {
128            Ok(()) => return Ok(()),
129            Err(TryLockError::WouldBlock) => {}
130            Err(TryLockError::Error(source)) => {
131                return Err(LockError::Io {
132                    path: path.to_path_buf(),
133                    mode,
134                    source,
135                })
136            }
137        }
138        // Checked after the attempt, not before, so a zero timeout still tries
139        // once. Callers that only want a single try use `try_lock` directly;
140        // one that passes a deadline should never be refused without an
141        // attempt against the current state of the lock.
142        let waited = started.elapsed();
143        if waited >= timeout {
144            return Err(LockError::Timeout {
145                path: path.to_path_buf(),
146                mode,
147                waited,
148            });
149        }
150        // Never overshoot the deadline: a caller that budgeted 30s should hear
151        // back at 30s, not at the end of whichever backoff straddles it. The
152        // check above already proved `waited < timeout`; saturating says so
153        // without adding a panic path that could never fire.
154        let remaining = timeout.saturating_sub(waited);
155        std::thread::sleep(backoff.min(remaining));
156        backoff = (backoff * 2).min(MAX_BACKOFF);
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    /// Two handles on one path, so the second waiter is genuinely blocked
165    /// rather than re-entering its own lock. File locks are per-handle on
166    /// Windows and per-open-file-description under `flock`, so this deadlocks
167    /// a single process just as readily as two.
168    fn locked_pair() -> (tempfile::TempDir, PathBuf, File, File) {
169        let dir = tempfile::tempdir().expect("temp dir");
170        let path = dir.path().join("guard.lock");
171        let first = File::create(&path).expect("create lock file");
172        let second = File::open(&path).expect("reopen lock file");
173        (dir, path, first, second)
174    }
175
176    #[test]
177    fn an_uncontended_lock_is_taken_immediately() {
178        let (_dir, path, first, _second) = locked_pair();
179        lock_with_deadline(&first, &path, LockMode::Exclusive, Duration::from_secs(30))
180            .expect("uncontended exclusive lock");
181    }
182
183    /// The whole point: a held lock must produce an error naming it, not a
184    /// process that stops.
185    #[test]
186    fn a_held_lock_times_out_and_names_itself() {
187        let (_dir, path, first, second) = locked_pair();
188        first.lock().expect("hold the lock");
189
190        let error = lock_with_deadline(
191            &second,
192            &path,
193            LockMode::Exclusive,
194            Duration::from_millis(50),
195        )
196        .expect_err("second acquisition must not succeed");
197
198        match error {
199            LockError::Timeout {
200                path: reported,
201                mode,
202                waited,
203            } => {
204                assert_eq!(reported, path);
205                assert_eq!(mode, LockMode::Exclusive);
206                assert!(waited >= Duration::from_millis(50), "waited {waited:?}");
207            }
208            other => panic!("expected a timeout, got {other:?}"),
209        }
210        assert!(format!(
211            "{}",
212            LockError::Timeout {
213                path: path.clone(),
214                mode: LockMode::Exclusive,
215                waited: Duration::from_millis(50),
216            }
217        )
218        .contains(&path.display().to_string()));
219    }
220
221    /// Readers must not exclude each other, or every concurrent open of a
222    /// ready database would serialize behind the first.
223    #[test]
224    fn shared_locks_admit_each_other() {
225        let (_dir, path, first, second) = locked_pair();
226        first.lock_shared().expect("first shared lock");
227        lock_with_deadline(&second, &path, LockMode::Shared, Duration::from_millis(50))
228            .expect("a second shared lock must be admitted");
229    }
230
231    /// An exclusive holder must still block a reader, or the deadline would be
232    /// hiding a lock that never actually excluded anything.
233    #[test]
234    fn an_exclusive_holder_blocks_a_shared_waiter() {
235        let (_dir, path, first, second) = locked_pair();
236        first.lock().expect("hold exclusively");
237        let error = lock_with_deadline(&second, &path, LockMode::Shared, Duration::from_millis(50))
238            .expect_err("shared waiter must not pass an exclusive holder");
239        assert!(matches!(
240            error,
241            LockError::Timeout {
242                mode: LockMode::Shared,
243                ..
244            }
245        ));
246    }
247}