1use 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#[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 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 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 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
80pub fn write_lock_path(database: &Path) -> PathBuf {
83 database.parent().unwrap_or_else(|| Path::new(".")).join("rag-rat-write.lock")
84}
85
86pub const MAX_SOCKET_PATH_LEN: usize = 100;
88
89fn 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
102pub 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
114pub 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
120pub 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
130pub 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
138pub 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
146fn 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 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 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 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 fn long_base_dir() -> PathBuf {
243 let mut base = temp_dir();
244 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 let long_base = long_base_dir();
256 let root = temp_dir();
257 let short_runtime_base = std::env::temp_dir(); 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 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 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 assert!(!socket.starts_with(&long_base));
279 assert!(!socket.starts_with(&long_runtime_base));
280 assert!(
282 socket.starts_with(std::env::temp_dir()),
283 "expected temp-dir fallback, got: {}",
284 socket.display()
285 );
286 }
287}