1#![expect(
2 clippy::cast_possible_truncation,
3 clippy::cast_precision_loss,
4 reason = "bounded process sample counts are serialized as u32 and averaged as f64"
5)]
6
7use std::{
8 process::{Command, Stdio},
9 time::{Duration, Instant},
10};
11
12use serde::Serialize;
13use wowlab_common::sys;
14use wowlab_fs::path::Path;
15
16const SAMPLE_INTERVAL: Duration = Duration::from_millis(200);
17const KB_PER_MB: f64 = 1024.0;
18
19#[derive(Clone, Debug, Serialize)]
20pub(super) struct ProcessSample {
21 pub cpu_pct: f64,
22 pub rss_mb: f64,
23 pub threads: u32,
24}
25
26#[derive(Clone, Debug, Default, Serialize)]
27pub(super) struct RuntimeStats {
28 pub sample_count: u32,
29 pub proc_cpu_pct_avg: f64,
30 pub proc_cpu_pct_max: f64,
31 pub rss_mb_avg: f64,
32 pub rss_mb_max: f64,
33 pub threads_avg: f64,
34 pub threads_max: u32,
35}
36
37#[derive(Clone, Debug, Default, Serialize)]
38pub(super) struct HostStats {
39 pub platform: String,
40 pub cpu_model: String,
41 pub physical_cpus: usize,
42 pub logical_cpus: usize,
43 pub memory_gb: f64,
44}
45
46pub(super) fn collect_host_stats() -> HostStats {
47 let (total_mb, _) = sys::read_os_memory_mb();
48
49 HostStats {
50 platform: std::env::consts::OS.to_string(),
51 cpu_model: sys::cpu_model(),
52 physical_cpus: sys::physical_cores(),
53 logical_cpus: sys::logical_cores(),
54 memory_gb: total_mb / KB_PER_MB,
55 }
56}
57
58#[derive(Debug)]
59pub(super) struct MonitoredRun {
60 pub exit_code: i32,
61 pub stdout: String,
62 pub stderr: String,
63 pub duration: Duration,
64 pub stats: RuntimeStats,
65}
66
67pub(super) fn run_monitored(binary: &Path, args: &[String], env_rust_log: &str) -> MonitoredRun {
68 let mut cmd = Command::new(binary);
69
70 cmd.args(args)
71 .env("RUST_LOG", env_rust_log)
72 .stdout(Stdio::piped())
73 .stderr(Stdio::piped());
74
75 let start = Instant::now();
76
77 let mut child = match cmd.spawn() {
78 Ok(c) => c,
79 Err(e) => {
80 return MonitoredRun {
81 exit_code: 1,
82 stdout: String::new(),
83 stderr: format!("failed to spawn: {e}"),
84 duration: Duration::ZERO,
85 stats: RuntimeStats::default(),
86 };
87 }
88 };
89
90 let pid = child.id();
91
92 let child_stdout = child.stdout.take();
94 let child_stderr = child.stderr.take();
95
96 let mut samples = Vec::new();
97
98 loop {
99 match child.try_wait() {
100 Ok(Some(_)) | Err(_) => break,
101 Ok(None) => {}
102 }
103
104 if let Some(sample) = sample_process(pid) {
105 samples.push(sample);
106 }
107
108 std::thread::sleep(SAMPLE_INTERVAL);
110 }
111
112 let duration = start.elapsed();
113
114 let status = child.wait().ok();
115 let exit_code = status.and_then(|s| s.code()).unwrap_or(1);
116
117 let stdout = child_stdout
118 .map(|mut r| {
119 let mut buf = Vec::new();
120 let _ = std::io::Read::read_to_end(&mut r, &mut buf);
121
122 String::from_utf8_lossy(&buf).to_string()
123 })
124 .unwrap_or_default();
125 let stderr = child_stderr
126 .map(|mut r| {
127 let mut buf = Vec::new();
128 let _ = std::io::Read::read_to_end(&mut r, &mut buf);
129
130 String::from_utf8_lossy(&buf).to_string()
131 })
132 .unwrap_or_default();
133
134 MonitoredRun {
135 exit_code,
136 stdout,
137 stderr,
138 duration,
139 stats: aggregate(&samples),
140 }
141}
142
143fn aggregate(samples: &[ProcessSample]) -> RuntimeStats {
144 if samples.is_empty() {
145 return RuntimeStats::default();
146 }
147
148 let n = samples.len() as f64;
149 let cpu_sum: f64 = samples.iter().map(|s| s.cpu_pct).sum();
150 let rss_sum: f64 = samples.iter().map(|s| s.rss_mb).sum();
151 let thread_sum: f64 = samples.iter().map(|s| f64::from(s.threads)).sum();
152
153 RuntimeStats {
154 sample_count: samples.len() as u32,
155 proc_cpu_pct_avg: cpu_sum / n,
156 proc_cpu_pct_max: samples.iter().map(|s| s.cpu_pct).fold(0.0_f64, f64::max),
157 rss_mb_avg: rss_sum / n,
158 rss_mb_max: samples.iter().map(|s| s.rss_mb).fold(0.0_f64, f64::max),
159 threads_avg: thread_sum / n,
160 threads_max: samples.iter().map(|s| s.threads).max().unwrap_or(0),
161 }
162}
163
164fn sample_process(pid: u32) -> Option<ProcessSample> {
165 let output = if cfg!(target_os = "macos") {
166 Command::new("ps")
167 .args(["-p", &pid.to_string(), "-o", "%cpu=", "-o", "rss="])
168 .stdout(Stdio::piped())
169 .stderr(Stdio::null())
170 .output()
171 .ok()?
172 } else {
173 Command::new("ps")
174 .args([
175 "-p",
176 &pid.to_string(),
177 "-o",
178 "%cpu=",
179 "-o",
180 "rss=",
181 "-o",
182 "nlwp=",
183 ])
184 .stdout(Stdio::piped())
185 .stderr(Stdio::null())
186 .output()
187 .ok()?
188 };
189
190 if !output.status.success() {
191 return None;
192 }
193
194 let line = String::from_utf8_lossy(&output.stdout);
195 let parts: Vec<&str> = line.split_whitespace().collect();
196
197 if parts.len() < 2 {
199 return None;
200 }
201
202 let cpu_pct: f64 = parts[0].parse().ok()?;
203 let rss_kb: f64 = parts[1].parse().ok()?;
204 let threads: u32 = if parts.len() >= 3 {
206 parts[2].parse().unwrap_or(0)
208 } else {
209 thread_count_macos(pid)
210 };
211
212 Some(ProcessSample {
213 cpu_pct,
214 rss_mb: rss_kb / KB_PER_MB,
215 threads,
216 })
217}
218
219fn thread_count_macos(pid: u32) -> u32 {
220 if !cfg!(target_os = "macos") {
221 return 0;
222 }
223
224 let output = Command::new("ps")
225 .args(["-M", "-p", &pid.to_string()])
226 .stdout(Stdio::piped())
227 .stderr(Stdio::null())
228 .output()
229 .ok();
230
231 match output {
232 Some(o) if o.status.success() => {
233 let count = String::from_utf8_lossy(&o.stdout).lines().count();
234
235 count.saturating_sub(1) as u32
236 }
237 _ => 0,
238 }
239}