Skip to content

Commit 3a766f1

Browse files
moha-bekhclaude
andauthored
fix(memory): grant the eBPF capabilities to a copy of the runner
With memtrack bundled, `setcap` targeted the runner binary itself, so every `codspeed` invocation ran in glibc's secure-execution mode: `LD_*`, `TMPDIR` and similar variables were stripped before the runner could forward them to the benchmark, in every mode. Grant the capabilities to a copy of the binary in `~/.cache`, keyed by its ELF build id, and run `codspeed memtrack` from it. The copy is installed in one sudo call, since `~/.cache` can be root-owned, and copies of other builds are pruned once they are a day old. The runner keeps the user's environment and no longer holds the eBPF capabilities itself. The memtrack benchmarks call `codspeed memtrack` from PATH, so CI grants that binary its capabilities directly. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent becef76 commit 3a766f1

4 files changed

Lines changed: 70 additions & 14 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,9 +216,10 @@ jobs:
216216
217217
# The benchmarked command is memtrack, a subcommand of the runner binary,
218218
# which needs file capabilities to load its eBPF programs even though the
219-
# runner measures walltime.
219+
# runner measures walltime. `setup --mode memory` would grant them to a
220+
# private copy, not to the binary the benchmarks call.
220221
- name: Grant memtrack file capabilities
221-
run: codspeed setup --mode memory
222+
run: sudo setcap cap_dac_read_search,cap_sys_admin,cap_perfmon,cap_bpf,cap_sys_resource+ep "$(which codspeed)"
222223

223224
# The write benchmarks overwrite these files every round; creating them
224225
# inside a measured round would time one-off disk allocation.

