Skip to main content

rightkit_qa/
frametrace.rs

1//! Owned PresentMon frame-timing sessions (#433): start a trace for one process, confirm it is
2//! recording, stop it and summarise the frame times. PresentMon is an external Windows tool; the
3//! caller supplies its path. The stats come from its CSV output (`MsBetweenPresents` and, when
4//! present, `Dropped`). A trace that is dropped unstopped kills its PresentMon child.
5use 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/// The process to trace.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum Target {
14    /// By process id (`-process_id`).
15    Pid(u32),
16    /// By executable name, for example `CutRight.exe` (`-process_name`).
17    Name(String),
18}
19
20/// Frame timing of one trace. Percentiles are nearest-rank over the frame intervals in ms.
21#[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    /// Presents PresentMon marked as dropped (`Dropped` column is `1` or `true`).
28    pub drops: u64,
29}
30
31/// A running PresentMon session. Call [`FrameTrace::stop`] to end it and read the stats.
32pub struct FrameTrace {
33    child: Option<Child>,
34    output: PathBuf,
35}
36
37/// Start PresentMon for `target`, writing its CSV to `output`. The arguments are PresentMon 1.x's
38/// command line (`-process_id` or `-process_name`, `-output_file`, `-no_top`,
39/// `-stop_existing_session`).
40pub 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); // CREATE_NO_WINDOW: PresentMon writes its CSV; never flash a console
50    }
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    /// Wait up to `wait` for the session to be recording: the PresentMon process is still alive
77    /// and its CSV has started. Fails when it exits early or writes nothing in time.
78    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    /// Stop the session and summarise its CSV.
107    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
126/// Nearest-rank percentile of an ascending slice (`0.0` when empty).
127fn 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
135/// Frame statistics from PresentMon CSV text. Rows whose `MsBetweenPresents` does not parse are
136/// skipped. Fails when the header has no `MsBetweenPresents` column.
137pub 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}