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}