chore: update ratatui from 0.28 to 0.30 (#33)
* chore: update ratatui from 0.28 to 0.30 * style: cargo fmt * fix: replace manual zero-guarded divisions with checked_div * fix: collapse nested if into match guard * style: cargo fmt * bump crossterm and optimize various types, remove stale code. * fix windows build * only show parent level processes on main tui
This commit is contained in:
@@ -1,95 +0,0 @@
|
||||
//! Caching for process metrics and journal entries
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::types::{ProcessMetricsResponse, JournalResponse};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct CacheEntry<T> {
|
||||
data: T,
|
||||
cached_at: Instant,
|
||||
ttl: Duration,
|
||||
}
|
||||
|
||||
impl<T> CacheEntry<T> {
|
||||
fn is_expired(&self) -> bool {
|
||||
self.cached_at.elapsed() > self.ttl
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct ProcessCache {
|
||||
process_metrics: RwLock<HashMap<u32, CacheEntry<ProcessMetricsResponse>>>,
|
||||
journal_entries: RwLock<HashMap<u32, CacheEntry<JournalResponse>>>,
|
||||
}
|
||||
|
||||
impl ProcessCache {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
process_metrics: RwLock::new(HashMap::new()),
|
||||
journal_entries: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached process metrics if available and not expired (250ms TTL)
|
||||
pub async fn get_process_metrics(&self, pid: u32) -> Option<ProcessMetricsResponse> {
|
||||
let cache = self.process_metrics.read().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if !entry.is_expired() {
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Cache process metrics with 250ms TTL
|
||||
pub async fn set_process_metrics(&self, pid: u32, data: ProcessMetricsResponse) {
|
||||
let mut cache = self.process_metrics.write().await;
|
||||
cache.insert(pid, CacheEntry {
|
||||
data,
|
||||
cached_at: Instant::now(),
|
||||
ttl: Duration::from_millis(250),
|
||||
});
|
||||
}
|
||||
|
||||
/// Get cached journal entries if available and not expired (1s TTL)
|
||||
pub async fn get_journal_entries(&self, pid: u32) -> Option<JournalResponse> {
|
||||
let cache = self.journal_entries.read().await;
|
||||
if let Some(entry) = cache.get(&pid) {
|
||||
if !entry.is_expired() {
|
||||
return Some(entry.data.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Cache journal entries with 1s TTL
|
||||
pub async fn set_journal_entries(&self, pid: u32, data: JournalResponse) {
|
||||
let mut cache = self.journal_entries.write().await;
|
||||
cache.insert(pid, CacheEntry {
|
||||
data,
|
||||
cached_at: Instant::now(),
|
||||
ttl: Duration::from_secs(1),
|
||||
});
|
||||
}
|
||||
|
||||
/// Clean up expired entries periodically
|
||||
pub async fn cleanup_expired(&self) {
|
||||
{
|
||||
let mut cache = self.process_metrics.write().await;
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
}
|
||||
{
|
||||
let mut cache = self.journal_entries.write().await;
|
||||
cache.retain(|_, entry| !entry.is_expired());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ProcessCache {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
+331
-234
@@ -23,45 +23,40 @@ use tracing::warn;
|
||||
|
||||
// NOTE: CPU normalization env removed; non-Linux now always reports per-process share (0..100) as given by sysinfo.
|
||||
|
||||
// Helper functions to get CPU time from /proc/stat on Linux
|
||||
// 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::<Vec<_>>()` 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_time_user(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||
if fields.len() > 13 {
|
||||
// Field 13 (0-indexed) is utime (user CPU time in clock ticks)
|
||||
if let Ok(utime) = fields[13].parse::<u64>() {
|
||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
||||
return utime * 10; // 1 tick = 10ms at 100 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn get_cpu_time_system(pid: u32) -> u64 {
|
||||
if let Ok(stat) = fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
let fields: Vec<&str> = stat.split_whitespace().collect();
|
||||
if fields.len() > 14 {
|
||||
// Field 14 (0-indexed) is stime (system CPU time in clock ticks)
|
||||
if let Ok(stime) = fields[14].parse::<u64>() {
|
||||
// Convert clock ticks to milliseconds (assuming 100 Hz)
|
||||
return stime * 10; // 1 tick = 10ms at 100 Hz
|
||||
}
|
||||
}
|
||||
}
|
||||
0
|
||||
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::<u64>().ok()).unwrap_or(0);
|
||||
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
// 1 tick = 10ms at 100 Hz (USER_HZ).
|
||||
(utime * 10, stime * 10)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn get_cpu_time_user(_pid: u32) -> u64 {
|
||||
0 // Not implemented for non-Linux platforms
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn get_cpu_time_system(_pid: u32) -> u64 {
|
||||
0 // Not implemented for non-Linux platforms
|
||||
fn get_cpu_times_ms(_pid: u32) -> (u64, u64) {
|
||||
(0, 0)
|
||||
}
|
||||
// Runtime toggles (read once)
|
||||
fn gpu_enabled() -> bool {
|
||||
@@ -81,6 +76,47 @@ fn temp_enabled() -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
// 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<u64> = 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<u64> = 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)
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
fn processes_ttl_ms() -> u64 {
|
||||
static V: OnceCell<u64> = 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<usize> = 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 {
|
||||
@@ -89,6 +125,32 @@ struct TempCache {
|
||||
}
|
||||
static TEMP: OnceCell<Mutex<TempCache>> = 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<Mutex<Option<Instant>>> = 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<Instant>,
|
||||
v: Option<Vec<crate::gpu::GpuMetrics>>,
|
||||
@@ -154,12 +216,7 @@ fn set_gpus(v: Option<Vec<crate::gpu::GpuMetrics>>) {
|
||||
|
||||
// Collect only fast-changing metrics (CPU/mem/net + optional temps/gpus).
|
||||
pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
// TTL (ms) overridable via env, default 250ms
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_METRICS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(250);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
let ttl = StdDuration::from_millis(metrics_ttl_ms());
|
||||
{
|
||||
let cache = state.cache_metrics.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
@@ -202,7 +259,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
} else if temp_enabled() {
|
||||
let val = {
|
||||
let mut components = state.components.lock().await;
|
||||
components.refresh(false);
|
||||
refresh_components_if_stale(&mut components);
|
||||
components.iter().find_map(|c| {
|
||||
let l = c.label().to_ascii_lowercase();
|
||||
if l.contains("cpu")
|
||||
@@ -236,12 +293,19 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
});
|
||||
let mut cache = cache.lock().unwrap();
|
||||
|
||||
// Collect current network names
|
||||
let current_names: Vec<_> = nets.keys().map(|name| name.to_string()).collect();
|
||||
|
||||
// Update cached network names if they changed
|
||||
if cache.names != current_names {
|
||||
cache.names = current_names;
|
||||
// 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::<Vec<_>>()`
|
||||
// every tick — a fresh Vec<String> 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
|
||||
@@ -319,11 +383,7 @@ pub async fn collect_fast_metrics(state: &AppState) -> Metrics {
|
||||
|
||||
// Cached disks
|
||||
pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
let ttl_ms: u64 = std::env::var("SOCKTOP_AGENT_DISKS_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1_000);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
let ttl = StdDuration::from_millis(disks_ttl_ms());
|
||||
{
|
||||
let cache = state.cache_disks.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
@@ -339,7 +399,9 @@ pub async fn collect_disks(state: &AppState) -> Vec<DiskInfo> {
|
||||
// NVMe temps show up as "Composite" under different chip names
|
||||
let disk_temps = {
|
||||
let mut components = state.components.lock().await;
|
||||
components.refresh(true); // true = refresh values, not just the list
|
||||
// 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();
|
||||
|
||||
@@ -572,12 +634,7 @@ fn read_proc_jiffies(pid: u32) -> Option<u64> {
|
||||
/// 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_ms: u64 = std::env::var("SOCKTOP_AGENT_PROCESSES_TTL_MS")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
// Higher default (1500ms) on non-Linux only; keep 1500 here for Linux correctness (more frequent updates).
|
||||
.unwrap_or(1_500);
|
||||
let ttl = StdDuration::from_millis(ttl_ms);
|
||||
let ttl = StdDuration::from_millis(processes_ttl_ms());
|
||||
{
|
||||
let cache = state.cache_processes.lock().await;
|
||||
if cache.is_fresh(ttl)
|
||||
@@ -586,13 +643,24 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
return c.clone();
|
||||
}
|
||||
}
|
||||
// Reuse shared System to avoid reallocation; refresh processes fully.
|
||||
// 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()`).
|
||||
//
|
||||
// `without_tasks()` is REQUIRED: it suppresses per-thread entries in the
|
||||
// process map (without it, sysinfo returns one entry per /proc/[tid] —
|
||||
// 780+ entries on a typical desktop because of glib/gdbus/Chrome thread
|
||||
// pools). The original code paired this with `everything()`; we keep the
|
||||
// filter when downgrading to a minimal refresh spec.
|
||||
let mut sys_guard = state.sys.lock().await;
|
||||
let sys = &mut *sys_guard;
|
||||
sys.refresh_processes_specifics(
|
||||
ProcessesToUpdate::All,
|
||||
false,
|
||||
ProcessRefreshKind::everything().without_tasks(),
|
||||
ProcessRefreshKind::nothing().with_memory().without_tasks(),
|
||||
);
|
||||
|
||||
let total_count = sys.processes().len();
|
||||
@@ -607,36 +675,50 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
}
|
||||
let total_now = read_total_jiffies().unwrap_or(0);
|
||||
|
||||
// Compute deltas vs last sample
|
||||
let (last_total, mut last_map) = {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut t = state.proc_cpu.lock().await;
|
||||
let lt = t.last_total;
|
||||
let lm = std::mem::take(&mut t.last_per_pid);
|
||||
t.last_total = total_now;
|
||||
t.last_per_pid = current.clone();
|
||||
(lt, lm)
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _: u64 = total_now; // silence unused warning
|
||||
(0u64, HashMap::new())
|
||||
}
|
||||
};
|
||||
// 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;
|
||||
|
||||
// On first run or if total delta is tiny, report zeros
|
||||
// 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 procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| ProcessInfo {
|
||||
pid: p.pid().as_u32(),
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
let mut procs: Vec<ProcessInfo> = 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(),
|
||||
})
|
||||
.collect();
|
||||
});
|
||||
}
|
||||
// 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,
|
||||
@@ -645,23 +727,31 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
|
||||
let dt = total_now.saturating_sub(last_total).max(1) as f32;
|
||||
|
||||
let procs: Vec<ProcessInfo> = sys
|
||||
.processes()
|
||||
.values()
|
||||
.map(|p| {
|
||||
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);
|
||||
ProcessInfo {
|
||||
pid,
|
||||
name: p.name().to_string_lossy().into_owned(),
|
||||
cpu_usage: cpu,
|
||||
mem_bytes: p.memory(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let mut procs: Vec<ProcessInfo> = 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,
|
||||
@@ -749,11 +839,8 @@ pub async fn collect_processes_all(state: &AppState) -> ProcessesPayload {
|
||||
// .unwrap_or(std::cmp::Ordering::Equal)
|
||||
// });
|
||||
|
||||
// Clean up old process names cache when it grows too large
|
||||
let cache_cleanup_threshold = std::env::var("SOCKTOP_AGENT_NAME_CACHE_CLEANUP_THRESHOLD")
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(1000); // Default: most modern systems have 400-700 processes
|
||||
// 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();
|
||||
@@ -811,17 +898,94 @@ fn enumerate_child_processes_lightweight(
|
||||
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<ProcStatus> {
|
||||
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<u32> {
|
||||
let stat = fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
|
||||
// Format: pid (comm) state ppid ...
|
||||
// We need to handle process names with spaces/parentheses
|
||||
// Format: pid (comm) state ppid ... — comm can contain spaces/parens,
|
||||
// so we step past the closing paren first.
|
||||
let ppid_start = stat.rfind(')')?;
|
||||
let fields: Vec<&str> = stat[ppid_start + 1..].split_whitespace().collect();
|
||||
// After the closing paren: state ppid ...
|
||||
// Field 1 (0-indexed) is ppid
|
||||
fields.get(1)?.parse::<u32>().ok()
|
||||
// After ") ": state, ppid, ... — ppid is the second field.
|
||||
stat[ppid_start + 1..]
|
||||
.split_whitespace()
|
||||
.nth(1)?
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Collect process information from /proc files
|
||||
@@ -830,8 +994,11 @@ fn collect_process_info_from_proc(
|
||||
pid: u32,
|
||||
system: &sysinfo::System,
|
||||
) -> Option<DetailedProcessInfo> {
|
||||
// Try to get basic info from sysinfo if it's already loaded (cheap lookup)
|
||||
// Otherwise read from /proc directly
|
||||
// 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)) {
|
||||
(
|
||||
@@ -841,30 +1008,13 @@ fn collect_process_info_from_proc(
|
||||
proc.virtual_memory(),
|
||||
)
|
||||
} else {
|
||||
// Process not in sysinfo cache, read minimal info from /proc
|
||||
// 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();
|
||||
|
||||
// Read memory from /proc/{pid}/status
|
||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let mut mem_bytes = 0u64;
|
||||
let mut virtual_mem_bytes = 0u64;
|
||||
|
||||
for line in status_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("VmRSS:") {
|
||||
if let Some(kb) = value.split_whitespace().next() {
|
||||
mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("VmSize:")
|
||||
&& let Some(kb) = value.split_whitespace().next()
|
||||
{
|
||||
virtual_mem_bytes = kb.parse::<u64>().unwrap_or(0) * 1024;
|
||||
}
|
||||
}
|
||||
|
||||
(name, 0.0, mem_bytes, virtual_mem_bytes)
|
||||
(name, 0.0, st.rss_kb * 1024, st.vsize_kb * 1024)
|
||||
};
|
||||
|
||||
// Read command line
|
||||
@@ -873,54 +1023,21 @@ fn collect_process_info_from_proc(
|
||||
.map(|s| s.replace('\0', " ").trim().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Read status information
|
||||
let status_content = fs::read_to_string(format!("/proc/{pid}/status")).ok()?;
|
||||
let mut uid = 0u32;
|
||||
let mut gid = 0u32;
|
||||
let mut thread_count = 0u32;
|
||||
let mut status = "Unknown".to_string();
|
||||
let uid = st.uid;
|
||||
let gid = st.gid;
|
||||
let thread_count = st.threads;
|
||||
let status = proc_state_label(st.state_ch).to_string();
|
||||
|
||||
for line in status_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Uid:") {
|
||||
if let Some(uid_str) = value.split_whitespace().next() {
|
||||
uid = uid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
||||
if let Some(gid_str) = value.split_whitespace().next() {
|
||||
gid = gid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Threads:") {
|
||||
thread_count = value.trim().parse().unwrap_or(0);
|
||||
} else if let Some(value) = line.strip_prefix("State:") {
|
||||
status = value
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.map(|c| 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",
|
||||
})
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Read start time from stat
|
||||
// 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(')')?;
|
||||
let fields: Vec<&str> = stat[stat_end + 1..].split_whitespace().collect();
|
||||
// Field 19 (0-indexed) is starttime in clock ticks since boot
|
||||
fields.get(19)?.parse::<u64>().ok()?
|
||||
// After ") ": state, ppid, ..., starttime — starttime is the 20th
|
||||
// post-comm field (index 19).
|
||||
stat[stat_end + 1..]
|
||||
.split_whitespace()
|
||||
.nth(19)?
|
||||
.parse::<u64>()
|
||||
.ok()?
|
||||
} else {
|
||||
0
|
||||
};
|
||||
@@ -954,6 +1071,9 @@ fn collect_process_info_from_proc(
|
||||
.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,
|
||||
@@ -969,8 +1089,8 @@ fn collect_process_info_from_proc(
|
||||
user_id: uid,
|
||||
group_id: gid,
|
||||
start_time,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
cpu_time_user,
|
||||
cpu_time_system,
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
@@ -1059,22 +1179,24 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
// Read thread stat for CPU times and status
|
||||
// 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;
|
||||
};
|
||||
|
||||
// Parse stat file (similar format to process stat)
|
||||
// Fields: pid comm state ... utime stime ...
|
||||
let fields: Vec<&str> = stat_content.split_whitespace().collect();
|
||||
if fields.len() < 15 {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Field 2 is state (R, S, D, Z, T, etc.)
|
||||
let status = fields
|
||||
.get(2)
|
||||
};
|
||||
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",
|
||||
@@ -1089,16 +1211,9 @@ fn collect_thread_info(pid: u32) -> Vec<crate::types::ThreadInfo> {
|
||||
.unwrap_or("Unknown")
|
||||
.to_string();
|
||||
|
||||
// Field 13 is utime (user CPU time in clock ticks)
|
||||
// Field 14 is stime (system CPU time in clock ticks)
|
||||
let utime = fields
|
||||
.get(13)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
let stime = fields
|
||||
.get(14)
|
||||
.and_then(|s| s.parse::<u64>().ok())
|
||||
.unwrap_or(0);
|
||||
// 10 fields between state and utime (ppid..cmajflt).
|
||||
let utime = it.nth(10).and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
let stime = it.next().and_then(|s| s.parse::<u64>().ok()).unwrap_or(0);
|
||||
|
||||
// Convert clock ticks to microseconds (assuming 100 Hz)
|
||||
// 1 tick = 10ms = 10,000 microseconds
|
||||
@@ -1167,34 +1282,13 @@ pub async fn collect_process_metrics(
|
||||
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
|
||||
// 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) =
|
||||
if let Ok(status_content) = std::fs::read_to_string(format!("/proc/{pid}/status")) {
|
||||
let mut uid = 0u32;
|
||||
let mut gid = 0u32;
|
||||
|
||||
for line in status_content.lines() {
|
||||
if let Some(value) = line.strip_prefix("Uid:") {
|
||||
// Uid line format: "Uid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
||||
// We want the real UID (first value)
|
||||
if let Some(uid_str) = value.split_whitespace().next() {
|
||||
uid = uid_str.parse().unwrap_or(0);
|
||||
}
|
||||
} else if let Some(value) = line.strip_prefix("Gid:") {
|
||||
// Gid line format: "Gid: 1000 1000 1000 1000" (real, effective, saved, filesystem)
|
||||
// We want the real GID (first value)
|
||||
if let Some(gid_str) = value.split_whitespace().next() {
|
||||
gid = gid_str.parse().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(uid, gid)
|
||||
} else {
|
||||
// Fallback if /proc read fails (permission issue)
|
||||
(0, 0)
|
||||
};
|
||||
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);
|
||||
@@ -1248,6 +1342,9 @@ pub async fn collect_process_metrics(
|
||||
// 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,
|
||||
@@ -1264,8 +1361,8 @@ pub async fn collect_process_metrics(
|
||||
user_id,
|
||||
group_id,
|
||||
start_time,
|
||||
cpu_time_user: get_cpu_time_user(pid),
|
||||
cpu_time_system: get_cpu_time_system(pid),
|
||||
cpu_time_user,
|
||||
cpu_time_system,
|
||||
read_bytes,
|
||||
write_bytes,
|
||||
working_directory,
|
||||
|
||||
@@ -17,6 +17,10 @@ pub type SharedNetworks = Arc<Mutex<Networks>>;
|
||||
pub struct ProcCpuTracker {
|
||||
pub last_total: u64,
|
||||
pub last_per_pid: HashMap<u32, u64>,
|
||||
/// PID → process name cache. Mirrors the non-Linux `ProcessCache.names`.
|
||||
/// On a Pi with ~150-300 mostly-stable processes this avoids re-allocating
|
||||
/// the same `String`s on every processes poll (~once per 1.5s).
|
||||
pub names: HashMap<u32, String>,
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
|
||||
+28
-19
@@ -69,12 +69,12 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
Message::Text(ref text) if text == "get_processes" => {
|
||||
let payload = collect_processes_all(&state).await;
|
||||
|
||||
// Map to protobuf message
|
||||
// Get cached buffers
|
||||
// Get cached buffers. The Vec capacity is preserved across
|
||||
// calls (with_capacity(512) seeds it, then we swap-back after
|
||||
// encode so the allocation outlives any single request).
|
||||
let cache = COMPRESSION_CACHE.get_or_init(|| Mutex::new(CompressionCache::new()));
|
||||
let mut cache = cache.lock().await;
|
||||
|
||||
// Reuse process vector to build the list
|
||||
cache.processes_vec.clear();
|
||||
cache
|
||||
.processes_vec
|
||||
@@ -85,29 +85,38 @@ async fn handle_socket(mut socket: WebSocket, state: AppState) {
|
||||
mem_bytes: p.mem_bytes,
|
||||
}));
|
||||
|
||||
let pb = pb::Processes {
|
||||
// Move the populated Vec into the proto, encode, then move it
|
||||
// BACK into the cache so the next call reuses the same heap
|
||||
// allocation. The previous code did `mem::take(...)` here but
|
||||
// then dropped `pb` (and the Vec along with it), leaving the
|
||||
// cache holding an empty zero-capacity Vec — defeating the
|
||||
// whole point of `with_capacity(512)`.
|
||||
let mut pb = pb::Processes {
|
||||
process_count: payload.process_count as u64,
|
||||
rows: std::mem::take(&mut cache.processes_vec),
|
||||
};
|
||||
|
||||
let mut buf = Vec::with_capacity(8 * 1024);
|
||||
if prost::Message::encode(&pb, &mut buf).is_err() {
|
||||
let encode_result = prost::Message::encode(&pb, &mut buf);
|
||||
// Restore the (now-encoded-from) Vec to the cache before pb is
|
||||
// dropped. We `take` it out of pb to leave that field empty,
|
||||
// and the next request will `.clear()` before refilling.
|
||||
cache.processes_vec = std::mem::take(&mut pb.rows);
|
||||
|
||||
if encode_result.is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
} else if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// compress if large
|
||||
if buf.len() <= COMPRESSION_THRESHOLD {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
} else {
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
// Create a new encoder for each message to ensure proper gzip headers
|
||||
let mut encoder =
|
||||
GzEncoder::new(Vec::with_capacity(buf.len()), Compression::fast());
|
||||
match encoder.write_all(&buf).and_then(|_| encoder.finish()) {
|
||||
Ok(compressed) => {
|
||||
let _ = socket.send(Message::Binary(compressed)).await;
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = socket.send(Message::Binary(buf)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user