Skip to main content

krabka_parse_benches/
executables.rs

1//! Which compiled benchmark executables a measurement pass ran.
2//!
3//! The nightly criterion lane measures a reference and a candidate revision on
4//! the same host. Cargo decides freshness per target directory, so when both
5//! revisions share one, a candidate that differs only in source can be judged
6//! fresh and the reference's binary runs again under the candidate's name. The
7//! lane therefore builds each revision into its own target directory before it
8//! measures either, and this module keeps the evidence:
9//!
10//! - [`manifest_from_cargo_messages`] records each bench executable that
11//!   `cargo bench --no-run --message-format=json` built, by canonical path and
12//!   SHA-256, in an [`ExecutableManifest`] for one commit.
13//! - [`verify_pass`] reads the `Running` lines Cargo printed during one pass,
14//!   and accepts the pass only when every executable it ran is in the manifest
15//!   and still has the recorded digest.
16//! - [`compare_summaries`](crate::compare_summaries) refuses a comparison whose
17//!   reference and candidate share an executable path, or whose passes on one
18//!   side ran different executables.
19
20use {
21    crate::ParseBenchesError,
22    serde::{Deserialize, Serialize},
23    sha2::{Digest, Sha256},
24    std::{
25        fs,
26        path::{Path, PathBuf},
27    },
28};
29
30/// One compiled benchmark executable.
31#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
32pub struct BenchExecutable {
33    /// The Cargo bench target name, such as `log`.
34    pub target: String,
35    /// The canonical path of the executable.
36    pub path: PathBuf,
37    /// The SHA-256 of its bytes, in lowercase hex.
38    pub sha256: String,
39}
40
41/// The bench executables that one revision was built into.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43pub struct ExecutableManifest {
44    /// The commit the executables were built from.
45    pub commit: String,
46    /// The executables, sorted by target name and path.
47    pub executables: Vec<BenchExecutable>,
48}
49
50/// The fields of one Cargo JSON message that identify a bench executable.
51#[derive(Deserialize)]
52struct CargoMessage {
53    reason: String,
54    #[serde(default)]
55    target: Option<CargoTarget>,
56    #[serde(default)]
57    executable: Option<PathBuf>,
58}
59
60#[derive(Deserialize)]
61struct CargoTarget {
62    name: String,
63    kind: Vec<String>,
64}
65
66/// The SHA-256 of the file at `path`, in lowercase hex.
67///
68/// # Errors
69///
70/// Returns [`ParseBenchesError::Io`] when the file cannot be read.
71pub fn file_digest(path: &Path) -> Result<String, ParseBenchesError> {
72    Ok(hex::encode(Sha256::digest(fs::read(path)?)))
73}
74
75/// Record the bench executables in Cargo's `--message-format=json` output.
76///
77/// Every `compiler-artifact` message for a `bench` target that names an
78/// executable contributes one entry, with its path canonicalized and its
79/// current bytes digested.
80///
81/// # Errors
82///
83/// Returns [`ParseBenchesError::Json`] for a line that is not a Cargo JSON
84/// message, [`ParseBenchesError::Io`] for an executable that cannot be read,
85/// and [`ParseBenchesError::NoBenchExecutables`] when no bench executable was
86/// built.
87pub fn manifest_from_cargo_messages(
88    messages: &str,
89    commit: &str,
90) -> Result<ExecutableManifest, ParseBenchesError> {
91    let mut executables = Vec::new();
92    for line in messages.lines().filter(|line| !line.trim().is_empty()) {
93        let message: CargoMessage = serde_json::from_str(line)?;
94        let (Some(target), Some(executable)) = (message.target, message.executable) else {
95            continue;
96        };
97        if message.reason != "compiler-artifact" || !target.kind.iter().any(|kind| kind == "bench")
98        {
99            continue;
100        }
101        let path = fs::canonicalize(&executable)?;
102        executables.push(BenchExecutable {
103            target: target.name,
104            sha256: file_digest(&path)?,
105            path,
106        });
107    }
108    if executables.is_empty() {
109        return Err(ParseBenchesError::NoBenchExecutables);
110    }
111    executables.sort();
112    executables.dedup();
113    Ok(ExecutableManifest {
114        commit: commit.to_owned(),
115        executables,
116    })
117}
118
119/// The executables Cargo reports running in `cargo_log`, resolved against
120/// `source_dir`.
121///
122/// Cargo prints `Running benches/log.rs (target/release/deps/log-0123)` to
123/// standard error before it starts each bench executable. The path is relative
124/// to the directory Cargo ran in when the target directory is below it, and
125/// absolute otherwise. With `CARGO_TERM_COLOR=always` Cargo wraps the
126/// `Running` label in ANSI colour sequences, which are removed before the
127/// line is read.
128#[must_use]
129pub fn ran_executables(cargo_log: &str, source_dir: &Path) -> Vec<PathBuf> {
130    cargo_log
131        .lines()
132        .filter_map(|line| {
133            let line = strip_ansi_csi(line);
134            let rest = line.trim().strip_prefix("Running ")?;
135            let inner = rest.strip_suffix(')')?;
136            let (_, path) = inner.rsplit_once(" (")?;
137            Some(source_dir.join(path))
138        })
139        .collect()
140}
141
142/// `line` without its ANSI control sequences: `ESC [`, any parameter and
143/// intermediate bytes, and the final byte in `@` to `~`.
144fn strip_ansi_csi(line: &str) -> String {
145    let mut plain = String::with_capacity(line.len());
146    let mut chars = line.chars().peekable();
147    while let Some(c) = chars.next() {
148        if c == '\x1b' && chars.peek() == Some(&'[') {
149            chars.next();
150            for c in chars.by_ref() {
151                if ('@'..='~').contains(&c) {
152                    break;
153                }
154            }
155        } else {
156            plain.push(c);
157        }
158    }
159    plain
160}
161
162/// Check that one measurement pass ran only the manifest's executables,
163/// unchanged, and return the executables it ran.
164///
165/// # Errors
166///
167/// Returns [`ParseBenchesError::CommitMismatch`] when the manifest was built
168/// from another commit, [`ParseBenchesError::NoExecutableRan`] when the log
169/// names no executable, [`ParseBenchesError::UnrecordedExecutable`] when the
170/// pass ran an executable the manifest does not list, and
171/// [`ParseBenchesError::ExecutableChanged`] when a listed executable's bytes
172/// no longer match its digest.
173pub fn verify_pass(
174    manifest: &ExecutableManifest,
175    cargo_log: &str,
176    source_dir: &Path,
177    commit: &str,
178) -> Result<Vec<BenchExecutable>, ParseBenchesError> {
179    if manifest.commit != commit {
180        return Err(ParseBenchesError::CommitMismatch {
181            manifest: manifest.commit.clone(),
182            pass: commit.to_owned(),
183        });
184    }
185    let ran = ran_executables(cargo_log, source_dir);
186    if ran.is_empty() {
187        return Err(ParseBenchesError::NoExecutableRan);
188    }
189    let mut verified = Vec::new();
190    for path in ran {
191        let canonical = fs::canonicalize(&path)
192            .map_err(|_| ParseBenchesError::UnrecordedExecutable(path.clone()))?;
193        let recorded = manifest
194            .executables
195            .iter()
196            .find(|executable| executable.path == canonical)
197            .ok_or_else(|| ParseBenchesError::UnrecordedExecutable(canonical.clone()))?;
198        if file_digest(&canonical)? != recorded.sha256 {
199            return Err(ParseBenchesError::ExecutableChanged(canonical));
200        }
201        verified.push(recorded.clone());
202    }
203    verified.sort();
204    verified.dedup();
205    Ok(verified)
206}