Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ rmp-serde = "1.3.0"
uuid = { version = "1.21.0", features = ["v4"] }
which = "8.0.2"
crc32fast = "1.5.0"
samply = { git = "https://github.com/AvalancheHQ/samply", branch = "codspeed" }
samply = { git = "https://github.com/CodSpeedHQ/samply-codspeed", branch = "codspeed" }

[target.'cfg(target_os = "linux")'.dependencies]
procfs = "0.17.0"
Expand Down
11 changes: 11 additions & 0 deletions src/executor/helpers/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,17 @@ impl CommandBuilder {
self
}

#[cfg(target_os = "macos")]
pub fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.envs
.insert(key.as_ref().to_owned(), value.as_ref().to_owned());
self
}

pub fn current_dir<D>(&mut self, dir: D)
where
D: AsRef<OsStr>,
Expand Down
83 changes: 83 additions & 0 deletions src/executor/helpers/homebrew.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
//! Thin wrappers around the `brew` CLI for macOS-only setup paths.

use crate::executor::helpers::env::is_codspeed_debug_enabled;
use crate::prelude::*;
use std::path::PathBuf;
use std::process::{Command, Stdio};

/// Fail unless `brew` is on `PATH`. We intentionally do not install Homebrew
/// ourselves — that's too invasive a side effect for a profiler setup step.
fn ensure_brew_available() -> Result<()> {
let installed = Command::new("which")
.arg("brew")
.output()
.is_ok_and(|o| o.status.success());
if !installed {
bail!("Homebrew is required but was not found on PATH");
}
Ok(())
}

/// Return Homebrew's install prefix (`/opt/homebrew` on Apple Silicon,
/// `/usr/local` on Intel). Shells out to `brew --prefix` rather than hardcoding
/// so we don't have to guess the architecture.
pub fn prefix() -> Result<PathBuf> {
let output = Command::new("brew")
.arg("--prefix")
.output()
.context("failed to spawn `brew --prefix`")?;
if !output.status.success() {
bail!("`brew --prefix` exited with status {}", output.status);
}
let path = String::from_utf8(output.stdout)
.context("`brew --prefix` returned non-UTF-8 output")?
.trim()
.to_owned();
Ok(PathBuf::from(path))
}

/// Check whether a brew formula is already installed. Uses `brew list <pkg>`,
/// which is local-only (no network/API hit) and returns non-zero when missing.
pub fn is_installed(package: &str) -> bool {
Command::new("brew")
.args(["list", "--formula", "--quiet", package])
.output()
.is_ok_and(|o| o.status.success())
}

/// Run `brew install <package>`. Idempotent: brew exits 0 when the formula
/// is already installed, so callers don't need to pre-check.
pub fn install(package: &str) -> Result<()> {
ensure_brew_available()?;

// Bypass the logger here: `info!` goes through the spinner-suspend path
// which buffers until the spinner ticks, so the message would only show
// up after brew returns. We want the user to see it before brew starts.
eprintln!("Installing {package} via Homebrew...");

// Check the user-facing debug knob rather than log::max_level(); the
// latter is forced to Trace by the runner's file logger and can't
// distinguish "user wants debug output" from "captured to runner.log".
let stdio = || {
if is_codspeed_debug_enabled() {
Stdio::inherit()
} else {
Stdio::piped()
}
};
let output = Command::new("brew")
.args(["install", package])
.stdout(stdio())
.stderr(stdio())
.output()
.with_context(|| format!("failed to spawn `brew install {package}`"))?;
if !output.status.success() {
bail!(
"`brew install {package}` exited with status {}\nstdout:\n{}\nstderr:\n{}",
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
);
}
Ok(())
}
2 changes: 2 additions & 0 deletions src/executor/helpers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ pub mod detect_executable;
pub mod env;
pub mod get_bench_command;
pub mod harvest_perf_maps_for_pids;
#[cfg(target_os = "macos")]
pub mod homebrew;
pub mod introspected_golang;
pub mod introspected_nodejs;
pub mod profile_folder;
Expand Down
106 changes: 104 additions & 2 deletions src/executor/wall_time/profiler/samply/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,20 @@ pub struct SamplyProfiler {
/// returns — samply writes the file itself — but we hold onto it so future
/// `finalize` work (e.g. validation, conversion) has the path on hand.
output_path: Option<PathBuf>,
/// macOS only: set in [`Profiler::setup`] when the `bash` resolved on PATH
/// is Apple-signed and samply can't profile it, so [`Profiler::wrap_command`]
/// must prepend brew's bin dir to PATH.
#[cfg(target_os = "macos")]
needs_brew_bash: std::cell::Cell<bool>,
}

