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: `O_EXCL`-create a sentinel file, hold an OS-level exclusive
5//! advisory lock on it, then delete on release. The lockfile is visible
6//! on disk so a stale lock left behind by a SIGKILL'd `mkit` is
7//! debuggable (`ls .mkit/*.lock`) and removable by hand.
8//!
9//! We take an exclusive `flock(2)`-equivalent on the file via
10//! `std::fs::File::lock` so concurrent acquirers within the same OS
11//! block on the kernel instead of spinning on `EEXIST`. The `O_EXCL`
12//! create still wins the file-creation race on the first attempt; on
13//! the wait path we open the existing file read-only and call `lock()`
14//! to wait for the current holder to release.
15//!
16//! POSIX-only intent (macOS + Linux). `std::fs::File::lock` is also
17//! supported on Windows since Rust 1.89, so this works there too — the
18//! lock semantics are equivalent (mandatory `LockFileEx` rather than
19//! advisory `flock`).
20
21use std::fs::{File, OpenOptions};
22use std::io;
23use std::path::{Path, PathBuf};
24use std::time::{Duration, Instant};
25
26/// Default per-attempt sleep between create-retries on contention. Long
27/// enough to avoid CPU monopolisation, short enough that another fast
28/// `mkit` finishing a quick commit is observed promptly.
29pub const DEFAULT_SLEEP: Duration = Duration::from_millis(50);
30
31/// Default total wall-clock timeout (≈5s). Long enough that a slow
32/// commit in another process finishes; short enough that a stale lock
33/// from a SIGKILL'd `mkit` does not wedge the user for more than a moment.
34pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);
35
36/// Maximum filename length for a lock name.
37const MAX_NAME_LEN: usize = 255;
38
39/// Errors returned by [`acquire`].
40#[derive(Debug, thiserror::Error)]
41pub enum LockError {
42    /// Timeout exhausted; another holder still owns the lock.
43    #[error("lock '{0}' busy after timeout")]
44    Busy(String),
45    /// `name` is empty or longer than the platform-safe filename cap.
46    #[error("lock name length {0} is invalid (must be 1..={MAX_NAME_LEN})")]
47    NameLength(usize),
48    /// `name` contains a path separator (`/`, `\`) or a NUL byte. A
49    /// length-based classification would be misleading here — the
50    /// value's length is fine; it's the contents that are wrong.
51    #[error("lock name contains an invalid character (`/`, `\\`, or NUL): {0:?}")]
52    InvalidName(String),
53    /// Underlying filesystem failure (disk full, permission denied, …).
54    #[error(transparent)]
55    Io(#[from] io::Error),
56}
57
58/// Result alias used throughout this module.
59pub type LockResult<T> = Result<T, LockError>;
60
61/// Holder for an acquired repo lock. Releases the file on `Drop`.
62///
63/// `release()` is the explicit form; calling it is optional because
64/// `Drop` does the same work. After `release()` is called, `Drop` is a
65/// cheap no-op.
66#[must_use = "RepoLock releases on drop; bind it to a name to keep the lock"]
67#[derive(Debug)]
68pub struct RepoLock {
69    /// Held file with the OS-level exclusive lock applied.
70    /// `None` after `release()`.
71    file: Option<File>,
72    /// Absolute path to the lockfile, for the unlink-on-release step
73    /// and for diagnostics via [`Self::path`].
74    path: PathBuf,
75}
76
77impl RepoLock {
78    /// Returns the absolute path of the held lock file, for diagnostics.
79    #[must_use]
80    pub fn path(&self) -> &Path {
81        &self.path
82    }
83
84    /// Release the lock: drop the OS lock and unlink the file. Safe to
85    /// call multiple times — subsequent calls are no-ops.
86    pub fn release(&mut self) {
87        if let Some(file) = self.file.take() {
88            // `unlock()` is best-effort; `Drop` of the file would also
89            // release the kernel lock. We still call it explicitly so a
90            // mid-test reader can re-acquire on the same handle if it
91            // wants to.
92            let _ = file.unlock();
93            drop(file);
94            // Best-effort unlink — if another process already grabbed
95            // the slot via `O_EXCL` we deliberately leave their file
96            // alone, but in practice the kernel lock above prevents
97            // that race. Errors are swallowed because we have no
98            // user-visible path to surface them on `Drop`.
99            let _ = std::fs::remove_file(&self.path);
100        }
101    }
102}
103
104impl Drop for RepoLock {
105    fn drop(&mut self) {
106        self.release();
107    }
108}
109
110/// Acquire a repo-level lock at `<dir>/<name>`. Spins up to `timeout`
111/// waiting for an existing holder to release. Returns a guard that
112/// `Drop`s into a release.
113///
114/// `dir` is usually the `.mkit/` directory (not the worktree root).
115/// `name` is the lockfile basename, e.g. `"index.lock"`.
116///
117/// # Errors
118/// - [`LockError::Busy`] if `timeout` elapses without the lock becoming
119///   available.
120/// - [`LockError::NameLength`] if `name` is empty or longer than 255.
121/// - [`LockError::InvalidName`] if `name` contains a path separator
122///   (`/`, `\`) or a NUL byte.
123/// - [`LockError::Io`] for underlying filesystem failures.
124pub fn acquire(dir: &Path, name: &str, timeout: Duration) -> LockResult<RepoLock> {
125    if name.is_empty() || name.len() > MAX_NAME_LEN {
126        return Err(LockError::NameLength(name.len()));
127    }
128    // Reject path separators and NUL so callers cannot escape `dir`
129    // nor embed bytes the platform filesystem treats specially. These
130    // are CONTENT violations, not LENGTH violations — hence a
131    // dedicated variant.
132    if name.contains('/') || name.contains('\\') || name.contains('\0') {
133        return Err(LockError::InvalidName(name.to_string()));
134    }
135    let path = dir.join(name);
136    let start = Instant::now();
137    loop {
138        match OpenOptions::new().write(true).create_new(true).open(&path) {
139            Ok(file) => {
140                // We won the create race; take the kernel lock for
141                // good measure and return.
142                file.lock()?;
143                return Ok(RepoLock {
144                    file: Some(file),
145                    path,
146                });
147            }
148            Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
149                if start.elapsed() >= timeout {
150                    return Err(LockError::Busy(name.to_string()));
151                }
152                std::thread::sleep(DEFAULT_SLEEP);
153            }
154            Err(e) => return Err(LockError::Io(e)),
155        }
156    }
157}
158
159/// Convenience wrapper: acquire with the default timeout.
160///
161/// # Errors
162/// See [`acquire`].
163pub fn acquire_default(dir: &Path, name: &str) -> LockResult<RepoLock> {
164    acquire(dir, name, DEFAULT_TIMEOUT)
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    #[test]
173    fn acquire_release_round_trip() {
174        let dir = TempDir::new().unwrap();
175        {
176            let lock = acquire_default(dir.path(), "index.lock").unwrap();
177            assert!(lock.path().is_file());
178            assert_eq!(lock.path().file_name().unwrap(), "index.lock");
179        }
180        // After Drop, the file is gone.
181        assert!(!dir.path().join("index.lock").exists());
182    }
183
184    #[test]
185    fn second_acquire_after_release_succeeds() {
186        let dir = TempDir::new().unwrap();
187        let l1 = acquire_default(dir.path(), "index.lock").unwrap();
188        drop(l1);
189        let l2 = acquire_default(dir.path(), "index.lock").unwrap();
190        assert!(l2.path().is_file());
191    }
192
193    #[test]
194    fn acquire_while_held_returns_busy_after_short_timeout() {
195        let dir = TempDir::new().unwrap();
196        let _l1 = acquire_default(dir.path(), "index.lock").unwrap();
197        let err = acquire(dir.path(), "index.lock", Duration::from_millis(150)).unwrap_err();
198        assert!(matches!(err, LockError::Busy(_)));
199    }
200
201    #[test]
202    fn release_is_idempotent() {
203        let dir = TempDir::new().unwrap();
204        let mut lock = acquire_default(dir.path(), "index.lock").unwrap();
205        lock.release();
206        lock.release(); // No-op, no panic.
207        assert!(!dir.path().join("index.lock").exists());
208    }
209
210    #[test]
211    fn acquire_rejects_empty_name() {
212        let dir = TempDir::new().unwrap();
213        let err = acquire(dir.path(), "", DEFAULT_TIMEOUT).unwrap_err();
214        assert!(matches!(err, LockError::NameLength(0)));
215    }
216
217    #[test]
218    fn acquire_rejects_oversize_name() {
219        let dir = TempDir::new().unwrap();
220        let huge = "a".repeat(300);
221        let err = acquire(dir.path(), &huge, DEFAULT_TIMEOUT).unwrap_err();
222        assert!(matches!(err, LockError::NameLength(300)));
223    }
224
225    #[test]
226    fn acquire_rejects_separators() {
227        let dir = TempDir::new().unwrap();
228        assert!(matches!(
229            acquire(dir.path(), "../escape", DEFAULT_TIMEOUT).unwrap_err(),
230            LockError::InvalidName(_)
231        ));
232        assert!(matches!(
233            acquire(dir.path(), "sub/lock", DEFAULT_TIMEOUT).unwrap_err(),
234            LockError::InvalidName(_)
235        ));
236    }
237
238    #[test]
239    fn acquire_rejects_backslash_and_nul() {
240        let dir = TempDir::new().unwrap();
241        assert!(matches!(
242            acquire(dir.path(), "has\\backslash", DEFAULT_TIMEOUT).unwrap_err(),
243            LockError::InvalidName(_)
244        ));
245        assert!(matches!(
246            acquire(dir.path(), "has\0nul", DEFAULT_TIMEOUT).unwrap_err(),
247            LockError::InvalidName(_)
248        ));
249    }
250
251    #[test]
252    fn two_distinct_lock_names_coexist() {
253        let dir = TempDir::new().unwrap();
254        let _a = acquire_default(dir.path(), "a.lock").unwrap();
255        let _b = acquire_default(dir.path(), "b.lock").unwrap();
256        assert!(dir.path().join("a.lock").is_file());
257        assert!(dir.path().join("b.lock").is_file());
258    }
259}