Skip to main content

oxicode_agent/issues/
liveness.rs

1//! Process-liveness tracking via OS advisory locks.
2//!
3//! Each session holds an exclusive `flock` on `.oxicode/issues/.alive/<session_id>`.
4//! The lock is released by the OS when the process exits (including crashes
5//! and `kill -9`). This lets us answer "is session X still alive?" without
6//! any wall-clock timeout, PID-recycling heuristics, or heartbeats.
7
8use std::fs::{self, OpenOptions};
9use std::io;
10use std::os::unix::io::AsRawFd;
11use std::path::{Path, PathBuf};
12
13/// Path of the alive-lock file for `session_id` under `issues_dir`.
14pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
15    issues_dir.join(".alive").join(session_id)
16}
17
18/// Try to acquire (and hold) an exclusive advisory lock for `session_id`.
19///
20/// The returned [`AliveGuard`] releases the lock when dropped — so callers
21/// must keep it alive for the whole session. Opening with write+create and
22/// calling `flock(LOCK_EX | LOCK_NB)` is atomic enough for our purposes:
23/// failure to acquire means another live process holds it.
24pub 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    // Failure (EWOULDBLOCK/EAGAIN) means another live process holds it.
35    try_flock_exclusive(fd)?;
36    // Record *who* holds the lock, in the lock file itself. Best-effort:
37    // the flock is the source of truth for liveness; this payload only adds
38    // human-readable provenance (pid/host/cwd/started) for display surfaces.
39    write_owner_info(&file, session_id);
40    Ok(AliveGuard { _file: file, path })
41}
42
43/// Provenance of a live alive-lock holder, persisted inside the lock file.
44///
45/// Written by [`acquire`] right after the exclusive flock is taken; read
46/// back with [`read_owner_info`]. The lock file doubles as the storage so
47/// there is exactly one artifact per session — the existing orphan reaper
48/// ([`reap_orphans`]) and the RAII unlink in [`AliveGuard::drop`] clean it
49/// up with no extra bookkeeping.
50#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
51pub struct OwnerInfo {
52    /// Owning session id (same as the lock-file name).
53    pub session: String,
54    /// OS pid of the holder.
55    pub pid: u32,
56    /// Hostname of the holder (empty when gethostname fails).
57    pub host: String,
58    /// Working directory at acquisition time.
59    pub cwd: String,
60    /// Unix seconds at acquisition time.
61    pub started: u64,
62}
63
64/// Read back the [`OwnerInfo`] recorded by [`acquire`] for `session_id`.
65///
66/// Returns `None` when the lock file is missing (dead session) or holds no
67/// parseable payload (written by an older oxicode, before this metadata
68/// existed) — callers must treat `None` as "unknown holder", not "dead".
69pub 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
74/// Overwrite the lock file's payload with the [`OwnerInfo`] JSON.
75///
76/// Best-effort — any I/O error is swallowed because liveness comes from the
77/// flock, never from the payload.
78fn 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
101/// Local hostname, or `""` when gethostname fails/truncates.
102fn hostname() -> String {
103    let mut buf = [0u8; 256];
104    // SAFETY: `buf` is a valid 256-byte array; gethostname writes at most
105    // `buf.len()` bytes and NUL-terminates (truncating the name if needed).
106    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
114/// Returns `true` iff a live process currently holds the alive-lock for
115/// `session_id`. Used to decide whether an [`crate::issues::Assignment`]
116/// is still valid.
117pub 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    // Try to acquire a *shared* lock non-blockingly. If we can't, someone
123    // holds an exclusive lock → alive. If we can, no one holds it → dead.
124    let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
125        return false;
126    };
127    let fd = file.as_raw_fd();
128    // Ok = nobody holds exclusive (dead); Err = held by a live process (alive).
129    probe_flock_shared(fd).is_err()
130}
131
132// ── flock helpers (#11: centralize the two unsafe call sites) ────────
133//
134// Both take a raw fd that the caller obtained from a live `File` via
135// `as_raw_fd()`, so fd validity is guaranteed by construction. Naming
136// them (with SAFETY docs) keeps the `unsafe` surface to these two spots
137// instead of being scattered through the liveness logic.
138
139/// Try a non-blocking exclusive flock on `fd`.
140///
141/// `Ok` on success; `Err` on contention (`EWOULDBLOCK`/`EAGAIN` — another
142/// live process holds it) or any other OS error.
143///
144/// `fd` must be a valid open file descriptor.
145fn try_flock_exclusive(fd: i32) -> io::Result<()> {
146    // SAFETY: `fd` is a valid, owned descriptor (caller passes
147    // `File::as_raw_fd()` from a live `File`). `LOCK_NB` never blocks.
148    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
156/// Probe liveness by attempting a non-blocking shared flock.
157///
158/// `Ok` if no one holds an exclusive lock (we acquired and released a
159/// shared one); `Err` if someone holds exclusive (a live process).
160///
161/// `fd` must be a valid open file descriptor.
162fn probe_flock_shared(fd: i32) -> io::Result<()> {
163    // SAFETY: `fd` is a valid, owned descriptor (see `try_flock_exclusive`).
164    let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
165    if rc == 0 {
166        // SAFETY: releasing the shared lock we just acquired on a valid fd.
167        unsafe { libc::flock(fd, libc::LOCK_UN) };
168        Ok(())
169    } else {
170        Err(io::Error::last_os_error())
171    }
172}
173
174// ── Orphan reaping (#8) ─────────────────────────────────────────────
175
176/// Minimum age (seconds) a dead alive-lock file must reach before reaping.
177///
178/// The age gate is the TOCTOU mitigation: a reaper checks `is_session_alive`,
179/// and a process could acquire the lock in the gap before `remove_file`.
180/// Only reaping files older than this threshold leaves a wide margin for
181/// any session that is actively starting up, while still clearing the
182/// steady-state accumulation of zombies from crashed/killed processes.
183pub const ORPHAN_AGE_SECS: u64 = 3600; // 1 hour
184
185/// Best-effort, idempotent cleanup of dead alive-lock files under
186/// `<issues_dir>/.alive/`.
187///
188/// Two guards keep it safe:
189/// 1. **Holder check** — files whose session still holds an exclusive flock
190///    ([`is_session_alive`] → `true`) are never touched.
191/// 2. **Age gate** — even dead files younger than [`ORPHAN_AGE_SECS`] are
192///    skipped, so a process racing to acquire can't lose its lock file.
193///
194/// Returns the number of files removed. Missing `.alive/` is `Ok(0)`.
195pub 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; // (1) someone holds it — never reap
209        }
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; // (2) too young — TOCTOU margin
214        }
215        if fs::remove_file(entry.path()).is_ok() {
216            removed += 1;
217        }
218    }
219    Ok(removed)
220}
221
222/// RAII guard for an acquired alive-lock.
223#[derive(Debug)]
224pub struct AliveGuard {
225    _file: fs::File,
226    path: PathBuf,
227}
228
229impl AliveGuard {
230    /// Path of the alive-lock file this guard holds.
231    pub fn path(&self) -> &Path {
232        &self.path
233    }
234}
235
236impl Drop for AliveGuard {
237    fn drop(&mut self) {
238        // Drop closes the fd → OS releases the lock. Best-effort unlink.
239        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    // ── Phase 4: orphan reap (#8) ──
272
273    /// Helper: backdate a file's mtime by `secs` so it crosses the age gate.
274    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-or-create without erasing (clippy::suspicious_open_options)
284                    .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        // No `.alive/` at all.
295        assert_eq!(reap_orphans(&dir).unwrap(), 0);
296        fs::create_dir_all(dir.join(".alive")).unwrap();
297        // Empty dir, repeated calls stay at 0.
298        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        // A dead (unheld) orphan younger than ORPHAN_AGE_SECS must be
305        // preserved — the age gate is the TOCTOU mitigation.
306        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        // mtime ~ now.
312        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        // A genuinely live lock — must never be reaped.
324        let _g_live = acquire(&dir, "alive-session").unwrap();
325        // An old dead orphan (no holder, mtime > threshold).
326        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        // Live holder is still alive and its file untouched.
335        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        // Dropping the guard unlinks the lock file — owner info goes with it.
351        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}