a789495a98
FreeBSD Smoke / FreeBSD Smoke (x86_64) (push) Has been cancelled
CI / Quality Guardrails (push) Has been cancelled
CI / Build & Test (macos-latest) (push) Has been cancelled
CI / Build & Test (ubuntu-latest) (push) Has been cancelled
CI / Build & Test (windows-latest) (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / PowerShell Syntax (push) Has been cancelled
CI / Windows Cross-Target Check (Linux) (push) Has been cancelled
301 lines
10 KiB
Rust
301 lines
10 KiB
Rust
//! 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<PathBuf> {
|
|
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<std::path::PathBuf> {
|
|
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<String>) -> 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<String> {
|
|
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::<u32>().ok()? == pid {
|
|
return Some(session_id);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
/// List active session IDs currently tracked in `~/.jcode/active_pids`.
|
|
pub fn active_session_ids() -> Vec<String> {
|
|
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<std::time::SystemTime>,
|
|
}
|
|
|
|
/// 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<SessionPresence> {
|
|
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::<u32>().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::<u32>().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");
|
|
}
|
|
}
|