zeroclaw_runtime/
process_stats.rs1#[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 pub rss_bytes: u64,
25 pub system_ram_total_bytes: u64,
30 pub cpu_percent: Option<f32>,
33 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
63pub 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 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 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 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 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 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}