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    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            // Stale-holder recovery (13-06 dogfood finding): a killed or
96            // crashed holder never runs LockGuard's Drop, and its abandoned
97            // lock wedges every future `advance` for the project — silently,
98            // since advance usually runs from a detached monitor with no
99            // terminal. If the recorded holder is not alive, reclaim the
100            // lock and retry the atomic create once. Best-effort: PID reuse
101            // is theoretically possible but the window is negligible for a
102            // per-project lock.
103            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
131/// Whether the pid recorded in a lock file refers to a live process.
132///
133/// A non-numeric pid (corrupt lock) is treated as dead so the lock can be
134/// reclaimed. Delegates to [`crate::agent::agent_running`] — the crate's one
135/// PID-liveness implementation — which also rejects `0` (a `kill -0 0`
136/// probes the caller's own process group and always succeeds, making a
137/// corrupted lock permanently "held") and values that would wrap negative
138/// through the `pid_t` cast.
139fn pid_is_alive(pid: &str) -> bool {
140    pid.parse::<u32>().is_ok_and(crate::agent::agent_running)
141}
142
143/// Check whether a lock is currently held for this project/phase,
144/// returning the PID of the holder if the file exists.
145pub 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        // Stale empty lock file — clean it up
151        let _ = fs::remove_file(&path);
152        return None;
153    }
154    Some((pid, path))
155}
156
157/// Release a lock by removing the lock file, ignoring errors
158/// if it's already gone.
159fn release(path: &Path) {
160    let _ = fs::remove_file(path);
161}
162
163/// Guard that releases the lock file on drop.
164#[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
175/// Filename prefix shared by every per-phase lock file. Owned here so
176/// sweepers (e.g. `recover --clean`) never hardcode the naming scheme.
177/// The project-wide checkout lock (`lock-project`) shares the prefix
178/// deliberately: the same stale-holder sweep covers it.
179const 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
193/// Remove this project's per-phase lock files, skipping any whose recorded
194/// holder PID is still alive — deleting a live holder's lock would let a
195/// duplicate `advance` acquire it, after which the original holder's
196/// `LockGuard::Drop` deletes the NEW holder's file.
197///
198/// Returns human-readable warnings for anything skipped or that failed to
199/// delete, so callers surface problems instead of reporting a clean sweep
200/// that left wedging locks behind.
201pub 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    /// CR-03 (13-REVIEW.md): the lock is scoped per-phase, so a different
270    /// phase in the same project must never contend on another phase's lock
271    /// — this is what lets `devflow parallel`'s sibling phases keep making
272    /// progress while one phase blocks on a multi-day gate wait.
273    #[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        // After the guard drops the lock file is gone and re-acquiring works.
288        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        // Empty/stale lock should be removed so a fresh acquire succeeds.
307        assert!(!path.exists());
308        let _guard = acquire(dir.path(), 1).expect("acquire after stale cleanup");
309    }
310
311    /// 13-06 dogfood regression: a killed poller's abandoned lock wedged
312    /// every subsequent `advance` for the project. A lock whose holder pid
313    /// is dead (or non-numeric) must be reclaimed transparently.
314    #[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        // Above default kernel pid_max (4194304) — guaranteed not alive.
320        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    /// `remove_stale_locks` must sweep dead-holder locks but never a live
339    /// holder's — deleting a held lock lets a duplicate advance acquire it,
340    /// and the original guard's Drop then removes the new holder's file.
341    #[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    /// Two-level locking (13-DEFERRED-CR-03): the coarse checkout lock is
359    /// independent of every per-phase lock — holding a phase's advance lock
360    /// (potentially for gate-days) must not block another phase's
361    /// seconds-long checkout mutation, and vice versa.
362    #[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    /// The blocking variant must wait out a short critical section instead of
380    /// failing fast — that is the whole point of a seconds-scale coarse lock.
381    #[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            // Give the waiter time to hit contention at least once, then
391            // release; it must then acquire rather than time out.
392            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    /// A lock file containing "0" parses as a valid u32, but `kill -0 0`
411    /// probes the caller's own process group and always succeeds — the old
412    /// subprocess-based check treated it as a live holder forever, wedging
413    /// every future acquire behind a Contended error.
414    #[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}