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}