1use std::fs::{self, File};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, thiserror::Error)]
19pub enum LockError {
20 #[error("lock already held by pid {pid} at {path}")]
22 Contended { pid: String, path: PathBuf },
23 #[error("lock I/O failed: {0}")]
25 Io(#[from] io::Error),
26}
27
28pub fn acquire(project_root: &Path, phase: u32) -> Result<LockGuard, LockError> {
33 acquire_path(lock_path(project_root, phase))
34}
35
36pub fn acquire_project(project_root: &Path) -> Result<LockGuard, LockError> {
46 acquire_path(project_lock_path(project_root))
47}
48
49pub fn acquire_project_blocking(
55 project_root: &Path,
56 timeout: std::time::Duration,
57) -> Result<LockGuard, LockError> {
58 let start = std::time::Instant::now();
59 let mut backoff = std::time::Duration::from_millis(100);
60 loop {
61 match acquire_project(project_root) {
62 Ok(guard) => return Ok(guard),
63 Err(err @ LockError::Contended { .. }) => {
64 if start.elapsed() >= timeout {
65 return Err(err);
66 }
67 std::thread::sleep(backoff.min(timeout.saturating_sub(start.elapsed())));
68 backoff = (backoff * 2).min(std::time::Duration::from_secs(2));
69 }
70 Err(err) => return Err(err),
71 }
72 }
73}
74
75fn acquire_path(path: PathBuf) -> Result<LockGuard, LockError> {
76 let parent = path.parent().ok_or_else(|| {
77 io::Error::new(
78 io::ErrorKind::InvalidInput,
79 "lock path has no parent directory",
80 )
81 })?;
82 fs::create_dir_all(parent)?;
83
84 match File::create_new(&path) {
85 Ok(mut f) => {
86 let pid = std::process::id().to_string();
87 write!(f, "{pid}")?;
88 Ok(LockGuard { path })
89 }
90 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
91 let pid = fs::read_to_string(&path)
92 .unwrap_or_else(|_| "unknown".into())
93 .trim()
94 .to_string();
95 if !pid_is_alive(&pid) {
104 tracing::warn!(
105 "reclaiming stale devflow lock at {} (holder pid {pid} is not alive)",
106 path.display()
107 );
108 let _ = fs::remove_file(&path);
109 return match File::create_new(&path) {
110 Ok(mut f) => {
111 let pid = std::process::id().to_string();
112 write!(f, "{pid}")?;
113 Ok(LockGuard { path })
114 }
115 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
116 let pid = fs::read_to_string(&path)
117 .unwrap_or_else(|_| "unknown".into())
118 .trim()
119 .to_string();
120 Err(LockError::Contended { pid, path })
121 }
122 Err(err) => Err(err.into()),
123 };
124 }
125 Err(LockError::Contended { pid, path })
126 }
127 Err(err) => Err(err.into()),
128 }
129}
130
131fn pid_is_alive(pid: &str) -> bool {
140 pid.parse::<u32>().is_ok_and(crate::agent::agent_running)
141}
142
143pub fn holder(project_root: &Path, phase: u32) -> Option<(String, PathBuf)> {
146 let path = lock_path(project_root, phase);
147 let pid = fs::read_to_string(&path).ok()?;
148 let pid = pid.trim().to_string();
149 if pid.is_empty() {
150 let _ = fs::remove_file(&path);
152 return None;
153 }
154 Some((pid, path))
155}
156
157fn release(path: &Path) {
160 let _ = fs::remove_file(path);
161}
162
163#[derive(Debug)]
165pub struct LockGuard {
166 path: PathBuf,
167}
168
169impl Drop for LockGuard {
170 fn drop(&mut self) {
171 release(&self.path);
172 }
173}
174
175const LOCK_FILE_PREFIX: &str = "lock-";
180
181fn lock_path(project_root: &Path, phase: u32) -> PathBuf {
182 project_root
183 .join(".devflow")
184 .join(format!("{LOCK_FILE_PREFIX}{phase:02}"))
185}
186
187fn project_lock_path(project_root: &Path) -> PathBuf {
188 project_root
189 .join(".devflow")
190 .join(format!("{LOCK_FILE_PREFIX}project"))
191}
192
193pub fn remove_stale_locks(project_root: &Path) -> Vec<String> {
202 let mut warnings = Vec::new();
203 let devflow_dir = project_root.join(".devflow");
204 let Ok(entries) = fs::read_dir(&devflow_dir) else {
205 return warnings;
206 };
207 for entry in entries.flatten() {
208 let name = entry.file_name();
209 let Some(name) = name.to_str() else { continue };
210 if !name.starts_with(LOCK_FILE_PREFIX) {
211 continue;
212 }
213 let path = entry.path();
214 let holder_pid = fs::read_to_string(&path)
215 .unwrap_or_default()
216 .trim()
217 .to_string();
218 if pid_is_alive(&holder_pid) {
219 warnings.push(format!(
220 "kept {} — holder pid {holder_pid} is still alive",
221 path.display()
222 ));
223 continue;
224 }
225 if let Err(err) = fs::remove_file(&path) {
226 warnings.push(format!("could not remove {}: {err}", path.display()));
227 }
228 }
229 warnings
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn acquire_creates_lock_and_records_pid() {
238 let dir = tempfile::tempdir().unwrap();
239 let guard = acquire(dir.path(), 1).expect("acquire");
240
241 let (pid, path) = holder(dir.path(), 1).expect("holder present");
242 assert_eq!(pid, std::process::id().to_string());
243 assert!(path.exists());
244 drop(guard);
245 }
246
247 #[test]
248 fn acquire_creates_devflow_directory_when_absent() {
249 let dir = tempfile::tempdir().unwrap();
250 assert!(!dir.path().join(".devflow").exists());
251 let _guard = acquire(dir.path(), 1).expect("acquire");
252 assert!(dir.path().join(".devflow").exists());
253 }
254
255 #[test]
256 fn second_acquire_is_contended() {
257 let dir = tempfile::tempdir().unwrap();
258 let _guard = acquire(dir.path(), 1).expect("first acquire");
259
260 match acquire(dir.path(), 1) {
261 Err(LockError::Contended { pid, .. }) => {
262 assert_eq!(pid, std::process::id().to_string());
263 }
264 Ok(_) => panic!("second acquire must fail"),
265 Err(other) => panic!("expected Contended, got {other:?}"),
266 }
267 }
268
269 #[test]
274 fn different_phases_do_not_contend() {
275 let dir = tempfile::tempdir().unwrap();
276 let _guard_a = acquire(dir.path(), 1).expect("acquire phase 1");
277 let _guard_b = acquire(dir.path(), 2).expect("acquire phase 2 must not contend");
278 }
279
280 #[test]
281 fn dropping_guard_releases_lock() {
282 let dir = tempfile::tempdir().unwrap();
283 {
284 let _guard = acquire(dir.path(), 1).expect("acquire");
285 assert!(holder(dir.path(), 1).is_some());
286 }
287 assert!(holder(dir.path(), 1).is_none());
289 let _again = acquire(dir.path(), 1).expect("re-acquire after release");
290 }
291
292 #[test]
293 fn holder_is_none_without_lock_file() {
294 let dir = tempfile::tempdir().unwrap();
295 assert!(holder(dir.path(), 1).is_none());
296 }
297
298 #[test]
299 fn holder_cleans_up_empty_lock_file() {
300 let dir = tempfile::tempdir().unwrap();
301 let path = lock_path(dir.path(), 1);
302 fs::create_dir_all(path.parent().unwrap()).unwrap();
303 fs::write(&path, " \n").unwrap();
304
305 assert!(holder(dir.path(), 1).is_none());
306 assert!(!path.exists());
308 let _guard = acquire(dir.path(), 1).expect("acquire after stale cleanup");
309 }
310
311 #[test]
315 fn acquire_reclaims_lock_from_dead_holder() {
316 let dir = tempfile::tempdir().unwrap();
317 let path = lock_path(dir.path(), 1);
318 fs::create_dir_all(path.parent().unwrap()).unwrap();
319 fs::write(&path, "9999999").unwrap();
321
322 let guard = acquire(dir.path(), 1).expect("stale lock must be reclaimed");
323 let (pid, _) = holder(dir.path(), 1).expect("holder present");
324 assert_eq!(pid, std::process::id().to_string());
325 drop(guard);
326 }
327
328 #[test]
329 fn acquire_reclaims_lock_with_corrupt_pid() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = lock_path(dir.path(), 1);
332 fs::create_dir_all(path.parent().unwrap()).unwrap();
333 fs::write(&path, "not-a-pid").unwrap();
334
335 acquire(dir.path(), 1).expect("corrupt lock must be reclaimed");
336 }
337
338 #[test]
342 fn remove_stale_locks_keeps_live_holder_and_sweeps_dead() {
343 let dir = tempfile::tempdir().unwrap();
344 let live = lock_path(dir.path(), 1);
345 let dead = lock_path(dir.path(), 2);
346 fs::create_dir_all(live.parent().unwrap()).unwrap();
347 fs::write(&live, std::process::id().to_string()).unwrap();
348 fs::write(&dead, "9999999").unwrap();
349
350 let warnings = remove_stale_locks(dir.path());
351
352 assert!(live.exists(), "live holder's lock must be kept");
353 assert!(!dead.exists(), "dead holder's lock must be swept");
354 assert_eq!(warnings.len(), 1, "keeping a live lock must be reported");
355 assert!(warnings[0].contains("still alive"));
356 }
357
358 #[test]
363 fn project_lock_is_independent_of_phase_locks() {
364 let dir = tempfile::tempdir().unwrap();
365 let _phase = acquire(dir.path(), 1).expect("phase lock");
366 let _project = acquire_project(dir.path()).expect("project lock must not contend");
367 }
368
369 #[test]
370 fn project_lock_contends_with_itself() {
371 let dir = tempfile::tempdir().unwrap();
372 let _held = acquire_project(dir.path()).expect("first acquire");
373 assert!(matches!(
374 acquire_project(dir.path()),
375 Err(LockError::Contended { .. })
376 ));
377 }
378
379 #[test]
382 fn project_lock_blocking_waits_for_release() {
383 let dir = tempfile::tempdir().unwrap();
384 let held = acquire_project(dir.path()).expect("first acquire");
385 let root = dir.path().to_path_buf();
386
387 std::thread::scope(|scope| {
388 let waiter = scope
389 .spawn(move || acquire_project_blocking(&root, std::time::Duration::from_secs(10)));
390 std::thread::sleep(std::time::Duration::from_millis(300));
393 drop(held);
394 waiter
395 .join()
396 .expect("waiter thread")
397 .expect("blocking acquire must succeed once the holder releases");
398 });
399 }
400
401 #[test]
402 fn project_lock_blocking_times_out_against_live_holder() {
403 let dir = tempfile::tempdir().unwrap();
404 let _held = acquire_project(dir.path()).expect("first acquire");
405 let err = acquire_project_blocking(dir.path(), std::time::Duration::from_millis(300))
406 .expect_err("must time out while the live holder keeps the lock");
407 assert!(matches!(err, LockError::Contended { .. }));
408 }
409
410 #[test]
415 fn acquire_reclaims_lock_with_pid_zero() {
416 let dir = tempfile::tempdir().unwrap();
417 let path = lock_path(dir.path(), 1);
418 fs::create_dir_all(path.parent().unwrap()).unwrap();
419 fs::write(&path, "0").unwrap();
420
421 acquire(dir.path(), 1).expect("pid-0 lock must be reclaimed");
422 }
423}