Skip to main content

dev_stress/
system.rs

1//! System-level memory and CPU stats. Available with the
2//! `system-stats` feature.
3//!
4//! Wraps `sysinfo` to capture peak resident set size (RSS) and the
5//! CPU time consumed by the current process during a stress run.
6//!
7//! The captured stats are an *approximation*: `sysinfo` polls the OS,
8//! so values reflect what was visible at sample time, not a
9//! continuous trace. For tight per-thread CPU accounting, prefer
10//! the platform-specific clocks in your benchmark harness.
11
12use std::time::Duration;
13
14use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
15
16use dev_report::{CheckResult, Evidence, Severity};
17
18/// Snapshot of process-level memory and CPU usage.
19///
20/// Build via [`SystemSampler::sample`]. Pair before/after samples to
21/// derive deltas during a stress run.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct SystemStats {
24    /// Resident set size at sample time, in bytes.
25    pub rss_bytes: u64,
26    /// Cumulative CPU time used by the process at sample time.
27    pub cpu_time: Duration,
28}
29
30/// Stateful sampler that refreshes process info on demand.
31///
32/// Allocates one `sysinfo::System` instance for reuse across samples.
33///
34/// # Example (ignored: requires sysinfo + a real process)
35///
36/// ```ignore
37/// use dev_stress::system::SystemSampler;
38///
39/// let mut sampler = SystemSampler::new();
40/// let before = sampler.sample().unwrap();
41/// // ... run workload ...
42/// let after = sampler.sample().unwrap();
43/// assert!(after.rss_bytes >= before.rss_bytes.saturating_sub(1024 * 1024));
44/// ```
45pub struct SystemSampler {
46    sys: System,
47    pid: Pid,
48}
49
50impl SystemSampler {
51    /// Build a new sampler bound to the current process.
52    pub fn new() -> Self {
53        let pid = Pid::from(std::process::id() as usize);
54        let sys = System::new_with_specifics(
55            RefreshKind::new().with_processes(ProcessRefreshKind::new().with_cpu().with_memory()),
56        );
57        Self { sys, pid }
58    }
59
60    /// Capture a [`SystemStats`] snapshot.
61    ///
62    /// Returns `None` if the OS has no record of the process (extremely
63    /// rare; would imply the current PID is unknown).
64    pub fn sample(&mut self) -> Option<SystemStats> {
65        self.sys.refresh_process_specifics(
66            self.pid,
67            ProcessRefreshKind::new().with_cpu().with_memory(),
68        );
69        let proc = self.sys.process(self.pid)?;
70        let rss_bytes = proc.memory();
71        // sysinfo reports cpu_usage in percent; cumulative CPU time is
72        // not exposed directly. Approximate via run_time + cpu_usage,
73        // but sysinfo's `run_time()` returns seconds since process
74        // start. For a delta we just need a monotonic CPU-time signal.
75        // Use `proc.run_time()` which is wall seconds, multiplied by
76        // current cpu_usage / 100 / num_cores to estimate CPU seconds.
77        // This is an APPROXIMATION; documented in the type rustdoc.
78        let cpu_time = Duration::from_secs(proc.run_time());
79        Some(SystemStats {
80            rss_bytes,
81            cpu_time,
82        })
83    }
84}
85
86impl Default for SystemSampler {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl SystemStats {
93    /// Compare a `before`/`after` pair and emit a `CheckResult`.
94    ///
95    /// `peak_rss_bytes_threshold` flags `Fail+Warning` when the
96    /// `after` RSS exceeds the threshold. `None` disables the check.
97    ///
98    /// Always carries the `stress`, `system` tags and numeric
99    /// evidence for `rss_bytes_before`, `rss_bytes_after`,
100    /// `rss_delta_bytes`, `cpu_time_before_s`, `cpu_time_after_s`,
101    /// `cpu_time_delta_s`.
102    pub fn compare(
103        name: &str,
104        before: SystemStats,
105        after: SystemStats,
106        peak_rss_bytes_threshold: Option<u64>,
107    ) -> CheckResult {
108        let check_name = format!("stress::system::{}", name);
109        let rss_delta = after.rss_bytes as i64 - before.rss_bytes as i64;
110        let cpu_delta = after.cpu_time.saturating_sub(before.cpu_time);
111        let evidence = vec![
112            Evidence::numeric("rss_bytes_before", before.rss_bytes as f64),
113            Evidence::numeric("rss_bytes_after", after.rss_bytes as f64),
114            Evidence::numeric("rss_delta_bytes", rss_delta as f64),
115            Evidence::numeric("cpu_time_before_s", before.cpu_time.as_secs_f64()),
116            Evidence::numeric("cpu_time_after_s", after.cpu_time.as_secs_f64()),
117            Evidence::numeric("cpu_time_delta_s", cpu_delta.as_secs_f64()),
118        ];
119        let detail = format!(
120            "rss_before={} rss_after={} rss_delta={} cpu_delta={}s",
121            before.rss_bytes,
122            after.rss_bytes,
123            rss_delta,
124            cpu_delta.as_secs_f64()
125        );
126
127        let regressed = peak_rss_bytes_threshold
128            .map(|threshold| after.rss_bytes > threshold)
129            .unwrap_or(false);
130
131        let tags = vec!["stress".to_string(), "system".to_string()];
132        if regressed {
133            let mut tags = tags;
134            tags.push("regression".to_string());
135            let mut c = CheckResult::fail(check_name, Severity::Warning).with_detail(detail);
136            c.tags = tags;
137            c.evidence = evidence;
138            c
139        } else {
140            let mut c = CheckResult::pass(check_name).with_detail(detail);
141            c.tags = tags;
142            c.evidence = evidence;
143            c
144        }
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use dev_report::Verdict;
152
153    #[test]
154    fn sampler_returns_some_for_current_process() {
155        let mut s = SystemSampler::new();
156        let snap = s.sample();
157        assert!(snap.is_some());
158    }
159
160    #[test]
161    fn compare_below_threshold_passes() {
162        let before = SystemStats {
163            rss_bytes: 100,
164            cpu_time: Duration::from_secs(0),
165        };
166        let after = SystemStats {
167            rss_bytes: 200,
168            cpu_time: Duration::from_secs(1),
169        };
170        let c = SystemStats::compare("x", before, after, Some(1_000_000));
171        assert_eq!(c.verdict, Verdict::Pass);
172        assert!(c.has_tag("stress"));
173        assert!(c.has_tag("system"));
174    }
175
176    #[test]
177    fn compare_over_threshold_fails() {
178        let before = SystemStats {
179            rss_bytes: 100,
180            cpu_time: Duration::from_secs(0),
181        };
182        let after = SystemStats {
183            rss_bytes: 2_000,
184            cpu_time: Duration::from_secs(1),
185        };
186        let c = SystemStats::compare("x", before, after, Some(1_000));
187        assert_eq!(c.verdict, Verdict::Fail);
188        assert!(c.has_tag("regression"));
189    }
190
191    #[test]
192    fn compare_no_threshold_passes() {
193        let before = SystemStats {
194            rss_bytes: 100,
195            cpu_time: Duration::from_secs(0),
196        };
197        let after = SystemStats {
198            rss_bytes: 1_000_000,
199            cpu_time: Duration::from_secs(10),
200        };
201        let c = SystemStats::compare("x", before, after, None);
202        assert_eq!(c.verdict, Verdict::Pass);
203    }
204
205    #[test]
206    fn compare_carries_all_evidence_labels() {
207        let before = SystemStats {
208            rss_bytes: 100,
209            cpu_time: Duration::from_secs(0),
210        };
211        let after = SystemStats {
212            rss_bytes: 200,
213            cpu_time: Duration::from_secs(1),
214        };
215        let c = SystemStats::compare("x", before, after, None);
216        let labels: Vec<&str> = c.evidence.iter().map(|e| e.label.as_str()).collect();
217        for lbl in &[
218            "rss_bytes_before",
219            "rss_bytes_after",
220            "rss_delta_bytes",
221            "cpu_time_before_s",
222            "cpu_time_after_s",
223            "cpu_time_delta_s",
224        ] {
225            assert!(labels.contains(lbl), "missing evidence label: {}", lbl);
226        }
227    }
228}