diff --git a/AGENTS.md b/AGENTS.md index 60e90ebb2..8158b3a18 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,7 +234,7 @@ live in [docs/development/gcloud-robot.md](docs/development/gcloud-robot.md). **Configuration Persistence:** User config stored at `~/.freshell/config.json`. Atomic writes with temp file + rename. Settings changes POST to `/api/settings` and broadcast via WebSocket. -**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status) and `browser` (with URL, devtools state). +**Pane System:** Tabs contain pane layouts (tree structure of splits). Each pane owns its terminal lifecycle via `createRequestId` and `terminalId`. When splitting panes, each new pane gets its own `createRequestId`, ensuring independent backend terminals. Pane content types: `terminal` (with mode, shell, status), `browser` (with URL, devtools state), `editor` (file path), and `host-stats` (host pressure dashboard; no per-pane payload). **Agent Status Indicators:** Blue/busy status is derived from provider activity slices through `resolvePaneActivity`; green/needs-attention and the idle sound flow through `recordTurnComplete` and `useTurnCompletionNotifications`. Turn-complete (green/sound) is server-authoritative everywhere: terminal CLIs via `terminal.turn.complete`, and fresh-agent panes (freshclaude/kilroy/freshcodex/freshopencode) via a discrete `freshAgent.turn.complete` edge emitted only on a positive completion — freshclaude/kilroy on the SDK `result` with `subtype === 'success'`, freshopencode on the success-only `emitStatus(idle)` path, and freshcodex on `turn/completed` only when `params.turn.status === 'completed'` (the notification also fires on interrupt). The client folds it in via `applyFreshAgentCompletion` using the `at`-monotonic dedupe regime (wall-clock `at`, no per-session counter, so a resumed durable session can't swallow completions across a server restart). The waiting-for-approval edge is ALSO server-authoritative: the Claude/kilroy `SdkBridge` emits a discrete `freshAgent.turn.waiting` edge on the 0→≥1 pending permission/question transition (only Claude/kilroy raise approvals/questions), and the client folds it in via `applyFreshAgentWaiting` under a distinct `${provider}:${sessionId}#waiting` dedupe namespace so it can never poison (or be poisoned by) the turn-complete bucket. The fragile client-side busy→idle derivation AND the client-side waiting-edge hook (`useAgentSessionTurnCompletion`) were both removed — all green/sound edges are now server-emitted. freshcodex additionally self-heals a crashed/disconnected codex sidecar by consuming the runtime `onExit` hook in `subscribe()`, emitting `sdk.status:'exited'` to clear BLUE (no chime — a crash is not a positive completion). `freshopencode` still runs on a shared long-lived `opencode serve` sidecar and uses server-pushed `session.idle`/`session.status` events to drive busy. Gemini and Kimi terminal modes are status-in... [truncated] Separately, the sidebar shows cross-device remote status rings around a session row's icon: a green ring means the session is open on another device, a blue ring means it is busy on another device (blue wins over green), and rings are suppressed entirely when the session is open on this device (derived from `tabs.sync` registry snapshots — producing clients stamp pane payloads with `sessionKeys`/`busySessionKeys`, consumers re-query remote snapshots on a 30s interval, and the server partitions same-device records into `sameDeviceOpen`, which never produces rings). diff --git a/README.md b/README.md index 6b62da4d6..3a19ed128 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ - **Extension system** — Add new pane types, CLI integrations, and server-side services via manifest-based extensions. Enable and disable from the Extensions management page. - **Self-configuring workspace** — Just ask Claude or Codex to open a browser in a pane, or create a tab with four subagents. Built-in tmux-like API and skill makes it simple. - **Live pane headers** — See your active directory, git branch, and context usage in every pane title bar, updating live as you work. Fresh-agent panes carry their context meter in their status strip instead of the header. +- **Host pressure dashboard pane** — CPU, memory, pressure, and I/O at a glance with near-zero overhead (metrics stream only while you're watching). Linux, WSL, and macOS only — not shown on Windows. - **Activity notifications** — Configurable attention indicators (highlight, pulse, darken) on tabs and pane headers when a coding CLI finishes its turn, with click or type dismiss modes - **AI-powered session titles** — Right-click any session and generate a Gemini-powered title based on conversation content - **Progressive sidebar search** — Two-phase search with instant local results followed by deep server-side content search diff --git a/crates/freshell-codex/src/sidecar_store_tests.rs b/crates/freshell-codex/src/sidecar_store_tests.rs index ed6fe7ac2..0ee60f0bc 100644 --- a/crates/freshell-codex/src/sidecar_store_tests.rs +++ b/crates/freshell-codex/src/sidecar_store_tests.rs @@ -195,12 +195,34 @@ fn spawn_own_sleep_child() -> ChildGuard { } /// A record carrying the spawned child's REAL `/proc` evidence. +/// +/// Race note: between fork() and exec(), `/proc//cmdline` transiently +/// holds the PARENT's argv (possibly a truncated prefix) — reading in that +/// window captures wrong bytes and the verify re-read a millisecond later +/// diverges (observed as a load-only `Mismatch` flake in `cargo test +/// --workspace`). Poll until cmdline demonstrably reflects the exec'ed child. #[cfg(target_os = "linux")] fn record_for_child(pid: u32) -> CodexSidecarRecord { + let cmdline = { + let mut attempts = 0; + loop { + if let Some(args) = proc_cmdline(pid as i32) { + if args == ["sleep", "300"] { + break args; + } + } + attempts += 1; + assert!( + attempts <= 1000, + "child cmdline never reflected exec within 1000ms" + ); + std::thread::sleep(std::time::Duration::from_millis(1)); + } + }; CodexSidecarRecord { pid, starttime: proc_starttime(pid as i32).expect("live child has a starttime"), - cmdline: proc_cmdline(pid as i32).expect("live child has a cmdline"), + cmdline, ..sample_record("codex-sidecar-88888888-8888-4888-8888-888888888888") } } diff --git a/crates/freshell-freshagent/src/layout_store_content.rs b/crates/freshell-freshagent/src/layout_store_content.rs index 63c8c3c35..6293ba52f 100644 --- a/crates/freshell-freshagent/src/layout_store_content.rs +++ b/crates/freshell-freshagent/src/layout_store_content.rs @@ -58,6 +58,7 @@ pub fn derive_pane_title(content: &Value) -> String { .filter(|name| !name.is_empty()) .unwrap_or("Extension") .to_string(), + "host-stats" => "Host Stats".to_string(), "terminal" => match obj.get("mode").and_then(Value::as_str) { Some("claude") => "Claude CLI".to_string(), Some("codex") => "Codex CLI".to_string(), diff --git a/crates/freshell-freshagent/src/layout_store_tests.rs b/crates/freshell-freshagent/src/layout_store_tests.rs index 14716acdd..439edd7a7 100644 --- a/crates/freshell-freshagent/src/layout_store_tests.rs +++ b/crates/freshell-freshagent/src/layout_store_tests.rs @@ -482,6 +482,12 @@ fn derive_pane_title_full_matrix() { ); assert_eq!(derive_pane_title(&json!({ "kind": "terminal" })), "Shell"); + // host-stats -> fixed title (stateless pane; plan Task 8 arm) + assert_eq!( + derive_pane_title(&json!({ "kind": "host-stats" })), + "Host Stats" + ); + // non-terminal unknown kinds and non-objects -> no title (Node: undefined) assert_eq!(derive_pane_title(&json!({ "kind": "picker" })), ""); assert_eq!(derive_pane_title(&json!(null)), ""); diff --git a/crates/freshell-freshagent/src/pane_ops.rs b/crates/freshell-freshagent/src/pane_ops.rs index c0e879a19..d9ce12274 100644 --- a/crates/freshell-freshagent/src/pane_ops.rs +++ b/crates/freshell-freshagent/src/pane_ops.rs @@ -188,7 +188,20 @@ pub(crate) async fn split_pane( Err(_) => return approx_json(Value::Null, "pane split requested; not applied"), }; - let new_content = if let Some(url) = body.get("browser").and_then(Value::as_str) { + let new_content = if body + .get("hostStats") + .and_then(Value::as_bool) + .unwrap_or(false) + { + // Stateless cheap content kind (router.ts `wantsHostStats` split branch). + let content = json!({ "kind": "host-stats" }); + state + .content_panes + .lock() + .expect("content_panes mutex") + .insert(new_pane_id.clone(), content.clone()); + content + } else if let Some(url) = body.get("browser").and_then(Value::as_str) { let content = json!({ "kind": "browser", "url": url, diff --git a/crates/freshell-freshagent/src/pane_ops_tests.rs b/crates/freshell-freshagent/src/pane_ops_tests.rs index f61881f8c..ce8eef4dd 100644 --- a/crates/freshell-freshagent/src/pane_ops_tests.rs +++ b/crates/freshell-freshagent/src/pane_ops_tests.rs @@ -245,6 +245,24 @@ async fn split_browser_pane_registers_cheap_content_no_terminal() { assert_eq!(body["message"], json!("pane split (non-terminal)")); } +#[tokio::test] +async fn split_host_stats_pane_registers_cheap_content_no_terminal() { + let state = state_with_registry(); + let router = app(state.clone()); + let (_tab_id, pane_id, _terminal_id) = create_shell_tab(router.clone()).await; + + let (status, body) = post( + router, + &format!("/api/panes/{pane_id}/split"), + json!({ "hostStats": true }), + true, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["data"]["terminalId"].is_null()); + assert_eq!(body["message"], json!("pane split (non-terminal)")); +} + /// kata ejh6: `POST /api/panes/:id/split` REFUSES a body carrying the legacy /// `resumeSessionId` field at the door-top — 400 with the frozen text, /// presence-based for EVERY JSON value type, and (finding 3) the layout must diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index 9a94bbe20..9fdd5f34e 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -200,6 +200,21 @@ async fn create_terminal_or_content_tab_with_delivery( .and_then(Value::as_str) .map(str::to_string); + // `hostStats: true` -> stateless host-stats pane (router.ts `wantsHostStats` + // branch before browser): no process, no terminal admission. + if body + .get("hostStats") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return create_content_tab( + &state, + name, + json!({ "kind": "host-stats" }), + restore_key.as_deref(), + broadcast, + ); + } if let Some(url) = body.get("browser").and_then(Value::as_str) { // `devToolsOpen` flows into the frozen client verbatim via // `paneContent` (ui-commands.ts `tab.create` -> initLayout), so a @@ -3179,6 +3194,23 @@ mod tests { assert!(body["data"]["tabId"].as_str().is_some()); } + #[tokio::test] + async fn create_host_stats_tab_attaches_host_stats_pane_content_and_no_terminal() { + let state = state_with_registry(); + let mut rx = state.broadcast_tx.subscribe(); + let (status, body) = + post(app(state), "/api/tabs", json!({ "hostStats": true }), true).await; + assert_eq!(status, StatusCode::OK); + assert!(body["data"]["tabId"].as_str().is_some()); + assert!(body["data"]["paneId"].as_str().is_some()); + assert!(body["data"].get("terminalId").is_none()); + + let frame = rx.recv().await.expect("ui.command frame broadcast"); + let msg: Value = serde_json::from_str(&frame).unwrap(); + assert_eq!(msg["command"], json!("tab.create")); + assert_eq!(msg["payload"]["paneContent"]["kind"], json!("host-stats")); + } + // ── GET /api/tabs ──────────────────────────────────────────────────────── #[tokio::test] diff --git a/crates/freshell-platform/src/host_stats_readers.rs b/crates/freshell-platform/src/host_stats_readers.rs new file mode 100644 index 000000000..37a42d60a --- /dev/null +++ b/crates/freshell-platform/src/host_stats_readers.rs @@ -0,0 +1,1140 @@ +//! Host-stats `/proc` + `/sys` reader layer — the Rust port of +//! `server/host-stats/readers.ts` (plan: `docs/plans/2026-08-25-host-pressure-pane.md`, +//! Task 9 contract lines 874–933; reader semantics frozen by Task 2). +//! +//! Pure, path-injected, synchronous readers mirroring the Node layer one for +//! one: every reader NEVER panics on a read/parse failure — it returns `None` +//! instead. The ONLY async piece of the Node layer (`scanProcessTable`'s +//! two-sample dwell) is not here: this crate is deliberately tokio-free (see +//! `lib.rs`), so the dwell + deadline loop lives in `freshell-server`'s +//! concrete collector (`host_stats.rs`); the pure pieces it needs +//! ([`parse_proc_pid_stat`], [`parse_status_vm_rss_kb`], [`list_numeric_pids`], +//! [`read_pid_file_bounded`], [`compute_cpu_pct`]) are exported here. +//! +//! Platform notes: `/proc` readers are Linux-only — on darwin/Windows the +//! files do not exist and the readers return `None`; the caller (the +//! collector) then degrades the section to its zero-shape +//! (`available: false`). Unlike Node there is NO darwin `ps` subprocess path +//! (frozen Task 9 note: the Rust collector on darwin reports +//! `cpu.available:false`; `/proc`-dependent sections are zero-shaped). +//! +//! Known intentional divergence from `readers.ts`: Node's +//! `readNumberFile`/`parseCgroupLimit` lean on `Number('') === 0`, so an +//! EMPTY limit file reads as 0 there; here an unparsable payload is `None` +//! (degraded). Kernel `/proc`+`/sys` files are never empty when present, so +//! the divergence is unreachable on a real host. + +use std::collections::BTreeMap; +use std::path::Path; + +/// USER_HZ=100 is the documented ABI exposure of `/proc//stat` tick +/// fields on every Linux architecture this project targets, so ticks -> +/// seconds is a plain /100 (Task 2 documented assumption; computed cpuPct is +/// also clamped defensively). +pub const USER_HZ: u64 = 100; + +/// Cap on numeric `/proc` entries enumerated by [`list_numeric_pids`] +/// (mirrors Node's `PROC_SCAN_CAP`). +pub const PROC_SCAN_CAP: usize = 100_000; +/// Cap on [`read_self_fd_count`] (mirrors Node's `FD_COUNT_CAP`). +pub const FD_COUNT_CAP: u64 = 1_048_576; +/// Cap on [`read_pid_count`] (mirrors Node's `PID_COUNT_CAP`). +pub const PID_COUNT_CAP: u64 = 10_000_000; +/// Bounded scan cap for the inotify fd sweep (mirrors Node's +/// `INOTIFY_FD_SCAN_CAP`). +pub const INOTIFY_FD_SCAN_CAP: usize = 4096; +/// cgroup v1 reports "unlimited" as a huge sentinel (varies by kernel); +/// >= 2^60 is garbage. +pub const CGROUP_V1_GARBAGE_LIMIT: u64 = 1 << 60; +/// Bounded `/proc/` file read (mirrors Node's +/// `PROC_STAT_READ_MAX_BYTES`). +pub const PROC_PID_FILE_MAX_BYTES: usize = 4096; + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Read a whole file as utf8 (lossy); `None` on any failure. +fn safe_read(file_path: &Path) -> Option { + std::fs::read_to_string(file_path).ok() +} + +/// Non-empty, right-trimmed lines of a text file's contents. +fn non_empty_lines(text: &str) -> impl Iterator { + text.lines().filter(|line| !line.trim().is_empty()) +} + +/// Parse a file whose entire payload is a single number (e.g. threads-max). +fn read_number_file(file_path: &Path) -> Option { + safe_read(file_path)?.trim().parse::().ok() +} + +/// List a directory's entry NAMES; `None` instead of throwing. +fn safe_read_dir(dir_path: &Path) -> Option> { + let rd = std::fs::read_dir(dir_path).ok()?; + Some( + rd.filter_map(|entry| entry.ok()) + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect(), + ) +} + +/// Resolve THIS process's cgroup leaf from `/self/cgroup`. The +/// cgroup fs root has NO limit files by design, so callers must always +/// resolve the leaf and never read the fs root. +enum CgroupLeaf { + V1(String), + V2(String), +} + +fn resolve_cgroup_leaf(proc_root: &Path, v1_controller: &str) -> Option { + let text = safe_read(&proc_root.join("self").join("cgroup"))?; + let lines: Vec<&str> = non_empty_lines(&text).collect(); + // v2 unified hierarchy: a single "0::/path" line. + for line in &lines { + if let Some(rest) = line.strip_prefix("0::") { + let leaf = rest.trim_start_matches('/'); + if leaf.is_empty() { + // process sits at the cgroup2 root: no limit files there + return None; + } + return Some(CgroupLeaf::V2(leaf.to_string())); + } + } + // v1: "::/path" + for line in lines { + let parts: Vec<&str> = line.split(':').collect(); + if parts.len() != 3 { + continue; + } + if !parts[1].split(',').any(|c| c == v1_controller) { + continue; + } + let leaf = parts[2].trim_start_matches('/'); + if leaf.is_empty() { + return None; + } + return Some(CgroupLeaf::V1(leaf.to_string())); + } + None +} + +/// 'max' / unreadable / non-finite cgroup limit -> `None` (unlimited). +fn parse_cgroup_limit(text: &str) -> Option { + let trimmed = text.trim(); + if trimmed == "max" || trimmed.is_empty() { + return None; + } + trimmed.parse::().ok() +} + +// --------------------------------------------------------------------------- +// CPU / load / memory +// --------------------------------------------------------------------------- + +/// One `cpuN` line's cumulative counters (jiffies). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CpuCoreTimes { + pub total: f64, + pub busy: f64, +} + +/// `/proc/stat` aggregated + per-core totals; steal jiffies (Node +/// `readCpuTimes`). +#[derive(Debug, Clone, PartialEq)] +pub struct CpuTimes { + pub total: f64, + pub busy: f64, + pub steal: f64, + pub per_core: Vec, +} + +fn parse_proc_stat_cpu_fields(fields: &[f64]) -> Option<(f64, f64, f64)> { + // user nice system idle iowait irq softirq steal [guest guest_nice] + if fields.len() < 8 || fields.iter().any(|f| !f.is_finite()) { + return None; + } + let total: f64 = fields.iter().sum(); + let busy = total - fields[3] - fields[4]; // idle + iowait + Some((total, busy, fields[7])) +} + +/// `/proc/stat` aggregated + per-core totals; steal jiffies. +pub fn read_cpu_times(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("stat"))?; + let mut aggregate: Option<(f64, f64, f64)> = None; + let mut per_core: Vec = Vec::new(); + for line in non_empty_lines(&text) { + // /^cpu(\d*)\s+(.*)$/ + let Some(after_cpu) = line.strip_prefix("cpu") else { + continue; + }; + let Some(idx_end) = after_cpu.find(char::is_whitespace) else { + continue; + }; + let idx_str = &after_cpu[..idx_end]; + if !idx_str.is_empty() && !idx_str.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + let fields: Vec = after_cpu[idx_end..] + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(f64::NAN)) + .collect(); + let Some((total, busy, steal)) = parse_proc_stat_cpu_fields(&fields) else { + continue; + }; + if idx_str.is_empty() { + aggregate = Some((total, busy, steal)); + } else { + let idx: usize = idx_str.parse().ok()?; + if per_core.len() <= idx { + per_core.resize( + idx + 1, + CpuCoreTimes { + total: 0.0, + busy: 0.0, + }, + ); + } + per_core[idx] = CpuCoreTimes { total, busy }; + } + } + let (total, busy, steal) = aggregate?; + Some(CpuTimes { + total, + busy, + steal, + per_core, + }) +} + +/// `/proc/loadavg` (Node `readLoadavg`). +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct LoadAvg { + pub load1: f64, + pub load5: f64, + pub load15: f64, +} + +/// `/proc/loadavg`. On darwin the file does not exist -> `None`. +pub fn read_loadavg(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("loadavg"))?; + let fields: Vec = text + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(f64::NAN)) + .collect(); + if fields.len() < 3 || fields[..3].iter().any(|f| !f.is_finite()) { + return None; + } + Some(LoadAvg { + load1: fields[0], + load5: fields[1], + load15: fields[2], + }) +} + +/// `/proc/meminfo` kB values (Node `readMeminfo`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MeminfoKb { + pub total_kb: u64, + pub avail_kb: u64, + pub swap_total_kb: u64, + pub swap_free_kb: u64, +} + +/// `/proc/meminfo`. Returns `None` when the file is absent or the two +/// mandatory keys (`MemTotal`/`MemAvailable`) are missing. +pub fn read_meminfo(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("meminfo"))?; + let mut values: BTreeMap = BTreeMap::new(); + for line in non_empty_lines(&text) { + // /^([^:]+):\s+(\d+)/ + let Some((key, rest)) = line.split_once(':') else { + continue; + }; + let Some(value_tok) = rest.split_whitespace().next() else { + continue; + }; + if let Ok(value) = value_tok.parse::() { + values.insert(key.to_string(), value); + } + } + Some(MeminfoKb { + total_kb: *values.get("MemTotal")?, + avail_kb: *values.get("MemAvailable")?, + swap_total_kb: values.get("SwapTotal").copied().unwrap_or(0), + swap_free_kb: values.get("SwapFree").copied().unwrap_or(0), + }) +} + +/// This process's cgroup memory view (Node `readCgroupMemory`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CgroupMemory { + /// `None` = unlimited ('max' / v1 garbage sentinel / unreadable). + pub limit_bytes: Option, + pub current_bytes: u64, +} + +/// Resolves THIS process's cgroup leaf from `/self/cgroup` and +/// reads its memory files. v2: `0::/path` -> `/path/ +/// memory.current` + `memory.max` ('max' -> `None` limit). v1: `memory` +/// controller line -> `/memory/path/usage_in_bytes` + +/// `limit_in_bytes` (garbage limit >= 2^60 -> `None`). The cgroup fs root has +/// NO limit files by design, so the leaf is always resolved; the fs root is +/// never read. +/// +/// NOTE (frozen contract): parameter order here is (cgroup_root, proc_root) +/// — the opposite of [`read_pids_limit`]. Callers: read the signatures, do +/// not assume. +pub fn read_cgroup_memory(cgroup_root: &Path, proc_root: &Path) -> Option { + let leaf = resolve_cgroup_leaf(proc_root, "memory")?; + match leaf { + CgroupLeaf::V2(leaf) => { + let dir = cgroup_root.join(leaf); + let current_bytes = read_number_file(&dir.join("memory.current"))?; + let limit_bytes = safe_read(&dir.join("memory.max")) + .as_deref() + .and_then(parse_cgroup_limit); + Some(CgroupMemory { + limit_bytes, + current_bytes, + }) + } + CgroupLeaf::V1(leaf) => { + let dir = cgroup_root.join("memory").join(leaf); + let current_bytes = read_number_file(&dir.join("memory.usage_in_bytes"))?; + let raw = read_number_file(&dir.join("memory.limit_in_bytes")); + // v1 "unlimited" is a huge sentinel value (>= 2^60 depending on + // kernel) -> None + let limit_bytes = raw.filter(|v| *v < CGROUP_V1_GARBAGE_LIMIT); + Some(CgroupMemory { + limit_bytes, + current_bytes, + }) + } + } +} + +// --------------------------------------------------------------------------- +// Paging / PSI +// --------------------------------------------------------------------------- + +/// `/proc/vmstat` paging counters (Node `readVmstat`). `oom_kill` is `None` +/// when the kernel omits the `oom_kill` line. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Vmstat { + pub pswpin: u64, + pub pswpout: u64, + pub pgmajfault: u64, + pub oom_kill: Option, +} + +/// `/proc/vmstat` paging counters. +pub fn read_vmstat(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("vmstat"))?; + let mut values: BTreeMap = BTreeMap::new(); + for line in non_empty_lines(&text) { + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() == 2 { + if let Ok(value) = parts[1].parse::() { + values.insert(parts[0].to_string(), value); + } + } + } + Some(Vmstat { + pswpin: *values.get("pswpin")?, + pswpout: *values.get("pswpout")?, + pgmajfault: *values.get("pgmajfault")?, + oom_kill: values.get("oom_kill").copied(), + }) +} + +/// `/proc/pressure/{cpu,memory,io}` avg10 values (Node `readPsi`); `None` +/// per-file when unreadable, `None` overall when the PSI directory is missing +/// entirely. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PsiSnapshot { + pub cpu_some10: Option, + pub mem_some10: Option, + pub mem_full10: Option, + pub io_some10: Option, + pub io_full10: Option, +} + +/// `/proc/pressure/{cpu,memory,io}` avg10 values. +pub fn read_psi(proc_root: &Path) -> Option { + let pressure_dir = proc_root.join("pressure"); + let cpu = safe_read(&pressure_dir.join("cpu")); + let memory = safe_read(&pressure_dir.join("memory")); + let io = safe_read(&pressure_dir.join("io")); + if cpu.is_none() && memory.is_none() && io.is_none() { + return None; + } + Some(PsiSnapshot { + cpu_some10: cpu.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + mem_some10: memory.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + mem_full10: memory.as_deref().and_then(|t| parse_psi_avg10(t, "full")), + io_some10: io.as_deref().and_then(|t| parse_psi_avg10(t, "some")), + io_full10: io.as_deref().and_then(|t| parse_psi_avg10(t, "full")), + }) +} + +fn parse_psi_avg10(text: &str, line_kind: &str) -> Option { + // /^(some|full)\s+.*?\bavg10=([\d.]+)/ + for line in non_empty_lines(text) { + let mut tokens = line.split_whitespace(); + if tokens.next() != Some(line_kind) { + continue; + } + for token in tokens { + if let Some(rest) = token.strip_prefix("avg10=") { + if let Ok(value) = rest.parse::() { + return value.is_finite().then_some(value); + } + // Malformed avg10 on the matching line: Node's regex simply + // fails this line and the search continues. + break; + } + } + } + None +} + +// --------------------------------------------------------------------------- +// Disk / network +// --------------------------------------------------------------------------- + +/// One whole-device row of `/proc/diskstats` (Node `DiskCounters`). Field +/// mapping per the kernel iostats doc (1-indexed after the device name). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DiskCounters { + pub reads_completed: u64, + pub read_ms: u64, + pub writes_completed: u64, + pub write_ms: u64, + pub read_sectors: u64, + pub written_sectors: u64, + pub time_doing_ios_ms: u64, +} + +/// Whole-device name filter for `/proc/diskstats`: partitions (`sda1`, +/// `nvme0n1p1`, `mmcblk0p1`), loop and ram devices are excluded; everything +/// else (whole disks, `dm-*`, `drbd`, ...) is kept — fail-open so an +/// unrecognized whole device is still shown. +pub fn is_whole_device(name: &str) -> bool { + // /^(?:loop|ram)\d+/ (prefix match) + for prefix in ["loop", "ram"] { + if let Some(rest) = name.strip_prefix(prefix) { + if rest.chars().next().is_some_and(|c| c.is_ascii_digit()) { + return false; + } + } + } + // /^nvme\d+n\d+p\d+$/ + if let Some(rest) = name.strip_prefix("nvme") { + if let Some((bus, tail)) = rest.split_once('n') { + if let Some((inst, part)) = tail.split_once('p') { + if !bus.is_empty() + && bus.bytes().all(|b| b.is_ascii_digit()) + && !inst.is_empty() + && inst.bytes().all(|b| b.is_ascii_digit()) + && !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + } + // /^mmcblk\d+p\d+$/ + if let Some(rest) = name.strip_prefix("mmcblk") { + if let Some((idx, part)) = rest.split_once('p') { + if !idx.is_empty() + && idx.bytes().all(|b| b.is_ascii_digit()) + && !part.is_empty() + && part.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + // /^(?:sd|vd|xvd|hd)[a-z]+\d+$/ + for prefix in ["sd", "vd", "xvd", "hd"] { + if let Some(rest) = name.strip_prefix(prefix) { + // Longest-first prefix order matters (vd before d-shadowing); + // "xvd" must be tried before "vd" would also match after 'x' is + // consumed — strip_prefix is anchored, so only exact prefixes fire. + let letters: usize = rest.chars().take_while(|c| c.is_ascii_lowercase()).count(); + if letters == 0 { + continue; + } + let (alpha, digits) = rest.split_at(letters); + if !alpha.is_empty() + && alpha.bytes().all(|b| b.is_ascii_lowercase()) + && !digits.is_empty() + && digits.bytes().all(|b| b.is_ascii_digit()) + { + return false; + } + } + } + true +} + +/// `/proc/diskstats` keyed by whole-device name. +pub fn read_disk_stats(proc_root: &Path) -> Option> { + let text = safe_read(&proc_root.join("diskstats"))?; + let mut devices = BTreeMap::new(); + for line in non_empty_lines(&text) { + let cols: Vec<&str> = line.split_whitespace().collect(); + if cols.len() < 14 { + continue; + } + let name = cols[2]; + if !is_whole_device(name) { + continue; + } + // Node: `numbers.some((n) => !Number.isFinite(n))` skips the LINE, + // never the file. + let mut numbers: Vec = Vec::with_capacity(cols.len() - 3); + let mut unparsable = false; + for tok in &cols[3..] { + match tok.parse::() { + Ok(v) => numbers.push(v), + Err(_) => { + unparsable = true; + break; + } + } + } + if unparsable { + continue; + } + // doc field 1 = readsCompleted, 3 = readSectors, 4 = readMs, + // 5 = writesCompleted, 7 = writtenSectors, 8 = writeMs, + // 10 = timeDoingIosMs. + devices.insert( + name.to_string(), + DiskCounters { + reads_completed: numbers[0], + read_ms: numbers[3], + writes_completed: numbers[4], + write_ms: numbers[7], + read_sectors: numbers[2], + written_sectors: numbers[6], + time_doing_ios_ms: numbers[9], + }, + ); + } + Some(devices) +} + +/// `/proc/net/dev` summed across interfaces, EXCLUDING loopback (`lo`) +/// (Node `readNetDev`; virtual interfaces are kept). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct NetDevTotals { + pub rx_bytes: u64, + pub tx_bytes: u64, + pub rx_err: u64, + pub tx_err: u64, + pub rx_drop: u64, + pub tx_drop: u64, +} + +/// `/proc/net/dev` interface totals. +pub fn read_net_dev(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("net").join("dev"))?; + let mut totals = NetDevTotals { + rx_bytes: 0, + tx_bytes: 0, + rx_err: 0, + tx_err: 0, + rx_drop: 0, + tx_drop: 0, + }; + for line in non_empty_lines(&text) { + let Some(colon) = line.find(':') else { + continue; + }; + let name = line[..colon].trim(); + if name == "lo" { + continue; + } + let numbers: Vec = line[colon + 1..] + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(u64::MAX)) + .collect(); + if numbers.len() < 16 || numbers.contains(&u64::MAX) { + continue; + } + totals.rx_bytes += numbers[0]; + totals.rx_err += numbers[2]; + totals.rx_drop += numbers[3]; + totals.tx_bytes += numbers[8]; + totals.tx_err += numbers[10]; + totals.tx_drop += numbers[11]; + } + Some(totals) +} + +/// TIME_WAIT (state `06`) count across `tcp` + `tcp6` (Node +/// `readTcpStateCounts`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TcpStateCounts { + pub time_wait: u64, +} + +/// TIME_WAIT connection count across `/proc/net/tcp` + `/proc/net/tcp6`. +pub fn read_tcp_state_counts(proc_root: &Path) -> Option { + let tcp = safe_read(&proc_root.join("net").join("tcp")); + let tcp6 = safe_read(&proc_root.join("net").join("tcp6")); + if tcp.is_none() && tcp6.is_none() { + return None; + } + let mut time_wait = 0u64; + for text in [tcp, tcp6].into_iter().flatten() { + for line in non_empty_lines(&text) { + let tokens: Vec<&str> = line.split_whitespace().collect(); + if tokens.len() < 4 { + continue; + } + // /^\d+:$/ + let Some(sl) = tokens[0].strip_suffix(':') else { + continue; + }; + if sl.is_empty() || !sl.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if tokens[3] == "06" { + time_wait += 1; + } + } + } + Some(TcpStateCounts { time_wait }) +} + +/// `/proc/sys/net/ipv4/ip_local_port_range` (Node `readEphemeralPortRange`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct PortRange { + pub start: u64, + pub end: u64, +} + +/// `/proc/sys/net/ipv4/ip_local_port_range`. +pub fn read_ephemeral_port_range(proc_root: &Path) -> Option { + let text = safe_read( + &proc_root + .join("sys") + .join("net") + .join("ipv4") + .join("ip_local_port_range"), + )?; + let fields: Vec = text + .split_whitespace() + .map(|tok| tok.parse::().unwrap_or(u64::MAX)) + .collect(); + if fields.len() < 2 || fields[..2].contains(&u64::MAX) { + return None; + } + Some(PortRange { + start: fields[0], + end: fields[1], + }) +} + +// --------------------------------------------------------------------------- +// Limits +// --------------------------------------------------------------------------- + +/// Count of entries in `/self/fd`, capped at [`FD_COUNT_CAP`] +/// (Node `readSelfFdCount`). +pub fn read_self_fd_count(proc_root: &Path) -> Option { + let entries = safe_read_dir(&proc_root.join("self").join("fd"))?; + Some((entries.len() as u64).min(FD_COUNT_CAP)) +} + +/// Count of numeric `` entries (processes), capped at +/// [`PID_COUNT_CAP`] (Node `readPidCount`). +pub fn read_pid_count(proc_root: &Path) -> Option { + let entries = safe_read_dir(proc_root)?; + let mut count = 0u64; + for entry in entries { + if !entry.is_empty() && entry.bytes().all(|b| b.is_ascii_digit()) { + count += 1; + } + } + Some(count.min(PID_COUNT_CAP)) +} + +/// The BINDING process cap: cgroup v2 leaf `pids.max` ('max' -> unlimited -> +/// fall back), else cgroup v1 `pids.max`, else +/// `/proc/sys/kernel/threads-max`. `/proc/sys/kernel/pid_max` is a PID-number +/// wrap boundary, NOT a creatable-process cap, and is deliberately never used +/// (validated R3M2). +/// +/// NOTE (frozen contract): parameter order here is (proc_root, cgroup_root) +/// — the opposite of [`read_cgroup_memory`]. Callers: read the signatures, +/// do not assume. +pub fn read_pids_limit(proc_root: &Path, cgroup_root: &Path) -> Option { + if let Some(leaf) = resolve_cgroup_leaf(proc_root, "pids") { + let dir = match &leaf { + CgroupLeaf::V2(leaf) => cgroup_root.join(leaf), + CgroupLeaf::V1(leaf) => cgroup_root.join("pids").join(leaf), + }; + if let Some(text) = safe_read(&dir.join("pids.max")) { + if let Some(limit) = parse_cgroup_limit(&text) { + if limit > 0 { + return Some(limit); + } + } + // 'max'/garbage: cgroup says unlimited -> the binding cap is the + // host limit below + } + } + read_number_file(&proc_root.join("sys").join("kernel").join("threads-max")) +} + +/// `Max open files` SOFT limit from `/proc/self/limits` ('unlimited' -> +/// `None`) (Node `readSelfLimitsFdsMax`). +pub fn read_self_limits_fds_max(proc_root: &Path) -> Option { + let text = safe_read(&proc_root.join("self").join("limits"))?; + for line in non_empty_lines(&text) { + // /^Max open files\s+(\S+)/ + let Some(rest) = line.strip_prefix("Max open files") else { + continue; + }; + if !rest.starts_with(char::is_whitespace) { + continue; + } + let soft = rest.split_whitespace().next()?; + return soft.parse::().ok(); + } + None +} + +/// This process's inotify usage (Node `readSelfInotifyStats`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InotifyUsage { + pub instances: u64, + pub watches: u64, +} + +/// inotify usage of THIS process: bounded scan (cap +/// [`INOTIFY_FD_SCAN_CAP`] fds) of `/proc/self/fd` where the readlink target +/// starts with `anon_inode:inotify` counts instances; +/// `/proc/self/fdinfo/` lines starting with `inotify` count watches. +pub fn read_self_inotify_stats(proc_root: &Path) -> Option { + let fd_dir = proc_root.join("self").join("fd"); + let entries = safe_read_dir(&fd_dir)?; + let mut instances = 0u64; + let mut watches = 0u64; + for fd in entries.iter().take(INOTIFY_FD_SCAN_CAP) { + let Ok(target) = std::fs::read_link(fd_dir.join(fd)) else { + continue; // fd vanished mid-scan + }; + if !target.to_string_lossy().starts_with("anon_inode:inotify") { + continue; + } + instances += 1; + if let Some(fdinfo) = safe_read(&proc_root.join("self").join("fdinfo").join(fd)) { + for line in non_empty_lines(&fdinfo) { + if line.starts_with("inotify") { + watches += 1; + } + } + } + } + Some(InotifyUsage { instances, watches }) +} + +/// `/proc/sys/fs/inotify/max_user_{watches,instances}` (Node +/// `readInotifyLimits`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct InotifyLimits { + pub max_user_watches: Option, + pub max_user_instances: Option, +} + +/// inotify sysctls; `None` when BOTH limit files are unreadable. +pub fn read_inotify_limits(proc_root: &Path) -> Option { + let base = proc_root.join("sys").join("fs").join("inotify"); + let max_user_watches = read_number_file(&base.join("max_user_watches")); + let max_user_instances = read_number_file(&base.join("max_user_instances")); + if max_user_watches.is_none() && max_user_instances.is_none() { + return None; + } + Some(InotifyLimits { + max_user_watches, + max_user_instances, + }) +} + +// --------------------------------------------------------------------------- +// Sysfs sensors / machine info +// --------------------------------------------------------------------------- + +/// Mean of `/sys/devices/system/cpu/cpuN/cpufreq/scaling_cur_freq` +/// (kHz -> MHz) (Node `readCpuFreqMHz`). +pub fn read_cpu_freq_mhz(sys_root: &Path) -> Option { + let cpu_dir = sys_root.join("devices").join("system").join("cpu"); + let entries = safe_read_dir(&cpu_dir)?; + let mut freqs: Vec = Vec::new(); + for entry in entries { + // /^cpu\d+$/ + let Some(rest) = entry.strip_prefix("cpu") else { + continue; + }; + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Some(khz) = read_number_file( + &cpu_dir + .join(&entry) + .join("cpufreq") + .join("scaling_cur_freq"), + ) { + if khz > 0 { + freqs.push(khz as f64); + } + } + } + if freqs.is_empty() { + return None; + } + Some(freqs.iter().sum::() / freqs.len() as f64 / 1000.0) +} + +fn probe_psi_readable(proc_root: &Path) -> bool { + proc_root.join("pressure").is_dir() +} + +fn probe_cgroup_version(proc_root: &Path) -> &'static str { + let Some(text) = safe_read(&proc_root.join("self").join("cgroup")) else { + return "none"; + }; + if text.trim().is_empty() { + return "none"; + } + if non_empty_lines(&text).any(|line| line.starts_with("0::")) { + "v2" + } else { + "v1" + } +} + +fn list_thermal_zones(sys_root: &Path) -> Option> { + let entries = safe_read_dir(&sys_root.join("class").join("thermal"))?; + let mut zones: Vec<(u64, String)> = entries + .into_iter() + .filter_map(|entry| { + let rest = entry.strip_prefix("thermal_zone")?; + if rest.is_empty() || !rest.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + Some((rest.parse::().ok()?, entry)) + }) + .collect(); + zones.sort_by_key(|(idx, _)| *idx); + Some(zones.into_iter().map(|(_, name)| name).collect()) +} + +fn list_battery_entries(sys_root: &Path) -> Option> { + let power_supply = sys_root.join("class").join("power_supply"); + let entries = safe_read_dir(&power_supply)?; + Some( + entries + .into_iter() + .filter(|entry| { + match safe_read(&power_supply.join(entry).join("type")) { + Some(kind) => kind.trim() == "Battery", + // No type file: fall back to the /^bat/i name heuristic + // (Node parity). + None => { + let lower = entry.to_ascii_lowercase(); + lower.starts_with("bat") + } + } + }) + .collect(), + ) +} + +/// Kernel release from the injected root; `None` when absent (there is no +/// `os.release()` fallback on this Rust path — the payload field is nullable +/// by contract). +fn read_kernel_release(proc_root: &Path) -> Option { + let release = safe_read(&proc_root.join("sys").join("kernel").join("osrelease"))?; + let release = release.trim(); + (!release.is_empty()).then(|| release.to_string()) +} + +/// Hostname from the injected root (`/proc/sys/kernel/hostname`); `None` +/// when absent (there is no `os.hostname()` fallback on this Rust path — the +/// payload field is nullable by contract). +fn read_hostname(proc_root: &Path) -> Option { + let hostname = safe_read(&proc_root.join("sys").join("kernel").join("hostname"))?; + let hostname = hostname.trim(); + (!hostname.is_empty()).then(|| hostname.to_string()) +} + +/// First battery under `/sys/class/power_supply` (capacity % + status +/// string) (Node `readBattery`). +#[derive(Debug, Clone, PartialEq)] +pub struct Battery { + pub pct: f64, + pub status: String, +} + +/// First battery under `/sys/class/power_supply`; `None` if none. +pub fn read_battery(sys_root: &Path) -> Option { + let batteries = list_battery_entries(sys_root)?; + let entry = batteries.first()?; + let dir = sys_root.join("class").join("power_supply").join(entry); + let pct = read_number_file(&dir.join("capacity"))?; + let status = safe_read(&dir.join("status")) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "Unknown".to_string()); + Some(Battery { + pct: (pct as f64).clamp(0.0, 100.0), + status, + }) +} + +/// One thermal zone (millidegree -> celsius, `type` as label). +#[derive(Debug, Clone, PartialEq)] +pub struct ThermalZone { + pub label: String, + pub celsius: f64, +} + +/// Thermal zones (max 16); `None` when the thermal class dir is missing +/// (Node `readThermals`). +pub fn read_thermals(sys_root: &Path) -> Option> { + let zones = list_thermal_zones(sys_root)?; + let base = sys_root.join("class").join("thermal"); + let mut results = Vec::new(); + for zone in zones.iter().take(16) { + let Some(milli) = read_number_file(&base.join(zone).join("temp")) else { + continue; + }; + let label = safe_read(&base.join(zone).join("type")) + .map(|t| t.trim().to_string()) + .filter(|t| !t.is_empty()) + .unwrap_or_else(|| zone.clone()); + results.push(ThermalZone { + label, + celsius: milli as f64 / 1000.0, + }); + } + Some(results) +} + +/// Machine identity + capability snapshot (Node `readMachineInfo`, cheap +/// probes only — dir listings, no scans). `cgroup` is the exact `'v1' | +/// 'v2' | 'none'` vocabulary of the Node payload. +#[derive(Debug, Clone, PartialEq)] +pub struct MachineInfo { + pub cores: u64, + pub mem_total_bytes: u64, + pub platform: String, + pub wsl: bool, + pub kernel: Option, + pub hostname: Option, + pub psi: bool, + pub cgroup: String, + pub thermal_count: u64, + pub battery_present: bool, + pub gpu: String, +} + +/// Machine identity + capability snapshot. There is no `os.cpus()`/ +/// `os.totalmem()`/`os.hostname()` equivalent on this Rust path: cores come +/// from [`std::thread::available_parallelism`], `mem_total_bytes` from the +/// injected meminfo (0 when absent), kernel/hostname from the injected +/// `/sys/kernel/{osrelease,hostname}` (`None` when absent — both +/// payload fields are nullable by contract). +pub fn read_machine_info(proc_root: &Path, sys_root: &Path) -> MachineInfo { + let release = read_kernel_release(proc_root); + let thermal_zones = list_thermal_zones(sys_root); + let batteries = list_battery_entries(sys_root); + let release_lower = release.as_deref().unwrap_or("").to_ascii_lowercase(); + MachineInfo { + cores: std::thread::available_parallelism() + .map(|n| n.get() as u64) + .unwrap_or(1), + mem_total_bytes: read_meminfo(proc_root) + .map(|m| m.total_kb.saturating_mul(1024)) + .unwrap_or(0), + platform: if cfg!(target_os = "windows") { + "win32".to_string() + } else if cfg!(target_os = "macos") { + "darwin".to_string() + } else { + "linux".to_string() + }, + // /microsoft|wsl/i + wsl: release_lower.contains("microsoft") || release_lower.contains("wsl"), + kernel: release, + hostname: read_hostname(proc_root), + psi: probe_psi_readable(proc_root), + cgroup: probe_cgroup_version(proc_root).to_string(), + thermal_count: thermal_zones.as_ref().map(|z| z.len() as u64).unwrap_or(0), + battery_present: batteries.as_ref().map(|b| !b.is_empty()).unwrap_or(false), + // GPU detection is out of scope by design (renders 'n/a' truthfully). + gpu: "none".to_string(), + } +} + +// --------------------------------------------------------------------------- +// Process-table scan pure pieces (the async dwell loop lives in +// freshell-server::host_stats — this crate is tokio-free) +// --------------------------------------------------------------------------- + +/// Parsed `/proc//stat`: comm (after the LAST ')'), state, utime+stime +/// busy jiffies. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProcPidStat { + pub name: String, + pub state: String, + pub busy_jiffies: u64, +} + +/// `pid (comm) state ...` — comm may contain spaces AND parens, so fields +/// are counted after the LAST ')' (precedent: +/// `server/coding-cli/codex-child-registry.ts`, mirrored by +/// `freshell-server`'s `shutdown_forensics`). After the close paren, +/// zero-indexed fields: [0] state, [11] utime, [12] stime. +pub fn parse_proc_pid_stat(text: &str) -> Option { + let open = text.find('(')?; + let close = text.rfind(')')?; + if open > close { + return None; + } + let fields: Vec<&str> = text[close + 1..].split_whitespace().collect(); + if fields.len() < 13 { + return None; + } + let state = fields[0]; + if state.is_empty() { + return None; + } + let utime = fields[11].trim().parse::().ok()?; + let stime = fields[12].trim().parse::().ok()?; + Some(ProcPidStat { + name: text[open + 1..close].to_string(), + state: state.to_string(), + busy_jiffies: utime + stime, + }) +} + +/// `/proc//status` VmRSS in kB. Preferred over stat rss pages x 4096: +/// page size is NOT 4096 on every target (aarch64 16K/64K pages would +/// silently inflate RSS 16x). +pub fn parse_status_vm_rss_kb(text: &str) -> Option { + // /^VmRSS:\s+(\d+)\s*kB/m + for line in text.lines() { + let Some(rest) = line.strip_prefix("VmRSS:") else { + continue; + }; + let tokens: Vec<&str> = rest.split_whitespace().collect(); + if tokens.len() < 2 || tokens[1] != "kB" { + return None; + } + return tokens[0].parse::().ok(); + } + None +} + +/// Numeric `/proc` entries (pids), capped at [`PROC_SCAN_CAP`]; `None` when +/// the root is unreadable. +pub fn list_numeric_pids(proc_root: &Path) -> Option> { + let entries = safe_read_dir(proc_root)?; + let mut pids = Vec::new(); + for entry in entries { + if entry.is_empty() || !entry.bytes().all(|b| b.is_ascii_digit()) { + continue; + } + if let Ok(pid) = entry.parse::() { + pids.push(pid); + if pids.len() >= PROC_SCAN_CAP { + break; + } + } + } + pids.sort_unstable(); + Some(pids) +} + +/// Bounded read of one `/proc/` file (mirrors Node's +/// `readTextFileBounded(path, 4096)`); `None` on any failure. +pub fn read_pid_file_bounded(proc_root: &Path, pid: u64, name: &str) -> Option { + use std::io::Read; + let file = std::fs::File::open(proc_root.join(pid.to_string()).join(name)).ok()?; + let mut buffer = Vec::with_capacity(PROC_PID_FILE_MAX_BYTES); + file.take(PROC_PID_FILE_MAX_BYTES as u64) + .read_to_end(&mut buffer) + .ok()?; + Some(String::from_utf8_lossy(&buffer).into_owned()) +} + +/// jiffies delta over `dwell_ms` -> cpu percent, clamped to +/// `[0, 100 * cores]` (Node `computeCpuPct`; USER_HZ=100). A non-positive +/// dwell returns 0 (never NaN/Infinity). +pub fn compute_cpu_pct(delta_jiffies: f64, dwell_ms: u64, cores: u64) -> f64 { + if !delta_jiffies.is_finite() || dwell_ms == 0 { + return 0.0; + } + let cores = cores.max(1); + let pct = (delta_jiffies / USER_HZ as f64 / (dwell_ms as f64 / 1000.0)) * 100.0; + pct.clamp(0.0, 100.0 * cores as f64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_stats_readers_is_whole_device_classifies_names() { + assert!(is_whole_device("sda")); + assert!(is_whole_device("nvme0n1")); + assert!(is_whole_device("mmcblk0")); + assert!(is_whole_device("dm-0")); + assert!(!is_whole_device("sda1")); + assert!(!is_whole_device("vda2")); + assert!(!is_whole_device("nvme0n1p1")); + assert!(!is_whole_device("mmcblk0p1")); + assert!(!is_whole_device("loop0")); + assert!(!is_whole_device("ram0")); + } + + #[test] + fn host_stats_readers_compute_cpu_pct_jiffy_math() { + // 30 jiffies over a 300ms dwell = 100% of one core (USER_HZ=100). + assert_eq!(compute_cpu_pct(30.0, 300, 4), 100.0); + assert_eq!(compute_cpu_pct(15.0, 300, 4), 50.0); + // Clamped to [0, 100 * cores]; non-positive dwell -> 0. + assert_eq!(compute_cpu_pct(1e12, 1, 4), 400.0); + assert_eq!(compute_cpu_pct(-5.0, 300, 4), 0.0); + assert_eq!(compute_cpu_pct(50.0, 0, 4), 0.0); + } + + #[test] + fn host_stats_readers_parse_proc_pid_stat_comm_with_parens() { + // The procmini fixture's pid 404 line: comm contains parens AND + // spaces — the split must happen after the LAST ')'. + let text = "404 (my (weird) proc) D 1 404 404 0 -1 4194304 200 0 5 0 999 111 0 0 20 0 2 0 8000 300000000 6000\n"; + let parsed = parse_proc_pid_stat(text).expect("valid stat line"); + assert_eq!(parsed.name, "my (weird) proc"); + assert_eq!(parsed.state, "D"); + assert_eq!(parsed.busy_jiffies, 999 + 111); + assert!(parse_proc_pid_stat("999 (broken").is_none()); + } + + #[test] + fn host_stats_readers_parse_status_vm_rss_kb() { + let text = "Name:\tsystemd\nVmRSS:\t 12345 kB\nThreads:\t1\n"; + assert_eq!(parse_status_vm_rss_kb(text), Some(12345)); + assert_eq!(parse_status_vm_rss_kb("Name:\tx\n"), None); + } +} diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 528ae3cbc..cf8b19730 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -55,6 +55,7 @@ pub mod cli_launch; pub mod clock; pub mod detect; pub mod git_meta; +pub mod host_stats_readers; pub mod mcp_inject; pub mod opencode_plugin; pub mod path; diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index 4d9e61bc0..9fee76906 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -1,4 +1,4 @@ -//! Client → server messages (`ClientMessage`, 31 discriminants). +//! Client → server messages (`ClientMessage`, 34 discriminants). //! //! These are the Zod-validated inbound surface. Deserialization is //! accept-and-strip (no `deny_unknown_fields`), mirroring the runtime. @@ -81,11 +81,17 @@ pub enum ClientMessage { FreshAgentFork(FreshAgentFork), #[serde(rename = "pane.reconcile.request")] PaneReconcileRequest(PaneReconcileRequest), + #[serde(rename = "hoststats.subscribe")] + HostStatsSubscribe, + #[serde(rename = "hoststats.unsubscribe")] + HostStatsUnsubscribe, + #[serde(rename = "hoststats.refresh")] + HostStatsRefresh(HostStatsRefresh), } /// The exact `type` discriminants of every client→server message, in the frozen /// inventory's order. This is the T0 conformance checklist. -pub const CLIENT_MESSAGE_TYPES: [&str; 31] = [ +pub const CLIENT_MESSAGE_TYPES: [&str; 34] = [ "amplifier.activity.list", "claude.activity.list", "client.diagnostic", @@ -103,6 +109,9 @@ pub const CLIENT_MESSAGE_TYPES: [&str; 31] = [ "freshAgent.question.respond", "freshAgent.send", "hello", + "hoststats.refresh", + "hoststats.subscribe", + "hoststats.unsubscribe", "opencode.activity.list", "pane.reconcile.request", "ping", @@ -682,3 +691,13 @@ pub struct FreshAgentFork { #[serde(skip_serializing_if = "Option::is_none")] pub cwd: Option, } + +// --- hoststats.* ----------------------------------------------------------- + +/// `HostStatsRefreshSchema` (`shared/ws-protocol.ts`) — client-minted +/// `requestId`, echoed verbatim by `hoststats.refresh.response`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct HostStatsRefresh { + #[serde(rename = "requestId")] + pub request_id: String, +} diff --git a/crates/freshell-protocol/src/server_messages.rs b/crates/freshell-protocol/src/server_messages.rs index 114ff646b..c70cb6634 100644 --- a/crates/freshell-protocol/src/server_messages.rs +++ b/crates/freshell-protocol/src/server_messages.rs @@ -1,10 +1,11 @@ -//! Server → client messages (`ServerMessage`, 58 discriminants). +//! Server → client messages (`ServerMessage`, 60 discriminants). //! //! These are TypeScript-typed (not runtime-validated) on the wire; their frozen //! shape authority is `port/contract/ws-server-messages.schema.json`. use serde::{Deserialize, Serialize}; use serde_json::Value; +use std::collections::HashMap; use crate::common::{ AgentProvider, AmplifierActivityRecord, ClaudeActivityRecord, CodexActivityRecord, @@ -75,6 +76,10 @@ pub enum ServerMessage { FreshAgentSendAccepted(FreshAgentSendAccepted), #[serde(rename = "freshAgent.session.materialized")] FreshAgentSessionMaterialized(FreshAgentSessionMaterialized), + #[serde(rename = "hoststats.refresh.response")] + HostStatsRefreshResponse(HostStatsRefreshResponse), + #[serde(rename = "hoststats.snapshot")] + HostStatsSnapshot(Box), #[serde(rename = "opencode.activity.list.response")] OpencodeActivityListResponse(OpencodeActivityListResponse), #[serde(rename = "opencode.activity.updated")] @@ -148,7 +153,7 @@ pub enum ServerMessage { /// The exact `type` discriminants of every server→client message, in the frozen /// inventory's order. This is the T0 conformance checklist. -pub const SERVER_MESSAGE_TYPES: [&str; 58] = [ +pub const SERVER_MESSAGE_TYPES: [&str; 60] = [ "amplifier.activity.list.response", "amplifier.activity.updated", "claude.activity.list.response", @@ -174,6 +179,8 @@ pub const SERVER_MESSAGE_TYPES: [&str; 58] = [ "freshAgent.killed", "freshAgent.send.accepted", "freshAgent.session.materialized", + "hoststats.refresh.response", + "hoststats.snapshot", "opencode.activity.list.response", "opencode.activity.updated", "pane.reconcile.result", @@ -1199,3 +1206,266 @@ pub struct UiCommand { #[serde(skip_serializing_if = "Option::is_none")] pub payload: Option, } + +// --- hoststats.* ----------------------------------------------------------- +// +// Shape authority: the zod schemas in `shared/ws-protocol.ts` +// (`HostStats*Schema`). Serde discipline: zod `.nullable()` (required-but-may +// -be-null) fields map to `Option` that ALWAYS serialize (null allowed); +// zod `.optional()` (may-be-absent) fields map to `Option` PLUS +// `#[serde(skip_serializing_if = "Option::is_none", default)]` — never +// serialized as explicit null. Pinned by `tests/hoststats_shape.rs`. + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsMachine { + pub cores: u64, + pub mem_total_bytes: u64, + pub platform: String, + pub wsl: bool, + pub kernel: Option, + pub hostname: Option, + pub psi: bool, + pub cgroup: String, + pub thermal_count: u64, + pub battery_present: bool, + pub gpu: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsCpu { + pub available: bool, + pub usage_pct: f64, + pub steal_pct: Option, + pub per_core_pct: Vec, + pub freq_m_hz: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsLoad { + pub available: bool, + pub load1: f64, + pub load5: f64, + pub load15: f64, + pub cores: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsMemory { + pub available: bool, + pub source: String, + pub total_bytes: u64, + pub used_bytes: u64, + pub available_bytes: u64, + pub cgroup_limit_bytes: Option, + pub swap_total_bytes: Option, + pub swap_used_bytes: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsPaging { + pub available: bool, + pub swap_in_kbps: f64, + pub swap_out_kbps: f64, + pub maj_faults_per_sec: f64, + pub oom_kills_delta: u64, + pub oom_kills_total: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsPsi { + pub available: bool, + pub cpu_some10: Option, + pub mem_some10: Option, + pub mem_full10: Option, + pub io_some10: Option, + pub io_full10: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsDiskIo { + pub available: bool, + pub read_bps: f64, + pub write_bps: f64, + pub util_pct: Option, + pub weighted_await_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsNetwork { + pub available: bool, + pub rx_bps: f64, + pub tx_bps: f64, + pub rx_errors_total: u64, + pub tx_errors_total: u64, + pub rx_dropped_total: u64, + pub tx_dropped_total: u64, + pub rx_errors_delta: u64, + pub tx_errors_delta: u64, + pub rx_dropped_delta: u64, + pub tx_dropped_delta: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsLimits { + pub available: bool, + pub fds_used: Option, + pub fds_max: Option, + pub pids_used: Option, + pub pids_max: Option, + pub time_wait: Option, + pub ephemeral_ports: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsFreshell { + pub available: bool, + pub source: String, + pub ptys_running: u64, + pub ptys_max: u64, + pub ws_clients: u64, + pub ws_clients_max: u64, + pub event_loop_lag_p99_ms: Option, + pub rss_bytes: Option, + pub uptime_sec: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsLive { + pub machine: HostStatsMachine, + pub cpu: HostStatsCpu, + pub load: HostStatsLoad, + pub memory: HostStatsMemory, + pub paging: HostStatsPaging, + pub psi: HostStatsPsi, + pub disk_io: HostStatsDiskIo, + pub network: HostStatsNetwork, + pub limits: HostStatsLimits, + pub freshell: HostStatsFreshell, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsTopProcess { + pub pid: u64, + pub name: String, + pub cpu_pct: f64, + pub rss_bytes: u64, + pub state: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsTopProcesses { + pub available: bool, + pub dwell_ms: u64, + pub list: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsProcessHealth { + pub available: bool, + pub zombies: u64, + pub d_state: u64, + pub total: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsInotify { + pub available: bool, + pub instances: Option, + pub watches: Option, + pub max_user_watches: Option, + pub max_user_instances: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsDisk { + pub mount: String, + pub total_bytes: u64, + pub free_bytes: u64, + pub used_pct: f64, + pub inodes_total: Option, + pub inodes_free: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsDisks { + pub available: bool, + pub list: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsThermalZone { + pub label: String, + pub celsius: f64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsBattery { + pub pct: f64, + pub status: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsThermals { + pub available: bool, + pub zones: Vec, + pub battery: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsManual { + pub top_processes: HostStatsTopProcesses, + pub process_health: HostStatsProcessHealth, + pub inotify: HostStatsInotify, + pub disks: HostStatsDisks, + pub thermals: HostStatsThermals, + pub section_errors: HashMap, +} + +/// `HostStatsSnapshotSchema` (`shared/ws-protocol.ts`). +/// `manual_at`/`manual` are zod `.nullable()` (required, may be null) — they +/// serialize explicitly as `null`, never skipped. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsSnapshot { + pub at: u64, + pub live: HostStatsLive, + pub manual_at: Option, + pub manual: Option, +} + +/// `HostStatsRefreshResponseSchema` (`shared/ws-protocol.ts`). +/// `at`/`manual`/`error` are zod `.optional()` (may be absent) — they are +/// omitted from the wire when `None`, never serialized as explicit null. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct HostStatsRefreshResponse { + pub request_id: String, + pub ok: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub at: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub manual: Option, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub error: Option, +} diff --git a/crates/freshell-protocol/tests/hoststats_shape.rs b/crates/freshell-protocol/tests/hoststats_shape.rs new file mode 100644 index 000000000..1a304eaff --- /dev/null +++ b/crates/freshell-protocol/tests/hoststats_shape.rs @@ -0,0 +1,286 @@ +//! Field-level drift pin for the hoststats.* payloads (LB14). +//! +//! The inventory test pins discriminants only; this pins the exact key set and +//! camelCase spelling of every nested section, plus the nullable-vs-optional +//! serde split (`.nullable()` fields serialize as explicit `null`; `.optional()` +//! fields are omitted from the wire). + +use std::collections::HashMap; + +use freshell_protocol::server_messages::{ + HostStatsCpu, HostStatsDisk, HostStatsDiskIo, HostStatsDisks, HostStatsFreshell, + HostStatsInotify, HostStatsLimits, HostStatsLive, HostStatsLoad, HostStatsMachine, + HostStatsManual, HostStatsMemory, HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, + HostStatsPsi, HostStatsRefreshResponse, HostStatsSnapshot, HostStatsThermalZone, + HostStatsThermals, HostStatsTopProcess, HostStatsTopProcesses, +}; + +fn sample_live() -> HostStatsLive { + HostStatsLive { + machine: HostStatsMachine { + cores: 12, + mem_total_bytes: 34_000_000_000, + platform: "linux".into(), + wsl: true, + kernel: Some("6.6".into()), + hostname: Some("h".into()), + psi: true, + cgroup: "v2".into(), + thermal_count: 1, + battery_present: false, + gpu: "none".into(), + }, + cpu: HostStatsCpu { + available: true, + usage_pct: 12.5, + steal_pct: Some(0.0), + per_core_pct: vec![1.0, 2.0], + freq_m_hz: Some(3400.0), + }, + load: HostStatsLoad { + available: true, + load1: 0.5, + load5: 1.0, + load15: 1.2, + cores: 12, + }, + memory: HostStatsMemory { + available: true, + source: "host".into(), + total_bytes: 1, + used_bytes: 1, + available_bytes: 1, + cgroup_limit_bytes: None, + swap_total_bytes: Some(0), + swap_used_bytes: Some(0), + }, + paging: HostStatsPaging { + available: true, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total: 0, + }, + psi: HostStatsPsi { + available: true, + cpu_some10: Some(0.1), + mem_some10: None, + mem_full10: None, + io_some10: Some(0.2), + io_full10: Some(0.0), + }, + disk_io: HostStatsDiskIo { + available: true, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }, + network: HostStatsNetwork { + available: true, + rx_bps: 0.0, + tx_bps: 0.0, + rx_errors_total: 0, + tx_errors_total: 0, + rx_dropped_total: 0, + tx_dropped_total: 0, + rx_errors_delta: 0, + tx_errors_delta: 0, + rx_dropped_delta: 0, + tx_dropped_delta: 0, + }, + limits: HostStatsLimits { + available: true, + fds_used: Some(128), + fds_max: Some(1_048_576), + pids_used: Some(900), + pids_max: Some(4_194_304), + time_wait: Some(42), + ephemeral_ports: Some(28232), + }, + freshell: HostStatsFreshell { + available: true, + source: "node".into(), + ptys_running: 1, + ptys_max: 50, + ws_clients: 2, + ws_clients_max: 50, + event_loop_lag_p99_ms: Some(3.2), + rss_bytes: Some(900_000_000), + uptime_sec: 100.0, + }, + } +} + +fn sample_manual() -> HostStatsManual { + HostStatsManual { + top_processes: HostStatsTopProcesses { + available: true, + dwell_ms: 300, + list: vec![HostStatsTopProcess { + pid: 5, + name: "node".into(), + cpu_pct: 12.3, + rss_bytes: 1_000_000, + state: "S".into(), + }], + }, + process_health: HostStatsProcessHealth { + available: true, + zombies: 0, + d_state: 0, + total: 900, + }, + inotify: HostStatsInotify { + available: true, + instances: Some(3), + watches: Some(420), + max_user_watches: Some(1_048_576), + max_user_instances: Some(128), + }, + disks: HostStatsDisks { + available: true, + list: vec![HostStatsDisk { + mount: "/".into(), + total_bytes: 1_000_000_000_000, + free_bytes: 500_000_000_000, + used_pct: 50.0, + inodes_total: Some(100_000_000), + inodes_free: Some(90_000_000), + }], + }, + thermals: HostStatsThermals { + available: true, + zones: vec![HostStatsThermalZone { + label: "cpu".into(), + celsius: 51.5, + }], + battery: None, + }, + section_errors: HashMap::new(), + } +} + +#[test] +fn fully_populated_snapshot_serializes_exact_camel_case_shape() { + let snap = HostStatsSnapshot { + at: 1_756_000_000_000, + live: sample_live(), + manual_at: Some(1_756_000_000_500), + manual: Some(sample_manual()), + }; + let v = serde_json::to_value(&snap).expect("serialize"); + let expected = serde_json::json!({ + "at": 1_756_000_000_000u64, + "live": { + "machine": { + "cores": 12, "memTotalBytes": 34_000_000_000u64, "platform": "linux", + "wsl": true, "kernel": "6.6", "hostname": "h", "psi": true, + "cgroup": "v2", "thermalCount": 1, "batteryPresent": false, "gpu": "none" + }, + "cpu": { + "available": true, "usagePct": 12.5, "stealPct": 0.0, + "perCorePct": [1.0, 2.0], "freqMHz": 3400.0 + }, + "load": { "available": true, "load1": 0.5, "load5": 1.0, "load15": 1.2, "cores": 12 }, + "memory": { + "available": true, "source": "host", "totalBytes": 1, "usedBytes": 1, + "availableBytes": 1, "cgroupLimitBytes": null, + "swapTotalBytes": 0, "swapUsedBytes": 0 + }, + "paging": { + "available": true, "swapInKbps": 0.0, "swapOutKbps": 0.0, + "majFaultsPerSec": 0.0, "oomKillsDelta": 0, "oomKillsTotal": 0 + }, + "psi": { + "available": true, "cpuSome10": 0.1, "memSome10": null, "memFull10": null, + "ioSome10": 0.2, "ioFull10": 0.0 + }, + "diskIo": { + "available": true, "readBps": 0.0, "writeBps": 0.0, + "utilPct": null, "weightedAwaitMs": null + }, + "network": { + "available": true, "rxBps": 0.0, "txBps": 0.0, + "rxErrorsTotal": 0, "txErrorsTotal": 0, "rxDroppedTotal": 0, "txDroppedTotal": 0, + "rxErrorsDelta": 0, "txErrorsDelta": 0, "rxDroppedDelta": 0, "txDroppedDelta": 0 + }, + "limits": { + "available": true, "fdsUsed": 128, "fdsMax": 1_048_576, + "pidsUsed": 900, "pidsMax": 4_194_304, "timeWait": 42, "ephemeralPorts": 28232 + }, + "freshell": { + "available": true, "source": "node", "ptysRunning": 1, "ptysMax": 50, + "wsClients": 2, "wsClientsMax": 50, "eventLoopLagP99Ms": 3.2, + "rssBytes": 900_000_000, "uptimeSec": 100.0 + } + }, + "manualAt": 1_756_000_000_500u64, + "manual": { + "topProcesses": { + "available": true, "dwellMs": 300, + "list": [{ "pid": 5, "name": "node", "cpuPct": 12.3, "rssBytes": 1_000_000, "state": "S" }] + }, + "processHealth": { "available": true, "zombies": 0, "dState": 0, "total": 900 }, + "inotify": { + "available": true, "instances": 3, "watches": 420, + "maxUserWatches": 1_048_576, "maxUserInstances": 128 + }, + "disks": { + "available": true, + "list": [{ + "mount": "/", "totalBytes": 1_000_000_000_000u64, "freeBytes": 500_000_000_000u64, + "usedPct": 50.0, "inodesTotal": 100_000_000, "inodesFree": 90_000_000 + }] + }, + "thermals": { "available": true, "zones": [{ "label": "cpu", "celsius": 51.5 }], "battery": null }, + "sectionErrors": {} + } + }); + // serde_json::Value equality is key-set + value + spelling exact (maps + // compare as sets, so a renamed/missing/extra key diverges). + assert_eq!(v, expected, "snapshot wire shape drifted"); +} + +#[test] +fn nullable_fields_serialize_null_and_optional_fields_are_absent() { + // Bare refresh response: `.optional()` fields must be ABSENT, never null. + let resp = HostStatsRefreshResponse { + request_id: "r1".into(), + ok: true, + at: None, + manual: None, + error: None, + }; + let v = serde_json::to_value(&resp).expect("serialize"); + let obj = v.as_object().expect("object"); + assert_eq!(obj.len(), 2, "bare response carries requestId+ok only"); + assert_eq!(v["requestId"], "r1"); + assert!(!obj.contains_key("at"), "at must be absent, not null"); + assert!( + !obj.contains_key("manual"), + "manual must be absent, not null" + ); + assert!(!obj.contains_key("error"), "error must be absent, not null"); + + // Snapshot with no manual refresh yet: `.nullable()` fields must be + // PRESENT as explicit null. + let snap = HostStatsSnapshot { + at: 1, + live: sample_live(), + manual_at: None, + manual: None, + }; + let v = serde_json::to_value(&snap).expect("serialize"); + assert!(v.as_object().expect("object").contains_key("manualAt")); + assert!(v.as_object().expect("object").contains_key("manual")); + assert_eq!(v["manualAt"], serde_json::Value::Null); + assert_eq!(v["manual"], serde_json::Value::Null); + + // Message-level envelope: serde tag must be the frozen discriminant. + let msg = freshell_protocol::ServerMessage::HostStatsRefreshResponse(resp); + let v = serde_json::to_value(&msg).expect("serialize envelope"); + assert_eq!(v["type"], "hoststats.refresh.response"); + assert!(!v.as_object().expect("object").contains_key("at")); +} diff --git a/crates/freshell-protocol/tests/inventory.rs b/crates/freshell-protocol/tests/inventory.rs index 37b4bab83..ee6652b44 100644 --- a/crates/freshell-protocol/tests/inventory.rs +++ b/crates/freshell-protocol/tests/inventory.rs @@ -31,12 +31,12 @@ fn client_types_match_inventory_exactly() { let inv = inventory(); assert_eq!( inv["clientToServer"]["count"].as_u64(), - Some(31), - "inventory declares 31 client→server types" + Some(34), + "inventory declares 34 client→server types" ); let expected = json_type_set(&inv["clientToServer"]["types"]); let actual: BTreeSet = CLIENT_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual.len(), 31, "crate declares 31 client types (no dups)"); + assert_eq!(actual.len(), 34, "crate declares 34 client types (no dups)"); assert_eq!( actual, expected, "CLIENT_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)" @@ -48,12 +48,12 @@ fn server_types_match_inventory_exactly() { let inv = inventory(); assert_eq!( inv["serverToClient"]["count"].as_u64(), - Some(58), - "inventory declares 58 server→client types" + Some(60), + "inventory declares 60 server→client types" ); let expected = json_type_set(&inv["serverToClient"]["types"]); let actual: BTreeSet = SERVER_MESSAGE_TYPES.iter().map(|s| s.to_string()).collect(); - assert_eq!(actual.len(), 58, "crate declares 58 server types (no dups)"); + assert_eq!(actual.len(), 60, "crate declares 60 server types (no dups)"); assert_eq!( actual, expected, "SERVER_MESSAGE_TYPES must equal the frozen inventory (no missing/extra)" @@ -61,14 +61,14 @@ fn server_types_match_inventory_exactly() { } #[test] -fn combined_surface_is_89() { +fn combined_surface_is_94() { let all = all_message_types(); - assert_eq!(all.len(), 89, "31 client + 58 server = 89 discriminants"); + assert_eq!(all.len(), 94, "34 client + 60 server = 94 discriminants"); // sorted + unique let unique: BTreeSet<&str> = all.iter().copied().collect(); assert_eq!( unique.len(), - 89, + 94, "no discriminant collides across directions" ); } diff --git a/crates/freshell-server/src/host_stats.rs b/crates/freshell-server/src/host_stats.rs new file mode 100644 index 000000000..85e8c2ccc --- /dev/null +++ b/crates/freshell-server/src/host_stats.rs @@ -0,0 +1,2259 @@ +//! HostStatsCollectorService — the Rust port of the subscriber-gated two-tier +//! host pressure collector `server/host-stats/service.ts` +//! (`docs/plans/2026-08-25-host-pressure-pane.md` Task 9 contract lines +//! 874–933). Implements [`freshell_ws::host_stats_collector::HostStatsCollector`] +//! over the pure path-injected readers in +//! [`freshell_platform::host_stats_readers`] (themselves the port of +//! `server/host-stats/readers.ts`); `freshell-ws` owns the trait + +//! interest registry + dispatch and never touches `/proc` or timers. +//! +//! Tiers: FAST (default `FRESHELL_HOST_STATS_FAST_MS` || 2000) reads +//! cpu/load/memory (cgroup-aware)/paging/psi + freshell internals; SLOW +//! (default `FRESHELL_HOST_STATS_SLOW_MS` || 5000) reads +//! diskstats/netdev/tcp/limits/cpufreq. Rates (cpu%, paging KB/s, disk/net +//! B/s) come from CUMULATIVE reader counters delta'd over dt; the previous +//! sample of each counter family lives in the shared cache. The first tick of +//! each family has no window, so it reports null-safe zeros (rates 0, +//! nullable windows null). +//! +//! `set_active(true)` runs ONE immediate fast tick (a fresh subscriber gets a +//! shaped snapshot at once); the slow tier only ticks on its own interval. +//! `set_active(false)` aborts ALL collection tasks (true zero cost). +//! `snapshot()` never blocks on I/O — ticks write caches, snapshots read +//! caches. +//! +//! `refresh()` (on-request manual data — process table, disks, inotify, +//! thermals/battery) is single-flight with a 1s post-completion cooldown +//! (connection-agnostic, R3M6). Section budgets are COOPERATIVE: every +//! section gets a shared absolute deadline (start + section_budget; the +//! process-table scan's per-pid deadline check exists for this) and an +//! overall_budget watchdog marks any still-running section failed. A failed +//! section keeps the full zero-shape + `available:false` + a sectionErrors +//! entry; other sections complete. +//! +//! Platform: `/proc` + `/sys` readers are Linux-only. Unlike Node there is NO +//! darwin fallback (`os.cpus()/os.loadavg()/os.totalmem()` scraped objects +//! and the `ps` subprocess are Node-only): on darwin/Windows the files simply +//! do not exist, so every `/proc`-dependent section degrades to its +//! zero-shape (`available:false`) and `cpu.available` is `false` (frozen +//! Task 9 note). +//! +//! Delivery (frozen contract): snapshots flow ONLY to subscribed connections +//! — the cadence iterates the interest registry's per-connection senders +//! (captured by `terminal.rs` at subscribe time); the shared `broadcast_tx` +//! fan-out bus is NEVER used (non-watchers get zero traffic). + +use std::collections::{BTreeMap, HashMap}; +use std::path::PathBuf; +#[cfg(test)] +use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use freshell_platform::host_stats_readers as readers; +use freshell_protocol::{ + HostStatsBattery, HostStatsCpu, HostStatsDisk, HostStatsDiskIo, HostStatsDisks, + HostStatsFreshell, HostStatsInotify, HostStatsLimits, HostStatsLive, HostStatsLoad, + HostStatsMachine, HostStatsManual, HostStatsMemory, HostStatsNetwork, HostStatsPaging, + HostStatsProcessHealth, HostStatsPsi, HostStatsSnapshot, HostStatsThermalZone, + HostStatsThermals, HostStatsTopProcess, HostStatsTopProcesses, +}; +use freshell_ws::host_stats_collector::{ + HostStatsCollector, HostStatsRefreshFuture, HostStatsRefreshOk, +}; +use freshell_ws::host_stats_interest::HostStatsInterestRegistry; + +const DEFAULT_FAST: Duration = Duration::from_millis(2000); +const DEFAULT_SLOW: Duration = Duration::from_millis(5000); +const DEFAULT_OVERALL_BUDGET: Duration = Duration::from_millis(4000); +/// No re-start stampede: refresh() rejects <1s after the previous refresh +/// COMPLETED (connection-agnostic, mirrors Node's REFRESH_MIN_INTERVAL_MS). +const DEFAULT_REFRESH_COOLDOWN: Duration = Duration::from_millis(1000); +/// Scheduler-drift sampler cadence while active (the Rust stand-in for +/// Node's `monitorEventLoopDelay` histogram; samples land in a per-fast-tick +/// window whose p99 becomes `eventLoopLagP99Ms`). +const DEFAULT_DRIFT_SAMPLE_INTERVAL: Duration = Duration::from_millis(100); +/// On-request process-table dwell (two `/proc` samples + dwell → per-process +/// cpuPct). Mirrors Node's PROC_SCAN_DWELL_MS. +const PROC_SCAN_DWELL: Duration = Duration::from_millis(300); +const TOP_PROCESS_COUNT: usize = 12; +const DISK_SECTOR_BYTES: u64 = 512; +/// `/proc/vmstat` pswpin/pswpout count PAGES; 4KB pages on every production +/// target (documented Node assumption, mirrored). +const VMSTAT_PAGE_KB: u64 = 4; + +fn now_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn env_positive_ms(name: &str, fallback: Duration) -> Duration { + std::env::var(name) + .ok() + .and_then(|raw| raw.trim().parse::().ok()) + .filter(|v| *v > 0) + .map(Duration::from_millis) + .unwrap_or(fallback) +} + +fn clamp_pct(value: f64) -> f64 { + value.clamp(0.0, 100.0) +} + +/// Tunables + injected filesystem roots. `Default` is the production +/// contract; tests inject the committed fixture tree + fast cadences (no +/// tokio time control — real short cadences, deterministic count +/// assertions). +#[derive(Debug, Clone)] +pub struct HostStatsCollectorConfig { + /// Default `/proc` (the machine probe + every reader root). + pub proc_root: PathBuf, + /// Default `/sys` (cgroup root = `/fs/cgroup`, cpufreq, + /// thermal, power_supply). + pub sys_root: PathBuf, + pub fast: Duration, + pub slow: Duration, + /// Watchdog for a refresh section still running past the cooperative + /// per-section budget (the trait's `deadline` argument is that budget). + pub overall_budget: Duration, + pub refresh_cooldown: Duration, + pub drift_sample_interval: Duration, +} + +impl Default for HostStatsCollectorConfig { + fn default() -> Self { + Self { + proc_root: PathBuf::from("/proc"), + sys_root: PathBuf::from("/sys"), + fast: DEFAULT_FAST, + slow: DEFAULT_SLOW, + overall_budget: DEFAULT_OVERALL_BUDGET, + refresh_cooldown: DEFAULT_REFRESH_COOLDOWN, + drift_sample_interval: DEFAULT_DRIFT_SAMPLE_INTERVAL, + } + } +} + +impl HostStatsCollectorConfig { + /// Production wiring: defaults with the two cadence env overrides + /// (`FRESHELL_HOST_STATS_FAST_MS`/`_SLOW_MS`, positive ms only — Node + /// `envPositiveMs` parity). + pub fn from_env() -> Self { + Self { + fast: env_positive_ms("FRESHELL_HOST_STATS_FAST_MS", DEFAULT_FAST), + slow: env_positive_ms("FRESHELL_HOST_STATS_SLOW_MS", DEFAULT_SLOW), + ..Default::default() + } + } + + fn cgroup_root(&self) -> PathBuf { + self.sys_root.join("fs").join("cgroup") + } +} + +/// The in-flight wire one refresh clones to every waiter (single-flight). +type RefreshWire = Result; + +/// The mutable collection state, shared by the collector handle and its +/// spawned cadence tasks. All guards are std Mutexes: locks are never held +/// across an await (ticks are sync reader calls; refresh awaits only the +/// dwell sleep / watch channel). +struct Share { + live: Mutex, + manual: Mutex>, + prev_cpu: Mutex>, + prev_vmstat: Mutex>, + prev_disks: Mutex)>>, + prev_net: Mutex>, + /// Scheduler-drift samples (ms) since the previous fast tick; drained per + /// fast tick into `freshell.eventLoopLagP99Ms`. + lag_samples: Mutex>, + cadence: Mutex>, + /// Single-flight: while Some, a refresh is in flight and later callers + /// clone this receiver and await the SAME wire (Node returns the same + /// in-flight promise). The run itself is the COLLECTOR's own spawned + /// task — never the requesting caller's future — so a caller teardown + /// cancels nothing for anyone else (Node service-owned pendingRefresh). + refresh_flight: Mutex>>>, + last_refresh_completed: Mutex>, +} + +struct CadenceHandles { + fast: tokio::task::JoinHandle<()>, + slow: tokio::task::JoinHandle<()>, + drift: tokio::task::JoinHandle<()>, +} + +/// Everything the cadence tasks + refresh path need, Arc-shared. +struct CollectorCtx { + cfg: HostStatsCollectorConfig, + registry: freshell_terminal::TerminalRegistry, + interest: HostStatsInterestRegistry, + boot_anchor: Instant, + machine: HostStatsMachine, + scan_runs: AtomicUsize, + /// Test-only fault-injection seam (never in production builds): a run + /// that consumes a `true` here dies mid-scan, unwinding the + /// collector's spawned run task — the "run vanished without + /// completing" fault the flight-slot guard cleans up after. One-shot, + /// so a recovery refresh runs healthily. + #[cfg(test)] + test_run_panic: AtomicBool, + share: Share, +} + +/// The concrete Task 9 collector. Construct + `Arc` +/// it in `main.rs` next to the terminal-registry construction; NO task spawns +/// here — the interest-transition callback (`set_active`) owns spawn/abort. +pub struct HostStatsCollectorService { + ctx: Arc, +} + +impl HostStatsCollectorService { + pub fn new( + cfg: HostStatsCollectorConfig, + registry: freshell_terminal::TerminalRegistry, + interest: HostStatsInterestRegistry, + boot_anchor: Instant, + ) -> Self { + let machine_info = readers::read_machine_info(&cfg.proc_root, &cfg.sys_root); + let machine = HostStatsMachine { + cores: machine_info.cores, + mem_total_bytes: machine_info.mem_total_bytes, + platform: machine_info.platform, + wsl: machine_info.wsl, + kernel: machine_info.kernel, + hostname: machine_info.hostname, + psi: machine_info.psi, + cgroup: machine_info.cgroup, + thermal_count: machine_info.thermal_count, + battery_present: machine_info.battery_present, + gpu: machine_info.gpu, + }; + Self { + ctx: Arc::new(CollectorCtx { + share: Share { + live: Mutex::new(zero_live(&machine)), + manual: Mutex::new(None), + prev_cpu: Mutex::new(None), + prev_vmstat: Mutex::new(None), + prev_disks: Mutex::new(None), + prev_net: Mutex::new(None), + lag_samples: Mutex::new(Vec::new()), + cadence: Mutex::new(None), + refresh_flight: Mutex::new(None), + last_refresh_completed: Mutex::new(None), + }, + cfg, + registry, + interest, + boot_anchor, + machine, + scan_runs: AtomicUsize::new(0), + #[cfg(test)] + test_run_panic: AtomicBool::new(false), + }), + } + } + + /// Test-visible cadence state: true while the two-tier cadence + drift + /// sampler JoinHandles are owned (between `set_active(true)` and + /// `set_active(false)`). Only test code reads this (the binary crate's + /// non-test build has no other consumer, hence the allow). + #[allow(dead_code)] + pub fn is_running(&self) -> bool { + self.ctx.share.cadence.lock().unwrap().is_some() + } + + /// Test-support instrumentation: how many process-table scans the + /// refresh path has run (single-flight proof). + #[allow(dead_code)] + pub fn scan_run_count(&self) -> usize { + self.ctx.scan_runs.load(Ordering::SeqCst) + } +} + +// --------------------------------------------------------------------------- +// Cadence internals +// --------------------------------------------------------------------------- + +impl CollectorCtx { + /// The merge view `snapshot()` publishes (ticks write caches; snapshots + /// read caches — never blocks on fresh I/O). + fn snapshot_payload(&self) -> HostStatsSnapshot { + let live = self.share.live.lock().unwrap().clone(); + let manual = self.share.manual.lock().unwrap().clone(); + HostStatsSnapshot { + at: now_ms(), + live, + manual_at: manual.as_ref().map(|(at, _)| *at), + manual: manual.map(|(_, m)| m), + } + } + + /// Push the current snapshot to SUBSCRIBED connections only (the frozen + /// Task 9 delivery contract: the per-connection senders captured at + /// subscribe time; never `broadcast_tx`). + fn deliver_snapshot(&self) { + if !self.interest.any() { + return; + } + let msg = + freshell_protocol::ServerMessage::HostStatsSnapshot(Box::new(self.snapshot_payload())); + for sink in self.interest.senders() { + sink(msg.clone()); + } + } + + /// FAST tier (Node `tickFast`): cpu/load/memory/paging/psi + freshell + /// internals, then the snapshot fan-out (Node emits after fast ticks + /// only; the slow tier is pull-side). + fn tick_fast(&self) { + let at = now_ms(); + let cpu = self.read_cpu_section(at); + let load = self.read_load_section(); + let memory = self.read_memory_section(); + let paging = self.read_paging_section(at); + let psi = self.read_psi_section(); + let freshell = self.read_freshell_section(); + { + let mut live = self.share.live.lock().unwrap(); + live.cpu = cpu; + live.load = load; + live.memory = memory; + live.paging = paging; + live.psi = psi; + live.freshell = freshell; + } + self.deliver_snapshot(); + } + + /// SLOW tier (Node `tickSlow`): cpufreq merges into the cached cpu + /// section; diskstats/netdev/limits are delta'd here. + fn tick_slow(&self) { + let at = now_ms(); + let freq_m_hz = readers::read_cpu_freq_mhz(&self.cfg.sys_root); + let disk_io = self.read_disk_io_section(at); + let network = self.read_network_section(at); + let limits = self.read_limits_section(); + let mut live = self.share.live.lock().unwrap(); + live.cpu.freq_m_hz = freq_m_hz; + live.disk_io = disk_io; + live.network = network; + live.limits = limits; + } + + // ----------------------------------------------------------------- + // Fast-tier sections + // ----------------------------------------------------------------- + + fn read_cpu_section(&self, at: u64) -> HostStatsCpu { + let Some(sample) = readers::read_cpu_times(&self.cfg.proc_root) else { + return zero_cpu(); + }; + let prev = self + .share + .prev_cpu + .lock() + .unwrap() + .replace((at, sample.clone())); + let freq_m_hz = self.share.live.lock().unwrap().cpu.freq_m_hz; + let Some((prev_at, prev_v)) = prev else { + // First tick: no window — null-safe zero rates. + return HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: Some(0.0), + per_core_pct: sample.per_core.iter().map(|_| 0.0).collect(), + freq_m_hz, + }; + }; + if at <= prev_at || sample.total <= prev_v.total { + return HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: Some(0.0), + per_core_pct: sample.per_core.iter().map(|_| 0.0).collect(), + freq_m_hz, + }; + } + let d_total = sample.total - prev_v.total; + HostStatsCpu { + available: true, + usage_pct: clamp_pct((sample.busy - prev_v.busy) / d_total * 100.0), + steal_pct: Some(clamp_pct((sample.steal - prev_v.steal) / d_total * 100.0)), + per_core_pct: sample + .per_core + .iter() + .enumerate() + .map(|(i, core)| { + let Some(before) = prev_v.per_core.get(i) else { + return 0.0; + }; + let d_core_total = core.total - before.total; + if d_core_total <= 0.0 { + 0.0 + } else { + clamp_pct((core.busy - before.busy) / d_core_total * 100.0) + } + }) + .collect(), + freq_m_hz, + } + } + + fn read_load_section(&self) -> HostStatsLoad { + let cores = self.machine.cores; + let Some(load) = readers::read_loadavg(&self.cfg.proc_root) else { + return zero_load(cores); + }; + HostStatsLoad { + available: true, + load1: load.load1, + load5: load.load5, + load15: load.load15, + cores, + } + } + + /// Memory precedence (contract point 2): a FINITE cgroup leaf limit wins + /// outright (source 'cgroup'; total/used/available/limit all from the + /// leaf). Unlimited or absent → host meminfo (source 'host'); a cgroup + /// current is NEVER mixed with a host total. Swap stays host-scoped + /// context either way (no cgroup swap accounting is collected). + fn read_memory_section(&self) -> HostStatsMemory { + let cgroup = readers::read_cgroup_memory(&self.cfg.cgroup_root(), &self.cfg.proc_root); + let meminfo = readers::read_meminfo(&self.cfg.proc_root); + let swap_total_bytes = meminfo.map(|m| m.swap_total_kb * 1024); + let swap_used_bytes = meminfo.map(|m| (m.swap_total_kb - m.swap_free_kb) * 1024); + if let Some(cg) = cgroup { + if let Some(limit) = cg.limit_bytes { + return HostStatsMemory { + available: true, + source: "cgroup".to_string(), + total_bytes: limit, + used_bytes: cg.current_bytes, + available_bytes: limit.saturating_sub(cg.current_bytes), + cgroup_limit_bytes: Some(limit), + swap_total_bytes, + swap_used_bytes, + }; + } + } + if let Some(mem) = meminfo { + let total_bytes = mem.total_kb * 1024; + let available_bytes = mem.avail_kb * 1024; + return HostStatsMemory { + available: true, + source: "host".to_string(), + total_bytes, + used_bytes: total_bytes.saturating_sub(available_bytes), + available_bytes, + cgroup_limit_bytes: None, + swap_total_bytes, + swap_used_bytes, + }; + } + zero_memory() + } + + fn read_paging_section(&self, at: u64) -> HostStatsPaging { + let Some(vm) = readers::read_vmstat(&self.cfg.proc_root) else { + return zero_paging(); + }; + let prev = self.share.prev_vmstat.lock().unwrap().replace((at, vm)); + let oom_kills_total = vm.oom_kill.unwrap_or(0); + let Some((prev_at, prev_v)) = prev else { + return HostStatsPaging { + available: true, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total, + }; + }; + if at <= prev_at { + return HostStatsPaging { + available: true, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total, + }; + } + let dt_sec = (at - prev_at) as f64 / 1000.0; + HostStatsPaging { + available: true, + swap_in_kbps: (vm.pswpin.saturating_sub(prev_v.pswpin) * VMSTAT_PAGE_KB) as f64 + / dt_sec, + swap_out_kbps: (vm.pswpout.saturating_sub(prev_v.pswpout) * VMSTAT_PAGE_KB) as f64 + / dt_sec, + maj_faults_per_sec: vm.pgmajfault.saturating_sub(prev_v.pgmajfault) as f64 / dt_sec, + oom_kills_delta: match (vm.oom_kill, prev_v.oom_kill) { + (Some(cur), Some(before)) => cur.saturating_sub(before), + _ => 0, + }, + oom_kills_total, + } + } + + fn read_psi_section(&self) -> HostStatsPsi { + let Some(psi) = readers::read_psi(&self.cfg.proc_root) else { + return zero_psi(); + }; + HostStatsPsi { + available: true, + cpu_some10: psi.cpu_some10, + mem_some10: psi.mem_some10, + mem_full10: psi.mem_full10, + io_some10: psi.io_some10, + io_full10: psi.io_full10, + } + } + + fn read_freshell_section(&self) -> HostStatsFreshell { + HostStatsFreshell { + available: true, + source: "rust".to_string(), + // The diag.rs access pattern: the live inventory length. + ptys_running: self.registry.inventory().len() as u64, + ptys_max: 0, + ws_clients: self.registry.connection_count() as u64, + ws_clients_max: 0, + event_loop_lag_p99_ms: self.drain_lag_p99_ms(), + rss_bytes: read_self_rss_bytes(), + uptime_sec: self.boot_anchor.elapsed().as_secs_f64(), + } + } + + /// p99 scheduler drift (ms) collected since the previous fast tick; + /// None when unmeasurable (Node histogram parity: drain + reset per fast + /// tick). + fn drain_lag_p99_ms(&self) -> Option { + let mut guard = self.share.lag_samples.lock().unwrap(); + if guard.is_empty() { + return None; + } + let mut samples = std::mem::take(&mut *guard); + drop(guard); + samples.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); + // nearest-rank p99 + let rank = ((0.99 * samples.len() as f64).ceil() as usize).clamp(1, samples.len()); + let value = samples[rank - 1]; + (value.is_finite() && value >= 0.0).then_some(value) + } + + // ----------------------------------------------------------------- + // Slow-tier sections + // ----------------------------------------------------------------- + + fn read_disk_io_section(&self, at: u64) -> HostStatsDiskIo { + let Some(devs) = readers::read_disk_stats(&self.cfg.proc_root) else { + return zero_disk_io(); + }; + let prev = self + .share + .prev_disks + .lock() + .unwrap() + .replace((at, devs.clone())); + let Some((prev_at, prev_v)) = prev else { + return HostStatsDiskIo { + available: true, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }; + }; + if at <= prev_at { + return HostStatsDiskIo { + available: true, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }; + } + let dt_ms = (at - prev_at) as f64; + let dt_sec = dt_ms / 1000.0; + let mut read_bytes = 0u64; + let mut write_bytes = 0u64; + let mut util_pct: Option = None; + let mut weighted_await_ms: Option = None; + for (name, cur) in &devs { + let Some(before) = prev_v.get(name) else { + continue; + }; + read_bytes += cur.read_sectors.saturating_sub(before.read_sectors) * DISK_SECTOR_BYTES; + write_bytes += + cur.written_sectors.saturating_sub(before.written_sectors) * DISK_SECTOR_BYTES; + // Multi-device rule (plan thresholds): worst device wins; util + // can never exceed 100. + let util = clamp_pct( + (cur.time_doing_ios_ms + .saturating_sub(before.time_doing_ios_ms)) as f64 + / dt_ms + * 100.0, + ); + if util_pct.is_none_or(|best| util > best) { + util_pct = Some(util); + let ios = cur.reads_completed.saturating_sub(before.reads_completed) + + cur.writes_completed.saturating_sub(before.writes_completed); + let io_ms = cur.read_ms.saturating_sub(before.read_ms) + + cur.write_ms.saturating_sub(before.write_ms); + weighted_await_ms = if ios > 0 { + Some(io_ms as f64 / ios as f64) + } else { + None + }; + } + } + HostStatsDiskIo { + available: true, + read_bps: read_bytes as f64 / dt_sec, + write_bps: write_bytes as f64 / dt_sec, + util_pct, + weighted_await_ms, + } + } + + fn read_network_section(&self, at: u64) -> HostStatsNetwork { + let Some(net) = readers::read_net_dev(&self.cfg.proc_root) else { + return zero_network(); + }; + let prev = self.share.prev_net.lock().unwrap().replace((at, net)); + let totals = |rx_bps: f64, tx_bps: f64, deltas: (u64, u64, u64, u64)| HostStatsNetwork { + available: true, + rx_bps, + tx_bps, + rx_errors_total: net.rx_err, + tx_errors_total: net.tx_err, + rx_dropped_total: net.rx_drop, + tx_dropped_total: net.tx_drop, + rx_errors_delta: deltas.0, + tx_errors_delta: deltas.1, + rx_dropped_delta: deltas.2, + tx_dropped_delta: deltas.3, + }; + let Some((prev_at, prev_v)) = prev else { + return totals(0.0, 0.0, (0, 0, 0, 0)); + }; + if at <= prev_at { + return totals(0.0, 0.0, (0, 0, 0, 0)); + } + let dt_sec = (at - prev_at) as f64 / 1000.0; + totals( + net.rx_bytes.saturating_sub(prev_v.rx_bytes) as f64 / dt_sec, + net.tx_bytes.saturating_sub(prev_v.tx_bytes) as f64 / dt_sec, + ( + net.rx_err.saturating_sub(prev_v.rx_err), + net.tx_err.saturating_sub(prev_v.tx_err), + net.rx_drop.saturating_sub(prev_v.rx_drop), + net.tx_drop.saturating_sub(prev_v.tx_drop), + ), + ) + } + + fn read_limits_section(&self) -> HostStatsLimits { + let proc_root = &self.cfg.proc_root; + let fds_used = readers::read_self_fd_count(proc_root); + let fds_max = readers::read_self_limits_fds_max(proc_root); + let pids_used = readers::read_pid_count(proc_root); + let pids_max = readers::read_pids_limit(proc_root, &self.cfg.cgroup_root()); + let time_wait = readers::read_tcp_state_counts(proc_root).map(|t| t.time_wait); + let ephemeral_ports = + readers::read_ephemeral_port_range(proc_root).map(|r| r.end - r.start + 1); + if fds_used.is_none() + && fds_max.is_none() + && pids_used.is_none() + && pids_max.is_none() + && time_wait.is_none() + && ephemeral_ports.is_none() + { + return zero_limits(); + } + HostStatsLimits { + available: true, + fds_used, + fds_max, + pids_used, + pids_max, + time_wait, + ephemeral_ports, + } + } +} + +/// `/proc/self/statm` resident pages × page size (Node +/// `process.memoryUsage().rss`). A REAL self-read independent of the injected +/// proc root (same as Node's). +#[cfg(unix)] +fn read_self_rss_bytes() -> Option { + let text = std::fs::read_to_string("/proc/self/statm").ok()?; + let resident_pages: u64 = text.split_whitespace().nth(1)?.parse().ok()?; + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + if page_size <= 0 { + return None; + } + Some(resident_pages.saturating_mul(page_size as u64)) +} + +/// Non-unix: no RSS source on this Rust path (nullable by contract). +#[cfg(not(unix))] +fn read_self_rss_bytes() -> Option { + None +} + +impl HostStatsCollector for HostStatsCollectorService { + fn snapshot(&self) -> HostStatsSnapshot { + self.ctx.snapshot_payload() + } + + fn refresh(&self, deadline: Duration) -> HostStatsRefreshFuture<'_> { + let ctx = Arc::clone(&self.ctx); + Box::pin(async move { + // Connection-AGNOSTIC post-completion cooldown (Node + // REFRESH_MIN_INTERVAL_MS — separate from terminal.rs's + // per-connection floor). + { + let last = ctx.share.last_refresh_completed.lock().unwrap(); + if let Some(t) = *last { + if t.elapsed() < ctx.cfg.refresh_cooldown { + return Err("rate_limited".to_string()); + } + } + } + let mut rx = { + let mut flight = ctx.share.refresh_flight.lock().unwrap(); + if let Some(rx) = flight.clone() { + rx + } else { + let (tx, rx) = tokio::sync::watch::channel(None); + *flight = Some(rx.clone()); + // The COLLECTOR owns the run (Node parity: the service owns + // pendingRefresh independent of any requesting socket): the + // refresh runs as the collector's own spawned task, and + // every caller — the leader included — merely awaits a + // receiver. A leader connection tearing down mid-flight + // cancels NOTHING: the run still completes, the completion + // stamps land unconditionally, and every waiter gets the + // wire. + // + // Stamp the cooldown + free the flight slot BEFORE waking + // the waiters: a waiter whose next move is an immediate + // re-refresh must see the cooldown and never re-run. + let run_ctx = Arc::clone(&ctx); + tokio::spawn(async move { + // Declared first so a panic anywhere below unwinds + // through this guard (its Drop frees the flight + // slot); locals drop before the moved-in `tx`, so + // waiters only wake AFTER the slot is free again. + let mut guard = RefreshFlightGuard::new(&run_ctx.share); + let result = run_refresh(&run_ctx, deadline).await; + *run_ctx.share.last_refresh_completed.lock().unwrap() = + Some(Instant::now()); + *run_ctx.share.refresh_flight.lock().unwrap() = None; + guard.disarm(); + let _ = tx.send(Some(result)); + }); + rx + } + }; + loop { + if let Some(wire) = rx.borrow().clone() { + return wire; + } + if rx.changed().await.is_err() { + // Only reachable if the collector's own run task vanished + // without completing (runtime teardown/panic — run_refresh + // never fails for data reasons). + return Err("refresh run vanished".to_string()); + } + } + }) + } + + fn set_active(&self, active: bool) { + let mut cadence = self.ctx.share.cadence.lock().unwrap(); + if active { + if cadence.is_some() { + return; // idempotent + } + // ONE immediate fast tick (Node start() parity: a fresh + // subscriber gets a shaped snapshot at once). Sync reader calls; + // holding the cadence lock across them is safe (disjoint mutexes). + self.ctx.tick_fast(); + let fast_ctx = Arc::clone(&self.ctx); + let fast = tokio::spawn(async move { + let mut ticker = tokio::time::interval(fast_ctx.cfg.fast); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + // setInterval never fires at t=0; the inline tick already ran. + ticker.tick().await; + loop { + ticker.tick().await; + fast_ctx.tick_fast(); + } + }); + let slow_ctx = Arc::clone(&self.ctx); + let slow = tokio::spawn(async move { + let mut ticker = tokio::time::interval(slow_ctx.cfg.slow); + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + ticker.tick().await; + loop { + ticker.tick().await; + slow_ctx.tick_slow(); + } + }); + let drift_ctx = Arc::clone(&self.ctx); + let drift = tokio::spawn(async move { + let interval = drift_ctx.cfg.drift_sample_interval; + let mut last = Instant::now(); + loop { + tokio::time::sleep(interval).await; + let now = Instant::now(); + let drift_ms = now.duration_since(last).as_secs_f64() * 1000.0 + - interval.as_secs_f64() * 1000.0; + last = now; + if drift_ms.is_finite() && drift_ms > 0.0 { + drift_ctx.share.lag_samples.lock().unwrap().push(drift_ms); + } + } + }); + *cadence = Some(CadenceHandles { fast, slow, drift }); + } else if let Some(handles) = cadence.take() { + handles.fast.abort(); + handles.slow.abort(); + handles.drift.abort(); + } + } +} + +// --------------------------------------------------------------------------- +// On-request refresh (manual sections) +// --------------------------------------------------------------------------- + +/// Panic-safety for the collector-owned refresh run (Node parity: `service.ts` +/// wraps `runRefresh()` in `.finally(() => { pendingRefresh = null; +/// lastRefreshCompletedAt = nowFn() })`, which runs even when the run +/// THROWS). Constructed as the first statement of the spawned run — the +/// earliest point after the flight slot is occupied. If the run dies without +/// completing (a panic unwinds the spawned task), Drop frees the flight slot +/// and stamps the cooldown, so every later refresh() starts a FRESH run +/// instead of joining a dead channel forever. The manual cache is NEVER +/// touched here — a run that did not complete has no data to cache. A normal +/// completion stamps + clears explicitly (in the stamp-then-clear-then-send +/// order waiters rely on) and then disarms the guard. +struct RefreshFlightGuard<'a> { + share: &'a Share, + armed: bool, +} + +impl<'a> RefreshFlightGuard<'a> { + fn new(share: &'a Share) -> Self { + Self { share, armed: true } + } + + /// The run completed and already performed the finalize itself. + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for RefreshFlightGuard<'_> { + fn drop(&mut self) { + if self.armed { + // The run died mid-flight (panic-unwind): Node .finally parity — + // free the slot AND stamp the cooldown. Never the manual cache. + *self.share.last_refresh_completed.lock().unwrap() = Some(Instant::now()); + *self.share.refresh_flight.lock().unwrap() = None; + } + } +} + +/// `(total_bytes, free_bytes, used_pct, inodes_total, inodes_free)`. +/// `free_bytes` is the unprivileged view (`bavail`); inodes are None when the +/// filesystem reports 0 total (some report 0/0 by design). +type StatfsInfo = (u64, u64, f64, Option, Option); + +/// `fs.statfs` on a mount. Node `statfsInfo` parity; unix-only on this Rust path. +#[cfg(unix)] +fn statfs_info(mount: &str) -> Option { + let c_path = std::ffi::CString::new(mount).ok()?; + let mut stats: libc::statfs = unsafe { std::mem::zeroed() }; + if unsafe { libc::statfs(c_path.as_ptr(), &mut stats) } != 0 { + return None; + } + let bsize = stats.f_bsize as u64; + let blocks = stats.f_blocks as u64; + let bavail = stats.f_bavail as u64; + let files = stats.f_files as u64; + let ffree = stats.f_ffree as u64; + let total_bytes = bsize * blocks; + let free_bytes = bsize * bavail; + let used_pct = if blocks > 0 { + (1.0 - bavail as f64 / blocks as f64) * 100.0 + } else { + 0.0 + }; + // inodes from files/ffree; some filesystems report 0/0 -> None + let inodes_total = (files > 0).then_some(files); + let inodes_free = (files > 0).then_some(ffree); + Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) +} + +#[cfg(not(unix))] +fn statfs_info(_mount: &str) -> Option { + None +} + +/// How the scan arm resolves (drives BOTH `topProcesses` and +/// `processHealth`, mirroring the Node sections' shared scan promise). +enum ScanOutcome { + Completed(Option), + /// Cooperative per-pid deadline tripped (Node DeadlineExceeded). + SectionDeadline, + /// Overall watchdog preempted a still-running scan. + Watchdog, +} + +/// Node's overall-watchdog section-error payload (the `DeadlineExceeded` +/// message in `service.ts` runRefresh's watchdog promise). +const REFRESH_WATCHDOG_MSG: &str = "host-stats refresh overall budget exceeded"; + +/// The overall-watchdog verdict for a non-scan refresh section arm (Node +/// `Promise.race([section.run(), watchdog])` settling with the watchdog): +/// the section keeps its zero-shape and gains the watchdog sectionErrors +/// entry. The entry check and a mid-flight timeout are the same race. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SectionWatchdogFired; + +/// Race one refresh section arm against the overall watchdog. Sync-reader +/// arms never yield, so a plain `timeout_at` wrapper's immediately-ready +/// inner future would win the race even against an ALREADY-exhausted +/// deadline (tokio observes an expired timer on a driver turn, which an +/// instant section beats). Check the clock at arm ENTRY: a section whose +/// turn comes after the watchdog fired (its first poll was delayed past the +/// budget, e.g. by an earlier arm's sync work on the same executor task) +/// degrades WITHOUT running its reads — exactly how a section's race +/// settles in Node when the watchdog promise has already rejected. The +/// `timeout_at` wrapper still covers a section that runs past the budget. +async fn race_section_watchdog( + overall_deadline: tokio::time::Instant, + work: impl std::future::Future, +) -> Result { + match tokio::time::timeout_at(overall_deadline, async { + if tokio::time::Instant::now() >= overall_deadline { + None + } else { + Some(work.await) + } + }) + .await + { + Ok(Some(value)) => Ok(value), + Ok(None) | Err(_) => Err(SectionWatchdogFired), + } +} + +/// One refresh run: sections race under a shared absolute cooperative +/// deadline (`started + deadline`, the trait argument — Node +/// `sectionBudgetMs`) and EVERY section arm races the overall watchdog +/// (`started + overall_budget`, Node `overallBudgetMs`). Never fails for +/// data reasons. +async fn run_refresh(ctx: &Arc, deadline: Duration) -> RefreshWire { + let started = Instant::now(); + let section_deadline = started + deadline; + let overall_deadline = tokio::time::Instant::from_std(started + ctx.cfg.overall_budget); + + let scan_ctx = Arc::clone(ctx); + let scan_fut = async move { + scan_ctx.scan_runs.fetch_add(1, Ordering::SeqCst); + #[cfg(test)] + if scan_ctx.test_run_panic.swap(false, Ordering::SeqCst) { + // Test-injected run death: the run task unwinds from here — + // no completion stamp, no slot clear, no cache write, no send. + panic!("test-injected refresh run death"); + } + match tokio::time::timeout_at( + overall_deadline, + scan_process_table(&scan_ctx.cfg.proc_root, PROC_SCAN_DWELL, section_deadline), + ) + .await + { + Ok(Ok(scan)) => ScanOutcome::Completed(scan), + Ok(Err(ScanError::DeadlineExceeded)) => ScanOutcome::SectionDeadline, + Err(_elapsed) => ScanOutcome::Watchdog, + } + }; + let inotify_ctx = Arc::clone(ctx); + let inotify_fut = async move { + let work = async move { + let usage = readers::read_self_inotify_stats(&inotify_ctx.cfg.proc_root); + let limits = readers::read_inotify_limits(&inotify_ctx.cfg.proc_root); + (usage, limits) + }; + race_section_watchdog(overall_deadline, work).await + }; + let disks_fut = async move { + let work = async move { + // Node: darwin mounts ['/'], else ['/', '/dev/shm']. + let mounts: &[&str] = if cfg!(target_os = "macos") { + &["/"] + } else if cfg!(target_os = "windows") { + &[] + } else { + &["/", "/dev/shm"] + }; + let mut list = Vec::new(); + for mount in mounts { + if let Some((total_bytes, free_bytes, used_pct, inodes_total, inodes_free)) = + statfs_info(mount) + { + list.push(HostStatsDisk { + mount: mount.to_string(), + total_bytes, + free_bytes, + used_pct, + inodes_total, + inodes_free, + }); + } + } + list + }; + race_section_watchdog(overall_deadline, work).await + }; + let thermals_ctx = Arc::clone(ctx); + let thermals_fut = async move { + let work = async move { + let zones = readers::read_thermals(&thermals_ctx.cfg.sys_root); + let battery = readers::read_battery(&thermals_ctx.cfg.sys_root); + (zones, battery) + }; + race_section_watchdog(overall_deadline, work).await + }; + + let (scan_out, inotify_out, disks_out, thermals_out) = + tokio::join!(scan_fut, inotify_fut, disks_fut, thermals_fut); + + let mut manual = zero_manual(); + let mut section_errors = HashMap::new(); + + match scan_out { + ScanOutcome::Completed(Some(scan)) => { + manual.top_processes = HostStatsTopProcesses { + available: true, + dwell_ms: PROC_SCAN_DWELL.as_millis() as u64, + list: scan + .top + .into_iter() + .map(|p| HostStatsTopProcess { + pid: p.pid, + name: p.name, + cpu_pct: p.cpu_pct, + rss_bytes: p.rss_bytes, + state: p.state, + }) + .collect(), + }; + manual.process_health = HostStatsProcessHealth { + available: true, + zombies: scan.zombies, + d_state: scan.d_state, + total: scan.total, + }; + } + ScanOutcome::Completed(None) => { + // Missing proc root: degraded WITHOUT an error entry (Node parity: + // `if (!table) return zeroManualSection(key)`). + } + ScanOutcome::SectionDeadline => { + section_errors.insert( + "topProcesses".to_string(), + ScanError::DeadlineExceeded.message().to_string(), + ); + section_errors.insert( + "processHealth".to_string(), + ScanError::DeadlineExceeded.message().to_string(), + ); + } + ScanOutcome::Watchdog => { + let msg = REFRESH_WATCHDOG_MSG.to_string(); + section_errors.insert("topProcesses".to_string(), msg.clone()); + section_errors.insert("processHealth".to_string(), msg); + } + } + + // A watchdog-losing non-scan section keeps the zero-shape already in + // place (zero_manual) and adds ONLY the sectionErrors entry — the same + // degradation Node's race produces for that key. + match inotify_out { + Ok((usage, limits)) => { + if usage.is_some() || limits.is_some() { + manual.inotify = HostStatsInotify { + available: true, + instances: usage.map(|u| u.instances), + watches: usage.map(|u| u.watches), + max_user_watches: limits.and_then(|l| l.max_user_watches), + max_user_instances: limits.and_then(|l| l.max_user_instances), + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("inotify".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } + } + + match disks_out { + Ok(disk_list) => { + if !disk_list.is_empty() { + manual.disks = HostStatsDisks { + available: true, + list: disk_list, + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("disks".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } + } + + match thermals_out { + Ok((zones, battery)) => { + if let Some(zones) = zones { + manual.thermals = HostStatsThermals { + available: true, + zones: zones + .into_iter() + .map(|z| HostStatsThermalZone { + label: z.label, + celsius: z.celsius, + }) + .collect(), + battery: battery.map(|b| HostStatsBattery { + pct: b.pct, + status: b.status, + }), + }; + } + } + Err(SectionWatchdogFired) => { + section_errors.insert("thermals".to_string(), REFRESH_WATCHDOG_MSG.to_string()); + } + } + + manual.section_errors = section_errors; + let at = now_ms(); + *ctx.share.manual.lock().unwrap() = Some((at, manual.clone())); + // Merged snapshot: live may be one tick stale, manual/manualAt are fresh + // (contract point 9) — and subscribers see it (Node emitSnapshot). + ctx.deliver_snapshot(); + Ok(HostStatsRefreshOk { at, manual }) +} + +// --------------------------------------------------------------------------- +// On-request process-table scan (the ONLY async reader family; the dwell is +// why the pure `/proc/` parsers live in freshell-platform but this loop +// lives here — freshell-platform is deliberately tokio-free) +// --------------------------------------------------------------------------- + +/// A scanned process row (mirrors Node's `ProcessSample`). +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessSampleR { + pub pid: u64, + pub name: String, + pub cpu_pct: f64, + pub rss_bytes: u64, + pub state: String, +} + +/// The scan outcome (mirrors Node's `ProcessTableScan`). +#[derive(Debug, Clone, PartialEq)] +pub struct ProcessTableScan { + pub top: Vec, + pub zombies: u64, + pub d_state: u64, + pub total: u64, +} + +/// The scan's only sanctioned failure (Node `DeadlineExceeded`): the shared +/// absolute section budget was exhausted mid-scan. All other failures +/// (missing root, vanished pid, truncated stat) degrade to `Ok(None)` / +/// per-pid skips. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScanError { + DeadlineExceeded, +} + +impl ScanError { + /// The exact Node `DeadlineExceeded` section-error message + /// (`sectionErrors[key]` payload parity). + pub fn message(&self) -> &'static str { + "host-stats section deadline exceeded" + } +} + +/// On-request process table scan: enumerate numeric `` dirs (cap +/// 100k), sample utime+stime (A), dwell, sample again (B) + status VmRSS; +/// cpuPct from the jiffy delta. `deadline` is an ABSOLUTE monotonic budget +/// (the section's cooperative deadline), checked BEFORE each pid's unit of +/// work; on expiry this returns `Err(ScanError::DeadlineExceeded)`. +async fn scan_process_table( + proc_root: &std::path::Path, + dwell: Duration, + deadline: Instant, +) -> Result, ScanError> { + let Some(pids) = readers::list_numeric_pids(proc_root) else { + return Ok(None); + }; + // total = numeric /proc entries discovered (enumeration truth), + // independent of per-pid parse health. + let total = pids.len() as u64; + let mut sample_a: HashMap = HashMap::new(); + let mut zombies = 0u64; + let mut d_state = 0u64; + for pid in &pids { + if Instant::now() > deadline { + return Err(ScanError::DeadlineExceeded); + } + // truncated/vanished -> process skipped, never thrown + let Some(text) = readers::read_pid_file_bounded(proc_root, *pid, "stat") else { + continue; + }; + let Some(parsed) = readers::parse_proc_pid_stat(&text) else { + continue; + }; + if parsed.state == "Z" { + zombies += 1; + } + if parsed.state == "D" { + d_state += 1; + } + sample_a.insert(*pid, parsed); + } + + tokio::time::sleep(dwell).await; + + let cores = std::thread::available_parallelism() + .map(|n| n.get() as u64) + .unwrap_or(1); + let mut top: Vec = Vec::new(); + for (pid, before) in &sample_a { + if Instant::now() > deadline { + return Err(ScanError::DeadlineExceeded); + } + let Some(stat_text) = readers::read_pid_file_bounded(proc_root, *pid, "stat") else { + continue; + }; + let Some(after) = readers::parse_proc_pid_stat(&stat_text) else { + continue; + }; + let rss_kb = readers::read_pid_file_bounded(proc_root, *pid, "status") + .and_then(|text| readers::parse_status_vm_rss_kb(&text)); + top.push(ProcessSampleR { + pid: *pid, + name: after.name, + cpu_pct: readers::compute_cpu_pct( + after.busy_jiffies as f64 - before.busy_jiffies as f64, + dwell.as_millis() as u64, + cores, + ), + rss_bytes: rss_kb.unwrap_or(0) * 1024, + state: after.state, + }); + } + top.sort_by(|a, b| { + b.cpu_pct + .partial_cmp(&a.cpu_pct) + .unwrap_or(std::cmp::Ordering::Equal) + }); + top.truncate(TOP_PROCESS_COUNT); + Ok(Some(ProcessTableScan { + top, + zombies, + d_state, + total, + })) +} + +// --------------------------------------------------------------------------- +// Zero shapes (mirror of the Node LIVE_SECTION_ZERO / zeroManualSection tree; +// every degraded section reports `available:false` with the SAME otherwise- +// zero payload, so the client renders the em-dash family) +// --------------------------------------------------------------------------- + +fn zero_cpu() -> HostStatsCpu { + HostStatsCpu { + available: false, + usage_pct: 0.0, + steal_pct: None, + per_core_pct: Vec::new(), + freq_m_hz: None, + } +} + +fn zero_load(cores: u64) -> HostStatsLoad { + HostStatsLoad { + available: false, + load1: 0.0, + load5: 0.0, + load15: 0.0, + cores, + } +} + +fn zero_memory() -> HostStatsMemory { + HostStatsMemory { + available: false, + source: "host".to_string(), + total_bytes: 0, + used_bytes: 0, + available_bytes: 0, + cgroup_limit_bytes: None, + swap_total_bytes: None, + swap_used_bytes: None, + } +} + +fn zero_paging() -> HostStatsPaging { + HostStatsPaging { + available: false, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total: 0, + } +} + +fn zero_psi() -> HostStatsPsi { + HostStatsPsi { + available: false, + cpu_some10: None, + mem_some10: None, + mem_full10: None, + io_some10: None, + io_full10: None, + } +} + +fn zero_disk_io() -> HostStatsDiskIo { + HostStatsDiskIo { + available: false, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + } +} + +fn zero_network() -> HostStatsNetwork { + HostStatsNetwork { + available: false, + rx_bps: 0.0, + tx_bps: 0.0, + rx_errors_total: 0, + tx_errors_total: 0, + rx_dropped_total: 0, + tx_dropped_total: 0, + rx_errors_delta: 0, + tx_errors_delta: 0, + rx_dropped_delta: 0, + tx_dropped_delta: 0, + } +} + +fn zero_limits() -> HostStatsLimits { + HostStatsLimits { + available: false, + fds_used: None, + fds_max: None, + pids_used: None, + pids_max: None, + time_wait: None, + ephemeral_ports: None, + } +} + +fn zero_freshell() -> HostStatsFreshell { + HostStatsFreshell { + available: false, + source: "rust".to_string(), + ptys_running: 0, + // LB9 (frozen): freshell-ws has NO connection cap and the Rust spawn + // gate is a concurrency gate, not a PTY-count cap — both maxes are 0 + // (client renders '—'). + ptys_max: 0, + ws_clients: 0, + ws_clients_max: 0, + event_loop_lag_p99_ms: None, + rss_bytes: None, + uptime_sec: 0.0, + } +} + +fn zero_live(machine: &HostStatsMachine) -> HostStatsLive { + HostStatsLive { + machine: machine.clone(), + cpu: zero_cpu(), + load: zero_load(machine.cores), + memory: zero_memory(), + paging: zero_paging(), + psi: zero_psi(), + disk_io: zero_disk_io(), + network: zero_network(), + limits: zero_limits(), + freshell: zero_freshell(), + } +} + +fn zero_manual() -> HostStatsManual { + HostStatsManual { + top_processes: HostStatsTopProcesses { + available: false, + dwell_ms: 0, + list: Vec::new(), + }, + process_health: HostStatsProcessHealth { + available: false, + zombies: 0, + d_state: 0, + total: 0, + }, + inotify: HostStatsInotify { + available: false, + instances: None, + watches: None, + max_user_watches: None, + max_user_instances: None, + }, + disks: HostStatsDisks { + available: false, + list: Vec::new(), + }, + thermals: HostStatsThermals { + available: false, + zones: Vec::new(), + battery: None, + }, + section_errors: HashMap::new(), + } +} + +// =========================================================================== +// Task 9 behavioral tests. These call the REAL production surface (they were +// authored RED-first against the compiling skeleton — runtime assertion +// failures/`unimplemented!()` panics, never compile errors). Fixture bytes are +// the intentional duplication of `test/fixtures/host-stats/` (plan step 5: +// ports drift independently). +// =========================================================================== +#[cfg(test)] +mod tests { + use super::*; + use freshell_platform::host_stats_readers as readers; + use freshell_protocol::{HostStatsBattery, ServerMessage}; + use freshell_terminal::FrameSink; + use std::path::{Path, PathBuf}; + use std::sync::Mutex as StdMutex; + + fn fixtures() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/host-stats") + } + fn proc_fixture() -> PathBuf { + fixtures().join("proc") + } + fn procmini_fixture() -> PathBuf { + fixtures().join("procmini") + } + fn sys_fixture() -> PathBuf { + fixtures().join("sys") + } + fn cgroup_fixture() -> PathBuf { + sys_fixture().join("fs").join("cgroup") + } + fn missing() -> PathBuf { + fixtures().join("never-existed") + } + + fn test_config(proc_root: PathBuf, sys_root: PathBuf) -> HostStatsCollectorConfig { + HostStatsCollectorConfig { + proc_root, + sys_root, + fast: Duration::from_millis(25), + slow: Duration::from_millis(50), + ..Default::default() + } + } + + fn test_collector( + proc_root: PathBuf, + sys_root: PathBuf, + interest: &HostStatsInterestRegistry, + ) -> HostStatsCollectorService { + HostStatsCollectorService::new( + test_config(proc_root, sys_root), + freshell_terminal::TerminalRegistry::new(), + interest.clone(), + Instant::now(), + ) + } + + async fn wait_until(timeout: Duration, mut predicate: impl FnMut() -> bool) -> bool { + let start = Instant::now(); + loop { + if predicate() { + return true; + } + if start.elapsed() >= timeout { + return false; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + } + + /// Copy a fixture tree into a fresh tmpdir (the process-scan overlay then + /// adds a truncated-stat pid). Mirrors the Node suite's beforeAll tmp + /// overlays (symlinks/empty dirs cannot be committed to git). + fn copy_tree(src: &Path, dst: &Path) { + std::fs::create_dir_all(dst).unwrap(); + for entry in std::fs::read_dir(src).unwrap() { + let entry = entry.unwrap(); + let to = dst.join(entry.file_name()); + if entry.file_type().unwrap().is_dir() { + copy_tree(&entry.path(), &to); + } else { + std::fs::copy(entry.path(), &to).unwrap(); + } + } + } + + fn scan_proc_overlay(tmp: &Path) -> PathBuf { + let scan = tmp.join("scan-proc"); + copy_tree(&procmini_fixture(), &scan); + let broken = scan.join("999"); + std::fs::create_dir_all(&broken).unwrap(); + std::fs::write(broken.join("stat"), "999 (broken").unwrap(); + std::fs::write( + broken.join("status"), + "Name:\tbroken\nVmRSS:\t 1234 kB\n", + ) + .unwrap(); + scan + } + + // ----------------------------------------------------------------- + // Fixture readers (Task 2 semantics pinned against the duplicated + // fixture bytes — Node suite: test/unit/server/host-stats/readers.test.ts) + // ----------------------------------------------------------------- + + #[test] + fn host_stats_fixture_cpu_times_parse_exact() { + let times = readers::read_cpu_times(&proc_fixture()).expect("fixture stat parses"); + assert_eq!(times.total, 174236.0); + assert_eq!(times.busy, 7885.0); + assert_eq!(times.steal, 777.0); // steal>0 is a fixture requirement + assert_eq!(times.per_core.len(), 16); + assert_eq!( + times.per_core[0], + readers::CpuCoreTimes { + total: 10645.0, + busy: 495.0 + } + ); + assert!(readers::read_cpu_times(&missing()).is_none()); + } + + #[test] + fn host_stats_fixture_load_meminfo_vmstat_psi_parse_exact() { + let load = readers::read_loadavg(&proc_fixture()).expect("loadavg"); + assert_eq!( + load, + readers::LoadAvg { + load1: 0.5, + load5: 1.0, + load15: 1.2 + } + ); + let mem = readers::read_meminfo(&proc_fixture()).expect("meminfo"); + assert_eq!( + mem, + readers::MeminfoKb { + total_kb: 67108864, + avail_kb: 33554432, + swap_total_kb: 8388608, + swap_free_kb: 7340032, + } + ); + let vm = readers::read_vmstat(&proc_fixture()).expect("vmstat"); + assert_eq!(vm.pswpin, 1234); + assert_eq!(vm.pswpout, 5678); + assert_eq!(vm.pgmajfault, 890); + assert_eq!(vm.oom_kill, Some(3)); + let psi = readers::read_psi(&proc_fixture()).expect("psi"); + assert_eq!(psi.cpu_some10, Some(1.23)); + assert_eq!(psi.mem_some10, Some(0.5)); + assert_eq!(psi.mem_full10, Some(0.3)); + assert_eq!(psi.io_some10, Some(2.5)); + assert_eq!(psi.io_full10, Some(1.0)); + // procmini has no pressure/ dir -> PSI absent (not per-file nulls). + assert!(readers::read_psi(&procmini_fixture()).is_none()); + } + + #[test] + fn host_stats_fixture_cgroup_memory_leaf_resolution() { + // Committed v2 leaf: memory.max = 'max' (freshell itself runs in an + // unlimited cgroup) -> limit None. + let leaf = readers::read_cgroup_memory(&cgroup_fixture(), &procmini_fixture()) + .expect("v2 leaf resolves"); + assert_eq!(leaf.limit_bytes, None); + assert_eq!(leaf.current_bytes, 17000000000); + // The cgroup fs root has NO limit files by design: a cgroup root that + // lacks the leaf tree must NOT fall back to reading the fs root. + let empty = tempfile::tempdir().unwrap(); + assert!(readers::read_cgroup_memory(empty.path(), &procmini_fixture()).is_none()); + // self/cgroup absent -> None (never a panic). + assert!(readers::read_cgroup_memory(&cgroup_fixture(), &missing()).is_none()); + } + + #[test] + fn host_stats_fixture_pids_limit_cgroup_then_threads_max() { + // v2 leaf pids.max wins outright. + assert_eq!( + readers::read_pids_limit(&procmini_fixture(), &cgroup_fixture()), + Some(10854) + ); + // No self/cgroup (full proc fixture) -> threads-max fallback. + assert_eq!( + readers::read_pids_limit(&proc_fixture(), &cgroup_fixture()), + Some(123456) + ); + // pid_max is a wrap boundary, NEVER the cap. + let tmp = tempfile::tempdir().unwrap(); + let pid_max_only = tmp.path().join("pid-max-only").join("proc"); + std::fs::create_dir_all(pid_max_only.join("sys/kernel")).unwrap(); + std::fs::write(pid_max_only.join("sys/kernel/pid_max"), "4194304\n").unwrap(); + assert_eq!( + readers::read_pids_limit(&pid_max_only, &cgroup_fixture()), + None + ); + } + + #[test] + fn host_stats_fixture_disk_net_tcp_limits_parse_exact() { + let disks = readers::read_disk_stats(&proc_fixture()).expect("diskstats"); + // Whole devices only: partitions and loop devices are filtered out. + assert!(disks.contains_key("sda")); + assert!(disks.contains_key("nvme0n1")); + assert!(!disks.contains_key("sda1")); + assert!(!disks.contains_key("nvme0n1p1")); + assert!(!disks.contains_key("loop0")); + let sda = disks.get("sda").unwrap(); + assert_eq!( + *sda, + readers::DiskCounters { + reads_completed: 5000, + read_ms: 6000, + writes_completed: 2000, + write_ms: 3000, + read_sectors: 400000, + written_sectors: 200000, + time_doing_ios_ms: 4000, + } + ); + let net = readers::read_net_dev(&proc_fixture()).expect("net/dev"); + assert_eq!( + net, + readers::NetDevTotals { + rx_bytes: 7000000, + tx_bytes: 11000000, + rx_err: 9, + tx_err: 16, + rx_drop: 4, + tx_drop: 6, + } + ); + let tcp = readers::read_tcp_state_counts(&proc_fixture()).expect("tcp counts"); + assert_eq!(tcp.time_wait, 3); + let ports = readers::read_ephemeral_port_range(&proc_fixture()).expect("port range"); + assert_eq!((ports.start, ports.end), (32768, 60999)); + assert_eq!( + readers::read_self_limits_fds_max(&proc_fixture()), + Some(1024) + ); + let inotify = readers::read_inotify_limits(&proc_fixture()).expect("inotify limits"); + assert_eq!(inotify.max_user_watches, Some(1048576)); + assert_eq!(inotify.max_user_instances, Some(128)); + assert_eq!(readers::read_pid_count(&procmini_fixture()), Some(7)); + } + + #[test] + fn host_stats_fixture_sysfs_sensors_parse_exact() { + assert_eq!(readers::read_cpu_freq_mhz(&sys_fixture()), Some(3100.0)); + let zones = readers::read_thermals(&sys_fixture()).expect("thermal zones"); + assert_eq!(zones.len(), 1); + assert_eq!(zones[0].label, "x86_pkg_temp"); + assert_eq!(zones[0].celsius, 51.5); + let battery = readers::read_battery(&sys_fixture()).expect("battery"); + assert_eq!(battery.pct, 87.0); + assert_eq!(battery.status, "Discharging"); + assert!(readers::read_thermals(&missing()).is_none()); + assert!(readers::read_battery(&missing()).is_none()); + } + + #[test] + fn host_stats_fixture_machine_info_probes() { + let info = readers::read_machine_info(&procmini_fixture(), &sys_fixture()); + assert_eq!(info.cgroup, "v2"); + assert!(!info.psi); // procmini has no pressure/ dir + assert_eq!(info.thermal_count, 1); + assert!(info.battery_present); + assert_eq!(info.gpu, "none"); + assert!(info.cores >= 1); + // Full proc fixture: psi readable, no self/cgroup -> 'none'. + let full = readers::read_machine_info(&proc_fixture(), &sys_fixture()); + assert!(full.psi); + assert_eq!(full.cgroup, "none"); + } + + #[cfg(unix)] + #[test] + fn host_stats_fixture_inotify_self_stats_readlink_counting() { + // fd readlink fixtures are REAL symlinks built in tmpdir (git cannot + // commit dangling symlinks) — the Node suite's exact overlay. + let tmp = tempfile::tempdir().unwrap(); + let fd_proc = tmp.path().join("fd-proc"); + std::fs::create_dir_all(fd_proc.join("self/fd")).unwrap(); + std::fs::create_dir_all(fd_proc.join("self/fdinfo")).unwrap(); + for fd in [3, 4, 5] { + std::os::unix::fs::symlink( + "anon_inode:inotify", + fd_proc.join("self/fd").join(fd.to_string()), + ) + .unwrap(); + std::fs::copy( + proc_fixture().join("self/fdinfo").join(fd.to_string()), + fd_proc.join("self/fdinfo").join(fd.to_string()), + ) + .unwrap(); + } + std::os::unix::fs::symlink("socket:[12345]", fd_proc.join("self/fd/6")).unwrap(); + std::os::unix::fs::symlink("pipe:[67890]", fd_proc.join("self/fd/7")).unwrap(); + std::os::unix::fs::symlink("/dev/null", fd_proc.join("self/fd/8")).unwrap(); + assert_eq!(readers::read_self_fd_count(&fd_proc), Some(6)); + let usage = readers::read_self_inotify_stats(&fd_proc).expect("inotify usage"); + assert_eq!(usage.instances, 3); + assert_eq!(usage.watches, 6); // fdinfo 3/4/5 carry 2/3/1 inotify lines + } + + // ----------------------------------------------------------------- + // Process-table scan (the collector-owned two-sample + dwell loop over + // the platform's pure pieces) + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_scan_fixture_table_counts_and_names() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let scan = scan_process_table( + &scan_root, + Duration::from_millis(50), + Instant::now() + Duration::from_secs(10), + ) + .await + .expect("fixture scan resolves") + .expect("no deadline"); + // 8 numeric entries enumerated (7 committed + truncated 999). + assert_eq!(scan.total, 8); + assert_eq!(scan.zombies, 1); + assert_eq!(scan.d_state, 1); + // truncated-stat pid 999 is skipped, never fatal. + assert_eq!(scan.top.len(), 7); + assert!(scan.top.iter().all(|p| p.pid != 999)); + let by_pid: HashMap = scan.top.iter().map(|p| (p.pid, p)).collect(); + // comm-with-parens splits after the LAST ')'. + assert_eq!(by_pid[&404].name, "my (weird) proc"); + assert_eq!(by_pid[&404].state, "D"); + assert_eq!(by_pid[&505].state, "Z"); + // rssBytes from status VmRSS kB -> bytes, NOT stat rss pages. + assert_eq!(by_pid[&101].rss_bytes, 12345 * 1024); + // static fixture: sample A == sample B -> zero cpu deltas. + assert!(scan.top.iter().all(|p| p.cpu_pct == 0.0)); + } + + #[tokio::test] + async fn host_stats_scan_deadline_exceeded_is_an_error_never_a_panic() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let result = scan_process_table( + &scan_root, + Duration::ZERO, + Instant::now() - Duration::from_secs(1), + ) + .await; + assert!(matches!(result, Err(ScanError::DeadlineExceeded))); + // Missing proc root -> None (degraded), never an error. + let missing_result = scan_process_table( + &missing(), + Duration::ZERO, + Instant::now() + Duration::from_secs(10), + ) + .await; + assert!(matches!(missing_result, Ok(None))); + } + + // ----------------------------------------------------------------- + // Lifecycle (parity test 1): set_active spawns/aborts the cadence + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_set_active_spawn_abort_lifecycle() { + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + assert!( + !collector.is_running(), + "zero-cost idle before first interest" + ); + collector.set_active(true); + assert!(collector.is_running(), "0->1 interest spawns the cadence"); + collector.set_active(true); + assert!(collector.is_running(), "idempotent re-activate is harmless"); + collector.set_active(false); + assert!(!collector.is_running(), "1->0 interest aborts the cadence"); + collector.set_active(false); + assert!(!collector.is_running(), "idempotent deactivate is harmless"); + // Restart resumes ticking. + collector.set_active(true); + assert!(collector.is_running()); + collector.set_active(false); + } + + #[test] + fn host_stats_snapshot_zero_shape_before_first_tick() { + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + let snap = collector.snapshot(); + assert!(snap.at > 0); + assert!(snap.manual_at.is_none()); + assert!(snap.manual.is_none()); + // machine filled from cheap probes, every section unavailable. + assert_eq!(snap.live.machine.thermal_count, 1); + assert!(!snap.live.cpu.available); + assert!(!snap.live.load.available); + assert!(!snap.live.memory.available); + assert!(!snap.live.paging.available); + assert!(!snap.live.psi.available); + assert!(!snap.live.disk_io.available); + assert!(!snap.live.network.available); + assert!(!snap.live.limits.available); + assert!(!snap.live.freshell.available); + // LB9 frozen: no caps exist on the Rust side — 0 renders '—'. + assert_eq!(snap.live.freshell.ws_clients_max, 0); + assert_eq!(snap.live.freshell.ptys_max, 0); + } + + #[tokio::test] + async fn host_stats_set_active_runs_one_immediate_fast_tick() { + // A fresh subscriber gets a SHAPED snapshot at once (Node start() + // parity): after set_active(true) returns, the live cache holds the + // first tick's null-safe zeros — no wall-clock wait needed. + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + collector.set_active(true); + let live = &collector.snapshot().live; + assert!(live.cpu.available, "first fast tick ran inline"); + assert_eq!(live.cpu.usage_pct, 0.0, "first tick has no delta window"); + assert_eq!(live.cpu.per_core_pct.len(), 16); + assert!(live.load.available); + assert_eq!(live.load.load1, 0.5); + // Memory precedence: no cgroup for the full proc fixture -> host. + assert!(live.memory.available); + assert_eq!(live.memory.source, "host"); + assert_eq!(live.memory.total_bytes, 67108864 * 1024); + assert!(live.paging.available); + assert_eq!(live.paging.oom_kills_total, 3); + assert!(live.psi.available); + assert_eq!(live.psi.cpu_some10, Some(1.23)); + // freshell internals on the first fast tick. + assert!(live.freshell.available); + assert_eq!(live.freshell.source, "rust"); + assert_eq!(live.freshell.ws_clients_max, 0); + assert_eq!(live.freshell.ptys_max, 0); + assert!(live.freshell.uptime_sec >= 0.0); + // Slow-tier sections are STILL zero: the slow tier only ticks on its + // own interval (Node parity). + assert!(!live.disk_io.available); + assert!(!live.limits.available); + collector.set_active(false); + } + + #[tokio::test] + async fn host_stats_cadence_delivers_to_subscribed_conns_only() { + // Frozen delivery contract: snapshots flow ONLY to subscribed + // connections via their per-connection senders — never broadcast_tx. + let interest = HostStatsInterestRegistry::default(); + let delivered = Arc::new(StdMutex::new(Vec::::new())); + let not_watching = Arc::new(StdMutex::new(Vec::::new())); + let watcher_sink: FrameSink = { + let delivered = Arc::clone(&delivered); + Arc::new(move |msg| delivered.lock().unwrap().push(msg)) + }; + let bystander_sink: FrameSink = { + let not_watching = Arc::clone(¬_watching); + Arc::new(move |msg| not_watching.lock().unwrap().push(msg)) + }; + let collector = test_collector(proc_fixture(), sys_fixture(), &interest); + assert_eq!( + interest.set(1, Some(watcher_sink)), + freshell_ws::host_stats_interest::InterestTransition::BecameActive + ); + collector.set_active(true); + let got = wait_until(Duration::from_millis(500), || { + !delivered.lock().unwrap().is_empty() + }) + .await; + collector.set_active(false); + assert!(got, "a subscribed connection receives cadence snapshots"); + { + let frames = delivered.lock().unwrap(); + let first = serde_json::to_value(&frames[0]).unwrap(); + assert_eq!(first["type"], "hoststats.snapshot"); + assert_eq!(first["live"]["freshell"]["source"], "rust"); + assert_eq!(first["live"]["freshell"]["wsClientsMax"], 0); + assert_eq!(first["live"]["freshell"]["ptysMax"], 0); + assert_eq!(first["live"]["memory"]["source"], "host"); + } + // A connection that never subscribed is never touched. (The sink is + // kept alive so the assertion above isn't vacuous.) + let _ = bystander_sink; + assert!( + not_watching.lock().unwrap().is_empty(), + "non-watchers get zero traffic" + ); + // After ->0 interest (abort), no further snapshots arrive. + let count_at_stop = delivered.lock().unwrap().len(); + tokio::time::sleep(Duration::from_millis(120)).await; + assert_eq!(delivered.lock().unwrap().len(), count_at_stop); + } + + // ----------------------------------------------------------------- + // refresh(): single-flight, post-completion cooldown, cooperative budget + // ----------------------------------------------------------------- + + #[tokio::test] + async fn host_stats_refresh_is_single_flight() { + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(scan_root, sys_fixture(), &interest); + let (one, two) = tokio::join!( + collector.refresh(Duration::from_millis(2000)), + collector.refresh(Duration::from_millis(2000)) + ); + let one = one.expect("leader refresh succeeds"); + let two = two.expect("joiner refresh succeeds"); + assert_eq!( + collector.scan_run_count(), + 1, + "one scan serves both callers" + ); + assert_eq!(one, two, "the joiner gets the leader's exact result"); + // The fixture scan powered both process sections. + assert!(one.manual.top_processes.available); + assert_eq!(one.manual.top_processes.list.len(), 7); + assert!(one.manual.process_health.available); + assert_eq!(one.manual.process_health.zombies, 1); + assert_eq!(one.manual.process_health.d_state, 1); + assert_eq!(one.manual.process_health.total, 8); + // thermals from the injected sys root. + assert!(one.manual.thermals.available); + assert_eq!(one.manual.thermals.zones[0].label, "x86_pkg_temp"); + assert_eq!( + one.manual.thermals.battery, + Some(HostStatsBattery { + pct: 87.0, + status: "Discharging".to_string() + }) + ); + // Empty success: no section failed (procmini has no inotify sysctls, + // so that section is zero WITHOUT an error entry — Node parity). + assert!(one.manual.section_errors.is_empty()); + assert!(!one.manual.inotify.available); + // The merged snapshot now carries the manual cache. + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(one.at)); + assert_eq!(snap.manual, Some(one.manual)); + } + + #[tokio::test] + async fn host_stats_refresh_post_completion_cooldown_rate_limited() { + // Parity test 2: the connection-AGNOSTIC 1s post-completion cooldown + // (Instant-controlled; test shortens the cooldown and proves the floor + // + the allow-again sides with short real sleeps). + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + cfg.refresh_cooldown = Duration::from_millis(150); + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + assert!(collector.refresh(Duration::from_millis(2000)).await.is_ok()); + let limited = collector.refresh(Duration::from_millis(2000)).await; + assert_eq!(limited, Err("rate_limited".to_string())); + // Single-flight is NOT the cooldown: the first completed already. + assert_eq!(collector.scan_run_count(), 1); + tokio::time::sleep(Duration::from_millis(250)).await; + let again = collector.refresh(Duration::from_millis(2000)).await; + assert!(again.is_ok(), "the floor lifts after the cooldown window"); + assert_eq!(collector.scan_run_count(), 2); + } + + #[tokio::test] + async fn host_stats_refresh_section_budget_degrades_scan_sections_only() { + // Cooperative budget: an already-exhausted shared absolute deadline + // marks ONLY the scan sections failed (zero-shape + sectionErrors); + // the file-reading sections still complete. + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = test_collector(scan_root, sys_fixture(), &interest); + let result = collector + .refresh(Duration::ZERO) + .await + .expect("budget exhaustion degrades sections, never rejects"); + assert!(!result.manual.top_processes.available); + assert!(!result.manual.process_health.available); + assert_eq!( + result + .manual + .section_errors + .get("topProcesses") + .map(String::as_str), + Some("host-stats section deadline exceeded") + ); + assert_eq!( + result + .manual + .section_errors + .get("processHealth") + .map(String::as_str), + Some("host-stats section deadline exceeded") + ); + // Non-scan sections complete under the same refresh. + assert!(result.manual.disks.available); + assert!(!result.manual.disks.list.is_empty()); + assert!(result.manual.thermals.available); + assert!(!result.manual.section_errors.contains_key("disks")); + } + + #[tokio::test] + async fn host_stats_refresh_leader_teardown_joiner_and_cache_survive() { + // Parity regression (service.ts:321-331): the in-flight refresh run is + // owned by the COLLECTOR (Node's service-owned pendingRefresh), never + // by the requesting caller's future. If the "leader" caller is torn + // down mid-flight (its connection dies), the run still completes: + // the next caller joins the SAME collector-owned run and receives its + // result, and the manual cache is updated unconditionally. + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let collector = Arc::new(test_collector(scan_root, sys_fixture(), &interest)); + // The leader drives refresh() from its own task, then tears down + // mid-flight (the abort drops the future mid-dwell). + let leader = { + let leader_collector = Arc::clone(&collector); + tokio::spawn(async move { leader_collector.refresh(Duration::from_millis(2000)).await }) + }; + let in_flight = + wait_until(Duration::from_secs(2), || collector.scan_run_count() == 1).await; + assert!(in_flight, "the leader's run started (scan in flight)"); + leader.abort(); + let outcome = leader.await; + let cancelled = matches!(&outcome, Err(e) if e.is_cancelled()); + assert!( + cancelled, + "the leader task was aborted mid-flight: {outcome:?}" + ); + // The NEXT caller joins the collector-owned run (never a "refresh + // leader vanished" error, never a poisoned flight slot). + let joined = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("the collector-owned run completes for every caller"); + assert_eq!( + collector.scan_run_count(), + 1, + "no re-run: the surviving run serves the joiner" + ); + assert!(joined.manual.top_processes.available); + assert_eq!(joined.manual.top_processes.list.len(), 7); + assert!(joined.manual.process_health.available); + assert_eq!(joined.manual.process_health.zombies, 1); + assert_eq!(joined.manual.process_health.d_state, 1); + assert_eq!(joined.manual.process_health.total, 8); + assert!(joined.manual.disks.available); + assert!(joined.manual.thermals.available); + assert!(joined.manual.section_errors.is_empty()); + // The manual cache was written by the collector at completion. + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(joined.at)); + assert_eq!(snap.manual, Some(joined.manual)); + } + + #[tokio::test] + async fn host_stats_refresh_overall_watchdog_covers_every_section() { + // Parity regression (service.ts:744): EVERY section arm races the + // overall watchdog — not only the process-scan arm. An overall budget + // that is already exhausted must degrade EVERY section to its full + // zero-shape (available:false + the watchdog sectionErrors entry) + // while the refresh still resolves Ok and the manual cache updates. + // (Healthy-path completion under the same wrapper is pinned by the + // single-flight + cooperative-budget tests above.) + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + // Give the overlay readable inotify sysctls so an UNGUARDED inotify + // arm would complete as available:true (pre-fix discrimination). + let inotify_dir = scan_root.join("sys").join("fs").join("inotify"); + std::fs::create_dir_all(&inotify_dir).unwrap(); + std::fs::write(inotify_dir.join("max_user_watches"), "1048576\n").unwrap(); + std::fs::write(inotify_dir.join("max_user_instances"), "128\n").unwrap(); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + // The watchdog fires at the first per-section preemption point. + cfg.overall_budget = Duration::ZERO; + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + // A HEALTHY cooperative budget: only the overall-watchdog path is + // under test here (the per-pid cooperative deadline never trips). + let result = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("watchdog preemption degrades sections, never rejects"); + let manual = &result.manual; + assert!(!manual.top_processes.available); + assert!(!manual.process_health.available); + assert!( + !manual.inotify.available, + "the watchdog must preempt the inotify arm" + ); + assert!( + !manual.disks.available, + "the watchdog must preempt the disks arm" + ); + assert!( + !manual.thermals.available, + "the watchdog must preempt the thermals arm" + ); + for key in [ + "topProcesses", + "processHealth", + "inotify", + "disks", + "thermals", + ] { + assert_eq!( + manual.section_errors.get(key).map(String::as_str), + Some("host-stats refresh overall budget exceeded"), + "section {key} carries the watchdog error" + ); + } + assert_eq!(manual.section_errors.len(), 5); + assert_eq!(collector.scan_run_count(), 1); + // The refresh still resolved and the manual cache holds the degraded + // shape (Node: manualCache is written after Promise.all, errors or not). + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(result.at)); + assert_eq!(snap.manual, Some(result.manual.clone())); + } + + #[tokio::test] + async fn host_stats_refresh_dead_run_frees_flight_slot_and_never_caches() { + // Parity regression (service.ts:326-329 — Node wraps runRefresh() in + // `.finally(() => { pendingRefresh = null; lastRefreshCompletedAt = + // nowFn() })`, which runs even when the run THROWS): a refresh run + // that dies without completing (a panic unwinds the collector's + // spawned run task) must not brick the path. Without the drop-guard + // the flight slot stays occupied forever and every later refresh() + // joins the dead channel ("refresh run vanished"); with it, the next + // refresh() starts a FRESH run. The dead run NEVER writes the manual + // cache (Node's manualCache is only written by a completed run) but + // DOES stamp the cooldown (Node's .finally stamps even on a throw). + let tmp = tempfile::tempdir().unwrap(); + let scan_root = scan_proc_overlay(tmp.path()); + let interest = HostStatsInterestRegistry::default(); + let mut cfg = test_config(scan_root, sys_fixture()); + // The cooldown stamp has its own dedicated test; here it must not + // gate the recovery refresh. + cfg.refresh_cooldown = Duration::ZERO; + let collector = HostStatsCollectorService::new( + cfg, + freshell_terminal::TerminalRegistry::new(), + interest, + Instant::now(), + ); + // Arm the one-shot seam: the FIRST refresh run dies mid-scan. + collector.ctx.test_run_panic.store(true, Ordering::SeqCst); + let dead = collector.refresh(Duration::from_millis(2000)).await; + assert_eq!( + dead, + Err("refresh run vanished".to_string()), + "a caller attached to the dead run gets the vanished-run error" + ); + assert_eq!( + collector.scan_run_count(), + 1, + "the dead run started (and died in flight)" + ); + // Node's .finally runs even on a throw: the flight slot is freed and + // the cooldown stamped... + assert!( + collector.ctx.share.refresh_flight.lock().unwrap().is_none(), + "a dead run frees the flight slot (Node .finally clears pendingRefresh)" + ); + assert!( + collector + .ctx + .share + .last_refresh_completed + .lock() + .unwrap() + .is_some(), + "a dead run still stamps the cooldown (Node .finally stamps lastRefreshCompletedAt)" + ); + // ...but the manual cache is NEVER updated by a failed run. + let snap = collector.snapshot(); + assert!( + snap.manual_at.is_none() && snap.manual.is_none(), + "the dead run never wrote the manual cache" + ); + // The next refresh() recovers: a FRESH run serves it. + let recovered = collector + .refresh(Duration::from_millis(2000)) + .await + .expect("a dead run must not brick the refresh path"); + assert_eq!( + collector.scan_run_count(), + 2, + "a FRESH run served the recovery refresh" + ); + assert!(recovered.manual.top_processes.available); + assert_eq!(recovered.manual.top_processes.list.len(), 7); + assert!(recovered.manual.section_errors.is_empty()); + let snap = collector.snapshot(); + assert_eq!(snap.manual_at, Some(recovered.at)); + assert_eq!(snap.manual, Some(recovered.manual)); + } +} diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index b385deb37..43f1f6d62 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -27,6 +27,7 @@ mod existence; mod existence_by_id; mod extensions; mod files; +mod host_stats; mod identity_sink; mod instance_id; mod legacy_local_seed; @@ -387,6 +388,28 @@ async fn main() -> ExitCode { // Cloned (cheap Arc) into the files REST surface too, whose `candidate-dirs` // sources the running terminals' cwds for the DirectoryPicker. let registry = freshell_terminal::TerminalRegistry::new(); + // HOST-PRESSURE PANE (Task 9, docs/plans/2026-08-25-host-pressure-pane.md): + // the Rust host-stats collector — freshell-platform readers over + // freshell-ws's trait bridge. Constructed here (not at the ~1311 + // subagent-cadence spawn the plan cites, which sits inside the + // session-index block BELOW `ws_state`): the concrete instance must be + // Arc'd and injected INTO `WsState::host_stats` when that literal builds. + // NO cadence spawns here — `terminal.rs`'s `hoststats.subscribe` + // 0->1 edge calls the collector's `set_active(true)`, which owns + // spawn/abort internally (zero-cost idle). The interest registry clone + // shared into WsState is the SAME instance the collector's cadence + // delivers snapshots through (subscribed connections only — never + // `broadcast_tx`). `boot_anchor` backs `freshell.uptimeSec`. + let host_stats_interest = + freshell_ws::host_stats_interest::HostStatsInterestRegistry::default(); + let host_stats_collector: std::sync::Arc< + dyn freshell_ws::host_stats_collector::HostStatsCollector, + > = std::sync::Arc::new(host_stats::HostStatsCollectorService::new( + host_stats::HostStatsCollectorConfig::from_env(), + registry.clone(), + host_stats_interest.clone(), + std::time::Instant::now(), + )); // Slice 1 (docs/plans/2026-07-18-agent-api-mcp-parity-spec.md \u00a79 Risk 1): the // Agent-API's terminal-mode `POST /api/tabs` shares THIS SAME registry -- // never a second one -- so an Agent-API-created shell terminal is a first-class @@ -1047,6 +1070,12 @@ async fn main() -> ExitCode { layout: layout_store.clone(), screenshots: screenshots.clone(), subagent_interest: subagent_interest.clone(), + // Task 9: the SAME interest registry the collector's cadence delivers + // through + the injected concrete collector. + host_stats: freshell_ws::host_stats_collector::WsHostStatsState { + interest: host_stats_interest.clone(), + collector: Some(host_stats_collector.clone()), + }, terminals_revision: Arc::clone(&terminals_revision), sessions_revision: Arc::clone(&sessions_revision), cli_commands: Arc::clone(&cli_commands), @@ -2434,17 +2463,24 @@ fn kilroy_enabled_flag() -> bool { /// declare now that the hardened resolve response surface /// (degraded/providerErrors/unsearchedProviders/homeDir, warming default) /// landed — see `docs/plans/2026-07-30-rust-resolve-parity-hardened.md` -/// Tasks 2-6 (SYNC-06). +/// Tasks 2-6 (SYNC-06). `featureFlags.hostStatsAvailable` mirrors Node's +/// `process.platform !== 'win32'` as boot-static `cfg!(not(target_os = +/// "windows"))`. fn build_platform_payload( available_clis: serde_json::Value, ai_enabled: bool, ) -> serde_json::Value { let platform = detect_platform_proc(host_os_live(), read_proc_version().as_deref()); + // Boot-static host-stats availability (mirrors Node's + // `process.platform !== 'win32'` in `detectFeatureFlags`): the collector + // reads /proc + /sys, so Windows reports `false`; no /proc probe at boot — + // readers degrade to `available: false` on failure. + let host_stats_available = cfg!(not(target_os = "windows")); serde_json::json!({ "platform": platform, "availableClis": available_clis, "hostName": read_host_name(), - "featureFlags": { "kilroy": kilroy_enabled_flag(), "aiEnabled": ai_enabled, "sessionResolve": true }, + "featureFlags": { "kilroy": kilroy_enabled_flag(), "aiEnabled": ai_enabled, "sessionResolve": true, "hostStatsAvailable": host_stats_available }, }) } @@ -3085,6 +3121,7 @@ mod sessions_sweep_tests { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), @@ -3581,12 +3618,16 @@ mod tests { #[test] fn platform_payload_feature_flags_shape_matches_legacy() { // `server/platform-router.ts#detectFeatureFlags`: `{ kilroy, aiEnabled, - // sessionResolve }`, camelCase, no extra fields — mirrored 1:1 in the - // Rust payload. `sessionResolve` is TRUE: the hardened resolve - // response surface (degraded/providerErrors/unsearchedProviders/ + // sessionResolve, hostStatsAvailable }`, camelCase, no extra fields — + // mirrored 1:1 in the Rust payload. `sessionResolve` is TRUE: the + // hardened resolve response surface + // (degraded/providerErrors/unsearchedProviders/ // homeDir, warming default) landed via the hardened plan Tasks 2-6 - // (SYNC-06), so the flag is genuinely earned. `KILROY_ENABLED` is pinned - // off under the shared lock so the assertion is environment-independent. + // (SYNC-06), so the flag is genuinely earned. `hostStatsAvailable` is + // boot-static on the build target (`cfg!(not(target_os = "windows"))`, + // mirroring Node's `process.platform !== 'win32'`). `KILROY_ENABLED` is + // pinned off under the shared lock so the assertion is + // environment-independent. let _lock = crate::session_directory::HOME_ENV_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); @@ -3595,7 +3636,7 @@ mod tests { let payload = build_platform_payload(serde_json::json!({}), cell.enabled()); assert_eq!( payload["featureFlags"], - serde_json::json!({ "kilroy": false, "aiEnabled": true, "sessionResolve": true }) + serde_json::json!({ "kilroy": false, "aiEnabled": true, "sessionResolve": true, "hostStatsAvailable": cfg!(not(target_os = "windows")) }) ); } @@ -3609,7 +3650,21 @@ mod tests { let payload = build_platform_payload(serde_json::json!({}), cell.enabled()); assert_eq!( payload["featureFlags"], - serde_json::json!({ "kilroy": false, "aiEnabled": false, "sessionResolve": true }) + serde_json::json!({ "kilroy": false, "aiEnabled": false, "sessionResolve": true, "hostStatsAvailable": cfg!(not(target_os = "windows")) }) + ); + } + + #[test] + fn host_stats_flag_present_in_platform_payload() { + // Host-stats collection reads Linux `/proc` + `/sys`; both servers expose + // a boot-static availability flag so clients can degrade to + // `available: false` instead of probing — Node mirrors + // `process.platform !== 'win32'` (server/platform-router.ts), Rust uses + // the build target. Present-and-boolean regardless of host runtime. + let payload = build_platform_payload(serde_json::json!({}), false); + assert_eq!( + payload["featureFlags"]["hostStatsAvailable"], + serde_json::json!(cfg!(not(target_os = "windows"))) ); } diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats b/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats new file mode 100644 index 000000000..e0866839a --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/diskstats @@ -0,0 +1,5 @@ + 8 0 sda 5000 100 400000 6000 2000 50 200000 3000 0 4000 9000 + 8 1 sda1 4000 80 300000 5000 1500 40 150000 2500 0 3000 7500 + 7 0 loop0 100 0 800 10 0 0 0 0 0 10 10 + 259 0 nvme0n1 9000 200 700000 8000 3000 60 300000 4000 0 5000 12000 + 259 1 nvme0n1p1 8000 150 600000 7000 2500 55 250000 3500 0 4500 10500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg b/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg new file mode 100644 index 000000000..ecb2936f0 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/loadavg @@ -0,0 +1 @@ +0.50 1.00 1.20 2/1234 5678 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo b/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo new file mode 100644 index 000000000..48d7877ca --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/meminfo @@ -0,0 +1,49 @@ +MemTotal: 67108864 kB +MemFree: 8388608 kB +MemAvailable: 33554432 kB +Buffers: 524288 kB +Cached: 4194304 kB +SwapCached: 0 kB +Active: 20971520 kB +Inactive: 8388608 kB +Active(anon): 16777216 kB +Inactive(anon): 4194304 kB +Active(file): 4194304 kB +Inactive(file): 4194304 kB +Unevictable: 0 kB +Mlocked: 0 kB +SwapTotal: 8388608 kB +SwapFree: 7340032 kB +Dirty: 100 kB +Writeback: 0 kB +AnonPages: 20970000 kB +Mapped: 500000 kB +Shmem: 150000 kB +Slab: 800000 kB +SReclaimable: 600000 kB +SUnreclaim: 200000 kB +KernelStack: 30000 kB +PageTables: 60000 kB +NFS_Unstable: 0 kB +Bounce: 0 kB +WritebackTmp: 0 kB +CommitLimit: 41943040 kB +Committed_AS: 30000000 kB +VmallocTotal: 34359738367 kB +VmallocUsed: 50000 kB +VmallocChunk: 0 kB +Percpu: 20000 kB +HardwareCorrupted: 0 kB +AnonHugePages: 0 kB +ShmemHugePages: 0 kB +ShmemPmdMapped: 0 kB +FileHugePages: 0 kB +FilePmdMapped: 0 kB +HugePages_Total: 0 +HugePages_Free: 0 +HugePages_Rsvd: 0 +HugePages_Surp: 0 +Hugepagesize: 2048 kB +Hugetlb: 0 kB +DirectMap4k: 1000000 kB +DirectMap2M: 66000000 kB diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev b/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev new file mode 100644 index 000000000..89f4f112b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/dev @@ -0,0 +1,5 @@ +Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1000000 10000 0 0 0 0 0 0 1000000 10000 0 0 0 0 0 0 + eth0: 5000000 50000 7 3 0 0 0 0 8000000 80000 11 4 0 0 0 0 +docker0: 2000000 20000 2 1 0 0 0 0 3000000 30000 5 2 0 0 0 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp new file mode 100644 index 000000000..642fc6731 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp @@ -0,0 +1,5 @@ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22334 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:9C40 0200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 22335 1 0000000000000000 20 4 30 10 -1 + 2: 0A00000A:C350 0100000A:01BB 01 00000000:00000000 02:000A9A78 00000000 1000 0 22336 2 0000000000000000 20 4 31 10 -1 + 3: 0100007F:8AE0 0200000A:1F91 06 00000000:00000000 00:00000000 00000000 1000 0 22337 1 0000000000000000 20 4 30 10 -1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 new file mode 100644 index 000000000..e67cd19ab --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/net/tcp6 @@ -0,0 +1,3 @@ + sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000001000000:1F91 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33001 1 0000000000000000 100 0 0 10 0 + 1: 00000000000000000000000001000000:9C41 0000000000000000000000000200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 33002 1 0000000000000000 20 4 30 10 -1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu new file mode 100644 index 000000000..50be24887 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/cpu @@ -0,0 +1 @@ +some avg10=1.23 avg60=2.34 avg300=3.45 total=987654321 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io new file mode 100644 index 000000000..dd9f0a14b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/io @@ -0,0 +1,2 @@ +some avg10=2.50 avg60=1.00 avg300=0.50 total=654321 +full avg10=1.00 avg60=0.40 avg300=0.20 total=600000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory new file mode 100644 index 000000000..8593cd563 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/pressure/memory @@ -0,0 +1,2 @@ +some avg10=0.50 avg60=0.20 avg300=0.10 total=123456 +full avg10=0.30 avg60=0.10 avg300=0.05 total=100000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 new file mode 100644 index 000000000..4fe3ccb64 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/3 @@ -0,0 +1,6 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 1234 +inotify wd:1 ino:600001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0106a000743b0200 +inotify wd:2 ino:600002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0206a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 new file mode 100644 index 000000000..4191793c2 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/4 @@ -0,0 +1,7 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 2234 +inotify wd:1 ino:610001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0306a000743b0200 +inotify wd:2 ino:610002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0406a000743b0200 +inotify wd:3 ino:610003 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0506a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 new file mode 100644 index 000000000..bc73d2c98 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/fdinfo/5 @@ -0,0 +1,5 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 3234 +inotify wd:1 ino:620001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0606a000743b0200 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits b/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits new file mode 100644 index 000000000..7d4f86556 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/self/limits @@ -0,0 +1,17 @@ +Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +Max file size unlimited unlimited bytes +Max data size unlimited unlimited bytes +Max stack size 8388608 unlimited bytes +Max core file size 0 unlimited bytes +Max resident set unlimited unlimited bytes +Max processes 257913 257913 processes +Max open files 1024 1048576 files +Max locked memory 1090519040 1090519040 bytes +Max address space unlimited unlimited bytes +Max file locks unlimited unlimited locks +Max pending signals 257913 257913 signals +Max msgqueue size 819200 819200 bytes +Max nice priority 0 0 +Max realtime priority 0 0 +Max realtime timeout unlimited unlimited us diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/stat b/crates/freshell-server/tests/fixtures/host-stats/proc/stat new file mode 100644 index 000000000..a1c60bc76 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/stat @@ -0,0 +1,24 @@ +cpu 4705 356 1622 164331 2020 80 345 777 0 0 +cpu0 300 10 120 10000 150 5 20 40 0 0 +cpu1 200 5 100 9001 100 2 10 21 0 0 +cpu2 200 5 100 9002 100 2 10 22 0 0 +cpu3 200 5 100 9003 100 2 10 23 0 0 +cpu4 200 5 100 9004 100 2 10 24 0 0 +cpu5 200 5 100 9005 100 2 10 25 0 0 +cpu6 200 5 100 9006 100 2 10 26 0 0 +cpu7 200 5 100 9007 100 2 10 27 0 0 +cpu8 200 5 100 9008 100 2 10 28 0 0 +cpu9 200 5 100 9009 100 2 10 29 0 0 +cpu10 200 5 100 9010 100 2 10 30 0 0 +cpu11 200 5 100 9011 100 2 10 31 0 0 +cpu12 200 5 100 9012 100 2 10 32 0 0 +cpu13 200 5 100 9013 100 2 10 33 0 0 +cpu14 200 5 100 9014 100 2 10 34 0 0 +cpu15 100 0 50 9000 10 0 5 15 0 0 +intr 1234567 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +ctxt 7654321 +btime 1690000000 +processes 12345 +procs_running 2 +procs_blocked 0 +softirq 123456 100 50000 200 60000 300 1000 5000 60000 2000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances new file mode 100644 index 000000000..a949a93df --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances @@ -0,0 +1 @@ +128 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches new file mode 100644 index 000000000..6820bf177 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches @@ -0,0 +1 @@ +1048576 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max new file mode 100644 index 000000000..9f358a4ad --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/kernel/threads-max @@ -0,0 +1 @@ +123456 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range new file mode 100644 index 000000000..10d6ed9d7 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range @@ -0,0 +1 @@ +32768 60999 diff --git a/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat b/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat new file mode 100644 index 000000000..99751486d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/proc/vmstat @@ -0,0 +1,18 @@ +nr_free_pages 2000000 +nr_zone_inactive_anon 100000 +nr_zone_active_anon 200000 +nr_inactive_anon 100000 +nr_active_anon 200000 +nr_inactive_file 150000 +nr_active_file 250000 +nr_unevictable 0 +nr_slab_reclaimable 150000 +nr_slab_unreclaimable 50000 +pswpin 1234 +pswpout 5678 +pgmajfault 890 +pgpgin 100000 +pgpgout 200000 +oom_kill 3 +nr_dirty 25 +nr_writeback 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat new file mode 100644 index 000000000..4392649cb --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/stat @@ -0,0 +1 @@ +101 (systemd) S 1 101 101 0 -1 4194304 1000 0 50 0 120 30 0 0 20 0 1 0 5000 200000000 1500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status new file mode 100644 index 000000000..c0913c7d8 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/101/status @@ -0,0 +1,12 @@ +Name: systemd +Umask: 0022 +State: S (sleeping) +Tgid: 101 +Pid: 101 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 200000 kB +VmSize: 195312 kB +VmRSS: 12345 kB +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat new file mode 100644 index 000000000..f4d82bf79 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/stat @@ -0,0 +1 @@ +202 (node) S 1 202 202 0 -1 4194304 20000 0 100 0 800 200 0 0 20 0 8 0 6000 1500000000 50000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status new file mode 100644 index 000000000..e5e1daf7b --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/202/status @@ -0,0 +1,12 @@ +Name: node +Umask: 0022 +State: S (sleeping) +Tgid: 202 +Pid: 202 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 1500000 kB +VmSize: 1464843 kB +VmRSS: 654321 kB +Threads: 8 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat new file mode 100644 index 000000000..c0749889a --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/stat @@ -0,0 +1 @@ +303 (postgres) S 1 303 303 0 -1 4194304 30000 0 200 0 400 100 0 0 20 0 4 0 7000 300000000 30000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status new file mode 100644 index 000000000..7b678376e --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/303/status @@ -0,0 +1,12 @@ +Name: postgres +Umask: 0022 +State: S (sleeping) +Tgid: 303 +Pid: 303 +PPid: 1 +Uid: 999 999 999 999 +Gid: 999 999 999 999 +VmPeak: 350000 kB +VmSize: 292968 kB +VmRSS: 88888 kB +Threads: 4 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat new file mode 100644 index 000000000..1153d22bd --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/stat @@ -0,0 +1 @@ +404 (my (weird) proc) D 1 404 404 0 -1 4194304 200 0 5 0 999 111 0 0 20 0 2 0 8000 300000000 6000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status new file mode 100644 index 000000000..dc934d325 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/404/status @@ -0,0 +1,12 @@ +Name: my (weird) proc +Umask: 0022 +State: D (disk sleep) +Tgid: 404 +Pid: 404 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 97656 kB +VmRSS: 4321 kB +Threads: 2 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat new file mode 100644 index 000000000..9b240a326 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/stat @@ -0,0 +1 @@ +505 (zomb) Z 1 505 505 0 -1 4194304 0 0 0 0 10 5 0 0 20 0 1 0 9000 0 0 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status new file mode 100644 index 000000000..77aaf01a9 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/505/status @@ -0,0 +1,9 @@ +Name: zomb +Umask: 0022 +State: Z (zombie) +Tgid: 505 +Pid: 505 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat new file mode 100644 index 000000000..7e539090d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/stat @@ -0,0 +1 @@ +606 (nginx) R 1 606 606 0 -1 4194304 40000 0 300 0 2000 500 0 0 20 0 4 0 10000 100000000 12000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status new file mode 100644 index 000000000..aff39a979 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/606/status @@ -0,0 +1,12 @@ +Name: nginx +Umask: 0022 +State: R (running) +Tgid: 606 +Pid: 606 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 150000 kB +VmSize: 146484 kB +VmRSS: 23456 kB +Threads: 4 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat new file mode 100644 index 000000000..7fe337910 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/stat @@ -0,0 +1 @@ +707 (bash) S 1 707 707 0 -1 4194304 500 0 20 0 60 20 0 0 20 0 1 0 11000 80000000 2000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status new file mode 100644 index 000000000..993f15f3f --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/707/status @@ -0,0 +1,12 @@ +Name: bash +Umask: 0022 +State: S (sleeping) +Tgid: 707 +Pid: 707 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 78125 kB +VmRSS: 3456 kB +Threads: 1 diff --git a/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup b/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup new file mode 100644 index 000000000..41d81f742 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/procmini/self/cgroup @@ -0,0 +1 @@ +0::/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity new file mode 100644 index 000000000..84df3526d --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/capacity @@ -0,0 +1 @@ +87 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status new file mode 100644 index 000000000..4674475b6 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/status @@ -0,0 +1 @@ +Discharging diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type new file mode 100644 index 000000000..6784dd35c --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/power_supply/BAT0/type @@ -0,0 +1 @@ +Battery diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp new file mode 100644 index 000000000..304cba046 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp @@ -0,0 +1 @@ +51500 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type new file mode 100644 index 000000000..0a11ba228 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/class/thermal/thermal_zone0/type @@ -0,0 +1 @@ +x86_pkg_temp diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..98be78b86 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +3400000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..c754f1a46 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +2800000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current new file mode 100644 index 000000000..fcd6d3c41 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current @@ -0,0 +1 @@ +17000000000 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max new file mode 100644 index 000000000..355295a05 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max @@ -0,0 +1 @@ +max diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current new file mode 100644 index 000000000..d81cc0710 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current @@ -0,0 +1 @@ +42 diff --git a/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max new file mode 100644 index 000000000..fff795a14 --- /dev/null +++ b/crates/freshell-server/tests/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max @@ -0,0 +1 @@ +10854 diff --git a/crates/freshell-ws/src/codex_association.rs b/crates/freshell-ws/src/codex_association.rs index 72d1bcf97..af443ceb8 100644 --- a/crates/freshell-ws/src/codex_association.rs +++ b/crates/freshell-ws/src/codex_association.rs @@ -308,6 +308,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/codex_proxy_route.rs b/crates/freshell-ws/src/codex_proxy_route.rs index 3fb5edf76..b2fab257b 100644 --- a/crates/freshell-ws/src/codex_proxy_route.rs +++ b/crates/freshell-ws/src/codex_proxy_route.rs @@ -257,6 +257,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/host_stats_collector.rs b/crates/freshell-ws/src/host_stats_collector.rs new file mode 100644 index 000000000..3c60c90da --- /dev/null +++ b/crates/freshell-ws/src/host_stats_collector.rs @@ -0,0 +1,86 @@ +//! The host-stats collector TRAIT bridge for the freshell-ws crate +//! (`docs/plans/2026-08-25-host-pressure-pane.md` Task 9). +//! +//! Dependency direction is frozen: freshell-ws canNOT depend on +//! freshell-server. The concrete collector (cadences, `/proc` reads, refresh +//! budgets) lives in freshell-server (`host_stats.rs`); this crate owns ZERO +//! `/proc` knowledge and ZERO timers — it knows only the trait, so +//! `terminal.rs`'s `hoststats.*` dispatch can drive it and `main.rs` can +//! inject the concrete `Arc` into [`WsState`]. +//! +//! Lifecycle contract (mirrors Node's `HostStatsService` start/stop): +//! `terminal.rs` calls [`HostStatsCollector::set_active`] ONLY on the +//! interest registry's cardinality edges — `true` on 0->1 (the collector +//! spawns its two-tier cadence + drift sampler internally), `false` on ->0 +//! (the collector aborts the JoinHandles — true zero-cost idle). The interest +//! registry itself never holds a JoinHandle. + +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use freshell_protocol::{HostStatsManual, HostStatsSnapshot}; + +use crate::host_stats_interest::HostStatsInterestRegistry; + +/// The data returned by a successful [`HostStatsCollector::refresh`] — the +/// payload halves of `hoststats.refresh.response { ok:true, at, manual }` +/// (the response's `requestId` echo is the dispatcher's job). +#[derive(Debug, Clone, PartialEq)] +pub struct HostStatsRefreshOk { + pub at: u64, + pub manual: HostStatsManual, +} + +/// The boxed future [`HostStatsCollector::refresh`] returns (object-safe +/// async without an async-trait dependency; borrows the collector so the +/// boxed Arc in `WsState` can be driven in place). +pub type HostStatsRefreshFuture<'a> = + Pin> + Send + 'a>>; + +/// The host-stats collector contract. The concrete implementation is +/// freshell-server's `HostStatsCollectorService`; freshell-ws tests install +/// fakes. +pub trait HostStatsCollector: Send + Sync { + /// Cache read only — NEVER waits on I/O newer than the last tick (mirrors + /// `HostStatsService.getSnapshot`; ticks write caches, snapshots read + /// them). Fresh subscribers get this frame immediately on subscribe. + fn snapshot(&self) -> HostStatsSnapshot; + + /// On-request manual data (process table, disks, inotify, + /// thermals/battery). Single-flight with a connection-agnostic 1s + /// post-completion cooldown (`Err("rate_limited")`); NEVER fails for data + /// reasons — a failed section degrades to its zero-shape while the others + /// complete. `deadline` is the cooperative per-section budget (the shared + /// absolute deadline is `start + deadline`; Node `sectionBudgetMs`). + fn refresh(&self, deadline: Duration) -> HostStatsRefreshFuture<'_>; + + /// Interest-transition callback: `true` (0->1 interested) spawns the + /// cadence internally (one immediate fast tick so a fresh subscriber gets + /// a shaped snapshot at once), `false` (->0) aborts it. Idempotent. + fn set_active(&self, active: bool); +} + +/// The `WsState.host_stats` sub-struct (Task 9's `WsState` literal sweep: the +/// sweep exceeded the ~6-site threshold, so BOTH new fields are wrapped here +/// and every legacy `WsState { ... }` literal gains exactly one +/// `host_stats: Default::default()` arm). This is the type the plan's crate +/// architecture bullet names "`HostStatsShare`": the share BETWEEN the ws +/// crate (interest bookkeeping + dispatch) and the injected concrete +/// collector. +/// +/// `collector` is `None` ONLY in unit tests that never exercise host-stats +/// (like `WsState.activity`); on a real boot `freshell-server`'s `main.rs` +/// always wires the concrete collector. A `hoststats.refresh` with no +/// collector answers `{ ok:false, error:"host stats unavailable" }` (Node +/// parity when `this.hostStats` is unset); subscribe with no collector +/// records interest and sends no snapshot (Node `sendHostStatsSnapshot`'s +/// early return). +#[derive(Clone, Default)] +pub struct WsHostStatsState { + /// Per-connection subscribe bookkeeping + the cadence delivery fan-out. + pub interest: HostStatsInterestRegistry, + /// The injected concrete collector (freshell-server); `None` in + /// host-stats-free unit tests. + pub collector: Option>, +} diff --git a/crates/freshell-ws/src/host_stats_interest.rs b/crates/freshell-ws/src/host_stats_interest.rs new file mode 100644 index 000000000..1431a7a35 --- /dev/null +++ b/crates/freshell-ws/src/host_stats_interest.rs @@ -0,0 +1,182 @@ +//! Per-connection `hoststats.subscribe` interest registry (host-pressure pane, +//! `docs/plans/2026-08-25-host-pressure-pane.md` Task 9). +//! +//! Shape precedent: [`crate::subagent_interest::SubagentInterestRegistry`] — +//! a cheaply-cloneable `Arc` handle; interest set/remove/any/count ONLY, with +//! NO cadence JoinHandle ownership (the concrete collector in freshell-server +//! owns spawn/abort through its `set_active` callback; `terminal.rs` calls it +//! on the transitions this registry reports). +//! +//! Task 9 delivery targeting difference from the subagent registry: each +//! entry ALSO stores the connection's outbound [`FrameSink`] (the +//! per-connection sender `terminal.rs` already owns for its socket write +//! loop), captured at subscribe time. This is the plan's frozen delivery +//! contract: host-stats snapshots flow ONLY to subscribed connections via +//! their per-conn channels — NEVER via the shared `broadcast_tx` bus +//! (non-watchers get zero traffic). [`HostStatsInterestRegistry::senders`] is +//! the cadence task's read surface for that fan-out. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use freshell_terminal::FrameSink; + +/// How a [`HostStatsInterestRegistry::set`]/[`HostStatsInterestRegistry::remove`] +/// mutated the interested-connection cardinality. `terminal.rs` maps +/// `BecameActive` -> `collector.set_active(true)` (0->1 spawns the cadence) +/// and `BecameIdle` -> `collector.set_active(false)` (1->0 aborts it). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InterestTransition { + /// Cardinality unchanged (idempotent re-subscribe / unknown-id removal). + Unchanged, + /// 0 -> 1: the first interested connection arrived. + BecameActive, + /// -> 0: the last interested connection left. + BecameIdle, +} + +/// The shared interior: interested connection-id -> outbound sender map under +/// a lock, plus a lock-free mirror of its cardinality for cheap gate reads. +/// The two are always updated under the same lock acquisition, so `count` +/// can never drift from `subs.len()`. +#[derive(Default)] +struct Inner { + subs: Mutex>, + count: Arc, +} + +/// A cheaply-cloneable handle to the per-connection host-stats interest map. +/// All clones share the one underlying map (like `SubagentInterestRegistry`). +#[derive(Clone, Default)] +pub struct HostStatsInterestRegistry { + inner: Arc, +} + +impl HostStatsInterestRegistry { + /// Declare (`Some(sink)`) or retract (`None`) this connection's + /// host-stats interest. Re-subscribing overwrites the connection's LATEST + /// sender (idempotent in cardinality, fresh sink). Reports the + /// cardinality transition so the caller can drive the collector's + /// `set_active` exactly on 0->1 / ->0 edges. + pub fn set(&self, conn_id: u64, sink: Option) -> InterestTransition { + let mut guard = self.inner.subs.lock().unwrap(); + // `insert`/`remove` on the map give exact cardinality under the lock; + // the count mirror is stored under the same lock acquisition, so it + // can never drift from the map. + let old_count = guard.len(); + match sink { + Some(sink) => { + guard.insert(conn_id, sink); + } + None => { + guard.remove(&conn_id); + } + } + let new_count = guard.len(); + self.inner.count.store(new_count, Ordering::SeqCst); + if new_count == old_count { + InterestTransition::Unchanged + } else if old_count == 0 && new_count == 1 { + InterestTransition::BecameActive + } else if new_count == 0 { + InterestTransition::BecameIdle + } else { + // 1->2, 2->1, ...: still active, no edge. + InterestTransition::Unchanged + } + } + + /// Clear a connection's entry entirely (socket teardown + the + /// `hoststats.unsubscribe` arm). Unknown ids are a no-op + /// (`InterestTransition::Unchanged`). + pub fn remove(&self, conn_id: u64) -> InterestTransition { + self.set(conn_id, None) + } + + /// True iff at least one connected client is currently interested. + pub fn any(&self) -> bool { + self.count() > 0 + } + + /// The lock-free cardinality mirror (e.g. teardown-edge checks). + pub fn count(&self) -> usize { + self.inner.count.load(Ordering::SeqCst) + } + + /// Snapshot of the live per-connection outbound senders, in + /// insertion-irrelevant order — the cadence task's delivery fan-out + /// (Task 9 frozen contract: subscribed connections ONLY, never + /// `broadcast_tx`). A conn whose socket is mid-teardown is removed + /// BEFORE its sink can go stale because `terminal.rs`'s teardown block + /// calls [`HostStatsInterestRegistry::remove`] under the same connection + /// lifecycle. + pub fn senders(&self) -> Vec { + let guard = self.inner.subs.lock().unwrap(); + guard.values().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn noop_sink() -> FrameSink { + Arc::new(|_| {}) + } + + #[test] + fn host_stats_interest_set_any_count_remove_semantics() { + let r = HostStatsInterestRegistry::default(); + assert!(!r.any()); + assert_eq!(r.count(), 0); + + r.set(7, Some(noop_sink())); + assert!(r.any()); + assert_eq!(r.count(), 1); + // Idempotent re-subscribe: cardinality must NOT double-count (the + // sink is overwritten, the set gains nothing). + r.set(7, Some(noop_sink())); + assert_eq!(r.count(), 1); + + r.set(9, Some(noop_sink())); + assert_eq!(r.count(), 2); + + r.remove(7); + assert!(r.any(), "other connection still interested"); + assert_eq!(r.count(), 1); + r.remove(42); // unknown id is a no-op + assert_eq!(r.count(), 1); + r.remove(9); + assert!(!r.any()); + assert_eq!(r.count(), 0); + } + + #[test] + fn host_stats_interest_reports_0_to_1_and_1_to_0_transitions() { + let r = HostStatsInterestRegistry::default(); + // First arrival is the ->active edge; repeats are unchanged. + assert_eq!( + r.set(1, Some(noop_sink())), + InterestTransition::BecameActive + ); + assert_eq!(r.set(1, Some(noop_sink())), InterestTransition::Unchanged); + assert_eq!(r.set(2, Some(noop_sink())), InterestTransition::Unchanged); + // Removing one of two stays active; removing the last is ->idle. + assert_eq!(r.remove(1), InterestTransition::Unchanged); + assert_eq!(r.remove(2), InterestTransition::BecameIdle); + // Teardown on an unknown id never fires an edge. + assert_eq!(r.remove(2), InterestTransition::Unchanged); + } + + #[test] + fn host_stats_interest_senders_snapshots_live_sinks_only() { + let r = HostStatsInterestRegistry::default(); + assert!(r.senders().is_empty()); + r.set(1, Some(noop_sink())); + r.set(2, Some(noop_sink())); + assert_eq!(r.senders().len(), 2); + r.remove(1); + assert_eq!(r.senders().len(), 1); + } +} diff --git a/crates/freshell-ws/src/lib.rs b/crates/freshell-ws/src/lib.rs index 573a9f91f..c592e4831 100644 --- a/crates/freshell-ws/src/lib.rs +++ b/crates/freshell-ws/src/lib.rs @@ -33,6 +33,8 @@ pub mod create_dedupe; pub(crate) mod create_gate; pub mod create_limit; pub mod existence; +pub mod host_stats_collector; +pub mod host_stats_interest; pub mod identity; pub mod invariants; pub mod opencode_association; @@ -233,6 +235,13 @@ pub struct WsState { /// amplifier subagent rescan cadence (`freshell-server`, Task 9) runs while /// `any()` is true. See [`crate::subagent_interest`]. pub subagent_interest: crate::subagent_interest::SubagentInterestRegistry, + /// HOST-PRESSURE PANE (Task 9, `docs/plans/2026-08-25-host-pressure-pane.md`): + /// per-connection `hoststats.subscribe` interest + the injected concrete + /// collector (`Arc`, freshell-server). Bundled as + /// ONE sub-struct so the ~35 `WsState { ... }` literals across crates + /// gain exactly one `host_stats: Default::default()` arm each (the plan's + /// >~6-site sweep rule). See [`crate::host_stats_collector`]. + pub host_stats: crate::host_stats_collector::WsHostStatsState, /// The handler-scoped monotonic `terminals.changed` revision counter /// (`ws-handler.ts:566` `terminalsRevision`). SHARED with the REST /// `/api/terminals` PATCH/DELETE broadcasts (`terminals::TerminalsState`), @@ -895,6 +904,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/src/opencode_association.rs b/crates/freshell-ws/src/opencode_association.rs index 8c28cfc2d..f417d0be0 100644 --- a/crates/freshell-ws/src/opencode_association.rs +++ b/crates/freshell-ws/src/opencode_association.rs @@ -432,6 +432,7 @@ mod tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: StdArc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: StdArc::new(Vec::new()), diff --git a/crates/freshell-ws/src/tabs_persist_tests.rs b/crates/freshell-ws/src/tabs_persist_tests.rs index 15827a2d0..0bcbd12c8 100644 --- a/crates/freshell-ws/src/tabs_persist_tests.rs +++ b/crates/freshell-ws/src/tabs_persist_tests.rs @@ -1196,6 +1196,7 @@ fn every_supported_pane_kind_passes_semantic_generation_validation() { "sandbox": "workspace-write", "style": "sans" } }, { "paneId": "extension", "kind": "extension", "payload": { "extensionName": "demo", "props": {} } }, + { "paneId": "hoststats", "kind": "host-stats", "payload": {} }, { "paneId": "picker", "kind": "picker", "payload": {} } ]); put(dir.path(), "dev", "c1", 1, 1000, vec![record]); diff --git a/crates/freshell-ws/src/tabs_persist_validation.rs b/crates/freshell-ws/src/tabs_persist_validation.rs index 8c09c2bd1..0aad2d2ee 100644 --- a/crates/freshell-ws/src/tabs_persist_validation.rs +++ b/crates/freshell-ws/src/tabs_persist_validation.rs @@ -507,10 +507,11 @@ fn validate_pane( "fresh-agent" => validate_fresh_agent(path, payload, &payload_name), "extension" => validate_extension(path, payload, &payload_name), "picker" => Ok(()), + "host-stats" => Ok(()), _ => Err(invalid( path, &format!("{name}.kind"), - "one of terminal, browser, editor, fresh-agent, extension, or picker", + "one of terminal, browser, editor, fresh-agent, extension, picker, or host-stats", )), } } diff --git a/crates/freshell-ws/src/tabs_store_model.rs b/crates/freshell-ws/src/tabs_store_model.rs index 8615640a0..9f913b211 100644 --- a/crates/freshell-ws/src/tabs_store_model.rs +++ b/crates/freshell-ws/src/tabs_store_model.rs @@ -245,12 +245,13 @@ pub fn archive_timestamp(now_ms: i64) -> String { // ── Record validation (TabRegistryRecordSchema port, types.ts:57-83) ───────── -/// The seven legal pane kinds (`RegistryPaneKindSchema`, types.ts:7-15). -const PANE_KINDS: [&str; 7] = [ +/// The eight legal pane kinds (`RegistryPaneKindSchema`, types.ts:7-16). +const PANE_KINDS: [&str; 8] = [ "terminal", "browser", "editor", "picker", + "host-stats", "claude-chat", "fresh-agent", "extension", diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index 849843b28..2bdaaea2b 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -81,6 +81,14 @@ mod terminal_launch_prep_tests; /// The write half of a split axum WebSocket. pub(crate) type WsSink = SplitSink; +/// Task 9: per-connection `hoststats.refresh` floor (legacy parity: +/// `ws-handler.ts` `HOST_STATS_REFRESH_MIN_INTERVAL_MS`, default 1000). +const HOST_STATS_REFRESH_FLOOR: std::time::Duration = std::time::Duration::from_millis(1000); +/// Task 9: the cooperative per-section budget handed to the collector on +/// `hoststats.refresh` (legacy parity: `HostStatsService.sectionBudgetMs`, +/// default 2000). +const HOST_STATS_REFRESH_DEADLINE: std::time::Duration = std::time::Duration::from_millis(2000); + /// Serialize + send one server→client message. Returns `false` if the socket is /// closed/errored (the caller then tears the connection down). pub(crate) async fn send(ws_tx: &mut WsSink, msg: &ServerMessage) -> bool { @@ -330,6 +338,12 @@ async fn run_loop( (state.term09.catastrophic_stall_ms / 4).max(10), )); + // Task 9 (host-pressure pane): THIS connection's last `hoststats.refresh` + // stamp — the per-connection 1s floor (legacy parity: + // `ClientState.hostStatsLastRefreshAt`, `ws-handler.ts:3330-3336`). Fresh + // on every (re)connect, exactly like `create_limiter` above. + let mut host_stats_last_refresh_at: Option = None; + // Whether the broadcast bus is still open (guards the select branch so a closed // bus can never busy-loop). The bus outlives every connection in practice. let mut bus_open = true; @@ -402,6 +416,7 @@ async fn run_loop( pane_reconcile_fresh_agent_v1, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await { @@ -587,6 +602,17 @@ async fn run_loop( // it (amplifier watch reduction): the demand-driven subagent rescan cadence // stops when the last interested connection leaves. state.subagent_interest.remove(conn_id); + // Task 9 (host-pressure pane): this connection's `hoststats.subscribe` + // interest is gone with it; when the LAST watcher leaves, the collector's + // cadence JoinHandles are aborted (zero-cost idle) via the trait callback + // (`ws-handler.ts:1297-1298` teardown parity). + if state.host_stats.interest.remove(conn_id) + == crate::host_stats_interest::InterestTransition::BecameIdle + { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(false); + } + } // Multi-client layout store: this connection's mirrored layout snapshot is // gone with it (its pane/tab ids are client-local and unreachable now); // the primary falls back to the most recently synced remaining client. @@ -629,6 +655,8 @@ async fn handle_client_text( pane_reconcile_fresh_agent_v1: bool, create_limiter: &mut crate::create_limit::CreateRateLimiter, create_cancel_rx: &tokio::sync::watch::Receiver, + // Task 9: per-connection hoststats.refresh floor stamp (see run_loop). + host_stats_last_refresh_at: &mut Option, ) -> bool { // Accept-and-strip: unknown/unparseable frames are ignored (matches the // runtime's tolerance; the handshake already gated auth). @@ -1223,6 +1251,117 @@ async fn handle_client_text( .set(conn_id, prefs.include_subagents); true } + // Task 9 (host-pressure pane) — `hoststats.subscribe`. Idempotent; the + // 0->1 interest edge starts the collector cadence; the CURRENT cached + // snapshot goes back to THIS connection immediately (Node + // `setHostStatsSubscribed` + `sendHostStatsSnapshot`, ws-handler.ts + // :3309-3325 — including the idempotent re-send). No collector (unit + // tests): interest is recorded, no snapshot is sent (Node early-return + // when `this.hostStats` is unset). + ClientMessage::HostStatsSubscribe => { + let transition = state + .host_stats + .interest + .set(conn_id, Some(std::sync::Arc::clone(conn_sink))); + if transition == crate::host_stats_interest::InterestTransition::BecameActive { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(true); + } + } + if let Some(collector) = &state.host_stats.collector { + return send( + ws_tx, + &ServerMessage::HostStatsSnapshot(Box::new(collector.snapshot())), + ) + .await; + } + true + } + // `hoststats.unsubscribe` — the 1->0 edge stops the cadence (zero-cost + // idle). No reply frame (Node parity). + ClientMessage::HostStatsUnsubscribe => { + let transition = state.host_stats.interest.remove(conn_id); + if transition == crate::host_stats_interest::InterestTransition::BecameIdle { + if let Some(collector) = &state.host_stats.collector { + collector.set_active(false); + } + } + true + } + // `hoststats.refresh` — on-request manual data. No collector: explicit + // refusal (Node's 'host stats unavailable'). Per-connection 1s floor: + // a repeat <1s after THIS connection's last stamped refresh rejects + // with `rate_limited` WITHOUT invoking the collector (legacy parity: + // `ws-handler.ts:3330-3336`); the stamp is consumed only past the + // floor, BEFORE invoking (a failed invoke still holds the slot). + ClientMessage::HostStatsRefresh(request) => { + let Some(collector) = &state.host_stats.collector else { + return send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some("host stats unavailable".to_string()), + }, + ), + ) + .await; + }; + let now = std::time::Instant::now(); + if let Some(last) = *host_stats_last_refresh_at { + if now.duration_since(last) < HOST_STATS_REFRESH_FLOOR { + return send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some("rate_limited".to_string()), + }, + ), + ) + .await; + } + } + *host_stats_last_refresh_at = Some(now); + match collector.refresh(HOST_STATS_REFRESH_DEADLINE).await { + Ok(ok) => { + send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: true, + at: Some(ok.at), + manual: Some(ok.manual), + error: None, + }, + ), + ) + .await + } + Err(error) => { + send( + ws_tx, + &ServerMessage::HostStatsRefreshResponse( + freshell_protocol::HostStatsRefreshResponse { + request_id: request.request_id.clone(), + ok: false, + at: None, + manual: None, + error: Some(error), + }, + ), + ) + .await + } + } + } // Application-level liveness ping (legacy parity: `ws-handler.ts:1832-1835` // -- `if (m.type === 'ping') { this.send(ws, { type: 'pong', timestamp: // nowIso() }); return }`). Byte-identical reply shape: exactly @@ -6030,6 +6169,7 @@ mod terminals_changed_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), @@ -6268,6 +6408,7 @@ mod terminal_meta_created_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: std::sync::Arc::new(Vec::new()), @@ -6860,6 +7001,7 @@ mod pane_reconcile_gate_tests { tabs: crate::tabs::TabsRegistry::new(), screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), @@ -6889,6 +7031,7 @@ mod pane_reconcile_gate_tests { let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; let keep_open = handle_client_text( r#"{"type":"pane.reconcile.request","reconcileId":"r1","panes":[{"paneKey":"tab-1:pane-1","kind":"terminal","mode":"shell","createRequestId":"cr-1"}]}"#, @@ -6901,6 +7044,7 @@ mod pane_reconcile_gate_tests { false, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await; assert!( @@ -6923,6 +7067,7 @@ mod pane_reconcile_gate_tests { false, &mut create_limiter, &create_cancel_rx, + &mut host_stats_last_refresh_at, ) .await; assert!(pong_ok); @@ -6939,3 +7084,491 @@ mod pane_reconcile_gate_tests { assert_eq!(pong["type"], "pong"); } } + +/// Task 9 (host-pressure pane): the `hoststats.subscribe` / `.unsubscribe` / +/// `.refresh` dispatch arms. A REAL loopback websocket pair (same scaffold as +/// `pane_reconcile_gate_tests`) drives `handle_client_text`'s real +/// serialization + send path; the collector is a fake implementing the +/// freshell-server-owned trait (dependency direction is frozen — freshell-ws +/// can never import the concrete collector). +#[cfg(test)] +mod host_stats_dispatch_tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Mutex as StdMutex; + use std::time::Duration; + + use freshell_protocol::{ + HostStatsCpu, HostStatsDiskIo, HostStatsFreshell, HostStatsInotify, HostStatsLimits, + HostStatsLive, HostStatsLoad, HostStatsMachine, HostStatsManual, HostStatsMemory, + HostStatsNetwork, HostStatsPaging, HostStatsProcessHealth, HostStatsPsi, HostStatsSnapshot, + HostStatsThermals, HostStatsTopProcesses, + }; + + use crate::host_stats_collector::{ + HostStatsCollector, HostStatsRefreshFuture, HostStatsRefreshOk, WsHostStatsState, + }; + use crate::host_stats_interest::HostStatsInterestRegistry; + + fn canned_snapshot() -> HostStatsSnapshot { + HostStatsSnapshot { + at: 111, + live: HostStatsLive { + machine: HostStatsMachine { + cores: 4, + mem_total_bytes: 1024, + platform: "linux".to_string(), + wsl: false, + kernel: None, + hostname: None, + psi: false, + cgroup: "none".to_string(), + thermal_count: 0, + battery_present: false, + gpu: "none".to_string(), + }, + cpu: HostStatsCpu { + available: true, + usage_pct: 0.0, + steal_pct: None, + per_core_pct: vec![0.0; 4], + freq_m_hz: None, + }, + load: HostStatsLoad { + available: true, + load1: 0.0, + load5: 0.0, + load15: 0.0, + cores: 4, + }, + memory: HostStatsMemory { + available: false, + source: "host".to_string(), + total_bytes: 0, + used_bytes: 0, + available_bytes: 0, + cgroup_limit_bytes: None, + swap_total_bytes: None, + swap_used_bytes: None, + }, + paging: HostStatsPaging { + available: false, + swap_in_kbps: 0.0, + swap_out_kbps: 0.0, + maj_faults_per_sec: 0.0, + oom_kills_delta: 0, + oom_kills_total: 0, + }, + psi: HostStatsPsi { + available: false, + cpu_some10: None, + mem_some10: None, + mem_full10: None, + io_some10: None, + io_full10: None, + }, + disk_io: HostStatsDiskIo { + available: false, + read_bps: 0.0, + write_bps: 0.0, + util_pct: None, + weighted_await_ms: None, + }, + network: HostStatsNetwork { + available: false, + rx_bps: 0.0, + tx_bps: 0.0, + rx_errors_total: 0, + tx_errors_total: 0, + rx_dropped_total: 0, + tx_dropped_total: 0, + rx_errors_delta: 0, + tx_errors_delta: 0, + rx_dropped_delta: 0, + tx_dropped_delta: 0, + }, + limits: HostStatsLimits { + available: false, + fds_used: None, + fds_max: None, + pids_used: None, + pids_max: None, + time_wait: None, + ephemeral_ports: None, + }, + freshell: HostStatsFreshell { + available: true, + source: "rust".to_string(), + ptys_running: 0, + ptys_max: 0, + ws_clients: 0, + ws_clients_max: 0, + event_loop_lag_p99_ms: None, + rss_bytes: None, + uptime_sec: 0.0, + }, + }, + manual_at: Some(222), + manual: Some(HostStatsManual { + top_processes: HostStatsTopProcesses { + available: false, + dwell_ms: 0, + list: Vec::new(), + }, + process_health: HostStatsProcessHealth { + available: false, + zombies: 0, + d_state: 0, + total: 0, + }, + inotify: HostStatsInotify { + available: false, + instances: None, + watches: None, + max_user_watches: None, + max_user_instances: None, + }, + disks: freshell_protocol::HostStatsDisks { + available: false, + list: Vec::new(), + }, + thermals: HostStatsThermals { + available: false, + zones: Vec::new(), + battery: None, + }, + section_errors: Default::default(), + }), + } + } + + struct FakeCollector { + refresh_calls: Arc, + set_active_calls: Arc>>, + } + + impl HostStatsCollector for FakeCollector { + fn snapshot(&self) -> HostStatsSnapshot { + canned_snapshot() + } + fn refresh(&self, _deadline: Duration) -> HostStatsRefreshFuture<'_> { + self.refresh_calls.fetch_add(1, Ordering::SeqCst); + Box::pin(async move { + Ok(HostStatsRefreshOk { + at: 333, + manual: canned_snapshot().manual.unwrap(), + }) + }) + } + fn set_active(&self, active: bool) { + self.set_active_calls.lock().unwrap().push(active); + } + } + + /// Same REAL loopback pair scaffold as `pane_reconcile_gate_tests`: the + /// upgrade handler parks forever; the listener task dies with the test + /// runtime. + type TestClient = tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >; + + async fn loopback_sink_and_client() -> (WsSink, TestClient) { + let (sink_tx, sink_rx) = tokio::sync::oneshot::channel::(); + let sink_tx = std::sync::Arc::new(tokio::sync::Mutex::new(Some(sink_tx))); + let router = axum::Router::new().route( + "/ws", + axum::routing::any(move |upgrade: axum::extract::ws::WebSocketUpgrade| { + let sink_tx = std::sync::Arc::clone(&sink_tx); + async move { + upgrade.on_upgrade(move |socket| async move { + let (sink, _read) = socket.split(); + if let Some(tx) = sink_tx.lock().await.take() { + let _ = tx.send(sink); + } + std::future::pending::<()>().await; + }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind ephemeral loopback port"); + let addr = listener.local_addr().expect("loopback local addr"); + tokio::spawn(async move { + let _ = axum::serve(listener, router).await; + }); + let (client, _resp) = tokio_tungstenite::connect_async(format!("ws://{addr}/ws")) + .await + .expect("ws connect to scratch server"); + let sink = sink_rx + .await + .expect("upgrade handler delivered the write half"); + (sink, client) + } + + async fn next_text_frame(client: &mut TestClient) -> serde_json::Value { + let msg = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("frame within timeout") + .expect("stream not ended") + .expect("no ws error"); + match msg { + tokio_tungstenite::tungstenite::Message::Text(text) => { + serde_json::from_str(&text).expect("json frame") + } + other => panic!("expected a text frame, got {other:?}"), + } + } + + fn state_with_host_stats(host_stats: WsHostStatsState) -> WsState { + let auth_token = Arc::new("s3cr3t-token-abcdef".to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); + WsState { + pane_ledger: std::sync::Arc::new(crate::pane_ledger::PaneLedger::disabled()), + layout: Default::default(), + identity: crate::identity::TerminalIdentityRegistry::new(), + terminal_meta: Default::default(), + auth_token: Arc::clone(&auth_token), + server_instance_id: Arc::new("srv-1111".to_string()), + boot_id: Arc::new("boot-2222".to_string()), + settings: Arc::new(crate::test_settings()), + handshake_settings: Arc::new(tokio::sync::RwLock::new(crate::test_settings())), + broadcast_tx: Arc::clone(&broadcast_tx), + auto_resume_tx: tokio::sync::mpsc::unbounded_channel().0, + auto_resume_cancels: Default::default(), + fresh_codex: freshell_freshagent::FreshCodexState::new( + Arc::clone(&auth_token), + Arc::clone(&broadcast_tx), + serde_json::json!({ "freshAgent": { "enabled": false } }), + ), + fresh_claude: freshell_freshagent::FreshClaudeState::new(Arc::clone(&broadcast_tx)), + fresh_opencode: freshell_freshagent::FreshOpencodeState::new( + freshell_freshagent::FreshAgentState::new(auth_token, Arc::clone(&broadcast_tx)), + ), + registry: freshell_terminal::TerminalRegistry::new(), + shutdown: Arc::new(tokio::sync::Notify::new()), + tabs: crate::tabs::TabsRegistry::new(), + screenshots: crate::screenshot::ScreenshotBroker::new(broadcast_tx), + subagent_interest: Default::default(), + host_stats, + terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), + cli_commands: Arc::new(Vec::new()), + ping_interval_ms: 30_000, + hello_timeout_ms: 5_000, + allowed_origins: Arc::new(crate::origin::default_allowed_origins()), + ws_max_payload_bytes: 16 * 1024 * 1024, + term09: crate::backpressure::Term09Config::default(), + create_protect: crate::create_limit::CreateProtectConfig::default(), + spawn_gate: std::sync::Arc::new(crate::spawn_gate::SpawnGate::new(4, 64)), + shutdown_started: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + create_dedupe: std::sync::Arc::new(crate::create_dedupe::CreateDedupe::default()), + config_fallback: None, + opencode_locator: None, + codex_locator: None, + activity: None, + session_existence: std::sync::Arc::new(crate::existence::NoIndexProbe::default()), + reconcile_deferral_budget_ms: crate::reconcile::RECONCILE_DEFERRAL_BUDGET_MS_DEFAULT, + fresh_agent_respawn_counts: Default::default(), + } + } + + #[tokio::test] + async fn host_stats_subscribe_snapshot_and_set_active_edges() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let interest = HostStatsInterestRegistry::default(); + let fake = Arc::new(FakeCollector { + refresh_calls: Arc::new(AtomicUsize::new(0)), + set_active_calls: Arc::new(StdMutex::new(Vec::new())), + }); + let state = state_with_host_stats(WsHostStatsState { + interest: interest.clone(), + collector: Some(fake.clone()), + }); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + // subscribe: 0->1 edge drives set_active(true) ONCE and the current + // snapshot is sent immediately Node `sendHostStatsSnapshot` parity, + // including idempotent re-subscribe (no double edge, re-sent frame). + for round in 0..2 { + let ok = handle_client_text( + r#"{"type":"hoststats.subscribe"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let frame = next_text_frame(&mut client).await; + assert_eq!(frame["type"], "hoststats.snapshot", "round {round}"); + assert_eq!(frame["at"], 111); + assert_eq!(frame["manualAt"], 222); + assert_eq!( + fake.set_active_calls.lock().unwrap().clone(), + vec![true], + "re-subscribe must not double-fire the 0->1 edge (round {round})" + ); + } + assert!(state.host_stats.interest.any()); + assert_eq!(state.host_stats.interest.count(), 1); + + // unsubscribe: 1->0 edge drives set_active(false) ONCE; no reply + // frame (Node parity) — proven by the followed ping answering pong. + let ok = handle_client_text( + r#"{"type":"hoststats.unsubscribe"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + assert!(!state.host_stats.interest.any()); + assert_eq!( + fake.set_active_calls.lock().unwrap().clone(), + vec![true, false] + ); + let pong_ok = handle_client_text( + r#"{"type":"ping"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(pong_ok); + let pong = next_text_frame(&mut client).await; + assert_eq!(pong["type"], "pong", "unsubscribe itself sends no frame"); + } + + #[tokio::test] + async fn host_stats_refresh_per_connection_floor_rate_limits_without_invoking_collector() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let interest = HostStatsInterestRegistry::default(); + let fake = Arc::new(FakeCollector { + refresh_calls: Arc::new(AtomicUsize::new(0)), + set_active_calls: Arc::new(StdMutex::new(Vec::new())), + }); + let state = state_with_host_stats(WsHostStatsState { + interest: interest.clone(), + collector: Some(fake.clone()), + }); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + // First refresh passes the floor and invokes the collector. + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r1"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let first = next_text_frame(&mut client).await; + assert_eq!(first["type"], "hoststats.refresh.response"); + assert_eq!(first["requestId"], "r1"); + assert_eq!(first["ok"], true); + assert_eq!(first["at"], 333); + assert!(first["manual"].is_object()); + assert_eq!(fake.refresh_calls.load(Ordering::SeqCst), 1); + + // Second refresh <1s later is rejected by the PER-CONNECTION floor + // WITHOUT invoking the collector (the service single-flight/cooldown + // is downstream and never reached). + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r2"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let second = next_text_frame(&mut client).await; + assert_eq!(second["type"], "hoststats.refresh.response"); + assert_eq!(second["requestId"], "r2"); + assert_eq!(second["ok"], false); + assert_eq!(second["error"], "rate_limited"); + // zod `.optional()` discipline: at/manual are ABSENT on the reject, + // never explicit null. + assert!(second.get("at").is_none()); + assert!(second.get("manual").is_none()); + assert_eq!( + fake.refresh_calls.load(Ordering::SeqCst), + 1, + "the rate-limited repeat never reaches the collector" + ); + } + + #[tokio::test] + async fn host_stats_refresh_without_collector_reports_unavailable() { + let (mut ws_tx, mut client) = loopback_sink_and_client().await; + let state = state_with_host_stats(WsHostStatsState::default()); + let conn_sink: FrameSink = std::sync::Arc::new(|_| {}); + let mut create_limiter = crate::create_limit::CreateRateLimiter::new(8, 60_000); + let (_cancel_tx, create_cancel_rx) = tokio::sync::watch::channel(false); + let mut host_stats_last_refresh_at = None; + + let ok = handle_client_text( + r#"{"type":"hoststats.refresh","requestId":"r9"}"#, + &mut ws_tx, + &state, + 1, + &conn_sink, + false, + false, + false, + &mut create_limiter, + &create_cancel_rx, + &mut host_stats_last_refresh_at, + ) + .await; + assert!(ok); + let frame = next_text_frame(&mut client).await; + assert_eq!(frame["type"], "hoststats.refresh.response"); + assert_eq!(frame["requestId"], "r9"); + assert_eq!(frame["ok"], false); + assert_eq!(frame["error"], "host stats unavailable"); + // A rejected refresh must NOT claim the floor slot (Node stamps only + // after passing the floor, with a live service). + assert!(host_stats_last_refresh_at.is_none()); + } +} diff --git a/crates/freshell-ws/tests/auto_resume_respawn.rs b/crates/freshell-ws/tests/auto_resume_respawn.rs index 6aed49dcb..aaf7bec4f 100644 --- a/crates/freshell-ws/tests/auto_resume_respawn.rs +++ b/crates/freshell-ws/tests/auto_resume_respawn.rs @@ -395,6 +395,7 @@ fn respawn_state_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![common::sleeper_cli_spec("amplifier")]), diff --git a/crates/freshell-ws/tests/claude_session_rebind.rs b/crates/freshell-ws/tests/claude_session_rebind.rs index 8274045f7..77850b4fc 100644 --- a/crates/freshell-ws/tests/claude_session_rebind.rs +++ b/crates/freshell-ws/tests/claude_session_rebind.rs @@ -169,6 +169,7 @@ async fn spawn_server_returning_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), diff --git a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs index c13f4c11b..958080725 100644 --- a/crates/freshell-ws/tests/codex_managed_launch_e2e.rs +++ b/crates/freshell-ws/tests/codex_managed_launch_e2e.rs @@ -151,6 +151,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/codex_session_ref_resume.rs b/crates/freshell-ws/tests/codex_session_ref_resume.rs index 8d1fd7bf7..fa17c9fe7 100644 --- a/crates/freshell-ws/tests/codex_session_ref_resume.rs +++ b/crates/freshell-ws/tests/codex_session_ref_resume.rs @@ -141,6 +141,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs b/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs index 4a207e166..512ba5466 100644 --- a/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs +++ b/crates/freshell-ws/tests/codex_sidecar_reattach_e2e.rs @@ -295,6 +295,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/common/mod.rs b/crates/freshell-ws/tests/common/mod.rs index ad282c923..d7ecfab43 100644 --- a/crates/freshell-ws/tests/common/mod.rs +++ b/crates/freshell-ws/tests/common/mod.rs @@ -172,6 +172,7 @@ pub async fn spawn_server_with_specs_and_shared_settings( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -251,6 +252,7 @@ pub async fn spawn_server_with_specs( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -332,6 +334,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_rx( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -417,6 +420,7 @@ pub async fn spawn_server_with_specs_and_auto_resume_hub( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -499,6 +503,7 @@ pub async fn spawn_server_with_specs_and_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -589,6 +594,7 @@ pub async fn spawn_server_with_ledger( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -675,6 +681,7 @@ pub async fn spawn_server_with_specs_and_activity( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -760,6 +767,7 @@ pub async fn spawn_server_with_specs_activity_and_codex_locator( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), @@ -867,6 +875,7 @@ pub async fn spawn_server_with_create_protect_probes( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/cross_kind_liveness.rs b/crates/freshell-ws/tests/cross_kind_liveness.rs index 42f21200e..1e46e4deb 100644 --- a/crates/freshell-ws/tests/cross_kind_liveness.rs +++ b/crates/freshell-ws/tests/cross_kind_liveness.rs @@ -271,6 +271,7 @@ async fn spawn_server() -> (String, freshell_terminal::TerminalRegistry) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![sleeper_cli_spec("claude")]), diff --git a/crates/freshell-ws/tests/diag01_lifecycle_events.rs b/crates/freshell-ws/tests/diag01_lifecycle_events.rs index 7cb7b33a8..5c2f688e2 100644 --- a/crates/freshell-ws/tests/diag01_lifecycle_events.rs +++ b/crates/freshell-ws/tests/diag01_lifecycle_events.rs @@ -240,6 +240,7 @@ async fn spawn_server(ping_interval_ms: u64) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_claude_attach.rs b/crates/freshell-ws/tests/freshagent_claude_attach.rs index c21047215..83f63d6a2 100644 --- a/crates/freshell-ws/tests/freshagent_claude_attach.rs +++ b/crates/freshell-ws/tests/freshagent_claude_attach.rs @@ -202,6 +202,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs index 30c988f71..14dbded61 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_interrupt.rs @@ -199,6 +199,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/freshagent_session_lease.rs b/crates/freshell-ws/tests/freshagent_session_lease.rs index 25fe78147..1a43d8e26 100644 --- a/crates/freshell-ws/tests/freshagent_session_lease.rs +++ b/crates/freshell-ws/tests/freshagent_session_lease.rs @@ -306,6 +306,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/hello_timeout.rs b/crates/freshell-ws/tests/hello_timeout.rs index 36fd28ce1..d0bbf319d 100644 --- a/crates/freshell-ws/tests/hello_timeout.rs +++ b/crates/freshell-ws/tests/hello_timeout.rs @@ -84,6 +84,7 @@ async fn spawn_server(hello_timeout_ms: u64) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/keepalive.rs b/crates/freshell-ws/tests/keepalive.rs index 8b4dc31f2..e03f2e796 100644 --- a/crates/freshell-ws/tests/keepalive.rs +++ b/crates/freshell-ws/tests/keepalive.rs @@ -85,6 +85,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/max_payload.rs b/crates/freshell-ws/tests/max_payload.rs index 4c2424d94..78a416ec5 100644 --- a/crates/freshell-ws/tests/max_payload.rs +++ b/crates/freshell-ws/tests/max_payload.rs @@ -85,6 +85,7 @@ async fn spawn_server(ws_max_payload_bytes: usize) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/opencode_switch_rebind.rs b/crates/freshell-ws/tests/opencode_switch_rebind.rs index a17a20bef..39703c638 100644 --- a/crates/freshell-ws/tests/opencode_switch_rebind.rs +++ b/crates/freshell-ws/tests/opencode_switch_rebind.rs @@ -240,6 +240,7 @@ async fn spawn_server_returning_state( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(cli_commands), diff --git a/crates/freshell-ws/tests/origin_policy.rs b/crates/freshell-ws/tests/origin_policy.rs index cfd9daed6..895aa0507 100644 --- a/crates/freshell-ws/tests/origin_policy.rs +++ b/crates/freshell-ws/tests/origin_policy.rs @@ -75,6 +75,7 @@ async fn spawn_server(allowed_origins: Vec) -> (String, String) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/pane_reconcile.rs b/crates/freshell-ws/tests/pane_reconcile.rs index 3d255daf1..0f5db1eff 100644 --- a/crates/freshell-ws/tests/pane_reconcile.rs +++ b/crates/freshell-ws/tests/pane_reconcile.rs @@ -157,6 +157,7 @@ async fn spawn_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs index c235cc6d8..7336a405f 100644 --- a/crates/freshell-ws/tests/pane_reconcile_freshagent.rs +++ b/crates/freshell-ws/tests/pane_reconcile_freshagent.rs @@ -227,6 +227,7 @@ async fn spawn_server_with_probe(probe: Arc) -> Server { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/rest_claude_identity.rs b/crates/freshell-ws/tests/rest_claude_identity.rs index a6ec5fa09..9fa0f6f26 100644 --- a/crates/freshell-ws/tests/rest_claude_identity.rs +++ b/crates/freshell-ws/tests/rest_claude_identity.rs @@ -94,6 +94,7 @@ async fn spawn_merged_server() -> Harness { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::clone(&cli_commands), diff --git a/crates/freshell-ws/tests/rest_locator_identity.rs b/crates/freshell-ws/tests/rest_locator_identity.rs index 503f8244e..09062efbd 100644 --- a/crates/freshell-ws/tests/rest_locator_identity.rs +++ b/crates/freshell-ws/tests/rest_locator_identity.rs @@ -118,6 +118,7 @@ async fn spawn_merged_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::clone(&cli_commands), diff --git a/crates/freshell-ws/tests/rest_ws_shared_gate.rs b/crates/freshell-ws/tests/rest_ws_shared_gate.rs index 02d88907e..675544ffe 100644 --- a/crates/freshell-ws/tests/rest_ws_shared_gate.rs +++ b/crates/freshell-ws/tests/rest_ws_shared_gate.rs @@ -154,6 +154,7 @@ async fn spawn_combined_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/restore_plan_queue_cap.rs b/crates/freshell-ws/tests/restore_plan_queue_cap.rs index d32f047ad..1c008b700 100644 --- a/crates/freshell-ws/tests/restore_plan_queue_cap.rs +++ b/crates/freshell-ws/tests/restore_plan_queue_cap.rs @@ -128,6 +128,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/restore_spawn_gate.rs b/crates/freshell-ws/tests/restore_spawn_gate.rs index d07b29c40..5d1b8a678 100644 --- a/crates/freshell-ws/tests/restore_spawn_gate.rs +++ b/crates/freshell-ws/tests/restore_spawn_gate.rs @@ -124,6 +124,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/restore_storm.rs b/crates/freshell-ws/tests/restore_storm.rs index 8972011b0..f038419d0 100644 --- a/crates/freshell-ws/tests/restore_storm.rs +++ b/crates/freshell-ws/tests/restore_storm.rs @@ -134,6 +134,7 @@ async fn spawn_server( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ diff --git a/crates/freshell-ws/tests/resume_validation_gate.rs b/crates/freshell-ws/tests/resume_validation_gate.rs index 700a58575..e3c22efaa 100644 --- a/crates/freshell-ws/tests/resume_validation_gate.rs +++ b/crates/freshell-ws/tests/resume_validation_gate.rs @@ -167,6 +167,7 @@ async fn spawn_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![ @@ -362,6 +363,7 @@ async fn spawn_managed_codex_server_with_probe( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(vec![codex_cli_spec()]), diff --git a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs index 2f9060047..57e181f16 100644 --- a/crates/freshell-ws/tests/safe08_restore_diagnostics.rs +++ b/crates/freshell-ws/tests/safe08_restore_diagnostics.rs @@ -171,6 +171,7 @@ async fn spawn_server() -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/sessions_prefs.rs b/crates/freshell-ws/tests/sessions_prefs.rs index 02eec51d0..7b53fbf7e 100644 --- a/crates/freshell-ws/tests/sessions_prefs.rs +++ b/crates/freshell-ws/tests/sessions_prefs.rs @@ -89,6 +89,7 @@ async fn spawn_server() -> ( tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: interest.clone(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/term09_output_queue.rs b/crates/freshell-ws/tests/term09_output_queue.rs index f58dd4fa4..127cbb59d 100644 --- a/crates/freshell-ws/tests/term09_output_queue.rs +++ b/crates/freshell-ws/tests/term09_output_queue.rs @@ -78,6 +78,7 @@ async fn spawn_server(term09: Term09Config) -> String { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/crates/freshell-ws/tests/ui_layout_sync.rs b/crates/freshell-ws/tests/ui_layout_sync.rs index 5c9cb10ca..970a09537 100644 --- a/crates/freshell-ws/tests/ui_layout_sync.rs +++ b/crates/freshell-ws/tests/ui_layout_sync.rs @@ -104,6 +104,7 @@ async fn spawn_server() -> (String, String, LayoutStore) { tabs: freshell_ws::tabs::TabsRegistry::new(), screenshots: freshell_ws::screenshot::ScreenshotBroker::new(Arc::clone(&broadcast_tx)), subagent_interest: Default::default(), + host_stats: Default::default(), terminals_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), sessions_revision: Arc::new(std::sync::atomic::AtomicI64::new(0)), cli_commands: Arc::new(Vec::new()), diff --git a/docs/index.html b/docs/index.html index 94cac5018..3947faed2 100644 --- a/docs/index.html +++ b/docs/index.html @@ -752,6 +752,13 @@ Shell S + +
+
diff --git a/docs/plans/2026-08-25-host-pressure-pane.md b/docs/plans/2026-08-25-host-pressure-pane.md new file mode 100644 index 000000000..7049296e1 --- /dev/null +++ b/docs/plans/2026-08-25-host-pressure-pane.md @@ -0,0 +1,999 @@ +# Host Pressure Pane Implementation Plan + +> **For agentic workers:** Execute this plan task by task with a fresh +> implementer and a specification-plus-quality review after every task. Track +> progress with the checkbox steps below. + +**Goal:** Freshell gains a `host-stats` pane — an at-a-glance host load dashboard (CPU, memory, paging, PSI, disk I/O, network, limits, Freshell's own footprint, plus on-request heavy measurements) that costs the host nothing while no pane watches it. + +**Architecture:** One server-side collector (`server/host-stats/`) reads `/proc`+`/sys` directly on two cadence tiers (2s fast / 5s slow) ONLY while ≥1 WebSocket client is subscribed; heavier sections refresh strictly on explicit request (`hoststats.refresh`) with per-section time budgets, single-flight suppression, and previous-value retention on failure. Snapshots flow to subscribers over WS (`hoststats.snapshot`); the client renders tiles from a new `hostStatsSlice`, with an on-request group that shared-ramp desaturates (full color ≤30s → fully grey at 5min). The pane kind `host-stats` is gated by a new `hostStatsAvailable` feature flag (true on linux/wsl/darwin, false on win32). Full Rust-server parity: protocol discriminants in `crates/freshell-protocol`, collector in `crates/freshell-server/src/host_stats.rs`, flag in `build_platform_payload`. + +**Tech Stack:** TypeScript/React/Redux Toolkit client (Vitest + Testing Library), Node/Express+ws server (Vitest + raw `ws` client tests), Rust workspace (cargo test), frozen wire contract (`npm run contract:generate` + `npm run test:port`). + +## Global Constraints + +- **Test command env:** every vitest/npm `test` command in this plan MUST be prefixed with `env -u FRESHELL_BIND_HOST` — the orchestrator session exports `FRESHELL_BIND_HOST=0.0.0.0`, which fails `test/unit/vite-config.test.ts` (3 tests) by design of `getNetworkHost()`. Unprefixed runs show a false failure. +- **Repo-owned test paths only:** focused vitest via `npm run test:vitest -- run [--config config/vitest/vitest.server.config.ts]`; never raw `npx vitest`. Broad suites go through the coordinated runner (`npm test` etc.) at Stage 5's gate, not per task. +- **Server is NodeNext/ESM:** relative imports in `server/`/`shared/` must include `.js` extensions. +- **Client MUST import shared protocol with `import type`** (no zod runtime in the bundle) — `shared/ws-protocol.ts:1-8`. +- **Frozen wire contract:** any change to `shared/ws-protocol.ts` requires `npm run contract:generate` and committing the regenerated `port/contract/*` artifacts in the SAME commit; `npm run test:port` and `cargo test -p freshell-protocol --locked` must pass (the Rust inventory tests at `crates/freshell-protocol/tests/inventory.rs` hardcode type counts — update the counts with the discriminants). +- **No `WS_PROTOCOL_VERSION` bump** — additive messages follow the accept-and-strip precedent (`shared/ws-protocol.ts:376-381` comment). +- **No new runtime dependencies.** Node ≥22.5.0 (package.json engines). +- **Collector rules:** recurring paths are direct `/proc`+`/sys` reads only — NO subprocesses ever in recurring paths; NO subprocess at all on darwin except the single allowed `ps` call inside the on-request refresh. All collector timers `.unref?.()`. All per-section failures degrade that section to `{ available: false }` — never fail a whole snapshot or response. +- **Structured logging:** pino child `logger.child({ component: 'host-stats' })`, fields-first, stable `event:` snake_case keys, errors carry `{ err }` (convention: `server/index.ts:169`, `server/perf-logger.ts`). +- **A11y:** real ` + {ageText} + {refresh.error !== null ? ( +
{refresh.error}
+ ) : null} + + +
+ {renderManualTiles(manualAt === null ? null : manual)} +
+ + + + {/* One-shot completion announcement; the 1s-updating age label above is + deliberately not a live region. */} +
{announcement}
+ + ) +} + +/** Worst capped sub-limit as the Limits headline; all no-cap → em dash. */ +function limitsValue(limits: HostStatsLive['limits']): string { + const pcts: number[] = [] + if (limits.fdsUsed !== null && limits.fdsMax !== null && limits.fdsMax > 0) { + pcts.push((limits.fdsUsed / limits.fdsMax) * 100) + } + if (limits.pidsUsed !== null && limits.pidsMax !== null && limits.pidsMax > 0) { + pcts.push((limits.pidsUsed / limits.pidsMax) * 100) + } + if (limits.timeWait !== null && limits.ephemeralPorts !== null && limits.ephemeralPorts > 0) { + pcts.push((limits.timeWait / limits.ephemeralPorts) * 100) + } + return pcts.length > 0 ? formatPercent(Math.max(...pcts)) : EM_DASH +} + +/** + * On-request tiles. A null manual is the never-measured state (manualAt === + * null): every tile renders '—' placeholders. A degraded section + * (available:false inside a filled manual) renders '—' per value. + */ +function renderManualTiles(manual: HostStatsManual | null): ReactNode { + const topProcesses = manual?.topProcesses.available === true ? manual.topProcesses : null + const processHealth = manual?.processHealth.available === true ? manual.processHealth : null + const inotify = manual?.inotify.available === true ? manual.inotify : null + const disks = manual?.disks.available === true ? manual.disks : null + const thermals = manual?.thermals.available === true ? manual.thermals : null + + return ( + <> + ( +
+ {proc.name} + {formatPercent(proc.cpuPct)} + {formatBytes(proc.rssBytes)} + {proc.state} +
+ )) : null} + /> + + + + + ) : null} + /> + + ) : null} + /> + 0 + ? formatPercent(Math.max(...disks.list.map((disk) => disk.usedPct))) + : EM_DASH} + rows={disks ? disks.list.map((disk) => ( + + )) : null} + /> + 0 + ? `${Math.max(...thermals.zones.map((zone) => zone.celsius)).toFixed(1)}°C` + : EM_DASH} + rows={thermals ? ( + <> + {thermals.zones.map((zone) => ( + + ))} + + + ) : null} + /> + + ) +} diff --git a/src/components/panes/PaneContainer.tsx b/src/components/panes/PaneContainer.tsx index 259db55a9..f0c984442 100644 --- a/src/components/panes/PaneContainer.tsx +++ b/src/components/panes/PaneContainer.tsx @@ -9,6 +9,7 @@ import TerminalView from '../TerminalView' import BrowserPane from './BrowserPane' import FreshAgentView from '../fresh-agent/FreshAgentView' import ExtensionPane from './ExtensionPane' +import HostStatsPane from './HostStatsPane' import PanePicker, { type PanePickerType } from './PanePicker' import DirectoryPicker from './DirectoryPicker' import { getProviderLabel, isCodingCliProviderName } from '@/lib/coding-cli-utils' @@ -742,6 +743,8 @@ function PickerWrapper({ viewMode: 'source', wordWrap: true, } + case 'host-stats': + return { kind: 'host-stats' } default: throw new Error(`Unsupported pane type: ${String(type)}`) } @@ -885,6 +888,14 @@ function renderContent( ) } + if (content.kind === 'host-stats') { + return ( + + + + ) + } + if (content.kind === 'picker') { return ( > @@ -40,6 +40,10 @@ const nonShellOptions: PickerOption[] = [ { type: 'browser', label: 'Browser', icon: Globe, shortcut: 'B' }, ] +// Host pressure dashboard (plan-pane-types §3c): the server-derived flag +// already encodes platform support; the platform clause is belt-and-braces. +const hostStatsOption: PickerOption = { type: 'host-stats', label: 'Host Stats', icon: Gauge, shortcut: 'H' } + const EMPTY_AVAILABLE_CLIS: Record = {} const EMPTY_FEATURE_FLAGS: Record = {} const EMPTY_ENABLED_PROVIDERS: CodingCliProviderName[] = [] @@ -136,8 +140,16 @@ export default function PanePicker({ onSelect, onCancel, isOnlyPane, tabId, pane shortcut: ext.picker?.shortcut ?? '', })) - // Order: fresh-agent clients (before), CLIs, fresh-agent clients (after), Editor, Browser, Shell(s), Extensions - return [...freshAgentOptionsBeforeCli, ...cliOptions, ...freshAgentOptionsAfterCli, ...nonShellOptions, ...shellOptions, ...extensionOptions] + // Host Stats: gated on the server-advertised capability flag (which is + // false on win32) plus a direct platform clause; inserted before the + // non-shell options. First-match-wins shortcut dispatch accepts an 'H' + // collision with an H-named extension as cosmetic. + const hostStatsOptions = featureFlags.hostStatsAvailable === true && platform !== 'win32' + ? [hostStatsOption] + : [] + + // Order: fresh-agent clients (before), CLIs, fresh-agent clients (after), Host Stats, Editor, Browser, Shell(s), Extensions + return [...freshAgentOptionsBeforeCli, ...cliOptions, ...freshAgentOptionsAfterCli, ...hostStatsOptions, ...nonShellOptions, ...shellOptions, ...extensionOptions] }, [platform, availableClis, featureFlags, enabledProviders, disabledExtensions, freshClientsEnabled, extensionEntries]) const [focusedIndex, setFocusedIndex] = useState(null) diff --git a/src/lib/derivePaneTitle.ts b/src/lib/derivePaneTitle.ts index b5fc1da1d..01df9bfdb 100644 --- a/src/lib/derivePaneTitle.ts +++ b/src/lib/derivePaneTitle.ts @@ -39,6 +39,10 @@ export function derivePaneTitle(content: PaneContent, extensions?: ClientExtensi return content.extensionName } + if (content.kind === 'host-stats') { + return 'Host Stats' + } + // Terminal content — coding-agent (non-shell) terminals name by working directory if (isNonShellMode(content.mode)) { const segment = content.initialCwd ? basenameSegment(content.initialCwd) : null diff --git a/src/lib/host-stats-format.ts b/src/lib/host-stats-format.ts new file mode 100644 index 000000000..b48651943 --- /dev/null +++ b/src/lib/host-stats-format.ts @@ -0,0 +1,53 @@ +/** + * Pure display formatters for host-stat tiles. No threshold logic lives here + * (thresholds are in host-stats-status.ts); these never return throws on + * degenerate input — non-finite/negative values render as '—' so a tile can + * never lie with a synthesized zero. + */ + +const BYTE_UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'] as const + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '—' + let value = bytes + let unit = 0 + while (value >= 1024 && unit < BYTE_UNITS.length - 1) { + value /= 1024 + unit += 1 + } + const text = unit === 0 || value >= 100 + ? String(Math.round(value)) + : value >= 10 ? value.toFixed(1) : value.toFixed(2) + return `${text} ${BYTE_UNITS[unit]}` +} + +/** Bytes-per-second rates (diskIo.readBps/writeBps, network.rxBps/txBps). */ +export function formatBytesPerSec(bytesPerSec: number): string { + const rendered = formatBytes(bytesPerSec) + return rendered === '—' ? rendered : `${rendered}/s` +} + +export function formatPercent(pct: number): string { + if (!Number.isFinite(pct)) return '—' + return `${pct >= 100 ? Math.round(pct) : pct.toFixed(1)}%` +} + +/** Sub-second millisecond values (disk await, event-loop lag p99). */ +export function formatMs(ms: number): string { + if (!Number.isFinite(ms) || ms < 0) return '—' + if (ms >= 100) return `${Math.round(ms)} ms` + if (ms >= 10) return `${ms.toFixed(1)} ms` + return `${ms.toFixed(2)} ms` +} + +/** Uptime-style durations: '45s', '12m', '3h 12m', '2d 5h'. */ +export function formatUptimeSec(totalSeconds: number): string { + if (!Number.isFinite(totalSeconds) || totalSeconds < 0) return '—' + const s = Math.floor(totalSeconds) + if (s < 60) return `${s}s` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ${m % 60}m` + return `${Math.floor(h / 24)}d ${h % 24}h` +} diff --git a/src/lib/host-stats-status.ts b/src/lib/host-stats-status.ts new file mode 100644 index 000000000..9d25259f7 --- /dev/null +++ b/src/lib/host-stats-status.ts @@ -0,0 +1,151 @@ +import type { HostStatsLive } from '@shared/ws-protocol' + +/** + * Pure status-word mapping for host-stat tiles (docs/plans/2026-08-25-host-pressure-pane.md + * "Pane/component contract"). All threshold logic lives here; components never embed it. + * + * Severity mapping (frozen per this module): ok → ok; busy/tight/swapping/slow/errors/lagging + * → warn; maxed/full/thrashing/stalled/blocked → bad. Every function degrades to + * 'unknown' (ok-grey) when its section is !available. + */ + +export type StatusWord = 'ok' | 'busy' | 'maxed' | 'tight' | 'full' | 'swapping' | 'thrashing' | 'stalled' | 'slow' + | 'errors' | 'lagging' | 'blocked' | 'unknown' +export type Severity = 'ok' | 'warn' | 'bad' +export interface TileStatus { severity: Severity; word: string } // word is the DISPLAY word (uppercased at render) + +const UNKNOWN: TileStatus = { severity: 'ok', word: 'unknown' } +const OK: TileStatus = { severity: 'ok', word: 'ok' } + +export const HOST_STATS_THRESHOLDS = { + cpuBusyPct: 80, + cpuMaxedPct: 95, + memoryTightPct: 85, + memoryFullPct: 97, + pagingThrashingKbps: 5000, + psiStalledFull10: 1.0, + diskIoSlowAwaitMs: 20, + diskIoStalledAwaitMs: 100, + limitsTightPct: 70, + limitsFullPct: 90, + freshellLaggingMs: 50, + freshellBlockedMs: 500, +} as const + +export function cpuStatus(l: HostStatsLive): TileStatus { + if (!l.cpu.available) return UNKNOWN + const pct = l.cpu.usagePct + if (pct >= HOST_STATS_THRESHOLDS.cpuMaxedPct) return { severity: 'bad', word: 'maxed' } + if (pct >= HOST_STATS_THRESHOLDS.cpuBusyPct) return { severity: 'warn', word: 'busy' } + return OK +} + +export function memoryStatus(l: HostStatsLive): TileStatus { + if (!l.memory.available) return UNKNOWN + // totalBytes is the EFFECTIVE limit: the service reports the cgroup leaf limit + // as totalBytes whenever one applies (a cgroup current is never mixed with a + // host total), so no client-side cgroup special-casing. + if (l.memory.totalBytes <= 0) return OK + const pct = (l.memory.usedBytes / l.memory.totalBytes) * 100 + if (pct >= HOST_STATS_THRESHOLDS.memoryFullPct) return { severity: 'bad', word: 'full' } + if (pct >= HOST_STATS_THRESHOLDS.memoryTightPct) return { severity: 'warn', word: 'tight' } + return OK +} + +export function pagingStatus(l: HostStatsLive): TileStatus { + if (!l.paging.available) return UNKNOWN + // Single-snapshot semantics: the rate is already smoothed over the 2s fast + // interval — there is deliberately NO 2-tick carry/cross-tick memory. + const combinedKbps = l.paging.swapInKbps + l.paging.swapOutKbps + if (combinedKbps > HOST_STATS_THRESHOLDS.pagingThrashingKbps) return { severity: 'bad', word: 'thrashing' } + if (combinedKbps > 0) return { severity: 'warn', word: 'swapping' } + return OK +} + +export function psiStatus(l: HostStatsLive): TileStatus { + if (!l.psi.available) return UNKNOWN + // Only full10 stalls (all tasks blocked); some10 is never a stall. psistall is + // strict > 1.0. Null full10 values are skipped. + const fulls = [l.psi.memFull10, l.psi.ioFull10] + if (fulls.some((v) => v !== null && v > HOST_STATS_THRESHOLDS.psiStalledFull10)) { + return { severity: 'bad', word: 'stalled' } + } + return OK +} + +export function diskIoStatus(l: HostStatsLive): TileStatus { + if (!l.diskIo.available) return UNKNOWN + // The service already aggregates worst-device-wins (max utilPct device also + // provides the weighted await); this only maps the aggregated field. null = no + // ios in the sampling window. + if (l.diskIo.weightedAwaitMs === null) return OK + if (l.diskIo.weightedAwaitMs > HOST_STATS_THRESHOLDS.diskIoStalledAwaitMs) return { severity: 'bad', word: 'stalled' } + if (l.diskIo.weightedAwaitMs > HOST_STATS_THRESHOLDS.diskIoSlowAwaitMs) return { severity: 'warn', word: 'slow' } + return OK +} + +export function networkStatus(l: HostStatsLive): TileStatus { + if (!l.network.available) return UNKNOWN + const errorDelta = l.network.rxErrorsDelta + l.network.txErrorsDelta + + l.network.rxDroppedDelta + l.network.txDroppedDelta + if (errorDelta > 0) return { severity: 'warn', word: 'errors' } + return OK +} + +export function limitsStatus(l: HostStatsLive): TileStatus { + if (!l.limits.available) return UNKNOWN + // Per sub-limit (fds, pids, timeWait-share-of-ephemeral), worst drives the tile. + const pcts: number[] = [] + if (l.limits.fdsUsed !== null && l.limits.fdsMax !== null && l.limits.fdsMax > 0) { + pcts.push((l.limits.fdsUsed / l.limits.fdsMax) * 100) + } + if (l.limits.pidsUsed !== null && l.limits.pidsMax !== null && l.limits.pidsMax > 0) { + pcts.push((l.limits.pidsUsed / l.limits.pidsMax) * 100) + } + if (l.limits.timeWait !== null && l.limits.ephemeralPorts !== null && l.limits.ephemeralPorts > 0) { + pcts.push((l.limits.timeWait / l.limits.ephemeralPorts) * 100) + } + const worst = pcts.length > 0 ? Math.max(...pcts) : 0 + if (worst >= HOST_STATS_THRESHOLDS.limitsFullPct) return { severity: 'bad', word: 'full' } + if (worst >= HOST_STATS_THRESHOLDS.limitsTightPct) return { severity: 'warn', word: 'tight' } + return OK +} + +export function freshellStatus(l: HostStatsLive): TileStatus { + if (!l.freshell.available) return UNKNOWN + // Node: monitorEventLoopDelay p99; Rust: scheduler drift p99 — both mean + // "how late the runtime was to run its own timer". null = unmeasurable. + const lag = l.freshell.eventLoopLagP99Ms + if (lag === null) return OK + if (lag > HOST_STATS_THRESHOLDS.freshellBlockedMs) return { severity: 'bad', word: 'blocked' } + if (lag > HOST_STATS_THRESHOLDS.freshellLaggingMs) return { severity: 'warn', word: 'lagging' } + return OK +} + +const VERDICT_TILE_ORDER: Array<{ name: string; status: (l: HostStatsLive) => TileStatus }> = [ + { name: 'CPU', status: cpuStatus }, + { name: 'MEMORY', status: memoryStatus }, + { name: 'PAGING', status: pagingStatus }, + { name: 'PSI', status: psiStatus }, + { name: 'DISK I/O', status: diskIoStatus }, + { name: 'NETWORK', status: networkStatus }, + { name: 'LIMITS', status: limitsStatus }, + { name: 'FRESHELL', status: freshellStatus }, +] + +/** + * Verdict strip: ALL GOOD (green) / ELEVATED (amber) / TROUBLE (red). Offenders + * use the per-tile status words (uppercased), bad tiles first, then warn, each + * in fixed tile order. Unavailable ('unknown') sections are ok-grey and never + * offend. A null snapshot means nothing is known-bad yet → ALL GOOD. + */ +export function overallVerdict(l: HostStatsLive | null): { severity: Severity; label: string; offenders: string[] } { + if (!l) return { severity: 'ok', label: 'ALL GOOD', offenders: [] } + const tiles = VERDICT_TILE_ORDER.map((tile) => ({ name: tile.name, status: tile.status(l) })) + const bad = tiles.filter((t) => t.status.severity === 'bad') + const warn = tiles.filter((t) => t.status.severity === 'warn') + const offenders = [...bad, ...warn].map((t) => `${t.name} ${t.status.word.toUpperCase()}`) + if (bad.length > 0) return { severity: 'bad', label: 'TROUBLE', offenders } + if (warn.length > 0) return { severity: 'warn', label: 'ELEVATED', offenders } + return { severity: 'ok', label: 'ALL GOOD', offenders: [] } +} diff --git a/src/lib/host-stats-ws.ts b/src/lib/host-stats-ws.ts new file mode 100644 index 000000000..5b64d8934 --- /dev/null +++ b/src/lib/host-stats-ws.ts @@ -0,0 +1,19 @@ +import { getWsClient } from '@/lib/ws-client' + +/** + * Thin WS seam for the hoststats.* protocol — shared by the hostStats slice + * thunks and the Host Stats pane. Frames are Zod-validated by the server; + * inbound frames are trusted as validated (shared/ws-protocol.ts header — + * the client does not runtime-revalidate server frames). + */ +export function subscribeHostStats(): void { + getWsClient().send({ type: 'hoststats.subscribe' }) +} + +export function unsubscribeHostStats(): void { + getWsClient().send({ type: 'hoststats.unsubscribe' }) +} + +export function requestHostStatsRefreshWs(requestId: string): void { + getWsClient().send({ type: 'hoststats.refresh', requestId }) +} diff --git a/src/lib/tab-registry-open.ts b/src/lib/tab-registry-open.ts index 7836021b4..04f1a687f 100644 --- a/src/lib/tab-registry-open.ts +++ b/src/lib/tab-registry-open.ts @@ -6,6 +6,7 @@ import { nanoid } from 'nanoid' import { Bot, FileCode2, + Gauge, Globe, Square, TerminalSquare, @@ -177,6 +178,9 @@ export function sanitizePaneSnapshot( props: (payload.props as Record) || {}, } } + if (snapshot.kind === 'host-stats') { + return { kind: 'host-stats' } + } return { kind: 'picker' } } @@ -200,6 +204,7 @@ export function paneKindIcon(kind: RegistryPaneSnapshot['kind']): LucideIcon { if (kind === 'browser') return Globe if (kind === 'editor') return FileCode2 if (kind === 'fresh-agent') return Bot + if (kind === 'host-stats') return Gauge return Square } @@ -209,6 +214,7 @@ export function paneKindColorClass(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'editor') return 'text-emerald-500' if (kind === 'fresh-agent' || kind === 'claude-chat') return 'text-amber-500' if (kind === 'extension') return 'text-purple-500' + if (kind === 'host-stats') return 'text-cyan-500' return 'text-muted-foreground' } @@ -218,6 +224,7 @@ export function paneKindLabel(kind: RegistryPaneSnapshot['kind']): string { if (kind === 'editor') return 'Editor' if (kind === 'fresh-agent' || kind === 'claude-chat') return 'Agent' if (kind === 'extension') return 'Extension' + if (kind === 'host-stats') return 'Host Stats' return kind } diff --git a/src/lib/tab-registry-snapshot.ts b/src/lib/tab-registry-snapshot.ts index 05b979a60..ad14f540a 100644 --- a/src/lib/tab-registry-snapshot.ts +++ b/src/lib/tab-registry-snapshot.ts @@ -67,6 +67,8 @@ function stripPanePayload(content: PaneContent, serverInstanceId: string): Recor extensionName: content.extensionName, props: content.props, } + case 'host-stats': + return {} case 'picker': default: return {} diff --git a/src/store/hostStatsSlice.ts b/src/store/hostStatsSlice.ts new file mode 100644 index 000000000..4f7a8e9e8 --- /dev/null +++ b/src/store/hostStatsSlice.ts @@ -0,0 +1,218 @@ +import { createSlice, type PayloadAction } from '@reduxjs/toolkit' +import type { HostStatsLive, HostStatsManual } from '@shared/ws-protocol' +import { requestHostStatsRefreshWs, subscribeHostStats, unsubscribeHostStats } from '@/lib/host-stats-ws' +import type { AppDispatch, RootState } from './store' + +/** + * Client-side state for the hoststats.* protocol. The connection-level + * subscription is owned by a client-side mount refcount: N mounted Host Stats + * panes share a single `hoststats.subscribe`. Reducers are PURE — all WS side + * effects live in the thunks below, fired exactly on the 0→1 / 1→0 refcount + * transitions (computed by reading state AFTER the pure dispatch). Components + * must NEVER dispatch the raw reducers — always the thunks. + */ + +export type HostStatsRefreshState = { + inFlight: boolean + requestId: string | null + error: string | null +} + +export type HostStatsState = { + mountedPanes: number + subscribed: boolean + live: HostStatsLive | null + liveAt: number | null + /** + * `Date.now() - snapshot.at`, refreshed per snapshot; NEVER zero-clamped — + * a client behind the server yields a correctly negative offset so + * `serverNow = Date.now() - clockOffsetMs` stays skew-correct. + */ + clockOffsetMs: number | null + manualAt: number | null + manual: HostStatsManual | null + refresh: HostStatsRefreshState +} + +/** |Date.now() - at| beyond this is treated as unparseable garbage; previous offset kept. */ +export const HOST_STATS_CLOCK_OFFSET_REJECT_MS = 10 * 60 * 1000 + +/** Client-side acceptance deadline for one refresh round trip. */ +export const HOST_STATS_REFRESH_TIMEOUT_MS = 6_000 + +export const HOST_STATS_REFRESH_TIMEOUT_ERROR = 'refresh timed out — showing previous values' + +function createInitialState(): HostStatsState { + return { + mountedPanes: 0, + subscribed: false, + live: null, + liveAt: null, + clockOffsetMs: null, + manualAt: null, + manual: null, + refresh: { inFlight: false, requestId: null, error: null }, + } +} + +const initialState = createInitialState() + +type HostStatsSnapshotPayload = { + at: number + live: HostStatsLive + manualAt: number | null + manual: HostStatsManual | null +} + +const hostStatsSlice = createSlice({ + name: 'hostStats', + initialState, + reducers: { + /** PURE refcount mutation — the WS side effect belongs to the thunk. */ + hostStatsPaneMounted(state) { + state.mountedPanes += 1 + }, + /** PURE refcount mutation — the WS side effect belongs to the thunk. */ + hostStatsPaneUnmounted(state) { + state.mountedPanes = Math.max(0, state.mountedPanes - 1) + }, + /** The ONLY writer of `subscribed`. */ + hostStatsSubscribedSet(state, action: PayloadAction) { + state.subscribed = action.payload + }, + hostStatsSnapshotReceived(state, action: PayloadAction) { + const { at, live, manualAt, manual } = action.payload + state.live = live + state.liveAt = at + const offset = Date.now() - at + if (Math.abs(offset) <= HOST_STATS_CLOCK_OFFSET_REJECT_MS) { + state.clockOffsetMs = offset + } + // MERGE: a snapshot without manual MUST NOT clear existing manual/manualAt. + if (manualAt !== null) { + state.manualAt = manualAt + state.manual = manual + } + }, + hostStatsRefreshStarted(state, action: PayloadAction<{ requestId: string }>) { + state.refresh = { inFlight: true, requestId: action.payload.requestId, error: null } + }, + hostStatsRefreshResolved(state, action: PayloadAction<{ at: number; manual: HostStatsManual }>) { + state.manual = action.payload.manual + state.manualAt = action.payload.at + state.refresh = { inFlight: false, requestId: null, error: null } + }, + hostStatsRefreshFailed(state, action: PayloadAction<{ error: string }>) { + // Previous values AND the original manualAt are preserved; only the + // refresh slot clears and records the error text. + state.refresh = { inFlight: false, requestId: null, error: action.payload.error } + }, + hostStatsReset(state) { + // On ws disconnect/'ready': the subscription died with the old socket, + // but the last live/manual values are still the freshest known — keep them. + state.subscribed = false + }, + }, +}) + +export const { + hostStatsPaneMounted, + hostStatsPaneUnmounted, + hostStatsSubscribedSet, + hostStatsSnapshotReceived, + hostStatsRefreshStarted, + hostStatsRefreshResolved, + hostStatsRefreshFailed, + hostStatsReset, +} = hostStatsSlice.actions + +export default hostStatsSlice.reducer + +// ── Thunks (WS side effects live here only) ───────────────────────────── + +// requestId → acceptance-deadline timer. Module-level (not store state): the +// WS client is a process singleton shared by every store view, and timers are +// not persisted/serializable state. Tests drain it via _resetHostStatsThunkState. +const refreshDeadlineTimers = new Map>() + +function clearRefreshDeadline(requestId: string): void { + const timer = refreshDeadlineTimers.get(requestId) + if (timer !== undefined) { + clearTimeout(timer) + refreshDeadlineTimers.delete(requestId) + } +} + +export function _resetHostStatsThunkState(): void { + for (const requestId of [...refreshDeadlineTimers.keys()]) { + clearRefreshDeadline(requestId) + } +} + +/** Mount-side thunk: refcount++, send subscribe iff this is the 0→1 transition. */ +export function activateHostStats() { + return (dispatch: AppDispatch, getState: () => RootState): void => { + dispatch(hostStatsPaneMounted()) + if (getState().hostStats.mountedPanes === 1) { + subscribeHostStats() + dispatch(hostStatsSubscribedSet(true)) + } + } +} + +/** Unmount-side thunk: refcount--, send unsubscribe iff this is the 1→0 transition. */ +export function deactivateHostStats() { + return (dispatch: AppDispatch, getState: () => RootState): void => { + if (getState().hostStats.mountedPanes === 0) return + dispatch(hostStatsPaneUnmounted()) + if (getState().hostStats.mountedPanes === 0) { + unsubscribeHostStats() + dispatch(hostStatsSubscribedSet(false)) + } + } +} + +/** + * Mint `hsr--`, mark inFlight, send the refresh frame, and arm the + * 6000ms acceptance deadline → hostStatsRefreshFailed(timeout) on expiry. + * One in-flight refresh per client; a second call while inFlight is a no-op. + */ +export function requestHostStatsRefresh() { + return (dispatch: AppDispatch, getState: () => RootState): string | null => { + if (getState().hostStats.refresh.inFlight) return null + const requestId = `hsr-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + dispatch(hostStatsRefreshStarted({ requestId })) + requestHostStatsRefreshWs(requestId) + const timer = setTimeout(() => { + refreshDeadlineTimers.delete(requestId) + dispatch(failHostStatsRefresh({ requestId, error: HOST_STATS_REFRESH_TIMEOUT_ERROR })) + }, HOST_STATS_REFRESH_TIMEOUT_MS) + refreshDeadlineTimers.set(requestId, timer) + return requestId + } +} + +/** + * Fold an ok refresh response. Ref-map semantics keyed by requestId: a frame + * whose id is not the current in-flight request is ignored without throwing. + */ +export function resolveHostStatsRefresh(payload: { requestId: string; at: number; manual: HostStatsManual }) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + // `hostStats?.` mirrors the state.freshAgent?.sessions precedent: App-level + // folds dispatch these thunks against deliberately partial stores in tests. + const refresh = getState().hostStats?.refresh + if (!refresh?.inFlight || refresh.requestId !== payload.requestId) return + clearRefreshDeadline(payload.requestId) + dispatch(hostStatsRefreshResolved({ at: payload.at, manual: payload.manual })) + } +} + +/** Fold a failed refresh response (or the client-side acceptance deadline). */ +export function failHostStatsRefresh(payload: { requestId: string; error: string }) { + return (dispatch: AppDispatch, getState: () => RootState): void => { + const refresh = getState().hostStats?.refresh + if (!refresh?.inFlight || refresh.requestId !== payload.requestId) return + clearRefreshDeadline(payload.requestId) + dispatch(hostStatsRefreshFailed({ error: payload.error })) + } +} diff --git a/src/store/paneTreeValidation.ts b/src/store/paneTreeValidation.ts index 2fd647986..b3d37072e 100644 --- a/src/store/paneTreeValidation.ts +++ b/src/store/paneTreeValidation.ts @@ -60,6 +60,8 @@ function isPaneContentShape(content: unknown): boolean { && (content.viewMode === 'source' || content.viewMode === 'preview') case 'picker': return true + case 'host-stats': + return true case 'fresh-agent': { const sessionType = isFreshAgentSessionType(content.sessionType) ? content.sessionType : undefined const runtimeProvider = sessionType ? resolveFreshAgentRuntimeProvider(sessionType) : undefined diff --git a/src/store/paneTypes.ts b/src/store/paneTypes.ts index 529bf2461..85bd5a608 100644 --- a/src/store/paneTypes.ts +++ b/src/store/paneTypes.ts @@ -159,6 +159,15 @@ export type PickerPaneContent = { kind: 'picker' } +/** + * Host stats pane content — the host pressure dashboard (CPU/memory/PSI/IO). + * Stateless: every value lives in the connection-level hostStats slice, so a + * host-stats leaf carries no fields beyond its kind. + */ +export type HostStatsPaneContent = { + kind: 'host-stats' +} + /** SDK session statuses — richer than TerminalStatus to reflect Claude Code lifecycle */ export type SdkSessionStatus = 'creating' | 'starting' | 'connected' | 'running' | 'idle' | 'compacting' | 'exited' | 'create-failed' @@ -250,7 +259,7 @@ export type ExtensionPaneContent = { * Union type for all pane content types. */ export type PaneContent = TerminalPaneContent | BrowserPaneContent | EditorPaneContent - | PickerPaneContent | FreshAgentPaneContent | ExtensionPaneContent + | PickerPaneContent | FreshAgentPaneContent | ExtensionPaneContent | HostStatsPaneContent /** * Input type for creating terminal panes. @@ -279,7 +288,7 @@ export type FreshAgentPaneInput = Omit diff --git a/src/store/panesSlice.ts b/src/store/panesSlice.ts index 2e3408b8b..e905650b7 100644 --- a/src/store/panesSlice.ts +++ b/src/store/panesSlice.ts @@ -287,6 +287,10 @@ function normalizePaneContent( if (input.kind === 'extension') { return input // Extension content passes through unchanged } + if (input.kind === 'host-stats') { + // Stateless pane kind: the bare kind is the whole persisted/runtime shape. + return { kind: 'host-stats' } + } // Editor/picker content passes through unchanged return input } diff --git a/src/store/store.ts b/src/store/store.ts index eef4adf24..b87d302ea 100644 --- a/src/store/store.ts +++ b/src/store/store.ts @@ -19,6 +19,7 @@ import amplifierActivityReducer from './amplifierActivitySlice' import opencodeActivityReducer from './opencodeActivitySlice' import freshAgentReducer from './freshAgentSlice' import paneRuntimeActivityReducer from './paneRuntimeActivitySlice' +import hostStatsReducer from './hostStatsSlice' import { networkReducer } from './networkSlice' import tabRegistryReducer from './tabRegistrySlice' import extensionsReducer from './extensionsSlice' @@ -65,6 +66,8 @@ export const store = configureStore({ opencodeActivity: opencodeActivityReducer, freshAgent: freshAgentReducer, paneRuntimeActivity: paneRuntimeActivityReducer, + // Ephemeral live host metrics — never persisted (allowlist rule) + hostStats: hostStatsReducer, network: networkReducer, tabRegistry: tabRegistryReducer, extensions: extensionsReducer, diff --git a/test/e2e-browser/playwright.config.ts b/test/e2e-browser/playwright.config.ts index afb4a8142..4c0f68a14 100644 --- a/test/e2e-browser/playwright.config.ts +++ b/test/e2e-browser/playwright.config.ts @@ -167,6 +167,13 @@ export const MATRIX_SPECS = [ // shared code, so legacy is a true regression control proving they didn't // regress Node behavior. See title-sync-convergence.spec.ts. /title-sync-convergence\.spec\.ts$/, + // HOST-STATS (host-pressure-pane plan, Task 10) — Host Stats pane smoke: + // picker create, verdict strip/CPU tile, refresh interaction (Collecting + // state + age label), Disks fallback em-dash contract, tab-switch liveness, + // reload restore. Assertions are backend-agnostic (the Rust lane renders + // zero-shape values identically), so legacy is a true parity control. See + // test/e2e-browser/specs/host-stats-pane.spec.ts. + /host-stats-pane\.spec\.ts$/, ] // CONTINUITY TRIO: rust-only specs kept out of every match-all project diff --git a/test/e2e-browser/specs/host-stats-pane.spec.ts b/test/e2e-browser/specs/host-stats-pane.spec.ts new file mode 100644 index 000000000..61214311c --- /dev/null +++ b/test/e2e-browser/specs/host-stats-pane.spec.ts @@ -0,0 +1,152 @@ +import { test, expect } from '../helpers/fixtures.js' +import { openPanePicker } from '../helpers/pane-picker.js' + +/** + * HOST-STATS-PANE — React HostStatsPane e2e smoke + * (docs/plans/2026-08-25-host-pressure-pane.md, Task 10 content block). + * + * Authored + standalone-committed in Task 7 with captured RED evidence: the + * first test fails at the picker click because the 'Host Stats' picker option + * does not exist until Task 7's GREEN phase lands (this is the only sequence + * point where absence-RED is reachable). Task 10 registers this spec into + * MATRIX_SPECS (test/e2e-browser/playwright.config.ts) and re-runs it GREEN + * under BOTH legs (legacy-chromium + rust-chromium); Rust parity coverage is + * therefore inherent — the refresh-resolve and em-dash placeholder contracts + * are identical on both servers (degraded sections resolve with zero-shape). + * + * Selector notes: tile grouping is exposed via [data-host-stats-tile] / + * [data-host-stats-value] data test contracts (permitted by the HARNESS-11 + * a11y-selector gate); interactive elements are addressed by role + name. + */ + +type PaneNodeLike = { + type: string + content?: { kind?: string } + children?: PaneNodeLike[] +} + +function findLeafByKind(node: PaneNodeLike | null, kind: string): PaneNodeLike | null { + if (!node) return null + if (node.type === 'leaf') return node.content?.kind === kind ? node : null + for (const child of node.children ?? []) { + const hit = findLeafByKind(child, kind) + if (hit) return hit + } + return null +} + +test.describe('Host Stats pane', () => { + async function openHostStatsPane(page: any) { + await openPanePicker(page) + const option = page.getByRole('button', { name: /^Host Stats$/ }) + await expect(option).toBeVisible({ timeout: 10_000 }) + await option.click() + const section = page.getByRole('region', { name: 'Host stats' }) + await expect(section).toBeVisible({ timeout: 10_000 }) + return section + } + + test('opens a Host Stats pane from the pane picker', async ({ freshellPage, page, harness }) => { + await openHostStatsPane(page) + + const activeTabId = await harness.getActiveTabId() + const layout = await harness.getPaneLayout(activeTabId!) + const leaf = findLeafByKind(layout, 'host-stats') + expect(leaf).not.toBeNull() + expect(leaf?.content?.kind).toBe('host-stats') + }) + + test('renders verdict strip and live tiles from the subscription snapshot', async ({ freshellPage, page }) => { + const section = await openHostStatsPane(page) + + // Verdict strip (distinct from the sr-only one-shot refresh announcer, + // which is also role=status but never carries a verdict word). + const verdictStrip = section + .getByRole('status') + .filter({ hasText: /ALL GOOD|ELEVATED|TROUBLE/ }) + .first() + await expect(verdictStrip).toBeVisible({ timeout: 5_000 }) + expect((await verdictStrip.textContent())?.trim().length ?? 0).toBeGreaterThan(0) + + // CPU tile shows a measured percentage shortly after subscribe (the server + // emits one snapshot immediately on the 0→1 subscribe transition). + const cpuValue = section + .locator('[data-host-stats-tile="cpu"]') + .locator('[data-host-stats-value]') + await expect(cpuValue).toHaveText(/\d+(\.\d+)?%/, { timeout: 5_000 }) + }) + + test('on-request refresh resolves, re-enables the button, and updates the age label', async ({ freshellPage, page }) => { + const section = await openHostStatsPane(page) + + const refreshButton = section.getByRole('button', { name: /refresh on-request measurements/i }) + await expect(refreshButton).toBeEnabled() + await refreshButton.click() + + // (a) While awaiting the response the button shows the Collecting state. + // Always-true by design: the server-side refresh has a 300ms+ two-sample + // dwell, so the in-flight window is never zero-length. + await expect(refreshButton).toBeDisabled() + await expect(refreshButton).toContainText('Collecting…') + + // (b) The refresh always resolves (degraded sections still resolve with + // zero-shape, on both server implementations) and the button recovers. + await expect(refreshButton).toBeEnabled({ timeout: 15_000 }) + await expect(refreshButton).toContainText('Refresh') + + // (c) The ON REQUEST age label reports the fresh measurement. + await expect(section.getByText(/updated .*ago|just now/)).toBeVisible({ timeout: 5_000 }) + + // Per-design fallback: the Disks tile value is a real percent OR the frozen + // em-dash placeholder (gVisor/Cloud Run may lack the section) — never zeros. + const diskValue = section + .locator('[data-host-stats-tile="disks"]') + .locator('[data-host-stats-value]') + .first() + await expect(diskValue).toHaveText(/\d+%|—/, { timeout: 5_000 }) + }) + + test('live tiles survive a tab switch away and back', async ({ freshellPage, page, harness }) => { + const section = await openHostStatsPane(page) + const cpuValue = section + .locator('[data-host-stats-tile="cpu"]') + .locator('[data-host-stats-value]') + await expect(cpuValue).toHaveText(/\d/, { timeout: 5_000 }) + + // Switch to a new tab, then back to the first. + await page.locator('[data-context="tab-add"]').click() + await harness.waitForTabCount(2) + await page.locator('[data-context="tab"]').first().click() + + const restored = page.getByRole('region', { name: 'Host stats' }) + await expect(restored).toBeVisible({ timeout: 5_000 }) + await expect( + restored + .locator('[data-host-stats-tile="cpu"]') + .locator('[data-host-stats-value]'), + ).toHaveText(/\d/, { timeout: 5_000 }) + + const activeTabId = await harness.getActiveTabId() + const layout = await harness.getPaneLayout(activeTabId!) + expect(findLeafByKind(layout, 'host-stats')).not.toBeNull() + }) + + test('restores as host-stats after a full page reload', async ({ freshellPage, page, harness }) => { + await openHostStatsPane(page) + + await page.reload() + await harness.waitForHarness() + await harness.waitForConnection() + + // paneTreeValidation must accept { kind: 'host-stats' } or the pane would + // be dropped from the persisted layout on reload. + const restored = page.getByRole('region', { name: 'Host stats' }) + await expect(restored).toBeVisible({ timeout: 10_000 }) + + const activeTabId = await harness.getActiveTabId() + const layout = await harness.getPaneLayout(activeTabId!) + const leaf = findLeafByKind(layout, 'host-stats') + expect(leaf).not.toBeNull() + expect(leaf?.content?.kind).toBe('host-stats') + }) +}) diff --git a/test/fixtures/host-stats/proc/diskstats b/test/fixtures/host-stats/proc/diskstats new file mode 100644 index 000000000..e0866839a --- /dev/null +++ b/test/fixtures/host-stats/proc/diskstats @@ -0,0 +1,5 @@ + 8 0 sda 5000 100 400000 6000 2000 50 200000 3000 0 4000 9000 + 8 1 sda1 4000 80 300000 5000 1500 40 150000 2500 0 3000 7500 + 7 0 loop0 100 0 800 10 0 0 0 0 0 10 10 + 259 0 nvme0n1 9000 200 700000 8000 3000 60 300000 4000 0 5000 12000 + 259 1 nvme0n1p1 8000 150 600000 7000 2500 55 250000 3500 0 4500 10500 diff --git a/test/fixtures/host-stats/proc/loadavg b/test/fixtures/host-stats/proc/loadavg new file mode 100644 index 000000000..ecb2936f0 --- /dev/null +++ b/test/fixtures/host-stats/proc/loadavg @@ -0,0 +1 @@ +0.50 1.00 1.20 2/1234 5678 diff --git a/test/fixtures/host-stats/proc/meminfo b/test/fixtures/host-stats/proc/meminfo new file mode 100644 index 000000000..48d7877ca --- /dev/null +++ b/test/fixtures/host-stats/proc/meminfo @@ -0,0 +1,49 @@ +MemTotal: 67108864 kB +MemFree: 8388608 kB +MemAvailable: 33554432 kB +Buffers: 524288 kB +Cached: 4194304 kB +SwapCached: 0 kB +Active: 20971520 kB +Inactive: 8388608 kB +Active(anon): 16777216 kB +Inactive(anon): 4194304 kB +Active(file): 4194304 kB +Inactive(file): 4194304 kB +Unevictable: 0 kB +Mlocked: 0 kB +SwapTotal: 8388608 kB +SwapFree: 7340032 kB +Dirty: 100 kB +Writeback: 0 kB +AnonPages: 20970000 kB +Mapped: 500000 kB +Shmem: 150000 kB +Slab: 800000 kB +SReclaimable: 600000 kB +SUnreclaim: 200000 kB +KernelStack: 30000 kB +PageTables: 60000 kB +NFS_Unstable: 0 kB +Bounce: 0 kB +WritebackTmp: 0 kB +CommitLimit: 41943040 kB +Committed_AS: 30000000 kB +VmallocTotal: 34359738367 kB +VmallocUsed: 50000 kB +VmallocChunk: 0 kB +Percpu: 20000 kB +HardwareCorrupted: 0 kB +AnonHugePages: 0 kB +ShmemHugePages: 0 kB +ShmemPmdMapped: 0 kB +FileHugePages: 0 kB +FilePmdMapped: 0 kB +HugePages_Total: 0 +HugePages_Free: 0 +HugePages_Rsvd: 0 +HugePages_Surp: 0 +Hugepagesize: 2048 kB +Hugetlb: 0 kB +DirectMap4k: 1000000 kB +DirectMap2M: 66000000 kB diff --git a/test/fixtures/host-stats/proc/net/dev b/test/fixtures/host-stats/proc/net/dev new file mode 100644 index 000000000..89f4f112b --- /dev/null +++ b/test/fixtures/host-stats/proc/net/dev @@ -0,0 +1,5 @@ +Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1000000 10000 0 0 0 0 0 0 1000000 10000 0 0 0 0 0 0 + eth0: 5000000 50000 7 3 0 0 0 0 8000000 80000 11 4 0 0 0 0 +docker0: 2000000 20000 2 1 0 0 0 0 3000000 30000 5 2 0 0 0 0 diff --git a/test/fixtures/host-stats/proc/net/tcp b/test/fixtures/host-stats/proc/net/tcp new file mode 100644 index 000000000..642fc6731 --- /dev/null +++ b/test/fixtures/host-stats/proc/net/tcp @@ -0,0 +1,5 @@ + sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 0100007F:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 22334 1 0000000000000000 100 0 0 10 0 + 1: 0100007F:9C40 0200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 22335 1 0000000000000000 20 4 30 10 -1 + 2: 0A00000A:C350 0100000A:01BB 01 00000000:00000000 02:000A9A78 00000000 1000 0 22336 2 0000000000000000 20 4 31 10 -1 + 3: 0100007F:8AE0 0200000A:1F91 06 00000000:00000000 00:00000000 00000000 1000 0 22337 1 0000000000000000 20 4 30 10 -1 diff --git a/test/fixtures/host-stats/proc/net/tcp6 b/test/fixtures/host-stats/proc/net/tcp6 new file mode 100644 index 000000000..e67cd19ab --- /dev/null +++ b/test/fixtures/host-stats/proc/net/tcp6 @@ -0,0 +1,3 @@ + sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode + 0: 00000000000000000000000001000000:1F91 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 33001 1 0000000000000000 100 0 0 10 0 + 1: 00000000000000000000000001000000:9C41 0000000000000000000000000200000A:0050 06 00000000:00000000 00:00000000 00000000 1000 0 33002 1 0000000000000000 20 4 30 10 -1 diff --git a/test/fixtures/host-stats/proc/pressure/cpu b/test/fixtures/host-stats/proc/pressure/cpu new file mode 100644 index 000000000..50be24887 --- /dev/null +++ b/test/fixtures/host-stats/proc/pressure/cpu @@ -0,0 +1 @@ +some avg10=1.23 avg60=2.34 avg300=3.45 total=987654321 diff --git a/test/fixtures/host-stats/proc/pressure/io b/test/fixtures/host-stats/proc/pressure/io new file mode 100644 index 000000000..dd9f0a14b --- /dev/null +++ b/test/fixtures/host-stats/proc/pressure/io @@ -0,0 +1,2 @@ +some avg10=2.50 avg60=1.00 avg300=0.50 total=654321 +full avg10=1.00 avg60=0.40 avg300=0.20 total=600000 diff --git a/test/fixtures/host-stats/proc/pressure/memory b/test/fixtures/host-stats/proc/pressure/memory new file mode 100644 index 000000000..8593cd563 --- /dev/null +++ b/test/fixtures/host-stats/proc/pressure/memory @@ -0,0 +1,2 @@ +some avg10=0.50 avg60=0.20 avg300=0.10 total=123456 +full avg10=0.30 avg60=0.10 avg300=0.05 total=100000 diff --git a/test/fixtures/host-stats/proc/self/fdinfo/3 b/test/fixtures/host-stats/proc/self/fdinfo/3 new file mode 100644 index 000000000..4fe3ccb64 --- /dev/null +++ b/test/fixtures/host-stats/proc/self/fdinfo/3 @@ -0,0 +1,6 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 1234 +inotify wd:1 ino:600001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0106a000743b0200 +inotify wd:2 ino:600002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0206a000743b0200 diff --git a/test/fixtures/host-stats/proc/self/fdinfo/4 b/test/fixtures/host-stats/proc/self/fdinfo/4 new file mode 100644 index 000000000..4191793c2 --- /dev/null +++ b/test/fixtures/host-stats/proc/self/fdinfo/4 @@ -0,0 +1,7 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 2234 +inotify wd:1 ino:610001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0306a000743b0200 +inotify wd:2 ino:610002 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0406a000743b0200 +inotify wd:3 ino:610003 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0506a000743b0200 diff --git a/test/fixtures/host-stats/proc/self/fdinfo/5 b/test/fixtures/host-stats/proc/self/fdinfo/5 new file mode 100644 index 000000000..bc73d2c98 --- /dev/null +++ b/test/fixtures/host-stats/proc/self/fdinfo/5 @@ -0,0 +1,5 @@ +pos: 0 +flags: 02004000 +mnt_id: 15 +ino: 3234 +inotify wd:1 ino:620001 sdev:800013 mask:800afce ignored_mask:0 fhandle-bytes:8 fhandle-type:1 f_handle:0606a000743b0200 diff --git a/test/fixtures/host-stats/proc/self/limits b/test/fixtures/host-stats/proc/self/limits new file mode 100644 index 000000000..7d4f86556 --- /dev/null +++ b/test/fixtures/host-stats/proc/self/limits @@ -0,0 +1,17 @@ +Limit Soft Limit Hard Limit Units +Max cpu time unlimited unlimited seconds +Max file size unlimited unlimited bytes +Max data size unlimited unlimited bytes +Max stack size 8388608 unlimited bytes +Max core file size 0 unlimited bytes +Max resident set unlimited unlimited bytes +Max processes 257913 257913 processes +Max open files 1024 1048576 files +Max locked memory 1090519040 1090519040 bytes +Max address space unlimited unlimited bytes +Max file locks unlimited unlimited locks +Max pending signals 257913 257913 signals +Max msgqueue size 819200 819200 bytes +Max nice priority 0 0 +Max realtime priority 0 0 +Max realtime timeout unlimited unlimited us diff --git a/test/fixtures/host-stats/proc/stat b/test/fixtures/host-stats/proc/stat new file mode 100644 index 000000000..a1c60bc76 --- /dev/null +++ b/test/fixtures/host-stats/proc/stat @@ -0,0 +1,24 @@ +cpu 4705 356 1622 164331 2020 80 345 777 0 0 +cpu0 300 10 120 10000 150 5 20 40 0 0 +cpu1 200 5 100 9001 100 2 10 21 0 0 +cpu2 200 5 100 9002 100 2 10 22 0 0 +cpu3 200 5 100 9003 100 2 10 23 0 0 +cpu4 200 5 100 9004 100 2 10 24 0 0 +cpu5 200 5 100 9005 100 2 10 25 0 0 +cpu6 200 5 100 9006 100 2 10 26 0 0 +cpu7 200 5 100 9007 100 2 10 27 0 0 +cpu8 200 5 100 9008 100 2 10 28 0 0 +cpu9 200 5 100 9009 100 2 10 29 0 0 +cpu10 200 5 100 9010 100 2 10 30 0 0 +cpu11 200 5 100 9011 100 2 10 31 0 0 +cpu12 200 5 100 9012 100 2 10 32 0 0 +cpu13 200 5 100 9013 100 2 10 33 0 0 +cpu14 200 5 100 9014 100 2 10 34 0 0 +cpu15 100 0 50 9000 10 0 5 15 0 0 +intr 1234567 8 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 +ctxt 7654321 +btime 1690000000 +processes 12345 +procs_running 2 +procs_blocked 0 +softirq 123456 100 50000 200 60000 300 1000 5000 60000 2000 diff --git a/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances b/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances new file mode 100644 index 000000000..a949a93df --- /dev/null +++ b/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_instances @@ -0,0 +1 @@ +128 diff --git a/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches b/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches new file mode 100644 index 000000000..6820bf177 --- /dev/null +++ b/test/fixtures/host-stats/proc/sys/fs/inotify/max_user_watches @@ -0,0 +1 @@ +1048576 diff --git a/test/fixtures/host-stats/proc/sys/kernel/threads-max b/test/fixtures/host-stats/proc/sys/kernel/threads-max new file mode 100644 index 000000000..9f358a4ad --- /dev/null +++ b/test/fixtures/host-stats/proc/sys/kernel/threads-max @@ -0,0 +1 @@ +123456 diff --git a/test/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range b/test/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range new file mode 100644 index 000000000..10d6ed9d7 --- /dev/null +++ b/test/fixtures/host-stats/proc/sys/net/ipv4/ip_local_port_range @@ -0,0 +1 @@ +32768 60999 diff --git a/test/fixtures/host-stats/proc/vmstat b/test/fixtures/host-stats/proc/vmstat new file mode 100644 index 000000000..99751486d --- /dev/null +++ b/test/fixtures/host-stats/proc/vmstat @@ -0,0 +1,18 @@ +nr_free_pages 2000000 +nr_zone_inactive_anon 100000 +nr_zone_active_anon 200000 +nr_inactive_anon 100000 +nr_active_anon 200000 +nr_inactive_file 150000 +nr_active_file 250000 +nr_unevictable 0 +nr_slab_reclaimable 150000 +nr_slab_unreclaimable 50000 +pswpin 1234 +pswpout 5678 +pgmajfault 890 +pgpgin 100000 +pgpgout 200000 +oom_kill 3 +nr_dirty 25 +nr_writeback 0 diff --git a/test/fixtures/host-stats/procmini/101/stat b/test/fixtures/host-stats/procmini/101/stat new file mode 100644 index 000000000..4392649cb --- /dev/null +++ b/test/fixtures/host-stats/procmini/101/stat @@ -0,0 +1 @@ +101 (systemd) S 1 101 101 0 -1 4194304 1000 0 50 0 120 30 0 0 20 0 1 0 5000 200000000 1500 diff --git a/test/fixtures/host-stats/procmini/101/status b/test/fixtures/host-stats/procmini/101/status new file mode 100644 index 000000000..c0913c7d8 --- /dev/null +++ b/test/fixtures/host-stats/procmini/101/status @@ -0,0 +1,12 @@ +Name: systemd +Umask: 0022 +State: S (sleeping) +Tgid: 101 +Pid: 101 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 200000 kB +VmSize: 195312 kB +VmRSS: 12345 kB +Threads: 1 diff --git a/test/fixtures/host-stats/procmini/202/stat b/test/fixtures/host-stats/procmini/202/stat new file mode 100644 index 000000000..f4d82bf79 --- /dev/null +++ b/test/fixtures/host-stats/procmini/202/stat @@ -0,0 +1 @@ +202 (node) S 1 202 202 0 -1 4194304 20000 0 100 0 800 200 0 0 20 0 8 0 6000 1500000000 50000 diff --git a/test/fixtures/host-stats/procmini/202/status b/test/fixtures/host-stats/procmini/202/status new file mode 100644 index 000000000..e5e1daf7b --- /dev/null +++ b/test/fixtures/host-stats/procmini/202/status @@ -0,0 +1,12 @@ +Name: node +Umask: 0022 +State: S (sleeping) +Tgid: 202 +Pid: 202 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 1500000 kB +VmSize: 1464843 kB +VmRSS: 654321 kB +Threads: 8 diff --git a/test/fixtures/host-stats/procmini/303/stat b/test/fixtures/host-stats/procmini/303/stat new file mode 100644 index 000000000..c0749889a --- /dev/null +++ b/test/fixtures/host-stats/procmini/303/stat @@ -0,0 +1 @@ +303 (postgres) S 1 303 303 0 -1 4194304 30000 0 200 0 400 100 0 0 20 0 4 0 7000 300000000 30000 diff --git a/test/fixtures/host-stats/procmini/303/status b/test/fixtures/host-stats/procmini/303/status new file mode 100644 index 000000000..7b678376e --- /dev/null +++ b/test/fixtures/host-stats/procmini/303/status @@ -0,0 +1,12 @@ +Name: postgres +Umask: 0022 +State: S (sleeping) +Tgid: 303 +Pid: 303 +PPid: 1 +Uid: 999 999 999 999 +Gid: 999 999 999 999 +VmPeak: 350000 kB +VmSize: 292968 kB +VmRSS: 88888 kB +Threads: 4 diff --git a/test/fixtures/host-stats/procmini/404/stat b/test/fixtures/host-stats/procmini/404/stat new file mode 100644 index 000000000..1153d22bd --- /dev/null +++ b/test/fixtures/host-stats/procmini/404/stat @@ -0,0 +1 @@ +404 (my (weird) proc) D 1 404 404 0 -1 4194304 200 0 5 0 999 111 0 0 20 0 2 0 8000 300000000 6000 diff --git a/test/fixtures/host-stats/procmini/404/status b/test/fixtures/host-stats/procmini/404/status new file mode 100644 index 000000000..dc934d325 --- /dev/null +++ b/test/fixtures/host-stats/procmini/404/status @@ -0,0 +1,12 @@ +Name: my (weird) proc +Umask: 0022 +State: D (disk sleep) +Tgid: 404 +Pid: 404 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 97656 kB +VmRSS: 4321 kB +Threads: 2 diff --git a/test/fixtures/host-stats/procmini/505/stat b/test/fixtures/host-stats/procmini/505/stat new file mode 100644 index 000000000..9b240a326 --- /dev/null +++ b/test/fixtures/host-stats/procmini/505/stat @@ -0,0 +1 @@ +505 (zomb) Z 1 505 505 0 -1 4194304 0 0 0 0 10 5 0 0 20 0 1 0 9000 0 0 diff --git a/test/fixtures/host-stats/procmini/505/status b/test/fixtures/host-stats/procmini/505/status new file mode 100644 index 000000000..77aaf01a9 --- /dev/null +++ b/test/fixtures/host-stats/procmini/505/status @@ -0,0 +1,9 @@ +Name: zomb +Umask: 0022 +State: Z (zombie) +Tgid: 505 +Pid: 505 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +Threads: 1 diff --git a/test/fixtures/host-stats/procmini/606/stat b/test/fixtures/host-stats/procmini/606/stat new file mode 100644 index 000000000..7e539090d --- /dev/null +++ b/test/fixtures/host-stats/procmini/606/stat @@ -0,0 +1 @@ +606 (nginx) R 1 606 606 0 -1 4194304 40000 0 300 0 2000 500 0 0 20 0 4 0 10000 100000000 12000 diff --git a/test/fixtures/host-stats/procmini/606/status b/test/fixtures/host-stats/procmini/606/status new file mode 100644 index 000000000..aff39a979 --- /dev/null +++ b/test/fixtures/host-stats/procmini/606/status @@ -0,0 +1,12 @@ +Name: nginx +Umask: 0022 +State: R (running) +Tgid: 606 +Pid: 606 +PPid: 1 +Uid: 0 0 0 0 +Gid: 0 0 0 0 +VmPeak: 150000 kB +VmSize: 146484 kB +VmRSS: 23456 kB +Threads: 4 diff --git a/test/fixtures/host-stats/procmini/707/stat b/test/fixtures/host-stats/procmini/707/stat new file mode 100644 index 000000000..7fe337910 --- /dev/null +++ b/test/fixtures/host-stats/procmini/707/stat @@ -0,0 +1 @@ +707 (bash) S 1 707 707 0 -1 4194304 500 0 20 0 60 20 0 0 20 0 1 0 11000 80000000 2000 diff --git a/test/fixtures/host-stats/procmini/707/status b/test/fixtures/host-stats/procmini/707/status new file mode 100644 index 000000000..993f15f3f --- /dev/null +++ b/test/fixtures/host-stats/procmini/707/status @@ -0,0 +1,12 @@ +Name: bash +Umask: 0022 +State: S (sleeping) +Tgid: 707 +Pid: 707 +PPid: 1 +Uid: 1000 1000 1000 1000 +Gid: 1000 1000 1000 1000 +VmPeak: 100000 kB +VmSize: 78125 kB +VmRSS: 3456 kB +Threads: 1 diff --git a/test/fixtures/host-stats/procmini/self/cgroup b/test/fixtures/host-stats/procmini/self/cgroup new file mode 100644 index 000000000..41d81f742 --- /dev/null +++ b/test/fixtures/host-stats/procmini/self/cgroup @@ -0,0 +1 @@ +0::/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service diff --git a/test/fixtures/host-stats/sys/class/power_supply/BAT0/capacity b/test/fixtures/host-stats/sys/class/power_supply/BAT0/capacity new file mode 100644 index 000000000..84df3526d --- /dev/null +++ b/test/fixtures/host-stats/sys/class/power_supply/BAT0/capacity @@ -0,0 +1 @@ +87 diff --git a/test/fixtures/host-stats/sys/class/power_supply/BAT0/status b/test/fixtures/host-stats/sys/class/power_supply/BAT0/status new file mode 100644 index 000000000..4674475b6 --- /dev/null +++ b/test/fixtures/host-stats/sys/class/power_supply/BAT0/status @@ -0,0 +1 @@ +Discharging diff --git a/test/fixtures/host-stats/sys/class/power_supply/BAT0/type b/test/fixtures/host-stats/sys/class/power_supply/BAT0/type new file mode 100644 index 000000000..6784dd35c --- /dev/null +++ b/test/fixtures/host-stats/sys/class/power_supply/BAT0/type @@ -0,0 +1 @@ +Battery diff --git a/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp b/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp new file mode 100644 index 000000000..304cba046 --- /dev/null +++ b/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/temp @@ -0,0 +1 @@ +51500 diff --git a/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/type b/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/type new file mode 100644 index 000000000..0a11ba228 --- /dev/null +++ b/test/fixtures/host-stats/sys/class/thermal/thermal_zone0/type @@ -0,0 +1 @@ +x86_pkg_temp diff --git a/test/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq b/test/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..98be78b86 --- /dev/null +++ b/test/fixtures/host-stats/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +3400000 diff --git a/test/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq b/test/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq new file mode 100644 index 000000000..c754f1a46 --- /dev/null +++ b/test/fixtures/host-stats/sys/devices/system/cpu/cpu1/cpufreq/scaling_cur_freq @@ -0,0 +1 @@ +2800000 diff --git a/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current new file mode 100644 index 000000000..fcd6d3c41 --- /dev/null +++ b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.current @@ -0,0 +1 @@ +17000000000 diff --git a/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max new file mode 100644 index 000000000..355295a05 --- /dev/null +++ b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/memory.max @@ -0,0 +1 @@ +max diff --git a/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current new file mode 100644 index 000000000..d81cc0710 --- /dev/null +++ b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.current @@ -0,0 +1 @@ +42 diff --git a/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max new file mode 100644 index 000000000..fff795a14 --- /dev/null +++ b/test/fixtures/host-stats/sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/freshell-rust.service/pids.max @@ -0,0 +1 @@ +10854 diff --git a/test/integration/client/editor-pane.test.tsx b/test/integration/client/editor-pane.test.tsx index f23bde354..103c48462 100644 --- a/test/integration/client/editor-pane.test.tsx +++ b/test/integration/client/editor-pane.test.tsx @@ -58,6 +58,9 @@ vi.mock('lucide-react', () => ({ SplitSquareVertical: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Globe: ({ className }: { className?: string }) => ( ), diff --git a/test/server/agent-panes-write.test.ts b/test/server/agent-panes-write.test.ts index b93060293..3eded54a2 100644 --- a/test/server/agent-panes-write.test.ts +++ b/test/server/agent-panes-write.test.ts @@ -28,6 +28,28 @@ it('splits a pane horizontally', async () => { expect(attachPaneContent).toHaveBeenCalled() }) +it('splits a pane into a host-stats pane without spawning a terminal', async () => { + const app = express() + app.use(express.json()) + const splitPane = vi.fn(() => ({ newPaneId: 'pane_new', tabId: 'tab_1' })) + const attachPaneContent = vi.fn() + const registryCreate = vi.fn(() => ({ terminalId: 'term_new' })) + app.use('/api', createAgentApiRouter({ + layoutStore: { splitPane, attachPaneContent }, + registry: { create: registryCreate }, + wsHandler: { broadcastUiCommand: () => {} }, + })) + + const res = await request(app).post('/api/panes/pane_1/split').send({ hostStats: true }) + expect(res.body.status).toBe('ok') + expect(res.body.message).toBe('pane split (non-terminal)') + expect(res.body.data.paneId).toBe('pane_new') + expect(res.body.data.terminalId).toBeUndefined() + expect(registryCreate).not.toHaveBeenCalled() + expect(splitPane).toHaveBeenCalledWith(expect.objectContaining({ hostStats: true })) + expect(attachPaneContent).toHaveBeenCalledWith('tab_1', 'pane_new', { kind: 'host-stats' }) +}) + it('rejects invalid Codex settings when splitting a pane before spawning', async () => { const app = express() app.use(express.json()) diff --git a/test/server/agent-tabs-write.test.ts b/test/server/agent-tabs-write.test.ts index 0b98dc082..baa40317a 100644 --- a/test/server/agent-tabs-write.test.ts +++ b/test/server/agent-tabs-write.test.ts @@ -54,6 +54,32 @@ describe('tab endpoints', () => { expect(layoutStore.attachPaneContent).toHaveBeenCalled() }) + it('creates host-stats tabs without spawning a terminal', async () => { + const app = express() + app.use(express.json()) + const registry = new FakeRegistry() + const createTab = vi.fn(() => ({ tabId: 'tab_1', paneId: 'pane_1' })) + const attachPaneContent = vi.fn() + const layoutStore = { + createTab, + attachPaneContent, + selectTab: () => ({}), + renameTab: () => ({}), + closeTab: () => ({}), + hasTab: () => true, + selectNextTab: () => ({ tabId: 'tab_1' }), + selectPrevTab: () => ({ tabId: 'tab_1' }), + } + app.use('/api', createAgentApiRouter({ layoutStore, registry, wsHandler: { broadcastUiCommand: () => {} } })) + const res = await request(app).post('/api/tabs').send({ name: 'stats', hostStats: true }) + + expect(res.body.status).toBe('ok') + expect(registry.create).not.toHaveBeenCalled() + expect(createTab).toHaveBeenCalledWith(expect.objectContaining({ hostStats: true })) + expect(attachPaneContent).toHaveBeenCalledWith('tab_1', 'pane_1', { kind: 'host-stats' }) + expect(res.body.data.terminalId).toBeUndefined() + }) + it('allocates and passes an OpenCode control endpoint when creating an opencode tab', async () => { const app = express() app.use(express.json()) diff --git a/test/server/ws-hoststats.test.ts b/test/server/ws-hoststats.test.ts new file mode 100644 index 000000000..1148f8245 --- /dev/null +++ b/test/server/ws-hoststats.test.ts @@ -0,0 +1,378 @@ +/** + * Behavioral tests for the hoststats.* ws wiring (Task 4 of + * docs/plans/2026-08-25-host-pressure-pane.md): subscribe/unsubscribe lifecycle gating + * the sampling service on/off, immediate + fanned-out snapshot delivery limited to + * subscribed+authenticated sockets, per-request refresh responses, and the two-layer + * refresh rate limit (per-connection floor + service-level post-completion cooldown). + * + * Scaffolding cloned from test/server/ws-codex-activity.test.ts (FakeRegistry, + * listen-on-port-0, hello->ready dance, waitForMessage/expectNoMatchingMessage) with a + * REAL HostStatsService (fastMs 25 / slowMs 50) reading the Task 2 fixture tree under + * test/fixtures/host-stats/. statfs in the disks section hits the REAL host mounts + * ('/' and '/dev/shm'), so refresh assertions are shape + numerically sane, never + * fixture-exact. + */ +import { describe, it, expect, vi } from 'vitest' +import http from 'http' +import path from 'path' +import WebSocket from 'ws' +import { + WS_PROTOCOL_VERSION, + HostStatsSnapshotSchema, + HostStatsRefreshResponseSchema, +} from '../../shared/ws-protocol' +import type { HostStatsService } from '../../server/host-stats/service' +import type { HostStatsServiceDeps } from '../../server/host-stats/service' +import type { WsHandler } from '../../server/ws-handler' + +vi.mock('../../server/config-store', () => ({ + configStore: { + snapshot: vi.fn().mockResolvedValue({ + version: 1, + settings: {}, + sessionOverrides: {}, + terminalOverrides: {}, + projectColors: {}, + }), + }, +})) + +const AUTH_TOKEN = 'hoststats-test-token' +const FIXTURES = path.resolve(__dirname, '../fixtures/host-stats') +const PROC = path.join(FIXTURES, 'proc') +const PROMINI = path.join(FIXTURES, 'procmini') +const SYS = path.join(FIXTURES, 'sys') + +function listen(server: http.Server): Promise { + return new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + const address = server.address() + if (!address || typeof address === 'string') { + reject(new Error('Failed to bind test server')) + return + } + resolve(address.port) + }) + }) +} + +function waitForMessage(ws: WebSocket, predicate: (msg: any) => boolean, timeoutMs = 3000): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.off('message', onMessage) + reject(new Error('Timed out waiting for websocket message')) + }, timeoutMs) + + const onMessage = (raw: WebSocket.Data) => { + const msg = JSON.parse(raw.toString()) + if (!predicate(msg)) return + clearTimeout(timeout) + ws.off('message', onMessage) + resolve(msg) + } + + ws.on('message', onMessage) + }) +} + +function expectNoMatchingMessage(ws: WebSocket, predicate: (msg: any) => boolean, timeoutMs = 250): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.off('message', onMessage) + resolve() + }, timeoutMs) + + const onMessage = (raw: WebSocket.Data) => { + const msg = JSON.parse(raw.toString()) + if (!predicate(msg)) return + clearTimeout(timeout) + ws.off('message', onMessage) + reject(new Error(`Unexpected websocket message: ${JSON.stringify(msg)}`)) + } + + ws.on('message', onMessage) + }) +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + +async function until(predicate: () => boolean, timeoutMs = 2000): Promise { + const start = Date.now() + while (!predicate()) { + if (Date.now() - start > timeoutMs) throw new Error('Timed out waiting for condition') + await sleep(15) + } +} + +class FakeRegistry { + list() { + return [] + } + get() { return null } + create() { throw new Error('not used') } + attach() { return null } + finishAttachSnapshot() {} + detach() { return false } + input() { return false } + resize() { return false } + kill() { return false } + findRunningClaudeTerminalBySession() { return undefined } +} + +type HostStatsTestServer = { + server: http.Server + wsHandler: WsHandler + service: HostStatsService + port: number +} + +async function setupHostStatsServer(serviceDeps: HostStatsServiceDeps = {}): Promise { + process.env.NODE_ENV = 'test' + process.env.AUTH_TOKEN = AUTH_TOKEN + + const { WsHandler } = await import('../../server/ws-handler') + const { HostStatsService } = await import('../../server/host-stats/service') + + const server = http.createServer((_req, res) => { + res.statusCode = 404 + res.end() + }) + // fast/slow tiers fast enough for tick-window assertions (fastMs 25 / slowMs 50). + const service = new HostStatsService({ procRoot: PROC, sysRoot: SYS, fastMs: 25, slowMs: 50, ...serviceDeps }) + const wsHandler = new WsHandler(server, new FakeRegistry() as any, { hostStats: service }) + // Same wiring shape as server/index.ts: sources close over the handler, so they are + // set AFTER it exists. wsClientsMax 50 mirrors the default MAX_CONNECTIONS fallback. + service.setSources({ + getPtyCounts: () => ({ running: 0, max: 10 }), + getWsClientCounts: () => ({ clients: wsHandler.connectionCount(), max: 50 }), + }) + const port = await listen(server) + return { server, wsHandler, service, port } +} + +async function teardownHostStatsServer(ctx: HostStatsTestServer): Promise { + ctx.wsHandler.close() + ctx.service.stop() + await new Promise((resolve) => ctx.server.close(() => resolve())) +} + +async function connectAuthenticated(port: number): Promise { + const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`) + await new Promise((resolve) => ws.on('open', () => resolve())) + ws.send(JSON.stringify({ type: 'hello', token: AUTH_TOKEN, protocolVersion: WS_PROTOCOL_VERSION })) + await waitForMessage(ws, (msg) => msg.type === 'ready') + return ws +} + +describe('ws hoststats protocol', () => { + it('(a) subscribe starts the service, sends an immediate schema-shaped snapshot, then ticks', async () => { + const ctx = await setupHostStatsServer() + try { + const ws = await connectAuthenticated(ctx.port) + ws.send(JSON.stringify({ type: 'hoststats.subscribe' })) + + const first = await waitForMessage(ws, (msg) => msg.type === 'hoststats.snapshot') + const parsed = HostStatsSnapshotSchema.safeParse(first) + expect(parsed.success).toBe(true) + expect(first.live.machine.cores).toBeGreaterThan(0) + expect(ctx.service.isRunning()).toBe(true) + + // fastMs is 25 -> two further snapshots comfortably inside 300ms. + await waitForMessage(ws, (msg) => msg.type === 'hoststats.snapshot', 300) + await waitForMessage(ws, (msg) => msg.type === 'hoststats.snapshot', 300) + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(b) snapshot live values are fixture-consistent (load/memory/cpu)', async () => { + const ctx = await setupHostStatsServer() + try { + const ws = await connectAuthenticated(ctx.port) + ws.send(JSON.stringify({ type: 'hoststats.subscribe' })) + const first = await waitForMessage(ws, (msg) => msg.type === 'hoststats.snapshot') + + // fixture proc/loadavg: "0.50 1.00 1.20 2/1234 5678" + expect(first.live.load).toMatchObject({ available: true, load1: 0.5, load5: 1, load15: 1.2 }) + expect(first.live.cpu.available).toBe(true) + // fixture proc/meminfo: MemTotal 67108864 kB, MemAvailable 33554432 kB, host source + expect(first.live.memory).toMatchObject({ + available: true, + source: 'host', + totalBytes: 67108864 * 1024, + availableBytes: 33554432 * 1024, + }) + // freshell internals come from setSources wiring (wsClientsMax 50 as passed above) + expect(first.live.freshell).toMatchObject({ available: true, ptysMax: 10, wsClientsMax: 50 }) + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(c) unsubscribe stops the stream and (1->0) stops the service', async () => { + const ctx = await setupHostStatsServer() + try { + const ws = await connectAuthenticated(ctx.port) + ws.send(JSON.stringify({ type: 'hoststats.subscribe' })) + await waitForMessage(ws, (msg) => msg.type === 'hoststats.snapshot') + expect(ctx.service.isRunning()).toBe(true) + + ws.send(JSON.stringify({ type: 'hoststats.unsubscribe' })) + await until(() => !ctx.service.isRunning()) + // absorb any rounds already on the wire before opening the quiet window + await sleep(100) + await expect( + expectNoMatchingMessage(ws, (msg) => msg.type === 'hoststats.snapshot', 150), + ).resolves.toBeUndefined() + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(d) the last subscriber closing its socket stops the service; earlier closes do not', async () => { + const ctx = await setupHostStatsServer() + try { + const ws1 = await connectAuthenticated(ctx.port) + const ws2 = await connectAuthenticated(ctx.port) + ws1.send(JSON.stringify({ type: 'hoststats.subscribe' })) + ws2.send(JSON.stringify({ type: 'hoststats.subscribe' })) + await waitForMessage(ws1, (msg) => msg.type === 'hoststats.snapshot') + await waitForMessage(ws2, (msg) => msg.type === 'hoststats.snapshot') + expect(ctx.service.isRunning()).toBe(true) + + ws1.close() + await until(() => ctx.wsHandler.connectionCount() === 1) + // one subscriber still attached -> service keeps sampling + expect(ctx.service.isRunning()).toBe(true) + + ws2.close() + await until(() => !ctx.service.isRunning()) + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(e) refresh answers the requester with its own requestId and real-host disks', async () => { + const ctx = await setupHostStatsServer() + try { + const ws = await connectAuthenticated(ctx.port) + ws.send(JSON.stringify({ type: 'hoststats.refresh', requestId: 'refresh-e1' })) + const response = await waitForMessage( + ws, + (msg) => msg.type === 'hoststats.refresh.response' && msg.requestId === 'refresh-e1', + 5000, + ) + expect(HostStatsRefreshResponseSchema.safeParse(response).success).toBe(true) + expect(response.ok).toBe(true) + expect(typeof response.at).toBe('number') + + // statfs runs against the REAL host: assert shape + numerically sane, not fixture-exact. + expect(response.manual.disks.list.length).toBeGreaterThan(0) + const rootMount = response.manual.disks.list.find((d: any) => d.mount === '/') + expect(rootMount).toBeDefined() + expect(rootMount.totalBytes).toBeGreaterThan(0) + expect(rootMount.freeBytes).toBeGreaterThanOrEqual(0) + expect(rootMount.usedPct).toBeGreaterThanOrEqual(0) + expect(rootMount.usedPct).toBeLessThanOrEqual(100) + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(e2) a section blowing its budget degrades to available:false while the response stays ok', async () => { + // procmini has 7 numeric pids; the 300ms scan dwell always blows a 1ms budget. + const ctx = await setupHostStatsServer({ procRoot: PROMINI, sectionBudgetMs: 1 }) + try { + const ws = await connectAuthenticated(ctx.port) + ws.send(JSON.stringify({ type: 'hoststats.refresh', requestId: 'refresh-e2' })) + const response = await waitForMessage( + ws, + (msg) => msg.type === 'hoststats.refresh.response' && msg.requestId === 'refresh-e2', + 5000, + ) + expect(HostStatsRefreshResponseSchema.safeParse(response).success).toBe(true) + expect(response.ok).toBe(true) + expect(response.manual.topProcesses.available).toBe(false) + expect(typeof response.manual.sectionErrors.topProcesses).toBe('string') + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(f) a pre-hello subscribe is rejected by the existing NOT_AUTHENTICATED gate', async () => { + const ctx = await setupHostStatsServer() + try { + const ws = new WebSocket(`ws://127.0.0.1:${ctx.port}/ws`) + await new Promise((resolve) => ws.on('open', () => resolve())) + ws.send(JSON.stringify({ type: 'hoststats.subscribe' })) + const error = await waitForMessage(ws, (msg) => msg.type === 'error') + expect(error.code).toBe('NOT_AUTHENTICATED') + expect(ctx.service.isRunning()).toBe(false) + ws.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(g) with zero subscribers the service never samples (zero-cost idle)', async () => { + const ctx = await setupHostStatsServer() + try { + expect(ctx.service.isRunning()).toBe(false) + await sleep(150) // several fastMs (25ms) and slowMs (50ms) windows elapse + expect(ctx.service.isRunning()).toBe(false) + } finally { + await teardownHostStatsServer(ctx) + } + }) + + it('(h) refresh is rate-limited per-connection AND by the service post-completion cooldown', async () => { + const ctx = await setupHostStatsServer() + try { + const refreshSpy = vi.spyOn(ctx.service, 'refresh') + const ws1 = await connectAuthenticated(ctx.port) + const firstResponsePromise = waitForMessage( + ws1, + (msg) => msg.type === 'hoststats.refresh.response' && msg.requestId === 'refresh-h1', + 5000, + ) + ws1.send(JSON.stringify({ type: 'hoststats.refresh', requestId: 'refresh-h1' })) + await sleep(100) // < the 1000ms per-connection floor + ws1.send(JSON.stringify({ type: 'hoststats.refresh', requestId: 'refresh-h2' })) + + const second = await waitForMessage( + ws1, + (msg) => msg.type === 'hoststats.refresh.response' && msg.requestId === 'refresh-h2', + ) + expect(second).toMatchObject({ ok: false, error: 'rate_limited' }) + // the per-connection floor rejected WITHOUT invoking the service + expect(refreshSpy).toHaveBeenCalledTimes(1) + + const first = await firstResponsePromise + expect(first.ok).toBe(true) + + // Multi-socket bypass (R3M6): a FRESH connection has a clean per-connection floor, + // but <1000ms after the first refresh COMPLETED the service-level cooldown rejects. + const ws2 = await connectAuthenticated(ctx.port) + ws2.send(JSON.stringify({ type: 'hoststats.refresh', requestId: 'refresh-h3' })) + const third = await waitForMessage( + ws2, + (msg) => msg.type === 'hoststats.refresh.response' && msg.requestId === 'refresh-h3', + ) + expect(third).toMatchObject({ ok: false, error: 'rate_limited' }) + // the service WAS invoked (per-conn floor clean) and rejected from its own cooldown + expect(refreshSpy).toHaveBeenCalledTimes(2) + ws1.close() + ws2.close() + } finally { + await teardownHostStatsServer(ctx) + } + }) +}) diff --git a/test/unit/client/components/App.hoststats-ws.test.tsx b/test/unit/client/components/App.hoststats-ws.test.tsx new file mode 100644 index 000000000..79c65afec --- /dev/null +++ b/test/unit/client/components/App.hoststats-ws.test.tsx @@ -0,0 +1,444 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, cleanup, waitFor, act } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import App from '@/App' +import settingsReducer, { defaultSettings } from '@/store/settingsSlice' +import tabsReducer from '@/store/tabsSlice' +import connectionReducer from '@/store/connectionSlice' +import sessionsReducer from '@/store/sessionsSlice' +import panesReducer from '@/store/panesSlice' +import tabRegistryReducer from '@/store/tabRegistrySlice' +import terminalMetaReducer from '@/store/terminalMetaSlice' +import extensionsReducer from '@/store/extensionsSlice' +import turnCompletionReducer from '@/store/turnCompletionSlice' +import { networkReducer } from '@/store/networkSlice' +import codexActivityReducer from '@/store/codexActivitySlice' +import opencodeActivityReducer from '@/store/opencodeActivitySlice' +import hostStatsReducer, { + activateHostStats, + requestHostStatsRefresh, + _resetHostStatsThunkState, +} from '@/store/hostStatsSlice' +import type { HostStatsLive, HostStatsManual } from '@shared/ws-protocol' +import { + createDefaultServerSettings, + composeResolvedSettings, + resolveLocalSettings, +} from '@shared/settings' + +// Mock heavy child components to avoid xterm/canvas issues (same scaffold as +// App.reconcile-adoption.test.tsx). +vi.mock('@/components/TabContent', () => ({ + default: () =>
Tab Content
, +})) +vi.mock('@/components/Sidebar', () => ({ + default: () =>
Sidebar
, + AppView: {} as any, +})) +vi.mock('@/components/HistoryView', () => ({ + default: () =>
History View
, +})) +vi.mock('@/components/SettingsView', () => ({ + default: () =>
Settings View
, +})) +vi.mock('@/components/OverviewView', () => ({ + default: () =>
Overview View
, +})) +vi.mock('@/hooks/useTheme', () => ({ + useThemeEffect: () => {}, +})) +vi.mock('@/components/SetupWizard', () => ({ + SetupWizard: () =>
Setup Wizard
, +})) + +const defaultServerSettings = createDefaultServerSettings({ + loggingDebug: defaultSettings.logging.debug, +}) + +function stubAudio(): void { + vi.stubGlobal('Audio', vi.fn(() => ({ + preload: '', + volume: 1, + pause: vi.fn(), + play: vi.fn().mockResolvedValue(undefined), + currentTime: 0, + src: '', + }) as unknown as HTMLAudioElement)) +} + +const wsMocks = vi.hoisted(() => ({ + send: vi.fn(), + connect: vi.fn(), + onMessage: vi.fn(), + onReconnect: vi.fn().mockReturnValue(() => {}), + onDisconnect: vi.fn().mockReturnValue(() => {}), + setHelloExtensionProvider: vi.fn(), + cancelCreate: vi.fn(), + setReconcilePendingCreates: vi.fn(), + clearReconcileCreateHold: vi.fn(), + isReady: false, + serverInstanceId: undefined as string | undefined, +})) + +const terminalRestoreMocks = vi.hoisted(() => ({ + addTerminalRestoreRequestId: vi.fn(), + addTerminalFreshRecoveryRequestId: vi.fn(), + setPaneReconcileActive: vi.fn(), +})) + +vi.mock('@/lib/terminal-restore', () => ({ + addTerminalRestoreRequestId: terminalRestoreMocks.addTerminalRestoreRequestId, + addTerminalFreshRecoveryRequestId: terminalRestoreMocks.addTerminalFreshRecoveryRequestId, + setPaneReconcileActive: terminalRestoreMocks.setPaneReconcileActive, +})) + +let messageHandler: ((msg: any) => void) | null = null +let disconnectHandler: (() => void) | null = null + +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ + send: wsMocks.send, + connect: wsMocks.connect, + onMessage: wsMocks.onMessage, + onReconnect: wsMocks.onReconnect, + onDisconnect: wsMocks.onDisconnect, + setHelloExtensionProvider: wsMocks.setHelloExtensionProvider, + cancelCreate: wsMocks.cancelCreate, + setReconcilePendingCreates: wsMocks.setReconcilePendingCreates, + clearReconcileCreateHold: wsMocks.clearReconcileCreateHold, + get isReady() { + return wsMocks.isReady + }, + get serverInstanceId() { + return wsMocks.serverInstanceId + }, + }), +})) + +const apiGet = vi.hoisted(() => vi.fn()) +const fetchSidebarSessionsSnapshot = vi.hoisted(() => vi.fn()) +const getTerminalDirectoryPage = vi.hoisted(() => vi.fn()) +const searchTerminalView = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/api', () => ({ + api: { + get: (url: string) => apiGet(url), + patch: vi.fn().mockResolvedValue({}), + post: vi.fn().mockResolvedValue({}), + }, + fetchSidebarSessionsSnapshot: (options?: unknown) => fetchSidebarSessionsSnapshot(options), + getRecoveryInventory: async () => ({ recoverable: false, contentId: 'test', device: null, otherDevices: [], ledgerOnly: [] }), + getTerminalDirectoryPage: (options?: unknown, init?: unknown) => getTerminalDirectoryPage(options, init), + searchTerminalView: (terminalId: string, query: string, options?: unknown) => searchTerminalView(terminalId, query, options), + isApiUnauthorizedError: (err: any) => !!err && typeof err === 'object' && err.status === 401, + isTransientRequestFailure: (err: any) => + !!err && (err.name === 'NetworkError' || err.name === 'AbortError' || [502, 503, 504].includes(err.status)), +})) + +const sentFrames: any[] = [] + +function makeLive(): HostStatsLive { + return { + machine: { + cores: 8, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: false, + kernel: '6.6', hostname: 'test', psi: true, cgroup: 'v2', + thermalCount: 1, batteryPresent: false, gpu: 'none', + }, + cpu: { available: true, usagePct: 10, stealPct: 0, perCorePct: [10], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 0.6, load15: 0.7, cores: 8 }, + memory: { + available: true, source: 'host', totalBytes: 10_000, usedBytes: 1_000, availableBytes: 9_000, + cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0, + }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: 0.2, memFull10: 0, ioSome10: 0.1, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: 1, weightedAwaitMs: 5 }, + network: { + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }, + limits: { available: true, fdsUsed: 100, fdsMax: 1_048_576, pidsUsed: 100, pidsMax: 4_194_304, timeWait: 10, ephemeralPorts: 28_232 }, + freshell: { + available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 1, wsClientsMax: 50, + eventLoopLagP99Ms: 5, rssBytes: 1_000_000, uptimeSec: 60, + }, + } +} + +function makeManual(): HostStatsManual { + return { + topProcesses: { available: true, dwellMs: 300, list: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }] }, + processHealth: { available: true, zombies: 0, dState: 0, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1_048_576, maxUserInstances: 128 }, + disks: { available: true, list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }] }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, + } +} + +function createSettingsState() { + const localSettings = resolveLocalSettings() + return { + serverSettings: defaultServerSettings, + localSettings, + settings: composeResolvedSettings(defaultServerSettings, localSettings), + loaded: true, + lastSavedAt: undefined, + } +} + +function createStore() { + return configureStore({ + reducer: { + settings: settingsReducer, + tabs: tabsReducer, + connection: connectionReducer, + sessions: sessionsReducer, + panes: panesReducer, + network: networkReducer, + codexActivity: codexActivityReducer, + opencodeActivity: opencodeActivityReducer, + tabRegistry: tabRegistryReducer, + terminalMeta: terminalMetaReducer, + extensions: extensionsReducer, + turnCompletion: turnCompletionReducer, + hostStats: hostStatsReducer, + }, + middleware: (getDefault) => + getDefault({ + serializableCheck: { ignoredPaths: ['sessions.expandedProjects'] }, + }), + preloadedState: { + settings: createSettingsState(), + tabs: { tabs: [{ id: 'tab-1', mode: 'shell' }] as any, activeTabId: 'tab-1' }, + connection: { + status: 'disconnected' as const, + lastError: undefined, + platform: null, + availableClis: {}, + }, + sessions: { + projects: [], + expandedProjects: new Set(), + wsSnapshotReceived: false, + isLoading: false, + error: null, + windows: {}, + }, + panes: { + layouts: {}, + activePane: {}, + paneTitles: {}, + paneTitleSetByUser: {}, + renameRequestTabId: null, + renameRequestPaneId: null, + zoomedPane: {}, + }, + network: { status: null, loading: false, configuring: false, error: null }, + codexActivity: { + byTerminalId: {}, + lastSnapshotSeq: 0, + liveMutationSeqByTerminalId: {}, + removedMutationSeqByTerminalId: {}, + }, + opencodeActivity: { + byTerminalId: {}, + lastSnapshotSeq: 0, + liveMutationSeqByTerminalId: {}, + removedMutationSeqByTerminalId: {}, + }, + tabRegistry: { + deviceId: 'device-test', + deviceLabel: 'device-test', + deviceAliases: {}, + localOpen: [], + remoteOpen: [], + closed: [], + localClosed: {}, + searchRangeDays: 30, + loading: false, + }, + terminalMeta: { byTerminalId: {} }, + extensions: { entries: [] }, + turnCompletion: { + seq: 0, + lastAtByTerminalId: {}, + lastIdleAtByTerminalId: {}, + pendingEvents: [], + attentionByTab: {}, + attentionByPane: {}, + }, + } as any, + }) +} + +function readyFrame() { + return { + type: 'ready', + timestamp: new Date().toISOString(), + serverInstanceId: 'srv-1', + bootId: 'boot-1', + } +} + +async function bootApp() { + const store = createStore() + render( + + + , + ) + await waitFor(() => { + expect(messageHandler).toBeTypeOf('function') + }) + return store +} + +async function receiveServerFrame(frame: Record) { + act(() => { + messageHandler?.(frame) + }) +} + +const hostStatsState = (store: { getState: () => any }) => store.getState().hostStats +const subscribeFrames = () => sentFrames.filter((f) => f.type === 'hoststats.subscribe') + +describe('App hoststats.* ws folding', () => { + beforeEach(() => { + cleanup() + vi.resetAllMocks() + stubAudio() + sentFrames.length = 0 + messageHandler = null + disconnectHandler = null + wsMocks.isReady = false + wsMocks.serverInstanceId = undefined + wsMocks.onReconnect.mockReturnValue(() => {}) + wsMocks.onDisconnect.mockImplementation((cb: () => void) => { + disconnectHandler = cb + return () => { disconnectHandler = null } + }) + wsMocks.onMessage.mockImplementation((cb: (msg: any) => void) => { + messageHandler = cb + return () => { messageHandler = null } + }) + wsMocks.send.mockImplementation((frame: unknown) => { + sentFrames.push(frame) + }) + + fetchSidebarSessionsSnapshot.mockResolvedValue([]) + getTerminalDirectoryPage.mockResolvedValue({ items: [], revision: 1, nextCursor: null }) + searchTerminalView.mockResolvedValue({ matches: [] }) + apiGet.mockImplementation((url: string) => { + if (url === '/api/bootstrap') { + return Promise.resolve({ + settings: defaultServerSettings, + platform: { platform: 'linux' }, + shell: { authenticated: true, ready: true }, + }) + } + return Promise.resolve({}) + }) + }) + + afterEach(() => { + cleanup() + _resetHostStatsThunkState() + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('folds hoststats.snapshot into the store, merging manual without clearing it', async () => { + const store = await bootApp() + const live = makeLive() + // Realistic server wall-clock `at` — a fake-epoch timestamp trips the 10min + // clock-offset garbage guard by design (that rejection is pinned in the slice tests). + const now = Date.now() + await receiveServerFrame({ type: 'hoststats.snapshot', at: now, live, manualAt: null, manual: null }) + expect(hostStatsState(store).live).toEqual(live) + expect(hostStatsState(store).liveAt).toBe(now) + expect(hostStatsState(store).clockOffsetMs).not.toBeNull() + expect(hostStatsState(store).manual).toBeNull() + + const manual = makeManual() + await receiveServerFrame({ type: 'hoststats.snapshot', at: now + 2_000, live, manualAt: now + 2_000, manual }) + expect(hostStatsState(store).manual).toEqual(manual) + expect(hostStatsState(store).manualAt).toBe(now + 2_000) + + // A later manual-less snapshot must not clear the stored manual group. + await receiveServerFrame({ type: 'hoststats.snapshot', at: now + 4_000, live, manualAt: null, manual: null }) + expect(hostStatsState(store).manual).toEqual(manual) + expect(hostStatsState(store).manualAt).toBe(now + 2_000) + expect(hostStatsState(store).liveAt).toBe(now + 4_000) + }) + + it('folds refresh responses by requestId; unknown ids are ignored without throwing', async () => { + const store = await bootApp() + act(() => { + store.dispatch(requestHostStatsRefresh() as any) + }) + const req = sentFrames.find((f) => f.type === 'hoststats.refresh') + expect(req.requestId).toMatch(/^hsr-/) + expect(hostStatsState(store).refresh.inFlight).toBe(true) + + await receiveServerFrame({ type: 'hoststats.refresh.response', requestId: 'hsr-unknown', ok: false, error: 'nope' }) + expect(hostStatsState(store).refresh.inFlight).toBe(true) + + const manual = makeManual() + await receiveServerFrame({ type: 'hoststats.refresh.response', requestId: req.requestId, ok: true, at: 555_000, manual }) + expect(hostStatsState(store).refresh).toEqual({ inFlight: false, requestId: null, error: null }) + expect(hostStatsState(store).manual).toEqual(manual) + expect(hostStatsState(store).manualAt).toBe(555_000) + + // Error path: failure keeps previous manual and records the error text. + act(() => { + store.dispatch(requestHostStatsRefresh() as any) + }) + const req2 = sentFrames.filter((f) => f.type === 'hoststats.refresh').pop() + await receiveServerFrame({ type: 'hoststats.refresh.response', requestId: req2.requestId, ok: false, error: 'deadline' }) + expect(hostStatsState(store).refresh.error).toBe('deadline') + expect(hostStatsState(store).manual).toEqual(manual) + expect(hostStatsState(store).manualAt).toBe(555_000) + }) + + it('on ready, resends hoststats.subscribe only when a pane is mounted', async () => { + const store = await bootApp() + await receiveServerFrame(readyFrame()) + expect(subscribeFrames()).toHaveLength(0) + + act(() => { + store.dispatch(activateHostStats() as any) + }) + expect(subscribeFrames()).toHaveLength(1) + + // Reconnect: the subscription died with the old socket and is re-sent. + await receiveServerFrame(readyFrame()) + expect(subscribeFrames()).toHaveLength(2) + expect(hostStatsState(store).subscribed).toBe(true) + expect(hostStatsState(store).mountedPanes).toBe(1) + }) + + it('ws disconnect keeps last live/manual, clears subscribed; next ready resubscribes', async () => { + const store = await bootApp() + act(() => { + store.dispatch(activateHostStats() as any) + }) + const live = makeLive() + const manual = makeManual() + await receiveServerFrame({ type: 'hoststats.snapshot', at: 100_000, live, manualAt: 100_000, manual }) + expect(hostStatsState(store).subscribed).toBe(true) + + expect(disconnectHandler).toBeTypeOf('function') + act(() => { + disconnectHandler?.() + }) + expect(hostStatsState(store).subscribed).toBe(false) + expect(hostStatsState(store).live).toEqual(live) + expect(hostStatsState(store).manual).toEqual(manual) + + await receiveServerFrame(readyFrame()) + expect(hostStatsState(store).subscribed).toBe(true) + expect(subscribeFrames()).toHaveLength(2) + }) +}) diff --git a/test/unit/client/components/component-edge-cases.test.tsx b/test/unit/client/components/component-edge-cases.test.tsx index 049f937ec..7c7ef3ee2 100644 --- a/test/unit/client/components/component-edge-cases.test.tsx +++ b/test/unit/client/components/component-edge-cases.test.tsx @@ -93,6 +93,7 @@ vi.mock('lucide-react', () => ({ Bot: ({ className }: { className?: string }) => , Square: ({ className }: { className?: string }) => , LayoutGrid: ({ className }: { className?: string }) => , + Gauge: ({ className }: { className?: string }) => , Globe: ({ className }: { className?: string }) => , FileText: ({ className }: { className?: string }) => , Search: ({ className }: { className?: string }) => , diff --git a/test/unit/client/components/panes/HostStatsPane.test.tsx b/test/unit/client/components/panes/HostStatsPane.test.tsx new file mode 100644 index 000000000..c9e37bfd7 --- /dev/null +++ b/test/unit/client/components/panes/HostStatsPane.test.tsx @@ -0,0 +1,342 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { render, screen, cleanup, fireEvent, act } from '@testing-library/react' +import { Provider } from 'react-redux' +import { configureStore } from '@reduxjs/toolkit' +import panesReducer from '@/store/panesSlice' +import settingsReducer from '@/store/settingsSlice' +import connectionReducer from '@/store/connectionSlice' +import hostStatsReducer, { + failHostStatsRefresh, + hostStatsSnapshotReceived, + requestHostStatsRefresh, + resolveHostStatsRefresh, + _resetHostStatsThunkState, +} from '@/store/hostStatsSlice' +import type { HostStatsLive, HostStatsManual } from '@shared/ws-protocol' +import { derivePaneTitle } from '@/lib/derivePaneTitle' +import PaneIcon from '@/components/icons/PaneIcon' +import HostStatsPane from '@/components/panes/HostStatsPane' + +// Repo thunk pattern (hostStatsSlice.test.ts): the thunks reach the real +// '@/lib/host-stats-ws' module, which reaches the mocked ws-client. +const sendSpy = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ send: sendSpy }), +})) + +function makeLive(): HostStatsLive { + return { + machine: { + cores: 8, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: false, + kernel: '6.6', hostname: 'test', psi: true, cgroup: 'v2', + thermalCount: 1, batteryPresent: false, gpu: 'none', + }, + cpu: { available: true, usagePct: 10, stealPct: 0, perCorePct: [10, 20, 30, 40], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 0.6, load15: 0.7, cores: 8 }, + memory: { + available: true, source: 'host', totalBytes: 10_000, usedBytes: 1_000, availableBytes: 9_000, + cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0, + }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: 0.2, memFull10: 0, ioSome10: 0.1, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: 1, weightedAwaitMs: 5 }, + network: { + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }, + limits: { available: true, fdsUsed: 321, fdsMax: 0, pidsUsed: 100, pidsMax: 4_194_304, timeWait: 10, ephemeralPorts: 28_232 }, + freshell: { + available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 1, wsClientsMax: 50, + eventLoopLagP99Ms: 5, rssBytes: 1_000_000, uptimeSec: 60, + }, + } +} + +function makeManual(): HostStatsManual { + return { + topProcesses: { available: true, dwellMs: 300, list: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }] }, + processHealth: { available: true, zombies: 0, dState: 0, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1_048_576, maxUserInstances: 128 }, + disks: { available: true, list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }] }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, + } +} + +const createMockStore = () => + configureStore({ + reducer: { + panes: panesReducer, + settings: settingsReducer, + connection: connectionReducer, + hostStats: hostStatsReducer, + }, + }) + +type TestStore = ReturnType + +function renderHostStatsPane(store: TestStore = createMockStore()) { + return { + store, + ...render( + + + , + ), + } +} + +function seedLive(store: TestStore, live: HostStatsLive, at: number = 50_000) { + store.dispatch(hostStatsSnapshotReceived({ at, live, manualAt: null, manual: null })) +} + +function seedLiveAndManual(store: TestStore, live: HostStatsLive, manual: HostStatsManual, at: number) { + store.dispatch(hostStatsSnapshotReceived({ at, live, manualAt: at, manual })) +} + +const verdictStrip = () => screen.getByText((_content, el) => + el?.getAttribute('role') === 'status' && !el.classList.contains('sr-only')) +const onRequestGroup = () => + screen.getByText('ON REQUEST').closest('[data-host-stats-on-request]') as HTMLElement +const ageLabel = () => onRequestGroup().querySelector('[data-host-stats-age]') as HTMLElement +const tileValue = (tileId: string) => + document.querySelector(`[data-host-stats-tile="${tileId}"] [data-host-stats-value]`) as HTMLElement + +describe('HostStatsPane', () => { + beforeEach(() => { + sendSpy.mockClear() + }) + + afterEach(() => { + cleanup() + _resetHostStatsThunkState() + vi.useRealTimers() + }) + + describe('(a) mount subscription lifecycle', () => { + it('sends exactly one hoststats.subscribe on mount and one hoststats.unsubscribe on unmount', () => { + const { unmount } = renderHostStatsPane() + const sendsAfterMount = sendSpy.mock.calls.map(([frame]) => frame) + expect(sendsAfterMount).toEqual([{ type: 'hoststats.subscribe' }]) + + unmount() + const sendsAfterUnmount = sendSpy.mock.calls.map(([frame]) => frame) + expect(sendsAfterUnmount).toEqual([ + { type: 'hoststats.subscribe' }, + { type: 'hoststats.unsubscribe' }, + ]) + }) + + it('a second mounted pane does not re-subscribe (client-side mount refcount)', () => { + const store = createMockStore() + const first = render( + + + , + ) + const second = render( + + + , + ) + expect(sendSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { type: 'hoststats.subscribe' }, + ]) + first.unmount() + expect(sendSpy.mock.calls.map(([frame]) => frame)).toHaveLength(1) + second.unmount() + expect(sendSpy.mock.calls.map(([frame]) => frame)).toEqual([ + { type: 'hoststats.subscribe' }, + { type: 'hoststats.unsubscribe' }, + ]) + }) + }) + + describe('(b) verdict strip + tile words from seeded live state', () => { + it('composes ELEVATED with offender names joined (BUSY tile at cpu 85%)', () => { + const store = createMockStore() + const live = makeLive() + live.cpu.usagePct = 85 + seedLive(store, live) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('ELEVATED — CPU BUSY') + expect(verdictStrip().className).toContain('bg-warning/15') + const cpuTile = document.querySelector('[data-host-stats-tile="cpu"]') as HTMLElement + expect(cpuTile.querySelector('[data-host-stats-value]')).toHaveTextContent('85.0%') + // The tile pill carries the same display word the strip names. + expect(cpuTile).toHaveTextContent('BUSY') + }) + + it('composes the ok verdict with the deliberate "nothing needs attention" suffix', () => { + const store = createMockStore() + seedLive(store, makeLive()) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('ALL GOOD — nothing needs attention') + expect(verdictStrip().className).toContain('bg-success/15') + }) + + it('composes TROUBLE with bad offenders first', () => { + const store = createMockStore() + const live = makeLive() + live.cpu.usagePct = 99 // maxed (bad) + live.memory.usedBytes = 9_000 // 90% of 10_000 → tight (warn) + seedLive(store, live) + renderHostStatsPane(store) + + expect(verdictStrip()).toHaveTextContent('TROUBLE — CPU MAXED · MEMORY TIGHT') + expect(verdictStrip().className).toContain('bg-destructive/10') + }) + + it('a *Max === 0 (no-cap convention) renders as —, never a zero', () => { + const store = createMockStore() + seedLive(store, makeLive()) // makeLive has fdsMax: 0 + renderHostStatsPane(store) + + const limitsTile = document.querySelector('[data-host-stats-tile="limits"]') as HTMLElement + expect(limitsTile).toHaveTextContent('fds') + expect(limitsTile.textContent).toContain('—') + // fdsUsed is 321 in the fixture; a rendered cap would show it — the — must not. + expect(limitsTile.textContent).not.toContain('321') + }) + }) + + describe('(c) manualAt === null → neutral on-request group', () => { + it('renders the on-request group at saturate(0) with an empty age label', () => { + renderHostStatsPane() + + expect(onRequestGroup().style.filter).toBe('saturate(0)') + expect(ageLabel()).toHaveTextContent('') + }) + + it('pre-first-snapshot frame: strip and tile values are neutral placeholders, never bright green ALL GOOD', () => { + renderHostStatsPane() + + // No live snapshot yet — the strip must NOT claim ALL GOOD (nit: zeros + // would lie); it renders a neutral grey '—' instead. + expect(verdictStrip()).toHaveTextContent('—') + expect(verdictStrip().textContent).not.toContain('ALL GOOD') + expect(verdictStrip().className).toContain('bg-muted') + expect(tileValue('cpu')).toHaveTextContent('—') + expect(tileValue('disks')).toHaveTextContent('—') + }) + }) + + describe('(d) desaturation ramp against server-now', () => { + it('fresh manual renders saturate(1); after 60s the group moves toward grey', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 1_000_000) + renderHostStatsPane(store) + + expect(onRequestGroup().style.filter).toBe('saturate(1)') + expect(ageLabel()).toHaveTextContent('just now') + + act(() => { + vi.advanceTimersByTime(60_000) + }) + + // 60s old: 1 - (60_000-30_000)/270_000 = 0.888… (past the full-color floor, + // not yet grey) — recomputed by the pane-local 1s interval. + const match = onRequestGroup().style.filter.match(/^saturate\(([\d.]+)\)$/) + expect(match).not.toBeNull() + const sat = Number(match![1]) + expect(sat).toBeGreaterThan(0.8) + expect(sat).toBeLessThan(1) + expect(sat).toBeCloseTo(1 - 30_000 / 270_000, 5) + expect(ageLabel()).toHaveTextContent('updated 1m 0s ago') + }) + + it('a manual older than 5 minutes renders fully grey (saturate(0))', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + // A fresh snapshot (at=now) can carry an older manual (on-request + // measurements age independently of the live cadence). + store.dispatch(hostStatsSnapshotReceived({ + at: 1_000_000, + live: makeLive(), + manualAt: 1_000_000 - 301_000, + manual: makeManual(), + })) + renderHostStatsPane(store) + + expect(onRequestGroup().style.filter).toBe('saturate(0)') + expect(ageLabel()).toHaveTextContent('updated 5m 1s ago') + }) + }) + + describe('(e) refresh interaction', () => { + it('click sends hoststats.refresh with an hsr- requestId and shows the Collecting state', () => { + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 42_000) + renderHostStatsPane(store) + + const button = screen.getByRole('button', { name: 'Refresh on-request measurements' }) + fireEvent.click(button) + + const refreshFrames = sendSpy.mock.calls + .map(([frame]) => frame as { type?: string; requestId?: string }) + .filter((frame) => frame.type === 'hoststats.refresh') + expect(refreshFrames).toHaveLength(1) + expect(refreshFrames[0].requestId).toMatch(/^hsr-\d+-[a-z0-9]+$/) + expect(button).toBeDisabled() + expect(button).toHaveTextContent('Collecting…') + }) + + it('failure shows role=alert and preserves the old manual values + age (no visual blanking)', () => { + const store = createMockStore() + seedLiveAndManual(store, makeLive(), makeManual(), 42_000) + renderHostStatsPane(store) + + fireEvent.click(screen.getByRole('button', { name: 'Refresh on-request measurements' })) + const requestId = store.getState().hostStats.refresh.requestId! + act(() => { + store.dispatch(failHostStatsRefresh({ requestId, error: 'server exploded' }) as any) + }) + + expect(screen.getByRole('alert')).toHaveTextContent('server exploded') + expect(screen.getByRole('button', { name: 'Refresh on-request measurements' })).toBeEnabled() + // Old values AND the original manualAt stay rendered (slice guarantee, visually pinned). + expect(document.querySelector('[data-host-stats-tile="top-processes"]')).toHaveTextContent('node') + expect(ageLabel().textContent).toMatch(/updated .*ago|just now/) + }) + + it('resolution announces "Measurements refreshed" once via a sr-only role=status, cleared on the next tick', () => { + vi.useFakeTimers({ now: 1_000_000 }) + const store = createMockStore() + seedLive(store, makeLive(), 1_000_000) + renderHostStatsPane(store) + + act(() => { + store.dispatch(requestHostStatsRefresh() as any) + }) + const requestId = store.getState().hostStats.refresh.requestId! + act(() => { + store.dispatch(resolveHostStatsRefresh({ requestId, at: 1_000_000, manual: makeManual() }) as any) + }) + + const announcer = () => screen.getAllByRole('status').find((el) => el.classList.contains('sr-only')) + expect(announcer()).toHaveTextContent('Measurements refreshed') + + act(() => { + vi.advanceTimersByTime(1) + }) + expect(announcer()).toHaveTextContent('') + }) + }) + + describe('(f) title + icon helpers', () => { + it('derivePaneTitle returns Host Stats for host-stats content', () => { + expect(derivePaneTitle({ kind: 'host-stats' })).toBe('Host Stats') + }) + + it('PaneIcon renders the Gauge icon for host-stats content', () => { + const { container } = render() + const svg = container.querySelector('svg') + expect(svg).not.toBeNull() + expect(svg!.getAttribute('class')).toContain('lucide-gauge') + }) + }) +}) diff --git a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx index 8337a0122..3a844769a 100644 --- a/test/unit/client/components/panes/PaneContainer.createContent.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.createContent.test.tsx @@ -10,6 +10,7 @@ import connectionReducer from '@/store/connectionSlice' import terminalMetaReducer from '@/store/terminalMetaSlice' import turnCompletionReducer from '@/store/turnCompletionSlice' import extensionsReducer from '@/store/extensionsSlice' +import hostStatsReducer from '@/store/hostStatsSlice' import type { PanesState } from '@/store/panesSlice' import type { PaneNode } from '@/store/paneTypes' import type { ClientExtensionEntry } from '@shared/extension-types' @@ -62,6 +63,7 @@ vi.mock('lucide-react', () => ({ Code: ({ className }: { className?: string }) => , FileText: ({ className }: { className?: string }) => , LayoutGrid: ({ className }: { className?: string }) => , + Gauge: ({ className }: { className?: string }) => , Maximize2: ({ className }: { className?: string }) => , Minimize2: ({ className }: { className?: string }) => , Pencil: ({ className }: { className?: string }) => , @@ -137,6 +139,7 @@ function createStore( terminalMeta: terminalMetaReducer, turnCompletion: turnCompletionReducer, extensions: extensionsReducer, + hostStats: hostStatsReducer, }, preloadedState: { panes: { @@ -543,4 +546,48 @@ describe('createContentForType with ext: prefix', () => { expect(paneContent.kind).toBe('editor') }) }) + + it('creates host-stats content when the host stats option is selected', async () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { status: 'ready', platform: 'linux', featureFlags: { hostStatsAvailable: true } }, + ) + + render( + + + , + ) + + const hostStatsButton = document.querySelector('[aria-label="Host Stats"]') as HTMLElement + expect(hostStatsButton).not.toBeNull() + fireEvent.click(hostStatsButton) + fireEvent.transitionEnd(getPickerContainer()) + + await waitFor(() => { + const paneContent = (store.getState().panes.layouts['tab-1'] as Extract).content + expect(paneContent).toEqual({ kind: 'host-stats' }) + }) + }) + + it('does not offer host stats when the feature flag is off', () => { + const node = createPickerNode('pane-1') + const store = createStore( + { layouts: { 'tab-1': node }, activePane: { 'tab-1': 'pane-1' } }, + [], + {}, + { status: 'ready', platform: 'linux', featureFlags: {} }, + ) + + render( + + + , + ) + + expect(document.querySelector('[aria-label="Host Stats"]')).toBeNull() + }) }) diff --git a/test/unit/client/components/panes/PaneContainer.test.tsx b/test/unit/client/components/panes/PaneContainer.test.tsx index fc0c737a9..bfd0aa795 100644 --- a/test/unit/client/components/panes/PaneContainer.test.tsx +++ b/test/unit/client/components/panes/PaneContainer.test.tsx @@ -143,6 +143,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Maximize2: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/panes/PaneLayout.test.tsx b/test/unit/client/components/panes/PaneLayout.test.tsx index 872373c8d..a2bc776f6 100644 --- a/test/unit/client/components/panes/PaneLayout.test.tsx +++ b/test/unit/client/components/panes/PaneLayout.test.tsx @@ -60,6 +60,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), Eye: ({ className }: { className?: string }) => ( ), diff --git a/test/unit/client/components/panes/PanePicker.test.tsx b/test/unit/client/components/panes/PanePicker.test.tsx index df3e94cf2..4d48ce925 100644 --- a/test/unit/client/components/panes/PanePicker.test.tsx +++ b/test/unit/client/components/panes/PanePicker.test.tsx @@ -48,6 +48,9 @@ vi.mock('lucide-react', () => ({ LayoutGrid: ({ className }: { className?: string }) => ( ), + Gauge: ({ className }: { className?: string }) => ( + + ), })) function createStore(overrides?: { @@ -672,6 +675,47 @@ describe('PanePicker', () => { }) }) + // Host Stats option gating (mirrors 'platform-specific shell options'): + // gate = featureFlags.hostStatsAvailable === true && platform !== 'win32'. + describe('host stats pane option', () => { + it('hides Host Stats when the hostStatsAvailable feature flag is absent', () => { + renderPicker({ platform: 'linux' }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + }) + + it('hides Host Stats when hostStatsAvailable is false', () => { + renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: false } }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + }) + + it('hides Host Stats on win32 even when the flag is true', () => { + renderPicker({ platform: 'win32', featureFlags: { hostStatsAvailable: true } }) + expect(screen.queryByRole('button', { name: 'Host Stats' })).not.toBeInTheDocument() + // Sanity: the platform-specific windows shells still render in this state. + expect(screen.getByText('PowerShell')).toBeInTheDocument() + }) + + it('shows Host Stats with an accessible button name and Gauge icon when the flag is true on linux', () => { + renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + expect(screen.getByRole('button', { name: 'Host Stats' })).toBeInTheDocument() + expect(screen.getByTestId('gauge-icon')).toBeInTheDocument() + }) + + it('calls onSelect with host-stats when Host Stats is clicked', () => { + const { onSelect } = renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + fireEvent.click(screen.getByRole('button', { name: 'Host Stats' })) + completeFadeAnimation() + expect(onSelect).toHaveBeenCalledWith('host-stats') + }) + + it('uses the H shortcut for Host Stats', () => { + const { onSelect } = renderPicker({ platform: 'linux', featureFlags: { hostStatsAvailable: true } }) + fireEvent.keyDown(getContainer(), { key: 'h' }) + completeFadeAnimation() + expect(onSelect).toHaveBeenCalledWith('host-stats') + }) + }) + describe('auto-focus on mount', () => { it('focuses the picker container on mount', () => { renderPicker() diff --git a/test/unit/client/lib/host-stats-status.test.ts b/test/unit/client/lib/host-stats-status.test.ts new file mode 100644 index 000000000..6de90d8a3 --- /dev/null +++ b/test/unit/client/lib/host-stats-status.test.ts @@ -0,0 +1,317 @@ +import { describe, it, expect } from 'vitest' +import type { HostStatsLive } from '@shared/ws-protocol' +import { + cpuStatus, + memoryStatus, + pagingStatus, + psiStatus, + diskIoStatus, + networkStatus, + limitsStatus, + freshellStatus, + overallVerdict, +} from '@/lib/host-stats-status' + +// Full-shape "neutral" fixture: every section available with well-below-threshold +// values. Tests spread-override exactly the section under test. +function makeLive(): HostStatsLive { + return { + machine: { + cores: 8, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: false, + kernel: '6.6', hostname: 'test', psi: true, cgroup: 'v2', + thermalCount: 1, batteryPresent: false, gpu: 'none', + }, + cpu: { available: true, usagePct: 10, stealPct: 0, perCorePct: [10], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 0.6, load15: 0.7, cores: 8 }, + memory: { + available: true, source: 'host', totalBytes: 10_000, usedBytes: 1_000, availableBytes: 9_000, + cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0, + }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: 0.2, memFull10: 0, ioSome10: 0.1, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: 1, weightedAwaitMs: 5 }, + network: { + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }, + limits: { available: true, fdsUsed: 100, fdsMax: 1_048_576, pidsUsed: 100, pidsMax: 4_194_304, timeWait: 10, ephemeralPorts: 28_232 }, + freshell: { + available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 1, wsClientsMax: 50, + eventLoopLagP99Ms: 5, rssBytes: 1_000_000, uptimeSec: 60, + }, + } +} + +describe('cpuStatus', () => { + it('is ok below 80%', () => { + expect(cpuStatus(makeLive())).toEqual({ severity: 'ok', word: 'ok' }) + const l = makeLive() + l.cpu.usagePct = 79.9 + expect(cpuStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is busy at exactly 80% and below 95%', () => { + const l = makeLive() + l.cpu.usagePct = 80 + expect(cpuStatus(l)).toEqual({ severity: 'warn', word: 'busy' }) + l.cpu.usagePct = 94.99 + expect(cpuStatus(l)).toEqual({ severity: 'warn', word: 'busy' }) + }) + it('is maxed at exactly 95%', () => { + const l = makeLive() + l.cpu.usagePct = 95 + expect(cpuStatus(l)).toEqual({ severity: 'bad', word: 'maxed' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.cpu = { available: false, usagePct: 0, stealPct: null, perCorePct: [], freqMHz: null } + expect(cpuStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('memoryStatus', () => { + const withUsed = (usedBytes: number): HostStatsLive => { + const l = makeLive() + l.memory.usedBytes = usedBytes // totalBytes is 10_000 → pct is exact tenths + return l + } + it('is ok below 85%', () => { + expect(memoryStatus(withUsed(8_499))).toEqual({ severity: 'ok', word: 'ok' }) // 84.99% + }) + it('is tight at exactly 85% and below 97%', () => { + expect(memoryStatus(withUsed(8_500))).toEqual({ severity: 'warn', word: 'tight' }) // 85% + expect(memoryStatus(withUsed(9_699))).toEqual({ severity: 'warn', word: 'tight' }) // 96.99% + }) + it('is full at exactly 97%', () => { + expect(memoryStatus(withUsed(9_700))).toEqual({ severity: 'bad', word: 'full' }) // 97% + }) + it('uses totalBytes as the effective limit (cgroup limit is already folded in server-side)', () => { + const l = makeLive() + l.memory = { + ...l.memory, source: 'cgroup', totalBytes: 10_000, usedBytes: 9_800, + availableBytes: 200, cgroupLimitBytes: 10_000, + } + expect(memoryStatus(l)).toEqual({ severity: 'bad', word: 'full' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.memory = { + available: false, source: 'host', totalBytes: 0, usedBytes: 0, availableBytes: 0, + cgroupLimitBytes: null, swapTotalBytes: null, swapUsedBytes: null, + } + expect(memoryStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('pagingStatus', () => { + const withRates = (swapInKbps: number, swapOutKbps: number): HostStatsLive => { + const l = makeLive() + l.paging.swapInKbps = swapInKbps + l.paging.swapOutKbps = swapOutKbps + return l + } + it('is ok at zero combined rate', () => { + expect(pagingStatus(withRates(0, 0))).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is swapping on ANY combined rate > 0 (single-snapshot, no 2-tick carry)', () => { + expect(pagingStatus(withRates(1, 0))).toEqual({ severity: 'warn', word: 'swapping' }) + expect(pagingStatus(withRates(0, 0.5))).toEqual({ severity: 'warn', word: 'swapping' }) + }) + it('is still swapping at exactly 5000 KB/s combined (thrashing is strict >)', () => { + expect(pagingStatus(withRates(2_500, 2_500))).toEqual({ severity: 'warn', word: 'swapping' }) + }) + it('is thrashing above 5000 KB/s combined', () => { + expect(pagingStatus(withRates(2_500, 2_501))).toEqual({ severity: 'bad', word: 'thrashing' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.paging = { available: false, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 } + expect(pagingStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('psiStatus', () => { + it('is ok when no full10 exceeds 1.0', () => { + expect(psiStatus(makeLive())).toEqual({ severity: 'ok', word: 'ok' }) + const l = makeLive() + l.psi.memFull10 = 1.0 // threshold is strict > + expect(psiStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is stalled when memFull10 > 1.0', () => { + const l = makeLive() + l.psi.memFull10 = 1.01 + expect(psiStatus(l)).toEqual({ severity: 'bad', word: 'stalled' }) + }) + it('is stalled when ioFull10 > 1.0', () => { + const l = makeLive() + l.psi.ioFull10 = 1.5 + expect(psiStatus(l)).toEqual({ severity: 'bad', word: 'stalled' }) + }) + it('ignores some10 (only full10 stalls)', () => { + const l = makeLive() + l.psi.cpuSome10 = 99 + l.psi.memSome10 = 99 + l.psi.ioSome10 = 99 + expect(psiStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is ok with all-null full10 values', () => { + const l = makeLive() + l.psi.memFull10 = null + l.psi.ioFull10 = null + expect(psiStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.psi = { available: false, cpuSome10: null, memSome10: null, memFull10: null, ioSome10: null, ioFull10: null } + expect(psiStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('diskIoStatus', () => { + const withAwait = (weightedAwaitMs: number | null): HostStatsLive => { + const l = makeLive() + l.diskIo.weightedAwaitMs = weightedAwaitMs + return l + } + it('is ok when weightedAwaitMs is null (no ios in window)', () => { + expect(diskIoStatus(withAwait(null))).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is ok at exactly 20ms', () => { + expect(diskIoStatus(withAwait(20))).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is slow above 20ms and up to exactly 100ms', () => { + expect(diskIoStatus(withAwait(20.01))).toEqual({ severity: 'warn', word: 'slow' }) + expect(diskIoStatus(withAwait(100))).toEqual({ severity: 'warn', word: 'slow' }) + }) + it('is stalled above 100ms', () => { + expect(diskIoStatus(withAwait(100.01))).toEqual({ severity: 'bad', word: 'stalled' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.diskIo = { available: false, readBps: 0, writeBps: 0, utilPct: null, weightedAwaitMs: null } + expect(diskIoStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('networkStatus', () => { + it('is ok with zero last-tick error/drop deltas', () => { + expect(networkStatus(makeLive())).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is errors when any last-tick delta is > 0 (totals are irrelevant)', () => { + const l = makeLive() + l.network.rxErrorsTotal = 1_000_000 // totals alone do NOT flip the tile + l.network.rxDroppedDelta = 1 + expect(networkStatus(l)).toEqual({ severity: 'warn', word: 'errors' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.network = { + available: false, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + } + expect(networkStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('limitsStatus', () => { + it('is ok below 70% on every sub-limit', () => { + const l = makeLive() + l.limits = { available: true, fdsUsed: 699, fdsMax: 1_000, pidsUsed: 69, pidsMax: 100, timeWait: 69, ephemeralPorts: 100 } + expect(limitsStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) // 69% across the board + }) + it('is tight when any sub-limit reaches exactly 70%', () => { + const l = makeLive() + l.limits = { available: true, fdsUsed: 700, fdsMax: 1_000, pidsUsed: 69, pidsMax: 100, timeWait: 69, ephemeralPorts: 100 } + expect(limitsStatus(l)).toEqual({ severity: 'warn', word: 'tight' }) + }) + it('is full when any sub-limit reaches exactly 90%', () => { + const l = makeLive() + l.limits = { available: true, fdsUsed: 69, fdsMax: 100, pidsUsed: 90, pidsMax: 100, timeWait: 69, ephemeralPorts: 100 } + expect(limitsStatus(l)).toEqual({ severity: 'bad', word: 'full' }) + }) + it('worst sub-limit drives the tile', () => { + const l = makeLive() + l.limits = { available: true, fdsUsed: 70, fdsMax: 100, pidsUsed: 95, pidsMax: 100, timeWait: 50, ephemeralPorts: 100 } + expect(limitsStatus(l)).toEqual({ severity: 'bad', word: 'full' }) + }) + it('skips null sub-limit pairs; all-null pairs with available:true is ok', () => { + const l = makeLive() + l.limits = { available: true, fdsUsed: null, fdsMax: null, pidsUsed: 95, pidsMax: 100, timeWait: null, ephemeralPorts: null } + expect(limitsStatus(l)).toEqual({ severity: 'bad', word: 'full' }) + l.limits = { available: true, fdsUsed: null, fdsMax: null, pidsUsed: null, pidsMax: null, timeWait: null, ephemeralPorts: null } + expect(limitsStatus(l)).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.limits = { available: false, fdsUsed: null, fdsMax: null, pidsUsed: null, pidsMax: null, timeWait: null, ephemeralPorts: null } + expect(limitsStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('freshellStatus', () => { + const withLag = (eventLoopLagP99Ms: number | null): HostStatsLive => { + const l = makeLive() + l.freshell.eventLoopLagP99Ms = eventLoopLagP99Ms + return l + } + it('is ok when lag p99 is null (unmeasurable)', () => { + expect(freshellStatus(withLag(null))).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is ok at exactly 50ms', () => { + expect(freshellStatus(withLag(50))).toEqual({ severity: 'ok', word: 'ok' }) + }) + it('is lagging above 50ms and up to exactly 500ms', () => { + expect(freshellStatus(withLag(50.01))).toEqual({ severity: 'warn', word: 'lagging' }) + expect(freshellStatus(withLag(500))).toEqual({ severity: 'warn', word: 'lagging' }) + }) + it('is blocked above 500ms', () => { + expect(freshellStatus(withLag(500.01))).toEqual({ severity: 'bad', word: 'blocked' }) + }) + it('degrades to unknown/ok when unavailable', () => { + const l = makeLive() + l.freshell = { + available: false, source: 'node', ptysRunning: 0, ptysMax: 0, wsClients: 0, wsClientsMax: 0, + eventLoopLagP99Ms: null, rssBytes: null, uptimeSec: 0, + } + expect(freshellStatus(l)).toEqual({ severity: 'ok', word: 'unknown' }) + }) +}) + +describe('overallVerdict', () => { + it('null live → ok ALL GOOD with no offenders (nothing known-bad)', () => { + expect(overallVerdict(null)).toEqual({ severity: 'ok', label: 'ALL GOOD', offenders: [] }) + }) + it('all-ok live → ALL GOOD', () => { + expect(overallVerdict(makeLive())).toEqual({ severity: 'ok', label: 'ALL GOOD', offenders: [] }) + }) + it('a warn tile → ELEVATED naming the offender', () => { + const l = makeLive() + l.cpu.usagePct = 80 + expect(overallVerdict(l)).toEqual({ severity: 'warn', label: 'ELEVATED', offenders: ['CPU BUSY'] }) + }) + it('a bad tile → TROUBLE', () => { + const l = makeLive() + l.cpu.usagePct = 95 + expect(overallVerdict(l)).toEqual({ severity: 'bad', label: 'TROUBLE', offenders: ['CPU MAXED'] }) + }) + it('orders offenders bad-first then warn, tile order within a tier', () => { + const l = makeLive() + l.cpu.usagePct = 80 // warn (tile slot 0) + l.memory.usedBytes = 8_500 // warn (tile slot 1) + l.paging.swapOutKbps = 5_001 // bad (tile slot 2) + expect(overallVerdict(l)).toEqual({ + severity: 'bad', + label: 'TROUBLE', + offenders: ['PAGING THRASHING', 'CPU BUSY', 'MEMORY TIGHT'], + }) + }) + it('unavailable sections are not offenders and never elevate', () => { + const l = makeLive() + l.memory = { + available: false, source: 'host', totalBytes: 0, usedBytes: 0, availableBytes: 0, + cgroupLimitBytes: null, swapTotalBytes: null, swapUsedBytes: null, + } + expect(overallVerdict(l)).toEqual({ severity: 'ok', label: 'ALL GOOD', offenders: [] }) + }) +}) diff --git a/test/unit/client/lib/tab-registry-open.test.ts b/test/unit/client/lib/tab-registry-open.test.ts index 905ab67f5..66a9e6f9e 100644 --- a/test/unit/client/lib/tab-registry-open.test.ts +++ b/test/unit/client/lib/tab-registry-open.test.ts @@ -4,6 +4,7 @@ import { jumpToRecord, openPaneInNewTab, openRecordAsUnlinkedCopy, + sanitizePaneSnapshot, type TabsRegistryGroups, } from '@/lib/tab-registry-open' import type { RegistryTabRecord } from '@/store/tabRegistryTypes' @@ -34,6 +35,14 @@ function makeGroups(overrides: Partial = {}): TabsRegistryGr return { localOpen: [], sameDeviceOpen: [], remoteOpen: [], closed: [], ...overrides } } +describe('sanitizePaneSnapshot', () => { + it('returns a host-stats pane for a host-stats snapshot (no picker fallback)', () => { + const record = makeRecord() + const snapshot = { paneId: 'pane-hs', kind: 'host-stats', payload: {} } as never + expect(sanitizePaneSnapshot(record, snapshot)).toEqual({ kind: 'host-stats' }) + }) +}) + describe('findRecordByTabKey', () => { it('finds a record in any group', () => { const record = makeRecord() diff --git a/test/unit/client/store/hostStatsSlice.test.ts b/test/unit/client/store/hostStatsSlice.test.ts new file mode 100644 index 000000000..0098e5c20 --- /dev/null +++ b/test/unit/client/store/hostStatsSlice.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { configureStore } from '@reduxjs/toolkit' +import hostStatsReducer, { + activateHostStats, + deactivateHostStats, + requestHostStatsRefresh, + resolveHostStatsRefresh, + failHostStatsRefresh, + hostStatsPaneMounted, + hostStatsPaneUnmounted, + hostStatsSnapshotReceived, + hostStatsReset, + _resetHostStatsThunkState, +} from '@/store/hostStatsSlice' +import type { HostStatsLive, HostStatsManual } from '@shared/ws-protocol' + +// Repo thunk pattern: the thunks reach the real getWsClient(); the module is +// mocked and the send spy captures frames. +const sendSpy = vi.hoisted(() => vi.fn()) +vi.mock('@/lib/ws-client', () => ({ + getWsClient: () => ({ send: sendSpy }), +})) + +function makeLive(): HostStatsLive { + return { + machine: { + cores: 8, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: false, + kernel: '6.6', hostname: 'test', psi: true, cgroup: 'v2', + thermalCount: 1, batteryPresent: false, gpu: 'none', + }, + cpu: { available: true, usagePct: 10, stealPct: 0, perCorePct: [10], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 0.6, load15: 0.7, cores: 8 }, + memory: { + available: true, source: 'host', totalBytes: 10_000, usedBytes: 1_000, availableBytes: 9_000, + cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0, + }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: 0.2, memFull10: 0, ioSome10: 0.1, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: 1, weightedAwaitMs: 5 }, + network: { + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }, + limits: { available: true, fdsUsed: 100, fdsMax: 1_048_576, pidsUsed: 100, pidsMax: 4_194_304, timeWait: 10, ephemeralPorts: 28_232 }, + freshell: { + available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 1, wsClientsMax: 50, + eventLoopLagP99Ms: 5, rssBytes: 1_000_000, uptimeSec: 60, + }, + } +} + +function makeManual(): HostStatsManual { + return { + topProcesses: { available: true, dwellMs: 300, list: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }] }, + processHealth: { available: true, zombies: 0, dState: 0, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1_048_576, maxUserInstances: 128 }, + disks: { available: true, list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }] }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, + } +} + +function createStore() { + return configureStore({ reducer: { hostStats: hostStatsReducer } }) +} + +type TestStore = ReturnType +const st = (store: TestStore) => store.getState().hostStats +const sentFrames = () => sendSpy.mock.calls.map(([frame]) => frame as { type?: string }) +const framesOfType = (type: string) => sentFrames().filter((f) => f.type === type) + +describe('hostStatsSlice', () => { + beforeEach(() => { + sendSpy.mockClear() + }) + afterEach(() => { + _resetHostStatsThunkState() + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('starts with no mounted panes, no subscription, no data, idle refresh', () => { + const store = createStore() + expect(st(store)).toEqual({ + mountedPanes: 0, + subscribed: false, + live: null, + liveAt: null, + clockOffsetMs: null, + manualAt: null, + manual: null, + refresh: { inFlight: false, requestId: null, error: null }, + }) + }) + + describe('mount refcount thunks', () => { + it('sends hoststats.subscribe exactly once across two activations (0→1 transition only)', () => { + const store = createStore() + store.dispatch(activateHostStats() as any) + store.dispatch(activateHostStats() as any) + expect(st(store).mountedPanes).toBe(2) + expect(st(store).subscribed).toBe(true) + expect(framesOfType('hoststats.subscribe')).toHaveLength(1) + }) + + it('sends hoststats.unsubscribe only at the 1→0 transition', () => { + const store = createStore() + store.dispatch(activateHostStats() as any) + store.dispatch(activateHostStats() as any) + store.dispatch(deactivateHostStats() as any) + expect(st(store).mountedPanes).toBe(1) + expect(st(store).subscribed).toBe(true) + expect(framesOfType('hoststats.unsubscribe')).toHaveLength(0) + store.dispatch(deactivateHostStats() as any) + expect(st(store).mountedPanes).toBe(0) + expect(st(store).subscribed).toBe(false) + expect(framesOfType('hoststats.unsubscribe')).toHaveLength(1) + }) + + it('deactivate at zero is a no-op (no spurious unsubscribe frame)', () => { + const store = createStore() + store.dispatch(deactivateHostStats() as any) + expect(st(store).mountedPanes).toBe(0) + expect(sendSpy).not.toHaveBeenCalled() + }) + + it('raw mounted/unmounted reducers are pure: refcount only, no WS side effects', () => { + const store = createStore() + store.dispatch(hostStatsPaneMounted()) + store.dispatch(hostStatsPaneMounted()) + store.dispatch(hostStatsPaneUnmounted()) + expect(st(store).mountedPanes).toBe(1) + expect(st(store).subscribed).toBe(false) + expect(sendSpy).not.toHaveBeenCalled() + }) + + it('unmounted clamps at zero', () => { + const store = createStore() + store.dispatch(hostStatsPaneUnmounted()) + expect(st(store).mountedPanes).toBe(0) + }) + }) + + describe('hostStatsSnapshotReceived', () => { + it('installs clockOffsetMs = Date.now() - at', () => { + vi.useFakeTimers({ now: 10_000_000 }) + const store = createStore() + const live = makeLive() + store.dispatch(hostStatsSnapshotReceived({ at: 10_000_000 - 5_000, live, manualAt: null, manual: null })) + expect(st(store).clockOffsetMs).toBe(5_000) + expect(st(store).live).toEqual(live) + expect(st(store).liveAt).toBe(10_000_000 - 5_000) + }) + + it('keeps a NEGATIVE offset when the client clock is behind the server (no zero-clamp)', () => { + vi.useFakeTimers({ now: 10_000_000 }) + const store = createStore() + store.dispatch(hostStatsSnapshotReceived({ at: 10_000_000 + 3_000, live: makeLive(), manualAt: null, manual: null })) + expect(st(store).clockOffsetMs).toBe(-3_000) + }) + + it('rejects |offset| > 10min as garbage and keeps the previous offset', () => { + vi.useFakeTimers({ now: 10_000_000 }) + const store = createStore() + store.dispatch(hostStatsSnapshotReceived({ at: 10_000_000 - 5_000, live: makeLive(), manualAt: null, manual: null })) + expect(st(store).clockOffsetMs).toBe(5_000) + // 700_000ms offset exceeds the 600_000ms guard: previous offset survives. + store.dispatch(hostStatsSnapshotReceived({ at: 10_000_000 - 700_000, live: makeLive(), manualAt: null, manual: null })) + expect(st(store).clockOffsetMs).toBe(5_000) + }) + + it('MERGE semantics: a snapshot without manual does NOT clear existing manual/manualAt', () => { + vi.useFakeTimers({ now: 20_000_000 }) + const store = createStore() + const manual = makeManual() + store.dispatch(hostStatsSnapshotReceived({ at: 20_000_000, live: makeLive(), manualAt: 111_000, manual })) + expect(st(store).manual).toEqual(manual) + expect(st(store).manualAt).toBe(111_000) + + const live2 = makeLive() + live2.cpu.usagePct = 55 + store.dispatch(hostStatsSnapshotReceived({ at: 20_002_000, live: live2, manualAt: null, manual: null })) + expect(st(store).manual).toEqual(manual) // preserved + expect(st(store).manualAt).toBe(111_000) // preserved + expect(st(store).live).toEqual(live2) // live always folds + expect(st(store).liveAt).toBe(20_002_000) + }) + + it('a snapshot carrying manual replaces manual/manualAt', () => { + const store = createStore() + const first = makeManual() + const second = makeManual() + second.processHealth.zombies = 7 + store.dispatch(hostStatsSnapshotReceived({ at: 1_000, live: makeLive(), manualAt: 1_000, manual: first })) + store.dispatch(hostStatsSnapshotReceived({ at: 2_000, live: makeLive(), manualAt: 2_000, manual: second })) + expect(st(store).manual).toEqual(second) + expect(st(store).manualAt).toBe(2_000) + }) + }) + + describe('requestHostStatsRefresh thunk', () => { + it('mints an hsr- requestId, sets inFlight, sends the refresh frame', () => { + const store = createStore() + store.dispatch(requestHostStatsRefresh() as any) + const { refresh } = st(store) + expect(refresh.inFlight).toBe(true) + expect(refresh.requestId).toMatch(/^hsr-\d+-[a-z0-9]+$/) + expect(refresh.error).toBeNull() + const frames = framesOfType('hoststats.refresh') + expect(frames).toHaveLength(1) + expect((frames[0] as { requestId?: string }).requestId).toBe(refresh.requestId) + }) + + it('allows only one in-flight refresh (second call is a no-op)', () => { + const store = createStore() + store.dispatch(requestHostStatsRefresh() as any) + const firstRequestId = st(store).refresh.requestId + store.dispatch(requestHostStatsRefresh() as any) + expect(st(store).refresh.requestId).toBe(firstRequestId) + expect(framesOfType('hoststats.refresh')).toHaveLength(1) + }) + + it('resolve folds the manual payload and clears inFlight', () => { + const store = createStore() + store.dispatch(requestHostStatsRefresh() as any) + const requestId = st(store).refresh.requestId! + store.dispatch(resolveHostStatsRefresh({ requestId, at: 999_000, manual: makeManual() }) as any) + expect(st(store).refresh).toEqual({ inFlight: false, requestId: null, error: null }) + expect(st(store).manualAt).toBe(999_000) + expect(st(store).manual).toEqual(makeManual()) + }) + + it('ignores resolve/fail with an unknown requestId without throwing', () => { + const store = createStore() + store.dispatch(requestHostStatsRefresh() as any) + const { refresh } = st(store) + expect(() => { + store.dispatch(resolveHostStatsRefresh({ requestId: 'hsr-bogus', at: 1, manual: makeManual() }) as any) + store.dispatch(failHostStatsRefresh({ requestId: 'hsr-bogus', error: 'nope' }) as any) + }).not.toThrow() + expect(st(store).refresh).toEqual(refresh) + expect(st(store).manual).toBeNull() + }) + + it('fail preserves previous manual/manualAt and records the error', () => { + const store = createStore() + const manual = makeManual() + store.dispatch(hostStatsSnapshotReceived({ at: 50_000, live: makeLive(), manualAt: 42_000, manual })) + store.dispatch(requestHostStatsRefresh() as any) + store.dispatch(failHostStatsRefresh({ requestId: st(store).refresh.requestId!, error: 'boom' }) as any) + expect(st(store).refresh).toEqual({ inFlight: false, requestId: null, error: 'boom' }) + expect(st(store).manual).toEqual(manual) + expect(st(store).manualAt).toBe(42_000) + // A fresh attempt clears the stale error. + store.dispatch(requestHostStatsRefresh() as any) + expect(st(store).refresh.error).toBeNull() + }) + + it('times out at exactly the 6000ms acceptance deadline with the frozen error text', () => { + vi.useFakeTimers() + const store = createStore() + const manual = makeManual() + store.dispatch(hostStatsSnapshotReceived({ at: 50_000, live: makeLive(), manualAt: 42_000, manual })) + store.dispatch(requestHostStatsRefresh() as any) + + vi.advanceTimersByTime(5_999) + expect(st(store).refresh.inFlight).toBe(true) + vi.advanceTimersByTime(1) + expect(st(store).refresh).toEqual({ + inFlight: false, + requestId: null, + error: 'refresh timed out — showing previous values', + }) + expect(st(store).manual).toEqual(manual) + expect(st(store).manualAt).toBe(42_000) + }) + + it('a resolved refresh disarms the deadline (no late failure)', () => { + vi.useFakeTimers() + const store = createStore() + store.dispatch(requestHostStatsRefresh() as any) + store.dispatch(resolveHostStatsRefresh({ requestId: st(store).refresh.requestId!, at: 1, manual: makeManual() }) as any) + vi.advanceTimersByTime(60_000) + expect(st(store).refresh).toEqual({ inFlight: false, requestId: null, error: null }) + expect(st(store).manualAt).toBe(1) + }) + }) + + describe('hostStatsReset', () => { + it('keeps last live+manual, clears subscribed, keeps mountedPanes', () => { + const store = createStore() + const live = makeLive() + const manual = makeManual() + store.dispatch(activateHostStats() as any) + store.dispatch(hostStatsSnapshotReceived({ at: 50_000, live, manualAt: 42_000, manual })) + expect(st(store).subscribed).toBe(true) + + store.dispatch(hostStatsReset()) + expect(st(store).subscribed).toBe(false) + expect(st(store).live).toEqual(live) + expect(st(store).liveAt).toBe(50_000) + expect(st(store).manual).toEqual(manual) + expect(st(store).manualAt).toBe(42_000) + expect(st(store).mountedPanes).toBe(1) + }) + }) +}) diff --git a/test/unit/client/store/panesPersistence.test.ts b/test/unit/client/store/panesPersistence.test.ts index 2eabd0695..538bcd8b2 100644 --- a/test/unit/client/store/panesPersistence.test.ts +++ b/test/unit/client/store/panesPersistence.test.ts @@ -28,6 +28,7 @@ import { resetPersistedLayoutCacheForTests, } from '../../../../src/store/persistMiddleware' import { PANES_SCHEMA_VERSION } from '../../../../src/store/persistedState' +import { isWellFormedPaneTree } from '../../../../src/store/paneTreeValidation' describe('Panes Persistence Integration', () => { beforeEach(() => { @@ -532,6 +533,59 @@ describe('Panes Persistence Integration', () => { expect(restored.crashTrace).toEqual({ exitCode: 1, resumedAtMs: 1_753_760_220_000 }) }) + it('round-trips a host-stats leaf: {kind:"host-stats"} normalizes bare and survives reload validation', () => { + const store1 = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + + store1.dispatch(addTab({ mode: 'shell' })) + const tabId = store1.getState().tabs.tabs[0].id + store1.dispatch(initLayout({ tabId, content: { kind: 'host-stats' } as any })) + + // normalize: the bare shape stays exactly {kind:'host-stats'} (no minted + // lifecycle fields), and the derived pane title is the fixed label. + const createdLayout = store1.getState().panes.layouts[tabId] as any + expect(createdLayout.type).toBe('leaf') + expect(createdLayout.content).toEqual({ kind: 'host-stats' }) + const createdPaneId = createdLayout.id + expect(store1.getState().panes.paneTitles[tabId][createdPaneId]).toBe('Host Stats') + + vi.runAllTimers() + + // The raw persisted bytes carry exactly the bare content. + const rawLayout = JSON.parse(localStorage.getItem('freshell.layout.v3')!) + expect(rawLayout.panes.layouts[tabId].content).toEqual({ kind: 'host-stats' }) + // Tree-validation round-trip: the persisted leaf must pass the reload gate + // (a missing isPaneContentShape case silently DROPS the pane on reload). + expect(isWellFormedPaneTree(rawLayout.panes.layouts[tabId])).toBe(true) + + const persistedTabs = loadPersistedTabs() + const persistedPanes = loadPersistedPanes() + + const store2 = configureStore({ + reducer: { + tabs: tabsReducer, + panes: panesReducer, + }, + middleware: (getDefault) => getDefault().concat(persistMiddleware as any), + }) + if (persistedTabs?.tabs) { + store2.dispatch(hydrateTabs(persistedTabs.tabs)) + } + if (persistedPanes) { + store2.dispatch(hydratePanes(persistedPanes)) + } + + const restoredLayout = store2.getState().panes.layouts[tabId] as any + expect(restoredLayout).toBeDefined() + expect(restoredLayout.type).toBe('leaf') + expect(restoredLayout.content).toEqual({ kind: 'host-stats' }) + }) + it('flushes pending writes on visibility change', () => { const store = configureStore({ reducer: { diff --git a/test/unit/port/ws-contract-freeze.test.ts b/test/unit/port/ws-contract-freeze.test.ts index 4dda9e7a1..cbb531412 100644 --- a/test/unit/port/ws-contract-freeze.test.ts +++ b/test/unit/port/ws-contract-freeze.test.ts @@ -25,6 +25,8 @@ const ZOD_BACKED_SERVER_MESSAGES = [ 'claude.activity.updated', 'codex.activity.list.response', 'codex.activity.updated', + 'hoststats.refresh.response', + 'hoststats.snapshot', 'opencode.activity.list.response', 'opencode.activity.updated', 'pane.reconcile.result', diff --git a/test/unit/server/host-stats/readers.test.ts b/test/unit/server/host-stats/readers.test.ts new file mode 100644 index 000000000..8f06b659c --- /dev/null +++ b/test/unit/server/host-stats/readers.test.ts @@ -0,0 +1,610 @@ +/** + * Behavioral tests for the host-stats /proc + /sys reader layer + * (docs/plans/2026-08-25-host-pressure-pane.md, Task 2 contract lines 323–410). + * + * All assertions are exact-value against the committed fixture tree under + * test/fixtures/host-stats/. Rate computation (deltas over ticks) is the + * service's job — readers return cumulative counters verbatim, so no + * rate/delta assertions live here. + * + * Plan-mandated exceptions to committed fixtures (git cannot commit empty + * dirs or dangling symlinks): + * - self/fd readlink fixtures are REAL symlinks created in os.tmpdir() at + * setup (fs.symlinkSync('anon_inode:inotify', ...)), never committed. + * - the "cgroup-absent empty dir" is created in os.tmpdir() at setup. + * - small cgroup variant trees (v2 finite limit, v2 pids 'max' fallback, + * v1 controllers, pid_max-only) are written into os.tmpdir() at setup, + * keeping the committed tree exactly as the plan enumerates. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { + DeadlineExceeded, + __testInternals, + readBattery, + readCgroupMemory, + readCpuFreqMHz, + readCpuTimes, + readDiskStats, + readEphemeralPortRange, + readInotifyLimits, + readLoadavg, + readMachineInfo, + readMeminfo, + readNetDev, + readPidCount, + readPidsLimit, + readPsi, + readSelfFdCount, + readSelfInotifyStats, + readSelfLimitsFdsMax, + readTcpStateCounts, + readThermals, + readVmstat, + scanProcessTable, + statfsInfo, +} from '../../../../server/host-stats/readers.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const FIXTURES = path.resolve(__dirname, '../../../fixtures/host-stats') +const PROC = path.join(FIXTURES, 'proc') +const PROMINI = path.join(FIXTURES, 'procmini') +const SYS = path.join(FIXTURES, 'sys') +const CGROUP = path.join(SYS, 'fs', 'cgroup') + +// --------------------------------------------------------------------------- +// tmpdir fixture variants (built in beforeAll; see header comment) +// --------------------------------------------------------------------------- + +let tmp: string +let missing: string +let fdProc: string +let scanProc: string +let emptyCgroupRoot: string +let v2LimitedProc: string +let v2LimitedCgroup: string +let v1Proc: string +let v1Cgroup: string +let pidMaxOnlyProc: string +let tcpOnlyProc: string +let noOomProc: string + +function writeFile(root: string, rel: string, content: string): void { + const full = path.join(root, rel) + fs.mkdirSync(path.dirname(full), { recursive: true }) + fs.writeFileSync(full, content) +} + +beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'host-stats-readers-')) + missing = path.join(tmp, 'never-existed') + + // fd readlink fixtures: real symlinks (never committed) + copies of the + // committed fdinfo files, so /self/fd and /self/fdinfo + // resolve under one tmp proc root. + fdProc = path.join(tmp, 'fd-proc') + fs.mkdirSync(path.join(fdProc, 'self', 'fd'), { recursive: true }) + fs.mkdirSync(path.join(fdProc, 'self', 'fdinfo'), { recursive: true }) + for (const fd of [3, 4, 5]) { + fs.symlinkSync('anon_inode:inotify', path.join(fdProc, 'self', 'fd', String(fd))) + fs.copyFileSync( + path.join(PROC, 'self', 'fdinfo', String(fd)), + path.join(fdProc, 'self', 'fdinfo', String(fd)), + ) + } + fs.symlinkSync('socket:[12345]', path.join(fdProc, 'self', 'fd', '6')) + fs.symlinkSync('pipe:[67890]', path.join(fdProc, 'self', 'fd', '7')) + fs.symlinkSync('/dev/null', path.join(fdProc, 'self', 'fd', '8')) + + // cgroup-absent empty dir: proving "exists but has no cgroup data" -> null. + emptyCgroupRoot = path.join(tmp, 'empty-cgroup-root') + fs.mkdirSync(emptyCgroupRoot, { recursive: true }) + + // process-scan tree: committed procmini + one pid whose stat is truncated + // (no closing paren) — must be skipped, never thrown. + scanProc = path.join(tmp, 'scan-proc') + fs.cpSync(PROMINI, scanProc, { recursive: true }) + writeFile(scanProc, '999/stat', '999 (broken') + writeFile(scanProc, '999/status', 'Name:\tbroken\nVmRSS:\t 1234 kB\n') + + // cgroup v2 with a FINITE memory limit, and pids.max = 'max' (exercises the + // unlimited -> threads-max fallback). + v2LimitedProc = path.join(tmp, 'v2-limited', 'proc') + v2LimitedCgroup = path.join(tmp, 'v2-limited', 'cgroup') + writeFile(v2LimitedProc, 'self/cgroup', '0::/limited.slice/app.service\n') + writeFile(v2LimitedProc, 'sys/kernel/threads-max', '999999\n') + writeFile(v2LimitedCgroup, 'limited.slice/app.service/memory.current', '500000000\n') + writeFile(v2LimitedCgroup, 'limited.slice/app.service/memory.max', '8000000000\n') + writeFile(v2LimitedCgroup, 'limited.slice/app.service/pids.max', 'max\n') + + // cgroup v1 with memory + pids controllers; memory limit is the classic + // "unlimited" garbage value (>= 2^60) which must be filtered to null. + v1Proc = path.join(tmp, 'v1', 'proc') + v1Cgroup = path.join(tmp, 'v1', 'cgroup') + writeFile( + v1Proc, + 'self/cgroup', + '7:memory:/limited.slice/svc.service\n3:pids:/limited.slice/svc.service\n1:name=systemd:/limited.slice/svc.service\n', + ) + writeFile(v1Proc, 'sys/kernel/threads-max', '888888\n') + writeFile(v1Cgroup, 'memory/limited.slice/svc.service/memory.usage_in_bytes', '1000000\n') + writeFile(v1Cgroup, 'memory/limited.slice/svc.service/memory.limit_in_bytes', '9223372036854771712\n') + writeFile(v1Cgroup, 'pids/limited.slice/svc.service/pids.max', '777\n') + + // Only /proc/sys/kernel/pid_max exists: it is a PID-number wrap boundary, + // NOT a creatable-process cap. readPidsLimit must never use it -> null. + pidMaxOnlyProc = path.join(tmp, 'pid-max-only', 'proc') + writeFile(pidMaxOnlyProc, 'sys/kernel/pid_max', '4194304\n') + + // tcp6 absent (IPv6 disabled hosts) — counts must come from tcp alone. + tcpOnlyProc = path.join(tmp, 'tcp-only', 'proc') + fs.mkdirSync(path.join(tcpOnlyProc, 'net'), { recursive: true }) + fs.copyFileSync(path.join(PROC, 'net', 'tcp'), path.join(tcpOnlyProc, 'net', 'tcp')) + + // vmstat without oom_kill (older kernels) -> oomKill null. + noOomProc = path.join(tmp, 'no-oom', 'proc') + writeFile(noOomProc, 'vmstat', 'pswpin 10\npswpout 20\npgmajfault 30\n') +}) + +afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }) +}) + +// --------------------------------------------------------------------------- + +describe('readCpuTimes', () => { + it('parses the aggregate line and all 16 per-core lines from proc/stat', () => { + const times = readCpuTimes(PROC) + expect(times).not.toBeNull() + // aggregate: total = 4705+356+1622+164331+2020+80+345+777, busy = total - idle(164331) - iowait(2020) + expect(times!.total).toBe(174236) + expect(times!.busy).toBe(7885) + expect(times!.steal).toBe(777) // steal>0 is a fixture requirement + expect(times!.perCore).toHaveLength(16) + expect(times!.perCore[0]).toEqual({ total: 10645, busy: 495 }) + expect(times!.perCore[15]).toEqual({ total: 9180, busy: 170 }) + }) + + it('returns null when /proc/stat is missing', () => { + expect(readCpuTimes(missing)).toBeNull() + }) +}) + +describe('readLoadavg', () => { + it('parses load1/load5/load15', () => { + expect(readLoadavg(PROC)).toEqual({ load1: 0.5, load5: 1, load15: 1.2 }) + }) + + it('returns null when missing', () => { + expect(readLoadavg(missing)).toBeNull() + }) +}) + +describe('readMeminfo', () => { + it('parses total/available/swap from a 64GB + swap fixture', () => { + expect(readMeminfo(PROC)).toEqual({ + totalKB: 67108864, + availKB: 33554432, + swapTotalKB: 8388608, + swapFreeKB: 7340032, + }) + }) + + it('returns null when missing', () => { + expect(readMeminfo(missing)).toBeNull() + }) +}) + +describe('readCgroupMemory', () => { + it('resolves the v2 leaf from self/cgroup and reads memory.current/memory.max', () => { + // committed leaf has memory.max = 'max' (the validated real-world case: + // freshell itself runs in an unlimited cgroup) -> limitBytes null + expect(readCgroupMemory(CGROUP, PROMINI)).toEqual({ + limitBytes: null, + currentBytes: 17000000000, + }) + }) + + it('parses a finite v2 memory.max as the byte limit', () => { + expect(readCgroupMemory(v2LimitedCgroup, v2LimitedProc)).toEqual({ + limitBytes: 8000000000, + currentBytes: 500000000, + }) + }) + + it('reads v1 memory controller files and filters the >=2^60 garbage limit to null', () => { + expect(readCgroupMemory(v1Cgroup, v1Proc)).toEqual({ + limitBytes: null, + currentBytes: 1000000, + }) + }) + + it('returns null when self/cgroup is absent', () => { + expect(readCgroupMemory(CGROUP, missing)).toBeNull() + expect(readCgroupMemory(CGROUP, emptyCgroupRoot)).toBeNull() + }) + + it('returns null when the leaf files are absent (fs root has no limit files by design)', () => { + // cgroupRoot exists but the leaf tree does not -> must NOT fall back to + // reading the cgroup fs root. + expect(readCgroupMemory(emptyCgroupRoot, PROMINI)).toBeNull() + }) +}) + +describe('readVmstat', () => { + it('parses pswpin/pswpout/pgmajfault/oom_kill', () => { + expect(readVmstat(PROC)).toEqual({ pswpin: 1234, pswpout: 5678, pgmajfault: 890, oomKill: 3 }) + }) + + it('returns oomKill null when the oom_kill line is absent', () => { + expect(readVmstat(noOomProc)).toEqual({ pswpin: 10, pswpout: 20, pgmajfault: 30, oomKill: null }) + }) + + it('returns null when missing', () => { + expect(readVmstat(missing)).toBeNull() + }) +}) + +describe('readPsi', () => { + it('parses cpu/memory/io pressure some/full avg10 values', () => { + expect(readPsi(PROC)).toEqual({ + cpuSome10: 1.23, + memSome10: 0.5, + memFull10: 0.3, + ioSome10: 2.5, + ioFull10: 1, + }) + }) + + it('returns null when the pressure directory is missing', () => { + expect(readPsi(missing)).toBeNull() + }) +}) + +describe('readDiskStats', () => { + it('keeps whole devices only and parses kernel iostats field positions', () => { + const disks = readDiskStats(PROC) + expect(disks).not.toBeNull() + expect([...disks!.keys()].sort()).toEqual(['nvme0n1', 'sda']) + expect(disks!.get('sda')).toEqual({ + readsCompleted: 5000, + readMs: 6000, + writesCompleted: 2000, + writeMs: 3000, + readSectors: 400000, + writtenSectors: 200000, + timeDoingIosMs: 4000, + }) + expect(disks!.get('nvme0n1')).toEqual({ + readsCompleted: 9000, + readMs: 8000, + writesCompleted: 3000, + writeMs: 4000, + readSectors: 700000, + writtenSectors: 300000, + timeDoingIosMs: 5000, + }) + // partitions and loop devices are filtered out + expect(disks!.has('sda1')).toBe(false) + expect(disks!.has('nvme0n1p1')).toBe(false) + expect(disks!.has('loop0')).toBe(false) + }) + + it('returns null when missing', () => { + expect(readDiskStats(missing)).toBeNull() + }) + + it('isWholeDevice classifies device names', () => { + const { isWholeDevice } = __testInternals + expect(isWholeDevice('sda')).toBe(true) + expect(isWholeDevice('sda1')).toBe(false) + expect(isWholeDevice('sdb')).toBe(true) + expect(isWholeDevice('vda2')).toBe(false) + expect(isWholeDevice('nvme0n1')).toBe(true) + expect(isWholeDevice('nvme0n1p1')).toBe(false) + expect(isWholeDevice('mmcblk0')).toBe(true) + expect(isWholeDevice('mmcblk0p1')).toBe(false) + expect(isWholeDevice('loop0')).toBe(false) + expect(isWholeDevice('ram0')).toBe(false) + }) +}) + +describe('readNetDev', () => { + it('sums rx/tx counters across non-loopback interfaces', () => { + // fixture: lo (excluded) + eth0 + docker0 + expect(readNetDev(PROC)).toEqual({ + rxBytes: 7000000, // 5000000 + 2000000 + txBytes: 11000000, // 8000000 + 3000000 + rxErr: 9, // 7 + 2 + txErr: 16, // 11 + 5 + rxDrop: 4, // 3 + 1 + txDrop: 6, // 4 + 2 + }) + }) + + it('returns null when missing', () => { + expect(readNetDev(missing)).toBeNull() + }) +}) + +describe('readTcpStateCounts', () => { + it('counts TIME_WAIT (state 06) across tcp + tcp6: exactly 3 in the fixture', () => { + expect(readTcpStateCounts(PROC)).toEqual({ timeWait: 3 }) + }) + + it('tolerates a missing tcp6 (IPv6 disabled)', () => { + expect(readTcpStateCounts(tcpOnlyProc)).toEqual({ timeWait: 2 }) + }) + + it('returns null when both tcp tables are missing', () => { + expect(readTcpStateCounts(missing)).toBeNull() + }) +}) + +describe('readEphemeralPortRange', () => { + it('parses ip_local_port_range', () => { + expect(readEphemeralPortRange(PROC)).toEqual({ start: 32768, end: 60999 }) + }) + + it('returns null when missing', () => { + expect(readEphemeralPortRange(missing)).toBeNull() + }) +}) + +describe('readSelfFdCount', () => { + it('counts entries in self/fd (6 fixture fds)', () => { + expect(readSelfFdCount(fdProc)).toBe(6) + }) + + it('returns null when missing', () => { + expect(readSelfFdCount(missing)).toBeNull() + }) +}) + +describe('readPidCount', () => { + it('counts numeric /proc entries (7 fixture pids; self/ is not numeric)', () => { + expect(readPidCount(PROMINI)).toBe(7) + }) + + it('returns null when missing', () => { + expect(readPidCount(missing)).toBeNull() + }) +}) + +describe('readPidsLimit', () => { + it('returns the cgroup v2 leaf pids.max when finite', () => { + // procmini/self/cgroup -> committed leaf with pids.max 10854 + expect(readPidsLimit(PROMINI, CGROUP)).toBe(10854) + }) + + it('falls back to threads-max when the v2 leaf pids.max is "max"', () => { + expect(readPidsLimit(v2LimitedProc, v2LimitedCgroup)).toBe(999999) + }) + + it('reads cgroup v1 pids controller pids.max', () => { + expect(readPidsLimit(v1Proc, v1Cgroup)).toBe(777) + }) + + it('falls back to /proc/sys/kernel/threads-max when no cgroup data exists', () => { + // committed proc/ fixture has no self/cgroup but does have threads-max + expect(readPidsLimit(PROC, CGROUP)).toBe(123456) + }) + + it('never uses /proc/sys/kernel/pid_max (wrap boundary, not a process cap)', () => { + expect(readPidsLimit(pidMaxOnlyProc, CGROUP)).toBeNull() + }) +}) + +describe('readSelfLimitsFdsMax', () => { + it('returns the SOFT Max open files limit (soft 1024, hard 1048576)', () => { + expect(readSelfLimitsFdsMax(PROC)).toBe(1024) + }) + + it('returns null when missing', () => { + expect(readSelfLimitsFdsMax(missing)).toBeNull() + }) +}) + +describe('readSelfInotifyStats', () => { + it('counts inotify instances via fd readlinks and watches via fdinfo lines', () => { + // fd 3/4/5 are anon_inode:inotify symlinks (tmpdir real symlinks); their + // fdinfo fixtures carry 2/3/1 inotify watch lines respectively. + expect(readSelfInotifyStats(fdProc)).toEqual({ instances: 3, watches: 6 }) + }) + + it('returns null when self/fd is missing', () => { + expect(readSelfInotifyStats(missing)).toBeNull() + }) +}) + +describe('readInotifyLimits', () => { + it('parses max_user_watches and max_user_instances', () => { + expect(readInotifyLimits(PROC)).toEqual({ maxUserWatches: 1048576, maxUserInstances: 128 }) + }) + + it('returns null when both limit files are missing', () => { + expect(readInotifyLimits(missing)).toBeNull() + }) +}) + +describe('readCpuFreqMHz', () => { + it('returns the mean scaling_cur_freq across cpus (kHz -> MHz)', () => { + // fixture: cpu0 3400 MHz, cpu1 2800 MHz + expect(readCpuFreqMHz(SYS)).toBe(3100) + }) + + it('returns null when no cpufreq data exists', () => { + expect(readCpuFreqMHz(missing)).toBeNull() + }) +}) + +describe('readThermals', () => { + it('parses thermal zones (millidegree -> celsius, type as label)', () => { + expect(readThermals(SYS)).toEqual([{ label: 'x86_pkg_temp', celsius: 51.5 }]) + }) + + it('returns null when the thermal class dir is missing', () => { + expect(readThermals(missing)).toBeNull() + }) +}) + +describe('readBattery', () => { + it('parses capacity and status from the first BAT* power_supply', () => { + expect(readBattery(SYS)).toEqual({ pct: 87, status: 'Discharging' }) + }) + + it('returns null when no battery exists', () => { + expect(readBattery(missing)).toBeNull() + }) +}) + +describe('readMachineInfo', () => { + it('probes capabilities from injected roots (v2 cgroup, no psi dir in procmini)', () => { + const info = readMachineInfo(PROMINI, SYS) + expect(info.platform).toBe(process.platform) + expect(info.cores).toBe(os.cpus().length) + expect(info.memTotalBytes).toBe(os.totalmem()) + expect(info.cgroup).toBe('v2') + expect(info.psi).toBe(false) // procmini has no pressure/ dir + expect(info.thermalCount).toBe(1) + expect(info.batteryPresent).toBe(true) + expect(info.gpu).toBe('none') + expect(typeof info.kernel).toBe('string') + expect(info.kernel).toBe(os.release()) + expect(typeof info.hostname).toBe('string') + expect(info.wsl).toBe(/microsoft|wsl/i.test(os.release())) + }) + + it('reports psi readable and cgroup none for the full proc fixture (no self/cgroup)', () => { + const info = readMachineInfo(PROC, SYS) + expect(info.psi).toBe(true) + expect(info.cgroup).toBe('none') + }) +}) + +describe('statfsInfo', () => { + it('returns sane totals/free/usedPct for a real mount', () => { + const info = statfsInfo('/') + expect(info).not.toBeNull() + expect(info!.totalBytes).toBeGreaterThan(0) + expect(info!.freeBytes).toBeGreaterThanOrEqual(0) + expect(info!.freeBytes).toBeLessThanOrEqual(info!.totalBytes) + expect(info!.usedPct).toBeGreaterThanOrEqual(0) + expect(info!.usedPct).toBeLessThanOrEqual(100) + if (info!.inodesTotal !== null) { + expect(info!.inodesTotal).toBeGreaterThan(0) + expect(info!.inodesFree).toBeGreaterThanOrEqual(0) + } + }) + + it('returns null for a nonexistent mount', () => { + expect(statfsInfo(missing)).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- + +describe('scanProcessTable (linux /proc path)', () => { + it('scans the fixture table: totals, zombies, D-state, names, VmRSS', async () => { + const result = await scanProcessTable(scanProc, 50, Date.now() + 10_000) + expect(result).not.toBeNull() + // 8 numeric entries enumerated (7 committed + truncated 999) + expect(result!.total).toBe(8) + expect(result!.zombies).toBe(1) + expect(result!.dState).toBe(1) + // truncated-stat pid 999 is skipped, not fatal + expect(result!.top).toHaveLength(7) + const byPid = new Map(result!.top.map((p) => [p.pid, p])) + expect(byPid.has(999)).toBe(false) + // comm-with-parens splits after the LAST ')' + expect(byPid.get(404)?.name).toBe('my (weird) proc') + expect(byPid.get(404)?.state).toBe('D') + expect(byPid.get(505)?.state).toBe('Z') + // rssBytes comes from status VmRSS kB -> bytes, NOT stat rss pages + expect(byPid.get(101)?.rssBytes).toBe(12345 * 1024) + // static fixture: sample A == sample B -> zero deltas + for (const row of result!.top) { + expect(row.cpuPct).toBe(0) + } + }) + + it('throws DeadlineExceeded when the budget is already expired', async () => { + await expect(scanProcessTable(scanProc, 0, Date.now() - 1)).rejects.toThrow(DeadlineExceeded) + }) + + it('returns null when the proc root is missing', async () => { + await expect(scanProcessTable(missing, 0, Date.now() + 10_000)).resolves.toBeNull() + }) +}) + +describe('__testInternals.computeCpuPct', () => { + it('converts jiffy deltas over a dwell to percent (USER_HZ=100)', () => { + // 30 jiffies over 300ms dwell: 30/100 Hz / 0.3s = 1 cpu-second per second + // = one fully busy core = 100% (the dwell window holds 30 jiffies per core). + expect(__testInternals.computeCpuPct(30, 300)).toBe(100) + expect(__testInternals.computeCpuPct(15, 300)).toBe(50) + }) + + it('clamps to [0, 100 * cores]', () => { + const cores = os.cpus().length + expect(__testInternals.computeCpuPct(1e12, 1)).toBe(100 * cores) + expect(__testInternals.computeCpuPct(-5, 300)).toBe(0) + }) + + it('returns 0 for a non-positive dwell instead of NaN/Infinity', () => { + expect(__testInternals.computeCpuPct(50, 0)).toBe(0) + }) +}) + +describe('__testInternals.parsePsOutput (darwin path)', () => { + // Representative `ps -Aceo pid,pcpu,rss,stat,comm` output: 15 processes, + // one zombie (STAT contains Z), one uninterruptible wait (contains U), + // a comm with spaces, and >12 rows to prove the top-12 cap. + const PS_OUTPUT = ` PID %CPU RSS STAT COMM + 1 45.0 100000 Ss /sbin/launchd + 201 42.0 2000000 S /Applications/Safari.app/Contents/MacOS/Safari + 202 39.0 50000 Z + 203 36.0 60000 UE /usr/sbin/coredaud + 204 33.0 70000 Ss /usr/libexec/logd + 205 30.0 80000 Ss /usr/sbin/syslogd + 206 27.0 90000 Ss /usr/libexec/dasd + 207 24.0 100000 Ss /usr/sbin/notifyd + 208 21.0 110000 Ss /usr/sbin/distnoted + 209 18.0 120000 Ss /usr/libexec/runningboardd + 210 15.0 130000 Ss /usr/libexec/loginwindow + 211 12.0 140000 Ss /System/Library/CoreServices/Finder.app/Contents/MacOS/Finder + 212 9.0 150000 Ss /usr/libexec/dockd + 213 6.0 160000 Ss /usr/sbin/coreaudiod + 214 3.0 170000 Ss /usr/libexec/systemstatsd` + + it('parses rows, counts health states, caps top at 12 by pcpu desc', () => { + const parsed = __testInternals.parsePsOutput(PS_OUTPUT) + expect(parsed.total).toBe(15) + expect(parsed.zombies).toBe(1) + expect(parsed.dState).toBe(1) + expect(parsed.top).toHaveLength(12) + expect(parsed.top[0]).toEqual({ + pid: 1, + name: '/sbin/launchd', + cpuPct: 45, + rssBytes: 100000 * 1024, + state: 'Ss', + }) + expect(parsed.top[11].pid).toBe(211) + expect(parsed.top[11].cpuPct).toBe(12) + const safari = parsed.top.find((p) => p.pid === 201) + expect(safari?.name).toBe('/Applications/Safari.app/Contents/MacOS/Safari') + expect(safari?.rssBytes).toBe(2000000 * 1024) + const defunct = parsed.top.find((p) => p.pid === 202) + expect(defunct?.name).toBe('') + expect(defunct?.state).toBe('Z') + }) + + it('returns an empty result for empty output', () => { + const parsed = __testInternals.parsePsOutput('') + expect(parsed).toEqual({ top: [], zombies: 0, dState: 0, total: 0 }) + }) +}) diff --git a/test/unit/server/host-stats/service.test.ts b/test/unit/server/host-stats/service.test.ts new file mode 100644 index 000000000..a05723aee --- /dev/null +++ b/test/unit/server/host-stats/service.test.ts @@ -0,0 +1,768 @@ +/** + * Behavioral tests for HostStatsService + * (docs/plans/2026-08-25-host-pressure-pane.md, Task 3 contract lines 411–490). + * + * The readers module is a full-module mock (one vi.fn() per reader): these tests assert + * the SERVICE contract — two-tier cadence, delta-rate math over cumulative counters, + * cgroup-aware memory precedence, darwin branching without /proc reads, refresh + * single-flight + post-completion cooldown + cooperative section budgets + overall + * watchdog. vi.useFakeTimers() drives both the intervals and Date.now() (the dt basis). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import os from 'node:os' +import { HostStatsLiveSchema, HostStatsManualSchema } from '../../../../shared/ws-protocol.js' +import * as readersMock from '../../../../server/host-stats/readers.js' +import * as loggerModule from '../../../../server/logger.js' +import { HostStatsService, type HostStatsServiceDeps } from '../../../../server/host-stats/service.js' + +vi.mock('../../../../server/host-stats/readers.js', () => { + class DeadlineExceeded extends Error { + constructor(message = 'host-stats section deadline exceeded') { + super(message) + this.name = 'DeadlineExceeded' + } + } + return { + DeadlineExceeded, + readCpuTimes: vi.fn(), + readLoadavg: vi.fn(), + readMeminfo: vi.fn(), + readCgroupMemory: vi.fn(), + readVmstat: vi.fn(), + readPsi: vi.fn(), + readDiskStats: vi.fn(), + readNetDev: vi.fn(), + readTcpStateCounts: vi.fn(), + readEphemeralPortRange: vi.fn(), + readSelfFdCount: vi.fn(), + readSelfLimitsFdsMax: vi.fn(), + readPidCount: vi.fn(), + readPidsLimit: vi.fn(), + readCpuFreqMHz: vi.fn(), + readMachineInfo: vi.fn(), + readSelfInotifyStats: vi.fn(), + readInotifyLimits: vi.fn(), + readThermals: vi.fn(), + readBattery: vi.fn(), + statfsInfo: vi.fn(), + scanProcessTable: vi.fn(), + } +}) + +vi.mock('../../../../server/logger.js', () => { + const child = { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn(), fatal: vi.fn(), trace: vi.fn() } + return { logger: { child: () => child }, __childLogger: child } +}) + +// Contract point 3 (enable / p99-drain+reset per fast tick / disable+null at stop) is +// pinned against this fake; 3_200_000ns → 3.2ms also exercises the ns→ms conversion. +const fakeHistogram = vi.hoisted(() => ({ + enable: vi.fn(), + disable: vi.fn(), + reset: vi.fn(), + percentile: vi.fn(() => 3_200_000), +})) + +vi.mock('node:perf_hooks', () => ({ + monitorEventLoopDelay: vi.fn(() => fakeHistogram), +})) + +const mockLog = (loggerModule as unknown as { __childLogger: { warn: ReturnType } }).__childLogger + +// --------------------------------------------------------------------------- +// Fixtures (service-level values; reader mocks return cumulative counters) +// --------------------------------------------------------------------------- + +const PROC = '/fake/proc' +const SYS = '/fake/sys' +const CGROUP = '/fake/sys/fs/cgroup' + +const MACHINE = { + cores: 12, + memTotalBytes: 34_000_000_000, + platform: 'linux', + wsl: false, + kernel: '6.6.0', + hostname: 'testbox', + psi: true, + cgroup: 'v2' as const, + thermalCount: 1, + batteryPresent: false, + gpu: 'none' as const, +} + +const CPU_T0 = { + total: 1000, + busy: 100, + steal: 0, + perCore: [ + { total: 250, busy: 25 }, + { total: 250, busy: 25 }, + { total: 250, busy: 25 }, + { total: 250, busy: 25 }, + ], +} +const CPU_T1 = { + total: 2000, + busy: 300, + steal: 20, + perCore: [ + { total: 500, busy: 100 }, + { total: 500, busy: 100 }, + { total: 500, busy: 100 }, + { total: 500, busy: 100 }, + ], +} +const VMSTAT_T0 = { pswpin: 100, pswpout: 40, pgmajfault: 50, oomKill: 2 } +const VMSTAT_T1 = { pswpin: 108, pswpout: 44, pgmajfault: 70, oomKill: 5 } +const MEMINFO = { totalKB: 64_000_000, availKB: 32_000_000, swapTotalKB: 8_000_000, swapFreeKB: 8_000_000 } +const PSI = { cpuSome10: 0.11, memSome10: 0.02, memFull10: 0.01, ioSome10: 0.3, ioFull10: 0.05 } + +const DISK_T0 = new Map([ + ['sda', { readsCompleted: 1000, readMs: 4000, writesCompleted: 2000, writeMs: 8000, readSectors: 100_000, writtenSectors: 400_000, timeDoingIosMs: 500 }], +]) +const DISK_T1 = new Map([ + ['sda', { readsCompleted: 1100, readMs: 6000, writesCompleted: 2400, writeMs: 10_000, readSectors: 151_200, writtenSectors: 502_400, timeDoingIosMs: 1500 }], +]) +const NET_T0 = { rxBytes: 1_000_000, txBytes: 500_000, rxErr: 3, txErr: 1, rxDrop: 2, txDrop: 4 } +const NET_T1 = { rxBytes: 1_500_000, txBytes: 600_000, rxErr: 5, txErr: 3, rxDrop: 3, txDrop: 5 } + +const TABLE = { + top: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }], + zombies: 1, + dState: 2, + total: 900, +} + +const FAST_READERS = [ + 'readCpuTimes', + 'readLoadavg', + 'readMeminfo', + 'readCgroupMemory', + 'readVmstat', + 'readPsi', +] as const +const SLOW_READERS = [ + 'readDiskStats', + 'readNetDev', + 'readTcpStateCounts', + 'readEphemeralPortRange', + 'readSelfFdCount', + 'readSelfLimitsFdsMax', + 'readPidCount', + 'readPidsLimit', + 'readCpuFreqMHz', +] as const + +let services: HostStatsService[] = [] + +function makeService(deps: HostStatsServiceDeps = {}): HostStatsService { + const service = new HostStatsService({ procRoot: PROC, sysRoot: SYS, fastMs: 2000, slowMs: 5000, ...deps }) + services.push(service) + return service +} + +function readerFn(name: (typeof FAST_READERS)[number] | (typeof SLOW_READERS)[number]) { + return vi.mocked(readersMock[name]) +} + +beforeEach(() => { + vi.useFakeTimers() + vi.clearAllMocks() + services = [] + + vi.mocked(readersMock.readMachineInfo).mockReturnValue(MACHINE) + vi.mocked(readersMock.readCpuTimes).mockReturnValue(CPU_T0) + vi.mocked(readersMock.readLoadavg).mockReturnValue({ load1: 0.5, load5: 1, load15: 1.25 }) + vi.mocked(readersMock.readMeminfo).mockReturnValue(MEMINFO) + vi.mocked(readersMock.readCgroupMemory).mockReturnValue({ limitBytes: null, currentBytes: 1_000_000_000 }) + vi.mocked(readersMock.readVmstat).mockReturnValue(VMSTAT_T0) + vi.mocked(readersMock.readPsi).mockReturnValue(PSI) + vi.mocked(readersMock.readDiskStats).mockReturnValue(DISK_T0) + vi.mocked(readersMock.readNetDev).mockReturnValue(NET_T0) + vi.mocked(readersMock.readTcpStateCounts).mockReturnValue({ timeWait: 42 }) + vi.mocked(readersMock.readEphemeralPortRange).mockReturnValue({ start: 32768, end: 60999 }) + vi.mocked(readersMock.readSelfFdCount).mockReturnValue(128) + vi.mocked(readersMock.readSelfLimitsFdsMax).mockReturnValue(1_048_576) + vi.mocked(readersMock.readPidCount).mockReturnValue(900) + vi.mocked(readersMock.readPidsLimit).mockReturnValue(4_194_304) + vi.mocked(readersMock.readCpuFreqMHz).mockReturnValue(3400) + vi.mocked(readersMock.readSelfInotifyStats).mockReturnValue({ instances: 3, watches: 420 }) + vi.mocked(readersMock.readInotifyLimits).mockReturnValue({ maxUserWatches: 1_048_576, maxUserInstances: 128 }) + vi.mocked(readersMock.readThermals).mockReturnValue([{ label: 'cpu', celsius: 51.5 }]) + vi.mocked(readersMock.readBattery).mockReturnValue(null) + vi.mocked(readersMock.statfsInfo).mockImplementation((mount: string) => + mount === '/' + ? { totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 } + : { totalBytes: 1e10, freeBytes: 9e9, usedPct: 10, inodesTotal: null, inodesFree: null }, + ) + vi.mocked(readersMock.scanProcessTable).mockResolvedValue(TABLE) +}) + +afterEach(() => { + for (const service of services) service.stop() + services = [] + restorePlatform?.() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +let restorePlatform: (() => void) | null = null + +function stubPlatform(value: string): void { + restorePlatform?.() + const original = Object.getOwnPropertyDescriptor(process, 'platform')! + Object.defineProperty(process, 'platform', { ...original, value }) + restorePlatform = () => { + Object.defineProperty(process, 'platform', original) + restorePlatform = null + } +} + +function cpuInfo(user: number, nice: number, sys: number, idle: number): os.CpuInfo { + return { model: 'fake', speed: 2400, times: { user, nice, sys, idle, irq: 0 } } +} + +// --------------------------------------------------------------------------- +// Snapshot shape / lifecycle +// --------------------------------------------------------------------------- + +describe('getSnapshot pre-start (contract point 8)', () => { + it('returns a structurally valid snapshot with machine filled and every section unavailable', () => { + const service = makeService() + const snap = service.getSnapshot() + expect(HostStatsLiveSchema.safeParse(snap.live).success).toBe(true) + expect(snap.live.machine).toEqual(MACHINE) + expect(snap.manualAt).toBeNull() + expect(snap.manual).toBeNull() + for (const key of ['cpu', 'load', 'memory', 'paging', 'psi', 'diskIo', 'network', 'limits', 'freshell'] as const) { + expect(snap.live[key].available, `section ${key}`).toBe(false) + } + // Nothing collected before start (only the constructor's one-time machine probe ran). + for (const name of [...FAST_READERS, ...SLOW_READERS]) { + expect(readerFn(name), name).not.toHaveBeenCalled() + } + }) +}) + +describe('start/stop (contract points 1, 5)', () => { + it('start runs one immediate fast tick with null-safe zero rates; slow readers untouched', () => { + const service = makeService() + expect(service.isRunning()).toBe(false) + service.start() + expect(service.isRunning()).toBe(true) + + const snap = service.getSnapshot() + expect(HostStatsLiveSchema.safeParse(snap.live).success).toBe(true) + const { cpu, load, memory, paging, psi, diskIo, network, limits, freshell } = snap.live + + expect(cpu).toEqual({ available: true, usagePct: 0, stealPct: 0, perCorePct: [0, 0, 0, 0], freqMHz: null }) + expect(load).toEqual({ available: true, load1: 0.5, load5: 1, load15: 1.25, cores: 12 }) + expect(memory).toEqual({ + available: true, + source: 'host', + totalBytes: 64_000_000 * 1024, + usedBytes: 32_000_000 * 1024, + availableBytes: 32_000_000 * 1024, + cgroupLimitBytes: null, + swapTotalBytes: 8_000_000 * 1024, + swapUsedBytes: 0, + }) + expect(paging).toEqual({ available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 2 }) + expect(psi).toEqual({ available: true, cpuSome10: 0.11, memSome10: 0.02, memFull10: 0.01, ioSome10: 0.3, ioFull10: 0.05 }) + expect(diskIo).toEqual({ available: false, readBps: 0, writeBps: 0, utilPct: null, weightedAwaitMs: null }) + expect(network.available).toBe(false) + expect(limits.available).toBe(false) + expect(freshell.available).toBe(true) + expect(freshell.source).toBe('node') + expect(freshell.rssBytes).toEqual(expect.any(Number)) + expect(freshell.eventLoopLagP99Ms === null || Number.isFinite(freshell.eventLoopLagP99Ms)).toBe(true) + + for (const name of FAST_READERS) expect(readerFn(name), name).toHaveBeenCalledTimes(1) + for (const name of SLOW_READERS) expect(readerFn(name), name).not.toHaveBeenCalled() + // Injected roots reach the readers (note the frozen arg-order asymmetry). + expect(readerFn('readCpuTimes')).toHaveBeenCalledWith(PROC) + expect(readerFn('readCgroupMemory')).toHaveBeenCalledWith(CGROUP, PROC) + }) + + it('installs the two-tier cadence: fast ticks every 2s, slow every 5s', () => { + const service = makeService() + service.start() + vi.advanceTimersByTime(2000) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(2) + for (const name of SLOW_READERS) expect(readerFn(name), name).not.toHaveBeenCalled() + vi.advanceTimersByTime(2000) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(3) + expect(readerFn('readDiskStats')).not.toHaveBeenCalled() + vi.advanceTimersByTime(2000) // t=6000: one more fast tick + first slow tick at 5000 + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(4) + for (const name of SLOW_READERS) expect(readerFn(name), name).toHaveBeenCalledTimes(1) + expect(readerFn('readDiskStats')).toHaveBeenCalledWith(PROC) + expect(readerFn('readCpuFreqMHz')).toHaveBeenCalledWith(SYS) + expect(readerFn('readPidsLimit')).toHaveBeenCalledWith(PROC, CGROUP) + vi.advanceTimersByTime(10000) // t=16000: fast at 8/10/12/14/16, slow at 10/15 + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(9) + expect(readerFn('readDiskStats')).toHaveBeenCalledTimes(3) + }) + + it('calling start twice does not double-install intervals', () => { + const service = makeService() + service.start() + service.start() + vi.advanceTimersByTime(2000) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(2) // 1 immediate + 1 interval tick + expect(readerFn('readDiskStats')).not.toHaveBeenCalled() + }) + + it('stop halts all collection and is idempotent; a restart resumes ticking', () => { + const service = makeService() + service.start() + service.stop() + service.stop() // idempotent + expect(service.isRunning()).toBe(false) + vi.clearAllMocks() + vi.advanceTimersByTime(20000) + for (const name of [...FAST_READERS, ...SLOW_READERS]) { + expect(readerFn(name), name).not.toHaveBeenCalled() + } + service.start() + expect(service.isRunning()).toBe(true) + vi.advanceTimersByTime(2000) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(2) + }) +}) + +describe('event-loop lag histogram lifecycle (contract point 3)', () => { + it('enables at start; drains p99 and resets per fast tick (never slow ticks); disables once at stop', () => { + const service = makeService() + service.start() + expect(fakeHistogram.enable).toHaveBeenCalledTimes(1) + // start() runs one immediate fast tick → one p99 read at the 99th percentile + one reset. + expect(fakeHistogram.percentile).toHaveBeenCalledTimes(1) + expect(fakeHistogram.percentile).toHaveBeenCalledWith(99) + expect(fakeHistogram.reset).toHaveBeenCalledTimes(1) + + // The drained value reaches the snapshot in ms (3_200_000ns → 3.2ms). + expect(service.getSnapshot().live.freshell.eventLoopLagP99Ms).toBe(3.2) + + vi.advanceTimersByTime(4000) // fast ticks at t=2s,4s + expect(fakeHistogram.percentile).toHaveBeenCalledTimes(3) + expect(fakeHistogram.reset).toHaveBeenCalledTimes(3) + vi.advanceTimersByTime(1000) // t=5s: slow tick only — histogram is a fast-tier instrument + expect(fakeHistogram.percentile).toHaveBeenCalledTimes(3) + expect(fakeHistogram.reset).toHaveBeenCalledTimes(3) + + service.stop() + expect(fakeHistogram.disable).toHaveBeenCalledTimes(1) + service.stop() // idempotent: no second disable + expect(fakeHistogram.disable).toHaveBeenCalledTimes(1) + }) + + it('collects no lag samples while stopped (cache retains last tick), then resumes per-tick on restart', () => { + const service = makeService() + service.start() + service.stop() + fakeHistogram.percentile.mockClear() + fakeHistogram.reset.mockClear() + vi.advanceTimersByTime(6000) + expect(fakeHistogram.percentile).not.toHaveBeenCalled() + expect(fakeHistogram.reset).not.toHaveBeenCalled() + service.start() + expect(fakeHistogram.reset).toHaveBeenCalledTimes(1) // immediate fast tick drains again + expect(service.getSnapshot().live.freshell.eventLoopLagP99Ms).toBe(3.2) + }) +}) + +// --------------------------------------------------------------------------- +// Rates + memory precedence +// --------------------------------------------------------------------------- + +describe('delta-rate computation (contract point 1)', () => { + it('computes cpu/paging/disk/network rates from cumulative deltas over dt', () => { + vi.mocked(readersMock.readCpuTimes).mockReturnValueOnce(CPU_T0).mockReturnValue(CPU_T1) + vi.mocked(readersMock.readVmstat).mockReturnValueOnce(VMSTAT_T0).mockReturnValue(VMSTAT_T1) + vi.mocked(readersMock.readDiskStats).mockReturnValueOnce(DISK_T0).mockReturnValue(DISK_T1) + vi.mocked(readersMock.readNetDev).mockReturnValueOnce(NET_T0).mockReturnValue(NET_T1) + + const service = makeService() + service.start() + vi.advanceTimersByTime(2000) // second fast tick: first real deltas, dt = 2000ms + const fast = service.getSnapshot().live + // cpu: dBusy 200 / dTotal 1000 = 20%; steal 20/1000 = 2%; per-core 75/250 = 30% + expect(fast.cpu).toEqual({ available: true, usagePct: 20, stealPct: 2, perCorePct: [30, 30, 30, 30], freqMHz: null }) + // paging: 8 pages*4KB/2s = 16 KB/s in; 4*4/2 = 8 KB/s out; 20/2 = 10 majfaults/s; oom 2->5 + expect(fast.paging).toEqual({ available: true, swapInKbps: 16, swapOutKbps: 8, majFaultsPerSec: 10, oomKillsDelta: 3, oomKillsTotal: 5 }) + + vi.advanceTimersByTime(3000) // t=5000: first slow tick → first slow-tier sample (no delta yet) + const firstSlow = service.getSnapshot().live + expect(firstSlow.diskIo).toEqual({ available: true, readBps: 0, writeBps: 0, utilPct: null, weightedAwaitMs: null }) + expect(firstSlow.network).toEqual({ + available: true, rxBps: 0, txBps: 0, + rxErrorsTotal: 3, txErrorsTotal: 1, rxDroppedTotal: 2, txDroppedTotal: 4, + rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0, + }) + expect(firstSlow.cpu.freqMHz).toBe(3400) + expect(firstSlow.limits).toEqual({ + available: true, + fdsUsed: 128, fdsMax: 1_048_576, + pidsUsed: 900, pidsMax: 4_194_304, + timeWait: 42, ephemeralPorts: 28232, // 60999 - 32768 + 1 + }) + + vi.advanceTimersByTime(5000) // t=10000: second slow tick, dt = 5000ms + const live = service.getSnapshot().live + // disk: dRead 51200 sectors*512 = 26,214,400 B / 5s = 5,242,880 B/s; dWrite 102400*512/5 = 10,485,760 B/s + // util = 1000ms busy / 5000ms = 20%; await = (2000+2000)/(100+400) = 8ms + expect(live.diskIo).toEqual({ available: true, readBps: 5_242_880, writeBps: 10_485_760, utilPct: 20, weightedAwaitMs: 8 }) + // net: dRx 500000/5 = 100000 B/s; dTx 100000/5 = 20000 B/s; err/drop deltas 2/2/1/1 + expect(live.network).toEqual({ + available: true, rxBps: 100_000, txBps: 20_000, + rxErrorsTotal: 5, txErrorsTotal: 3, rxDroppedTotal: 3, txDroppedTotal: 5, + rxErrorsDelta: 2, txErrorsDelta: 2, rxDroppedDelta: 1, txDroppedDelta: 1, + }) + }) +}) + +describe('memory precedence (contract point 2)', () => { + it('finite cgroup limit → source cgroup, all numbers from the cgroup leaf', () => { + vi.mocked(readersMock.readCgroupMemory).mockReturnValue({ limitBytes: 8_000_000_000, currentBytes: 500_000_000 }) + const service = makeService() + service.start() + expect(service.getSnapshot().live.memory).toEqual({ + available: true, + source: 'cgroup', + totalBytes: 8_000_000_000, + usedBytes: 500_000_000, + availableBytes: 7_500_000_000, + cgroupLimitBytes: 8_000_000_000, + // Swap is host-scoped context (cgroup swap accounting is not collected). + swapTotalBytes: 8_000_000 * 1024, + swapUsedBytes: 0, + }) + }) + + it('unlimited (memory.max = max) or absent cgroup → source host, all totals from meminfo', () => { + for (const cgroup of [ + { limitBytes: null, currentBytes: 16_000_000_000 }, // unlimited, like the self-hosted freshell + null, // absent + ]) { + vi.mocked(readersMock.readCgroupMemory).mockReturnValue(cgroup) + const service = makeService() + service.start() + expect(service.getSnapshot().live.memory).toEqual({ + available: true, + source: 'host', + totalBytes: 64_000_000 * 1024, + usedBytes: 32_000_000 * 1024, + availableBytes: 32_000_000 * 1024, + cgroupLimitBytes: null, + swapTotalBytes: 8_000_000 * 1024, + swapUsedBytes: 0, + }) + service.stop() + } + }) + + it('cgroup unlimited AND meminfo unreadable → memory section degraded', () => { + vi.mocked(readersMock.readCgroupMemory).mockReturnValue(null) + vi.mocked(readersMock.readMeminfo).mockReturnValue(null) + const service = makeService() + service.start() + expect(service.getSnapshot().live.memory).toEqual({ + available: false, + source: 'host', + totalBytes: 0, + usedBytes: 0, + availableBytes: 0, + cgroupLimitBytes: null, + swapTotalBytes: null, + swapUsedBytes: null, + }) + }) +}) + +describe('onSnapshot (Task 4 wiring seam; plan interface block)', () => { + it('fires after every fast tick (not slow), single listener slot, null clears', () => { + const service = makeService() + const snaps: { at: number }[] = [] + service.onSnapshot((s) => snaps.push(s)) + service.start() // immediate fast tick + vi.advanceTimersByTime(2000) // fast + vi.advanceTimersByTime(3000) // fast at t=4000 + slow at t=5000 (no fire) + expect(snaps).toHaveLength(3) + + const other: { at: number }[] = [] + service.onSnapshot((s) => other.push(s)) // replace the single slot + vi.advanceTimersByTime(2000) + expect(snaps).toHaveLength(3) + expect(other).toHaveLength(1) + + service.onSnapshot(null) + vi.advanceTimersByTime(2000) + expect(other).toHaveLength(1) + }) +}) + +// --------------------------------------------------------------------------- +// darwin (contract points 1, 2, 7) +// --------------------------------------------------------------------------- + +describe('darwin platform branch', () => { + it('fast tier uses os.cpus()/os.loadavg()/os.totalmem() and never attempts /proc readers', () => { + stubPlatform('darwin') + vi.spyOn(os, 'cpus') + .mockReturnValueOnce([cpuInfo(10, 0, 10, 80), cpuInfo(10, 0, 10, 80), cpuInfo(10, 0, 10, 80), cpuInfo(10, 0, 10, 80)]) + .mockReturnValue([cpuInfo(30, 5, 25, 140), cpuInfo(30, 5, 25, 140), cpuInfo(30, 5, 25, 140), cpuInfo(30, 5, 25, 140)]) + vi.spyOn(os, 'loadavg').mockReturnValue([0.5, 1.0, 1.5]) + vi.spyOn(os, 'totalmem').mockReturnValue(16_000_000_000) + vi.spyOn(os, 'freemem').mockReturnValue(8_000_000_000) + + const service = makeService({ procRoot: undefined }) // darwin default: procRoot null + service.start() + vi.advanceTimersByTime(2000) // second fast tick: darwin deltas (per core dBusy 40 / dTotal 100) + + const live = service.getSnapshot().live + expect(HostStatsLiveSchema.safeParse(live).success).toBe(true) + expect(live.cpu).toEqual({ available: true, usagePct: 40, stealPct: null, perCorePct: [40, 40, 40, 40], freqMHz: null }) + expect(live.load).toEqual({ available: true, load1: 0.5, load5: 1.0, load15: 1.5, cores: 12 }) + expect(live.memory).toEqual({ + available: true, + source: 'host', + totalBytes: 16_000_000_000, + usedBytes: 8_000_000_000, + availableBytes: 8_000_000_000, + cgroupLimitBytes: null, + swapTotalBytes: null, + swapUsedBytes: null, + }) + // /proc-dependent sections stay full zero-shape; readers never attempted on darwin. + expect(live.paging).toEqual({ available: false, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }) + expect(live.psi.available).toBe(false) + for (const name of ['readCpuTimes', 'readVmstat', 'readPsi', 'readMeminfo', 'readCgroupMemory', 'readLoadavg'] as const) { + expect(readerFn(name), name).not.toHaveBeenCalled() + } + + vi.advanceTimersByTime(3000) // t=5000: slow tier is entirely /proc+/sys-based → no-op on darwin + const after = service.getSnapshot().live + expect(after.diskIo.available).toBe(false) + expect(after.network.available).toBe(false) + expect(after.limits.available).toBe(false) + for (const name of SLOW_READERS) expect(readerFn(name), name).not.toHaveBeenCalled() + }) +}) + +// --------------------------------------------------------------------------- +// refresh() +// --------------------------------------------------------------------------- + +describe('refresh (contract points 6, 7, 9)', () => { + it('a successful refresh fills every manual section, caches it, and fires the merged snapshot', async () => { + const service = makeService() + const snaps: Array<{ at: number; manualAt: number | null; manual: unknown }> = [] + service.onSnapshot((s) => snaps.push(s)) + + const t0 = Date.now() + const { at, manual } = await service.refresh() + expect(at).toBe(t0) + expect(HostStatsManualSchema.safeParse(manual).success).toBe(true) + expect(manual).toEqual({ + topProcesses: { available: true, dwellMs: 300, list: TABLE.top }, + processHealth: { available: true, zombies: 1, dState: 2, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1_048_576, maxUserInstances: 128 }, + disks: { + available: true, + list: [ + { mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }, + { mount: '/dev/shm', totalBytes: 1e10, freeBytes: 9e9, usedPct: 10, inodesTotal: null, inodesFree: null }, + ], + }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, + }) + // Cooperative section budget: absolute deadline = refresh start + sectionBudgetMs. + expect(vi.mocked(readersMock.scanProcessTable)).toHaveBeenCalledWith(PROC, 300, t0 + 2000) + expect(mockLog.warn).not.toHaveBeenCalled() + + // Merged snapshot fired immediately (live cache untouched — service never started). + expect(snaps).toHaveLength(1) + expect(snaps[0].at).toBe(t0) + expect(snaps[0].manualAt).toBe(t0) + expect(snaps[0].manual).toEqual(manual) + + // Cached across subsequent live ticks. + service.start() + vi.advanceTimersByTime(2000) + const snap = service.getSnapshot() + expect(snap.manualAt).toBe(t0) + expect(snap.manual).toEqual(manual) + }) + + it('is single-flight: concurrent calls return the same promise', async () => { + const service = makeService() + let resolveScan: ((value: typeof TABLE) => void) | undefined + vi.mocked(readersMock.scanProcessTable).mockImplementation( + () => + new Promise((resolve) => { + resolveScan = resolve + }), + ) + const p1 = service.refresh() + const p2 = service.refresh() + expect(p2).toBe(p1) + expect(vi.mocked(readersMock.scanProcessTable)).toHaveBeenCalledTimes(1) + + resolveScan!({ ...TABLE, total: 901 }) + const { manual } = await p1 + expect(manual.processHealth.total).toBe(901) + }) + + it('enforces the 1s post-completion cooldown (rate_limited), then allows again', async () => { + const service = makeService() + await service.refresh() + await expect(service.refresh()).rejects.toThrow('rate_limited') + vi.advanceTimersByTime(999) + await expect(service.refresh()).rejects.toThrow('rate_limited') + vi.advanceTimersByTime(1) + await expect(service.refresh()).resolves.toBeDefined() + expect(vi.mocked(readersMock.scanProcessTable)).toHaveBeenCalledTimes(2) + }) + + it('a rejected refresh keeps the prior manual in the snapshot', async () => { + const service = makeService() + const first = await service.refresh() + await expect(service.refresh()).rejects.toThrow('rate_limited') + const snap = service.getSnapshot() + expect(snap.manualAt).toBe(first.at) + expect(snap.manual).toEqual(first.manual) + }) + + it('a section deadline degrades only that section (errors entry + warn), others complete', async () => { + vi.mocked(readersMock.scanProcessTable).mockRejectedValue(new readersMock.DeadlineExceeded()) + const service = makeService() + const { manual } = await service.refresh() + expect(HostStatsManualSchema.safeParse(manual).success).toBe(true) + expect(manual.topProcesses).toEqual({ available: false, dwellMs: 0, list: [] }) + expect(manual.processHealth).toEqual({ available: false, zombies: 0, dState: 0, total: 0 }) + expect(manual.sectionErrors.topProcesses).toEqual(expect.any(String)) + expect(manual.sectionErrors.processHealth).toEqual(expect.any(String)) + expect(manual.disks.available).toBe(true) + expect(manual.thermals.available).toBe(true) + expect(manual.inotify.available).toBe(true) + expect(manual.sectionErrors.disks).toBeUndefined() + expect(mockLog.warn).toHaveBeenCalledWith( + expect.objectContaining({ event: 'host_stats_section_timeout', section: 'topProcesses', budgetMs: 2000 }), + expect.any(String), + ) + expect(mockLog.warn).toHaveBeenCalledWith( + expect.objectContaining({ event: 'host_stats_section_timeout', section: 'processHealth', budgetMs: 2000 }), + expect.any(String), + ) + }) + + it('the overall watchdog marks still-running sections failed and is cleared in finally', async () => { + const service = makeService({ overallBudgetMs: 400 }) + vi.mocked(readersMock.scanProcessTable).mockReturnValue(new Promise(() => {})) // never resolves + const pending = service.refresh() + await vi.advanceTimersByTimeAsync(400) + const { manual } = await pending + expect(manual.topProcesses.available).toBe(false) + expect(manual.processHealth.available).toBe(false) + expect(manual.sectionErrors.topProcesses).toEqual(expect.any(String)) + expect(manual.disks.available).toBe(true) + + // Watchdog was cleared: a later refresh (post-cooldown) behaves normally. + vi.advanceTimersByTime(1000) + vi.mocked(readersMock.scanProcessTable).mockResolvedValue(TABLE) + await expect(service.refresh()).resolves.toBeDefined() + }) + + it('on darwin, the process scan goes through the ps path (procRoot null) and /proc sections are skipped', async () => { + stubPlatform('darwin') + const service = makeService({ procRoot: undefined }) // darwin default: procRoot null + const t0 = Date.now() + const { manual } = await service.refresh() + expect(vi.mocked(readersMock.scanProcessTable)).toHaveBeenCalledWith(null, 300, t0 + 2000) + expect(manual.topProcesses.available).toBe(true) + expect(manual.inotify).toEqual({ available: false, instances: null, watches: null, maxUserWatches: null, maxUserInstances: null }) + expect(readerFn('readSelfInotifyStats')).not.toHaveBeenCalled() + expect(readerFn('readInotifyLimits')).not.toHaveBeenCalled() + // / only — /dev/shm is skipped on darwin. + expect(vi.mocked(readersMock.statfsInfo).mock.calls.map((c) => c[0])).toEqual(['/']) + expect(manual.disks).toEqual({ + available: true, + list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }], + }) + expect(manual.thermals.available).toBe(true) + expect(manual.sectionErrors).toEqual({}) + }) +}) + +// --------------------------------------------------------------------------- +// freshell sources + env-configured cadence +// --------------------------------------------------------------------------- + +describe('freshell section sources (contract point 4)', () => { + it('constructor seeds are used until setSources overrides them', () => { + const service = makeService({ getPtyCounts: () => ({ running: 7, max: 50 }) }) + service.start() + let freshell = service.getSnapshot().live.freshell + expect(freshell).toMatchObject({ available: true, source: 'node', ptysRunning: 7, ptysMax: 50, wsClients: 0, wsClientsMax: 0 }) + + service.setSources({ + getPtyCounts: () => ({ running: 9, max: 50 }), + getWsClientCounts: () => ({ clients: 3, max: 50 }), + }) + vi.advanceTimersByTime(2000) + freshell = service.getSnapshot().live.freshell + expect(freshell).toMatchObject({ ptysRunning: 9, ptysMax: 50, wsClients: 3, wsClientsMax: 50 }) + }) +}) + +describe('default cadence configuration (contract point 1)', () => { + const FAST_ENV = 'FRESHELL_HOST_STATS_FAST_MS' + const SLOW_ENV = 'FRESHELL_HOST_STATS_SLOW_MS' + let savedFast: string | undefined + let savedSlow: string | undefined + + beforeEach(() => { + savedFast = process.env[FAST_ENV] + savedSlow = process.env[SLOW_ENV] + }) + afterEach(() => { + if (savedFast === undefined) delete process.env[FAST_ENV] + else process.env[FAST_ENV] = savedFast + if (savedSlow === undefined) delete process.env[SLOW_ENV] + else process.env[SLOW_ENV] = savedSlow + }) + + it('defaults to 2000ms fast / 5000ms slow when the env vars are absent', () => { + delete process.env[FAST_ENV] + delete process.env[SLOW_ENV] + const service = new HostStatsService({ procRoot: PROC, sysRoot: SYS }) // no fastMs/slowMs + services.push(service) + service.start() + vi.advanceTimersByTime(1999) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(1) + vi.advanceTimersByTime(1) // t=2000 + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(2) + expect(readerFn('readDiskStats')).not.toHaveBeenCalled() + vi.advanceTimersByTime(2999) // t=4999 + expect(readerFn('readDiskStats')).not.toHaveBeenCalled() + vi.advanceTimersByTime(1) // t=5000 + expect(readerFn('readDiskStats')).toHaveBeenCalledTimes(1) + }) + + it('honors FRESHELL_HOST_STATS_FAST_MS / FRESHELL_HOST_STATS_SLOW_MS', () => { + process.env[FAST_ENV] = '500' + process.env[SLOW_ENV] = '1250' + const service = new HostStatsService({ procRoot: PROC, sysRoot: SYS }) + services.push(service) + service.start() + vi.advanceTimersByTime(500) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(2) + expect(readerFn('readDiskStats')).not.toHaveBeenCalled() + vi.advanceTimersByTime(750) // t=1250 (fast at 1000 too) + expect(readerFn('readCpuTimes')).toHaveBeenCalledTimes(3) + expect(readerFn('readDiskStats')).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/unit/server/mcp/freshell-tool.test.ts b/test/unit/server/mcp/freshell-tool.test.ts index dc4ebf108..91dc12629 100644 --- a/test/unit/server/mcp/freshell-tool.test.ts +++ b/test/unit/server/mcp/freshell-tool.test.ts @@ -170,6 +170,16 @@ describe('executeAction -- tab actions', () => { expect(result).toBeTruthy() }) + it('new-tab passes hostStats through to /api/tabs', async () => { + mockClient.post.mockResolvedValue({ id: 't1' }) + + await executeAction('new-tab', { hostStats: true }) + + expect(mockClient.post).toHaveBeenCalledWith('/api/tabs', expect.objectContaining({ + hostStats: true, + })) + }) + // Fresh-agent shorthand resume: when `mode` is absent and the pane is a // fresh agent (`agent` param), resume sugar previously dropped the resume // fields silently. Only opencode is synthesized -- it is the only provider @@ -326,6 +336,22 @@ describe('executeAction -- pane actions', () => { ) }) + it('split-pane passes hostStats through to the split route', async () => { + mockClient.get.mockImplementation((path: string) => { + if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) + if (path.includes('/api/panes')) return Promise.resolve({ panes: [{ id: 'p1', index: 0, kind: 'terminal', terminalId: 'term-1' }] }) + return Promise.resolve({}) + }) + mockClient.post.mockResolvedValue({ ok: true }) + + await executeAction('split-pane', { target: 'p1', hostStats: true }) + + expect(mockClient.post).toHaveBeenCalledWith( + expect.stringContaining('/api/panes/p1/split'), + expect.objectContaining({ hostStats: true }), + ) + }) + it('split-pane passes explicit canonical Codex sessionRef', async () => { mockClient.get.mockImplementation((path: string) => { if (path === '/api/tabs') return Promise.resolve({ tabs: [{ id: 't1', activePaneId: 'p1' }], activeTabId: 't1' }) diff --git a/test/unit/server/platform-flags.test.ts b/test/unit/server/platform-flags.test.ts new file mode 100644 index 000000000..2bd8da53c --- /dev/null +++ b/test/unit/server/platform-flags.test.ts @@ -0,0 +1,16 @@ +import { describe, it, expect } from 'vitest' +import { detectFeatureFlags } from '../../../server/platform-router.js' + +describe('detectFeatureFlags hostStatsAvailable', () => { + it('matches process.platform !== "win32" on the current platform', () => { + expect(detectFeatureFlags().hostStatsAvailable).toBe(process.platform !== 'win32') + }) + + it('is false on a stubbed win32 platform', () => { + expect(detectFeatureFlags('win32').hostStatsAvailable).toBe(false) + }) + + it('is true on a stubbed linux platform', () => { + expect(detectFeatureFlags('linux').hostStatsAvailable).toBe(true) + }) +}) diff --git a/test/unit/server/tabs-registry/types.test.ts b/test/unit/server/tabs-registry/types.test.ts index 3af789d44..fedcb75de 100644 --- a/test/unit/server/tabs-registry/types.test.ts +++ b/test/unit/server/tabs-registry/types.test.ts @@ -27,6 +27,31 @@ describe('TabRegistryRecordSchema (server)', () => { expect(parsed.status).toBe('open') }) + it('accepts a tab record with a host-stats pane', () => { + const parsed = TabRegistryRecordSchema.parse({ + tabKey: 'device-1:tab-1', + tabId: 'tab-1', + serverInstanceId: 'srv-test', + deviceId: 'device-1', + deviceLabel: 'danlaptop', + tabName: 'stats', + status: 'open', + revision: 1, + createdAt: 1739491200000, + updatedAt: 1739577600000, + paneCount: 1, + titleSetByUser: false, + panes: [ + { + paneId: 'pane-1', + kind: 'host-stats', + payload: {}, + }, + ], + }) + expect(parsed.panes[0]?.kind).toBe('host-stats') + }) + it('rejects invalid status', () => { const result = TabRegistryRecordSchema.safeParse({ tabKey: 'device-1:tab-1', diff --git a/test/unit/server/terminal-registry.test.ts b/test/unit/server/terminal-registry.test.ts index 71661524b..bca2a817d 100644 --- a/test/unit/server/terminal-registry.test.ts +++ b/test/unit/server/terminal-registry.test.ts @@ -2258,6 +2258,10 @@ describe('TerminalRegistry', () => { }) describe('reaping exited terminals', () => { + it('exposes the constructed cap via getMaxTerminals()', () => { + expect(registry.getMaxTerminals()).toBe(10) + }) + it('does not count exited terminals against MAX_TERMINALS', () => { const reg = new TerminalRegistry(undefined, 2) const t1 = reg.create({ mode: 'shell' }) diff --git a/test/unit/shared/hoststats-protocol.test.ts b/test/unit/shared/hoststats-protocol.test.ts new file mode 100644 index 000000000..b397d3c4e --- /dev/null +++ b/test/unit/shared/hoststats-protocol.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { + ClientMessageSchema, HostStatsSubscribeSchema, HostStatsUnsubscribeSchema, HostStatsRefreshSchema, + HostStatsSnapshotSchema, HostStatsRefreshResponseSchema, +} from '../../../shared/ws-protocol' + +const live = { + machine: { cores: 12, memTotalBytes: 34_000_000_000, platform: 'linux', wsl: true, kernel: '6.6', hostname: 'h', psi: true, cgroup: 'v2', thermalCount: 1, batteryPresent: false, gpu: 'none' }, + cpu: { available: true, usagePct: 12.5, stealPct: 0, perCorePct: [1, 2], freqMHz: 3400 }, + load: { available: true, load1: 0.5, load5: 1, load15: 1.2, cores: 12 }, + memory: { available: true, source: 'host', totalBytes: 1, usedBytes: 1, availableBytes: 1, cgroupLimitBytes: null, swapTotalBytes: 0, swapUsedBytes: 0 }, + paging: { available: true, swapInKbps: 0, swapOutKbps: 0, majFaultsPerSec: 0, oomKillsDelta: 0, oomKillsTotal: 0 }, + psi: { available: true, cpuSome10: 0.1, memSome10: null, memFull10: null, ioSome10: 0.2, ioFull10: 0 }, + diskIo: { available: true, readBps: 0, writeBps: 0, utilPct: null, weightedAwaitMs: null }, + network: { available: true, rxBps: 0, txBps: 0, rxErrorsTotal: 0, txErrorsTotal: 0, rxDroppedTotal: 0, txDroppedTotal: 0, rxErrorsDelta: 0, txErrorsDelta: 0, rxDroppedDelta: 0, txDroppedDelta: 0 }, + limits: { available: true, fdsUsed: 128, fdsMax: 1048576, pidsUsed: 900, pidsMax: 4194304, timeWait: 42, ephemeralPorts: 28232 }, + freshell: { available: true, source: 'node', ptysRunning: 1, ptysMax: 50, wsClients: 2, wsClientsMax: 50, eventLoopLagP99Ms: 3.2, rssBytes: 900_000_000, uptimeSec: 100 }, +} +const manual = { + topProcesses: { available: true, dwellMs: 300, list: [{ pid: 5, name: 'node', cpuPct: 12.3, rssBytes: 1e6, state: 'S' }] }, + processHealth: { available: true, zombies: 0, dState: 0, total: 900 }, + inotify: { available: true, instances: 3, watches: 420, maxUserWatches: 1048576, maxUserInstances: 128 }, + disks: { available: true, list: [{ mount: '/', totalBytes: 1e12, freeBytes: 5e11, usedPct: 50, inodesTotal: 1e8, inodesFree: 9e7 }] }, + thermals: { available: true, zones: [{ label: 'cpu', celsius: 51.5 }], battery: null }, + sectionErrors: {}, +} + +describe('hoststats protocol', () => { + it('accepts subscribe/unsubscribe/refresh client messages', () => { + expect(() => ClientMessageSchema.parse({ type: 'hoststats.subscribe' })).not.toThrow() + expect(() => ClientMessageSchema.parse({ type: 'hoststats.unsubscribe' })).not.toThrow() + expect(() => ClientMessageSchema.parse({ type: 'hoststats.refresh', requestId: 'r1' })).not.toThrow() + }) + it('rejects malformed client frames', () => { + expect(HostStatsRefreshSchema.safeParse({ type: 'hoststats.refresh' }).success).toBe(false) + expect(HostStatsRefreshSchema.safeParse({ type: 'hoststats.refresh', requestId: '' }).success).toBe(false) + expect(HostStatsSubscribeSchema.safeParse({ type: 'hoststats.subscribe', sneaky: 1 }).success).toBe(false) + expect(HostStatsUnsubscribeSchema.safeParse({ type: 'hoststats.unsubscribe' }).success).toBe(true) + }) + it('validates a full snapshot and refresh response', () => { + const snap = { type: 'hoststats.snapshot', at: 1_756_000_000_000, live, manualAt: null, manual: null } + expect(HostStatsSnapshotSchema.safeParse(snap).success).toBe(true) + expect(HostStatsSnapshotSchema.safeParse({ ...snap, live: { ...live, cpu: { ...live.cpu, usagePct: 101 } } }).success).toBe(false) + expect(HostStatsRefreshResponseSchema.safeParse({ type: 'hoststats.refresh.response', requestId: 'r1', ok: true, at: 5, manual }).success).toBe(true) + expect(HostStatsRefreshResponseSchema.safeParse({ type: 'hoststats.refresh.response', requestId: 'r1', ok: false, error: 'deadline' }).success).toBe(true) + }) +})