impl SamplyProfiler {
pub fn new() -> Self {
Self { output_path: None }
Self {
output_path: None,
#[cfg(target_os = "macos")]
needs_brew_bash: std::cell::Cell::new(false),
}
}
}

Expand All @@ -42,7 +51,27 @@ impl Profiler for SamplyProfiler {
_system_info: &SystemInfo,
_setup_cache_dir: Option<&Path>,
) -> anyhow::Result<()> {
ensure_linux_profiling_sysctls()
ensure_linux_profiling_sysctls()?;

// samply can't profile Apple-signed bash. Only do the brew dance if the
// bash that samply would actually exec (the first `bash` on PATH) is
// signed; if a compatible (ad-hoc-signed) bash is already first on PATH,
// we're done.
#[cfg(target_os = "macos")]
{
use crate::executor::helpers::homebrew;
if bash_in_path_is_compatible()? {
return Ok(());
}

self.needs_brew_bash.set(true);
if !homebrew::is_installed("bash") {
confirm_bash_install()?;
homebrew::install("bash")?;
}
}

Ok(())
}

async fn wrap_command(
Expand Down Expand Up @@ -71,6 +100,23 @@ impl Profiler for SamplyProfiler {
.get_command_builder()?;

cmd_builder.wrap_with(samply_builder);

// If `setup` decided the bash on PATH is Apple-signed, prepend brew's
// bin so samply's spawned shell resolves to the ad-hoc-signed brew bash
// instead. Only the samply child's PATH is touched.
#[cfg(target_os = "macos")]
if self.needs_brew_bash.get() {
use crate::executor::helpers::homebrew;
let brew_bin = homebrew::prefix()?.join("bin");
let existing = std::env::var_os("PATH").unwrap_or_default();
let mut new_path = std::ffi::OsString::from(brew_bin);
if !existing.is_empty() {
new_path.push(":");
new_path.push(&existing);
}
cmd_builder.env("PATH", new_path);
}

self.output_path = Some(output_path);
Ok(cmd_builder)
}
Expand Down Expand Up @@ -114,3 +160,59 @@ impl Profiler for SamplyProfiler {
Ok(())
}
}

/// Return `true` if the first `bash` on `PATH` can be profiled by samply.
/// Compatible bashes (e.g. Homebrew's) are ad-hoc-signed and show
/// `Signature=adhoc`; the system `/bin/bash` is signed with an `Authority=`
/// line and is incompatible. Anything we can't classify is treated as
/// incompatible so we err on the side of installing the brew bash.
#[cfg(target_os = "macos")]
fn bash_in_path_is_compatible() -> anyhow::Result<bool> {
use std::process::Command;

let which = Command::new("/usr/bin/which")
.arg("bash")
.output()
.context("failed to spawn `which bash`")?;
if !which.status.success() {
// No bash on PATH at all — samply will fail. Force the brew install
// path so we end up with one.
return Ok(false);
}
let bash_path = String::from_utf8_lossy(&which.stdout).trim().to_owned();

// `codesign -dv` writes to stderr.
let codesign = Command::new("/usr/bin/codesign")
.args(["-dv", "--verbose=2", &bash_path])
.output()
.context("failed to spawn `codesign`")?;
let info = String::from_utf8_lossy(&codesign.stderr);
Ok(info.contains("Signature=adhoc") || info.contains("flags=0x2(adhoc)"))
}

#[cfg(target_os = "macos")]
fn confirm_bash_install() -> anyhow::Result<()> {
use crate::local_logger::IS_TTY;
use console::Term;

// Non-interactive (CI): just install
if !*IS_TTY {
return Ok(());
}

eprintln!(
"CodSpeed depends on bash for benchmark execution, but can't use /bin/bash because system executables are signed in a way that prevents profiling. Because of this, we need to install bash with Homebrew. This is a one-time setup, your system bash is untouched."
);
eprint!("\nRun `brew install bash` now? [Y/n] ");
let line = Term::stderr().read_line().unwrap_or_default();
let answer = line.trim();

// Default to yes on empty input (just pressing Enter).
if !(answer.is_empty()
|| answer.eq_ignore_ascii_case("y")
|| answer.eq_ignore_ascii_case("yes"))
{
bail!("Declined; cannot continue without an unsigned bash");
}
Ok(())
}