Skip to main content

rag_rat_core/
locks.rs

1//! Cross-platform file locks for the index file watcher, using std's native `File::{lock,
2//! try_lock, unlock}` (stable since Rust 1.89 — `flock` on Unix, `LockFileEx` on Windows, no
3//! external crate). Two distinct locks coordinate writers without an HTTP daemon:
4//!
5//! - the **per-worktree election lock** (one watcher per worktree), keyed by the canonicalized
6//!   worktree root and living under the git common dir;
7//! - the **per-DB write-serialization lock** (held by the watcher, the git hooks, and manual
8//!   `index`), so exactly one writer touches the shared index at a time.
9//!
10//! Locks release when the file handle drops (the OS also releases on process death), so there is no
11//! stale-pidfile cleanup. Caveat: file locks are unreliable on NFS and WSL2 `drvfs`/`9p` mounts.
12
13use std::{
14    fs::{self, File, OpenOptions, TryLockError},
15    path::{Path, PathBuf},
16    time::{Duration, Instant},
17};
18
19use anyhow::Context as _;
20use sha2::{Digest, Sha256};
21
22use crate::config::Config;
23
24/// A held exclusive file lock. Released on drop.
25#[derive(Debug)]
26pub struct FileLock {
27    _file: File,
28}
29
30impl FileLock {
31    fn open(path: &Path) -> anyhow::Result<File> {
32        if let Some(parent) = path.parent() {
33            fs::create_dir_all(parent)
34                .with_context(|| format!("creating lock dir {}", parent.display()))?;
35        }
36        OpenOptions::new()
37            .create(true)
38            .truncate(false)
39            .write(true)
40            .open(path)
41            .with_context(|| format!("opening lock file {}", path.display()))
42    }
43
44    /// Non-blocking. `Ok(Some)` if acquired now, `Ok(None)` if another holder has it.
45    pub fn try_acquire(path: &Path) -> anyhow::Result<Option<FileLock>> {
46        let file = Self::open(path)?;
47        match file.try_lock() {
48            Ok(()) => Ok(Some(FileLock { _file: file })),
49            Err(TryLockError::WouldBlock) => Ok(None),
50            Err(TryLockError::Error(err)) => {
51                Err(anyhow::Error::from(err).context(format!("try-locking {}", path.display())))
52            },
53        }
54    }
55
56    /// Blocks until acquired. Use only watcher-to-watcher; interactive callers use
57    /// [`FileLock::acquire_timeout`] so a hung holder can't hang `git checkout`.
58    pub fn acquire_blocking(path: &Path) -> anyhow::Result<FileLock> {
59        let file = Self::open(path)?;
60        file.lock().with_context(|| format!("locking {}", path.display()))?;
61        Ok(FileLock { _file: file })
62    }
63
64    /// Polls until acquired or `timeout` elapses; `Ok(None)` on timeout (caller should warn-skip).
65    pub fn acquire_timeout(path: &Path, timeout: Duration) -> anyhow::Result<Option<FileLock>> {
66        let deadline = Instant::now() + timeout;
67        let poll = Duration::from_millis(50).min(timeout.max(Duration::from_millis(1)));
68        loop {
69            if let Some(lock) = Self::try_acquire(path)? {
70                return Ok(Some(lock));
71            }
72            if Instant::now() >= deadline {
73                return Ok(None);
74            }
75            std::thread::sleep(poll);
76        }
77    }
78}
79
80/// Per-DB write-serialization lock path: next to the index database (under the git common dir for a
81/// shared DB, or `<root>/.rag-rat/` for a single worktree — both excluded from the watch tree).
82pub fn write_lock_path(database: &Path) -> PathBuf {
83    database.parent().unwrap_or_else(|| Path::new(".")).join("rag-rat-write.lock")
84}
85
86/// `sun_path` budget for Unix domain sockets (108 bytes on Linux, 104 on macOS) with headroom.
87pub const MAX_SOCKET_PATH_LEN: usize = 100;
88
89/// Stable per-worktree key: sha256 of the canonicalized root (see `election_lock_path` doc
90/// comment for why canonicalize-but-not-case-fold).
91fn worktree_hash(worktree_root: &Path) -> String {
92    let canonical = worktree_root.canonicalize().unwrap_or_else(|_| worktree_root.to_path_buf());
93    let digest = Sha256::digest(canonical.to_string_lossy().as_bytes());
94    let mut hash = String::with_capacity(32);
95    for byte in &digest[..16] {
96        use std::fmt::Write as _;
97        let _ = write!(hash, "{byte:02x}");
98    }
99    hash
100}
101
102/// Per-worktree election lock path, keyed by a hash of the **canonicalized** worktree root —
103/// `canonicalize` resolves symlink aliases (the common way one checkout is reached via two paths) to
104/// one key. We deliberately do **not** case-fold: folding would, on a case-sensitive volume,
105/// collapse two genuinely-distinct worktrees into one key and leave one permanently un-elected
106/// (silent staleness — the exact failure this design exists to prevent). The remaining edge — the
107/// same checkout reached via differently-cased paths on a case-insensitive FS — merely elects two
108/// watchers, which the write lock makes harmless. `base_dir` is the index DB's directory (the
109/// shared location across a repo's worktrees), so all election locks sit under `<base_dir>/locks/`.
110pub fn election_lock_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
111    base_dir.join("locks").join(format!("{}.lock", worktree_hash(worktree_root)))
112}
113
114/// Election lock for the grep-augment hook socket: one listener per worktree, separate from the
115/// watcher election so core never calls back into the MCP crate and either process may win each.
116pub fn socket_lock_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
117    base_dir.join("locks").join(format!("{}.socket.lock", worktree_hash(worktree_root)))
118}
119
120/// Where the elected listener binds. Prefers a `sockets/` sibling of `locks/` under the shared
121/// DB dir; diverts to `$XDG_RUNTIME_DIR/rag-rat/` then the OS temp dir when the result would
122/// exceed the `sun_path` budget. Hook clients compute the same path independently, so this must
123/// stay deterministic for a given (base_dir, worktree_root) and environment.
124pub fn hook_socket_path(base_dir: &Path, worktree_root: &Path) -> PathBuf {
125    let runtime_base =
126        std::env::var_os("XDG_RUNTIME_DIR").map(PathBuf::from).unwrap_or_else(std::env::temp_dir);
127    socket_path_with_runtime_base(base_dir, worktree_root, &runtime_base)
128}
129
130/// Single source of truth for the hook socket path given a `Config`. Shared by the MCP listener
131/// and the CLI client so the two cannot diverge.
132pub fn hook_socket_path_for(config: &Config) -> PathBuf {
133    let base =
134        config.database.parent().map(Path::to_path_buf).unwrap_or_else(|| config.root.clone());
135    hook_socket_path(&base, &config.root)
136}
137
138/// Single source of truth for the hook socket election-lock path given a `Config`. Shared by the
139/// MCP listener and the CLI client so the two cannot diverge.
140pub fn hook_socket_lock_path_for(config: &Config) -> PathBuf {
141    let base =
142        config.database.parent().map(Path::to_path_buf).unwrap_or_else(|| config.root.clone());
143    socket_lock_path(&base, &config.root)
144}
145
146/// Inner implementation: builds the candidate path cascade with an explicit `runtime_base` so the
147/// fallback logic can be unit-tested without touching the process environment.
148///
149/// Priority:
150/// 1. `<base_dir>/sockets/<hash>.sock` — within budget?  Use it.
151/// 2. `<runtime_base>/rag-rat/<hash>.sock` — within budget?  Use it.
152/// 3. `<temp_dir>/rag-rat/<hash>.sock` — best effort; callers fail open if still over budget.
153fn socket_path_with_runtime_base(
154    base_dir: &Path,
155    worktree_root: &Path,
156    runtime_base: &Path,
157) -> PathBuf {
158    let name = format!("{}.sock", worktree_hash(worktree_root));
159    let preferred = base_dir.join("sockets").join(&name);
160    if preferred.as_os_str().len() <= MAX_SOCKET_PATH_LEN {
161        return preferred;
162    }
163    let xdg_candidate = runtime_base.join("rag-rat").join(&name);
164    if xdg_candidate.as_os_str().len() <= MAX_SOCKET_PATH_LEN {
165        return xdg_candidate;
166    }
167    // Both preferred and XDG are over budget — fall through to the OS temp dir.
168    // If even this is over budget there is nothing better; callers fail open.
169    std::env::temp_dir().join("rag-rat").join(name)
170}
171
172#[cfg(test)]
173mod tests {
174    use std::sync::atomic::{AtomicU64, Ordering};
175
176    use super::*;
177
178    static LOCK_TEMP: AtomicU64 = AtomicU64::new(0);
179
180    fn temp_dir() -> PathBuf {
181        let id = LOCK_TEMP.fetch_add(1, Ordering::Relaxed);
182        let dir = std::env::temp_dir().join(format!("ragrat-lock-{}-{id}", std::process::id()));
183        fs::create_dir_all(&dir).unwrap();
184        dir
185    }
186
187    #[test]
188    fn exclusive_lock_blocks_second_holder_and_releases_on_drop() {
189        let dir = temp_dir();
190        let path = dir.join("a.lock");
191
192        let first = FileLock::try_acquire(&path).unwrap();
193        assert!(first.is_some(), "first acquire should succeed");
194
195        let second = FileLock::try_acquire(&path).unwrap();
196        assert!(second.is_none(), "second acquire must fail while held");
197
198        // A different path is independent (cross-project isolation).
199        let other = FileLock::try_acquire(&dir.join("b.lock")).unwrap();
200        assert!(other.is_some(), "a different lock path should acquire");
201
202        drop(first);
203        let reacquired = FileLock::try_acquire(&path).unwrap();
204        assert!(reacquired.is_some(), "should acquire after the holder drops");
205
206        let _ = fs::remove_dir_all(&dir);
207    }
208
209    #[test]
210    fn election_path_is_stable_per_root_and_distinct_across_roots() {
211        let base = Path::new("/repo/.git/rag-rat");
212        let a1 = election_lock_path(base, Path::new("/repo"));
213        let a2 = election_lock_path(base, Path::new("/repo"));
214        let b = election_lock_path(base, Path::new("/repo-wt"));
215        assert_eq!(a1, a2, "same worktree root → same lock");
216        assert_ne!(a1, b, "different worktree roots → different locks");
217        assert!(a1.starts_with(base.join("locks")));
218    }
219
220    #[test]
221    fn socket_lock_path_is_distinct_from_election_lock_path() {
222        let base = temp_dir();
223        let root = temp_dir();
224        let election = election_lock_path(&base, &root);
225        let socket_lock = socket_lock_path(&base, &root);
226        assert_ne!(election, socket_lock);
227        assert!(socket_lock.to_string_lossy().ends_with(".socket.lock"));
228        // Same worktree key: both live under <base>/locks/ with the same hash stem.
229        assert_eq!(election.parent(), socket_lock.parent());
230    }
231
232    #[test]
233    fn hook_socket_path_lives_under_base_sockets_dir() {
234        let base = temp_dir();
235        let root = temp_dir();
236        let socket = hook_socket_path(&base, &root);
237        assert_eq!(socket.parent().unwrap().file_name().unwrap(), "sockets");
238        assert!(socket.extension().is_some_and(|ext| ext == "sock"));
239    }
240
241    /// Build a base dir long enough that `<base>/sockets/<hash>.sock` exceeds `MAX_SOCKET_PATH_LEN`.
242    fn long_base_dir() -> PathBuf {
243        let mut base = temp_dir();
244        // Each push appends ~28 bytes; 12 × 28 = 336, well over the 100-byte budget.
245        for _ in 0..12 {
246            base.push("very-long-directory-segment");
247        }
248        base
249    }
250
251    #[test]
252    fn hook_socket_path_falls_back_when_base_path_is_too_long() {
253        // When the preferred path is over budget and XDG_RUNTIME_DIR is a short /tmp path, the
254        // XDG candidate fits within budget and is returned.
255        let long_base = long_base_dir();
256        let root = temp_dir();
257        // Use a known-short runtime_base so the test is independent of the runner environment.
258        let short_runtime_base = std::env::temp_dir(); // e.g. /tmp — always short
259        let socket = socket_path_with_runtime_base(&long_base, &root, &short_runtime_base);
260        assert!(
261            socket.as_os_str().len() <= MAX_SOCKET_PATH_LEN,
262            "XDG fallback path still too long: {}",
263            socket.display()
264        );
265        // Should NOT live under the long base dir.
266        assert!(!socket.starts_with(&long_base), "expected fallback, got preferred path");
267    }
268
269    #[test]
270    fn hook_socket_path_falls_back_to_temp_when_xdg_also_too_long() {
271        // When both the preferred path and the XDG candidate are over budget, the function falls
272        // through to the OS temp dir (best-effort; callers fail open).
273        let long_base = long_base_dir();
274        let long_runtime_base = long_base_dir();
275        let root = temp_dir();
276        let socket = socket_path_with_runtime_base(&long_base, &root, &long_runtime_base);
277        // Must not be under either long base.
278        assert!(!socket.starts_with(&long_base));
279        assert!(!socket.starts_with(&long_runtime_base));
280        // Should be rooted at the OS temp dir.
281        assert!(
282            socket.starts_with(std::env::temp_dir()),
283            "expected temp-dir fallback, got: {}",
284            socket.display()
285        );
286    }
287}