Skip to main content

oxicode/store/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/// Single source of truth for the liveness identity used by the TUI
14/// (and any in-TUI operations: agent tool, `/issue` slash command, panel).
15///
16/// Invariant: in TUI mode, [`crate::App::ownership_session_id`] MUST equal
17/// this constant. The TUI panel's
18/// `crate::tui::overlay::IssuesPanelOverlay::session_id()` references it,
19/// and the agent's `ToolContext.session_id` is set from it, so the flock
20/// acquired by `App` is the same one the panel and agent use to check
21/// `is_session_alive`. Keep the two in sync.
22pub const TUI_OWNERSHIP_ID: &str = "tui";
23
24/// Path of the alive-lock file for `session_id` under `issues_dir`.
25pub fn alive_path(issues_dir: &Path, session_id: &str) -> PathBuf {
26    issues_dir.join(".alive").join(session_id)
27}
28
29/// Try to acquire (and hold) an exclusive advisory lock for `session_id`.
30///
31/// The returned [`AliveGuard`] releases the lock when dropped — so callers
32/// must keep it alive for the whole session. Opening with write+create and
33/// calling `flock(LOCK_EX | LOCK_NB)` is atomic enough for our purposes:
34/// failure to acquire means another live process holds it.
35pub fn acquire(issues_dir: &Path, session_id: &str) -> io::Result<AliveGuard> {
36    let dir = issues_dir.join(".alive");
37    fs::create_dir_all(&dir)?;
38    let path = dir.join(session_id);
39    let file = OpenOptions::new()
40        .write(true)
41        .create(true)
42        .truncate(false)
43        .open(&path)?;
44    let fd = file.as_raw_fd();
45    // Failure (EWOULDBLOCK/EAGAIN) means another live process holds it.
46    try_flock_exclusive(fd)?;
47    Ok(AliveGuard { _file: file, path })
48}
49
50/// Returns `true` iff a live process currently holds the alive-lock for
51/// `session_id`. Used to decide whether an [`crate::store::issues::Assignment`]
52/// is still valid.
53pub fn is_session_alive(issues_dir: &Path, session_id: &str) -> bool {
54    let path = alive_path(issues_dir, session_id);
55    if !path.exists() {
56        return false;
57    }
58    // Try to acquire a *shared* lock non-blockingly. If we can't, someone
59    // holds an exclusive lock → alive. If we can, no one holds it → dead.
60    let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) else {
61        return false;
62    };
63    let fd = file.as_raw_fd();
64    // Ok = nobody holds exclusive (dead); Err = held by a live process (alive).
65    probe_flock_shared(fd).is_err()
66}
67
68// ── flock helpers (#11: centralize the two unsafe call sites) ────────
69//
70// Both take a raw fd that the caller obtained from a live `File` via
71// `as_raw_fd()`, so fd validity is guaranteed by construction. Naming
72// them (with SAFETY docs) keeps the `unsafe` surface to these two spots
73// instead of being scattered through the liveness logic.
74
75/// Try a non-blocking exclusive flock on `fd`.
76///
77/// `Ok` on success; `Err` on contention (`EWOULDBLOCK`/`EAGAIN` — another
78/// live process holds it) or any other OS error.
79///
80/// `fd` must be a valid open file descriptor.
81fn try_flock_exclusive(fd: i32) -> io::Result<()> {
82    // SAFETY: `fd` is a valid, owned descriptor (caller passes
83    // `File::as_raw_fd()` from a live `File`). `LOCK_NB` never blocks.
84    let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
85    if rc == 0 {
86        Ok(())
87    } else {
88        Err(io::Error::last_os_error())
89    }
90}
91
92/// Probe liveness by attempting a non-blocking shared flock.
93///
94/// `Ok` if no one holds an exclusive lock (we acquired and released a
95/// shared one); `Err` if someone holds exclusive (a live process).
96///
97/// `fd` must be a valid open file descriptor.
98fn probe_flock_shared(fd: i32) -> io::Result<()> {
99    // SAFETY: `fd` is a valid, owned descriptor (see `try_flock_exclusive`).
100    let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
101    if rc == 0 {
102        // SAFETY: releasing the shared lock we just acquired on a valid fd.
103        unsafe { libc::flock(fd, libc::LOCK_UN) };
104        Ok(())
105    } else {
106        Err(io::Error::last_os_error())
107    }
108}
109
110// ── Orphan reaping (#8) ─────────────────────────────────────────────
111
112/// Minimum age (seconds) a dead alive-lock file must reach before reaping.
113///
114/// The age gate is the TOCTOU mitigation: a reaper checks `is_session_alive`,
115/// and a process could acquire the lock in the gap before `remove_file`.
116/// Only reaping files older than this threshold leaves a wide margin for
117/// any session that is actively starting up, while still clearing the
118/// steady-state accumulation of zombies from crashed/killed processes.
119pub const ORPHAN_AGE_SECS: u64 = 3600; // 1 hour
120
121/// Best-effort, idempotent cleanup of dead alive-lock files under
122/// `<issues_dir>/.alive/`.
123///
124/// Two guards keep it safe:
125/// 1. **Holder check** — files whose session still holds an exclusive flock
126///    ([`is_session_alive`] → `true`) are never touched.
127/// 2. **Age gate** — even dead files younger than [`ORPHAN_AGE_SECS`] are
128///    skipped, so a process racing to acquire can't lose its lock file.
129///
130/// Returns the number of files removed. Missing `.alive/` is `Ok(0)`.
131pub fn reap_orphans(issues_dir: &Path) -> io::Result<usize> {
132    let dir = issues_dir.join(".alive");
133    let rd = match fs::read_dir(&dir) {
134        Ok(rd) => rd,
135        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
136        Err(e) => return Err(e),
137    };
138    let now = std::time::SystemTime::now();
139    let mut removed = 0;
140    for entry in rd.flatten() {
141        let sid = entry.file_name();
142        let sid = sid.to_string_lossy();
143        if is_session_alive(issues_dir, &sid) {
144            continue; // (1) someone holds it — never reap
145        }
146        let mtime = entry.metadata().and_then(|m| m.modified()).unwrap_or(now);
147        let age = now.duration_since(mtime).map(|d| d.as_secs()).unwrap_or(0);
148        if age < ORPHAN_AGE_SECS {
149            continue; // (2) too young — TOCTOU margin
150        }
151        if fs::remove_file(entry.path()).is_ok() {
152            removed += 1;
153        }
154    }
155    Ok(removed)
156}
157
158/// RAII guard for an acquired alive-lock.
159#[derive(Debug)]
160pub struct AliveGuard {
161    _file: fs::File,
162    path: PathBuf,
163}
164
165impl AliveGuard {
166    pub fn path(&self) -> &Path {
167        &self.path
168    }
169}
170
171impl Drop for AliveGuard {
172    fn drop(&mut self) {
173        // Drop closes the fd → OS releases the lock. Best-effort unlink.
174        let _ = fs::remove_file(&self.path);
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    #[test]
183    fn acquire_then_alive() {
184        let tmp = tempfile::tempdir().unwrap();
185        let dir = tmp.path().to_path_buf();
186        let sid = "s1";
187        let _g = acquire(&dir, sid).unwrap();
188        assert!(is_session_alive(&dir, sid));
189        drop(_g);
190        assert!(!is_session_alive(&dir, sid));
191    }
192
193    #[test]
194    fn second_acquire_fails_while_held() {
195        let tmp = tempfile::tempdir().unwrap();
196        let dir = tmp.path().to_path_buf();
197        let sid = "s2";
198        let g = acquire(&dir, sid).unwrap();
199        let second = acquire(&dir, sid);
200        assert!(second.is_err(), "second acquire should fail while held");
201        assert!(is_session_alive(&dir, sid));
202        drop(g);
203        assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
204    }
205
206    // ── Phase 4: orphan reap (#8) ──
207
208    /// Helper: backdate a file's mtime by `secs` so it crosses the age gate.
209    fn backdate(path: &std::path::Path, secs: u64) {
210        use std::fs::FileTimes;
211        let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
212        let f = std::fs::File::open(path)
213            .or_else(|_| {
214                std::fs::OpenOptions::new()
215                    .read(true)
216                    .write(true)
217                    .create(true)
218                    .truncate(false) // open-or-create without erasing (clippy::suspicious_open_options)
219                    .open(path)
220            })
221            .unwrap();
222        f.set_times(FileTimes::new().set_modified(then)).unwrap();
223    }
224
225    #[test]
226    fn reap_idempotent() {
227        let tmp = tempfile::tempdir().unwrap();
228        let dir = tmp.path().to_path_buf();
229        // No `.alive/` at all.
230        assert_eq!(reap_orphans(&dir).unwrap(), 0);
231        fs::create_dir_all(dir.join(".alive")).unwrap();
232        // Empty dir, repeated calls stay at 0.
233        assert_eq!(reap_orphans(&dir).unwrap(), 0);
234        assert_eq!(reap_orphans(&dir).unwrap(), 0);
235    }
236
237    #[test]
238    fn reap_skips_recent_dead_files() {
239        // A dead (unheld) orphan younger than ORPHAN_AGE_SECS must be
240        // preserved — the age gate is the TOCTOU mitigation.
241        let tmp = tempfile::tempdir().unwrap();
242        let dir = tmp.path().to_path_buf();
243        fs::create_dir_all(dir.join(".alive")).unwrap();
244        let recent = dir.join(".alive").join("dead-recent");
245        fs::write(&recent, b"").unwrap();
246        // mtime ~ now.
247        assert_eq!(reap_orphans(&dir).unwrap(), 0);
248        assert!(
249            recent.exists(),
250            "recent dead orphan must be preserved by the age gate"
251        );
252    }
253
254    #[test]
255    fn reap_removes_old_dead_and_keeps_alive() {
256        let tmp = tempfile::tempdir().unwrap();
257        let dir = tmp.path().to_path_buf();
258        // A genuinely live lock — must never be reaped.
259        let _g_live = acquire(&dir, "alive-session").unwrap();
260        // An old dead orphan (no holder, mtime > threshold).
261        fs::create_dir_all(dir.join(".alive")).unwrap();
262        let old = dir.join(".alive").join("dead-old");
263        fs::write(&old, b"").unwrap();
264        backdate(&old, ORPHAN_AGE_SECS + 60);
265
266        let removed = reap_orphans(&dir).unwrap();
267        assert_eq!(removed, 1, "only the old dead orphan should be reaped");
268        assert!(!old.exists(), "old dead orphan must be removed");
269        // Live holder is still alive and its file untouched.
270        assert!(
271            is_session_alive(&dir, "alive-session"),
272            "live lock must survive reap"
273        );
274    }
275}