‎src/cli/mod.rs‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ impl InternalCommands {
146146
/// Build a [`CommandBuilder`] that re-execs the current binary into this
147147
/// internal subcommand. Each variant owns its own arg layout.
148148
pub fn get_command_builder(&self) -> Result<CommandBuilder> {
149-
let mut builder = CommandBuilder::new(self_exe()?);
149+
Ok(self.command_builder_for(self_exe()?))
150+
}
151+
152+
/// Same as [`Self::get_command_builder`], re-executing `program` instead.
153+
pub fn command_builder_for(&self, program: PathBuf) -> CommandBuilder {
154+
let mut builder = CommandBuilder::new(program);
150155
match self {
151156
InternalCommands::Samply(args) => {
152157
builder.arg("samply");
@@ -162,7 +167,7 @@ impl InternalCommands {
162167
builder.args(args.args.iter().cloned());
163168
}
164169
}
165-
Ok(builder)
170+
builder
166171
}
167172

168173
pub fn get_shell_command(&self) -> Result<String> {

‎src/executor/memory/executor.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ use std::rc::Rc;
3333
use tempfile::NamedTempFile;
3434
use tokio::time::{Duration, timeout};
3535

36-
use super::setup::{ensure_memtrack_capabilities, has_memtrack_capabilities};
36+
use super::setup::{ensure_memtrack_capabilities, has_memtrack_capabilities, memtrack_program};
3737

3838
pub struct MemoryExecutor;
3939

@@ -61,8 +61,8 @@ impl MemoryExecutor {
6161
let bench_command = get_bench_command(&execution_context.config)?;
6262
let (bench_command, env_file) = prefix_command_with_env(&bench_command, &extra_env)?;
6363

64-
let mut cmd_builder =
65-
InternalCommands::Memtrack(MemtrackArgs { args: vec![] }).get_command_builder()?;
64+
let mut cmd_builder = InternalCommands::Memtrack(MemtrackArgs { args: vec![] })
65+
.command_builder_for(memtrack_program()?);
6666
if execution_context.config.memory_track_physical {
6767
cmd_builder.env("CODSPEED_MEMTRACK_TRACK_PHYSICAL", "1");
6868
}

‎src/executor/memory/setup.rs‎

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ use crate::executor::helpers::capabilities::binary_has_capabilities;
33
use crate::executor::helpers::run_with_sudo::{is_root_user, run_with_sudo};
44
use crate::prelude::*;
55
use caps::Capability;
6-
use std::path::PathBuf;
6+
use object::Object;
7+
use std::path::{Path, PathBuf};
8+
use std::sync::OnceLock;
79

810
const MEMTRACK_REQUIRED_CAPS: &[Capability] = &[
911
Capability::CAP_DAC_READ_SEARCH,
@@ -31,11 +33,43 @@ pub(crate) fn memtrack_setcap_spec() -> String {
3133
format!("{caps}+ep")
3234
}
3335

34-
/// The binary that carries the eBPF capabilities: memtrack is a subcommand of
35-
/// this executable. Granted `+ep`, not inheritable, so a benchmark spawned from
36-
/// here does not receive them.
36+
/// The binary that carries the eBPF capabilities: a copy of this executable, so
37+
/// the runner itself never runs in glibc's secure-execution mode, which strips
38+
/// `LD_*` and similar variables from the environment benchmarks inherit.
39+
/// Granted `+ep`, not inheritable, so a benchmark spawned from it does not
40+
/// receive them.
3741
fn memtrack_path() -> Option<PathBuf> {
38-
self_exe().ok()
42+
static PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
43+
PATH.get_or_init(|| {
44+
let build_id = build_id(&self_exe().ok()?)?;
45+
Some(memtrack_cache_dir()?.join(build_id).join("codspeed"))
46+
})
47+
.clone()
48+
}
49+
50+
fn memtrack_cache_dir() -> Option<PathBuf> {
51+
let cache = std::env::var_os("XDG_CACHE_HOME")
52+
.map(PathBuf::from)
53+
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".cache")))?;
54+
Some(cache.join("codspeed").join("memtrack"))
55+
}
56+
57+
/// Keys the copy, so a rebuild of the same version never runs a stale memtrack.
58+
fn build_id(exe: &Path) -> Option<String> {
59+
let file = std::fs::File::open(exe).ok()?;
60+
// SAFETY: a running executable cannot be written to (ETXTBSY).
61+
let data = unsafe { memmap2::Mmap::map(&file) }.ok()?;
62+
let id = object::File::parse(&*data).ok()?.build_id().ok()??;
63+
Some(id.iter().map(|byte| format!("{byte:02x}")).collect())
64+
}
65+
66+
/// The executable memtrack runs from: the copy once it holds the capabilities,
67+
/// this one otherwise (as root, or with a delegated BPF token).
68+
pub fn memtrack_program() -> Result<PathBuf> {
69+
match memtrack_path() {
70+
Some(path) if binary_has_capabilities(&path, memtrack_required_caps_mask()) => Ok(path),
71+
_ => self_exe(),
72+
}
3973
}
4074

4175
/// Whether the installed memtrack binary already carries the required capabilities.
@@ -72,8 +106,24 @@ pub fn ensure_memtrack_capabilities() -> Result<()> {
72106
"Granting {MEMTRACK_COMMAND} the capabilities it needs as a one-time setup for the \
73107
memory instrument (requires sudo)."
74108
);
75-
let setcap_args = [memtrack_setcap_spec(), path.to_string_lossy().into_owned()];
76-
if let Err(e) = run_with_sudo("setcap", setcap_args) {
109+
// Through sudo: `~/.cache` may be root-owned, samply writes there under sudo.
110+
// Renamed into place so a concurrent run never execs a partial copy. Only
111+
// copies older than a day are pruned: another build may be using a newer one.
112+
let install_script = r#"set -e
113+
install -D -m 0755 "$1" "$2.tmp"
114+
setcap "$3" "$2.tmp"
115+
mv -f "$2.tmp" "$2"
116+
dir=$(dirname "$2")
117+
find "$(dirname "$dir")" -mindepth 1 -maxdepth 1 ! -path "$dir" -mmin +1440 -exec rm -rf {} +"#;
118+
let install_args = [
119+
"-c".to_owned(),
120+
install_script.to_owned(),
121+
"sh".to_owned(),
122+
self_exe()?.to_string_lossy().into_owned(),
123+
path.to_string_lossy().into_owned(),
124+
memtrack_setcap_spec(),
125+
];
126+
if let Err(e) = run_with_sudo("sh", install_args) {
77127
warn!(
78128
"Failed to grant capabilities to {MEMTRACK_COMMAND} ({e}). \
79129
Memory profiling will require running as root."

0 commit comments

Comments
 (0)