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/// 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::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 /// Path of the alive-lock file this guard holds.
167 pub fn path(&self) -> &Path {
168 &self.path
169 }
170}
171
172impl Drop for AliveGuard {
173 fn drop(&mut self) {
174 // Drop closes the fd → OS releases the lock. Best-effort unlink.
175 let _ = fs::remove_file(&self.path);
176 }
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 #[test]
184 fn acquire_then_alive() {
185 let tmp = tempfile::tempdir().unwrap();
186 let dir = tmp.path().to_path_buf();
187 let sid = "s1";
188 let _g = acquire(&dir, sid).unwrap();
189 assert!(is_session_alive(&dir, sid));
190 drop(_g);
191 assert!(!is_session_alive(&dir, sid));
192 }
193
194 #[test]
195 fn second_acquire_fails_while_held() {
196 let tmp = tempfile::tempdir().unwrap();
197 let dir = tmp.path().to_path_buf();
198 let sid = "s2";
199 let g = acquire(&dir, sid).unwrap();
200 let second = acquire(&dir, sid);
201 assert!(second.is_err(), "second acquire should fail while held");
202 assert!(is_session_alive(&dir, sid));
203 drop(g);
204 assert!(acquire(&dir, sid).is_ok(), "after drop, acquire succeeds");
205 }
206
207 // ── Phase 4: orphan reap (#8) ──
208
209 /// Helper: backdate a file's mtime by `secs` so it crosses the age gate.
210 fn backdate(path: &std::path::Path, secs: u64) {
211 use std::fs::FileTimes;
212 let then = std::time::SystemTime::now() - std::time::Duration::from_secs(secs);
213 let f = std::fs::File::open(path)
214 .or_else(|_| {
215 std::fs::OpenOptions::new()
216 .read(true)
217 .write(true)
218 .create(true)
219 .truncate(false) // open-or-create without erasing (clippy::suspicious_open_options)
220 .open(path)
221 })
222 .unwrap();
223 f.set_times(FileTimes::new().set_modified(then)).unwrap();
224 }
225
226 #[test]
227 fn reap_idempotent() {
228 let tmp = tempfile::tempdir().unwrap();
229 let dir = tmp.path().to_path_buf();
230 // No `.alive/` at all.
231 assert_eq!(reap_orphans(&dir).unwrap(), 0);
232 fs::create_dir_all(dir.join(".alive")).unwrap();
233 // Empty dir, repeated calls stay at 0.
234 assert_eq!(reap_orphans(&dir).unwrap(), 0);
235 assert_eq!(reap_orphans(&dir).unwrap(), 0);
236 }
237
238 #[test]
239 fn reap_skips_recent_dead_files() {
240 // A dead (unheld) orphan younger than ORPHAN_AGE_SECS must be
241 // preserved — the age gate is the TOCTOU mitigation.
242 let tmp = tempfile::tempdir().unwrap();
243 let dir = tmp.path().to_path_buf();
244 fs::create_dir_all(dir.join(".alive")).unwrap();
245 let recent = dir.join(".alive").join("dead-recent");
246 fs::write(&recent, b"").unwrap();
247 // mtime ~ now.
248 assert_eq!(reap_orphans(&dir).unwrap(), 0);
249 assert!(
250 recent.exists(),
251 "recent dead orphan must be preserved by the age gate"
252 );
253 }
254
255 #[test]
256 fn reap_removes_old_dead_and_keeps_alive() {
257 let tmp = tempfile::tempdir().unwrap();
258 let dir = tmp.path().to_path_buf();
259 // A genuinely live lock — must never be reaped.
260 let _g_live = acquire(&dir, "alive-session").unwrap();
261 // An old dead orphan (no holder, mtime > threshold).
262 fs::create_dir_all(dir.join(".alive")).unwrap();
263 let old = dir.join(".alive").join("dead-old");
264 fs::write(&old, b"").unwrap();
265 backdate(&old, ORPHAN_AGE_SECS + 60);
266
267 let removed = reap_orphans(&dir).unwrap();
268 assert_eq!(removed, 1, "only the old dead orphan should be reaped");
269 assert!(!old.exists(), "old dead orphan must be removed");
270 // Live holder is still alive and its file untouched.
271 assert!(
272 is_session_alive(&dir, "alive-session"),
273 "live lock must survive reap"
274 );
275 }
276}