Skip to main content

devflow_core/
lock.rs

1//! File-based lock to prevent concurrent `devflow advance` invocations for
2//! the same phase.
3//!
4//! Creates `.devflow/lock-{phase:02}` with the PID of the lock holder.
5//! Uses O_EXCL for atomic acquisition — if the file already exists,
6//! the lock is contended.
7//!
8//! The lock is scoped per-phase (not per-project): `advance()` holds it
9//! across a gate's multi-day blocking wait, and every phase run ends at a
10//! mandatory Ship gate, so a project-wide lock would starve `devflow
11//! parallel`'s sibling phases with no retry (CR-03, 13-REVIEW.md).
12
13use std::fs::{self, File};
14use std::io::{self, Write};
15use std::path::{Path, PathBuf};
16
17/// Errors produced by lock operations.
18#[derive(Debug, thiserror::Error)]
19pub enum LockError {
20    /// Lock file already exists — another process holds it.
21    #[error("lock already held by pid {pid} at {path}")]
22    Contended { pid: String, path: PathBuf },
23    /// Filesystem operation failed.
24    #[error("lock I/O failed: {0}")]
25    Io(#[from] io::Error),
26}
27
28/// Acquire an exclusive lock for the given project root and phase.
29///
30/// Writes the current PID into `.devflow/lock-{phase:02}`. Returns a guard
31/// that releases the lock when dropped.
32pub fn acquire(project_root: &Path, phase: u32) -> Result<LockGuard, LockError> {
33    acquire_path(lock_path(project_root, phase))
34}
35
36/// Acquire the short-held, project-wide lock that serializes mutations of the
37/// primary checkout (version-bump commits/tags, docs commits, branch
38/// integration/cleanup) across concurrently finishing phases
39/// (13-DEFERRED-CR-03 fix shape #3).
40///
41/// This is the second level of the two-level model: per-phase locks guard a
42/// phase's own advance (held across gate-days), while this coarse lock guards
43/// the shared git checkout and is held for seconds. It must NEVER be held
44/// across a gate wait.
45pub fn acquire_project(project_root: &Path) -> Result<LockGuard, LockError> {
46    acquire_path(project_lock_path(project_root))
47}
48
49/// Blocking variant of [`acquire_project`]: waits out a sibling phase's short
50/// critical section, polling with backoff up to `timeout`. Returns the last
51/// `Contended` error if the sibling still holds the lock at the deadline —
52/// after `timeout` of waiting the holder is more likely wedged than slow, and
53/// failing loudly beats mutating the checkout concurrently.
54pub 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            // Stale-holder recovery (13-06 dogfood finding): a killed or
92            // crashed holder never runs LockGuard's Drop, and its abandoned
93            // lock wedges every future `advance` for the project — silently,
94            // since advance usually runs from a detached monitor with no
95            // terminal. If the recorded holder is not alive, reclaim the
96            // lock and retry the atomic create once. Best-effort: PID reuse
97            // is theoretically possible but the window is negligible for a
98            // per-project lock.
99            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
123/// The lock file's contents: the holder's pid on line 1, and its start time
124/// (`/proc/<pid>/stat` field 22) on line 2 when readable.
125///
126/// Two lines rather than one field, deliberately: every existing reader takes
127/// the first line, so the format stays backward compatible in both
128/// directions — an old binary reads a new lock file's pid correctly, and a
129/// new binary reads an old single-line lock file with the start time simply
130/// absent.
131///
132/// The start time is what makes the record an *identity* rather than a
133/// number. See [`crate::agent::process_start_time`]: a pid alone can be
134/// recycled, and a pid inspected via `/proc` can also be a devflow process's
135/// own child caught mid-`execve` (999.47), so anything that later signals
136/// this holder must match both halves.
137fn 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        // Fail soft on write, fail closed on use: a lock without a start
142        // time still works for mutual exclusion, and readers that need
143        // identity refuse to signal rather than guess.
144        None => format!("{pid}"),
145    }
146}
147
148/// The holder pid recorded in a lock file — the first line only, so a
149/// two-line lock file parses identically to the historical one-line form.
150fn 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
158/// The holder's recorded start time, if the lock file carries one. `None`
159/// for a legacy single-line lock, or when the value is unparseable.
160fn 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
170/// The recorded identity of a phase lock's holder: its pid, and its start
171/// time when the lock records one.
172///
173/// Callers that intend to **signal** the holder must require the start time
174/// to be present and to match [`crate::agent::process_start_time`] for that
175/// pid. A `None` start time means the lock predates identity recording and
176/// the holder cannot be confirmed — refuse, do not guess.
177pub 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
183/// Whether the pid recorded in a lock file refers to a live process.
184///
185/// A non-numeric pid (corrupt lock) is treated as dead so the lock can be
186/// reclaimed. Delegates to [`crate::agent::agent_running`] — the crate's one
187/// PID-liveness implementation — which also rejects `0` (a `kill -0 0`
188/// probes the caller's own process group and always succeeds, making a
189/// corrupted lock permanently "held") and values that would wrap negative
190/// through the `pid_t` cast.
191fn pid_is_alive(pid: &str) -> bool {
192    pid.parse::<u32>().is_ok_and(crate::agent::agent_running)
193}
194
195/// Check whether a lock is currently held for this project/phase,
196/// returning the PID of the holder if the file exists.
197pub fn holder(project_root: &Path, phase: u32) -> Option<(String, PathBuf)> {
198    let path = lock_path(project_root, phase);
199    // First line only: lock files now carry the holder's start time on line
200    // 2, and reading the whole file would yield "1234\n5678" as the "pid".
201    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        // Stale empty lock file — clean it up
206        let _ = fs::remove_file(&path);
207        return None;
208    }
209    Some((pid, path))
210}
211
212/// Release a lock by removing the lock file, ignoring errors
213/// if it's already gone.
214fn release(path: &Path) {
215    let _ = fs::remove_file(path);
216}
217
218/// Guard that releases the lock file on drop.
219#[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
230/// Filename prefix shared by every per-phase lock file. Owned here so
231/// sweepers (e.g. `recover --clean`) never hardcode the naming scheme.
232/// The project-wide checkout lock (`lock-project`) shares the prefix
233/// deliberately: the same stale-holder sweep covers it.
234const 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
248/// Remove this project's per-phase lock files, skipping any whose recorded
249/// holder PID is still alive — deleting a live holder's lock would let a
250/// duplicate `advance` acquire it, after which the original holder's
251/// `LockGuard::Drop` deletes the NEW holder's file.
252///
253/// Returns human-readable warnings for anything skipped or that failed to
254/// delete, so callers surface problems instead of reporting a clean sweep
255/// that left wedging locks behind.
256pub 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        // First line only — line 2 is the holder's start time.
270        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    /// CR-03 (13-REVIEW.md): the lock is scoped per-phase, so a different
323    /// phase in the same project must never contend on another phase's lock
324    /// — this is what lets `devflow parallel`'s sibling phases keep making
325    /// progress while one phase blocks on a multi-day gate wait.
326    #[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        // After the guard drops the lock file is gone and re-acquiring works.
341        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        // Empty/stale lock should be removed so a fresh acquire succeeds.
360        assert!(!path.exists());
361        let _guard = acquire(dir.path(), 1).expect("acquire after stale cleanup");
362    }
363
364    /// 13-06 dogfood regression: a killed poller's abandoned lock wedged
365    /// every subsequent `advance` for the project. A lock whose holder pid
366    /// is dead (or non-numeric) must be reclaimed transparently.
367    #[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        // Above default kernel pid_max (4194304) — guaranteed not alive.
373        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    /// `remove_stale_locks` must sweep dead-holder locks but never a live
392    /// holder's — deleting a held lock lets a duplicate advance acquire it,
393    /// and the original guard's Drop then removes the new holder's file.
394    #[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    /// Two-level locking (13-DEFERRED-CR-03): the coarse checkout lock is
412    /// independent of every per-phase lock — holding a phase's advance lock
413    /// (potentially for gate-days) must not block another phase's
414    /// seconds-long checkout mutation, and vice versa.
415    #[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    /// The blocking variant must wait out a short critical section instead of
433    /// failing fast — that is the whole point of a seconds-scale coarse lock.
434    #[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            // Give the waiter time to hit contention at least once, then
444            // release; it must then acquire rather than time out.
445            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    /// A lock file containing "0" parses as a valid u32, but `kill -0 0`
464    /// probes the caller's own process group and always succeeds — the old
465    /// subprocess-based check treated it as a live holder forever, wedging
466    /// every future acquire behind a Contended error.
467    #[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}