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 crate::phase_id::PhaseId;
14use std::fs::{self, File};
15use std::io::{self, Write};
16use std::path::{Path, PathBuf};
17
18/// Errors produced by lock operations.
19#[derive(Debug, thiserror::Error)]
20pub enum LockError {
21    /// Lock file already exists — another process holds it.
22    #[error("lock already held by pid {pid} at {path}")]
23    Contended { pid: String, path: PathBuf },
24    /// Filesystem operation failed.
25    #[error("lock I/O failed: {0}")]
26    Io(#[from] io::Error),
27}
28
29/// Acquire an exclusive lock for the given project root and phase.
30///
31/// Writes the current PID into `.devflow/lock-{phase:02}`. Returns a guard
32/// that releases the lock when dropped.
33pub fn acquire(project_root: &Path, phase: PhaseId) -> Result<LockGuard, LockError> {
34    acquire_path(lock_path(project_root, phase))
35}
36
37/// Acquire the short-held, project-wide lock that serializes mutations of the
38/// primary checkout (version-bump commits/tags, docs commits, branch
39/// integration/cleanup) across concurrently finishing phases
40/// (13-DEFERRED-CR-03 fix shape #3).
41///
42/// This is the second level of the two-level model: per-phase locks guard a
43/// phase's own advance (held across gate-days), while this coarse lock guards
44/// the shared git checkout and is held for seconds. It must NEVER be held
45/// across a gate wait.
46pub fn acquire_project(project_root: &Path) -> Result<LockGuard, LockError> {
47    acquire_path(project_lock_path(project_root))
48}
49
50/// Blocking variant of [`acquire_project`]: waits out a sibling phase's short
51/// critical section, polling with backoff up to `timeout`. Returns the last
52/// `Contended` error if the sibling still holds the lock at the deadline —
53/// after `timeout` of waiting the holder is more likely wedged than slow, and
54/// failing loudly beats mutating the checkout concurrently.
55pub fn acquire_project_blocking(
56    project_root: &Path,
57    timeout: std::time::Duration,
58) -> Result<LockGuard, LockError> {
59    let start = std::time::Instant::now();
60    let mut backoff = std::time::Duration::from_millis(100);
61    loop {
62        match acquire_project(project_root) {
63            Ok(guard) => return Ok(guard),
64            Err(err @ LockError::Contended { .. }) => {
65                if start.elapsed() >= timeout {
66                    return Err(err);
67                }
68                std::thread::sleep(backoff.min(timeout.saturating_sub(start.elapsed())));
69                backoff = (backoff * 2).min(std::time::Duration::from_secs(2));
70            }
71            Err(err) => return Err(err),
72        }
73    }
74}
75
76fn acquire_path(path: PathBuf) -> Result<LockGuard, LockError> {
77    let parent = path.parent().ok_or_else(|| {
78        io::Error::new(
79            io::ErrorKind::InvalidInput,
80            "lock path has no parent directory",
81        )
82    })?;
83    crate::workflow::ensure_devflow_dir(parent)?;
84
85    match File::create_new(&path) {
86        Ok(mut f) => {
87            write!(f, "{}", lock_contents())?;
88            Ok(LockGuard { path })
89        }
90        Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
91            let pid = read_holder_pid(&path);
92            // Stale-holder recovery (13-06 dogfood finding): a killed or
93            // crashed holder never runs LockGuard's Drop, and its abandoned
94            // lock wedges every future `advance` for the project — silently,
95            // since advance usually runs from a detached monitor with no
96            // terminal. If the recorded holder is not alive, reclaim the
97            // lock and retry the atomic create once. Best-effort: PID reuse
98            // is theoretically possible but the window is negligible for a
99            // per-project lock.
100            if !pid_is_alive(&pid) {
101                tracing::warn!(
102                    "reclaiming stale devflow lock at {} (holder pid {pid} is not alive)",
103                    path.display()
104                );
105                let _ = fs::remove_file(&path);
106                return match File::create_new(&path) {
107                    Ok(mut f) => {
108                        write!(f, "{}", lock_contents())?;
109                        Ok(LockGuard { path })
110                    }
111                    Err(err) if err.kind() == io::ErrorKind::AlreadyExists => {
112                        let pid = read_holder_pid(&path);
113                        Err(LockError::Contended { pid, path })
114                    }
115                    Err(err) => Err(err.into()),
116                };
117            }
118            Err(LockError::Contended { pid, path })
119        }
120        Err(err) => Err(err.into()),
121    }
122}
123
124/// The lock file's contents: the holder's pid on line 1, and its start time
125/// (`/proc/<pid>/stat` field 22) on line 2 when readable.
126///
127/// Two lines rather than one field, deliberately: every existing reader takes
128/// the first line, so the format stays backward compatible in both
129/// directions — an old binary reads a new lock file's pid correctly, and a
130/// new binary reads an old single-line lock file with the start time simply
131/// absent.
132///
133/// The start time is what makes the record an *identity* rather than a
134/// number. See [`crate::agent::process_start_time`]: a pid alone can be
135/// recycled, and a pid inspected via `/proc` can also be a devflow process's
136/// own child caught mid-`execve` (999.47), so anything that later signals
137/// this holder must match both halves.
138fn lock_contents() -> String {
139    let pid = std::process::id();
140    match crate::agent::process_start_time(pid) {
141        Some(start) => format!("{pid}\n{start}"),
142        // Fail soft on write, fail closed on use: a lock without a start
143        // time still works for mutual exclusion, and readers that need
144        // identity refuse to signal rather than guess.
145        None => format!("{pid}"),
146    }
147}
148
149/// The holder pid recorded in a lock file — the first line only, so a
150/// two-line lock file parses identically to the historical one-line form.
151fn read_holder_pid(path: &Path) -> String {
152    fs::read_to_string(path)
153        .ok()
154        .and_then(|text| text.lines().next().map(|line| line.trim().to_string()))
155        .filter(|pid| !pid.is_empty())
156        .unwrap_or_else(|| "unknown".into())
157}
158
159/// The holder's recorded start time, if the lock file carries one. `None`
160/// for a legacy single-line lock, or when the value is unparseable.
161fn read_holder_start_time(path: &Path) -> Option<u64> {
162    fs::read_to_string(path)
163        .ok()?
164        .lines()
165        .nth(1)?
166        .trim()
167        .parse::<u64>()
168        .ok()
169}
170
171/// The recorded identity of a phase lock's holder: its pid, and its start
172/// time when the lock records one.
173///
174/// Callers that intend to **signal** the holder must require the start time
175/// to be present and to match [`crate::agent::process_start_time`] for that
176/// pid. A `None` start time means the lock predates identity recording and
177/// the holder cannot be confirmed — refuse, do not guess.
178pub fn holder_identity(project_root: &Path, phase: PhaseId) -> Option<(u32, Option<u64>)> {
179    let path = lock_path(project_root, phase);
180    let pid = read_holder_pid(&path).parse::<u32>().ok()?;
181    Some((pid, read_holder_start_time(&path)))
182}
183
184/// Whether the pid recorded in a lock file refers to a live process.
185///
186/// A non-numeric pid (corrupt lock) is treated as dead so the lock can be
187/// reclaimed. Delegates to [`crate::agent::agent_running`] — the crate's one
188/// PID-liveness implementation — which also rejects `0` (a `kill -0 0`
189/// probes the caller's own process group and always succeeds, making a
190/// corrupted lock permanently "held") and values that would wrap negative
191/// through the `pid_t` cast.
192fn pid_is_alive(pid: &str) -> bool {
193    pid.parse::<u32>().is_ok_and(crate::agent::agent_running)
194}
195
196/// Check whether a lock is currently held for this project/phase,
197/// returning the PID of the holder if the file exists.
198pub fn holder(project_root: &Path, phase: PhaseId) -> Option<(String, PathBuf)> {
199    let path = lock_path(project_root, phase);
200    // First line only: lock files now carry the holder's start time on line
201    // 2, and reading the whole file would yield "1234\n5678" as the "pid".
202    fs::read_to_string(&path).ok()?;
203    let pid = read_holder_pid(&path);
204    let pid = if pid == "unknown" { String::new() } else { pid };
205    if pid.is_empty() {
206        // Stale empty lock file — clean it up
207        let _ = fs::remove_file(&path);
208        return None;
209    }
210    Some((pid, path))
211}
212
213/// Release a lock by removing the lock file, ignoring errors
214/// if it's already gone.
215fn release(path: &Path) {
216    let _ = fs::remove_file(path);
217}
218
219/// Guard that releases the lock file on drop.
220#[derive(Debug)]
221pub struct LockGuard {
222    path: PathBuf,
223}
224
225impl Drop for LockGuard {
226    fn drop(&mut self) {
227        release(&self.path);
228    }
229}
230
231/// Filename prefix shared by every per-phase lock file. Owned here so
232/// sweepers (e.g. `recover --clean`) never hardcode the naming scheme.
233/// The project-wide checkout lock (`lock-project`) shares the prefix
234/// deliberately: the same stale-holder sweep covers it.
235const LOCK_FILE_PREFIX: &str = "lock-";
236
237pub(crate) fn lock_path(project_root: &Path, phase: PhaseId) -> PathBuf {
238    project_root.join(".devflow").join(format!(
239        "{LOCK_FILE_PREFIX}{padded}",
240        padded = phase.padded()
241    ))
242}
243
244pub(crate) fn project_lock_path(project_root: &Path) -> PathBuf {
245    project_root
246        .join(".devflow")
247        .join(format!("{LOCK_FILE_PREFIX}project"))
248}
249
250/// Remove this project's per-phase lock files, skipping any whose recorded
251/// holder PID is still alive — deleting a live holder's lock would let a
252/// duplicate `advance` acquire it, after which the original holder's
253/// `LockGuard::Drop` deletes the NEW holder's file.
254///
255/// Returns human-readable warnings for anything skipped or that failed to
256/// delete, so callers surface problems instead of reporting a clean sweep
257/// that left wedging locks behind.
258pub fn remove_stale_locks(project_root: &Path) -> Vec<String> {
259    let mut warnings = Vec::new();
260    let devflow_dir = project_root.join(".devflow");
261    let Ok(entries) = fs::read_dir(&devflow_dir) else {
262        return warnings;
263    };
264    for entry in entries.flatten() {
265        let name = entry.file_name();
266        let Some(name) = name.to_str() else { continue };
267        if !name.starts_with(LOCK_FILE_PREFIX) {
268            continue;
269        }
270        let path = entry.path();
271        // First line only — line 2 is the holder's start time.
272        let holder_pid = read_holder_pid(&path);
273        if pid_is_alive(&holder_pid) {
274            warnings.push(format!(
275                "kept {} — holder pid {holder_pid} is still alive",
276                path.display()
277            ));
278            continue;
279        }
280        if let Err(err) = fs::remove_file(&path) {
281            warnings.push(format!("could not remove {}: {err}", path.display()));
282        }
283    }
284    warnings
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    #[test]
292    fn acquire_creates_lock_and_records_pid() {
293        let dir = tempfile::tempdir().unwrap();
294        let guard = acquire(dir.path(), PhaseId::new(1)).expect("acquire");
295
296        let (pid, path) = holder(dir.path(), PhaseId::new(1)).expect("holder present");
297        assert_eq!(pid, std::process::id().to_string());
298        assert!(path.exists());
299        drop(guard);
300    }
301
302    #[test]
303    fn acquire_creates_devflow_directory_when_absent() {
304        let dir = tempfile::tempdir().unwrap();
305        assert!(!dir.path().join(".devflow").exists());
306        let _guard = acquire(dir.path(), PhaseId::new(1)).expect("acquire");
307        assert!(dir.path().join(".devflow").exists());
308    }
309
310    #[test]
311    fn second_acquire_is_contended() {
312        let dir = tempfile::tempdir().unwrap();
313        let _guard = acquire(dir.path(), PhaseId::new(1)).expect("first acquire");
314
315        match acquire(dir.path(), PhaseId::new(1)) {
316            Err(LockError::Contended { pid, .. }) => {
317                assert_eq!(pid, std::process::id().to_string());
318            }
319            Ok(_) => panic!("second acquire must fail"),
320            Err(other) => panic!("expected Contended, got {other:?}"),
321        }
322    }
323
324    /// CR-03 (13-REVIEW.md): the lock is scoped per-phase, so a different
325    /// phase in the same project must never contend on another phase's lock
326    /// — this is what lets `devflow parallel`'s sibling phases keep making
327    /// progress while one phase blocks on a multi-day gate wait.
328    #[test]
329    fn different_phases_do_not_contend() {
330        let dir = tempfile::tempdir().unwrap();
331        let _guard_a = acquire(dir.path(), PhaseId::new(1)).expect("acquire phase 1");
332        let _guard_b =
333            acquire(dir.path(), PhaseId::new(2)).expect("acquire phase 2 must not contend");
334    }
335
336    #[test]
337    fn dropping_guard_releases_lock() {
338        let dir = tempfile::tempdir().unwrap();
339        {
340            let _guard = acquire(dir.path(), PhaseId::new(1)).expect("acquire");
341            assert!(holder(dir.path(), PhaseId::new(1)).is_some());
342        }
343        // After the guard drops the lock file is gone and re-acquiring works.
344        assert!(holder(dir.path(), PhaseId::new(1)).is_none());
345        let _again = acquire(dir.path(), PhaseId::new(1)).expect("re-acquire after release");
346    }
347
348    #[test]
349    fn holder_is_none_without_lock_file() {
350        let dir = tempfile::tempdir().unwrap();
351        assert!(holder(dir.path(), PhaseId::new(1)).is_none());
352    }
353
354    #[test]
355    fn holder_cleans_up_empty_lock_file() {
356        let dir = tempfile::tempdir().unwrap();
357        let path = lock_path(dir.path(), PhaseId::new(1));
358        fs::create_dir_all(path.parent().unwrap()).unwrap();
359        fs::write(&path, "   \n").unwrap();
360
361        assert!(holder(dir.path(), PhaseId::new(1)).is_none());
362        // Empty/stale lock should be removed so a fresh acquire succeeds.
363        assert!(!path.exists());
364        let _guard = acquire(dir.path(), PhaseId::new(1)).expect("acquire after stale cleanup");
365    }
366
367    /// 13-06 dogfood regression: a killed poller's abandoned lock wedged
368    /// every subsequent `advance` for the project. A lock whose holder pid
369    /// is dead (or non-numeric) must be reclaimed transparently.
370    #[test]
371    fn acquire_reclaims_lock_from_dead_holder() {
372        let dir = tempfile::tempdir().unwrap();
373        let path = lock_path(dir.path(), PhaseId::new(1));
374        fs::create_dir_all(path.parent().unwrap()).unwrap();
375        // Above default kernel pid_max (4194304) — guaranteed not alive.
376        fs::write(&path, "9999999").unwrap();
377
378        let guard = acquire(dir.path(), PhaseId::new(1)).expect("stale lock must be reclaimed");
379        let (pid, _) = holder(dir.path(), PhaseId::new(1)).expect("holder present");
380        assert_eq!(pid, std::process::id().to_string());
381        drop(guard);
382    }
383
384    #[test]
385    fn acquire_reclaims_lock_with_corrupt_pid() {
386        let dir = tempfile::tempdir().unwrap();
387        let path = lock_path(dir.path(), PhaseId::new(1));
388        fs::create_dir_all(path.parent().unwrap()).unwrap();
389        fs::write(&path, "not-a-pid").unwrap();
390
391        acquire(dir.path(), PhaseId::new(1)).expect("corrupt lock must be reclaimed");
392    }
393
394    /// `remove_stale_locks` must sweep dead-holder locks but never a live
395    /// holder's — deleting a held lock lets a duplicate advance acquire it,
396    /// and the original guard's Drop then removes the new holder's file.
397    #[test]
398    fn remove_stale_locks_keeps_live_holder_and_sweeps_dead() {
399        let dir = tempfile::tempdir().unwrap();
400        let live = lock_path(dir.path(), PhaseId::new(1));
401        let dead = lock_path(dir.path(), PhaseId::new(2));
402        fs::create_dir_all(live.parent().unwrap()).unwrap();
403        fs::write(&live, std::process::id().to_string()).unwrap();
404        fs::write(&dead, "9999999").unwrap();
405
406        let warnings = remove_stale_locks(dir.path());
407
408        assert!(live.exists(), "live holder's lock must be kept");
409        assert!(!dead.exists(), "dead holder's lock must be swept");
410        assert_eq!(warnings.len(), 1, "keeping a live lock must be reported");
411        assert!(warnings[0].contains("still alive"));
412    }
413
414    /// Two-level locking (13-DEFERRED-CR-03): the coarse checkout lock is
415    /// independent of every per-phase lock — holding a phase's advance lock
416    /// (potentially for gate-days) must not block another phase's
417    /// seconds-long checkout mutation, and vice versa.
418    #[test]
419    fn project_lock_is_independent_of_phase_locks() {
420        let dir = tempfile::tempdir().unwrap();
421        let _phase = acquire(dir.path(), PhaseId::new(1)).expect("phase lock");
422        let _project = acquire_project(dir.path()).expect("project lock must not contend");
423    }
424
425    #[test]
426    fn project_lock_contends_with_itself() {
427        let dir = tempfile::tempdir().unwrap();
428        let _held = acquire_project(dir.path()).expect("first acquire");
429        assert!(matches!(
430            acquire_project(dir.path()),
431            Err(LockError::Contended { .. })
432        ));
433    }
434
435    /// The blocking variant must wait out a short critical section instead of
436    /// failing fast — that is the whole point of a seconds-scale coarse lock.
437    #[test]
438    fn project_lock_blocking_waits_for_release() {
439        let dir = tempfile::tempdir().unwrap();
440        let held = acquire_project(dir.path()).expect("first acquire");
441        let root = dir.path().to_path_buf();
442
443        std::thread::scope(|scope| {
444            let waiter = scope
445                .spawn(move || acquire_project_blocking(&root, std::time::Duration::from_secs(10)));
446            // Give the waiter time to hit contention at least once, then
447            // release; it must then acquire rather than time out.
448            std::thread::sleep(std::time::Duration::from_millis(300));
449            drop(held);
450            waiter
451                .join()
452                .expect("waiter thread")
453                .expect("blocking acquire must succeed once the holder releases");
454        });
455    }
456
457    #[test]
458    fn project_lock_blocking_times_out_against_live_holder() {
459        let dir = tempfile::tempdir().unwrap();
460        let _held = acquire_project(dir.path()).expect("first acquire");
461        let err = acquire_project_blocking(dir.path(), std::time::Duration::from_millis(300))
462            .expect_err("must time out while the live holder keeps the lock");
463        assert!(matches!(err, LockError::Contended { .. }));
464    }
465
466    /// A lock file containing "0" parses as a valid u32, but `kill -0 0`
467    /// probes the caller's own process group and always succeeds — the old
468    /// subprocess-based check treated it as a live holder forever, wedging
469    /// every future acquire behind a Contended error.
470    #[test]
471    fn acquire_reclaims_lock_with_pid_zero() {
472        let dir = tempfile::tempdir().unwrap();
473        let path = lock_path(dir.path(), PhaseId::new(1));
474        fs::create_dir_all(path.parent().unwrap()).unwrap();
475        fs::write(&path, "0").unwrap();
476
477        acquire(dir.path(), PhaseId::new(1)).expect("pid-0 lock must be reclaimed");
478    }
479}