rightkit_qa/
frametrace.rs1use crate::util::{err, Error, Result};
6use serde::Serialize;
7use std::path::{Path, PathBuf};
8use std::process::{Child, Command, Stdio};
9use std::time::{Duration, Instant};
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Target {
14 Pid(u32),
16 Name(String),
18}
19
20#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct FrameStats {
23 pub present_count: u64,
24 pub p50_ms: f64,
25 pub p95_ms: f64,
26 pub p99_ms: f64,
27 pub drops: u64,
29}
30
31pub struct FrameTrace {
33 child: Option<Child>,
34 output: PathBuf,
35}
36
37pub fn start(presentmon: &Path, target: &Target, output: &Path) -> Result<FrameTrace> {
41 if let Some(dir) = output.parent() {
42 std::fs::create_dir_all(dir)?;
43 }
44 let _ = std::fs::remove_file(output);
45 let mut cmd = Command::new(presentmon);
46 #[cfg(windows)]
47 {
48 use std::os::windows::process::CommandExt;
49 cmd.creation_flags(0x0800_0000); }
51 match target {
52 Target::Pid(pid) => {
53 cmd.arg("-process_id").arg(pid.to_string());
54 }
55 Target::Name(name) => {
56 cmd.arg("-process_name").arg(name);
57 }
58 }
59 cmd.arg("-output_file")
60 .arg(output)
61 .arg("-no_top")
62 .arg("-stop_existing_session")
63 .stdin(Stdio::null())
64 .stdout(Stdio::null())
65 .stderr(Stdio::null());
66 let child = cmd
67 .spawn()
68 .map_err(|e| Error(format!("cannot start {}: {e}", presentmon.display())))?;
69 Ok(FrameTrace {
70 child: Some(child),
71 output: output.to_path_buf(),
72 })
73}
74
75impl FrameTrace {
76 pub fn confirm_running(&mut self, wait: Duration) -> Result<()> {
79 let deadline = Instant::now() + wait;
80 loop {
81 let child = self
82 .child
83 .as_mut()
84 .ok_or_else(|| Error("frame trace already stopped".into()))?;
85 if let Some(status) = child.try_wait()? {
86 return err(format!("PresentMon exited early ({status})"));
87 }
88 if std::fs::metadata(&self.output)
89 .map(|m| m.len())
90 .unwrap_or(0)
91 > 0
92 {
93 return Ok(());
94 }
95 if Instant::now() >= deadline {
96 return err(format!(
97 "PresentMon wrote nothing to {} within {}ms",
98 self.output.display(),
99 wait.as_millis()
100 ));
101 }
102 std::thread::sleep(Duration::from_millis(100));
103 }
104 }
105
106 pub fn stop(mut self) -> Result<FrameStats> {
108 if let Some(mut child) = self.child.take() {
109 let _ = child.kill();
110 let _ = child.wait();
111 }
112 let text = std::fs::read_to_string(&self.output)?;
113 parse_stats(&text)
114 }
115}
116
117impl Drop for FrameTrace {
118 fn drop(&mut self) {
119 if let Some(mut child) = self.child.take() {
120 let _ = child.kill();
121 let _ = child.wait();
122 }
123 }
124}
125
126fn percentile(sorted: &[f64], p: f64) -> f64 {
128 if sorted.is_empty() {
129 return 0.0;
130 }
131 let rank = ((p / 100.0) * sorted.len() as f64).ceil() as usize;
132 sorted[rank.clamp(1, sorted.len()) - 1]
133}
134
135pub fn parse_stats(csv: &str) -> Result<FrameStats> {
138 let mut lines = csv.lines();
139 let header: Vec<&str> = lines
140 .next()
141 .unwrap_or("")
142 .split(',')
143 .map(str::trim)
144 .collect();
145 let ms_col = header
146 .iter()
147 .position(|h| *h == "MsBetweenPresents")
148 .ok_or_else(|| Error("no MsBetweenPresents column in the PresentMon CSV".into()))?;
149 let drop_col = header.iter().position(|h| *h == "Dropped");
150 let mut ms: Vec<f64> = Vec::new();
151 let mut drops = 0u64;
152 for line in lines {
153 let cols: Vec<&str> = line.split(',').map(str::trim).collect();
154 let Some(v) = cols.get(ms_col).and_then(|s| s.parse::<f64>().ok()) else {
155 continue;
156 };
157 ms.push(v);
158 if drop_col
159 .and_then(|c| cols.get(c))
160 .is_some_and(|s| *s == "1" || s.eq_ignore_ascii_case("true"))
161 {
162 drops += 1;
163 }
164 }
165 ms.sort_by(|a, b| a.total_cmp(b));
166 Ok(FrameStats {
167 present_count: ms.len() as u64,
168 p50_ms: percentile(&ms, 50.0),
169 p95_ms: percentile(&ms, 95.0),
170 p99_ms: percentile(&ms, 99.0),
171 drops,
172 })
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
180 fn stats_from_presentmon_csv() {
181 let csv = "Application,MsBetweenPresents,Dropped\napp,16.0,0\napp,17.0,1\napp,33.0,0\n";
182 let s = parse_stats(csv).unwrap();
183 assert_eq!(s.present_count, 3);
184 assert_eq!(s.drops, 1);
185 assert_eq!(s.p50_ms, 17.0);
186 assert_eq!(s.p95_ms, 33.0);
187 assert_eq!(s.p99_ms, 33.0);
188 }
189
190 #[test]
191 fn rows_without_a_number_are_skipped_and_missing_column_is_an_error() {
192 let s = parse_stats("App,MsBetweenPresents\na,10\na,oops\n").unwrap();
193 assert_eq!(s.present_count, 1);
194 assert_eq!(s.drops, 0);
195 assert!(parse_stats("App,Other\na,1\n").is_err());
196 }
197
198 #[test]
199 fn empty_trace_has_zero_stats() {
200 let s = parse_stats("App,MsBetweenPresents\n").unwrap();
201 assert_eq!((s.present_count, s.p50_ms, s.drops), (0, 0.0, 0));
202 }
203}