1use std::time::Duration;
13
14use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
15
16use dev_report::{CheckResult, Evidence, Severity};
17
18#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct SystemStats {
24 pub rss_bytes: u64,
26 pub cpu_time: Duration,
28}
29
30pub struct SystemSampler {
46 sys: System,
47 pid: Pid,
48}
49
50impl SystemSampler {
51 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 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 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 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}