Skip to main content

agentic_planning/
locking.rs

1//! Concurrent startup locking with stale-lock recovery, and file-level locking
2//! for safe concurrent writes to `.aplan` files.
3//!
4//! Only one instance of a named lock can be held at a time. Locks older than
5//! `STALE_THRESHOLD` whose owning process is no longer alive are automatically
6//! recovered.
7
8use std::fs;
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime};
12
13/// How old a lock file must be before we check if the owning process is alive.
14const STALE_THRESHOLD: Duration = Duration::from_secs(5 * 60);
15
16/// How long to wait between retry attempts when acquiring a file lock.
17const FILE_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(50);
18
19/// Maximum time to wait for a file lock before giving up.
20const FILE_LOCK_TIMEOUT: Duration = Duration::from_secs(10);
21
22/// A PID-based lock file that auto-cleans on drop.
23pub struct StartupLock {
24    path: PathBuf,
25}
26
27impl StartupLock {
28    /// Acquire a named lock. Returns `Err` if another live process holds it.
29    pub fn acquire(name: &str) -> Result<Self, LockError> {
30        let dir = lock_dir();
31        fs::create_dir_all(&dir).map_err(LockError::Io)?;
32
33        let path = dir.join(format!("agentic-planning-{}.lock", name));
34        let my_pid = std::process::id();
35
36        // Check existing lock
37        if path.exists() {
38            let contents = fs::read_to_string(&path).unwrap_or_default();
39            if let Ok(existing_pid) = contents.trim().parse::<u32>() {
40                if existing_pid == my_pid {
41                    // We already hold it — re-acquire
42                    return Ok(Self { path });
43                }
44
45                let metadata = fs::metadata(&path).ok();
46                let age = metadata
47                    .and_then(|m| m.modified().ok())
48                    .and_then(|t| SystemTime::now().duration_since(t).ok());
49
50                let is_stale = age.map(|a| a > STALE_THRESHOLD).unwrap_or(true);
51
52                if is_stale && !is_process_alive(existing_pid) {
53                    // Stale lock from dead process — recover
54                    let _ = fs::remove_file(&path);
55                } else if is_process_alive(existing_pid) {
56                    return Err(LockError::AlreadyHeld {
57                        name: name.to_string(),
58                        pid: existing_pid,
59                    });
60                } else {
61                    // Process is dead but lock is recent — still recover
62                    let _ = fs::remove_file(&path);
63                }
64            } else {
65                // Corrupted lock file — remove
66                let _ = fs::remove_file(&path);
67            }
68        }
69
70        // Write our PID
71        let mut f = fs::File::create(&path).map_err(LockError::Io)?;
72        write!(f, "{}", my_pid).map_err(LockError::Io)?;
73        f.sync_all().map_err(LockError::Io)?;
74
75        // Verify we actually got it (race protection)
76        let verify = fs::read_to_string(&path).unwrap_or_default();
77        if verify.trim() != my_pid.to_string() {
78            return Err(LockError::RaceCondition {
79                name: name.to_string(),
80            });
81        }
82
83        Ok(Self { path })
84    }
85
86    /// Touch the lock file to prevent stale detection.
87    pub fn touch(&self) -> io::Result<()> {
88        let pid = std::process::id();
89        fs::write(&self.path, pid.to_string())
90    }
91
92    /// Path to the lock file.
93    pub fn path(&self) -> &Path {
94        &self.path
95    }
96}
97
98impl Drop for StartupLock {
99    fn drop(&mut self) {
100        let _ = fs::remove_file(&self.path);
101    }
102}
103
104#[derive(Debug)]
105pub enum LockError {
106    AlreadyHeld { name: String, pid: u32 },
107    RaceCondition { name: String },
108    Io(io::Error),
109}
110
111impl std::fmt::Display for LockError {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            LockError::AlreadyHeld { name, pid } => {
115                write!(f, "lock '{}' already held by PID {}", name, pid)
116            }
117            LockError::RaceCondition { name } => {
118                write!(f, "race condition acquiring lock '{}'", name)
119            }
120            LockError::Io(e) => write!(f, "lock I/O error: {}", e),
121        }
122    }
123}
124
125impl std::error::Error for LockError {}
126
127/// File-level lock for `.aplan` files. Creates a `.aplan.lock` sidecar that
128/// prevents concurrent writes to the same planning file.
129///
130/// The lock is held for the duration of the write and released on drop.
131pub struct FileLock {
132    path: PathBuf,
133}
134
135impl FileLock {
136    /// Acquire a file-level lock for the given `.aplan` path.
137    ///
138    /// Creates a `.aplan.lock` sidecar file containing the current PID.
139    /// Retries with backoff up to `FILE_LOCK_TIMEOUT`. Stale locks from
140    /// dead processes are automatically recovered.
141    pub fn acquire(aplan_path: &Path) -> Result<Self, LockError> {
142        let lock_path = lock_path_for(aplan_path);
143
144        if let Some(parent) = lock_path.parent() {
145            fs::create_dir_all(parent).map_err(LockError::Io)?;
146        }
147
148        let my_pid = std::process::id();
149        let start = std::time::Instant::now();
150
151        loop {
152            match Self::try_acquire(&lock_path, my_pid) {
153                Ok(lock) => return Ok(lock),
154                Err(LockError::AlreadyHeld { .. }) if start.elapsed() < FILE_LOCK_TIMEOUT => {
155                    std::thread::sleep(FILE_LOCK_RETRY_INTERVAL);
156                }
157                Err(e) => return Err(e),
158            }
159        }
160    }
161
162    fn try_acquire(lock_path: &Path, my_pid: u32) -> Result<Self, LockError> {
163        if lock_path.exists() {
164            let contents = fs::read_to_string(lock_path).unwrap_or_default();
165            if let Ok(existing_pid) = contents.trim().parse::<u32>() {
166                if existing_pid == my_pid {
167                    // We already hold it
168                    return Ok(Self {
169                        path: lock_path.to_path_buf(),
170                    });
171                }
172
173                if !is_process_alive(existing_pid) {
174                    // Dead process — recover
175                    let _ = fs::remove_file(lock_path);
176                } else {
177                    // Check age — stale locks from hung processes
178                    let age = fs::metadata(lock_path)
179                        .ok()
180                        .and_then(|m| m.modified().ok())
181                        .and_then(|t| SystemTime::now().duration_since(t).ok());
182
183                    let is_stale = age.map(|a| a > STALE_THRESHOLD).unwrap_or(false);
184                    if is_stale {
185                        let _ = fs::remove_file(lock_path);
186                    } else {
187                        return Err(LockError::AlreadyHeld {
188                            name: lock_path.display().to_string(),
189                            pid: existing_pid,
190                        });
191                    }
192                }
193            } else {
194                // Corrupted lock — remove
195                let _ = fs::remove_file(lock_path);
196            }
197        }
198
199        // Write our PID
200        let mut f = fs::File::create(lock_path).map_err(LockError::Io)?;
201        write!(f, "{}", my_pid).map_err(LockError::Io)?;
202        f.sync_all().map_err(LockError::Io)?;
203
204        // Verify we got it
205        let verify = fs::read_to_string(lock_path).unwrap_or_default();
206        if verify.trim() != my_pid.to_string() {
207            return Err(LockError::RaceCondition {
208                name: lock_path.display().to_string(),
209            });
210        }
211
212        Ok(Self {
213            path: lock_path.to_path_buf(),
214        })
215    }
216
217    /// Path to the lock file.
218    pub fn path(&self) -> &Path {
219        &self.path
220    }
221}
222
223impl Drop for FileLock {
224    fn drop(&mut self) {
225        let _ = fs::remove_file(&self.path);
226    }
227}
228
229/// Compute the `.aplan.lock` sidecar path for a given `.aplan` file.
230pub fn lock_path_for(aplan_path: &Path) -> PathBuf {
231    let mut lock = aplan_path.to_path_buf();
232    lock.set_extension("aplan.lock");
233    lock
234}
235
236fn lock_dir() -> PathBuf {
237    std::env::temp_dir().join("agentic-planning-locks")
238}
239
240/// Check if a process with the given PID is alive.
241#[cfg(unix)]
242fn is_process_alive(pid: u32) -> bool {
243    // kill(pid, 0) checks existence without sending a signal
244    unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
245}
246
247#[cfg(not(unix))]
248fn is_process_alive(_pid: u32) -> bool {
249    // Conservative: assume alive on non-Unix platforms
250    true
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn acquire_and_release() {
259        let lock = StartupLock::acquire("test-acquire").unwrap();
260        assert!(lock.path().exists());
261        let path = lock.path().to_path_buf();
262        drop(lock);
263        assert!(!path.exists());
264    }
265
266    #[test]
267    fn same_process_reacquire() {
268        let _lock1 = StartupLock::acquire("test-reacquire").unwrap();
269        // Same process can re-acquire
270        let _lock2 = StartupLock::acquire("test-reacquire").unwrap();
271    }
272
273    #[test]
274    fn touch_refreshes() {
275        let lock = StartupLock::acquire("test-touch").unwrap();
276        assert!(lock.touch().is_ok());
277    }
278
279    #[test]
280    fn file_lock_acquire_and_release() {
281        let dir = tempfile::tempdir().unwrap();
282        let aplan = dir.path().join("test.aplan");
283        std::fs::write(&aplan, "{}").unwrap();
284
285        let lock = FileLock::acquire(&aplan).unwrap();
286        let lock_path = lock.path().to_path_buf();
287        assert!(lock_path.exists());
288        drop(lock);
289        assert!(!lock_path.exists());
290    }
291
292    #[test]
293    fn file_lock_same_process_reacquire() {
294        let dir = tempfile::tempdir().unwrap();
295        let aplan = dir.path().join("reacq.aplan");
296        std::fs::write(&aplan, "{}").unwrap();
297
298        let _lock1 = FileLock::acquire(&aplan).unwrap();
299        // Same process can re-acquire
300        let _lock2 = FileLock::acquire(&aplan).unwrap();
301    }
302
303    #[test]
304    fn file_lock_dead_process_recovery() {
305        let dir = tempfile::tempdir().unwrap();
306        let aplan = dir.path().join("dead.aplan");
307        std::fs::write(&aplan, "{}").unwrap();
308
309        let lock_path = lock_path_for(&aplan);
310        // Write a lock from an unlikely PID
311        std::fs::write(&lock_path, "999999999").unwrap();
312
313        // Should recover since that PID is dead
314        let lock = FileLock::acquire(&aplan).unwrap();
315        assert!(lock.path().exists());
316    }
317
318    #[test]
319    fn lock_path_for_correctness() {
320        let p = std::path::PathBuf::from("/tmp/test.aplan");
321        let lp = lock_path_for(&p);
322        assert_eq!(lp.to_str().unwrap(), "/tmp/test.aplan.lock");
323    }
324
325    #[test]
326    fn stale_lock_recovery() {
327        let dir = lock_dir();
328        let _ = fs::create_dir_all(&dir);
329        let path = dir.join("agentic-planning-test-stale.lock");
330
331        // Write a fake lock from PID 1 (init — always alive on Linux, but
332        // we set the modified time far in the past)
333        fs::write(&path, "999999999").unwrap(); // unlikely PID
334
335        // Should recover
336        let lock = StartupLock::acquire("test-stale").unwrap();
337        assert!(lock.path().exists());
338    }
339}