use crate::settings::Settings;
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::process::{Command, Stdio};
use std::time::Instant;
#[derive(Debug, Clone)]
pub struct Plan {
pub cmd: Vec<String>,
pub warmup: u32,
pub runs: u32,
pub dir: Option<std::path::PathBuf>,
pub settings: Settings,
}
fn subject(bin: &str, settings: &Settings) -> Command {
let mut c = Command::new(bin);
for key in settings.scrubbed_env() {
c.env_remove(key);
}
c
}
fn time_once(cmd: &[String], dir: Option<&std::path::Path>, settings: &Settings) -> Result<f64> {
let (bin, args) = cmd.split_first().context("empty command")?;
let mut c = subject(bin, settings);
c.args(args).stdout(Stdio::null()).stderr(Stdio::null());
if let Some(d) = dir {
c.current_dir(d);
}
let start = Instant::now();
let status = c
.status()
.with_context(|| format!("failed to spawn `{bin}`"))?;
let elapsed = start.elapsed().as_secs_f64() * 1000.0;
let _ = status;
Ok(elapsed)
}
pub fn wall(plan: &Plan) -> Result<BTreeMap<String, f64>> {
if plan.runs == 0 {
bail!("runs must be at least 1");
}
let dir = plan.dir.as_deref();
for _ in 0..plan.warmup {
time_once(&plan.cmd, dir, &plan.settings)?;
}
let mut samples = Vec::with_capacity(plan.runs as usize);
for _ in 0..plan.runs {
samples.push(time_once(&plan.cmd, dir, &plan.settings)?);
}
samples.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = samples.len();
let mean = samples.iter().sum::<f64>() / n as f64;
let p50 = samples[n / 2];
Ok(BTreeMap::from([
("wall_min_ms".to_string(), samples[0]),
("wall_p50_ms".to_string(), p50),
("wall_mean_ms".to_string(), mean),
("wall_max_ms".to_string(), samples[n - 1]),
("wall_n".to_string(), n as f64),
]))
}
pub fn valgrind_available() -> bool {
Command::new("valgrind")
.arg("--version")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
const COUNTER_RUNS: u32 = 3;
const SPREAD_WARN_PCT: f64 = 0.5;
#[derive(Debug, Clone, Copy)]
pub struct Counted {
pub min: u64,
pub max: u64,
pub runs: u32,
}
impl Counted {
pub fn spread_pct(&self) -> f64 {
if self.min == 0 {
return 0.0;
}
(self.max - self.min) as f64 / self.min as f64 * 100.0
}
pub fn is_suspect(&self) -> bool {
self.spread_pct() > SPREAD_WARN_PCT
}
}
pub fn instructions(
cmd: &[String],
dir: Option<&std::path::Path>,
settings: &Settings,
) -> Result<Option<Counted>> {
if !valgrind_available() {
return Ok(None);
}
let mut samples: Vec<u64> = Vec::with_capacity(COUNTER_RUNS as usize);
for _ in 0..COUNTER_RUNS {
let mut c = subject("valgrind", settings);
c.args([
"--tool=cachegrind",
"--cache-sim=no",
"--branch-sim=no",
"--cachegrind-out-file=/dev/null",
])
.args(cmd)
.stdout(Stdio::null());
if let Some(d) = dir {
c.current_dir(d);
}
let out = c.output().context("failed to run valgrind")?;
let stderr = String::from_utf8_lossy(&out.stderr);
match parse_irefs(&stderr) {
Some(n) => samples.push(n),
None => bail!(
"valgrind ran but emitted no `I refs` summary: {}",
stderr.lines().last().unwrap_or("(no output)").trim()
),
}
}
Ok(Some(Counted {
min: *samples.iter().min().expect("COUNTER_RUNS > 0"),
max: *samples.iter().max().expect("COUNTER_RUNS > 0"),
runs: COUNTER_RUNS,
}))
}
fn parse_irefs(stderr: &str) -> Option<u64> {
let line = stderr.lines().find(|l| l.contains("I refs:"))?;
let digits: String = line
.rsplit(':')
.next()?
.chars()
.filter(|c| c.is_ascii_digit())
.collect();
digits.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_cachegrind_summary() {
let s = "==12== I refs: 48,349,132\n";
assert_eq!(parse_irefs(s), Some(48_349_132));
}
#[test]
fn missing_summary_is_none_not_panic() {
assert_eq!(parse_irefs("valgrind: command not found"), None);
}
fn removals(settings: &Settings) -> Vec<String> {
subject("true", settings)
.get_envs()
.filter(|(_, v)| v.is_none())
.map(|(k, _)| k.to_string_lossy().into_owned())
.collect()
}
#[test]
fn subject_commands_remove_the_default_denied_variables() {
let removed = removals(&Settings::default());
for key in Settings::default().scrubbed_env() {
assert!(removed.contains(&key.to_string()), "{key} not removed");
}
}
#[test]
fn the_removal_follows_the_settings() {
let removed = removals(&Settings {
env_deny: vec!["CUSTOM_SECRET".into()],
env_allow: Vec::new(),
});
assert!(removed.contains(&"CUSTOM_SECRET".to_string()));
assert!(!removed.contains(&"GITHUB_TOKEN".to_string()));
}
#[test]
fn an_allowed_variable_survives() {
let removed = removals(&Settings {
env_deny: vec!["GITHUB_TOKEN".into(), "GH_TOKEN".into()],
env_allow: vec!["GITHUB_TOKEN".into()],
});
assert!(!removed.contains(&"GITHUB_TOKEN".to_string()));
assert!(removed.contains(&"GH_TOKEN".to_string()));
}
#[test]
fn zero_runs_is_rejected_not_a_panic() {
let plan = Plan {
cmd: vec!["true".into()],
warmup: 0,
runs: 0,
dir: None,
settings: Settings::default(),
};
assert!(wall(&plan).is_err());
}
#[test]
fn wall_reports_min_le_p50_le_max() {
let plan = Plan {
cmd: vec!["true".into()],
warmup: 1,
runs: 5,
dir: None,
settings: Settings::default(),
};
let m = wall(&plan).unwrap();
assert!(m["wall_min_ms"] <= m["wall_p50_ms"]);
assert!(m["wall_p50_ms"] <= m["wall_max_ms"]);
assert_eq!(m["wall_n"], 5.0);
}
}