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 crate::workflow::ensure_devflow_dir(parent)?;
83
84 match File::create_new(&path) {
85 Ok(mut f) => {
86 write!(f, "{}", lock_contents())?;
87 Ok(LockGuard { path })
88 }
89 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
90 let pid = read_holder_pid(&path);
91 if !pid_is_alive(&pid) {
100 tracing::warn!(
101 "reclaiming stale devflow lock at {} (holder pid {pid} is not alive)",
102 path.display()
103 );
104 let _ = fs::remove_file(&path);
105 return match File::create_new(&path) {
106 Ok(mut f) => {
107 write!(f, "{}", lock_contents())?;
108 Ok(LockGuard { path })
109 }
110 Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
111 let pid = read_holder_pid(&path);
112 Err(LockError::Contended { pid, path })
113 }
114 Err(err) => Err(err.into()),
115 };
116 }
117 Err(LockError::Contended { pid, path })
118 }
119 Err(err) => Err(err.into()),
120 }
121}
122
123fn lock_contents() -> String {
138 let pid = std::process::id();
139 match crate::agent::process_start_time(pid) {
140 Some(start) => format!("{pid}\n{start}"),
141 None => format!("{pid}"),
145 }
146}
147
148fn read_holder_pid(path: &Path) -> String {
151 fs::read_to_string(path)
152 .ok()
153 .and_then(|text| text.lines().next().map(|line| line.trim().to_string()))
154 .filter(|pid| !pid.is_empty())
155 .unwrap_or_else(|| "unknown".into())
156}
157
158fn read_holder_start_time(path: &Path) -> Option<u64> {
161 fs::read_to_string(path)
162 .ok()?
163 .lines()
164 .nth(1)?
165 .trim()
166 .parse::<u64>()
167 .ok()
168}
169
170pub fn holder_identity(project_root: &Path, phase: u32) -> Option<(u32, Option<u64>)> {
178 let path = lock_path(project_root, phase);
179 let pid = read_holder_pid(&path).parse::<u32>().ok()?;
180 Some((pid, read_holder_start_time(&path)))
181}
182
183fn pid_is_alive(pid: &str) -> bool {
192 pid.parse::<u32>().is_ok_and(crate::agent::agent_running)
193}
194
195pub fn holder(project_root: &Path, phase: u32) -> Option<(String, PathBuf)> {
198 let path = lock_path(project_root, phase);
199 fs::read_to_string(&path).ok()?;
202 let pid = read_holder_pid(&path);
203 let pid = if pid == "unknown" { String::new() } else { pid };
204 if pid.is_empty() {
205 let _ = fs::remove_file(&path);
207 return None;
208 }
209 Some((pid, path))
210}
211
212fn release(path: &Path) {
215 let _ = fs::remove_file(path);
216}
217
218#[derive(Debug)]
220pub struct LockGuard {
221 path: PathBuf,
222}
223
224impl Drop for LockGuard {
225 fn drop(&mut self) {
226 release(&self.path);
227 }
228}
229
230const LOCK_FILE_PREFIX: &str = "lock-";
235
236pub(crate) fn lock_path(project_root: &Path, phase: u32) -> PathBuf {
237 project_root
238 .join(".devflow")
239 .join(format!("{LOCK_FILE_PREFIX}{phase:02}"))
240}
241
242pub(crate) fn project_lock_path(project_root: &Path) -> PathBuf {
243 project_root
244 .join(".devflow")
245 .join(format!("{LOCK_FILE_PREFIX}project"))
246}
247
248pub fn remove_stale_locks(project_root: &Path) -> Vec<String> {
257 let mut warnings = Vec::new();
258 let devflow_dir = project_root.join(".devflow");
259 let Ok(entries) = fs::read_dir(&devflow_dir) else {
260 return warnings;
261 };
262 for entry in entries.flatten() {
263 let name = entry.file_name();
264 let Some(name) = name.to_str() else { continue };
265 if !name.starts_with(LOCK_FILE_PREFIX) {
266 continue;
267 }
268 let path = entry.path();
269 let holder_pid = read_holder_pid(&path);
271 if pid_is_alive(&holder_pid) {
272 warnings.push(format!(
273 "kept {} — holder pid {holder_pid} is still alive",
274 path.display()
275 ));
276 continue;
277 }
278 if let Err(err) = fs::remove_file(&path) {
279 warnings.push(format!("could not remove {}: {err}", path.display()));
280 }
281 }
282 warnings
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn acquire_creates_lock_and_records_pid() {
291 let dir = tempfile::tempdir().unwrap();
292 let guard = acquire(dir.path(), 1).expect("acquire");
293
294 let (pid, path) = holder(dir.path(), 1).expect("holder present");
295 assert_eq!(pid, std::process::id().to_string());
296 assert!(path.exists());
297 drop(guard);
298 }
299
300 #[test]
301 fn acquire_creates_devflow_directory_when_absent() {
302 let dir = tempfile::tempdir().unwrap();
303 assert!(!dir.path().join(".devflow").exists());
304 let _guard = acquire(dir.path(), 1).expect("acquire");
305 assert!(dir.path().join(".devflow").exists());
306 }
307
308 #[test]
309 fn second_acquire_is_contended() {
310 let dir = tempfile::tempdir().unwrap();
311 let _guard = acquire(dir.path(), 1).expect("first acquire");
312
313 match acquire(dir.path(), 1) {
314 Err(LockError::Contended { pid, .. }) => {
315 assert_eq!(pid, std::process::id().to_string());
316 }
317 Ok(_) => panic!("second acquire must fail"),
318 Err(other) => panic!("expected Contended, got {other:?}"),
319 }
320 }
321
322 #[test]
327 fn different_phases_do_not_contend() {
328 let dir = tempfile::tempdir().unwrap();
329 let _guard_a = acquire(dir.path(), 1).expect("acquire phase 1");
330 let _guard_b = acquire(dir.path(), 2).expect("acquire phase 2 must not contend");
331 }
332
333 #[test]
334 fn dropping_guard_releases_lock() {
335 let dir = tempfile::tempdir().unwrap();
336 {
337 let _guard = acquire(dir.path(), 1).expect("acquire");
338 assert!(holder(dir.path(), 1).is_some());
339 }
340 assert!(holder(dir.path(), 1).is_none());
342 let _again = acquire(dir.path(), 1).expect("re-acquire after release");
343 }
344
345 #[test]
346 fn holder_is_none_without_lock_file() {
347 let dir = tempfile::tempdir().unwrap();
348 assert!(holder(dir.path(), 1).is_none());
349 }
350
351 #[test]
352 fn holder_cleans_up_empty_lock_file() {
353 let dir = tempfile::tempdir().unwrap();
354 let path = lock_path(dir.path(), 1);
355 fs::create_dir_all(path.parent().unwrap()).unwrap();
356 fs::write(&path, " \n").unwrap();
357
358 assert!(holder(dir.path(), 1).is_none());
359 assert!(!path.exists());
361 let _guard = acquire(dir.path(), 1).expect("acquire after stale cleanup");
362 }
363
364 #[test]
368 fn acquire_reclaims_lock_from_dead_holder() {
369 let dir = tempfile::tempdir().unwrap();
370 let path = lock_path(dir.path(), 1);
371 fs::create_dir_all(path.parent().unwrap()).unwrap();
372 fs::write(&path, "9999999").unwrap();
374
375 let guard = acquire(dir.path(), 1).expect("stale lock must be reclaimed");
376 let (pid, _) = holder(dir.path(), 1).expect("holder present");
377 assert_eq!(pid, std::process::id().to_string());
378 drop(guard);
379 }
380
381 #[test]
382 fn acquire_reclaims_lock_with_corrupt_pid() {
383 let dir = tempfile::tempdir().unwrap();
384 let path = lock_path(dir.path(), 1);
385 fs::create_dir_all(path.parent().unwrap()).unwrap();
386 fs::write(&path, "not-a-pid").unwrap();
387
388 acquire(dir.path(), 1).expect("corrupt lock must be reclaimed");
389 }
390
391 #[test]
395 fn remove_stale_locks_keeps_live_holder_and_sweeps_dead() {
396 let dir = tempfile::tempdir().unwrap();
397 let live = lock_path(dir.path(), 1);
398 let dead = lock_path(dir.path(), 2);
399 fs::create_dir_all(live.parent().unwrap()).unwrap();
400 fs::write(&live, std::process::id().to_string()).unwrap();
401 fs::write(&dead, "9999999").unwrap();
402
403 let warnings = remove_stale_locks(dir.path());
404
405 assert!(live.exists(), "live holder's lock must be kept");
406 assert!(!dead.exists(), "dead holder's lock must be swept");
407 assert_eq!(warnings.len(), 1, "keeping a live lock must be reported");
408 assert!(warnings[0].contains("still alive"));
409 }
410
411 #[test]
416 fn project_lock_is_independent_of_phase_locks() {
417 let dir = tempfile::tempdir().unwrap();
418 let _phase = acquire(dir.path(), 1).expect("phase lock");
419 let _project = acquire_project(dir.path()).expect("project lock must not contend");
420 }
421
422 #[test]
423 fn project_lock_contends_with_itself() {
424 let dir = tempfile::tempdir().unwrap();
425 let _held = acquire_project(dir.path()).expect("first acquire");
426 assert!(matches!(
427 acquire_project(dir.path()),
428 Err(LockError::Contended { .. })
429 ));
430 }
431
432 #[test]
435 fn project_lock_blocking_waits_for_release() {
436 let dir = tempfile::tempdir().unwrap();
437 let held = acquire_project(dir.path()).expect("first acquire");
438 let root = dir.path().to_path_buf();
439
440 std::thread::scope(|scope| {
441 let waiter = scope
442 .spawn(move || acquire_project_blocking(&root, std::time::Duration::from_secs(10)));
443 std::thread::sleep(std::time::Duration::from_millis(300));
446 drop(held);
447 waiter
448 .join()
449 .expect("waiter thread")
450 .expect("blocking acquire must succeed once the holder releases");
451 });
452 }
453
454 #[test]
455 fn project_lock_blocking_times_out_against_live_holder() {
456 let dir = tempfile::tempdir().unwrap();
457 let _held = acquire_project(dir.path()).expect("first acquire");
458 let err = acquire_project_blocking(dir.path(), std::time::Duration::from_millis(300))
459 .expect_err("must time out while the live holder keeps the lock");
460 assert!(matches!(err, LockError::Contended { .. }));
461 }
462
463 #[test]
468 fn acquire_reclaims_lock_with_pid_zero() {
469 let dir = tempfile::tempdir().unwrap();
470 let path = lock_path(dir.path(), 1);
471 fs::create_dir_all(path.parent().unwrap()).unwrap();
472 fs::write(&path, "0").unwrap();
473
474 acquire(dir.path(), 1).expect("pid-0 lock must be reclaimed");
475 }
476}