Skip to main content

forge/profile/
monitor.rs

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    // Take the pipes before the loop; wait_with_output() after try_wait() would re-wait on a reaped process.
93    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        // #t(rust_forbidden_deps) CLI-only binary, never compiled to WASM
109        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    // #t(rust_magic_numbers) ps output has at least 2 columns: %cpu and rss
198    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    // #t(rust_magic_numbers) ps output has 3 columns on Linux: %cpu, rss, nlwp
205    let threads: u32 = if parts.len() >= 3 {
206        // #t(rust_magic_numbers) third column (index 2) is thread count (nlwp)
207        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}