Skip to main content

zeroclaw_runtime/
process_stats.rs

1//! Self-process resource sampling — RSS (resident memory) and CPU%.
2//!
3//! Linux-only via `/proc/self/{status,stat}` so no extra deps. macOS /
4//! Windows return `ProcessStats::unsupported()` (rss=0, cpu=None); the
5//! dashboard renders the rss tile blank-with-note on those platforms.
6//!
7//! CPU% is computed across calls by stashing the previous (wall_instant,
8//! process_ticks) sample in a process-global `OnceLock<Mutex<...>>` and
9//! taking the delta. First call returns `cpu_percent = None` since
10//! there's no baseline yet; the first refresh after gateway boot fills
11//! it in.
12
13#[cfg(target_os = "linux")]
14use parking_lot::Mutex;
15use serde::Serialize;
16#[cfg(target_os = "linux")]
17use std::sync::OnceLock;
18#[cfg(target_os = "linux")]
19use std::time::Instant;
20
21#[derive(Debug, Clone, Serialize)]
22pub struct ProcessStats {
23    /// Resident set size in bytes. `0` when unsupported.
24    pub rss_bytes: u64,
25    /// Total system RAM in bytes, from `/proc/meminfo`'s `MemTotal`.
26    /// `0` when unsupported. The dashboard renders `rss / system_ram_total`
27    /// as a percentage so the RAM tile is meaningful at a glance regardless
28    /// of host size.
29    pub system_ram_total_bytes: u64,
30    /// CPU usage as a percentage averaged across logical cores (0..100*ncpu).
31    /// `None` on the first sample (no baseline) or unsupported platforms.
32    pub cpu_percent: Option<f32>,
33    /// Number of logical CPUs the OS reports. Useful for clamping the
34    /// CPU% bar on the dashboard. `0` when unknown.
35    pub num_cpus: u32,
36}
37
38impl ProcessStats {
39    fn unsupported() -> Self {
40        Self {
41            rss_bytes: 0,
42            system_ram_total_bytes: 0,
43            cpu_percent: None,
44            num_cpus: 0,
45        }
46    }
47}
48
49#[cfg(target_os = "linux")]
50struct LastSample {
51    wall: Instant,
52    process_ticks: u64,
53}
54
55#[cfg(target_os = "linux")]
56static LAST: OnceLock<Mutex<Option<LastSample>>> = OnceLock::new();
57
58#[cfg(target_os = "linux")]
59fn last() -> &'static Mutex<Option<LastSample>> {
60    LAST.get_or_init(|| Mutex::new(None))
61}
62
63/// Sample current RSS + CPU%. Cheap to call (single /proc read on Linux).
64/// Safe to call from any thread.
65pub fn sample() -> ProcessStats {
66    #[cfg(target_os = "linux")]
67    {
68        sample_linux().unwrap_or_else(ProcessStats::unsupported)
69    }
70    #[cfg(not(target_os = "linux"))]
71    {
72        ProcessStats::unsupported()
73    }
74}
75
76#[cfg(target_os = "linux")]
77fn sample_linux() -> Option<ProcessStats> {
78    let rss_bytes = read_rss_bytes()?;
79    let ticks = read_process_ticks()?;
80    let now = Instant::now();
81    let num_cpus = read_num_cpus();
82    let clock_ticks = clock_ticks_per_sec();
83    let system_ram_total_bytes = read_system_ram_total().unwrap_or(0);
84
85    let mut guard = last().lock();
86    let cpu_percent = if let Some(prev) = guard.as_ref() {
87        let elapsed = now.duration_since(prev.wall).as_secs_f64();
88        if elapsed > 0.0 && clock_ticks > 0 {
89            let dticks = ticks.saturating_sub(prev.process_ticks) as f64;
90            let cpu_seconds = dticks / clock_ticks as f64;
91            Some(((cpu_seconds / elapsed) * 100.0) as f32)
92        } else {
93            None
94        }
95    } else {
96        None
97    };
98    *guard = Some(LastSample {
99        wall: now,
100        process_ticks: ticks,
101    });
102
103    Some(ProcessStats {
104        rss_bytes,
105        system_ram_total_bytes,
106        cpu_percent,
107        num_cpus,
108    })
109}
110
111#[cfg(target_os = "linux")]
112fn read_system_ram_total() -> Option<u64> {
113    let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
114    for line in meminfo.lines() {
115        if let Some(rest) = line.strip_prefix("MemTotal:") {
116            let kb: u64 = rest
117                .split_whitespace()
118                .next()
119                .and_then(|s| s.parse().ok())?;
120            return Some(kb.saturating_mul(1024));
121        }
122    }
123    None
124}
125
126#[cfg(target_os = "linux")]
127fn read_rss_bytes() -> Option<u64> {
128    let status = std::fs::read_to_string("/proc/self/status").ok()?;
129    for line in status.lines() {
130        if let Some(rest) = line.strip_prefix("VmRSS:") {
131            // Format: `VmRSS:    12345 kB`
132            let kb: u64 = rest
133                .split_whitespace()
134                .next()
135                .and_then(|s| s.parse().ok())?;
136            return Some(kb.saturating_mul(1024));
137        }
138    }
139    None
140}
141
142#[cfg(target_os = "linux")]
143fn read_process_ticks() -> Option<u64> {
144    // /proc/self/stat fields are space-delimited but `comm` (field 2) is
145    // parenthesized and may contain spaces, so anchor on the closing `)`
146    // and count from there. Fields after comm: state(3) ppid(4) ...
147    // utime(14) stime(15).
148    let stat = std::fs::read_to_string("/proc/self/stat").ok()?;
149    let close = stat.rfind(')')?;
150    let after: &str = stat[close + 1..].trim_start();
151    let fields: Vec<&str> = after.split_whitespace().collect();
152    // After `comm)`, field indices are 0-based here but correspond to
153    // /proc indices 3..; utime is /proc field 14 → here index 11,
154    // stime is /proc field 15 → here index 12.
155    let utime: u64 = fields.get(11)?.parse().ok()?;
156    let stime: u64 = fields.get(12)?.parse().ok()?;
157    Some(utime + stime)
158}
159
160#[cfg(target_os = "linux")]
161fn clock_ticks_per_sec() -> u64 {
162    // SAFETY: sysconf(_SC_CLK_TCK) is a const POSIX query, no side effects.
163    let v = unsafe { libc::sysconf(libc::_SC_CLK_TCK) };
164    if v > 0 { v as u64 } else { 100 }
165}
166
167#[cfg(target_os = "linux")]
168fn read_num_cpus() -> u32 {
169    // SAFETY: sysconf(_SC_NPROCESSORS_ONLN) is a const POSIX query.
170    let v = unsafe { libc::sysconf(libc::_SC_NPROCESSORS_ONLN) };
171    if v > 0 { v as u32 } else { 0 }
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    #[test]
179    #[cfg(target_os = "linux")]
180    fn sample_returns_rss_on_linux() {
181        let s = sample();
182        assert!(s.rss_bytes > 0, "rss should be non-zero on Linux");
183    }
184
185    #[test]
186    #[cfg(target_os = "linux")]
187    fn sample_returns_system_ram_total_and_rss_is_a_subset() {
188        let s = sample();
189        assert!(
190            s.system_ram_total_bytes > 0,
191            "MemTotal should be non-zero on Linux"
192        );
193        assert!(
194            s.rss_bytes <= s.system_ram_total_bytes,
195            "process RSS ({}) cannot exceed system total ({})",
196            s.rss_bytes,
197            s.system_ram_total_bytes
198        );
199    }
200
201    #[test]
202    #[cfg(target_os = "linux")]
203    fn cpu_percent_filled_on_second_sample() {
204        let _ = sample();
205        std::thread::sleep(std::time::Duration::from_millis(20));
206        for _ in 0..10_000 {
207            std::hint::black_box(0u64);
208        }
209        let s2 = sample();
210        assert!(
211            s2.cpu_percent.is_some(),
212            "second sample should have cpu_percent"
213        );
214    }
215
216    #[test]
217    #[cfg(not(target_os = "linux"))]
218    fn sample_is_unsupported_off_linux() {
219        let s = sample();
220        assert_eq!(s.rss_bytes, 0);
221        assert!(s.cpu_percent.is_none());
222    }
223}