//! Metrics collection using sysinfo for socktop_agent. use crate::gpu::collect_all_gpus; use crate::state::AppState; use crate::types::{ DetailedProcessInfo, DiskInfo, JournalEntry, JournalResponse, LogLevel, Metrics, NetworkInfo, ProcessInfo, ProcessMetricsResponse, ProcessesPayload, }; use once_cell::sync::OnceCell; #[cfg(target_os = "linux")] use std::collections::HashMap; #[cfg(target_os = "linux")] use std::fs; #[cfg(target_os = "linux")] use std::io; use std::process::Command; use std::sync::Mutex; use std::time::Duration as StdDuration; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate}; #[cfg(feature = "logging")] use tracing::warn; // NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo. // Read (utime, stime) in milliseconds from /proc/{pid}/stat in one go. // Returns (0, 0) if the file can't be read. // // We use `rfind(')')` to step past the `comm` field, which can contain // arbitrary characters (including spaces and parens), then index the // post-comm fields by position. This is the same trick `read_proc_jiffies` // uses below — `split_whitespace().collect::>()` from the start of // the file would mis-parse process names with spaces, and also wastes an // allocation per call. Two callers used to read this file twice (once for // user, once for system); now it's one syscall per detailed-process record. #[cfg(target_os = "linux")] fn get_cpu_times_ms(pid: u32) -> (u64, u64) { let Ok(s) = fs::read_to_string(format!("/proc/{pid}/stat")) else { return (0, 0); }; let Some(rpar) = s.rfind(')') else { return (0, 0); }; let Some(after) = s.get(rpar + 2..) else { return (0, 0); }; let mut it = after.split_whitespace(); // Post-comm field offsets: state, ppid, pgrp, session, tty_nr, tpgid, // flags, minflt, cminflt, majflt, cmajflt, utime, stime, ... // utime is offset 11; stime follows. let utime = it.nth(11).and_then(|s| s.parse::().ok()).unwrap_or(0); let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); // 1 tick = 10ms at 100 Hz (USER_HZ). (utime * 10, stime * 10) } #[cfg(not(target_os = "linux"))] fn get_cpu_times_ms(_pid: u32) -> (u64, u64) { (0, 0) } // Runtime toggles (read once) fn gpu_enabled() -> bool { static ON: OnceCell = OnceCell::new(); *ON.get_or_init(|| { std::env::var("SOCKTOP_AGENT_GPU") .map(|v| v != "0") .unwrap_or(true) }) } fn temp_enabled() -> bool { static ON: OnceCell = OnceCell::new(); *ON.get_or_init(|| { std::env::var("SOCKTOP_AGENT_TEMP") .map(|v| v != "0") .unwrap_or(true) }) } // TTL knobs read once at first use, then cached. These hit the hot polling // paths (every 250ms-1.5s), so re-reading via libc getenv per call is wasted. fn metrics_ttl_ms() -> u64 { static V: OnceCell = OnceCell::new(); *V.get_or_init(|| { std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(250) }) } fn disks_ttl_ms() -> u64 { static V: OnceCell = OnceCell::new(); *V.get_or_init(|| { std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1_000) }) } fn processes_ttl_ms() -> u64 { static V: OnceCell = OnceCell::new(); *V.get_or_init(|| { std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1_500) }) } #[cfg(not(target_os = "linux"))] fn name_cache_cleanup_threshold() -> usize { static V: OnceCell = OnceCell::new(); *V.get_or_init(|| { std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(1000) }) } // Tiny TTL caches to avoid rescanning sensors every 500ms const TTL: Duration = Duration::from_millis(1500); struct TempCache { at: Option, v: Option, } static TEMP: OnceCell> = OnceCell::new(); // Last time `state.components` was refreshed (by any caller). Both // collect_fast_metrics and collect_disks need fresh sensor values; without // this gate they were each doing their own `Components::refresh` on their // own cadence, paying the hwmon syscall cost twice per polling cycle. // 1s is short enough that disk temps stay accurate (they change slowly) and // long enough to suppress back-to-back refreshes from concurrent endpoints. const COMPONENTS_REFRESH_TTL: Duration = Duration::from_millis(1000); static COMPONENTS_LAST_REFRESH: OnceCell>> = OnceCell::new(); /// Refresh `state.components` only if the cached refresh timestamp is older /// than `COMPONENTS_REFRESH_TTL`. Caller must already hold the components /// lock. fn refresh_components_if_stale(components: &mut sysinfo::Components) { let lock = COMPONENTS_LAST_REFRESH.get_or_init(|| Mutex::new(None)); let mut last = match lock.lock() { Ok(g) => g, Err(_) => return, // Poisoned — skip; values stay as-is until next call }; let now = Instant::now(); let stale = last.is_none_or(|t| now.duration_since(t) >= COMPONENTS_REFRESH_TTL); if stale { components.refresh(false); *last = Some(now); } } struct GpuCache { at: Option, v: Option>, } static GPUC: OnceCell> = OnceCell::new(); // Static caches for unchanging data static HOSTNAME: OnceCell = OnceCell::new(); struct NetworkNameCache { names: Vec, infos: Vec, } static NETWORK_CACHE: OnceCell> = OnceCell::new(); static CPU_VEC: OnceCell>> = OnceCell::new(); fn cached_temp() -> Option { if !temp_enabled() { return None; } let now = Instant::now(); let lock = TEMP.get_or_init(|| Mutex::new(TempCache { at: None, v: None })); let mut c = lock.lock().ok()?; if c.at.is_none_or(|t| now.duration_since(t) >= TTL) { c.at = Some(now); // caller will fill this; we just hold a slot c.v = None; } c.v } fn set_temp(v: Option) { if let Some(lock) = TEMP.get() && let Ok(mut c) = lock.lock() { c.v = v; c.at = Some(Instant::now()); } } fn cached_gpus() -> Option> { if !gpu_enabled() { return None; } let now = Instant::now(); let lock = GPUC.get_or_init(|| Mutex::new(GpuCache { at: None, v: None })); let mut c = lock.lock().ok()?; if c.at.is_none_or(|t| now.duration_since(t) >= TTL) { // mark stale; caller will refresh c.at = Some(now); c.v = None; } c.v.clone() } fn set_gpus(v: Option>) { if let Some(lock) = GPUC.get() && let Ok(mut c) = lock.lock() { c.v = v.clone(); c.at = Some(Instant::now()); } } // Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus). pub async fn collect_fast_metrics(state: &AppState) -> Metrics { let ttl = StdDuration::from_millis(metrics_ttl_ms()); { let cache = state.cache_metrics.lock().await; if cache.is_fresh(ttl) && let Some(c) = cache.get() { return c.clone(); } } let mut sys = state.sys.lock().await; if let Err(_e) = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { sys.refresh_cpu_usage(); sys.refresh_memory(); })) { #[cfg(feature = "logging")] warn!("sysinfo selective refresh panicked: {_e:?}"); } // Get or initialize hostname once let hostname = HOSTNAME.get_or_init(|| state.hostname.clone()).clone(); // Reuse CPU vector to avoid allocation let cpu_total = sys.global_cpu_usage(); let cpu_per_core = { let vec_lock = CPU_VEC.get_or_init(|| Mutex::new(Vec::with_capacity(32))); let mut vec = vec_lock.lock().unwrap(); vec.clear(); vec.extend(sys.cpus().iter().map(|c| c.cpu_usage())); vec.clone() // Still need to clone but the allocation is reused }; let mem_total = sys.total_memory(); let mem_used = mem_total.saturating_sub(sys.available_memory()); let swap_total = sys.total_swap(); let swap_used = sys.used_swap(); drop(sys); // CPU temperature: only refresh sensors if cache is stale let cpu_temp_c = if cached_temp().is_some() { cached_temp() } else if temp_enabled() { let val = { let mut components = state.components.lock().await; refresh_components_if_stale(&mut components); components.iter().find_map(|c| { let l = c.label().to_ascii_lowercase(); if l.contains("cpu") || l.contains("package") || l.contains("tctl") || l.contains("tdie") { c.temperature() } else { None } }) }; set_temp(val); val } else { None }; // Networks with reusable name cache let networks = { let mut nets = state.networks.lock().await; nets.refresh(false); // Get or initialize network cache let cache = NETWORK_CACHE.get_or_init(|| { Mutex::new(NetworkNameCache { names: Vec::new(), infos: Vec::with_capacity(4), // Most systems have few network interfaces }) }); let mut cache = cache.lock().unwrap(); // Detect a topology change without allocating: compare lengths first, // then zip and walk. Only on a real diff do we materialize the new // names list. Was: `nets.keys().map(to_string).collect::>()` // every tick — a fresh Vec just to compare. let topology_changed = cache.names.len() != nets.keys().count() || cache .names .iter() .zip(nets.keys()) .any(|(cached, current)| cached.as_str() != current.as_str()); if topology_changed { cache.names.clear(); cache.names.extend(nets.keys().map(|n| n.to_string())); } // Reuse NetworkInfo objects cache.infos.clear(); for (name, data) in nets.iter() { cache.infos.push(NetworkInfo { name: name.to_string(), // We'll still clone but avoid Vec reallocation received: data.total_received(), transmitted: data.total_transmitted(), }); } cache.infos.clone() }; // GPUs: if we already determined none exist, short-circuit (no repeated probing) let gpus = if gpu_enabled() { if state.gpu_checked.load(std::sync::atomic::Ordering::Acquire) && !state.gpu_present.load(std::sync::atomic::Ordering::Relaxed) { None } else if cached_gpus().is_some() { cached_gpus() } else { let v = match collect_all_gpus() { Ok(v) if !v.is_empty() => Some(v), Ok(_) => None, Err(_e) => { #[cfg(feature = "logging")] warn!("gpu collection failed: {_e}"); None } }; // First probe records presence; subsequent calls rely on cache flags. if !state .gpu_checked .swap(true, std::sync::atomic::Ordering::AcqRel) { if v.is_some() { state .gpu_present .store(true, std::sync::atomic::Ordering::Release); } else { state .gpu_present .store(false, std::sync::atomic::Ordering::Release); } } set_gpus(v.clone()); v } } else { None }; let metrics = Metrics { cpu_total, cpu_per_core, mem_total, mem_used, swap_total, swap_used, hostname, cpu_temp_c, disks: Vec::new(), networks, top_processes: Vec::new(), gpus, }; { let mut cache = state.cache_metrics.lock().await; cache.set(metrics.clone()); } metrics } // Cached disks pub async fn collect_disks(state: &AppState) -> Vec { let ttl = StdDuration::from_millis(disks_ttl_ms()); { let cache = state.cache_disks.lock().await; if cache.is_fresh(ttl) && let Some(v) = cache.get() { return v.clone(); } } let mut disks_list = state.disks.lock().await; disks_list.refresh(false); // don't drop missing disks // Collect disk temperatures from components // NVMe temps show up as "Composite" under different chip names let disk_temps = { let mut components = state.components.lock().await; // Shared TTL-gated refresh: avoids paying the hwmon scan twice when // both endpoints converge in the same second. refresh_components_if_stale(&mut components); let mut composite_temps = Vec::new(); for c in components.iter() { let label = c.label().to_ascii_lowercase(); // Collect all "Composite" temperatures (these are NVMe drives) // Labels are like "nvme Composite CT1000N7BSS503" or "nvme Composite Sabrent Rocket 4.0" if label.contains("composite") && let Some(temp) = c.temperature() { #[cfg(feature = "logging")] tracing::debug!("Found Composite temp: {}°C", temp); composite_temps.push(temp); } } // Store composite temps indexed by their order (nvme0n1, nvme1n1, nvme2n1, etc.) let mut temps = std::collections::HashMap::new(); for (idx, temp) in composite_temps.iter().enumerate() { let key = format!("nvme{}n1", idx); #[cfg(feature = "logging")] tracing::debug!("Mapping {} -> {}°C", key, temp); temps.insert(key, *temp); } #[cfg(feature = "logging")] tracing::debug!("Final disk_temps map: {:?}", temps); temps }; // First collect all partitions from sysinfo, deduplicating by device name // (same partition can be mounted at multiple mount points) let mut seen_partitions = std::collections::HashSet::new(); let partitions: Vec = disks_list .iter() .filter_map(|d| { let name = d.name().to_string_lossy().into_owned(); // Skip if we've already seen this partition/device if !seen_partitions.insert(name.clone()) { return None; } // Determine if this is a partition let is_partition = name.contains("p1") || name.contains("p2") || name.contains("p3") || name.ends_with('1') || name.ends_with('2') || name.ends_with('3') || name.ends_with('4') || name.ends_with('5') || name.ends_with('6') || name.ends_with('7') || name.ends_with('8') || name.ends_with('9'); // Try to find temperature for this disk let temperature = disk_temps.iter().find_map(|(key, &temp)| { if name.starts_with(key) { #[cfg(feature = "logging")] tracing::debug!("Matched {} with key {} -> {}°C", name, key, temp); Some(temp) } else { None } }); if temperature.is_none() && !name.starts_with("loop") && !name.starts_with("ram") { #[cfg(feature = "logging")] tracing::debug!("No temperature found for disk: {}", name); } Some(DiskInfo { name, total: d.total_space(), available: d.available_space(), temperature, is_partition, }) }) .collect(); // Now create parent disk entries by aggregating partition data let mut parent_disks: std::collections::HashMap)> = std::collections::HashMap::new(); for partition in &partitions { if partition.is_partition { // Extract parent disk name // nvme0n1p1 -> nvme0n1, sda1 -> sda, mmcblk0p1 -> mmcblk0 let parent_name = if let Some(pos) = partition.name.rfind('p') { // Check if character after 'p' is a digit if partition .name .chars() .nth(pos + 1) .is_some_and(|c| c.is_ascii_digit()) { &partition.name[..pos] } else { // Handle sda1, sdb2, etc (just trim trailing digit) partition.name.trim_end_matches(char::is_numeric) } } else { // Handle sda1, sdb2, etc (just trim trailing digit) partition.name.trim_end_matches(char::is_numeric) }; // Look up temperature for the PARENT disk, not the partition // Strip /dev/ prefix if present for matching let parent_name_for_match = parent_name.strip_prefix("/dev/").unwrap_or(parent_name); let parent_temp = disk_temps.iter().find_map(|(key, &temp)| { if parent_name_for_match.starts_with(key) { Some(temp) } else { None } }); // Aggregate partition stats into parent let entry = parent_disks .entry(parent_name.to_string()) .or_insert((0, 0, parent_temp)); entry.0 += partition.total; entry.1 += partition.available; // Keep temperature if any partition has it (or if we just found one) if entry.2.is_none() { entry.2 = parent_temp; } } } // Create parent disk entries let mut disks: Vec = parent_disks .into_iter() .map(|(name, (total, available, temperature))| DiskInfo { name, total, available, temperature, is_partition: false, }) .collect(); // Sort parent disks by name disks.sort_by(|a, b| a.name.cmp(&b.name)); // Add partitions after their parent disk for partition in partitions { if partition.is_partition { // Find parent disk index let parent_name = if let Some(pos) = partition.name.rfind('p') { if partition .name .chars() .nth(pos + 1) .is_some_and(|c| c.is_ascii_digit()) { &partition.name[..pos] } else { partition.name.trim_end_matches(char::is_numeric) } } else { partition.name.trim_end_matches(char::is_numeric) }; // Find where to insert this partition (after its parent) if let Some(parent_idx) = disks.iter().position(|d| d.name == parent_name) { // Insert after parent and any existing partitions of that parent let mut insert_idx = parent_idx + 1; while insert_idx < disks.len() && disks[insert_idx].is_partition && disks[insert_idx].name.starts_with(parent_name) { insert_idx += 1; } disks.insert(insert_idx, partition); } else { // Parent not found (shouldn't happen), just add at end disks.push(partition); } } else { // Not a partition (e.g., zram0), add at end disks.push(partition); } } { let mut cache = state.cache_disks.lock().await; cache.set(disks.clone()); } disks } // Linux-only helpers and implementation using /proc deltas for accurate CPU%. #[cfg(target_os = "linux")] #[inline] fn read_total_jiffies() -> io::Result { // /proc/stat first line: "cpu user nice system idle iowait irq softirq steal ..." let s = fs::read_to_string("/proc/stat")?; if let Some(line) = s.lines().next() { let mut it = line.split_whitespace(); let _cpu = it.next(); // "cpu" let mut sum: u64 = 0; for tok in it.take(8) { if let Ok(v) = tok.parse::() { sum = sum.saturating_add(v); } } return Ok(sum); } Err(io::Error::other("no cpu line")) } #[cfg(target_os = "linux")] #[inline] fn read_proc_jiffies(pid: u32) -> Option { let path = format!("/proc/{pid}/stat"); let s = fs::read_to_string(path).ok()?; // Find the right parenthesis that terminates comm; everything after is space-separated fields starting at "state" let rpar = s.rfind(')')?; let after = s.get(rpar + 2..)?; // skip ") " let mut it = after.split_whitespace(); // utime (14th field) is offset 11 from "state", stime (15th) is next let utime = it.nth(11)?.parse::().ok()?; let stime = it.next()?.parse::().ok()?; Some(utime.saturating_add(stime)) } /// Collect all processes (Linux): compute CPU% via /proc jiffies delta; sorting moved to client. #[cfg(target_os = "linux")] pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { let ttl = StdDuration::from_millis(processes_ttl_ms()); { let cache = state.cache_processes.lock().await; if cache.is_fresh(ttl) && let Some(c) = cache.get() { return c.clone(); } } // Reuse shared System to avoid reallocation. We only need name + memory // from sysinfo here — per-process CPU% is computed below from /proc/{pid}/stat // jiffies (see `read_proc_jiffies` + `read_total_jiffies`), so asking sysinfo // to gather CPU/exe/cmd/cwd/env per process is wasted /proc traffic on a Pi // (was reading /proc/{pid}/{cmdline,exe,cwd,environ,io,status} for every PID // on every 2 s poll via `everything()`). let mut sys_guard = state.sys.lock().await; let sys = &mut *sys_guard; sys.refresh_processes_specifics( ProcessesToUpdate::All, false, ProcessRefreshKind::nothing().with_memory(), ); let total_count = sys.processes().len(); // Snapshot current per-pid jiffies let mut current: HashMap = HashMap::with_capacity(total_count); for p in sys.processes().values() { let pid = p.pid().as_u32(); if let Some(j) = read_proc_jiffies(pid) { current.insert(pid, j); } } let total_now = read_total_jiffies().unwrap_or(0); // Compute deltas vs last sample. We hold the proc_cpu lock for the whole // collection below so we can read+update the per-pid name cache in one // critical section. let mut tracker = state.proc_cpu.lock().await; let last_total = tracker.last_total; // Move the old per-pid jiffies map out for delta computation. let mut last_map = std::mem::take(&mut tracker.last_per_pid); tracker.last_total = total_now; // Resolve a name through the per-pid cache. Allocates only on miss. let resolve_name = |tracker: &mut crate::state::ProcCpuTracker, pid: u32, p: &sysinfo::Process| -> String { if let Some(cached) = tracker.names.get(&pid) { return cached.clone(); } let new_name = p.name().to_string_lossy().into_owned(); tracker.names.insert(pid, new_name.clone()); new_name }; // On first run or if total delta is tiny, report zeros. if last_total == 0 || total_now <= last_total { let mut procs: Vec = Vec::with_capacity(total_count); for p in sys.processes().values() { let pid = p.pid().as_u32(); let name = resolve_name(&mut tracker, pid, p); procs.push(ProcessInfo { pid, name, cpu_usage: 0.0, mem_bytes: p.memory(), }); } // Stash the just-collected jiffies for next call's delta, then prune // dead pids from the name cache. Borrowing dance: retain reads // `tracker.last_per_pid` through the closure, which conflicts with // the mutable borrow of `tracker.names.retain`. Split via split-borrow: tracker.last_per_pid = current; let crate::state::ProcCpuTracker { ref last_per_pid, ref mut names, .. } = *tracker; names.retain(|pid, _| last_per_pid.contains_key(pid)); return ProcessesPayload { process_count: total_count, top_processes: procs, }; } let dt = total_now.saturating_sub(last_total).max(1) as f32; let mut procs: Vec = Vec::with_capacity(total_count); for p in sys.processes().values() { let pid = p.pid().as_u32(); let now = current.get(&pid).copied().unwrap_or(0); let prev = last_map.remove(&pid).unwrap_or(0); let du = now.saturating_sub(prev) as f32; let cpu = ((du / dt) * 100.0).clamp(0.0, 100.0); let name = resolve_name(&mut tracker, pid, p); procs.push(ProcessInfo { pid, name, cpu_usage: cpu, mem_bytes: p.memory(), }); } // Save current jiffies map for next call and prune dead pids from the // name cache. `current` is moved here (no clone — that's also #19). tracker.last_per_pid = current; let crate::state::ProcCpuTracker { ref last_per_pid, ref mut names, .. } = *tracker; names.retain(|pid, _| last_per_pid.contains_key(pid)); drop(tracker); let payload = ProcessesPayload { process_count: total_count, top_processes: procs, }; { let mut cache = state.cache_processes.lock().await; cache.set(payload.clone()); } payload } /// Collect all processes (non-Linux): optimized for reduced allocations and selective updates. #[cfg(not(target_os = "linux"))] pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload { // Serve from cache if fresh { let cache = state.cache_processes.lock().await; if cache.is_fresh(StdDuration::from_millis(2_000)) { // Use fixed TTL for cache check if let Some(c) = cache.get() { return c.clone(); } } } // Single efficient refresh with optimized CPU collection let (total_count, procs) = { let mut sys = state.sys.lock().await; let kind = ProcessRefreshKind::nothing().with_memory(); // Optimize refresh strategy based on system load //if load > 5.0 { //JW too complicated. simplify to remove strange behavior // For active systems, get accurate CPU metrics sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind.with_cpu()); // } else { // // For idle systems, just get basic process info // sys.refresh_processes_specifics(ProcessesToUpdate::All, false, kind); // sys.refresh_cpu_usage(); // } let total_count = sys.processes().len(); let cpu_count = sys.cpus().len() as f32; // Reuse allocations via process cache let mut proc_cache = state.proc_cache.lock().await; proc_cache.reusable_vec.clear(); // Collect all processes, will sort by CPU later for p in sys.processes().values() { let pid = p.pid().as_u32(); // Reuse cached name if available let name = if let Some(cached) = proc_cache.names.get(&pid) { cached.clone() } else { let new_name = p.name().to_string_lossy().into_owned(); proc_cache.names.insert(pid, new_name.clone()); new_name }; // Convert to percentage of total CPU capacity // e.g., 100% on 2 cores of 8 core system = 25% total CPU let raw = p.cpu_usage(); // This is per-core percentage let total_cpu = raw.clamp(0.0, 100.0) / cpu_count; proc_cache.reusable_vec.push(ProcessInfo { pid, name, cpu_usage: total_cpu, mem_bytes: p.memory(), }); } //JW no need to sort here; client does the sorting // // Sort by CPU usage // proc_cache.reusable_vec.sort_by(|a, b| { // b.cpu_usage // .partial_cmp(&a.cpu_usage) // .unwrap_or(std::cmp::Ordering::Equal) // }); // Clean up old process names cache when it grows too large. let cache_cleanup_threshold = name_cache_cleanup_threshold(); if total_count > proc_cache.names.len() + cache_cleanup_threshold { let now = std::time::Instant::now(); proc_cache .names .retain(|pid, _| sys.processes().contains_key(&sysinfo::Pid::from_u32(*pid))); #[cfg(feature = "logging")] tracing::debug!( "Cleaned up {} stale process names in {}ms", proc_cache.names.capacity() - proc_cache.names.len(), now.elapsed().as_millis() ); } // Get all processes, take ownership of the vec (will be replaced with empty vec) (total_count, std::mem::take(&mut proc_cache.reusable_vec)) }; let payload = ProcessesPayload { process_count: total_count, top_processes: procs, }; { let mut cache = state.cache_processes.lock().await; cache.set(payload.clone()); } payload } /// Lightweight child process enumeration using direct /proc access /// This avoids the expensive refresh_processes_specifics(All) call #[cfg(target_os = "linux")] fn enumerate_child_processes_lightweight( parent_pid: u32, system: &sysinfo::System, ) -> Vec { let mut children = Vec::new(); // Read /proc to find all child processes // This is much faster than refresh_processes_specifics(All) if let Ok(entries) = fs::read_dir("/proc") { for entry in entries.flatten() { if let Ok(file_name) = entry.file_name().into_string() && let Ok(pid) = file_name.parse::() && let Some(child_parent_pid) = read_parent_pid_from_proc(pid) && child_parent_pid == parent_pid && let Some(child_info) = collect_process_info_from_proc(pid, system) { children.push(child_info); } } } children } /// Single-read extraction of the /proc/{pid}/status fields the detail /// endpoint cares about. Callers used to open this file twice per /// detail-process record (once for VmRSS/VmSize, once for Uid/Gid/Threads/ /// State); now it's one read + one scan. #[cfg(target_os = "linux")] #[derive(Default)] struct ProcStatus { rss_kb: u64, vsize_kb: u64, uid: u32, gid: u32, threads: u32, /// Raw status letter from `State:` (e.g. 'R', 'S'). '?' if missing. state_ch: char, } #[cfg(target_os = "linux")] fn read_proc_status(pid: u32) -> Option { let content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?; let mut out = ProcStatus { state_ch: '?', ..Default::default() }; for line in content.lines() { if let Some(v) = line.strip_prefix("VmRSS:") { out.rss_kb = v .split_whitespace() .next() .and_then(|s| s.parse().ok()) .unwrap_or(0); } else if let Some(v) = line.strip_prefix("VmSize:") { out.vsize_kb = v .split_whitespace() .next() .and_then(|s| s.parse().ok()) .unwrap_or(0); } else if let Some(v) = line.strip_prefix("Uid:") { out.uid = v .split_whitespace() .next() .and_then(|s| s.parse().ok()) .unwrap_or(0); } else if let Some(v) = line.strip_prefix("Gid:") { out.gid = v .split_whitespace() .next() .and_then(|s| s.parse().ok()) .unwrap_or(0); } else if let Some(v) = line.strip_prefix("Threads:") { out.threads = v.trim().parse().unwrap_or(0); } else if let Some(v) = line.strip_prefix("State:") { out.state_ch = v.trim().chars().next().unwrap_or('?'); } } Some(out) } #[cfg(target_os = "linux")] fn proc_state_label(c: char) -> &'static str { match c { 'R' => "Running", 'S' => "Sleeping", 'D' => "Disk Sleep", 'Z' => "Zombie", 'T' => "Stopped", 't' => "Tracing Stop", 'X' | 'x' => "Dead", 'K' => "Wakekill", 'W' => "Waking", 'P' => "Parked", 'I' => "Idle", _ => "Unknown", } } /// Read parent PID from /proc/{pid}/stat #[cfg(target_os = "linux")] fn read_parent_pid_from_proc(pid: u32) -> Option { let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; // Format: pid (comm) state ppid ... — comm can contain spaces/parens, // so we step past the closing paren first. let ppid_start = stat.rfind(')')?; // After ") ": state, ppid, ... — ppid is the second field. stat[ppid_start + 1..] .split_whitespace() .nth(1)? .parse::() .ok() } /// Collect process information from /proc files #[cfg(target_os = "linux")] fn collect_process_info_from_proc( pid: u32, system: &sysinfo::System, ) -> Option { // One read of /proc/{pid}/status gets us everything the detail endpoint // needs from it: memory (when not in sysinfo cache), Uid/Gid, Threads, // and State. The previous code opened this file twice per process record. let st = read_proc_status(pid)?; let (name, cpu_usage, mem_bytes, virtual_mem_bytes) = if let Some(proc) = system.process(sysinfo::Pid::from_u32(pid)) { ( proc.name().to_string_lossy().to_string(), proc.cpu_usage(), proc.memory(), proc.virtual_memory(), ) } else { // Process not in sysinfo cache — derive name from /proc/{pid}/comm // and memory from the status read above. let name = fs::read_to_string(format!("/proc/{pid}/comm")) .ok()? .trim() .to_string(); (name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024) }; // Read command line let command = fs::read_to_string(format!("/proc/{pid}/cmdline")) .ok() .map(|s| s.replace('\0', " ").trim().to_string()) .unwrap_or_default(); let uid = st.uid; let gid = st.gid; let thread_count = st.threads; let status = proc_state_label(st.state_ch).to_string(); // Read start time from stat — comm-safe via rfind(')'). let start_time = if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) { let stat_end = stat.rfind(')')?; // After ") ": state, ppid, ..., starttime — starttime is the 20th // post-comm field (index 19). stat[stat_end + 1..] .split_whitespace() .nth(19)? .parse::() .ok()? } else { 0 }; // Read I/O stats if available let (read_bytes, write_bytes) = if let Ok(io_content) = fs::read_to_string(format!("/proc/{pid}/io")) { let mut read_bytes = None; let mut write_bytes = None; for line in io_content.lines() { if let Some(value) = line.strip_prefix("read_bytes:") { read_bytes = value.trim().parse().ok(); } else if let Some(value) = line.strip_prefix("write_bytes:") { write_bytes = value.trim().parse().ok(); } } (read_bytes, write_bytes) } else { (None, None) }; // Read working directory let working_directory = fs::read_link(format!("/proc/{pid}/cwd")) .ok() .map(|p| p.to_string_lossy().to_string()); // Read executable path let executable_path = fs::read_link(format!("/proc/{pid}/exe")) .ok() .map(|p| p.to_string_lossy().to_string()); // One read of /proc/{pid}/stat covers both user + system CPU times. let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); Some(DetailedProcessInfo { pid, name, command, cpu_usage, mem_bytes, virtual_mem_bytes, shared_mem_bytes: None, // Would need to parse /proc/{pid}/statm for this thread_count, fd_count: None, // Would need to count entries in /proc/{pid}/fd status, parent_pid: None, // We already know the parent user_id: uid, group_id: gid, start_time, cpu_time_user, cpu_time_system, read_bytes, write_bytes, working_directory, executable_path, child_processes: Vec::new(), // Don't recurse threads: Vec::new(), // Not collected for child processes }) } /// Fallback for non-Linux: use sysinfo (less efficient but functional) #[cfg(not(target_os = "linux"))] fn enumerate_child_processes_lightweight( parent_pid: u32, system: &sysinfo::System, ) -> Vec { let mut children = Vec::new(); // On non-Linux, we have to iterate through all processes in sysinfo // This is less efficient but maintains cross-platform compatibility for (child_pid, child_process) in system.processes() { if let Some(parent) = child_process.parent() && parent.as_u32() == parent_pid { let child_info = DetailedProcessInfo { pid: child_pid.as_u32(), name: child_process.name().to_string_lossy().to_string(), command: child_process .cmd() .iter() .map(|s| s.to_string_lossy().to_string()) .collect::>() .join(" "), cpu_usage: child_process.cpu_usage(), mem_bytes: child_process.memory(), virtual_mem_bytes: child_process.virtual_memory(), shared_mem_bytes: None, thread_count: child_process .tasks() .map(|tasks| tasks.len() as u32) .unwrap_or(0), fd_count: None, status: format!("{:?}", child_process.status()), parent_pid: Some(parent_pid), // On non-Linux platforms, sysinfo UID/GID might not be accurate // Just use 0 as placeholder since we can't read /proc user_id: 0, group_id: 0, start_time: child_process.start_time(), cpu_time_user: 0, // Not available on non-Linux in our implementation cpu_time_system: 0, read_bytes: Some(child_process.disk_usage().read_bytes), write_bytes: Some(child_process.disk_usage().written_bytes), working_directory: child_process.cwd().map(|p| p.to_string_lossy().to_string()), executable_path: child_process.exe().map(|p| p.to_string_lossy().to_string()), child_processes: Vec::new(), threads: Vec::new(), // Not collected for non-Linux }; children.push(child_info); } } children } /// Collect thread information for a specific process (Linux only) #[cfg(target_os = "linux")] fn collect_thread_info(pid: u32) -> Vec { let mut threads = Vec::new(); // Read /proc/{pid}/task directory let task_dir = format!("/proc/{pid}/task"); let Ok(entries) = fs::read_dir(&task_dir) else { return threads; }; for entry in entries.flatten() { let file_name = entry.file_name(); let tid_str = file_name.to_string_lossy(); let Ok(tid) = tid_str.parse::() else { continue; }; // Read thread name from comm let name = fs::read_to_string(format!("/proc/{pid}/task/{tid}/comm")) .unwrap_or_else(|_| format!("Thread-{tid}")) .trim() .to_string(); // Read thread stat for CPU times and status. let stat_path = format!("/proc/{pid}/task/{tid}/stat"); let Ok(stat_content) = fs::read_to_string(&stat_path) else { continue; }; // Thread/comm names can contain spaces or parens, so step past the // last ')' before parsing post-comm fields. Post-comm offsets: // 0: state, 1: ppid, 2: pgrp, ..., 11: utime, 12: stime let Some(rpar) = stat_content.rfind(')') else { continue; }; let Some(after) = stat_content.get(rpar + 1..) else { continue; }; let mut it = after.split_whitespace(); let status = it .next() .and_then(|s| s.chars().next()) .map(|c| match c { 'R' => "Running", 'S' => "Sleeping", 'D' => "Disk Sleep", 'Z' => "Zombie", 'T' => "Stopped", 't' => "Tracing Stop", 'X' | 'x' => "Dead", _ => "Unknown", }) .unwrap_or("Unknown") .to_string(); // 10 fields between state and utime (ppid..cmajflt). let utime = it.nth(10).and_then(|s| s.parse::().ok()).unwrap_or(0); let stime = it.next().and_then(|s| s.parse::().ok()).unwrap_or(0); // Convert clock ticks to microseconds (assuming 100 Hz) // 1 tick = 10ms = 10,000 microseconds let cpu_time_user = utime * 10_000; let cpu_time_system = stime * 10_000; threads.push(crate::types::ThreadInfo { tid, name, cpu_time_user, cpu_time_system, status, }); } threads } /// Fallback for non-Linux: return empty thread list #[cfg(not(target_os = "linux"))] fn collect_thread_info(_pid: u32) -> Vec { Vec::new() } /// Collect detailed metrics for a specific process pub async fn collect_process_metrics( pid: u32, state: &AppState, ) -> Result { let mut system = state.sys.lock().await; // OPTIMIZED: Only refresh the specific process we care about // This avoids polluting the main process list with threads and prevents race conditions system.refresh_processes_specifics( ProcessesToUpdate::Some(&[sysinfo::Pid::from_u32(pid)]), false, ProcessRefreshKind::nothing() .with_memory() .with_cpu() .with_disk_usage(), ); let process = system .process(sysinfo::Pid::from_u32(pid)) .ok_or_else(|| format!("Process {pid} not found"))?; // Get current timestamp let cached_at = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| format!("Time error: {e}"))? .as_secs(); // Extract all needed data from process while we have the lock let name = process.name().to_string_lossy().to_string(); let command = process .cmd() .iter() .map(|s| s.to_string_lossy().to_string()) .collect::>() .join(" "); let cpu_usage = process.cpu_usage(); let mem_bytes = process.memory(); let virtual_mem_bytes = process.virtual_memory(); let thread_count = process.tasks().map(|tasks| tasks.len() as u32).unwrap_or(0); let status = format!("{:?}", process.status()); let parent_pid = process.parent().map(|p| p.as_u32()); let start_time = process.start_time(); // Read UID and GID directly from /proc/{pid}/status for accuracy. // Uses the shared single-read helper (also extracts memory, threads, // state — we discard those here since sysinfo already provided them). #[cfg(target_os = "linux")] let (user_id, group_id) = read_proc_status(pid) .map(|s| (s.uid, s.gid)) .unwrap_or((0, 0)); #[cfg(not(target_os = "linux"))] let (user_id, group_id) = (0, 0); // Read I/O stats directly from /proc/{pid}/io // Use rchar/wchar to capture ALL I/O including cached reads (like htop/btop do) // sysinfo's total_read_bytes/total_written_bytes only count actual disk I/O #[cfg(target_os = "linux")] let (read_bytes, write_bytes) = if let Ok(io_content) = std::fs::read_to_string(format!("/proc/{pid}/io")) { let mut rchar = 0u64; let mut wchar = 0u64; for line in io_content.lines() { if let Some(value) = line.strip_prefix("rchar: ") { rchar = value.trim().parse().unwrap_or(0); } else if let Some(value) = line.strip_prefix("wchar: ") { wchar = value.trim().parse().unwrap_or(0); } } (Some(rchar), Some(wchar)) } else { // Fallback to sysinfo if we can't read /proc (permissions) let disk_usage = process.disk_usage(); ( Some(disk_usage.total_read_bytes), Some(disk_usage.total_written_bytes), ) }; #[cfg(not(target_os = "linux"))] let (read_bytes, write_bytes) = { let disk_usage = process.disk_usage(); ( Some(disk_usage.total_read_bytes), Some(disk_usage.total_written_bytes), ) }; let working_directory = process.cwd().map(|p| p.to_string_lossy().to_string()); let executable_path = process.exe().map(|p| p.to_string_lossy().to_string()); // Collect child processes using lightweight /proc access // This avoids the expensive system.refresh_processes_specifics(All) call let child_processes = enumerate_child_processes_lightweight(pid, &system); // Release the system lock early (automatic when system goes out of scope) drop(system); // Collect thread information (Linux only) let threads = collect_thread_info(pid); // One read of /proc/{pid}/stat covers both user + system CPU times. let (cpu_time_user, cpu_time_system) = get_cpu_times_ms(pid); // Now construct the detailed info without holding the lock let detailed_info = DetailedProcessInfo { pid, name, command, cpu_usage, mem_bytes, virtual_mem_bytes, shared_mem_bytes: None, // Not available from sysinfo thread_count, fd_count: None, // Not available from sysinfo on all platforms status, parent_pid, user_id, group_id, start_time, cpu_time_user, cpu_time_system, read_bytes, write_bytes, working_directory, executable_path, child_processes, threads, }; Ok(ProcessMetricsResponse { process: detailed_info, cached_at, }) } /// Collect journal entries for a specific process pub fn collect_journal_entries(pid: u32) -> Result { let output = Command::new("journalctl") .args([ &format!("_PID={pid}"), "--output=json", "--lines=100", "--no-pager", ]) .output() .map_err(|e| format!("Failed to execute journalctl: {e}"))?; if !output.status.success() { return Err(format!( "journalctl failed: {}", String::from_utf8_lossy(&output.stderr) )); } let stdout = String::from_utf8_lossy(&output.stdout); let mut entries = Vec::new(); // Parse each line as JSON (journalctl outputs one JSON object per line) for line in stdout.lines() { if line.trim().is_empty() { continue; } let json: serde_json::Value = serde_json::from_str(line).map_err(|e| format!("Failed to parse journal JSON: {e}"))?; // Extract relevant fields let timestamp_str = json .get("__REALTIME_TIMESTAMP") .and_then(|v| v.as_str()) .unwrap_or("0"); // Convert timestamp to ISO 8601 format let timestamp = if let Ok(ts_micros) = timestamp_str.parse::() { let ts_secs = ts_micros / 1_000_000; let ts_nanos = (ts_micros % 1_000_000) * 1000; let time = SystemTime::UNIX_EPOCH + Duration::from_secs(ts_secs) + Duration::from_nanos(ts_nanos); // Simple ISO 8601 format - we can improve this if needed format!("{time:?}") .replace("SystemTime { tv_sec: ", "") .replace(", tv_nsec: ", ".") .replace(" }", "") } else { timestamp_str.to_string() }; let priority = match json.get("PRIORITY").and_then(|v| v.as_str()) { Some("0") => LogLevel::Emergency, Some("1") => LogLevel::Alert, Some("2") => LogLevel::Critical, Some("3") => LogLevel::Error, Some("4") => LogLevel::Warning, Some("5") => LogLevel::Notice, Some("6") => LogLevel::Info, Some("7") => LogLevel::Debug, _ => LogLevel::Info, }; let message = json .get("MESSAGE") .and_then(|v| v.as_str()) .unwrap_or("") .to_string(); let unit = json .get("_SYSTEMD_UNIT") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let entry_pid = json .get("_PID") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()); let comm = json .get("_COMM") .and_then(|v| v.as_str()) .map(|s| s.to_string()); let uid = json .get("_UID") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()); let gid = json .get("_GID") .and_then(|v| v.as_str()) .and_then(|s| s.parse::().ok()); entries.push(JournalEntry { timestamp, priority, message, unit, pid: entry_pid, comm, uid, gid, }); } // Sort by timestamp (newest first) entries.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); let response_timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| format!("Time error: {e}"))? .as_secs(); let total_count = entries.len() as u32; let truncated = entries.len() >= 100; // We requested 100 lines, so if we got 100, there might be more Ok(JournalResponse { entries, total_count, truncated, cached_at: response_timestamp, }) }