diff --git a/Cargo.lock b/Cargo.lock index 3e32d34f..73148234 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1509,7 +1509,7 @@ dependencies = [ [[package]] name = "fxprof-processed-profile" version = "0.8.1" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "bitflags 2.10.0", "debugid", @@ -4416,7 +4416,7 @@ dependencies = [ [[package]] name = "samply" version = "0.13.1" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "bitflags 2.10.0", "byteorder", @@ -4485,7 +4485,7 @@ dependencies = [ [[package]] name = "samply-api" version = "0.24.0" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "samply-debugid", "samply-symbols", @@ -4501,7 +4501,7 @@ dependencies = [ [[package]] name = "samply-debugid" version = "0.1.0" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "debugid", "uuid", @@ -4510,7 +4510,7 @@ dependencies = [ [[package]] name = "samply-object" version = "0.1.0" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "debugid", "object 0.39.0", @@ -4521,7 +4521,7 @@ dependencies = [ [[package]] name = "samply-quota-manager" version = "0.1.0" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "bytesize", "futures", @@ -4535,7 +4535,7 @@ dependencies = [ [[package]] name = "samply-symbols" version = "0.24.1" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "addr2line 0.26.1", "bitflags 2.10.0", @@ -6016,7 +6016,7 @@ dependencies = [ [[package]] name = "wholesym" version = "0.8.1" -source = "git+https://github.com/AvalancheHQ/samply?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" +source = "git+https://github.com/CodSpeedHQ/samply-codspeed?branch=codspeed#7fbe8e5e2412b0cbbab7668f4321d4fbac84529c" dependencies = [ "bytes", "core-foundation 0.10.1", diff --git a/Cargo.toml b/Cargo.toml index 88e63201..867e8b2a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/executor/helpers/command.rs b/src/executor/helpers/command.rs index abd7dd8b..bdbc4e1c 100644 --- a/src/executor/helpers/command.rs +++ b/src/executor/helpers/command.rs @@ -48,6 +48,17 @@ impl CommandBuilder { self } + #[cfg(target_os = "macos")] + pub fn env(&mut self, key: K, value: V) -> &mut Self + where + K: AsRef, + V: AsRef, + { + self.envs + .insert(key.as_ref().to_owned(), value.as_ref().to_owned()); + self + } + pub fn current_dir(&mut self, dir: D) where D: AsRef, diff --git a/src/executor/helpers/homebrew.rs b/src/executor/helpers/homebrew.rs new file mode 100644 index 00000000..f9c8a320 --- /dev/null +++ b/src/executor/helpers/homebrew.rs @@ -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 { + 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 `, +/// 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 `. 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(()) +} diff --git a/src/executor/helpers/mod.rs b/src/executor/helpers/mod.rs index 3e433c7c..43483921 100644 --- a/src/executor/helpers/mod.rs +++ b/src/executor/helpers/mod.rs @@ -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; diff --git a/src/executor/wall_time/profiler/samply/mod.rs b/src/executor/wall_time/profiler/samply/mod.rs index c2f8b871..e56ee41b 100644 --- a/src/executor/wall_time/profiler/samply/mod.rs +++ b/src/executor/wall_time/profiler/samply/mod.rs @@ -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, + /// 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, } 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), + } } } @@ -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( @@ -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) } @@ -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 { + 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(()) +}