Files
1jehuang--jcode/crates/jcode-storage/src/active_pids.rs
T
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:10:34 +08:00

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");
}
}