mkit_core/repo_lock.rs
1//! Repo-level lockfile helper (named `repo_lock` to avoid collision
2//! with `std::sync::*Lock`).
3//!
4//! Pattern (mirrors `mkit-transport-file`'s `RefLock`): open-or-create a
5//! **never-unlinked** sentinel file at `<dir>/<name>` and take an
6//! OS-level exclusive advisory lock on it via `std::fs::File::lock`.
7//! Mutual exclusion comes entirely from the kernel lock, never from the
8//! sentinel's presence/absence on disk — so the file is *not* removed on
9//! release. That structurally removes the stale-vs-live ambiguity a
10//! delete-on-release design has: a sentinel orphaned by a SIGKILL'd
11//! `mkit` looks identical on disk to one backing a live holder, but the
12//! kernel already knows the difference, because it releases the
13//! process's `flock` the moment its file descriptors close (including on
14//! sudden process death). A waiter that actually blocks on that kernel
15//! lock therefore never confuses the two: `ls .mkit/*.lock` will show
16//! the file whether or not anyone holds it — use `lsof`/`fuser` (or a
17//! bounded acquire attempt) to check liveness, not existence.
18//!
19//! Acquisition first tries a non-blocking [`File::try_lock`] (the
20//! uncontended fast path costs no thread spawn). On contention, a helper
21//! thread performs the real blocking `lock()` call and reports back over
22//! a channel; the caller bounds the wait with `recv_timeout(timeout)`.
23//! If `timeout` elapses first, the caller gives up and returns
24//! [`LockError::Busy`] — but the helper thread is not abandoned holding
25//! anything: when it eventually wins the kernel lock (because the
26//! original, live holder finally released), its `send` back to the
27//! (already-dropped) receiver fails, handing the locked `File` back to
28//! the helper thread, which drops it immediately, releasing the kernel
29//! lock rather than leaking it into an unreachable holder.
30//!
31//! POSIX-only intent (macOS + Linux). `std::fs::File::lock` is also
32//! supported on Windows since Rust 1.89, so this works there too — the
33//! lock semantics are equivalent (mandatory `LockFileEx` rather than
34//! advisory `flock`).
35//!
36//! # Network-filesystem caveat
37//!
38//! Every guarantee this module makes — including the fail-closed
39//! GC-vs-writer exclusion `SPEC-GC.md` relies on — assumes `<dir>` is a
40//! genuinely local filesystem (or a well-behaved local-only virtual one
41//! such as tmpfs). `flock`/`fcntl` advisory locking is **not reliably
42//! coherent across NFS clients**: `NFSv3` offloads locking to a separate,
43//! frequently-absent `rpc.statd`/NLM side channel that many exports run
44//! without, and even where present, servers vary in whether a lock
45//! actually excludes a *different host's* holder rather than only
46//! callers on the same NFS client. `NFSv4`'s advertised in-protocol
47//! locking is closer to POSIX semantics but still depends on
48//! server/client support and is not something this module verifies at
49//! runtime. The same caution applies to SMB/CIFS mounts and to most
50//! FUSE-backed network filesystems. A `.mkit/` directory served from
51//! such a mount can silently lose the mutual-exclusion property this
52//! module documents above: two processes on different hosts (or even
53//! the same host, depending on client/server behavior) may both believe
54//! they hold the lock. This module performs no detection of the
55//! underlying filesystem type and has no fallback locking strategy for
56//! this case — repositories that must be shared across hosts should use
57//! one of mkit's network transports (see `docs/specs/SPEC-TRANSPORT.md`)
58//! rather than a shared network-mounted `.mkit/` directory. See
59//! `docs/THREAT-MODEL.md` §7 for how this affects mkit's fail-closed
60//! locking claims.
61
62use std::fs::{File, OpenOptions};
63use std::io;
64use std::path::{Path, PathBuf};
65use std::sync::mpsc;
66use std::time::{Duration, Instant};
67
68/// Default total wall-clock timeout (≈5s). Long enough that a slow
69/// commit in another process finishes; short enough that a stale lock
70/// from a SIGKILL'd `mkit` does not wedge the user for more than a moment.
71pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
72
73/// Maximum filename length for a lock name.
74const MAX_NAME_LEN: usize = 255;
75
76/// Errors returned by [`acquire`].
77#[derive(Debug, thiserror::Error)]
78pub enum LockError {
79 /// Timeout exhausted; another holder still owns the lock.
80 #[error("lock '{0}' busy after timeout")]
81 Busy(String),
82 /// `name` is empty or longer than the platform-safe filename cap.
83 #[error("lock name length {0} is invalid (must be 1..={MAX_NAME_LEN})")]
84 NameLength(usize),
85 /// `name` contains a path separator (`/`, `\`) or a NUL byte. A
86 /// length-based classification would be misleading here — the
87 /// value's length is fine; it's the contents that are wrong.
88 #[error("lock name contains an invalid character (`/`, `\\`, or NUL): {0:?}")]
89 InvalidName(String),
90 /// Underlying filesystem failure (disk full, permission denied, …).
91 #[error(transparent)]
92 Io(#[from] io::Error),
93}
94
95/// Result alias used throughout this module.
96pub type LockResult<T> = Result<T, LockError>;
97
98/// Holder for an acquired repo lock. Releases the kernel lock on `Drop`.
99///
100/// `release()` is the explicit form; calling it is optional because
101/// `Drop` does the same work. After `release()` is called, `Drop` is a
102/// cheap no-op.
103///
104/// The sentinel file at [`Self::path`] is **never** removed by
105/// `release`/`Drop` — see the module doc for why a never-unlinked
106/// sentinel is what makes stale-vs-live holder confusion structurally
107/// impossible.
108#[must_use = "RepoLock releases on drop; bind it to a name to keep the lock"]
109#[derive(Debug)]
110pub struct RepoLock {
111 /// Held file with the OS-level exclusive lock applied.
112 /// `None` after `release()`.
113 file: Option<File>,
114 /// Absolute path to the (never-unlinked) lockfile, for diagnostics
115 /// via [`Self::path`].
116 path: PathBuf,
117}
118
119impl RepoLock {
120 /// Returns the absolute path of the held lock file, for diagnostics.
121 #[must_use]
122 pub fn path(&self) -> &Path {
123 &self.path
124 }
125
126 /// Release the lock: drop the OS lock. The sentinel file itself is
127 /// left on disk (see module doc). Safe to call multiple times —
128 /// subsequent calls are no-ops.
129 pub fn release(&mut self) {
130 if let Some(file) = self.file.take() {
131 // `unlock()` is best-effort; `Drop` of the file would also
132 // release the kernel lock. We still call it explicitly so a
133 // mid-test reader can re-acquire on the same handle if it
134 // wants to.
135 let _ = file.unlock();
136 drop(file);
137 }
138 }
139}
140
141impl Drop for RepoLock {
142 fn drop(&mut self) {
143 self.release();
144 }
145}
146
147/// Acquire a repo-level lock at `<dir>/<name>`. Waits up to `timeout`
148/// for an existing holder to release, blocking on the kernel lock
149/// (no polling) rather than spinning. Returns a guard that `Drop`s into
150/// a release.
151///
152/// `dir` is usually the `.mkit/` directory (not the worktree root).
153/// `name` is the lockfile basename, e.g. `"index.lock"`.
154///
155/// # Errors
156/// - [`LockError::Busy`] if `timeout` elapses without the lock becoming
157/// available.
158/// - [`LockError::NameLength`] if `name` is empty or longer than 255.
159/// - [`LockError::InvalidName`] if `name` contains a path separator
160/// (`/`, `\`) or a NUL byte.
161/// - [`LockError::Io`] for underlying filesystem failures.
162pub fn acquire(dir: &Path, name: &str, timeout: Duration) -> LockResult<RepoLock> {
163 if name.is_empty() || name.len() > MAX_NAME_LEN {
164 return Err(LockError::NameLength(name.len()));
165 }
166 // Reject path separators and NUL so callers cannot escape `dir`
167 // nor embed bytes the platform filesystem treats specially. These
168 // are CONTENT violations, not LENGTH violations — hence a
169 // dedicated variant.
170 if name.contains('/') || name.contains('\\') || name.contains('\0') {
171 return Err(LockError::InvalidName(name.to_string()));
172 }
173 let path = dir.join(name);
174 let start = Instant::now();
175
176 // Open-or-create the never-unlinked sentinel. Unlike the old
177 // `O_EXCL`-create-new scheme, whether this call creates the file or
178 // reopens an existing one carries no meaning — the file's mere
179 // presence says nothing about whether anyone holds the kernel lock.
180 let file = OpenOptions::new()
181 .read(true)
182 .write(true)
183 .create(true)
184 .truncate(false)
185 .open(&path)?;
186
187 // Fast path: try the non-blocking lock first so the common
188 // uncontended case never pays for a thread spawn.
189 match file.try_lock() {
190 Ok(()) => {
191 return Ok(RepoLock {
192 file: Some(file),
193 path,
194 });
195 }
196 Err(std::fs::TryLockError::Error(e)) => return Err(LockError::Io(e)),
197 Err(std::fs::TryLockError::WouldBlock) => {}
198 }
199
200 let remaining = timeout.saturating_sub(start.elapsed());
201 if remaining.is_zero() {
202 return Err(LockError::Busy(name.to_string()));
203 }
204
205 // Slow path: block on the kernel lock for real. A helper thread
206 // performs the blocking `lock()` call (which cannot itself be
207 // bounded by a timeout) and reports back over a channel; this
208 // thread bounds the *wait* with `recv_timeout`. See the module doc
209 // for how an abandoned wait (timeout wins the race) avoids leaking
210 // the lock into an unreachable holder.
211 let (tx, rx) = mpsc::channel();
212 std::thread::spawn(move || {
213 let result = file.lock().map(|()| file);
214 let _ = tx.send(result);
215 });
216
217 match rx.recv_timeout(remaining) {
218 Ok(Ok(file)) => Ok(RepoLock {
219 file: Some(file),
220 path,
221 }),
222 Ok(Err(e)) => Err(LockError::Io(e)),
223 Err(mpsc::RecvTimeoutError::Timeout) => Err(LockError::Busy(name.to_string())),
224 Err(mpsc::RecvTimeoutError::Disconnected) => Err(LockError::Io(io::Error::other(
225 "repo lock wait thread exited without reporting a result",
226 ))),
227 }
228}
229
230/// Convenience wrapper: acquire with the default timeout.
231///
232/// # Errors
233/// See [`acquire`].
234pub fn acquire_default(dir: &Path, name: &str) -> LockResult<RepoLock> {
235 acquire(dir, name, DEFAULT_TIMEOUT)
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241 use tempfile::TempDir;
242
243 #[test]
244 fn acquire_release_round_trip() {
245 let dir = TempDir::new().unwrap();
246 {
247 let lock = acquire_default(dir.path(), "index.lock").unwrap();
248 assert!(lock.path().is_file());
249 assert_eq!(lock.path().file_name().unwrap(), "index.lock");
250 }
251 // Never-unlinked sentinel: the file persists after Drop/release
252 // (see module doc) — only the kernel lock is released. Re-acquire
253 // promptly to prove the lock itself is actually free.
254 assert!(
255 dir.path().join("index.lock").exists(),
256 "sentinel file must persist after release"
257 );
258 let l2 = acquire(dir.path(), "index.lock", Duration::from_millis(200)).unwrap();
259 drop(l2);
260 }
261
262 #[test]
263 fn second_acquire_after_release_succeeds() {
264 let dir = TempDir::new().unwrap();
265 let l1 = acquire_default(dir.path(), "index.lock").unwrap();
266 drop(l1);
267 let l2 = acquire_default(dir.path(), "index.lock").unwrap();
268 assert!(l2.path().is_file());
269 }
270
271 #[test]
272 fn acquire_while_held_returns_busy_after_short_timeout() {
273 let dir = TempDir::new().unwrap();
274 let _l1 = acquire_default(dir.path(), "index.lock").unwrap();
275 let err = acquire(dir.path(), "index.lock", Duration::from_millis(150)).unwrap_err();
276 assert!(matches!(err, LockError::Busy(_)));
277 }
278
279 #[test]
280 fn release_is_idempotent() {
281 let dir = TempDir::new().unwrap();
282 let mut lock = acquire_default(dir.path(), "index.lock").unwrap();
283 lock.release();
284 lock.release(); // No-op, no panic.
285 // Sentinel persists (never unlinked); the kernel lock is free.
286 assert!(dir.path().join("index.lock").exists());
287 let l2 = acquire(dir.path(), "index.lock", Duration::from_millis(200)).unwrap();
288 drop(l2);
289 }
290
291 #[test]
292 fn acquire_rejects_empty_name() {
293 let dir = TempDir::new().unwrap();
294 let err = acquire(dir.path(), "", DEFAULT_TIMEOUT).unwrap_err();
295 assert!(matches!(err, LockError::NameLength(0)));
296 }
297
298 #[test]
299 fn acquire_rejects_oversize_name() {
300 let dir = TempDir::new().unwrap();
301 let huge = "a".repeat(300);
302 let err = acquire(dir.path(), &huge, DEFAULT_TIMEOUT).unwrap_err();
303 assert!(matches!(err, LockError::NameLength(300)));
304 }
305
306 #[test]
307 fn acquire_rejects_separators() {
308 let dir = TempDir::new().unwrap();
309 assert!(matches!(
310 acquire(dir.path(), "../escape", DEFAULT_TIMEOUT).unwrap_err(),
311 LockError::InvalidName(_)
312 ));
313 assert!(matches!(
314 acquire(dir.path(), "sub/lock", DEFAULT_TIMEOUT).unwrap_err(),
315 LockError::InvalidName(_)
316 ));
317 }
318
319 #[test]
320 fn acquire_rejects_backslash_and_nul() {
321 let dir = TempDir::new().unwrap();
322 assert!(matches!(
323 acquire(dir.path(), "has\\backslash", DEFAULT_TIMEOUT).unwrap_err(),
324 LockError::InvalidName(_)
325 ));
326 assert!(matches!(
327 acquire(dir.path(), "has\0nul", DEFAULT_TIMEOUT).unwrap_err(),
328 LockError::InvalidName(_)
329 ));
330 }
331
332 #[test]
333 fn two_distinct_lock_names_coexist() {
334 let dir = TempDir::new().unwrap();
335 let _a = acquire_default(dir.path(), "a.lock").unwrap();
336 let _b = acquire_default(dir.path(), "b.lock").unwrap();
337 assert!(dir.path().join("a.lock").is_file());
338 assert!(dir.path().join("b.lock").is_file());
339 }
340
341 // -----------------------------------------------------------------
342 // #635 / INV-16 / INV-17 — blocking-wait regression tests.
343 // -----------------------------------------------------------------
344
345 /// INV-16: a lockfile orphaned by a process that died mid-hold must
346 /// not permanently wedge future acquires.
347 ///
348 /// We simulate "a process acquired the lock and was SIGKILL'd before
349 /// it could run its release/unlink cleanup" without a real
350 /// subprocess: create + `flock` the sentinel directly (bypassing
351 /// `acquire`, which would also register the normal release path),
352 /// then `drop` the handle without unlinking the file. Dropping the
353 /// `File` closes its descriptor, which releases the kernel `flock`
354 /// exactly as a killed process's fd closing would — but the sentinel
355 /// file itself is left behind on disk, exactly as `O_EXCL`-created
356 /// lockfiles are on a real SIGKILL.
357 ///
358 /// Against the old poll-`O_EXCL` wait loop this wedges forever: every
359 /// `acquire` sees `AlreadyExists` on `create_new`, never checks
360 /// whether the kernel lock is actually free, and burns the full
361 /// timeout before failing `Busy` — even though nobody holds it.
362 #[test]
363 fn orphaned_lock_does_not_wedge_future_acquire() {
364 let dir = TempDir::new().unwrap();
365 let path = dir.path().join("index.lock");
366
367 {
368 let orphan = OpenOptions::new()
369 .write(true)
370 .create_new(true)
371 .open(&path)
372 .unwrap();
373 orphan.lock().unwrap();
374 // Simulates the fd closing on process death: releases the
375 // kernel lock but leaves the sentinel file on disk.
376 drop(orphan);
377 }
378 assert!(
379 path.exists(),
380 "orphaned sentinel file must remain on disk after the simulated death"
381 );
382
383 // Nobody holds the kernel lock anymore, so a fresh acquire must
384 // succeed well within a bounded timeout rather than burning it.
385 let start = Instant::now();
386 let lock = acquire(dir.path(), "index.lock", Duration::from_secs(2))
387 .expect("acquire must succeed once the orphaned holder's kernel lock is free");
388 let elapsed = start.elapsed();
389 assert!(
390 elapsed < Duration::from_millis(500),
391 "acquire should not pay anywhere near the timeout on an orphaned lock, took {elapsed:?}"
392 );
393 drop(lock);
394 }
395
396 /// INV-8/INV-16: a genuinely blocking waiter must observe the actual
397 /// release event promptly, not merely "eventually, after polling
398 /// catches up." A holder thread releases well before the acquirer's
399 /// timeout; the acquirer must return success shortly after that
400 /// release rather than needing to reach the timeout.
401 #[test]
402 fn waiter_observes_release_from_a_live_holder_without_timing_out() {
403 let dir = TempDir::new().unwrap();
404 let dir_path = dir.path().to_path_buf();
405
406 let holder = acquire_default(&dir_path, "index.lock").unwrap();
407 let (release_tx, release_rx) = mpsc::channel::<()>();
408 let holder_thread = std::thread::spawn(move || {
409 // Hold the lock until told to let go.
410 let _ = release_rx.recv();
411 drop(holder);
412 });
413
414 let waiter_dir = dir_path.clone();
415 let waiter = std::thread::spawn(move || {
416 let start = Instant::now();
417 let lock = acquire(&waiter_dir, "index.lock", Duration::from_secs(5))
418 .expect("waiter must observe the release rather than timing out");
419 (lock, start.elapsed())
420 });
421
422 // Let the holder sit on the lock briefly, then release it. The
423 // waiter's bounded 5s timeout is generous; what matters is that
424 // it does not need anywhere near that long once release happens.
425 std::thread::sleep(Duration::from_millis(150));
426 release_tx.send(()).unwrap();
427 holder_thread.join().unwrap();
428
429 let (lock, elapsed) = waiter.join().unwrap();
430 assert!(
431 elapsed < Duration::from_secs(2),
432 "waiter should wake up promptly on release, not approach the 5s timeout, took {elapsed:?}"
433 );
434 drop(lock);
435 }
436}