oxicode_agent/issues/
liveness.rs1use std::fs::{self, OpenOptions};
9use std::io;
10use std::os::unix::io::AsRawFd;
11use std::path::{Path, PathBuf};
12
13pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
15 issues_dir.join(".alive").join(session_id)
16}
17
18pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
25 let dir = issues_dir.join(".alive");
26 fs::create_dir_all(&dir)?;
27 let path = dir.join(session_id);
28 let file = OpenOptions::new()
29 .write(true)
30 .create(true)
31 .truncate(false)
32 .open(&path)?;
33 let fd = file.as_raw_fd();
34 try_flock_exclusive(fd)?;
36 write_owner_info(&file, session_id);
40 Ok(AliveGuard { _file: file, path })
41}
42
43#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct OwnerInfo {
52 pub session: String,
54 pub pid: u32,
56 pub host: String,
58 pub cwd: String,
60 pub started: u64,
62}
63
64pub fn read_owner_info(issues_dir: &Path, session_id: &str) -> Option<OwnerInfo> {
70 let data = fs::read_to_string(alive_path(issues_dir, session_id)).ok()?;
71 serde_json::from_str(&data).ok()
72}
73
74fn write_owner_info(file: &fs::File, session_id: &str) {
79 use std::io::{Seek, SeekFrom, Write};
80 let info = OwnerInfo {
81 session: session_id.to_string(),
82 pid: std::process::id(),
83 host: hostname(),
84 cwd: std::env::current_dir()
85 .map(|p| p.to_string_lossy().into_owned())
86 .unwrap_or_default(),
87 started: std::time::SystemTime::now()
88 .duration_since(std::time::UNIX_EPOCH)
89 .map(|d| d.as_secs())
90 .unwrap_or(0),
91 };
92 let Ok(bytes) = serde_json::to_vec(&info) else {
93 return;
94 };
95 let mut f: &fs::File = file;
96 let _ = f.seek(SeekFrom::Start(0));
97 let _ = file.set_len(0);
98 let _ = f.write_all(&bytes);
99}
100
101fn hostname() -> String {
103 let mut buf = [0u8; 256];
104 let rc = unsafe { libc::gethostname(buf.as_mut_ptr().cast(), buf.len()) };
107 if rc != 0 {
108 return String::new();
109 }
110 let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
111 String::from_utf8_lossy(&buf[..end]).into_owned()
112}
113
114pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
118 let path = alive_path(issues_dir, session_id);
119 if !path.exists() {
120 return false;
121 }
122 let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
125 return false;
126 };
127 let fd = file.as_raw_fd();
128 probe_flock_shared(fd).is_err()
130}
131
132fn try_flock_exclusive(fd: i32) -> io::Result<()> {
146 let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
149 if rc == 0 {
150 Ok(())
151 } else {
152 Err(io::Error::last_os_error())
153 }
154}
155
156fn probe_flock_shared(fd: i32) -> io::Result<()> {
163 let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
165 if rc == 0 {
166 unsafe { libc::flock(fd, libc::LOCK_UN) };
168 Ok(())
169 } else {
170 Err(io::Error::last_os_error())
171 }
172}
173
174pub const ORPHAN_AGE_SECS: u64 = 3600; pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
196 let dir = issues_dir.join(".alive");
197 let rd = match fs::read_dir(&dir) {
198 Ok(rd) => rd,
199 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
200 Err(e) => return Err(e),
201 };
202 let now = std::time::SystemTime::now();
203 let mut removed = 0;
204 for entry in rd.flatten() {
205 let sid = entry.file_name();
206 let sid = sid.to_string_lossy();
207 if is_session_alive(issues_dir, &sid) {
208 continue; }
210 let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
211 let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
212 if age < ORPHAN_AGE_SECS {
213 continue; }
215 if fs::remove_file(entry.path()).is_ok() {
216 removed += 1;
217 }
218 }
219 Ok(removed)
220}
221
222#[derive(Debug)]
224pub struct AliveGuard {
225 _file: fs::File,
226 path: PathBuf,
227}
228
229impl AliveGuard {
230 pub fn path(&self) -> &Path {
232 &self.path
233 }
234}
235
236impl Drop for AliveGuard {
237 fn drop(&mut self) {
238 let _ = fs::remove_file(&self.path);
240 }
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246
247 #[test]
248 fn acquire_then_alive() {
249 let tmp = tempfile::tempdir().unwrap();
250 let dir = tmp.path().to_path_buf();
251 let sid = "s1";
252 let _g = acquire(&dir, sid).unwrap();
253 assert!(is_session_alive(&dir, sid));
254 drop(_g);
255 assert!(!is_session_alive(&dir, sid));
256 }
257
258 #[test]
259 fn second_acquire_fails_while_held() {
260 let tmp = tempfile::tempdir().unwrap();
261 let dir = tmp.path().to_path_buf();
262 let sid = "s2";
263 let g = acquire(&dir, sid).unwrap();
264 let second = acquire(&dir, sid);
265 assert!(second.is_err(), "second acquire should fail while held");
266 assert!(is_session_alive(&dir, sid));
267 drop(g);
268 assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
269 }
270
271 fn backdate(path: &std::path::Path, secs: u64) {
275 use std::fs::FileTimes;
276 let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
277 let f = std::fs::File::open(path)
278 .or_else(|_| {
279 std::fs::OpenOptions::new()
280 .read(true)
281 .write(true)
282 .create(true)
283 .truncate(false) .open(path)
285 })
286 .unwrap();
287 f.set_times(FileTimes::new().set_modified(then)).unwrap();
288 }
289
290 #[test]
291 fn reap_idempotent() {
292 let tmp = tempfile::tempdir().unwrap();
293 let dir = tmp.path().to_path_buf();
294 assert_eq!(reap_orphans(&dir).unwrap(), 0);
296 fs::create_dir_all(dir.join(".alive")).unwrap();
297 assert_eq!(reap_orphans(&dir).unwrap(), 0);
299 assert_eq!(reap_orphans(&dir).unwrap(), 0);
300 }
301
302 #[test]
303 fn reap_skips_recent_dead_files() {
304 let tmp = tempfile::tempdir().unwrap();
307 let dir = tmp.path().to_path_buf();
308 fs::create_dir_all(dir.join(".alive")).unwrap();
309 let recent = dir.join(".alive").join("dead-recent");
310 fs::write(&recent, b"").unwrap();
311 assert_eq!(reap_orphans(&dir).unwrap(), 0);
313 assert!(
314 recent.exists(),
315 "recent dead orphan must be preserved by the age gate"
316 );
317 }
318
319 #[test]
320 fn reap_removes_old_dead_and_keeps_alive() {
321 let tmp = tempfile::tempdir().unwrap();
322 let dir = tmp.path().to_path_buf();
323 let _g_live = acquire(&dir, "alive-session").unwrap();
325 fs::create_dir_all(dir.join(".alive")).unwrap();
327 let old = dir.join(".alive").join("dead-old");
328 fs::write(&old, b"").unwrap();
329 backdate(&old, ORPHAN_AGE_SECS + 60);
330
331 let removed = reap_orphans(&dir).unwrap();
332 assert_eq!(removed, 1, "only the old dead orphan should be reaped");
333 assert!(!old.exists(), "old dead orphan must be removed");
334 assert!(
336 is_session_alive(&dir, "alive-session"),
337 "live lock must survive reap"
338 );
339 }
340
341 #[test]
342 fn acquire_writes_readable_owner_info() {
343 let tmp = tempfile::tempdir().unwrap();
344 let dir = tmp.path().to_path_buf();
345 let g = acquire(&dir, "owner-check").unwrap();
346 let info = read_owner_info(&dir, "owner-check").expect("owner info must be recorded");
347 assert_eq!(info.session, "owner-check");
348 assert_eq!(info.pid, std::process::id());
349 assert!(info.started > 0, "started must be unix seconds");
350 drop(g);
352 assert!(read_owner_info(&dir, "owner-check").is_none());
353 }
354
355 #[test]
356 fn read_owner_info_missing_or_garbage_is_none() {
357 let tmp = tempfile::tempdir().unwrap();
358 let dir = tmp.path().to_path_buf();
359 assert!(read_owner_info(&dir, "nope").is_none());
360 fs::create_dir_all(dir.join(".alive")).unwrap();
361 fs::write(dir.join(".alive").join("junk"), b"not json").unwrap();
362 assert!(read_owner_info(&dir, "junk").is_none());
363 }
364}