agentic_planning/
locking.rs1use std::fs;
9use std::io::{self, Write};
10use std::path::{Path, PathBuf};
11use std::time::{Duration, SystemTime};
12
13const STALE_THRESHOLD: Duration = Duration::from_secs(5 * 60);
15
16const FILE_LOCK_RETRY_INTERVAL: Duration = Duration::from_millis(50);
18
19const FILE_LOCK_TIMEOUT: Duration = Duration::from_secs(10);
21
22pub struct StartupLock {
24 path: PathBuf,
25}
26
27impl StartupLock {
28 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 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 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 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 let _ = fs::remove_file(&path);
63 }
64 } else {
65 let _ = fs::remove_file(&path);
67 }
68 }
69
70 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 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 pub fn touch(&self) -> io::Result<()> {
88 let pid = std::process::id();
89 fs::write(&self.path, pid.to_string())
90 }
91
92 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
127pub struct FileLock {
132 path: PathBuf,
133}
134
135impl FileLock {
136 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 return Ok(Self {
169 path: lock_path.to_path_buf(),
170 });
171 }
172
173 if !is_process_alive(existing_pid) {
174 let _ = fs::remove_file(lock_path);
176 } else {
177 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 let _ = fs::remove_file(lock_path);
196 }
197 }
198
199 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 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 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
229pub 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#[cfg(unix)]
242fn is_process_alive(pid: u32) -> bool {
243 unsafe { libc::kill(pid as libc::pid_t, 0) == 0 }
245}
246
247#[cfg(not(unix))]
248fn is_process_alive(_pid: u32) -> bool {
249 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 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 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 std::fs::write(&lock_path, "999999999").unwrap();
312
313 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 fs::write(&path, "999999999").unwrap(); let lock = StartupLock::acquire("test-stale").unwrap();
337 assert!(lock.path().exists());
338 }
339}