Skip to main content

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}