//! Tracking of active session process IDs under `~/.jcode/active_pids`. //! //! This is pure filesystem state keyed by session ID, used to discover which //! sessions are currently running (and to map a PID back to its session). It //! lives in the storage crate because it only needs [`jcode_dir`] and is a //! low-level concern shared by session management, dictation, and crash //! recovery, none of which should pull the full `session` module into scope. use crate::jcode_dir; use std::path::PathBuf; /// Directory holding one file per active session ID (`~/.jcode/active_pids`). pub fn active_pids_dir() -> Option { jcode_dir().ok().map(|d| d.join("active_pids")) } /// Directory holding per-session "currently streaming" markers. A marker file /// exists only while a session is actively generating a model response. The /// file content is the owning process PID so stale markers (from crashed /// processes) can be detected and ignored. pub fn streaming_pids_dir() -> Option { jcode_dir().ok().map(|d| d.join("streaming_pids")) } /// Record that `session_id` is owned by process `pid`. pub fn register_active_pid(session_id: &str, pid: u32) { if let Some(dir) = active_pids_dir() { let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(session_id), pid.to_string()); } } /// Remove the active-PID record for `session_id`, if present. pub fn unregister_active_pid(session_id: &str) { if let Some(dir) = active_pids_dir() { let _ = std::fs::remove_file(dir.join(session_id)); } // A closed session is never streaming. unmark_streaming(session_id); } /// Mark a session as actively streaming a model response. pub fn mark_streaming(session_id: &str) { if let Some(dir) = streaming_pids_dir() { let _ = std::fs::create_dir_all(&dir); let _ = std::fs::write(dir.join(session_id), std::process::id().to_string()); } } /// Clear the streaming marker for a session (turn finished or interrupted). pub fn unmark_streaming(session_id: &str) { if let Some(dir) = streaming_pids_dir() { let _ = std::fs::remove_file(dir.join(session_id)); } } /// RAII guard that marks a session as streaming for its lifetime and clears the /// marker on drop. This guarantees the marker is cleared on every exit path /// (normal return, `?` propagation, interrupt, or panic) so the menu bar count /// never gets stuck showing a phantom streaming session. pub struct StreamingGuard { session_id: String, } impl StreamingGuard { pub fn new(session_id: impl Into) -> Self { let session_id = session_id.into(); mark_streaming(&session_id); Self { session_id } } } impl Drop for StreamingGuard { fn drop(&mut self) { unmark_streaming(&self.session_id); } } /// Find the active session ID currently owned by the given process ID. pub fn find_active_session_id_by_pid(pid: u32) -> Option { let dir = active_pids_dir()?; for entry in std::fs::read_dir(dir).ok()? { let entry = entry.ok()?; let session_id = entry.file_name().to_string_lossy().to_string(); let stored = std::fs::read_to_string(entry.path()).ok()?; if stored.trim().parse::().ok()? == pid { return Some(session_id); } } None } /// List active session IDs currently tracked in `~/.jcode/active_pids`. pub fn active_session_ids() -> Vec { let Some(dir) = active_pids_dir() else { return Vec::new(); }; let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new(); }; entries .filter_map(|entry| entry.ok()) .map(|entry| entry.file_name().to_string_lossy().to_string()) .collect() } #[cfg(unix)] fn process_is_running(pid: u32) -> bool { if pid == 0 { return false; } let result = unsafe { libc::kill(pid as libc::pid_t, 0) }; result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM) } #[cfg(not(unix))] fn process_is_running(pid: u32) -> bool { // Best-effort fallback for platforms where this low-level storage crate does // not have a process API. The active PID file is still useful, and stale // entries are cleaned up by higher-level session lifecycle code. pid != 0 } /// Live snapshot of how many jcode sessions are running, and how many of those /// are actively streaming a model response right now. Used by the menu bar /// indicator (`jcode menubar`) and any other presence UI. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] pub struct SessionCounts { /// Number of live sessions (registered PID is still running). pub total: usize, /// Number of live sessions currently streaming a model response. pub streaming: usize, } /// Live presence info for one running session, derived from the active-pid /// registry and the streaming markers. #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionPresence { /// Session ID, e.g. `session_fox_1234567890_deadbeef`. pub session_id: String, /// PID of the process that owns the session. pub pid: u32, /// Whether the session is actively streaming a model response right now. pub streaming: bool, /// When the current streaming turn started (streaming marker mtime), if /// the session is streaming. Lets presence UIs show "working for 2m". pub streaming_since: Option, } /// Snapshot per-session presence by scanning the active-pid registry and /// streaming markers, skipping any entries whose owning process is no longer /// alive. This is a cheap O(n) scan over a handful of tiny files; used by the /// menu bar indicator and other presence UI. pub fn session_presence() -> Vec { let Some(active_dir) = active_pids_dir() else { return Vec::new(); }; let Ok(entries) = std::fs::read_dir(&active_dir) else { return Vec::new(); }; let streaming_dir = streaming_pids_dir(); let mut sessions = Vec::new(); for entry in entries.filter_map(|entry| entry.ok()) { let path = entry.path(); let session_id = entry.file_name().to_string_lossy().to_string(); let Some(pid) = std::fs::read_to_string(&path) .ok() .and_then(|raw| raw.trim().parse::().ok()) else { continue; }; if !process_is_running(pid) { continue; } let marker_path = streaming_dir.as_ref().map(|dir| dir.join(&session_id)); let streaming = marker_path.as_ref().is_some_and(|marker| { std::fs::read_to_string(marker) .ok() .and_then(|raw| raw.trim().parse::().ok()) .is_some_and(process_is_running) }); let streaming_since = if streaming { marker_path .as_ref() .and_then(|marker| std::fs::metadata(marker).ok()) .and_then(|meta| meta.modified().ok()) } else { None }; sessions.push(SessionPresence { session_id, pid, streaming, streaming_since, }); } sessions } /// Compute the current session counts from [`session_presence`]. pub fn session_counts() -> SessionCounts { let sessions = session_presence(); SessionCounts { total: sessions.len(), streaming: sessions.iter().filter(|s| s.streaming).count(), } } #[cfg(test)] mod tests { use super::*; /// Serialize tests that mutate `JCODE_HOME`. fn lock_env() -> std::sync::MutexGuard<'static, ()> { static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); LOCK.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) } #[test] fn session_counts_counts_live_and_streaming_only() { let _guard = lock_env(); let temp = tempfile::tempdir().expect("tempdir"); jcode_core::env::set_var("JCODE_HOME", temp.path()); let live = std::process::id(); // Pick a PID that is almost certainly dead. let dead = 999_999u32; // live + streaming register_active_pid("session_alpha", live); mark_streaming("session_alpha"); // live + not streaming register_active_pid("session_beta", live); // dead session (should be ignored entirely) register_active_pid("session_gamma", dead); // live session whose streaming marker points at a dead pid (ignored for streaming) register_active_pid("session_delta", live); if let Some(dir) = streaming_pids_dir() { let _ = std::fs::write(dir.join("session_delta"), dead.to_string()); } let counts = session_counts(); assert_eq!(counts.total, 3, "three live sessions expected"); assert_eq!( counts.streaming, 1, "only one live streaming session expected" ); // Per-session presence reports the same view, keyed by session. let sessions = session_presence(); assert_eq!(sessions.len(), 3); let by_id = |id: &str| { sessions .iter() .find(|s| s.session_id == id) .unwrap_or_else(|| panic!("{id} should be present")) }; assert!(by_id("session_alpha").streaming); assert!(!by_id("session_beta").streaming); assert!(!by_id("session_delta").streaming); assert_eq!(by_id("session_alpha").pid, live); assert!(!sessions.iter().any(|s| s.session_id == "session_gamma")); // Clearing the streaming marker drops the streaming count. unmark_streaming("session_alpha"); assert_eq!(session_counts().streaming, 0); // Unregistering also clears any leftover streaming marker. register_active_pid("session_epsilon", live); mark_streaming("session_epsilon"); assert_eq!(session_counts().streaming, 1); unregister_active_pid("session_epsilon"); assert_eq!(session_counts().streaming, 0); jcode_core::env::remove_var("JCODE_HOME"); } #[test] fn streaming_guard_marks_and_clears_on_drop() { let _guard = lock_env(); let temp = tempfile::tempdir().expect("tempdir"); jcode_core::env::set_var("JCODE_HOME", temp.path()); register_active_pid("session_guard", std::process::id()); assert_eq!(session_counts().streaming, 0); { let _streaming = StreamingGuard::new("session_guard"); assert_eq!(session_counts().streaming, 1); } assert_eq!(session_counts().streaming, 0); jcode_core::env::remove_var("JCODE_HOME"); } }