//! Tracks open TUI sessions in `~/.kigi/active_sessions.json` for crash //! recovery. Clean exit removes the entry; crash leaves it behind. On next //! launch, [`collect_crashed`] finds orphaned entries (dead PIDs). use std::fs::{self, File, OpenOptions}; use std::io; use std::path::Path; use agent_client_protocol as acp; use chrono::{DateTime, Utc}; use fs2::FileExt; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ActiveSession { pub session_id: acp::SessionId, pub pid: u32, pub cwd: String, pub opened_at: DateTime, } const DATA_FILENAME: &str = "active_sessions.json"; const LOCK_FILENAME: &str = "active_sessions.lock"; const TMP_FILENAME: &str = "active_sessions.json.tmp"; // -- Public API (delegates to `_in` variants with default grok home) -------- /// Register a session as active (idempotent by session_id). pub fn register(session: ActiveSession) -> io::Result<()> { register_in(&crate::util::kigi_home::kigi_home(), session) } /// Unregister a session (clean exit). No-op if not found. pub fn unregister(session_id: &acp::SessionId) -> io::Result<()> { unregister_in(&crate::util::kigi_home::kigi_home(), session_id) } /// Non-blocking unregister for signal handlers. Returns `Ok(false)` on /// lock contention; the orphan is cleaned up by `collect_crashed` next launch. pub fn try_unregister(session_id: &acp::SessionId) -> io::Result { try_unregister_in(&crate::util::kigi_home::kigi_home(), session_id) } /// Remove entries with dead PIDs and return them. pub fn collect_crashed() -> io::Result> { collect_crashed_in(&crate::util::kigi_home::kigi_home()) } // -- Injectable-root variants (`_in`) for testing --------------------------- pub fn register_in(root: &Path, session: ActiveSession) -> io::Result<()> { with_locked_state(root, |sessions| { sessions.retain(|s| s.session_id != session.session_id); sessions.push(session); }) } pub fn unregister_in(root: &Path, session_id: &acp::SessionId) -> io::Result<()> { with_locked_state(root, |sessions| { sessions.retain(|s| s.session_id != *session_id); }) } pub fn try_unregister_in(root: &Path, session_id: &acp::SessionId) -> io::Result { try_with_locked_state(root, |sessions| { sessions.retain(|s| s.session_id != *session_id); }) .map(|opt| opt.is_some()) } pub fn collect_crashed_in(root: &Path) -> io::Result> { with_locked_state(root, |sessions| { let (alive, dead): (Vec<_>, Vec<_>) = sessions.drain(..).partition(|s| is_pid_alive(s.pid)); *sessions = alive; dead }) } pub fn list_in(root: &Path) -> io::Result> { let data_path = root.join(DATA_FILENAME); read_data_file(&data_path) } // -- Internal: locked read-modify-write ------------------------------------- fn with_locked_state(root: &Path, mutate: F) -> io::Result where F: FnOnce(&mut Vec) -> R, { let lock_path = root.join(LOCK_FILENAME); let data_path = root.join(DATA_FILENAME); let tmp_path = root.join(TMP_FILENAME); fs::create_dir_all(root)?; let lock_file = open_lock_file(&lock_path)?; lock_file.lock_exclusive()?; let result = locked_mutate(&data_path, &tmp_path, mutate); let _ = lock_file.unlock(); result } /// Non-blocking variant for signal handlers. fn try_with_locked_state(root: &Path, mutate: F) -> io::Result> where F: FnOnce(&mut Vec) -> R, { let lock_path = root.join(LOCK_FILENAME); let data_path = root.join(DATA_FILENAME); let tmp_path = root.join(TMP_FILENAME); fs::create_dir_all(root)?; let lock_file = open_lock_file(&lock_path)?; match lock_file.try_lock_exclusive() { Ok(()) => { let result = locked_mutate(&data_path, &tmp_path, mutate); let _ = lock_file.unlock(); result.map(Some) } Err(e) if e.kind() == io::ErrorKind::WouldBlock => Ok(None), Err(e) => Err(e), } } fn locked_mutate(data_path: &Path, tmp_path: &Path, mutate: F) -> io::Result where F: FnOnce(&mut Vec) -> R, { let mut sessions = read_data_file(data_path)?; let result = mutate(&mut sessions); write_data_file_atomic(tmp_path, data_path, &sessions)?; Ok(result) } fn open_lock_file(path: &Path) -> io::Result { OpenOptions::new() .read(true) .write(true) .create(true) .truncate(false) .open(path) } fn read_data_file(path: &Path) -> io::Result> { match fs::read(path) { Ok(bytes) if bytes.is_empty() => Ok(Vec::new()), Ok(bytes) => match serde_json::from_slice::>(&bytes) { Ok(sessions) => Ok(sessions), Err(e) => { tracing::warn!( path = %path.display(), error = %e, "active_sessions.json is corrupted, starting with empty list" ); Ok(Vec::new()) } }, Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(Vec::new()), Err(e) => Err(e), } } fn write_data_file_atomic( tmp_path: &Path, data_path: &Path, sessions: &[ActiveSession], ) -> io::Result<()> { let json = serde_json::to_string_pretty(sessions) .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?; fs::write(tmp_path, json.as_bytes())?; fs::rename(tmp_path, data_path).inspect_err(|_| { let _ = fs::remove_file(tmp_path); }) } fn is_pid_alive(pid: u32) -> bool { #[cfg(unix)] { let pid_i = match i32::try_from(pid) { Ok(p) if p > 0 => p, _ => return false, }; let ret = unsafe { libc::kill(pid_i as libc::pid_t, 0) }; if ret == 0 { return true; } io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } #[cfg(windows)] { use windows::Win32::Foundation::CloseHandle; use windows::Win32::System::Threading::{OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION}; let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) }; match handle { Ok(h) => { let _ = unsafe { CloseHandle(h) }; true } Err(_) => false, } } #[cfg(not(any(unix, windows)))] { // Conservative: assume alive if we can't check. true } } #[cfg(test)] mod tests { use super::*; use tempfile::TempDir; fn make_session(id: &str, pid: u32) -> ActiveSession { ActiveSession { session_id: acp::SessionId::new(id), pid, cwd: "/tmp/test".into(), opened_at: Utc::now(), } } #[test] fn register_is_idempotent() { let dir = TempDir::new().unwrap(); let s = make_session("s1", std::process::id()); register_in(dir.path(), s.clone()).unwrap(); register_in(dir.path(), s).unwrap(); assert_eq!(list_in(dir.path()).unwrap().len(), 1); } #[test] fn collect_crashed_partitions_by_pid_liveness() { let dir = TempDir::new().unwrap(); register_in(dir.path(), make_session("alive", std::process::id())).unwrap(); register_in(dir.path(), make_session("dead", 2_000_000_000)).unwrap(); let crashed = collect_crashed_in(dir.path()).unwrap(); assert_eq!(crashed.len(), 1); assert_eq!(&*crashed[0].session_id.0, "dead"); assert_eq!(&*list_in(dir.path()).unwrap()[0].session_id.0, "alive"); } #[test] fn concurrent_registers_no_corruption() { let dir = TempDir::new().unwrap(); let path = dir.path().to_path_buf(); std::thread::scope(|s| { for i in 0..10 { let p = path.clone(); s.spawn(move || { register_in(&p, make_session(&format!("s{i}"), std::process::id())).unwrap() }); } }); assert_eq!(list_in(dir.path()).unwrap().len(), 10); } #[test] fn try_unregister_skips_if_locked() { let dir = TempDir::new().unwrap(); let s = make_session("s1", std::process::id()); register_in(dir.path(), s.clone()).unwrap(); let lock_file = open_lock_file(&dir.path().join(LOCK_FILENAME)).unwrap(); lock_file.lock_exclusive().unwrap(); assert!(!try_unregister_in(dir.path(), &s.session_id).unwrap()); lock_file.unlock().unwrap(); assert_eq!(list_in(dir.path()).unwrap().len(), 1); } #[test] fn corrupt_file_recovers() { let dir = TempDir::new().unwrap(); fs::write(dir.path().join(DATA_FILENAME), "garbage{{{").unwrap(); assert!(list_in(dir.path()).unwrap().is_empty()); register_in(dir.path(), make_session("s1", std::process::id())).unwrap(); assert_eq!(list_in(dir.path()).unwrap().len(), 1); } }