From 64c06da533694ba2241428b1bcf75fccb2703d18 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 7 Jul 2026 19:40:28 +0200 Subject: [PATCH 1/9] feat(memtrack): add exec-mapping watcher BPF prog and inode maps --- crates/memtrack/src/ebpf/c/attach.bpf.h | 57 +++++++++++++++++++++++ crates/memtrack/src/ebpf/c/event.h | 7 +++ crates/memtrack/src/ebpf/c/memtrack.bpf.c | 9 +++- crates/memtrack/src/ebpf/events.rs | 25 ++++++++++ 4 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 crates/memtrack/src/ebpf/c/attach.bpf.h diff --git a/crates/memtrack/src/ebpf/c/attach.bpf.h b/crates/memtrack/src/ebpf/c/attach.bpf.h new file mode 100644 index 00000000..edee1f7e --- /dev/null +++ b/crates/memtrack/src/ebpf/c/attach.bpf.h @@ -0,0 +1,57 @@ +/* == Exec-mapping watcher == + * + * On a tracked process's first exec-map of an unknown inode: stop it + * (SIGSTOP) and queue an attach request on a ring buffer. Userspace + * classifies the file, attaches allocator probes, then resumes it. */ + +#define MEMTRACK_PROT_EXEC 0x4 +#define MEMTRACK_SIGSTOP 19 + +struct inode_key { + __u64 dev; + __u64 ino; +}; + +/* (dev, ino) -> 1; populated by userspace after classify/attach */ +BPF_HASH_MAP(known_inodes, struct inode_key, __u8, 8192); +/* Requests are 24 B and rare; overflow aborts the run via the counter below */ +BPF_RINGBUF(attach_requests, 128 * 1024); +BPF_ARRAY_MAP(attach_request_dropped, __u64, 1); + +SEC("fentry/security_mmap_file") +int BPF_PROG(watch_exec_mmap, struct file* file, unsigned long prot, unsigned long flags) { + if (!file || !(prot & MEMTRACK_PROT_EXEC)) { + return 0; + } + + __u64 pid_tgid = bpf_get_current_pid_tgid(); + __u32 tgid = pid_tgid >> 32; + if (!is_tracked(tgid)) { + return 0; + } + + struct inode_key key = { + .dev = BPF_CORE_READ(file, f_inode, i_sb, s_dev), + .ino = BPF_CORE_READ(file, f_inode, i_ino), + }; + if (bpf_map_lookup_elem(&known_inodes, &key)) { + return 0; + } + + struct attach_request* req = bpf_ringbuf_reserve(&attach_requests, sizeof(*req), 0); + if (!req) { + __u32 zero = 0; + __u64* drops = bpf_map_lookup_elem(&attach_request_dropped, &zero); + if (drops) { + __sync_fetch_and_add(drops, 1); + } + return 0; + } + req->pid = tgid; + req->dev = key.dev; + req->ino = key.ino; + bpf_ringbuf_submit(req, 0); + + bpf_send_signal(MEMTRACK_SIGSTOP); + return 0; +} diff --git a/crates/memtrack/src/ebpf/c/event.h b/crates/memtrack/src/ebpf/c/event.h index f914f634..ff41f0be 100644 --- a/crates/memtrack/src/ebpf/c/event.h +++ b/crates/memtrack/src/ebpf/c/event.h @@ -48,4 +48,11 @@ struct event { } data; }; +/* Request from the exec-mapping watcher to the userspace attach worker */ +struct attach_request { + uint32_t pid; + uint64_t dev; /* kernel s_dev encoding: (major << 20) | minor */ + uint64_t ino; +}; + #endif /* __EVENT_H__ */ diff --git a/crates/memtrack/src/ebpf/c/memtrack.bpf.c b/crates/memtrack/src/ebpf/c/memtrack.bpf.c index ad64d73b..4c93d241 100644 --- a/crates/memtrack/src/ebpf/c/memtrack.bpf.c +++ b/crates/memtrack/src/ebpf/c/memtrack.bpf.c @@ -92,6 +92,11 @@ int tracepoint_sched_fork(struct trace_event_raw_sched_process_fork* ctx) { return 0; } +/* Included as source, not a header: build.rs compiles this file as one + * translation unit into a single skeleton. Must follow is_tracked() and the + * BPF_*_MAP macros it uses. */ +#include "attach.bpf.h" + /* == Helper functions for the allocation tracking == */ /* Wake the consumer only once this much unconsumed data has accumulated. @@ -111,9 +116,9 @@ static __always_inline int is_enabled(void) { __u32 key = 0; __u8* enabled = bpf_map_lookup_elem(&tracking_enabled, &key); - /* Default to enabled if map not initialized */ + /* ARRAY-map lookups can't fail for a valid index; fail closed if one ever does. */ if (!enabled) { - return 1; + return 0; } return *enabled; diff --git a/crates/memtrack/src/ebpf/events.rs b/crates/memtrack/src/ebpf/events.rs index 3ecb7a67..f18216c6 100644 --- a/crates/memtrack/src/ebpf/events.rs +++ b/crates/memtrack/src/ebpf/events.rs @@ -89,6 +89,31 @@ pub fn parse_event(data: &[u8]) -> Option { }) } +/// A request from the exec-mapping watcher to attach allocator probes. +#[derive(Debug, Clone, Copy)] +pub struct AttachRequest { + pub pid: u32, + pub dev: u64, + pub ino: u64, +} +impl AttachRequest { + /// Parse an attach request from raw ring buffer bytes. + /// + /// SAFETY: The data must be a valid `bindings::attach_request` + pub fn parse(data: &[u8]) -> Option { + if data.len() < std::mem::size_of::() { + return None; + } + + let req = unsafe { &*(data.as_ptr() as *const bindings::attach_request) }; + Some(AttachRequest { + pid: req.pid, + dev: req.dev, + ino: req.ino, + }) + } +} + #[cfg(test)] mod tests { use super::*; From 8c23107a3b64b7018489ca80e850bcde8da23bff Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 8 Jul 2026 21:42:06 +0200 Subject: [PATCH 2/9] feat(memtrack): attach allocator probes on demand via exec-mapping watcher Instead of pre-attaching every allocator found on the system, a BPF fentry on security_mmap_file signals the first mapping of each unknown executable inode. A background worker stops the mapping processes, classifies the file by its symbols, attaches probes, and resumes them. This covers dlopen'd and statically linked allocators in spawned children, and skips libraries the benchmark never loads. The public API shrinks to Tracker (owns the attach worker) and Session (owns the spawned child and its event pipeline); ring buffers are polled through a generic RingBufferPoller with caller-supplied parsing. Fixes COD-1801 --- Cargo.lock | 1 + crates/memtrack/Cargo.toml | 1 + crates/memtrack/src/ebpf/attach_worker.rs | 251 ++++++++++++++++++++++ crates/memtrack/src/ebpf/memtrack.rs | 92 +++++++- crates/memtrack/src/ebpf/mod.rs | 5 +- crates/memtrack/src/ebpf/poller.rs | 98 ++++----- crates/memtrack/src/ebpf/proc_fs.rs | 195 +++++++++++++++++ crates/memtrack/src/ebpf/spawn.rs | 108 ++++++++++ crates/memtrack/src/ebpf/tracker.rs | 144 +++++++------ crates/memtrack/src/ipc.rs | 28 +-- crates/memtrack/src/lib.rs | 4 + crates/memtrack/src/main.rs | 63 +++--- crates/memtrack/src/session.rs | 41 ++++ 13 files changed, 852 insertions(+), 179 deletions(-) create mode 100644 crates/memtrack/src/ebpf/attach_worker.rs create mode 100644 crates/memtrack/src/ebpf/proc_fs.rs create mode 100644 crates/memtrack/src/ebpf/spawn.rs create mode 100644 crates/memtrack/src/session.rs diff --git a/Cargo.lock b/Cargo.lock index c53e2087..86eacac9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2389,6 +2389,7 @@ dependencies = [ "libc", "log", "object", + "parking_lot", "paste", "rayon", "rstest", diff --git a/crates/memtrack/Cargo.toml b/crates/memtrack/Cargo.toml index 7b76ad1a..8a2558bf 100644 --- a/crates/memtrack/Cargo.toml +++ b/crates/memtrack/Cargo.toml @@ -36,6 +36,7 @@ libbpf-rs = { version = "0.26", features = ["vendored"], optional = true } glob = "0.3.3" object = { workspace = true } rayon = "1.12" +parking_lot = "0.12" [build-dependencies] libbpf-cargo = { version = "0.26", optional = true } diff --git a/crates/memtrack/src/ebpf/attach_worker.rs b/crates/memtrack/src/ebpf/attach_worker.rs new file mode 100644 index 00000000..d82402f2 --- /dev/null +++ b/crates/memtrack/src/ebpf/attach_worker.rs @@ -0,0 +1,251 @@ +use crate::AllocatorLib; +use crate::ebpf::MemtrackBpf; +use crate::ebpf::events::AttachRequest; +use crate::ebpf::poller::RingBufferPoller; +use crate::prelude::*; +use parking_lot::Mutex; +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicI32, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError}; +use std::thread::JoinHandle; +use std::time::Duration; + +use super::proc_fs::{resolve_mapping, wait_all_stopped}; + +const STOP_DEADLINE: Duration = Duration::from_secs(1); +const POLL_INTERVAL_MS: u64 = 10; +const RECV_TIMEOUT: Duration = Duration::from_millis(100); + +/// SIGCONTs `pid` on drop, ignoring errors. Guarantees a stopped process is +/// resumed on every exit path, including panics. +struct ContGuard(i32); + +impl Drop for ContGuard { + fn drop(&mut self) { + // SAFETY: kill with SIGCONT has no memory effects; errors (e.g. the + // process already exited) are intentionally ignored. + unsafe { + libc::kill(self.0, libc::SIGCONT); + } + } +} + +/// Background worker that stops tracked processes on their first mapping of an +/// unknown executable file, classifies it, attaches allocator probes, and +/// resumes them. +pub(crate) struct AttachWorker { + handle: Option>, + shutdown: Arc, + fatal: Arc>>, + root_pid: Arc, + bpf: Arc>, +} + +impl AttachWorker { + pub(crate) fn start(bpf: Arc>) -> Result { + let shutdown = Arc::new(AtomicBool::new(false)); + let fatal = Arc::new(Mutex::new(None)); + let root_pid = Arc::new(AtomicI32::new(0)); + + let (tx, rx) = mpsc::channel(); + let poller = bpf.lock().poll_attach_with_channel(POLL_INTERVAL_MS, tx)?; + + let worker = Worker { + poller, + rx, + bpf: bpf.clone(), + shutdown: shutdown.clone(), + fatal: fatal.clone(), + root_pid: root_pid.clone(), + }; + + let handle = std::thread::spawn(move || worker.run()); + + Ok(Self { + handle: Some(handle), + shutdown, + fatal, + root_pid, + bpf, + }) + } + + /// Tell the worker which pid to SIGKILL on a fatal error. + pub(crate) fn set_root_pid(&self, pid: i32) { + self.root_pid.store(pid, Ordering::SeqCst); + } + + /// Stop the worker, join it, and surface any fatal error it recorded. + /// Fails when the attach-request ring buffer overflowed: exec mappings were + /// missed, so allocator coverage would be incomplete. + pub(crate) fn finish(mut self) -> Result<()> { + self.shutdown.store(true, Ordering::SeqCst); + if let Some(handle) = self.handle.take() + && let Err(panic) = handle.join() + { + let msg = panic + .downcast_ref::<&str>() + .map(|s| s.to_string()) + .or_else(|| panic.downcast_ref::().cloned()) + .unwrap_or_else(|| "unknown panic payload".to_string()); + bail!("attach worker thread panicked: {msg}"); + } + if let Some(err) = self.fatal.lock().take() { + bail!("{err}"); + } + + let dropped = self.bpf.lock().attach_request_dropped_count()?; + if dropped > 0 { + bail!( + "Memtrack attach-request ring buffer overflowed: {dropped} exec mappings missed, \ + aborting since allocator coverage is incomplete." + ); + } + + Ok(()) + } +} + +struct Worker { + poller: RingBufferPoller, + rx: mpsc::Receiver, + bpf: Arc>, + shutdown: Arc, + fatal: Arc>>, + root_pid: Arc, +} + +impl Worker { + fn run(self) { + let mut known: HashSet<(u64, u64)> = HashSet::new(); + + loop { + if self.shutdown.load(Ordering::SeqCst) { + // Producers are gone: a synchronous drain flushes everything. + if let Err(e) = self.poller.drain() { + self.record_fatal(e); + return; + } + let mut batch: Vec = self.rx.try_iter().collect(); + if batch.is_empty() { + break; + } + if let Err(e) = self.process_batch(&mut batch, &mut known) { + self.record_fatal(e); + return; + } + continue; + } + + let first = match self.rx.recv_timeout(RECV_TIMEOUT) { + Ok(req) => req, + Err(RecvTimeoutError::Timeout) => continue, + Err(RecvTimeoutError::Disconnected) => return, + }; + let mut batch: Vec = + std::iter::once(first).chain(self.rx.try_iter()).collect(); + + if let Err(e) = self.process_batch(&mut batch, &mut known) { + self.record_fatal(e); + return; + } + } + } + + /// Stop every producing pid (fixpoint, draining until no new pid appears), + /// then classify + attach for each unique `(dev, ino)`. `guards` resume every + /// stopped pid exactly once when this returns, including the error path. + fn process_batch( + &self, + batch: &mut Vec, + known: &mut HashSet<(u64, u64)>, + ) -> Result<()> { + let mut guards: Vec = Vec::new(); + let mut stopped: HashSet = HashSet::new(); + + loop { + let new_pids: HashSet = batch + .iter() + .map(|r| r.pid) + .filter(|pid| !stopped.contains(pid)) + .collect(); + + if new_pids.is_empty() { + break; + } + + for pid in new_pids { + stopped.insert(pid); + guards.push(ContGuard(pid as i32)); + wait_all_stopped(pid, STOP_DEADLINE)?; + } + + // Every producer is stopped, so a synchronous drain is complete. + self.poller.drain()?; + batch.extend(self.rx.try_iter()); + } + + let mut seen: HashSet<(u64, u64)> = HashSet::new(); + for req in batch.iter() { + let key = (req.dev, req.ino); + if !seen.insert(key) || known.contains(&key) { + continue; + } + + if self.handle_request(req)? { + self.bpf.lock().insert_known_inode(req.dev, req.ino)?; + known.insert(key); + } + } + + Ok(()) + } + + /// Returns `Ok(true)` when the inode should be marked known (attached, or + /// definitively not an allocator), `Ok(false)` when the mapping vanished + /// (retry later). An attach failure is a hard error (fail-closed). + fn handle_request(&self, req: &AttachRequest) -> Result { + let Some(mapping) = resolve_mapping(req.pid, req.dev, req.ino) else { + return Ok(false); + }; + + let Ok(lib) = AllocatorLib::from_path_static(&mapping.attach_path) else { + debug!("on-demand: not an allocator: {}", mapping.display); + return Ok(true); + }; + + let kind = lib.kind; + let mut bpf = self.bpf.lock(); + let before = bpf.probe_count(); + bpf.attach_allocator_probes(lib.kind, &lib.path)?; + if bpf.probe_count() == before { + bail!( + "{} classified at {} but zero probes attached", + kind.name(), + mapping.display + ); + } + + info!( + "on-demand attached {} probes to {}", + kind.name(), + mapping.display + ); + Ok(true) + } + + fn record_fatal(&self, err: anyhow::Error) { + let msg = format!("{err:#}"); + error!("on-demand attach worker fatal: {msg}"); + *self.fatal.lock() = Some(msg); + + let root = self.root_pid.load(Ordering::SeqCst); + if root > 0 { + // SAFETY: kill with SIGKILL has no memory effects. + unsafe { + libc::kill(root, libc::SIGKILL); + } + } + } +} diff --git a/crates/memtrack/src/ebpf/memtrack.rs b/crates/memtrack/src/ebpf/memtrack.rs index 649d1797..9ae34dce 100644 --- a/crates/memtrack/src/ebpf/memtrack.rs +++ b/crates/memtrack/src/ebpf/memtrack.rs @@ -575,16 +575,90 @@ impl MemtrackBpf { Ok(()) } - /// Start polling, forwarding each event over the returned channel. - pub fn start_polling_with_channel( + /// Poll the allocation-event ring buffer into `tx`. The returned poller + /// keeps the pipeline alive; events stop flowing when it is dropped. + pub fn poll_events_with_channel( &self, - poll_timeout_ms: u64, - ) -> Result<( - RingBufferPoller, - crossbeam_channel::Receiver, - )> { - // Use the syscalls skeleton's ring buffer (both programs share the same one) - RingBufferPoller::with_channel(&self.skel.maps.events, poll_timeout_ms) + poll_interval_ms: u64, + tx: std::sync::mpsc::Sender, + ) -> Result { + RingBufferPoller::new( + &self.skel.maps.events, + crate::ebpf::events::parse_event, + tx, + poll_interval_ms, + ) + } +} + +// ========================================================================= +// On-demand exec-mapping watcher +// ========================================================================= + +impl MemtrackBpf { + /// Attach the exec-mapping watcher (fentry/security_mmap_file). Only used in + /// on-demand mode; the program is loaded and verified in all modes. + pub fn attach_exec_watcher(&mut self) -> Result<()> { + let link = self + .skel + .progs + .watch_exec_mmap + .attach() + .context("Failed to attach exec-mapping watcher")?; + self.probes.push(link); + Ok(()) + } + + /// Mark a (dev, ino) as classified so the watcher stops re-signalling for it. + /// The 16-byte key matches `struct inode_key { __u64 dev; __u64 ino; }` (no padding). + pub fn insert_known_inode(&self, dev: u64, ino: u64) -> Result<()> { + let mut key = [0u8; 16]; + key[..8].copy_from_slice(&dev.to_le_bytes()); + key[8..].copy_from_slice(&ino.to_le_bytes()); + self.skel + .maps + .known_inodes + .update(&key, &1u8.to_le_bytes(), libbpf_rs::MapFlags::ANY) + .context("Failed to insert known inode")?; + Ok(()) + } + + /// Number of exec-mapping requests dropped because the request ring buffer was full. + pub fn attach_request_dropped_count(&self) -> Result { + let key = 0u32; + let value = self + .skel + .maps + .attach_request_dropped + .lookup(&key.to_le_bytes(), libbpf_rs::MapFlags::ANY) + .context("Failed to read attach_request_dropped counter")? + .ok_or_else(|| anyhow!("attach_request_dropped slot 0 missing"))?; + + let bytes: [u8; 8] = value + .as_slice() + .try_into() + .map_err(|_| anyhow!("attach_request_dropped value has unexpected size"))?; + Ok(u64::from_le_bytes(bytes)) + } + + /// Number of currently-attached probes/links. + pub fn probe_count(&self) -> usize { + self.probes.len() + } + + /// Poll the exec-mapping request ring buffer into `tx`. Same contract as + /// [`Self::poll_events_with_channel`]. + pub(crate) fn poll_attach_with_channel( + &self, + poll_interval_ms: u64, + tx: std::sync::mpsc::Sender, + ) -> Result { + RingBufferPoller::new( + &self.skel.maps.attach_requests, + crate::ebpf::events::AttachRequest::parse, + tx, + poll_interval_ms, + ) } } diff --git a/crates/memtrack/src/ebpf/mod.rs b/crates/memtrack/src/ebpf/mod.rs index cb4716be..f05153d7 100644 --- a/crates/memtrack/src/ebpf/mod.rs +++ b/crates/memtrack/src/ebpf/mod.rs @@ -1,6 +1,9 @@ +mod attach_worker; mod events; mod memtrack; -mod poller; +pub(crate) mod poller; +mod proc_fs; +mod spawn; mod tracker; pub use memtrack::{MemtrackBpf, ResolvedSymbols, resolve_symbol_offsets}; diff --git a/crates/memtrack/src/ebpf/poller.rs b/crates/memtrack/src/ebpf/poller.rs index 6d169afe..d80a1554 100644 --- a/crates/memtrack/src/ebpf/poller.rs +++ b/crates/memtrack/src/ebpf/poller.rs @@ -1,77 +1,79 @@ -use anyhow::Result; -use crossbeam_channel::{Receiver, unbounded}; +use anyhow::{Context, Result}; use libbpf_rs::{MapCore, RingBufferBuilder}; -use runner_shared::artifacts::MemtrackEvent as Event; -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::thread::JoinHandle; use std::time::Duration; -use super::events::parse_event; - -/// Polls a BPF ring buffer on a background thread and forwards events over a channel. +/// Polls a BPF ring buffer in a background thread, parsing raw entries with a +/// user-supplied closure and forwarding them to an mpsc channel. +/// +/// The poll thread runs until the poller is dropped, doing a final full +/// `consume()` on shutdown so no buffered entries are lost. pub struct RingBufferPoller { - shutdown: Arc, + ctl: Option>>, poll_thread: Option>, } impl RingBufferPoller { - /// Poll `rb_map` and forward each parsed event over the returned channel. - pub fn with_channel( - rb_map: &M, - poll_timeout_ms: u64, - ) -> Result<(Self, Receiver)> { - let (tx, rx) = unbounded::(); - + pub fn new(rb_map: &M, parse: F, tx: Sender, poll_interval_ms: u64) -> Result + where + M: MapCore, + T: Send + 'static, + F: Fn(&[u8]) -> Option + Send + 'static, + { let mut builder = RingBufferBuilder::new(); builder.add(rb_map, move |data| { - if let Some(event) = parse_event(data) { - let _ = tx.send(event); + if let Some(item) = parse(data) { + let _ = tx.send(item); } 0 })?; let ringbuf = builder.build()?; - let shutdown = Arc::new(AtomicBool::new(false)); - let poll_thread = std::thread::spawn({ - let shutdown = shutdown.clone(); - let timeout = Duration::from_millis(poll_timeout_ms); - move || { - while !shutdown.load(Ordering::Relaxed) { - let _ = ringbuf.poll(timeout); - - // consume() drains the buffer to empty in a single call, so - // records produced while poll() was draining are picked up - // here without paying another epoll_wait round-trip. - let _ = ringbuf.consume(); + // The control channel doubles as the poll pacing: a received message is + // a drain request (acked after a full consume), a timeout is a regular + // poll tick, and disconnection is the shutdown signal. + let (ctl, ctl_rx) = mpsc::channel::>(); + let poll_thread = std::thread::spawn(move || { + loop { + match ctl_rx.recv_timeout(Duration::from_millis(poll_interval_ms)) { + Ok(ack) => { + let _ = ringbuf.consume(); + let _ = ack.send(()); + } + Err(RecvTimeoutError::Timeout) => { + let _ = ringbuf.poll(Duration::ZERO); + } + Err(RecvTimeoutError::Disconnected) => { + let _ = ringbuf.consume(); + break; + } } - - // Events may still be sitting in the ring buffer after the last - // poll; consume once more so they reach the channel before it closes. - let _ = ringbuf.consume(); } }); - Ok(( - Self { - shutdown, - poll_thread: Some(poll_thread), - }, - rx, - )) + Ok(Self { + ctl: Some(ctl), + poll_thread: Some(poll_thread), + }) } - /// Stop the polling thread and wait for it to finish draining. - pub fn shutdown(&mut self) { - self.shutdown.store(true, Ordering::Relaxed); - if let Some(thread) = self.poll_thread.take() { - let _ = thread.join(); - } + /// Block until a full `consume()` of the ring buffer completes. When every + /// producer is stopped, all pending entries are in the channel afterwards. + pub fn drain(&self) -> Result<()> { + let (ack_tx, ack_rx) = mpsc::channel(); + let ctl = self.ctl.as_ref().context("poller already shut down")?; + ctl.send(ack_tx).context("poll thread is gone")?; + ack_rx.recv().context("poll thread died during drain")?; + Ok(()) } } impl Drop for RingBufferPoller { fn drop(&mut self) { - self.shutdown(); + drop(self.ctl.take()); + if let Some(thread) = self.poll_thread.take() { + let _ = thread.join(); + } } } diff --git a/crates/memtrack/src/ebpf/proc_fs.rs b/crates/memtrack/src/ebpf/proc_fs.rs new file mode 100644 index 00000000..1619dc4c --- /dev/null +++ b/crates/memtrack/src/ebpf/proc_fs.rs @@ -0,0 +1,195 @@ +use crate::prelude::*; +use std::path::PathBuf; +use std::time::{Duration, Instant}; + +/// A mapping resolved from `/proc//maps` back to an attachable path. +pub(super) struct ResolvedMapping { + /// `/proc//map_files/-` — resolves uniformly for deleted + /// files and requires root (which memtrack already has for uprobes). + pub attach_path: PathBuf, + /// The pathname column, for logs. + pub display: String, +} + +/// Block until every thread of `pid` is group-stopped. +/// +/// The process state is the first non-space char after the LAST `)` in +/// `/proc//task//stat`. Success means every thread is `T`/`t`. +/// +/// - A vanished process (`/proc/` gone) is success: the stop is moot. +/// - At the deadline, threads still in uninterruptible sleep (`D`) are treated +/// as stopped — they cannot execute user code and join the group stop when the +/// syscall returns. Any thread still `R`/`S` is a hard error: leaving it +/// running breaks the drain guarantee. +pub(super) fn wait_all_stopped(pid: u32, deadline: Duration) -> Result<()> { + let start = Instant::now(); + let task_dir = format!("/proc/{pid}/task"); + + loop { + let Ok(entries) = std::fs::read_dir(&task_dir) else { + return Ok(()); + }; + + let mut all_stopped = true; + let mut running_tid: Option = None; + + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(tid) = name.to_str().and_then(|s| s.parse::().ok()) else { + continue; + }; + + let Ok(stat) = std::fs::read_to_string(entry.path().join("stat")) else { + continue; + }; + let Some(state) = task_state(&stat) else { + continue; + }; + + match state { + 'T' | 't' => {} + 'D' => all_stopped = false, + _ => { + all_stopped = false; + running_tid = Some(tid); + } + } + } + + if all_stopped { + return Ok(()); + } + + if start.elapsed() >= deadline { + let Some(tid) = running_tid else { + warn!( + "pid {pid}: thread(s) in uninterruptible sleep (D) at stop deadline; treating as stopped" + ); + return Ok(()); + }; + bail!("thread {tid} of {pid} did not stop within {deadline:?}"); + } + + std::thread::sleep(Duration::from_millis(1)); + } +} + +/// The process state char: first non-space after the LAST `)` (comm can contain +/// both `)` and spaces). +fn task_state(stat: &str) -> Option { + let idx = stat.rfind(')')?; + stat[idx + 1..].trim_start().chars().next() +} + +/// Resolve the `(dev, ino)` reported by the watcher to an attachable mapping. +/// +/// `dev` is the kernel `s_dev` encoding `(major << 20) | minor`; the maps dev +/// column prints `MAJOR:MINOR` in lowercase hex (`major = dev >> 20`, +/// `minor = dev & 0xFFFFF`). Returns `None` if no row matches (the mmap failed +/// after the LSM hook, or the process exited) — the caller retries on the next +/// sighting. +pub(super) fn resolve_mapping(pid: u32, dev: u64, ino: u64) -> Option { + let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).ok()?; + let want_major = (dev >> 20) as u32; + let want_minor = (dev & 0xFFFFF) as u32; + + for line in maps.lines() { + // range perms offset dev inode pathname + let mut fields = line.split_whitespace(); + let (Some(range), Some(_perms), Some(_offset), Some(dev_col), Some(inode_col)) = ( + fields.next(), + fields.next(), + fields.next(), + fields.next(), + fields.next(), + ) else { + continue; + }; + + if inode_col.parse::().ok() != Some(ino) { + continue; + } + if parse_dev(dev_col) != Some((want_major, want_minor)) { + continue; + } + + let display = fields.next().unwrap_or("").to_string(); + return Some(ResolvedMapping { + attach_path: PathBuf::from(format!("/proc/{pid}/map_files/{range}")), + display, + }); + } + + None +} + +/// Parse a `MAJOR:MINOR` lowercase-hex dev column. +fn parse_dev(col: &str) -> Option<(u32, u32)> { + let (major, minor) = col.split_once(':')?; + Some(( + u32::from_str_radix(major, 16).ok()?, + u32::from_str_radix(minor, 16).ok()?, + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn task_state_reads_char_after_last_paren() { + assert_eq!(task_state("1234 (bash) S 1 0 0"), Some('S')); + assert_eq!(task_state("1234 (weird )name) T 1"), Some('T')); + assert_eq!(task_state("1 (x) R"), Some('R')); + assert_eq!(task_state("no paren here"), None); + } + + #[test] + fn parse_dev_reads_hex_major_minor() { + assert_eq!(parse_dev("08:01"), Some((8, 1))); + assert_eq!(parse_dev("fd:00"), Some((0xfd, 0))); + assert_eq!(parse_dev("00:1a"), Some((0, 0x1a))); + assert_eq!(parse_dev("nope"), None); + assert_eq!(parse_dev("zz:01"), None); + } + + /// Round-trips the kernel dev encoding `(major << 20) | minor` through + /// resolve_mapping against our own maps (no root needed): a file-backed + /// mapping's dev/ino must resolve back to that same mapping. + #[test] + fn resolve_mapping_round_trips_self_maps() { + let pid = std::process::id(); + let maps = std::fs::read_to_string(format!("/proc/{pid}/maps")).unwrap(); + + let target = maps.lines().find_map(|line| { + let mut f = line.split_whitespace(); + let (_range, _perms, _off, dev, inode) = + (f.next()?, f.next()?, f.next()?, f.next()?, f.next()?); + let path = f.next()?; + if !path.starts_with('/') { + return None; + } + let ino: u64 = inode.parse().ok()?; + if ino == 0 { + return None; + } + let (maj, min) = parse_dev(dev)?; + let s_dev = ((maj as u64) << 20) | (min as u64); + Some((s_dev, ino, path.to_string())) + }); + + let Some((s_dev, ino, path)) = target else { + return; + }; + + let resolved = resolve_mapping(pid, s_dev, ino).expect("mapping should resolve"); + assert_eq!(resolved.display, path); + assert!( + resolved + .attach_path + .starts_with(format!("/proc/{pid}/map_files/")), + "attach_path was {:?}", + resolved.attach_path + ); + } +} diff --git a/crates/memtrack/src/ebpf/spawn.rs b/crates/memtrack/src/ebpf/spawn.rs new file mode 100644 index 00000000..f4c8dadd --- /dev/null +++ b/crates/memtrack/src/ebpf/spawn.rs @@ -0,0 +1,108 @@ +use anyhow::{Result, bail}; +use std::ffi::{OsStr, OsString}; +use std::process::{Child, Command}; + +/// Build a shell stub that stops itself with `SIGSTOP` right after the shell +/// execs, then `exec`s the real target in the same pid. +/// +/// A `pre_exec` hook that raised `SIGSTOP` cannot be used: it blocks the child +/// before `execve`, and `Command::spawn` blocks reading its exec-status pipe +/// until the child execs, so the two deadlock. The stub sidesteps that — the +/// shell execs immediately (spawn returns), then stops itself. The caller arms +/// tracking while it is stopped and resumes it; the target's own `execve` then +/// happens in the already-tracked pid, so the watcher observes its mappings. +pub fn stopped_command(program: impl AsRef, args: &[OsString]) -> Command { + let mut cmd = Command::new("sh"); + cmd.arg("-c") + .arg(r#"kill -STOP "$$"; exec "$@""#) + .arg("sh") + .arg(program.as_ref()) + .args(args); + cmd +} + +/// Re-wrap an already-configured command as a self-stopping shell stub, +/// preserving its program, args, explicit env, and working directory. +/// +/// `uid`/`gid` cannot be read back from a `Command`; apply those to the returned +/// command if needed. +pub fn wrap_stopped(command: &Command) -> Command { + let args: Vec = command.get_args().map(OsStr::to_os_string).collect(); + let mut wrapped = stopped_command(command.get_program(), &args); + + for (key, val) in command.get_envs() { + match val { + Some(val) => { + wrapped.env(key, val); + } + None => { + wrapped.env_remove(key); + } + } + } + if let Some(dir) = command.get_current_dir() { + wrapped.current_dir(dir); + } + + wrapped +} + +/// Spawn a command built by [`stopped_command`] / [`wrap_stopped`] and block +/// until the shell stub group-stops itself. +pub fn spawn_stopped(cmd: &mut Command) -> Result { + let child = cmd.spawn()?; + let pid = child.id() as i32; + + let mut status: libc::c_int = 0; + let ret = unsafe { libc::waitpid(pid, &mut status, libc::WUNTRACED) }; + if ret != pid || !libc::WIFSTOPPED(status) { + bail!("child {pid} exited before tracking was armed (status {status:#x})"); + } + + Ok(child) +} + +/// Resume a process previously stopped by [`spawn_stopped`]. +/// +/// A vanished process (`ESRCH`) is treated as success: the stop is moot. +pub fn resume(pid: i32) -> Result<()> { + let ret = unsafe { libc::kill(pid, libc::SIGCONT) }; + if ret == 0 { + return Ok(()); + } + + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(libc::ESRCH) { + return Ok(()); + } + bail!("failed to SIGCONT {pid}: {err}"); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Regression for the pre_exec-SIGSTOP deadlock: this must not hang, and the + /// stub must be group-stopped before we resume it to completion. + #[test] + fn spawn_stopped_stops_then_resumes() { + let mut cmd = stopped_command("true", &[]); + let mut child = spawn_stopped(&mut cmd).expect("spawn_stopped should not hang"); + let pid = child.id() as i32; + + let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap(); + let state = stat + .rsplit(')') + .next() + .and_then(|s| s.trim_start().chars().next()) + .unwrap(); + assert!( + matches!(state, 'T' | 't'), + "expected stopped, got {state:?}" + ); + + resume(pid).expect("resume"); + let status = child.wait().expect("wait"); + assert!(status.success(), "stub-exec'd target should exit 0"); + } +} diff --git a/crates/memtrack/src/ebpf/tracker.rs b/crates/memtrack/src/ebpf/tracker.rs index eb53e6e7..11ecaf04 100644 --- a/crates/memtrack/src/ebpf/tracker.rs +++ b/crates/memtrack/src/ebpf/tracker.rs @@ -1,69 +1,102 @@ -use crate::ebpf::poller::RingBufferPoller; +use crate::ebpf::MemtrackBpf; +use crate::ebpf::attach_worker::AttachWorker; +use crate::ebpf::spawn::{resume, spawn_stopped, wrap_stopped}; use crate::prelude::*; -use crate::{AllocatorLib, ebpf::MemtrackBpf}; -use crossbeam_channel::Receiver; -use runner_shared::artifacts::MemtrackEvent as Event; +use crate::session::Session; +use parking_lot::Mutex; +use std::os::unix::process::CommandExt; +use std::process::Command; +use std::sync::Arc; +use std::sync::mpsc; pub struct Tracker { - bpf: MemtrackBpf, - poller: Option, + bpf: Arc>, + worker: Mutex>, } impl Tracker { - /// Create a new tracker instance - /// - /// This will: - /// - Initialize the BPF subsystem - /// - Bump memlock limits - /// - Attach uprobes to all libc instances - /// - Attach tracepoints for fork tracking + /// Create a new tracker. The exec-mapping watcher discovers and attaches + /// allocator probes as the tracked process tree maps executable files. pub fn new() -> Result { - let mut instance = Self::new_without_allocators()?; - - let allocators = AllocatorLib::find_all()?; - debug!("Found {} allocator instance(s)", allocators.len()); - instance.attach_allocators(&allocators)?; - - Ok(instance) - } - - pub fn new_without_allocators() -> Result { - // Bump memlock limits Self::bump_memlock_rlimit()?; let mut bpf = MemtrackBpf::new()?; bpf.attach_tracepoints()?; + bpf.attach_exec_watcher()?; + + let bpf = Arc::new(Mutex::new(bpf)); + let worker = AttachWorker::start(bpf.clone())?; - Ok(Self { bpf, poller: None }) + Ok(Self { + bpf, + worker: Mutex::new(Some(worker)), + }) } - pub fn attach_allocators(&mut self, libs: &[AllocatorLib]) -> Result<()> { - self.bpf.attach_allocators(libs) + /// Spawn `cmd` under tracking: the target is wrapped so it stops itself + /// before exec'ing, its pid is armed while stopped, then it is resumed. + /// The watcher observes the target's own `execve` mappings — no allocation + /// escapes untracked. + /// + /// `uid_gid` drops the child's privileges (a `Command`'s uid/gid cannot be + /// read back, so it cannot be preserved through the wrap). + pub fn spawn(&self, cmd: &Command, uid_gid: Option<(u32, u32)>) -> Result { + let mut wrapped = wrap_stopped(cmd); + if let Some((uid, gid)) = uid_gid { + wrapped.uid(uid).gid(gid); + } + + let child = spawn_stopped(&mut wrapped)?; + let pid = child.id() as i32; + self.worker + .lock() + .as_ref() + .context("tracker already finished")? + .set_root_pid(pid); + + let (tx, rx) = mpsc::channel(); + let poller = { + let mut bpf = self.bpf.lock(); + bpf.add_tracked_pid(pid)?; + bpf.poll_events_with_channel(10, tx)? + }; + resume(pid)?; + + Ok(Session::new(child, rx, poller)) } - pub fn attach_allocator(&mut self, lib: &AllocatorLib) -> Result<()> { - self.bpf.attach_allocator_probes(lib.kind, &lib.path) + /// Enable event tracking in the BPF program + pub fn enable_tracking(&self) -> Result<()> { + self.bpf.lock().enable_tracking() } - /// Start tracking allocations for a specific PID. - /// - /// Returns a receiver of allocation events. The poller is owned by the tracker - /// and keeps running until [`Tracker::stop_polling`] is called or the tracker - /// is dropped. - pub fn track(&mut self, pid: i32) -> Result> { - self.bpf.add_tracked_pid(pid)?; - debug!("Tracking PID {pid}"); + /// Disable event tracking in the BPF program + pub fn disable_tracking(&self) -> Result<()> { + self.bpf.lock().disable_tracking() + } - let (poller, event_rx) = self.bpf.start_polling_with_channel(10)?; - self.poller = Some(poller); + /// Number of events the kernel dropped because the ring buffer was full. + /// A non-zero value means the resulting trace is incomplete. + pub fn dropped_events_count(&self) -> Result { + self.bpf.lock().dropped_events_count() + } - Ok(event_rx) + /// Stop the attach worker and surface any fatal error it recorded, + /// including missed exec mappings (incomplete allocator coverage). + pub fn finish(&self) -> Result<()> { + let worker = self + .worker + .lock() + .take() + .context("tracker already finished")?; + worker.finish() } - /// Stop the poll thread, draining ring-buffer stragglers. This closes the - /// event channel returned by [`track`]. - pub fn stop_polling(&mut self) { - self.poller.take(); + /// Detach all attached probes. Called explicitly at teardown because the + /// process may exit without ever dropping the tracker (the IPC thread holds + /// an Arc clone), in which case the kernel would close each link fd serially. + pub fn detach(&self) { + self.bpf.lock().detach_probes(); } /// Bump RLIMIT_MEMLOCK for kernels older than 5.11. Newer kernels account BPF @@ -84,27 +117,4 @@ impl Tracker { Ok(()) } - - /// Enable event tracking in the BPF program - pub fn enable(&mut self) -> anyhow::Result<()> { - self.bpf.enable_tracking() - } - - /// Disable event tracking in the BPF program - pub fn disable(&mut self) -> anyhow::Result<()> { - self.bpf.disable_tracking() - } - - /// Detach all attached probes. Called explicitly at teardown because the - /// process may exit without ever dropping the tracker, in which case the - /// kernel would close each link fd serially at exit. - pub fn detach(&mut self) { - self.bpf.detach_probes(); - } - - /// Number of events the kernel dropped because the ring buffer was full. - /// A non-zero value means the resulting trace is incomplete. - pub fn dropped_events_count(&self) -> anyhow::Result { - self.bpf.dropped_events_count() - } } diff --git a/crates/memtrack/src/ipc.rs b/crates/memtrack/src/ipc.rs index 2b0d80d5..5d4b13d3 100644 --- a/crates/memtrack/src/ipc.rs +++ b/crates/memtrack/src/ipc.rs @@ -85,26 +85,20 @@ impl MemtrackIpcClient { /// Handle incoming IPC messages in memtrack #[cfg(feature = "ebpf")] -pub fn handle_ipc_message(msg: IpcMessage, tracker: &std::sync::Arc>) { +pub fn handle_ipc_message(msg: IpcMessage, tracker: &Tracker) { let response = match msg.command { - IpcCommand::Enable => match tracker.lock() { - Ok(mut t) => match t.enable() { - Ok(_) => { - debug!("Tracking enabled"); - IpcResponse::Ack - } - Err(_) => IpcResponse::Err, - }, + IpcCommand::Enable => match tracker.enable_tracking() { + Ok(_) => { + debug!("Tracking enabled"); + IpcResponse::Ack + } Err(_) => IpcResponse::Err, }, - IpcCommand::Disable => match tracker.lock() { - Ok(mut t) => match t.disable() { - Ok(_) => { - debug!("Tracking disabled"); - IpcResponse::Ack - } - Err(_) => IpcResponse::Err, - }, + IpcCommand::Disable => match tracker.disable_tracking() { + Ok(_) => { + debug!("Tracking disabled"); + IpcResponse::Ack + } Err(_) => IpcResponse::Err, }, IpcCommand::Ping => IpcResponse::Ack, diff --git a/crates/memtrack/src/lib.rs b/crates/memtrack/src/lib.rs index dc32092a..1186ef2d 100644 --- a/crates/memtrack/src/lib.rs +++ b/crates/memtrack/src/lib.rs @@ -3,6 +3,8 @@ mod allocators; mod ebpf; mod ipc; pub mod prelude; +#[cfg(feature = "ebpf")] +mod session; pub use allocators::{AllocatorKind, AllocatorLib}; pub use ipc::{ @@ -12,6 +14,8 @@ pub use ipc::{ #[cfg(feature = "ebpf")] pub use ebpf::*; +#[cfg(feature = "ebpf")] +pub use session::Session; #[cfg(feature = "ebpf")] pub use ipc::handle_ipc_message; diff --git a/crates/memtrack/src/main.rs b/crates/memtrack/src/main.rs index 24fc75af..8ad48791 100644 --- a/crates/memtrack/src/main.rs +++ b/crates/memtrack/src/main.rs @@ -3,10 +3,9 @@ use ipc_channel::ipc; use memtrack::prelude::*; use memtrack::{MemtrackIpcMessage, Tracker, handle_ipc_message}; use runner_shared::artifacts::{ArtifactExt, MemtrackArtifact, encode_events}; -use std::os::unix::process::CommandExt; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::thread; #[derive(Parser)] @@ -85,41 +84,38 @@ fn track_command( None }; - let tracker = Tracker::new()?; - let tracker_arc = Arc::new(Mutex::new(tracker)); + let tracker = Arc::new(Tracker::new()?); // Spawn IPC handler thread with the now-available tracker let ipc_handle = if let Some(rx) = ipc_channel { - let tracker_clone = tracker_arc.clone(); + let tracker = tracker.clone(); Some(thread::spawn(move || { while let Ok(msg) = rx.recv() { - handle_ipc_message(msg, &tracker_clone); + handle_ipc_message(msg, &tracker); } })) } else { // Without IPC, nothing toggles the tracking_enabled map, so events would // be dropped by the eBPF is_enabled() check. Enable it up front. - tracker_arc.lock().unwrap().enable()?; + tracker.enable_tracking()?; None }; - // Start the target command using bash to handle shell syntax + // Run the target command through bash to handle shell syntax. Drop + // privileges if running under sudo to avoid permission issues when the + // target accesses files owned by the original user. let mut cmd = Command::new("bash"); cmd.arg("-c").arg(cmd_string); - - // Drop privileges if running under sudo. This is required to avoid permission issues - // when the target command tries to access files or directories that the current user - // does not have permission to access. - if let Some((uid, gid)) = get_user_uid_gid() { + let uid_gid = get_user_uid_gid(); + if let Some((uid, gid)) = uid_gid { debug!("Running under sudo, dropping privileges to uid={uid}, gid={gid}"); - cmd.uid(uid).gid(gid); } - let mut child = cmd - .spawn() + let mut session = tracker + .spawn(&cmd, uid_gid) .map_err(|e| anyhow!("Failed to spawn child process: {e}"))?; - let root_pid = child.id() as i32; - let event_rx = { tracker_arc.lock().unwrap().track(root_pid)? }; + let root_pid = session.pid(); + let event_rx = session.take_events()?; debug!("Spawned child with pid {root_pid}"); // Generate output file name and create file for streaming events @@ -136,26 +132,20 @@ fn track_command( let pipeline_thread = thread::spawn(move || encode_events(event_rx, out_file, n_workers)); // Wait for the command to complete - let status = child.wait().context("Failed to wait for command")?; + let status = session.wait().context("Failed to wait for command")?; debug!("Command exited with status: {status}"); // Stop event production before draining: the child has exited, so anything // still arriving is already in the ring buffer. - let disable_result = tracker_arc - .lock() - .map_err(|_| anyhow!("tracker mutex poisoned"))? - .disable(); - if let Err(e) = disable_result { + if let Err(e) = tracker.disable_tracking() { warn!("Failed to disable tracking: {e:#}"); } - // Stopping the poller closes the event channel; without this the pipeline - // join below would block forever. + // Dropping the session drops the event poller, which does a final drain of + // the ring buffer and then closes the event channel. Without this the + // encode pipeline join below would block forever. debug!("Stopping the ring buffer poller"); - tracker_arc - .lock() - .map_err(|_| anyhow!("tracker mutex poisoned"))? - .stop_polling(); + drop(session); debug!("Waiting for the encode pipeline to finish"); let total = pipeline_thread @@ -164,20 +154,19 @@ fn track_command( info!("Wrote {total} memtrack events to disk"); + // Stop the attach worker and surface any fatal error it recorded (missed + // exec mappings mean incomplete allocator coverage). + tracker.finish()?; + // Detach probes explicitly: the IPC thread still holds an Arc clone, so the // tracker would otherwise never be dropped before process::exit and the // kernel would close every link fd serially during exit. - tracker_arc - .lock() - .map_err(|_| anyhow!("tracker mutex poisoned"))? - .detach(); + tracker.detach(); // Read the eBPF dropped-event counter after the run is complete. // A non-zero value means the ring buffer overflowed and the trace is // incomplete. - let dropped_events = tracker_arc - .lock() - .map_err(|_| anyhow!("tracker mutex poisoned"))? + let dropped_events = tracker .dropped_events_count() .context("Failed to read memtrack dropped-event counter")?; if dropped_events > 0 { diff --git a/crates/memtrack/src/session.rs b/crates/memtrack/src/session.rs new file mode 100644 index 00000000..7aec33fe --- /dev/null +++ b/crates/memtrack/src/session.rs @@ -0,0 +1,41 @@ +use crate::ebpf::poller::RingBufferPoller; +use crate::prelude::*; +use runner_shared::artifacts::MemtrackEvent; +use std::process::{Child, ExitStatus}; +use std::sync::mpsc::Receiver; + +/// A spawned, tracked process together with its event pipeline. The pipeline +/// stays alive as long as the session does; dropping it stops event delivery. +pub struct Session { + child: Child, + events: Option>, + _poller: RingBufferPoller, +} + +impl Session { + pub(crate) fn new( + child: Child, + events: Receiver, + poller: RingBufferPoller, + ) -> Self { + Self { + child, + events: Some(events), + _poller: poller, + } + } + + pub fn pid(&self) -> i32 { + self.child.id() as i32 + } + + /// Take ownership of the event receiver. Can only be taken once. + pub fn take_events(&mut self) -> Result> { + self.events.take().context("events already taken") + } + + /// Wait for the tracked process to exit. + pub fn wait(&mut self) -> Result { + Ok(self.child.wait()?) + } +} From 6c7ff4789f897029a3d7be1891c889ed0df5ca6e Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 8 Jul 2026 21:42:38 +0200 Subject: [PATCH 3/9] feat(memtrack)!: remove eager allocator discovery On-demand attach supersedes the startup scan: delete the system-wide library glob, the build-dir walk, and the CODSPEED_MEMTRACK_BINARIES env var (the runner no longer resolves exec target binaries for it). BREAKING CHANGE: CODSPEED_MEMTRACK_BINARIES is no longer read Refs COD-1801 --- Cargo.lock | 1 - crates/memtrack/Cargo.toml | 1 - crates/memtrack/src/allocators/dynamic.rs | 76 --------------- crates/memtrack/src/allocators/mod.rs | 61 ------------ .../memtrack/src/allocators/static_linked.rs | 97 +------------------ src/cli/exec/mod.rs | 38 +------- 6 files changed, 3 insertions(+), 271 deletions(-) delete mode 100644 crates/memtrack/src/allocators/dynamic.rs diff --git a/Cargo.lock b/Cargo.lock index 86eacac9..0dfb2a92 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2380,7 +2380,6 @@ dependencies = [ "clap", "crossbeam-channel", "env_logger", - "glob", "insta", "ipc-channel", "itertools 0.14.0", diff --git a/crates/memtrack/Cargo.toml b/crates/memtrack/Cargo.toml index 8a2558bf..c4311d9e 100644 --- a/crates/memtrack/Cargo.toml +++ b/crates/memtrack/Cargo.toml @@ -33,7 +33,6 @@ static_assertions = "1.1" itertools = { workspace = true } paste = "1.0.15" libbpf-rs = { version = "0.26", features = ["vendored"], optional = true } -glob = "0.3.3" object = { workspace = true } rayon = "1.12" parking_lot = "0.12" diff --git a/crates/memtrack/src/allocators/dynamic.rs b/crates/memtrack/src/allocators/dynamic.rs deleted file mode 100644 index db5b4df8..00000000 --- a/crates/memtrack/src/allocators/dynamic.rs +++ /dev/null @@ -1,76 +0,0 @@ -use crate::{AllocatorKind, AllocatorLib}; -use std::path::PathBuf; - -impl AllocatorKind { - /// Build glob patterns for finding this allocator's shared libraries. - fn search_patterns(&self) -> Vec { - const LIB_DIRS: &[&str] = &[ - // Debian, Ubuntu: multiarch paths - "/lib/*-linux-gnu", - "/usr/lib/*-linux-gnu", - // RHEL, Fedora, CentOS, Arch - "/lib*", - "/usr/lib*", - // Local installs - "/usr/local/lib*", - ]; - - let (filenames, nix_hints): (&[&str], &[&str]) = match self { - AllocatorKind::Libc => (&["libc.so.6"], &["glibc"]), - AllocatorKind::LibCpp => (&["libstdc++.so*"], &["gcc"]), - AllocatorKind::Jemalloc => (&["libjemalloc.so*"], &["jemalloc"]), - AllocatorKind::Mimalloc => (&["libmimalloc.so*"], &["mimalloc"]), - AllocatorKind::Tcmalloc => (&["libtcmalloc*.so*"], &["tcmalloc", "gperftools"]), - }; - - let mut patterns = Vec::new(); - - for dir in LIB_DIRS { - for filename in filenames { - patterns.push(format!("{dir}/{filename}")); - } - } - - for hint in nix_hints { - for filename in filenames { - patterns.push(format!("/nix/store/*{hint}*/lib/{filename}")); - } - } - - patterns - } -} - -/// Find dynamically linked allocator libraries on the system. -pub fn find_all() -> anyhow::Result> { - use std::collections::HashSet; - - let mut results = Vec::new(); - let mut seen_paths: HashSet = HashSet::new(); - - for kind in AllocatorKind::all() { - for pattern in kind.search_patterns() { - let paths = glob::glob(&pattern) - .ok() - .into_iter() - .flatten() - .filter_map(|p| p.ok()) - .filter_map(|p| p.canonicalize().ok()) - .filter(|path| { - std::fs::metadata(path) - .map(|m| m.is_file()) - .unwrap_or(false) - }) - .filter(|path| super::is_elf(path)) - .collect::>(); - - for path in paths { - if seen_paths.insert(path.clone()) { - results.push(AllocatorLib { kind: *kind, path }); - } - } - } - } - - Ok(results) -} diff --git a/crates/memtrack/src/allocators/mod.rs b/crates/memtrack/src/allocators/mod.rs index a2c4a1fb..d1a429ad 100644 --- a/crates/memtrack/src/allocators/mod.rs +++ b/crates/memtrack/src/allocators/mod.rs @@ -5,7 +5,6 @@ use std::path::PathBuf; -mod dynamic; mod static_linked; /// Represents the different allocator types we support. @@ -67,63 +66,3 @@ pub struct AllocatorLib { pub kind: AllocatorKind, pub path: PathBuf, } - -impl AllocatorLib { - pub fn find_all() -> anyhow::Result> { - let mut allocators = static_linked::find_all()?; - allocators.extend(Self::find_from_env()); - allocators.extend(dynamic::find_all()?); - Ok(allocators) - } - - /// Discover allocators from binaries listed in the `CODSPEED_MEMTRACK_BINARIES` env var. - /// - /// The variable is a platform path-list. Each path is checked for - /// a statically linked allocator via [`AllocatorLib::from_path_static`]. - /// Invalid or missing paths are silently skipped (with a debug log). - fn find_from_env() -> Vec { - let Some(raw) = std::env::var_os("CODSPEED_MEMTRACK_BINARIES") else { - return vec![]; - }; - - std::env::split_paths(&raw) - .filter(|p| !p.as_os_str().is_empty()) - .filter_map(|p| { - let path = p.as_path(); - match Self::from_path_static(path) { - Ok(alloc) => { - log::debug!( - "Found {} allocator in env-specified binary: {}", - alloc.kind.name(), - path.display() - ); - Some(alloc) - } - Err(e) => { - log::debug!("Skipping env-specified binary {}: {e}", path.display()); - None - } - } - }) - .collect() - } -} - -/// Check if a file is an ELF binary by reading its magic bytes. -fn is_elf(path: &std::path::Path) -> bool { - use std::fs; - use std::io::Read; - - let mut file = match fs::File::open(path) { - Ok(f) => f, - Err(_) => return false, - }; - - let mut magic = [0u8; 4]; - if file.read_exact(&mut magic).is_err() { - return false; - } - - // ELF magic: 0x7F 'E' 'L' 'F' - magic == [0x7F, b'E', b'L', b'F'] -} diff --git a/crates/memtrack/src/allocators/static_linked.rs b/crates/memtrack/src/allocators/static_linked.rs index 9695070d..d1891303 100644 --- a/crates/memtrack/src/allocators/static_linked.rs +++ b/crates/memtrack/src/allocators/static_linked.rs @@ -1,6 +1,6 @@ use std::collections::HashSet; use std::fs; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::allocators::{AllocatorKind, AllocatorLib}; @@ -17,79 +17,6 @@ impl AllocatorKind { } } -/// Walk upward and downward from current directory to find build directories. -/// Returns all found build directories in order of preference. -fn find_build_dirs() -> Vec { - let mut dirs = Vec::new(); - let Ok(current_dir) = std::env::current_dir() else { - return dirs; - }; - - let patterns = ["target/codspeed/analysis", "bazel-bin", "build"]; - let mut check_patterns = |dir: &Path| { - for pattern in &patterns { - let path = dir.join(pattern); - if path.is_dir() { - dirs.push(path); - } - } - }; - - // Walk upward from parent directories - // Note: We skip current_dir here since the downward walk (below) already checks it - let mut current = current_dir.clone(); - while current.pop() { - check_patterns(¤t); - } - - // Walk downward from current directory - let mut stack = vec![current_dir]; - while let Some(dir) = stack.pop() { - check_patterns(&dir); - - // Read subdirectories - let Ok(entries) = fs::read_dir(&dir) else { - continue; - }; - - for entry in entries.filter_map(Result::ok) { - let path = entry.path(); - - let Some(name) = path.file_name().and_then(|n| n.to_str()) else { - continue; - }; - - // Skip hidden dirs and common excludes - if name.starts_with('.') || matches!(name, "node_modules" | "vendor" | "venv") { - continue; - } - - // Don't recursive into dirs that we want to match. - // This can happen with `target` as it contains build dirs for statically linked crates. - if matches!(name, "target" | "bazel-bin" | "build") { - continue; - } - - if path.is_file() { - continue; - } - - stack.push(path); - } - } - - dirs -} - -fn find_binaries_in_dir(dir: &Path) -> Vec { - glob::glob(&format!("{}/**/*", dir.display())) - .into_iter() - .flatten() - .filter_map(Result::ok) - .filter(|p| p.is_file() && super::is_elf(p)) - .collect::>() -} - fn find_statically_linked_allocator(path: &Path) -> Option { use object::{Object, ObjectSymbol}; @@ -111,28 +38,6 @@ fn find_statically_linked_allocator(path: &Path) -> Option { .copied() } -pub fn find_all() -> anyhow::Result> { - let build_dirs = find_build_dirs(); - if build_dirs.is_empty() { - return Ok(vec![]); - } - - let mut allocators = Vec::new(); - for build_dir in build_dirs { - let bins = find_binaries_in_dir(&build_dir); - - for bin in bins { - let Some(kind) = find_statically_linked_allocator(&bin) else { - continue; - }; - - allocators.push(AllocatorLib { kind, path: bin }); - } - } - - Ok(allocators) -} - impl AllocatorLib { pub fn from_path_static(path: &Path) -> Result> { let kind = find_statically_linked_allocator(path).ok_or("No allocator found")?; diff --git a/src/cli/exec/mod.rs b/src/cli/exec/mod.rs index cefe5c75..89952255 100644 --- a/src/cli/exec/mod.rs +++ b/src/cli/exec/mod.rs @@ -8,7 +8,7 @@ use crate::project_config::ProjectConfig; use crate::project_config::merger::ConfigMerger; use crate::upload::poll_results::PollResultsOptions; use clap::Args; -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::path::Path; use url::Url; @@ -121,44 +121,10 @@ pub async fn run( /// Sets up the orchestrator and drives execution. Exec-harness installation is handled /// by the orchestrator when exec targets are present. pub async fn execute_config( - mut config: OrchestratorConfig, + config: OrchestratorConfig, api_client: &mut CodSpeedAPIClient, setup_cache_dir: Option<&Path>, ) -> Result<()> { - // Resolve exec target binary paths so memtrack can discover statically linked - // allocators (which may not live in known build dirs). - let memtrack_binaries: HashSet<_> = config - .targets - .iter() - .filter_map(|t| match t { - executor::BenchmarkTarget::Exec { command, .. } => command.first().cloned(), - _ => None, - }) - .filter_map(|bin| { - let result = match &config.working_directory { - Some(cwd) => which::which_in(&bin, std::env::var_os("PATH"), cwd), - None => which::which(&bin), - }; - result.ok() - }) - .collect(); - - if !memtrack_binaries.is_empty() { - let mut all_paths = memtrack_binaries; - - // Merge with any user-provided value from the parent environment. - if let Some(existing) = std::env::var_os("CODSPEED_MEMTRACK_BINARIES") { - all_paths.extend(std::env::split_paths(&existing)); - } - - let joined = - std::env::join_paths(&all_paths).expect("memtrack binary paths should be joinable"); - config.extra_env.insert( - "CODSPEED_MEMTRACK_BINARIES".into(), - joined.to_string_lossy().into_owned(), - ); - } - let orchestrator = executor::Orchestrator::new(config, api_client).await?; if !orchestrator.is_local() { From d29dc04232312a1665d9a0cf9734b7b076be18e3 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Wed, 8 Jul 2026 21:54:47 +0200 Subject: [PATCH 4/9] fix(memtrack): classify unprefixed jemalloc builds as jemalloc Distro libjemalloc is built without the je_ symbol prefix, so symbol classification missed it and fell through to libc++ (jemalloc exports operator new), leaving plain malloc/aligned_alloc unprobed. Match on mallocx, which keeps its name in unprefixed builds and is unique to jemalloc. --- crates/memtrack/src/allocators/static_linked.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/memtrack/src/allocators/static_linked.rs b/crates/memtrack/src/allocators/static_linked.rs index d1891303..0c2c7d79 100644 --- a/crates/memtrack/src/allocators/static_linked.rs +++ b/crates/memtrack/src/allocators/static_linked.rs @@ -10,7 +10,12 @@ impl AllocatorKind { match self { AllocatorKind::Libc => &["malloc", "free"], AllocatorKind::LibCpp => &["_Znwm", "_Znam", "_ZdlPv", "_ZdaPv"], - AllocatorKind::Jemalloc => &["_rjem_malloc", "je_malloc", "je_malloc_default"], + // "mallocx" covers unprefixed jemalloc builds (e.g. distro + // libjemalloc.so): the extended API keeps its name when the je_ + // prefix is disabled, and no other allocator exports it. + AllocatorKind::Jemalloc => { + &["_rjem_malloc", "je_malloc", "je_malloc_default", "mallocx"] + } AllocatorKind::Mimalloc => &["mi_malloc_aligned", "mi_malloc", "mi_free"], AllocatorKind::Tcmalloc => &["tc_malloc", "tc_free", "tc_version"], } From 4d3fbb05ecdb322a62d2b87ae018152d0a9699c1 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Thu, 9 Jul 2026 18:48:45 +0200 Subject: [PATCH 5/9] test(memtrack): add dlopen/on-demand allocator tests + CI matrix --- .github/workflows/ci.yml | 2 +- .../memtrack/testdata/dlopen/dlopen_alloc.c | 34 +++++ .../memtrack/testdata/dlopen/fake_jemalloc.c | 20 +++ .../memtrack/testdata/dlopen/fake_mimalloc.c | 21 +++ .../memtrack/testdata/dlopen/thread_dlopen.c | 53 +++++++ crates/memtrack/tests/cpp_tests.rs | 28 ++-- crates/memtrack/tests/dlopen_tests.rs | 131 ++++++++++++++++++ crates/memtrack/tests/rust_tests.rs | 10 +- crates/memtrack/tests/shared.rs | 79 ++++++----- ...awn_tests__spawn_on_demand_discovery.snap} | 1 - .../spawn_tests__spawn_without_discovery.snap | 6 - crates/memtrack/tests/spawn_tests.rs | 35 +---- 12 files changed, 327 insertions(+), 93 deletions(-) create mode 100644 crates/memtrack/testdata/dlopen/dlopen_alloc.c create mode 100644 crates/memtrack/testdata/dlopen/fake_jemalloc.c create mode 100644 crates/memtrack/testdata/dlopen/fake_mimalloc.c create mode 100644 crates/memtrack/testdata/dlopen/thread_dlopen.c create mode 100644 crates/memtrack/tests/dlopen_tests.rs rename crates/memtrack/tests/snapshots/{spawn_tests__spawn_with_discovery.snap => spawn_tests__spawn_on_demand_discovery.snap} (89%) delete mode 100644 crates/memtrack/tests/snapshots/spawn_tests__spawn_without_discovery.snap diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 49543b75..d6935beb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -95,7 +95,7 @@ jobs: # Each memtrack integration test binary runs its cases serially # (eBPF tracker can't overlap with itself in one process), so we # shard at the test-binary level to parallelize across jobs. - test: [c_tests, cpp_tests, rust_tests, spawn_tests] + test: [c_tests, cpp_tests, rust_tests, spawn_tests, dlopen_tests] steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: diff --git a/crates/memtrack/testdata/dlopen/dlopen_alloc.c b/crates/memtrack/testdata/dlopen/dlopen_alloc.c new file mode 100644 index 00000000..99b91493 --- /dev/null +++ b/crates/memtrack/testdata/dlopen/dlopen_alloc.c @@ -0,0 +1,34 @@ +#include +#include +#include + +/* dlopen an allocator lib and IMMEDIATELY (no sleep) allocate through it. The + * immediacy is the race assertion: every allocation must be captured, which + * requires the on-demand watcher to have stopped-classified-attached-resumed + * before dlopen returns. */ +int main(int argc, char** argv) { + if (argc < 2) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 1; + } + + void* handle = dlopen(argv[1], RTLD_NOW); + if (!handle) { + fprintf(stderr, "dlopen failed: %s\n", dlerror()); + return 1; + } + + void* (*mi_malloc)(size_t) = (void* (*)(size_t))dlsym(handle, "mi_malloc"); + void (*mi_free)(void*) = (void (*)(void*))dlsym(handle, "mi_free"); + if (!mi_malloc || !mi_free) { + fprintf(stderr, "dlsym failed\n"); + return 1; + } + + for (int i = 0; i < 100; i++) { + void* p = mi_malloc(4242); + mi_free(p); + } + + return 0; +} diff --git a/crates/memtrack/testdata/dlopen/fake_jemalloc.c b/crates/memtrack/testdata/dlopen/fake_jemalloc.c new file mode 100644 index 00000000..6fa72fdb --- /dev/null +++ b/crates/memtrack/testdata/dlopen/fake_jemalloc.c @@ -0,0 +1,20 @@ +#include + +/* Self-contained bump allocator; classifies as Jemalloc via the je_malloc + * symbol. See fake_mimalloc.c for why it does not call libc malloc. */ +static char arena[1 << 20]; +static size_t off = 0; + +void* je_malloc(size_t size) { + size_t aligned = (size + 15) & ~((size_t)15); + if (off + aligned > sizeof(arena)) { + return 0; + } + void* p = &arena[off]; + off += aligned; + return p; +} + +void je_free(void* p) { + (void)p; +} diff --git a/crates/memtrack/testdata/dlopen/fake_mimalloc.c b/crates/memtrack/testdata/dlopen/fake_mimalloc.c new file mode 100644 index 00000000..801f89ac --- /dev/null +++ b/crates/memtrack/testdata/dlopen/fake_mimalloc.c @@ -0,0 +1,21 @@ +#include + +/* Self-contained bump allocator so mi_malloc does not delegate to libc malloc + * (which is probed separately and would double-count). Classifies as Mimalloc + * via the mi_malloc/mi_free symbols. */ +static char arena[1 << 20]; +static size_t off = 0; + +void* mi_malloc(size_t size) { + size_t aligned = (size + 15) & ~((size_t)15); + if (off + aligned > sizeof(arena)) { + return 0; + } + void* p = &arena[off]; + off += aligned; + return p; +} + +void mi_free(void* p) { + (void)p; +} diff --git a/crates/memtrack/testdata/dlopen/thread_dlopen.c b/crates/memtrack/testdata/dlopen/thread_dlopen.c new file mode 100644 index 00000000..afdb49dd --- /dev/null +++ b/crates/memtrack/testdata/dlopen/thread_dlopen.c @@ -0,0 +1,53 @@ +#include +#include +#include +#include + +/* Two threads each dlopen a distinct allocator lib concurrently and allocate + * through it. Exercises concurrent stop-the-world: both threads trigger the + * watcher, both must be stopped, both libs classified/attached, both resumed. */ +struct job { + const char* path; + const char* alloc_sym; + const char* free_sym; + size_t size; +}; + +static void* run(void* arg) { + struct job* j = (struct job*)arg; + void* handle = dlopen(j->path, RTLD_NOW); + if (!handle) { + return NULL; + } + + void* (*alloc)(size_t) = (void* (*)(size_t))dlsym(handle, j->alloc_sym); + void (*dealloc)(void*) = (void (*)(void*))dlsym(handle, j->free_sym); + if (!alloc || !dealloc) { + return NULL; + } + + for (int i = 0; i < 100; i++) { + void* p = alloc(j->size); + dealloc(p); + } + + return NULL; +} + +int main(int argc, char** argv) { + if (argc < 3) { + fprintf(stderr, "usage: %s \n", argv[0]); + return 1; + } + + struct job j1 = {argv[1], "mi_malloc", "mi_free", 4242}; + struct job j2 = {argv[2], "je_malloc", "je_free", 4243}; + + pthread_t t1, t2; + pthread_create(&t1, NULL, run, &j1); + pthread_create(&t2, NULL, run, &j2); + pthread_join(t1, NULL); + pthread_join(t2, NULL); + + return 0; +} diff --git a/crates/memtrack/tests/cpp_tests.rs b/crates/memtrack/tests/cpp_tests.rs index 2a219441..b7c5b61c 100644 --- a/crates/memtrack/tests/cpp_tests.rs +++ b/crates/memtrack/tests/cpp_tests.rs @@ -1,12 +1,30 @@ #[macro_use] mod shared; -use memtrack::AllocatorLib; use rstest::rstest; use std::path::Path; use std::process::Command; +/// A cached cmake configure pins absolute library paths from a previous +/// environment (e.g. garbage-collected nix store entries). On failure, wipe +/// the build dir and retry once from a fresh configure. +/// +/// Builds are serialized: parallel test cases share the build dir, and the +/// retry path deletes it. fn compile_cpp_project(project_dir: &Path, target: &str) -> anyhow::Result { + static BUILD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = BUILD_LOCK.lock().unwrap(); + + match build_cpp_target(project_dir, target) { + Ok(path) => Ok(path), + Err(_) => { + std::fs::remove_dir_all(project_dir.join("build"))?; + build_cpp_target(project_dir, target) + } + } +} + +fn build_cpp_target(project_dir: &Path, target: &str) -> anyhow::Result { let build_exists = project_dir.join("build").exists(); if !build_exists { // Configure with cmake -B build @@ -57,13 +75,7 @@ fn test_cpp_alloc_tracking(#[case] target: &str) -> Result<(), Box PathBuf { + let src = dir.join(format!("{name}.c")); + std::fs::write(&src, source).expect("write source"); + let out = dir.join(format!("{name}.so")); + let ok = Command::new("gcc") + .args(["-shared", "-fPIC", "-o"]) + .arg(&out) + .arg(&src) + .status() + .expect("run gcc") + .success(); + assert!(ok, "failed to compile {name}.so"); + out +} + +fn compile_exe(source: &str, name: &str, dir: &Path, libs: &[&str]) -> PathBuf { + let src = dir.join(format!("{name}.c")); + std::fs::write(&src, source).expect("write source"); + let out = dir.join(name); + let ok = Command::new("gcc") + .arg("-o") + .arg(&out) + .arg(&src) + .args(libs) + .status() + .expect("run gcc") + .success(); + assert!(ok, "failed to compile {name}"); + out +} + +/// dlopen -> fentry -> SIGSTOP -> resolve -> classify -> attach -> SIGCONT -> +/// first allocation captured. The dlopen'd allocator's 100 allocations must all +/// be captured, proving the attach completes before the child's first alloc. +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_dlopen_allocator() -> Result<(), Box> { + let dir = TempDir::new()?; + let lib = compile_shared( + include_str!("../testdata/dlopen/fake_mimalloc.c"), + "libfake_mimalloc", + dir.path(), + ); + let exe = compile_exe( + include_str!("../testdata/dlopen/dlopen_alloc.c"), + "dlopen_alloc", + dir.path(), + &["-ldl"], + ); + + let mut cmd = Command::new(&exe); + cmd.arg(&lib); + let (events, thread_handle) = shared::track_command(cmd)?; + + let malloc_addrs: HashSet = events + .iter() + .filter_map(|e| match e.kind { + MemtrackEventKind::Malloc { size: 4242 } => Some(e.addr), + _ => None, + }) + .collect(); + let malloc_count = events + .iter() + .filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4242 })) + .count(); + let free_count = events + .iter() + .filter(|e| matches!(e.kind, MemtrackEventKind::Free) && malloc_addrs.contains(&e.addr)) + .count(); + + assert_eq!(malloc_count, 100, "expected 100 mi_malloc(4242) events"); + assert_eq!( + free_count, 100, + "expected 100 mi_free events for tracked addrs" + ); + + thread_handle.join().unwrap(); + Ok(()) +} + +/// Two threads dlopen distinct allocator libs concurrently. Both libs' 100 +/// allocations each must be captured, exercising concurrent stop-the-world. +#[test_with::env(GITHUB_ACTIONS)] +#[test_log::test] +fn test_thread_dlopen() -> Result<(), Box> { + let dir = TempDir::new()?; + let mimalloc = compile_shared( + include_str!("../testdata/dlopen/fake_mimalloc.c"), + "libfake_mimalloc", + dir.path(), + ); + let jemalloc = compile_shared( + include_str!("../testdata/dlopen/fake_jemalloc.c"), + "libfake_jemalloc", + dir.path(), + ); + let exe = compile_exe( + include_str!("../testdata/dlopen/thread_dlopen.c"), + "thread_dlopen", + dir.path(), + &["-ldl", "-lpthread"], + ); + + let mut cmd = Command::new(&exe); + cmd.arg(&mimalloc).arg(&jemalloc); + let (events, thread_handle) = shared::track_command(cmd)?; + + let m4242 = events + .iter() + .filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4242 })) + .count(); + let m4243 = events + .iter() + .filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4243 })) + .count(); + + assert_eq!(m4242, 100, "expected 100 mi_malloc(4242) events"); + assert_eq!(m4243, 100, "expected 100 je_malloc(4243) events"); + + thread_handle.join().unwrap(); + Ok(()) +} diff --git a/crates/memtrack/tests/rust_tests.rs b/crates/memtrack/tests/rust_tests.rs index 995fc2d5..ce1857aa 100644 --- a/crates/memtrack/tests/rust_tests.rs +++ b/crates/memtrack/tests/rust_tests.rs @@ -1,7 +1,6 @@ #[macro_use] mod shared; -use memtrack::AllocatorLib; use rstest::rstest; use std::path::Path; @@ -18,13 +17,8 @@ fn test_rust_alloc_tracking( let crate_path = Path::new("testdata/alloc_rust"); let binary = shared::compile_rust_binary(crate_path, "alloc_rust", features)?; - // Try to find a static allocator in the binary, then attach to it as well - // This is needed because the CWD is different, which breaks the heuristics. - let allocators = AllocatorLib::from_path_static(&binary) - .map(|a| vec![a]) - .unwrap_or_default(); - - let (events, thread_handle) = shared::track_binary_with_opts(&binary, &allocators)?; + // No extra allocators: the watcher must discover the static allocator itself. + let (events, thread_handle) = shared::track_binary(&binary)?; assert_events_with_marker!(name, &events); thread_handle.join().unwrap(); diff --git a/crates/memtrack/tests/shared.rs b/crates/memtrack/tests/shared.rs index 054b3b8e..d31bce09 100644 --- a/crates/memtrack/tests/shared.rs +++ b/crates/memtrack/tests/shared.rs @@ -1,12 +1,10 @@ #![allow(dead_code, unused)] -use anyhow::Context; +use memtrack::Tracker; use memtrack::prelude::*; -use memtrack::{AllocatorLib, Tracker}; use runner_shared::artifacts::{MemtrackEvent as Event, MemtrackEventKind}; use std::path::Path; use std::process::Command; -use std::time::Duration; type TrackResult = anyhow::Result<(Vec, std::thread::JoinHandle<()>)>; @@ -101,14 +99,28 @@ macro_rules! assert_events_with_marker { } /// Compile a Rust binary from a test crate directory. +/// +/// Each feature set builds into its own target dir: parallel test cases would +/// otherwise overwrite the same output binary and race against each other. pub fn compile_rust_binary( crate_dir: &Path, name: &str, features: &[&str], ) -> anyhow::Result { + let target_dir = match features { + [] => "target/default".to_string(), + _ => format!("target/{}", features.join("-")), + }; + let mut cmd = Command::new("cargo"); - cmd.current_dir(crate_dir) - .args(["build", "--release", "--bin", name]); + cmd.current_dir(crate_dir).args([ + "build", + "--release", + "--bin", + name, + "--target-dir", + &target_dir, + ]); if !features.is_empty() { cmd.arg("--features").arg(features.join(",")); @@ -121,51 +133,38 @@ pub fn compile_rust_binary( return Err(anyhow::anyhow!("Failed to compile Rust crate")); } - Ok(crate_dir.join(format!("target/release/{name}"))) + Ok(crate_dir.join(format!("{target_dir}/release/{name}"))) } -/// Track a spawned command, collecting all memory events. +/// Track a binary, collecting all memory events. +pub fn track_binary(binary: &Path) -> TrackResult { + track_command(Command::new(binary)) +} + +/// Track a command, collecting all memory events. /// -/// When `discover_system_allocators` is true, the tracker will scan for all -/// allocators on the system (slower). When false, only `extra_allocators` are used. -pub fn track_command( - mut command: Command, - extra_allocators: &[AllocatorLib], - discover_system_allocators: bool, -) -> TrackResult { - // IMPORTANT: Always initialize the tracker BEFORE spawning the binary, as it can take some time to - // attach to all the allocator libraries (especially when using NixOS). - let mut tracker = if discover_system_allocators { - memtrack::Tracker::new()? - } else { - memtrack::Tracker::new_without_allocators()? - }; - tracker.attach_allocators(extra_allocators)?; +/// No allocators are pre-attached: the exec-mapping watcher discovers and +/// attaches them as the tracked tree maps executable files. +pub fn track_command(command: Command) -> TrackResult { + let tracker = Tracker::new()?; + tracker.enable_tracking()?; - let child = command.spawn().context("Failed to spawn command")?; - let root_pid = child.id() as i32; + let mut session = tracker.spawn(&command, None)?; + let rx = session.take_events()?; - tracker.enable()?; - let rx = tracker.track(root_pid)?; + session.wait()?; + // Dropping the session does a final ring buffer drain and closes the + // channel, so collecting terminates without a silence timeout. + drop(session); + let events: Vec = rx.iter().collect(); - let mut events = Vec::new(); - while let Ok(event) = rx.recv_timeout(Duration::from_secs(10)) { - events.push(event); - } + tracker.finish()?; - // Drop the tracker in a new thread to not block the test - let thread_handle = std::thread::spawn(move || core::mem::drop(tracker)); + // Drop the tracker in a new thread to not block the test. + let thread_handle = std::thread::spawn(move || drop(tracker)); info!("Tracked {} events", events.len()); trace!("Events: {events:#?}"); Ok((events, thread_handle)) } - -pub fn track_binary_with_opts(binary: &Path, extra_allocators: &[AllocatorLib]) -> TrackResult { - track_command(Command::new(binary), extra_allocators, true) -} - -pub fn track_binary(binary: &Path) -> TrackResult { - track_binary_with_opts(binary, &[]) -} diff --git a/crates/memtrack/tests/snapshots/spawn_tests__spawn_with_discovery.snap b/crates/memtrack/tests/snapshots/spawn_tests__spawn_on_demand_discovery.snap similarity index 89% rename from crates/memtrack/tests/snapshots/spawn_tests__spawn_with_discovery.snap rename to crates/memtrack/tests/snapshots/spawn_tests__spawn_on_demand_discovery.snap index ab014ba8..f36dc606 100644 --- a/crates/memtrack/tests/snapshots/spawn_tests__spawn_with_discovery.snap +++ b/crates/memtrack/tests/snapshots/spawn_tests__spawn_on_demand_discovery.snap @@ -1,6 +1,5 @@ --- source: crates/memtrack/tests/spawn_tests.rs -assertion_line: 90 expression: formatted_events --- [ diff --git a/crates/memtrack/tests/snapshots/spawn_tests__spawn_without_discovery.snap b/crates/memtrack/tests/snapshots/spawn_tests__spawn_without_discovery.snap deleted file mode 100644 index f6fe99c4..00000000 --- a/crates/memtrack/tests/snapshots/spawn_tests__spawn_without_discovery.snap +++ /dev/null @@ -1,6 +0,0 @@ ---- -source: crates/memtrack/tests/spawn_tests.rs -assertion_line: 71 -expression: formatted_events ---- -[] diff --git a/crates/memtrack/tests/spawn_tests.rs b/crates/memtrack/tests/spawn_tests.rs index 8fb9f463..5511a752 100644 --- a/crates/memtrack/tests/spawn_tests.rs +++ b/crates/memtrack/tests/spawn_tests.rs @@ -1,8 +1,6 @@ #[macro_use] mod shared; -use memtrack::AllocatorLib; -use memtrack::prelude::*; use std::path::Path; use std::process::Command; @@ -10,41 +8,20 @@ fn compile_spawn_binary(name: &str) -> anyhow::Result { shared::compile_rust_binary(Path::new("testdata/spawn_wrapper"), name, &[]) } -/// Without discovering the child's static allocator, the spawned child's -/// jemalloc allocations should NOT be tracked — we expect no marker-delimited events. +/// The exec-mapping watcher discovers the statically-linked jemalloc in the +/// spawned grandchild binary automatically: no pre-attached allocators. This is +/// the COD-1801 core ask, and it exercises the watcher firing on execve. #[test_with::env(GITHUB_ACTIONS)] #[test_log::test] -fn test_spawn_without_static_allocator_discovery() -> Result<(), Box> { +fn test_spawn_static_allocator_discovery() -> Result<(), Box> { let wrapper = compile_spawn_binary("wrapper")?; let child = compile_spawn_binary("alloc_child")?; - // No allocators attached — static jemalloc in alloc_child won't be found let mut cmd = Command::new(&wrapper); cmd.arg(&child); - let (events, thread_handle) = shared::track_command(cmd, &[], false)?; + let (events, thread_handle) = shared::track_command(cmd)?; - assert_events_with_marker!("spawn_without_discovery", &events); - - thread_handle.join().unwrap(); - Ok(()) -} - -/// With the child binary's static allocator discovered (simulating -/// CODSPEED_MEMTRACK_BINARIES), allocations from the spawned child should be tracked. -#[test_with::env(GITHUB_ACTIONS)] -#[test_log::test] -fn test_spawn_with_static_allocator_discovery() -> Result<(), Box> { - let wrapper = compile_spawn_binary("wrapper")?; - let child = compile_spawn_binary("alloc_child")?; - - // Simulate what CODSPEED_MEMTRACK_BINARIES does: discover the static jemalloc - let allocator = AllocatorLib::from_path_static(&child)?; - - let mut cmd = Command::new(&wrapper); - cmd.arg(&child); - let (events, thread_handle) = shared::track_command(cmd, &[allocator], false)?; - - assert_events_with_marker!("spawn_with_discovery", &events); + assert_events_with_marker!("spawn_on_demand_discovery", &events); thread_handle.join().unwrap(); Ok(()) From 2769a38dfba2403b2ce7798a0c76afcdf6270098 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Mon, 13 Jul 2026 14:44:44 +0200 Subject: [PATCH 6/9] feat(memtrack): track RSS via kmem:rss_stat tracepoint Sample the kernel's per-mm resident counter through the kmem:rss_stat tracepoint, emitting absolute byte values per mm member. Adds the EVENT_TYPE_RSS contract, MemtrackEventKind::Rss, the parser arm, and a writer bench case. An rss_stat update from reclaim or another process's madvise fires in the actor's context; track (mm_id, member) -> owning pid so those updates reach the owner. External events may only lower a counter, so stale reads and mm_id collisions cannot invent peaks. --- crates/memtrack/src/ebpf/c/event.h | 6 +++ crates/memtrack/src/ebpf/c/memtrack.bpf.c | 37 +++++++++++-- crates/memtrack/src/ebpf/c/rss.bpf.h | 53 +++++++++++++++++++ crates/memtrack/src/ebpf/events.rs | 37 +++++++++++++ crates/memtrack/src/ebpf/memtrack.rs | 4 ++ .../runner-shared/benches/memtrack_writer.rs | 6 ++- .../src/artifacts/memtrack/mod.rs | 14 +++++ 7 files changed, 152 insertions(+), 5 deletions(-) create mode 100644 crates/memtrack/src/ebpf/c/rss.bpf.h diff --git a/crates/memtrack/src/ebpf/c/event.h b/crates/memtrack/src/ebpf/c/event.h index ff41f0be..89974526 100644 --- a/crates/memtrack/src/ebpf/c/event.h +++ b/crates/memtrack/src/ebpf/c/event.h @@ -9,6 +9,7 @@ #define EVENT_TYPE_MMAP 6 #define EVENT_TYPE_MUNMAP 7 #define EVENT_TYPE_BRK 8 +#define EVENT_TYPE_RSS 9 /* Common header shared by all event types */ struct event_header { @@ -45,6 +46,11 @@ struct event { uint64_t addr; /* address of mapping */ uint64_t size; /* size of mapping */ } mmap; + + struct { + int32_t member; + uint64_t size; + } rss; } data; }; diff --git a/crates/memtrack/src/ebpf/c/memtrack.bpf.c b/crates/memtrack/src/ebpf/c/memtrack.bpf.c index 4c93d241..36b2f57a 100644 --- a/crates/memtrack/src/ebpf/c/memtrack.bpf.c +++ b/crates/memtrack/src/ebpf/c/memtrack.bpf.c @@ -45,6 +45,22 @@ BPF_RINGBUF(events, 256 * 1024 * 1024); BPF_ARRAY_MAP(tracking_enabled, __u8, 1); /* Counter for events that couldn't be added to the ring buffer */ BPF_ARRAY_MAP(dropped_events, __u64, 1); +/* (rss_stat mm_id << 32 | member) -> {owning tgid, last in-context size}. Keyed per + * counter so an external (curr==0) update is attributed only once that mm/member was + * established in-context. An external event may only lower a counter: any size above + * the last in-context value is dropped, so neither a stale/racing reclaim read nor an + * mm_id hash collision with another task can invent a peak. LRU eviction + re-seeding + * on the owner's next in-context event covers hash reuse, so no teardown hook needed. */ +struct rss_owner { + __u32 pid; + __u64 size; +}; +struct { + __uint(type, BPF_MAP_TYPE_LRU_HASH); + __uint(max_entries, 40960); + __type(key, __u64); + __type(value, struct rss_owner); +} mm_to_pid SEC(".maps"); /* == Code that tracks process forks and execs == */ @@ -148,12 +164,11 @@ static __always_inline __u64* take_param(void* map) { /* Macro to handle common event submission boilerplate * Usage: SUBMIT_EVENT(event_type, { e->data.foo = bar; }) */ -#define SUBMIT_EVENT(evt_type, fill_data) \ +#define SUBMIT_EVENT_AS(owner_pid, evt_type, fill_data) \ { \ __u64 tid = bpf_get_current_pid_tgid(); \ - __u32 pid = tid >> 32; \ \ - if (!is_tracked(pid) || !is_enabled()) { \ + if (!is_enabled()) { \ return 0; \ } \ \ @@ -168,7 +183,7 @@ static __always_inline __u64* take_param(void* map) { } \ \ e->header.timestamp = bpf_ktime_get_ns(); \ - e->header.pid = pid; \ + e->header.pid = owner_pid; \ e->header.tid = tid & 0xFFFFFFFF; \ e->header.event_type = evt_type; \ \ @@ -178,6 +193,18 @@ static __always_inline __u64* take_param(void* map) { return 0; \ } +#define SUBMIT_EVENT(evt_type, fill_data) \ + { \ + __u64 tid = bpf_get_current_pid_tgid(); \ + __u32 pid = tid >> 32; \ + \ + if (!is_tracked(pid)) { \ + return 0; \ + } \ + \ + SUBMIT_EVENT_AS(pid, evt_type, fill_data); \ + } + /* Helper to submit an allocation event (malloc, calloc) */ static __always_inline int submit_alloc_event(__u64 size, __u64 addr) { SUBMIT_EVENT(EVENT_TYPE_MALLOC, { @@ -406,3 +433,5 @@ int tracepoint_sys_exit_brk(struct trace_event_raw_sys_exit* ctx) { return submit_mmap_event(new_brk, 0, EVENT_TYPE_BRK); } + +#include "rss.bpf.h" diff --git a/crates/memtrack/src/ebpf/c/rss.bpf.h b/crates/memtrack/src/ebpf/c/rss.bpf.h new file mode 100644 index 00000000..cadc198c --- /dev/null +++ b/crates/memtrack/src/ebpf/c/rss.bpf.h @@ -0,0 +1,53 @@ +#ifndef __RSS_BPF_H__ +#define __RSS_BPF_H__ + +static __always_inline int submit_rss_event(__u32 owner_pid, __s32 member, __u64 size) { + SUBMIT_EVENT_AS(owner_pid, EVENT_TYPE_RSS, { + e->data.rss.member = member; + e->data.rss.size = size; + }); +} + +SEC("tracepoint/kmem/rss_stat") +int tracepoint_rss_stat(struct trace_event_raw_rss_stat* ctx) { + if (ctx->member == MM_SWAPENTS) { + return 0; + } + + __u32 cur = bpf_get_current_pid_tgid() >> 32; + __u64 key = ((__u64)ctx->mm_id << 32) | (__u32)ctx->member; + __u64 size = ctx->size; + __u32 owner; + + if (ctx->curr) { + if (!is_tracked(cur)) { + return 0; + } + owner = cur; + struct rss_owner state = {.pid = cur, .size = size}; + bpf_map_update_elem(&mm_to_pid, &key, &state, BPF_ANY); + } else { + struct rss_owner* found = bpf_map_lookup_elem(&mm_to_pid, &key); + if (!found) { + return 0; + } + owner = found->pid; + /* The owner's own teardown also presents as curr==0 (current->mm is cleared + * on exit), so drop it. Genuine external actors (reclaim, another process's + * madvise) run in a different task, so cur != owner. */ + if (cur == owner) { + return 0; + } + /* An external actor may only lower a counter. A larger value is a stale + * reclaim read or an mm_id hash collision with another task; dropping it + * keeps the reconstructed peak identical to the in-context timeline. */ + if (size > found->size) { + return 0; + } + found->size = size; + } + + return submit_rss_event(owner, ctx->member, size); +} + +#endif /* __RSS_BPF_H__ */ diff --git a/crates/memtrack/src/ebpf/events.rs b/crates/memtrack/src/ebpf/events.rs index f18216c6..cfe56ec8 100644 --- a/crates/memtrack/src/ebpf/events.rs +++ b/crates/memtrack/src/ebpf/events.rs @@ -74,6 +74,13 @@ pub fn parse_event(data: &[u8]) -> Option { size: event.data.mmap.size, }, ), + EVENT_TYPE_RSS => ( + 0, + MemtrackEventKind::Rss { + member: event.data.rss.member, + size: event.data.rss.size, + }, + ), unknown => { panic!("Unknown event type: {unknown}"); } @@ -185,4 +192,34 @@ mod tests { _ => panic!("Expected Malloc event kind"), } } + + #[test] + fn test_parse_rss_event() { + let mut event: bindings::event = unsafe { std::mem::zeroed() }; + event.header.event_type = bindings::EVENT_TYPE_RSS as u8; + event.header.timestamp = 12345678; + event.header.pid = 1000; + event.header.tid = 2000; + event.data.rss.member = 1; + event.data.rss.size = 4096 * 10; + + let bytes = unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + std::mem::size_of_val(&event), + ) + }; + + let parsed = parse_event(bytes).unwrap(); + assert_eq!(parsed.pid, 1000); + assert_eq!(parsed.addr, 0); + + match parsed.kind { + MemtrackEventKind::Rss { member, size } => { + assert_eq!(member, 1); + assert_eq!(size, 4096 * 10); + } + _ => panic!("Expected Rss event kind"), + } + } } diff --git a/crates/memtrack/src/ebpf/memtrack.rs b/crates/memtrack/src/ebpf/memtrack.rs index 9ae34dce..6950bd96 100644 --- a/crates/memtrack/src/ebpf/memtrack.rs +++ b/crates/memtrack/src/ebpf/memtrack.rs @@ -569,9 +569,13 @@ impl MemtrackBpf { Ok(()) } attach_tracepoint!(sched_fork); + attach_tracepoint!(rss_stat); pub fn attach_tracepoints(&mut self) -> Result<()> { self.attach_sched_fork()?; + if let Err(e) = self.attach_rss_stat() { + warn!("Failed to attach rss_stat tracepoint, RSS collection disabled: {e:#}"); + } Ok(()) } diff --git a/crates/runner-shared/benches/memtrack_writer.rs b/crates/runner-shared/benches/memtrack_writer.rs index 62aa2451..17b4f172 100644 --- a/crates/runner-shared/benches/memtrack_writer.rs +++ b/crates/runner-shared/benches/memtrack_writer.rs @@ -13,7 +13,7 @@ fn generate_events(n: usize) -> Vec { let mut events = Vec::with_capacity(n); for _ in 0..n { let size = rng.gen_range(8..8192); - let kind = match rng.gen_range(0..8) { + let kind = match rng.gen_range(0..9) { 0 => MemtrackEventKind::Malloc { size }, 1 => MemtrackEventKind::Free, 2 => MemtrackEventKind::Realloc { @@ -25,6 +25,10 @@ fn generate_events(n: usize) -> Vec { 5 => MemtrackEventKind::Mmap { size }, 6 => MemtrackEventKind::Munmap { size }, 7 => MemtrackEventKind::Brk { size }, + 8 => MemtrackEventKind::Rss { + member: rng.gen_range(0..4), + size, + }, _ => unreachable!(), }; diff --git a/crates/runner-shared/src/artifacts/memtrack/mod.rs b/crates/runner-shared/src/artifacts/memtrack/mod.rs index ad843b28..d9a00923 100644 --- a/crates/runner-shared/src/artifacts/memtrack/mod.rs +++ b/crates/runner-shared/src/artifacts/memtrack/mod.rs @@ -78,6 +78,10 @@ pub enum MemtrackEventKind { Brk { size: u64, }, + Rss { + member: i32, + size: u64, + }, } pub struct MemtrackEventStream { @@ -116,6 +120,16 @@ mod tests { addr: 0x20, kind: MemtrackEventKind::Free, }, + MemtrackEvent { + pid: 1, + tid: 11, + timestamp: 300, + addr: 0, + kind: MemtrackEventKind::Rss { + member: 1, + size: 40960, + }, + }, ]; let artifact = MemtrackArtifact { From 559b13e51fc76a39146d5a8ed390d0d5e8bb9127 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 17 Jul 2026 15:38:40 +0200 Subject: [PATCH 7/9] feat(memtrack): reconstruct RSS from gated folio rmap fentry hooks Attach fentry hooks on the folio-rmap add/remove functions, emitting signed page-count deltas per MM_* bucket so anon, file, and shmem RSS can be reconstructed over time. Gated behind CODSPEED_MEMTRACK_TRACK_RMAP; the programs stay autoload-off by default so the skeleton loads on any kernel. Adds the EVENT_TYPE_RMAP contract, MemtrackEventKind::Rmap, parser arm, and bench case. --- crates/memtrack/src/ebpf/c/event.h | 7 + crates/memtrack/src/ebpf/c/rss.bpf.h | 124 +++++++++++ crates/memtrack/src/ebpf/events.rs | 38 ++++ crates/memtrack/src/ebpf/memtrack.rs | 197 +++++++++++++++++- .../runner-shared/benches/memtrack_writer.rs | 6 +- .../src/artifacts/memtrack/mod.rs | 4 + 6 files changed, 370 insertions(+), 6 deletions(-) diff --git a/crates/memtrack/src/ebpf/c/event.h b/crates/memtrack/src/ebpf/c/event.h index 89974526..6739c525 100644 --- a/crates/memtrack/src/ebpf/c/event.h +++ b/crates/memtrack/src/ebpf/c/event.h @@ -10,6 +10,7 @@ #define EVENT_TYPE_MUNMAP 7 #define EVENT_TYPE_BRK 8 #define EVENT_TYPE_RSS 9 +#define EVENT_TYPE_RMAP 10 /* Common header shared by all event types */ struct event_header { @@ -51,6 +52,12 @@ struct event { int32_t member; uint64_t size; } rss; + + struct { + int32_t member; /* MM_* counter index, same values as rss.member */ + int64_t delta; + uint64_t addr; + } rmap; } data; }; diff --git a/crates/memtrack/src/ebpf/c/rss.bpf.h b/crates/memtrack/src/ebpf/c/rss.bpf.h index cadc198c..7ff9ed1f 100644 --- a/crates/memtrack/src/ebpf/c/rss.bpf.h +++ b/crates/memtrack/src/ebpf/c/rss.bpf.h @@ -1,6 +1,10 @@ #ifndef __RSS_BPF_H__ #define __RSS_BPF_H__ +#define FOLIO_MAPPING_ANON 0x1UL + +const volatile __u32 page_shift = 12; + static __always_inline int submit_rss_event(__u32 owner_pid, __s32 member, __u64 size) { SUBMIT_EVENT_AS(owner_pid, EVENT_TYPE_RSS, { e->data.rss.member = member; @@ -50,4 +54,124 @@ int tracepoint_rss_stat(struct trace_event_raw_rss_stat* ctx) { return submit_rss_event(owner, ctx->member, size); } +static __always_inline __u64 folio_nr_pages_est(struct folio* folio) { + unsigned long flags = BPF_CORE_READ(folio, flags).f; + if (!(flags & (1UL << PG_head))) { + return 1; + } + unsigned long order = BPF_CORE_READ(folio, _flags_1) & 0xff; + return 1UL << order; +} + +static __always_inline int folio_is_anon(struct folio* folio) { + unsigned long mapping = (unsigned long)BPF_CORE_READ(folio, mapping); + return (mapping & FOLIO_MAPPING_ANON) != 0; +} + +/* Mirrors the kernel's mm_counter(): anon folios are also swapbacked, so the + * anon check must come first. */ +static __always_inline __s32 folio_mm_counter(struct folio* folio) { + if (folio_is_anon(folio)) { + return MM_ANONPAGES; + } + unsigned long flags = BPF_CORE_READ(folio, flags).f; + if (flags & (1UL << PG_swapbacked)) { + return MM_SHMEMPAGES; + } + return MM_FILEPAGES; +} + +static __always_inline __u64 folio_page_address(struct folio* folio, struct page* page, + struct vm_area_struct* vma) { + __u64 page_idx = ((__u64)page - (__u64)folio) / bpf_core_type_size(struct page); + __u64 pgoff = BPF_CORE_READ(folio, index) + page_idx; + __u64 vm_pgoff = BPF_CORE_READ(vma, vm_pgoff); + __u64 vm_start = BPF_CORE_READ(vma, vm_start); + if (pgoff < vm_pgoff) { + return vm_start; + } + return vm_start + ((pgoff - vm_pgoff) << page_shift); +} + +static __always_inline int submit_rmap(struct vm_area_struct* vma, __s32 member, __s64 delta, + __u64 addr) { + __u64 mm = (__u64)BPF_CORE_READ(vma, vm_mm); + struct task_struct* task = bpf_get_current_task_btf(); + if ((__u64)BPF_CORE_READ(task, mm) != mm) { + return 0; + } + + __u64 tid = bpf_get_current_pid_tgid(); + __u32 pid = tid >> 32; + if (!is_tracked(pid)) { + return 0; + } + + SUBMIT_EVENT_AS(pid, EVENT_TYPE_RMAP, { + e->data.rmap.member = member; + e->data.rmap.delta = delta; + e->data.rmap.addr = addr; + }); +} + +SEC("fentry/folio_add_new_anon_rmap") +int BPF_PROG(fentry_folio_add_new_anon_rmap, struct folio* folio, struct vm_area_struct* vma, + unsigned long address) { + return submit_rmap(vma, MM_ANONPAGES, (__s64)folio_nr_pages_est(folio), address); +} + +SEC("fentry/folio_add_anon_rmap_ptes") +int BPF_PROG(fentry_folio_add_anon_rmap_ptes, struct folio* folio, struct page* page, int nr_pages, + struct vm_area_struct* vma, unsigned long address) { + return submit_rmap(vma, MM_ANONPAGES, (__s64)nr_pages, address); +} + +SEC("fentry/folio_add_anon_rmap_pmd") +int BPF_PROG(fentry_folio_add_anon_rmap_pmd, struct folio* folio, struct page* page, + struct vm_area_struct* vma, unsigned long address) { + return submit_rmap(vma, MM_ANONPAGES, (__s64)folio_nr_pages_est(folio), address); +} + +SEC("fentry/folio_add_file_rmap_ptes") +int BPF_PROG(fentry_folio_add_file_rmap_ptes, struct folio* folio, struct page* page, int nr_pages, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), (__s64)nr_pages, + folio_page_address(folio, page, vma)); +} + +SEC("fentry/folio_add_file_rmap_pmd") +int BPF_PROG(fentry_folio_add_file_rmap_pmd, struct folio* folio, struct page* page, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), (__s64)folio_nr_pages_est(folio), + folio_page_address(folio, page, vma)); +} + +SEC("fentry/folio_add_file_rmap_pud") +int BPF_PROG(fentry_folio_add_file_rmap_pud, struct folio* folio, struct page* page, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), (__s64)folio_nr_pages_est(folio), + folio_page_address(folio, page, vma)); +} + +SEC("fentry/folio_remove_rmap_ptes") +int BPF_PROG(fentry_folio_remove_rmap_ptes, struct folio* folio, struct page* page, int nr_pages, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), -(__s64)nr_pages, + folio_page_address(folio, page, vma)); +} + +SEC("fentry/folio_remove_rmap_pmd") +int BPF_PROG(fentry_folio_remove_rmap_pmd, struct folio* folio, struct page* page, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), -(__s64)folio_nr_pages_est(folio), + folio_page_address(folio, page, vma)); +} + +SEC("fentry/folio_remove_rmap_pud") +int BPF_PROG(fentry_folio_remove_rmap_pud, struct folio* folio, struct page* page, + struct vm_area_struct* vma) { + return submit_rmap(vma, folio_mm_counter(folio), -(__s64)folio_nr_pages_est(folio), + folio_page_address(folio, page, vma)); +} + #endif /* __RSS_BPF_H__ */ diff --git a/crates/memtrack/src/ebpf/events.rs b/crates/memtrack/src/ebpf/events.rs index cfe56ec8..8fe3a63d 100644 --- a/crates/memtrack/src/ebpf/events.rs +++ b/crates/memtrack/src/ebpf/events.rs @@ -81,6 +81,13 @@ pub fn parse_event(data: &[u8]) -> Option { size: event.data.rss.size, }, ), + EVENT_TYPE_RMAP => ( + event.data.rmap.addr, + MemtrackEventKind::Rmap { + member: event.data.rmap.member, + delta: event.data.rmap.delta, + }, + ), unknown => { panic!("Unknown event type: {unknown}"); } @@ -222,4 +229,35 @@ mod tests { _ => panic!("Expected Rss event kind"), } } + + #[test] + fn test_parse_rmap_event() { + let mut event: bindings::event = unsafe { std::mem::zeroed() }; + event.header.event_type = bindings::EVENT_TYPE_RMAP as u8; + event.header.timestamp = 12345678; + event.header.pid = 1000; + event.header.tid = 2000; + event.data.rmap.member = 3; + event.data.rmap.delta = 8; + event.data.rmap.addr = 0x7f00; + + let bytes = unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + std::mem::size_of_val(&event), + ) + }; + + let parsed = parse_event(bytes).unwrap(); + assert_eq!(parsed.pid, 1000); + assert_eq!(parsed.addr, 0x7f00); + + match parsed.kind { + MemtrackEventKind::Rmap { member, delta } => { + assert_eq!(member, 3); + assert_eq!(delta, 8); + } + _ => panic!("Expected Rmap event kind"), + } + } } diff --git a/crates/memtrack/src/ebpf/memtrack.rs b/crates/memtrack/src/ebpf/memtrack.rs index 6950bd96..10366fd1 100644 --- a/crates/memtrack/src/ebpf/memtrack.rs +++ b/crates/memtrack/src/ebpf/memtrack.rs @@ -1,10 +1,9 @@ use crate::prelude::*; -use libbpf_rs::Link; -use libbpf_rs::skel::OpenSkel; -use libbpf_rs::skel::SkelBuilder; -use libbpf_rs::{MapCore, UprobeOpts}; +use libbpf_rs::skel::{OpenSkel, SkelBuilder}; +use libbpf_rs::{AsRawLibbpf, Link, MapCore, UprobeOpts}; use paste::paste; use std::collections::HashMap; +use std::ffi::CString; use std::mem::MaybeUninit; use std::path::Path; @@ -70,6 +69,27 @@ impl ResolvedSymbols { } } +fn page_shift() -> Result { + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + ensure!(page_size > 0, "Failed to read system page size"); + Ok((page_size as u32).trailing_zeros()) +} + +fn kernel_func_exists(btf: &libbpf_rs::btf::Btf, name: &str) -> bool { + let Ok(name) = CString::new(name) else { + return false; + }; + + let id = unsafe { + libbpf_rs::libbpf_sys::btf__find_by_name_kind( + btf.as_libbpf_object().as_ptr(), + name.as_ptr(), + libbpf_rs::libbpf_sys::BTF_KIND_FUNC, + ) + }; + id >= 0 +} + /// Macro to attach a function with both entry and return probes at a resolved /// file offset. Also generates an `attach_*_if_found` variant that skips /// symbols absent from the offset table (returning whether it attached) and @@ -207,20 +227,116 @@ macro_rules! attach_tracepoint { }; } +macro_rules! attach_fentry { + ($func:ident, $prog:ident) => { + fn $func(&mut self) -> Result<()> { + let link = self + .skel + .progs + .$prog + .attach() + .context(format!("Failed to attach {} fentry", stringify!($prog)))?; + self.probes.push(link); + Ok(()) + } + }; +} + pub struct MemtrackBpf { skel: Box>, probes: Vec, + track_rmap: bool, + btf_disabled_rmap_targets: Vec<&'static str>, } impl MemtrackBpf { pub fn new() -> Result { + let track_rmap = std::env::var("CODSPEED_MEMTRACK_TRACK_RMAP").is_ok_and(|v| v == "1"); + Self::new_with_rmap(track_rmap) + } + + pub fn new_with_rmap(track_rmap: bool) -> Result { // Build and open the syscalls BPF program let builder = MemtrackSkelBuilder::default(); let open_object = Box::leak(Box::new(MaybeUninit::uninit())); - let open_skel = builder + let mut open_skel = builder .open(open_object) .context("Failed to open syscalls BPF skeleton")?; + open_skel + .maps + .rodata_data + .as_deref_mut() + .context("rodata map missing")? + .page_shift = page_shift()?; + let mut btf_disabled_rmap_targets = Vec::new(); + + if !track_rmap { + open_skel + .progs + .fentry_folio_add_new_anon_rmap + .set_autoload(false); + open_skel + .progs + .fentry_folio_add_anon_rmap_ptes + .set_autoload(false); + open_skel + .progs + .fentry_folio_add_anon_rmap_pmd + .set_autoload(false); + open_skel + .progs + .fentry_folio_add_file_rmap_ptes + .set_autoload(false); + open_skel + .progs + .fentry_folio_add_file_rmap_pmd + .set_autoload(false); + open_skel + .progs + .fentry_folio_add_file_rmap_pud + .set_autoload(false); + open_skel + .progs + .fentry_folio_remove_rmap_ptes + .set_autoload(false); + open_skel + .progs + .fentry_folio_remove_rmap_pmd + .set_autoload(false); + open_skel + .progs + .fentry_folio_remove_rmap_pud + .set_autoload(false); + } else { + let btf = libbpf_rs::btf::Btf::from_vmlinux().context("Failed to load vmlinux BTF")?; + macro_rules! disable_missing_kernel_func { + ($prog:ident, $target:literal) => { + if !kernel_func_exists(&btf, $target) { + open_skel.progs.$prog.set_autoload(false); + btf_disabled_rmap_targets.push($target); + warn!( + "Kernel function {} not present in BTF, disabling rmap fentry", + $target + ); + } + }; + } + + disable_missing_kernel_func!(fentry_folio_add_new_anon_rmap, "folio_add_new_anon_rmap"); + disable_missing_kernel_func!( + fentry_folio_add_anon_rmap_ptes, + "folio_add_anon_rmap_ptes" + ); + disable_missing_kernel_func!(fentry_folio_add_anon_rmap_pmd, "folio_add_anon_rmap_pmd"); + disable_missing_kernel_func!(fentry_folio_add_file_rmap_ptes, "folio_add_file_rmap_ptes"); + disable_missing_kernel_func!(fentry_folio_add_file_rmap_pmd, "folio_add_file_rmap_pmd"); + disable_missing_kernel_func!(fentry_folio_add_file_rmap_pud, "folio_add_file_rmap_pud"); + disable_missing_kernel_func!(fentry_folio_remove_rmap_ptes, "folio_remove_rmap_ptes"); + disable_missing_kernel_func!(fentry_folio_remove_rmap_pmd, "folio_remove_rmap_pmd"); + disable_missing_kernel_func!(fentry_folio_remove_rmap_pud, "folio_remove_rmap_pud"); + } + let skel = Box::new( open_skel .load() @@ -230,6 +346,8 @@ impl MemtrackBpf { Ok(Self { skel, probes: Vec::new(), + track_rmap, + btf_disabled_rmap_targets, }) } @@ -570,12 +688,81 @@ impl MemtrackBpf { } attach_tracepoint!(sched_fork); attach_tracepoint!(rss_stat); + attach_fentry!( + attach_fentry_folio_add_new_anon_rmap, + fentry_folio_add_new_anon_rmap + ); + attach_fentry!( + attach_fentry_folio_add_anon_rmap_ptes, + fentry_folio_add_anon_rmap_ptes + ); + attach_fentry!( + attach_fentry_folio_add_anon_rmap_pmd, + fentry_folio_add_anon_rmap_pmd + ); + attach_fentry!( + attach_fentry_folio_add_file_rmap_ptes, + fentry_folio_add_file_rmap_ptes + ); + attach_fentry!( + attach_fentry_folio_add_file_rmap_pmd, + fentry_folio_add_file_rmap_pmd + ); + attach_fentry!( + attach_fentry_folio_add_file_rmap_pud, + fentry_folio_add_file_rmap_pud + ); + attach_fentry!( + attach_fentry_folio_remove_rmap_ptes, + fentry_folio_remove_rmap_ptes + ); + attach_fentry!( + attach_fentry_folio_remove_rmap_pmd, + fentry_folio_remove_rmap_pmd + ); + attach_fentry!( + attach_fentry_folio_remove_rmap_pud, + fentry_folio_remove_rmap_pud + ); + + fn rmap_target_enabled(&self, target: &str) -> bool { + !self.btf_disabled_rmap_targets.contains(&target) + } pub fn attach_tracepoints(&mut self) -> Result<()> { self.attach_sched_fork()?; if let Err(e) = self.attach_rss_stat() { warn!("Failed to attach rss_stat tracepoint, RSS collection disabled: {e:#}"); } + if self.track_rmap { + if self.rmap_target_enabled("folio_add_new_anon_rmap") { + self.attach_fentry_folio_add_new_anon_rmap()?; + } + if self.rmap_target_enabled("folio_add_anon_rmap_ptes") { + self.attach_fentry_folio_add_anon_rmap_ptes()?; + } + if self.rmap_target_enabled("folio_add_anon_rmap_pmd") { + self.attach_fentry_folio_add_anon_rmap_pmd()?; + } + if self.rmap_target_enabled("folio_add_file_rmap_ptes") { + self.attach_fentry_folio_add_file_rmap_ptes()?; + } + if self.rmap_target_enabled("folio_add_file_rmap_pmd") { + self.attach_fentry_folio_add_file_rmap_pmd()?; + } + if self.rmap_target_enabled("folio_add_file_rmap_pud") { + self.attach_fentry_folio_add_file_rmap_pud()?; + } + if self.rmap_target_enabled("folio_remove_rmap_ptes") { + self.attach_fentry_folio_remove_rmap_ptes()?; + } + if self.rmap_target_enabled("folio_remove_rmap_pmd") { + self.attach_fentry_folio_remove_rmap_pmd()?; + } + if self.rmap_target_enabled("folio_remove_rmap_pud") { + self.attach_fentry_folio_remove_rmap_pud()?; + } + } Ok(()) } diff --git a/crates/runner-shared/benches/memtrack_writer.rs b/crates/runner-shared/benches/memtrack_writer.rs index 17b4f172..a6c610e8 100644 --- a/crates/runner-shared/benches/memtrack_writer.rs +++ b/crates/runner-shared/benches/memtrack_writer.rs @@ -13,7 +13,7 @@ fn generate_events(n: usize) -> Vec { let mut events = Vec::with_capacity(n); for _ in 0..n { let size = rng.gen_range(8..8192); - let kind = match rng.gen_range(0..9) { + let kind = match rng.gen_range(0..10) { 0 => MemtrackEventKind::Malloc { size }, 1 => MemtrackEventKind::Free, 2 => MemtrackEventKind::Realloc { @@ -29,6 +29,10 @@ fn generate_events(n: usize) -> Vec { member: rng.gen_range(0..4), size, }, + 9 => MemtrackEventKind::Rmap { + member: rng.gen_range(0..4), + delta: rng.gen_range(-1024..1024), + }, _ => unreachable!(), }; diff --git a/crates/runner-shared/src/artifacts/memtrack/mod.rs b/crates/runner-shared/src/artifacts/memtrack/mod.rs index d9a00923..aba8b8cc 100644 --- a/crates/runner-shared/src/artifacts/memtrack/mod.rs +++ b/crates/runner-shared/src/artifacts/memtrack/mod.rs @@ -82,6 +82,10 @@ pub enum MemtrackEventKind { member: i32, size: u64, }, + Rmap { + member: i32, + delta: i64, + }, } pub struct MemtrackEventStream { From 8ef53f4e60f308c22146159f23d689fff7ca3c2a Mon Sep 17 00:00:00 2001 From: not-matthias Date: Tue, 14 Jul 2026 18:24:39 +0200 Subject: [PATCH 8/9] feat(memtrack): emit fork/exec/exit lifecycle events A forked child's inherited RSS is invisible to rss_stat: the fork-time counter copies fire outside the child's context, and anon COW faults are counter-neutral, so a child that only touches inherited memory never reports anything on its own. A fork event carrying the parent pid lets consumers seed the child from the parent's last absolutes; exec and exit mark where the address space is replaced or torn down. --- crates/memtrack/src/ebpf/c/event.h | 8 ++++ crates/memtrack/src/ebpf/c/memtrack.bpf.c | 43 +++++++++++++++++++ crates/memtrack/src/ebpf/events.rs | 35 +++++++++++++++ crates/memtrack/src/ebpf/memtrack.rs | 6 +++ .../src/artifacts/memtrack/mod.rs | 5 +++ 5 files changed, 97 insertions(+) diff --git a/crates/memtrack/src/ebpf/c/event.h b/crates/memtrack/src/ebpf/c/event.h index 6739c525..5dabffbe 100644 --- a/crates/memtrack/src/ebpf/c/event.h +++ b/crates/memtrack/src/ebpf/c/event.h @@ -11,6 +11,9 @@ #define EVENT_TYPE_BRK 8 #define EVENT_TYPE_RSS 9 #define EVENT_TYPE_RMAP 10 +#define EVENT_TYPE_FORK 11 +#define EVENT_TYPE_EXEC 12 +#define EVENT_TYPE_EXIT 13 /* Common header shared by all event types */ struct event_header { @@ -58,6 +61,11 @@ struct event { int64_t delta; uint64_t addr; } rmap; + + /* Process lifecycle events (fork carries the parent; exec/exit have no payload) */ + struct { + uint32_t parent_pid; + } fork; } data; }; diff --git a/crates/memtrack/src/ebpf/c/memtrack.bpf.c b/crates/memtrack/src/ebpf/c/memtrack.bpf.c index 36b2f57a..fa0d6c2e 100644 --- a/crates/memtrack/src/ebpf/c/memtrack.bpf.c +++ b/crates/memtrack/src/ebpf/c/memtrack.bpf.c @@ -434,4 +434,47 @@ int tracepoint_sys_exit_brk(struct trace_event_raw_sys_exit* ctx) { return submit_mmap_event(new_brk, 0, EVENT_TYPE_BRK); } +/* == Process lifecycle events == + * + * FORK lets userland seed a child's RSS from its parent at fork time: the + * kernel copies the mm counters during dup_mmap, but those updates fire + * rss_stat out of the child's context and anon COW faults are + * counter-neutral, so a child that only touches inherited memory never + * reports its RSS on its own. EXEC and EXIT mark the points where the + * address space is replaced or torn down, so userland resets to zero. + */ + +#define CLONE_THREAD 0x00010000 + +SEC("tracepoint/task/task_newtask") +int tracepoint_task_newtask(struct trace_event_raw_task_newtask* ctx) { + if (ctx->clone_flags & CLONE_THREAD) { + return 0; + } + + __u64 tid = bpf_get_current_pid_tgid(); + __u32 parent_pid = tid >> 32; + if (!is_tracked(parent_pid)) { + return 0; + } + + __u32 child_pid = ctx->pid; + SUBMIT_EVENT_AS(child_pid, EVENT_TYPE_FORK, { e->data.fork.parent_pid = parent_pid; }); +} + +SEC("tracepoint/sched/sched_process_exec") +int tracepoint_sched_process_exec(void* ctx) { + SUBMIT_EVENT(EVENT_TYPE_EXEC, {}); +} + +SEC("tracepoint/sched/sched_process_exit") +int tracepoint_sched_process_exit(void* ctx) { + __u64 tid = bpf_get_current_pid_tgid(); + if ((tid >> 32) != (tid & 0xFFFFFFFF)) { + return 0; + } + + SUBMIT_EVENT(EVENT_TYPE_EXIT, {}); +} + #include "rss.bpf.h" diff --git a/crates/memtrack/src/ebpf/events.rs b/crates/memtrack/src/ebpf/events.rs index 8fe3a63d..ccab7f5f 100644 --- a/crates/memtrack/src/ebpf/events.rs +++ b/crates/memtrack/src/ebpf/events.rs @@ -88,6 +88,14 @@ pub fn parse_event(data: &[u8]) -> Option { delta: event.data.rmap.delta, }, ), + EVENT_TYPE_FORK => ( + 0, + MemtrackEventKind::Fork { + parent_pid: event.data.fork.parent_pid as i32, + }, + ), + EVENT_TYPE_EXEC => (0, MemtrackEventKind::Exec), + EVENT_TYPE_EXIT => (0, MemtrackEventKind::Exit), unknown => { panic!("Unknown event type: {unknown}"); } @@ -260,4 +268,31 @@ mod tests { _ => panic!("Expected Rmap event kind"), } } + + #[test] + fn test_parse_fork_event() { + let mut event: bindings::event = unsafe { std::mem::zeroed() }; + event.header.event_type = bindings::EVENT_TYPE_FORK as u8; + event.header.timestamp = 12345678; + event.header.pid = 1001; + event.header.tid = 2000; + event.data.fork.parent_pid = 1000; + + let bytes = unsafe { + std::slice::from_raw_parts( + &event as *const _ as *const u8, + std::mem::size_of_val(&event), + ) + }; + + let parsed = parse_event(bytes).unwrap(); + assert_eq!(parsed.pid, 1001); + + match parsed.kind { + MemtrackEventKind::Fork { parent_pid } => { + assert_eq!(parent_pid, 1000); + } + _ => panic!("Expected Fork event kind"), + } + } } diff --git a/crates/memtrack/src/ebpf/memtrack.rs b/crates/memtrack/src/ebpf/memtrack.rs index 10366fd1..e20196c6 100644 --- a/crates/memtrack/src/ebpf/memtrack.rs +++ b/crates/memtrack/src/ebpf/memtrack.rs @@ -688,6 +688,9 @@ impl MemtrackBpf { } attach_tracepoint!(sched_fork); attach_tracepoint!(rss_stat); + attach_tracepoint!(task_newtask); + attach_tracepoint!(sched_process_exec); + attach_tracepoint!(sched_process_exit); attach_fentry!( attach_fentry_folio_add_new_anon_rmap, fentry_folio_add_new_anon_rmap @@ -731,6 +734,9 @@ impl MemtrackBpf { pub fn attach_tracepoints(&mut self) -> Result<()> { self.attach_sched_fork()?; + self.attach_task_newtask()?; + self.attach_sched_process_exec()?; + self.attach_sched_process_exit()?; if let Err(e) = self.attach_rss_stat() { warn!("Failed to attach rss_stat tracepoint, RSS collection disabled: {e:#}"); } diff --git a/crates/runner-shared/src/artifacts/memtrack/mod.rs b/crates/runner-shared/src/artifacts/memtrack/mod.rs index aba8b8cc..94c911c4 100644 --- a/crates/runner-shared/src/artifacts/memtrack/mod.rs +++ b/crates/runner-shared/src/artifacts/memtrack/mod.rs @@ -86,6 +86,11 @@ pub enum MemtrackEventKind { member: i32, delta: i64, }, + Fork { + parent_pid: pid_t, + }, + Exec, + Exit, } pub struct MemtrackEventStream { From 411b71340728dffb988397a59dfa6fa3e3dbaa75 Mon Sep 17 00:00:00 2001 From: not-matthias Date: Fri, 17 Jul 2026 16:22:09 +0200 Subject: [PATCH 9/9] test(memtrack): validate RSS and rmap reconstruction against /proc Nine fixtures compare three per-process views - the fixture's own /proc report, rss_stat peaks, and rmap-reconstructed totals - plus an external-reclaim fixture proving out-of-context decrements reach the owner. Fork-seeded children are validated via fork_idle, whose 32 MiB is observable only through the fork-event seed. Pids are redacted and rows keep first-activity order so snapshots are stable across runs; fixtures report VmHWM instead of ru_maxrss, which survives execve and would leak the harness's peak RSS. --- crates/memtrack/.clang-format | 1 + crates/memtrack/Cargo.toml | 2 +- crates/memtrack/src/ebpf/c/memtrack.bpf.c | 52 +-- crates/memtrack/src/ebpf/memtrack.rs | 5 +- crates/memtrack/src/ebpf/tracker.rs | 45 ++- crates/memtrack/testdata/rss/anon.c | 17 + crates/memtrack/testdata/rss/file.c | 30 ++ crates/memtrack/testdata/rss/fork.c | 39 ++ crates/memtrack/testdata/rss/fork_idle.c | 49 +++ crates/memtrack/testdata/rss/madvise.c | 64 ++++ crates/memtrack/testdata/rss/madvise_extern.c | 47 +++ crates/memtrack/testdata/rss/mremap_move.c | 31 ++ crates/memtrack/testdata/rss/munmap_hole.c | 40 ++ crates/memtrack/testdata/rss/rss_report.h | 50 +++ crates/memtrack/testdata/rss/shmem.c | 22 ++ crates/memtrack/testdata/rss/triangle.c | 24 ++ crates/memtrack/tests/rss_tests.rs | 359 ++++++++++++++++++ crates/memtrack/tests/shared.rs | 50 ++- .../tests/snapshots/rss_tests__rss_anon.snap | 30 ++ .../tests/snapshots/rss_tests__rss_file.snap | 30 ++ .../tests/snapshots/rss_tests__rss_fork.snap | 42 ++ .../snapshots/rss_tests__rss_fork_idle.snap | 45 +++ .../snapshots/rss_tests__rss_madvise.snap | 30 ++ .../snapshots/rss_tests__rss_mremap_move.snap | 30 ++ .../snapshots/rss_tests__rss_munmap_hole.snap | 30 ++ .../tests/snapshots/rss_tests__rss_shmem.snap | 30 ++ .../snapshots/rss_tests__rss_triangle.snap | 30 ++ 27 files changed, 1179 insertions(+), 45 deletions(-) create mode 100644 crates/memtrack/testdata/rss/anon.c create mode 100644 crates/memtrack/testdata/rss/file.c create mode 100644 crates/memtrack/testdata/rss/fork.c create mode 100644 crates/memtrack/testdata/rss/fork_idle.c create mode 100644 crates/memtrack/testdata/rss/madvise.c create mode 100644 crates/memtrack/testdata/rss/madvise_extern.c create mode 100644 crates/memtrack/testdata/rss/mremap_move.c create mode 100644 crates/memtrack/testdata/rss/munmap_hole.c create mode 100644 crates/memtrack/testdata/rss/rss_report.h create mode 100644 crates/memtrack/testdata/rss/shmem.c create mode 100644 crates/memtrack/testdata/rss/triangle.c create mode 100644 crates/memtrack/tests/rss_tests.rs create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_anon.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_file.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_fork.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_fork_idle.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_madvise.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_mremap_move.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_munmap_hole.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_shmem.snap create mode 100644 crates/memtrack/tests/snapshots/rss_tests__rss_triangle.snap diff --git a/crates/memtrack/.clang-format b/crates/memtrack/.clang-format index b687ea40..4755a340 100644 --- a/crates/memtrack/.clang-format +++ b/crates/memtrack/.clang-format @@ -4,3 +4,4 @@ BasedOnStyle: Google PointerAlignment: Left ColumnLimit: 100 IndentWidth: 4 +AllowShortFunctionsOnASingleLine: None diff --git a/crates/memtrack/Cargo.toml b/crates/memtrack/Cargo.toml index c4311d9e..b3bd218e 100644 --- a/crates/memtrack/Cargo.toml +++ b/crates/memtrack/Cargo.toml @@ -46,7 +46,7 @@ bindgen = "0.72" tempfile = { workspace = true } rstest = { workspace = true } test-log = { workspace = true } -insta = { workspace = true } +insta = { workspace = true, features = ["json", "redactions"] } test-with = { workspace = true } [package.metadata.dist] diff --git a/crates/memtrack/src/ebpf/c/memtrack.bpf.c b/crates/memtrack/src/ebpf/c/memtrack.bpf.c index fa0d6c2e..23666476 100644 --- a/crates/memtrack/src/ebpf/c/memtrack.bpf.c +++ b/crates/memtrack/src/ebpf/c/memtrack.bpf.c @@ -193,15 +193,15 @@ static __always_inline __u64* take_param(void* map) { return 0; \ } -#define SUBMIT_EVENT(evt_type, fill_data) \ - { \ - __u64 tid = bpf_get_current_pid_tgid(); \ - __u32 pid = tid >> 32; \ - \ - if (!is_tracked(pid)) { \ - return 0; \ - } \ - \ +#define SUBMIT_EVENT(evt_type, fill_data) \ + { \ + __u64 tid = bpf_get_current_pid_tgid(); \ + __u32 pid = tid >> 32; \ + \ + if (!is_tracked(pid)) { \ + return 0; \ + } \ + \ SUBMIT_EVENT_AS(pid, evt_type, fill_data); \ } @@ -252,22 +252,24 @@ static __always_inline int submit_mmap_event(__u64 addr, __u64 size, __u8 event_ } /* Macro to generate uprobe/uretprobe pairs for allocation functions with 1 argument */ -#define UPROBE_ARG_RET(name, arg_expr, submit_block) \ - BPF_HASH_MAP(name##_arg, __u64, __u64, 10000); \ - SEC("uprobe") \ - int uprobe_##name(struct pt_regs* ctx) { return store_param(&name##_arg, arg_expr); } \ - SEC("uretprobe") \ - int uretprobe_##name(struct pt_regs* ctx) { \ - __u64* arg_ptr = take_param(&name##_arg); \ - if (!arg_ptr) { \ - return 0; \ - } \ - __u64 ret_val = PT_REGS_RC(ctx); \ - if (ret_val == 0) { \ - return 0; \ - } \ - __u64 arg0 = *arg_ptr; \ - submit_block; \ +#define UPROBE_ARG_RET(name, arg_expr, submit_block) \ + BPF_HASH_MAP(name##_arg, __u64, __u64, 10000); \ + SEC("uprobe") \ + int uprobe_##name(struct pt_regs* ctx) { \ + return store_param(&name##_arg, arg_expr); \ + } \ + SEC("uretprobe") \ + int uretprobe_##name(struct pt_regs* ctx) { \ + __u64* arg_ptr = take_param(&name##_arg); \ + if (!arg_ptr) { \ + return 0; \ + } \ + __u64 ret_val = PT_REGS_RC(ctx); \ + if (ret_val == 0) { \ + return 0; \ + } \ + __u64 arg0 = *arg_ptr; \ + submit_block; \ } /* Macro for simple return value only functions like free */ diff --git a/crates/memtrack/src/ebpf/memtrack.rs b/crates/memtrack/src/ebpf/memtrack.rs index e20196c6..2d37e44d 100644 --- a/crates/memtrack/src/ebpf/memtrack.rs +++ b/crates/memtrack/src/ebpf/memtrack.rs @@ -329,7 +329,10 @@ impl MemtrackBpf { "folio_add_anon_rmap_ptes" ); disable_missing_kernel_func!(fentry_folio_add_anon_rmap_pmd, "folio_add_anon_rmap_pmd"); - disable_missing_kernel_func!(fentry_folio_add_file_rmap_ptes, "folio_add_file_rmap_ptes"); + disable_missing_kernel_func!( + fentry_folio_add_file_rmap_ptes, + "folio_add_file_rmap_ptes" + ); disable_missing_kernel_func!(fentry_folio_add_file_rmap_pmd, "folio_add_file_rmap_pmd"); disable_missing_kernel_func!(fentry_folio_add_file_rmap_pud, "folio_add_file_rmap_pud"); disable_missing_kernel_func!(fentry_folio_remove_rmap_ptes, "folio_remove_rmap_ptes"); diff --git a/crates/memtrack/src/ebpf/tracker.rs b/crates/memtrack/src/ebpf/tracker.rs index 11ecaf04..ff6c0881 100644 --- a/crates/memtrack/src/ebpf/tracker.rs +++ b/crates/memtrack/src/ebpf/tracker.rs @@ -33,10 +33,26 @@ impl Tracker { }) } + /// Track per-process RSS via the rss_stat tracepoint and folio rmap fentry + /// hooks without attaching any allocator probes. No exec-mapping watcher + /// runs, so `spawn` arms no on-demand allocator attachment — the tracker + /// observes only tracepoints and (when `track_rmap`) the rmap fentries. + pub fn new_without_allocators_with_rmap(track_rmap: bool) -> Result { + Self::bump_memlock_rlimit()?; + + let mut bpf = MemtrackBpf::new_with_rmap(track_rmap)?; + bpf.attach_tracepoints()?; + + Ok(Self { + bpf: Arc::new(Mutex::new(bpf)), + worker: Mutex::new(None), + }) + } + /// Spawn `cmd` under tracking: the target is wrapped so it stops itself - /// before exec'ing, its pid is armed while stopped, then it is resumed. - /// The watcher observes the target's own `execve` mappings — no allocation - /// escapes untracked. + /// before exec'ing, its pid is armed while stopped, then it is resumed. When + /// the tracker runs an exec-mapping watcher, arming the pid before resume + /// ensures no allocation mapping escapes untracked. /// /// `uid_gid` drops the child's privileges (a `Command`'s uid/gid cannot be /// read back, so it cannot be preserved through the wrap). @@ -48,11 +64,9 @@ impl Tracker { let child = spawn_stopped(&mut wrapped)?; let pid = child.id() as i32; - self.worker - .lock() - .as_ref() - .context("tracker already finished")? - .set_root_pid(pid); + if let Some(worker) = self.worker.lock().as_ref() { + worker.set_root_pid(pid); + } let (tx, rx) = mpsc::channel(); let poller = { @@ -81,15 +95,14 @@ impl Tracker { self.bpf.lock().dropped_events_count() } - /// Stop the attach worker and surface any fatal error it recorded, - /// including missed exec mappings (incomplete allocator coverage). + /// Stop the attach worker, if any, and surface any fatal error it recorded, + /// including missed exec mappings (incomplete allocator coverage). A tracker + /// without an allocator watcher has no worker, so this is a no-op. pub fn finish(&self) -> Result<()> { - let worker = self - .worker - .lock() - .take() - .context("tracker already finished")?; - worker.finish() + match self.worker.lock().take() { + Some(worker) => worker.finish(), + None => Ok(()), + } } /// Detach all attached probes. Called explicitly at teardown because the diff --git a/crates/memtrack/testdata/rss/anon.c b/crates/memtrack/testdata/rss/anon.c new file mode 100644 index 00000000..8aa0974e --- /dev/null +++ b/crates/memtrack/testdata/rss/anon.c @@ -0,0 +1,17 @@ +#include +#include +#include + +#include "rss_report.h" + +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + size_t len = 64UL * 1024 * 1024; + void* mem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (mem == MAP_FAILED) return 1; + memset(mem, 0x42, len); + int ret = write_rss_report(argv[1]); + munmap(mem, len); + return ret; +} diff --git a/crates/memtrack/testdata/rss/file.c b/crates/memtrack/testdata/rss/file.c new file mode 100644 index 00000000..5d45c293 --- /dev/null +++ b/crates/memtrack/testdata/rss/file.c @@ -0,0 +1,30 @@ +#include +#include +#include +#include +#include + +#include "rss_report.h" + +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + size_t len = 64UL * 1024 * 1024; + char template[] = "/tmp/memtrack-rss-file-XXXXXX"; + int fd = mkstemp(template); + if (fd < 0) return 1; + unlink(template); + char chunk[65536]; + memset(chunk, 0x42, sizeof(chunk)); + for (size_t off = 0; off < len; off += sizeof(chunk)) { + if (write(fd, chunk, sizeof(chunk)) != (ssize_t)sizeof(chunk)) return 1; + } + void* mem = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); + if (mem == MAP_FAILED) return 1; + volatile char sink = 0; + for (size_t i = 0; i < len; i += 4096) sink ^= ((volatile char*)mem)[i]; + int ret = write_rss_report(argv[1]); + munmap(mem, len); + close(fd); + return ret + (sink & 0); +} diff --git a/crates/memtrack/testdata/rss/fork.c b/crates/memtrack/testdata/rss/fork.c new file mode 100644 index 00000000..f6fcfac0 --- /dev/null +++ b/crates/memtrack/testdata/rss/fork.c @@ -0,0 +1,39 @@ +#include +#include +#include +#include +#include + +#include "rss_report.h" + +static int append_rss(const char* path, const char* label) { + long kb = rss_status_kb("RssAnon:"); + if (kb < 0) return 1; + FILE* report = fopen(path, "a"); + if (!report) return 1; + fprintf(report, "%s: %ld\n", label, kb); + fclose(report); + return 0; +} + +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + size_t len = 0x20 * 1024 * 1024; + void* parent = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (parent == MAP_FAILED) return 1; + memset(parent, 0x42, len); + pid_t pid = fork(); + if (pid < 0) return 1; + if (pid == 0) { + void* child = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (child == MAP_FAILED) _exit(1); + memset(child, 0x24, len); + _exit(append_rss(argv[1], "ChildRssAnonKb")); + } + int status; + if (waitpid(pid, &status, 0) < 0) return 1; + int ret = append_rss(argv[1], "ParentRssAnonKb"); + munmap(parent, len); + return ret || !WIFEXITED(status) || WEXITSTATUS(status) != 0; +} diff --git a/crates/memtrack/testdata/rss/fork_idle.c b/crates/memtrack/testdata/rss/fork_idle.c new file mode 100644 index 00000000..0c5e5643 --- /dev/null +++ b/crates/memtrack/testdata/rss/fork_idle.c @@ -0,0 +1,49 @@ +#include +#include +#include +#include +#include + +#include "rss_report.h" + +/* The child only touches memory inherited from the parent: COW faults of anon + * pages are counter-neutral, so the child never reports its own RSS. Its + * footprint is only observable through fork-event seeding. The child must not + * allocate (no stdio/malloc), so the parent samples /proc//status while + * the child blocks on a pipe. */ +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + size_t len = 0x20 * 1024 * 1024; + void* mem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (mem == MAP_FAILED) return 1; + memset(mem, 0x42, len); + + int ready[2]; + int release[2]; + if (pipe(ready) || pipe(release)) return 1; + + pid_t pid = fork(); + if (pid < 0) return 1; + if (pid == 0) { + memset(mem, 0x24, len); + char b = 1; + if (write(ready[1], &b, 1) != 1) _exit(1); + if (read(release[0], &b, 1) != 1) _exit(1); + _exit(0); + } + + char b; + if (read(ready[0], &b, 1) != 1) return 1; + long child_anon = rss_status_kb_pid(pid, "RssAnon:"); + int ret = write_rss_report(argv[1]); + FILE* report = fopen(argv[1], "a"); + if (!report) return 1; + fprintf(report, "ChildRssAnon: %ld\n", child_anon); + fclose(report); + if (write(release[1], &b, 1) != 1) return 1; + + int status; + if (waitpid(pid, &status, 0) < 0) return 1; + return ret || child_anon < 0 || !WIFEXITED(status) || WEXITSTATUS(status) != 0; +} diff --git a/crates/memtrack/testdata/rss/madvise.c b/crates/memtrack/testdata/rss/madvise.c new file mode 100644 index 00000000..e5f77972 --- /dev/null +++ b/crates/memtrack/testdata/rss/madvise.c @@ -0,0 +1,64 @@ +#include +#include +#include +#include + +#include "rss_report.h" + +/* AnonHugePages for the whole process, from smaps_rollup (not in /proc/status). */ +static long anon_huge_pages_kb(void) { + FILE* rollup = fopen("/proc/self/smaps_rollup", "r"); + if (!rollup) return 0; + char line[256]; + long kb = 0; + while (fgets(line, sizeof(line), rollup)) { + if (sscanf(line, "AnonHugePages: %ld", &kb) == 1) break; + } + fclose(rollup); + return kb; +} + +/* Two 32 MiB anon regions: one advised MADV_HUGEPAGE (2 MiB-aligned so the + * kernel *may* fault it as PMD folios), one MADV_NOHUGEPAGE (guaranteed pte + * path). MADV_HUGEPAGE is only advisory, so nothing here depends on THP + * actually materializing: the accounted totals are identical either way. + * + * Both regions are dropped with MADV_DONTNEED and faulted a second time. If + * the in-context removes were missed, the reconstructed running total reaches + * 128 MiB instead of 64 MiB, so the snapshot peak is the assertion. The report + * also carries the observed AnonHugePages (hex, so it stays out of the + * snapshot) letting the test require PMD-sized rmap deltas exactly when THP + * actually materialized. */ +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + const size_t len = 32UL * 1024 * 1024; + const size_t align = 2UL * 1024 * 1024; + + void* raw = mmap(NULL, len + align, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (raw == MAP_FAILED) return 1; + char* huge = (char*)(((uintptr_t)raw + align - 1) & ~(uintptr_t)(align - 1)); + if (madvise(huge, len, MADV_HUGEPAGE) != 0) return 1; + + char* base = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (base == MAP_FAILED) return 1; + if (madvise(base, len, MADV_NOHUGEPAGE) != 0) return 1; + + memset(huge, 0x42, len); + memset(base, 0x42, len); + + if (madvise(huge, len, MADV_DONTNEED) != 0) return 1; + if (madvise(base, len, MADV_DONTNEED) != 0) return 1; + + memset(huge, 0x43, len); + memset(base, 0x43, len); + + int ret = write_rss_report(argv[1]); + FILE* report = fopen(argv[1], "a"); + if (!report) return 1; + fprintf(report, "ThpKb: 0x%lx\n", anon_huge_pages_kb()); + fclose(report); + munmap(raw, len + align); + munmap(base, len); + return ret; +} diff --git a/crates/memtrack/testdata/rss/madvise_extern.c b/crates/memtrack/testdata/rss/madvise_extern.c new file mode 100644 index 00000000..4be4c6b2 --- /dev/null +++ b/crates/memtrack/testdata/rss/madvise_extern.c @@ -0,0 +1,47 @@ +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + (void)argc; (void)argv; + sleep(1); /* let the tracker attach + enable + add root pid */ + + size_t len = 64UL * 1024 * 1024; + + char path[] = "/tmp/memtrack_madv_XXXXXX"; + int fd = mkstemp(path); + if (fd < 0) return 1; + unlink(path); + if (ftruncate(fd, len) != 0) return 1; + + void* mem = mmap(NULL, len, PROT_READ, MAP_PRIVATE, fd, 0); + if (mem == MAP_FAILED) return 1; + + /* Fault the file pages into A's RSS (MM_FILEPAGES), in-context -> seeds ownership. */ + volatile char sink = 0; + for (size_t i = 0; i < len; i += 4096) sink ^= ((volatile char*)mem)[i]; + (void)sink; + + pid_t pid = fork(); + if (pid < 0) return 1; + if (pid == 0) { + /* B: page out A's (parent's) region from B's context. */ + int pidfd = syscall(SYS_pidfd_open, getppid(), 0); + if (pidfd < 0) _exit(1); + struct iovec iov = {.iov_base = mem, .iov_len = len}; + syscall(SYS_process_madvise, pidfd, &iov, 1UL, MADV_PAGEOUT, 0UL); + _exit(0); + } + + int status; + if (waitpid(pid, &status, 0) < 0) return 1; + sleep(1); /* let the external decrement flush to the ring buffer */ + /* No munmap: an in-context decrement would mask the external-path signal. */ + return 0; +} diff --git a/crates/memtrack/testdata/rss/mremap_move.c b/crates/memtrack/testdata/rss/mremap_move.c new file mode 100644 index 00000000..3b86fc03 --- /dev/null +++ b/crates/memtrack/testdata/rss/mremap_move.c @@ -0,0 +1,31 @@ +#define _GNU_SOURCE +#include +#include +#include + +#include "rss_report.h" + +/* Fault 32 MiB, then force mremap to move it to a reserved destination. The + * move relocates page tables without rmap remove/add, so no events should + * fire and the second memset must not refault: a peak of 64 MiB instead of + * 32 MiB means the move was double-counted or the pages were dropped. */ +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + const size_t len = 32UL * 1024 * 1024; + + char* src = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (src == MAP_FAILED) return 1; + memset(src, 0x42, len); + + void* reserved = mmap(NULL, len, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (reserved == MAP_FAILED) return 1; + + char* dst = mremap(src, len, len, MREMAP_MAYMOVE | MREMAP_FIXED, reserved); + if (dst == MAP_FAILED) return 1; + memset(dst, 0x43, len); + + int ret = write_rss_report(argv[1]); + munmap(dst, len); + return ret; +} diff --git a/crates/memtrack/testdata/rss/munmap_hole.c b/crates/memtrack/testdata/rss/munmap_hole.c new file mode 100644 index 00000000..e769ff5b --- /dev/null +++ b/crates/memtrack/testdata/rss/munmap_hole.c @@ -0,0 +1,40 @@ +#include +#include +#include +#include + +#include "rss_report.h" + +/* Fault 64 MiB, punch a 16 MiB hole with munmap, then map and fault the hole + * again. If the partial-unmap removes were missed (or covered the wrong + * range), the reconstructed running total peaks at 80 MiB instead of 64 MiB. + * MADV_NOHUGEPAGE keeps every folio a single page: the region is only + * page-aligned, so a straddling PMD folio would blur the hole boundaries. */ +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + const size_t len = 64UL * 1024 * 1024; + const size_t hole_off = 24UL * 1024 * 1024; + const size_t hole_len = 16UL * 1024 * 1024; + + char* mem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (mem == MAP_FAILED) return 1; + if (madvise(mem, len, MADV_NOHUGEPAGE) != 0) return 1; + memset(mem, 0x42, len); + + if (munmap(mem + hole_off, hole_len) != 0) return 1; + + void* refill = mmap(mem + hole_off, hole_len, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0); + if (refill == MAP_FAILED) return 1; + if (madvise(refill, hole_len, MADV_NOHUGEPAGE) != 0) return 1; + memset(refill, 0x43, hole_len); + + int ret = write_rss_report(argv[1]); + FILE* report = fopen(argv[1], "a"); + if (!report) return 1; + fprintf(report, "Layout: 0x%lx 0x%lx 0x%lx 0x%lx\n", (unsigned long)mem, hole_off, hole_len, + len); + fclose(report); + return ret; +} diff --git a/crates/memtrack/testdata/rss/rss_report.h b/crates/memtrack/testdata/rss/rss_report.h new file mode 100644 index 00000000..e144d145 --- /dev/null +++ b/crates/memtrack/testdata/rss/rss_report.h @@ -0,0 +1,50 @@ +#ifndef RSS_REPORT_H +#define RSS_REPORT_H + +#include +#include +#include + +static long rss_status_kb_pid(int pid, const char* key) { + char status_path[64]; + snprintf(status_path, sizeof(status_path), "/proc/%d/status", pid); + FILE* status = fopen(status_path, "r"); + if (!status) return -1; + char line[256]; + long kb = -1; + size_t key_len = strlen(key); + while (fgets(line, sizeof(line), status)) { + if (strncmp(line, key, key_len) == 0 && sscanf(line + key_len, " %ld", &kb) == 1) { + break; + } + } + fclose(status); + return kb; +} + +static long rss_status_kb(const char* key) { + return rss_status_kb_pid(getpid(), key); +} + +static int write_rss_report(const char* path) { + long anon = rss_status_kb("RssAnon:"); + long file = rss_status_kb("RssFile:"); + long shmem = rss_status_kb("RssShmem:"); + /* VmHWM instead of getrusage(): ru_maxrss includes signal->maxrss, which + * survives execve and so reports the peak of the pre-exec parent image. + * VmHWM belongs to the mm and starts fresh at exec. */ + long max_rss = rss_status_kb("VmHWM:"); + if (anon < 0 || file < 0 || shmem < 0 || max_rss < 0) { + return 1; + } + FILE* report = fopen(path, "w"); + if (!report) { + return 1; + } + fprintf(report, "RssAnon: %ld\nRssFile: %ld\nRssShmem: %ld\nMaxRssKb: %ld\n", anon, file, + shmem, max_rss); + fclose(report); + return 0; +} + +#endif diff --git a/crates/memtrack/testdata/rss/shmem.c b/crates/memtrack/testdata/rss/shmem.c new file mode 100644 index 00000000..63b53e01 --- /dev/null +++ b/crates/memtrack/testdata/rss/shmem.c @@ -0,0 +1,22 @@ +#define _GNU_SOURCE +#include +#include +#include + +#include "rss_report.h" + +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + size_t len = 64UL * 1024 * 1024; + int fd = memfd_create("memtrack-rss-shmem", 0); + if (fd < 0) return 1; + if (ftruncate(fd, len) != 0) return 1; + void* mem = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0); + if (mem == MAP_FAILED) return 1; + memset(mem, 0x42, len); + int ret = write_rss_report(argv[1]); + munmap(mem, len); + close(fd); + return ret; +} diff --git a/crates/memtrack/testdata/rss/triangle.c b/crates/memtrack/testdata/rss/triangle.c new file mode 100644 index 00000000..69bf7bf6 --- /dev/null +++ b/crates/memtrack/testdata/rss/triangle.c @@ -0,0 +1,24 @@ +#include +#include +#include + +#include "rss_report.h" + +int main(int argc, char** argv) { + if (argc != 2) return 1; + sleep(1); + const size_t chunk = 16UL * 1024 * 1024; + void* bufs[4]; + for (int i = 0; i < 4; i++) { + bufs[i] = mmap(NULL, chunk, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (bufs[i] == MAP_FAILED) return 1; + memset(bufs[i], 0x42, chunk); + usleep(50 * 1000); + } + int ret = write_rss_report(argv[1]); + for (int i = 0; i < 4; i++) { + munmap(bufs[i], chunk); + usleep(50 * 1000); + } + return ret; +} diff --git a/crates/memtrack/tests/rss_tests.rs b/crates/memtrack/tests/rss_tests.rs new file mode 100644 index 00000000..d1099348 --- /dev/null +++ b/crates/memtrack/tests/rss_tests.rs @@ -0,0 +1,359 @@ +#[macro_use] +mod shared; + +use itertools::Itertools; +use rstest::rstest; +use runner_shared::artifacts::{MemtrackEvent, MemtrackEventKind}; +use serde::Serialize; +use std::collections::BTreeMap; +use std::process::Command; +use tempfile::TempDir; + +const MIB: u64 = 1024 * 1024; + +fn mib_16(bytes: u64) -> u64 { + (bytes + 8 * MIB) / (16 * MIB) * 16 +} + +fn parse_report(report: &str) -> BTreeMap { + report + .lines() + .filter_map(|line| { + let mut parts = line.split_whitespace(); + let key = parts.next()?.to_string(); + let kb: u64 = parts.next()?.parse().ok()?; + Some((key, mib_16(kb * 1024))) + }) + .collect() +} + +#[derive(Debug, Serialize)] +struct PidRss { + pid: i32, + file_mib: u64, + anon_mib: u64, + shmem_mib: u64, + max_rss_mib: u64, +} + +#[derive(Serialize)] +struct RssSummary { + report: BTreeMap, + rss_stat: Vec, + rmap: Vec, +} + +#[derive(Default)] +struct RssAccum { + latest: [u64; 4], + peaks: [u64; 4], + max_rss: u64, +} + +impl RssAccum { + fn update_peaks(&mut self) { + for (peak, latest) in self.peaks.iter_mut().zip(self.latest) { + *peak = (*peak).max(latest); + } + self.max_rss = self + .max_rss + .max(self.latest[0] + self.latest[1] + self.latest[3]); + } +} + +#[derive(Default)] +struct RmapAccum { + totals: [i64; 4], + peaks: [i64; 4], + max_rss: i64, +} + +impl RmapAccum { + fn update_peaks(&mut self) { + for (peak, total) in self.peaks.iter_mut().zip(self.totals) { + *peak = (*peak).max(total); + } + self.max_rss = self + .max_rss + .max(self.totals[0] + self.totals[1] + self.totals[3]); + } +} + +fn per_pid_peaks(events: &[MemtrackEvent]) -> (Vec, Vec) { + // Pid values can wrap, so numeric order is not stable; both views emit + // their rows in one shared first-activity order, keeping the relative + // order of processes consistent between `rss_stat` and `rmap` after + // the pids are redacted. + let mut order: Vec = Vec::new(); + let mut rss: BTreeMap = BTreeMap::new(); + let mut rmap: BTreeMap = BTreeMap::new(); + + fn seen(order: &mut Vec, pid: i32) { + if !order.contains(&pid) { + order.push(pid); + } + } + + for event in events.iter().sorted_by_key(|event| event.timestamp) { + match event.kind { + MemtrackEventKind::Rss { member, size } => { + let Ok(index @ 0..4) = usize::try_from(member) else { + continue; + }; + seen(&mut order, event.pid); + let acc = rss.entry(event.pid).or_default(); + acc.latest[index] = size; + acc.update_peaks(); + } + MemtrackEventKind::Rmap { member, delta } => { + let Ok(index @ 0..4) = usize::try_from(member) else { + continue; + }; + seen(&mut order, event.pid); + let acc = rmap.entry(event.pid).or_default(); + acc.totals[index] += delta * 4096; + acc.update_peaks(); + } + MemtrackEventKind::Fork { parent_pid } => { + seen(&mut order, event.pid); + + let seed = rss.get(&parent_pid).map(|p| p.latest).unwrap_or_default(); + let acc = rss.entry(event.pid).or_default(); + acc.latest = seed; + acc.update_peaks(); + + let seed = rmap.get(&parent_pid).map(|p| p.totals).unwrap_or_default(); + let acc = rmap.entry(event.pid).or_default(); + acc.totals = seed; + acc.update_peaks(); + } + MemtrackEventKind::Exec | MemtrackEventKind::Exit => { + if let Some(acc) = rss.get_mut(&event.pid) { + acc.latest = [0; 4]; + } + if let Some(acc) = rmap.get_mut(&event.pid) { + acc.totals = [0; 4]; + } + } + _ => {} + } + } + + let rss_stat = order + .iter() + .filter_map(|pid| { + let acc = rss.remove(pid)?; + Some(PidRss { + pid: *pid, + file_mib: mib_16(acc.peaks[0]), + anon_mib: mib_16(acc.peaks[1]), + shmem_mib: mib_16(acc.peaks[3]), + max_rss_mib: mib_16(acc.max_rss), + }) + }) + .collect(); + let rmap = order + .iter() + .filter_map(|pid| { + let acc = rmap.remove(pid)?; + Some(PidRss { + pid: *pid, + file_mib: mib_16(acc.peaks[0].max(0) as u64), + anon_mib: mib_16(acc.peaks[1].max(0) as u64), + shmem_mib: mib_16(acc.peaks[3].max(0) as u64), + max_rss_mib: mib_16(acc.max_rss.max(0) as u64), + }) + }) + .collect(); + (rss_stat, rmap) +} + +/// Compile a fixture that writes a `/proc` RSS report to its argv[1], run it under +/// `track`, and return the raw report text alongside the collected events. +fn track_fixture( + source: &str, + name: &str, + track: impl FnOnce(Command) -> shared::TrackResult, +) -> Result<(String, Vec), Box> { + let temp_dir = TempDir::new()?; + std::fs::write( + temp_dir.path().join("rss_report.h"), + include_str!("../testdata/rss/rss_report.h"), + )?; + let binary = shared::compile_c_source(source, name, temp_dir.path())?; + let report_path = temp_dir.path().join(format!("{name}.report")); + let mut command = Command::new(&binary); + command.arg(&report_path); + + let (events, thread_handle) = track(command)?; + let raw_report = std::fs::read_to_string(&report_path)?; + thread_handle.join().unwrap(); + Ok((raw_report, events)) +} + +/// Pins reconstructed rmap addresses to the exact punched range: hole pages are +/// the only ones removed and later re-added; every other page in the region is +/// added first and only removed afterwards. +fn assert_rmap_hole_addresses( + events: &[MemtrackEvent], + base: u64, + hole_off: u64, + hole_len: u64, + len: u64, +) { + const PAGE: u64 = 4096; + let n_pages = (len / PAGE) as usize; + let mut first_remove = vec![u64::MAX; n_pages]; + let mut added = vec![false; n_pages]; + let mut readded = vec![false; n_pages]; + + for event in events.iter().sorted_by_key(|e| e.timestamp) { + let MemtrackEventKind::Rmap { delta, .. } = event.kind else { + continue; + }; + if event.addr < base || event.addr >= base + len { + continue; + } + let first = ((event.addr - base) / PAGE) as usize; + let last = (first + delta.unsigned_abs() as usize).min(n_pages); + for page in first..last { + if delta > 0 { + added[page] = true; + if event.timestamp > first_remove[page] { + readded[page] = true; + } + } else { + first_remove[page] = first_remove[page].min(event.timestamp); + } + } + } + + let hole = (hole_off / PAGE) as usize..((hole_off + hole_len) / PAGE) as usize; + for page in 0..n_pages { + assert!(added[page], "page {page} never saw an rmap add"); + assert_eq!( + readded[page], + hole.contains(&page), + "page {page}: remove-then-add pattern does not match the hole range" + ); + } +} + +#[test_with::env(GITHUB_ACTIONS)] +#[rstest] +#[case::anon(include_str!("../testdata/rss/anon.c"), "anon")] +#[case::file(include_str!("../testdata/rss/file.c"), "file")] +#[case::shmem(include_str!("../testdata/rss/shmem.c"), "shmem")] +#[case::fork(include_str!("../testdata/rss/fork.c"), "fork")] +#[case::fork_idle(include_str!("../testdata/rss/fork_idle.c"), "fork_idle")] +#[case::triangle(include_str!("../testdata/rss/triangle.c"), "triangle")] +#[case::madvise(include_str!("../testdata/rss/madvise.c"), "madvise")] +#[case::munmap_hole(include_str!("../testdata/rss/munmap_hole.c"), "munmap_hole")] +#[case::mremap_move(include_str!("../testdata/rss/mremap_move.c"), "mremap_move")] +fn test_rss_rmap_tracking( + #[case] source: &str, + #[case] name: &str, +) -> Result<(), Box> { + let (raw_report, events) = track_fixture(source, name, shared::track_command_with_rmap)?; + let (rss_stat, rmap) = per_pid_peaks(&events); + let summary = RssSummary { + report: parse_report(&raw_report), + rss_stat, + rmap, + }; + insta::assert_json_snapshot!(format!("rss_{name}"), summary, { + ".rss_stat[].pid" => "[pid]", + ".rmap[].pid" => "[pid]", + }); + + if let Some(layout) = raw_report + .lines() + .find_map(|line| line.strip_prefix("Layout:")) + { + let values: Vec = layout + .split_whitespace() + .map(|token| u64::from_str_radix(token.trim_start_matches("0x"), 16)) + .collect::>()?; + let [base, hole_off, hole_len, len] = values[..] else { + panic!("malformed Layout line: {layout}"); + }; + assert_rmap_hole_addresses(&events, base, hole_off, hole_len, len); + } + + // ThpKb > 0 means the MADV_HUGEPAGE region really faulted as PMD folios, so + // huge-folio accounting must be visible: a +512-page delta from the new-anon + // fault path and a -512-page delta that can only come from the + // folio_remove_rmap_pmd hook (MADV_DONTNEED / munmap of a pmd-mapped THP). + if let Some(thp) = raw_report + .lines() + .find_map(|line| line.strip_prefix("ThpKb:")) + { + let thp_kb = u64::from_str_radix(thp.trim().trim_start_matches("0x"), 16)?; + if thp_kb > 0 { + let deltas = events.iter().filter_map(|e| match e.kind { + MemtrackEventKind::Rmap { delta, .. } => Some(delta), + _ => None, + }); + let (mut huge_add, mut huge_remove) = (false, false); + for delta in deltas { + huge_add |= delta >= 512; + huge_remove |= delta <= -512; + } + assert!( + huge_add, + "THP present ({thp_kb} kB) but no huge-folio rmap add" + ); + assert!( + huge_remove, + "THP present ({thp_kb} kB) but no pmd-sized rmap remove" + ); + } + } + Ok(()) +} + +#[test_with::env(GITHUB_ACTIONS)] +#[test] +fn test_rss_external_reclaim() -> Result<(), Box> { + let temp_dir = TempDir::new()?; + let binary = shared::compile_c_source( + include_str!("../testdata/rss/madvise_extern.c"), + "madvise_extern", + temp_dir.path(), + )?; + let (events, handle) = shared::track_command(Command::new(&binary))?; + handle.join().unwrap(); + + // Single fork: parent_pid == A (owner), event.pid == B (external caller, single-threaded + // so its tid == its pid). + let (a, b) = events + .iter() + .find_map(|e| match e.kind { + MemtrackEventKind::Fork { parent_pid } => Some((parent_pid, e.pid)), + _ => None, + }) + .expect("expected a fork event"); + + let peak = events + .iter() + .filter_map(|e| match e.kind { + MemtrackEventKind::Rss { member: 0, size } if e.pid == a => Some(size), + _ => None, + }) + .max() + .unwrap_or(0); + assert!(peak >= 32 * MIB, "peak file RSS too small: {peak}"); + + // A file decrement owned by A but emitted from B's context: only present when + // out-of-context rss_stat updates are attributed to the owning process. + let external_decrement = events.iter().any(|e| { + e.pid == a + && e.tid == b + && matches!(e.kind, MemtrackEventKind::Rss { member: 0, size } if size < peak) + }); + assert!( + external_decrement, + "external file-RSS decrement not attributed to A (tid=B)" + ); + Ok(()) +} diff --git a/crates/memtrack/tests/shared.rs b/crates/memtrack/tests/shared.rs index d31bce09..f38edb7c 100644 --- a/crates/memtrack/tests/shared.rs +++ b/crates/memtrack/tests/shared.rs @@ -6,7 +6,7 @@ use runner_shared::artifacts::{MemtrackEvent as Event, MemtrackEventKind}; use std::path::Path; use std::process::Command; -type TrackResult = anyhow::Result<(Vec, std::thread::JoinHandle<()>)>; +pub type TrackResult = anyhow::Result<(Vec, std::thread::JoinHandle<()>)>; /// Asserts memory events using snapshot testing without marker filtering. /// @@ -27,6 +27,17 @@ macro_rules! assert_events_snapshot { // Dedup events by address and type to remove duplicates let events = $events .iter() + .filter(|e| { + // Allocation snapshots track only allocator events; RSS and + // process-lifecycle events are asserted by dedicated tests. + !matches!( + e.kind, + MemtrackEventKind::Rss { .. } + | MemtrackEventKind::Fork { .. } + | MemtrackEventKind::Exec + | MemtrackEventKind::Exit + ) + }) .sorted_by_key(|e| e.timestamp) .dedup_by(|a, b| a.addr == b.addr && discriminant(&a.kind) == discriminant(&b.kind)) .collect::>(); @@ -76,6 +87,7 @@ macro_rules! assert_events_with_marker { // Remove events outside our 0xC0D59EED marker allocations let filtered_events = $events .iter() + .filter(|e| !matches!(e.kind, MemtrackEventKind::Rss { .. })) .sorted_by_key(|e| e.timestamp) .dedup_by(|a, b| a.addr == b.addr && discriminant(&a.kind) == discriminant(&b.kind)) .skip_while(|e| { @@ -141,12 +153,46 @@ pub fn track_binary(binary: &Path) -> TrackResult { track_command(Command::new(binary)) } +pub fn compile_c_source( + source_code: &str, + name: &str, + output_dir: &Path, +) -> Result> { + let source_path = output_dir.join(format!("{name}.c")); + let binary_path = output_dir.join(name); + std::fs::write(&source_path, source_code)?; + + let output = Command::new("gcc") + .args(["-O0", "-o", binary_path.to_str().unwrap()]) + .arg(&source_path) + .output()?; + if !output.status.success() { + error!("gcc stderr: {}", String::from_utf8_lossy(&output.stderr)); + return Err("Failed to compile C fixture".into()); + } + + Ok(binary_path) +} + /// Track a command, collecting all memory events. /// /// No allocators are pre-attached: the exec-mapping watcher discovers and /// attaches them as the tracked tree maps executable files. pub fn track_command(command: Command) -> TrackResult { - let tracker = Tracker::new()?; + track_command_impl(command, false) +} + +/// Track a command with folio rmap hooks enabled, reconstructing per-process RSS. +pub fn track_command_with_rmap(command: Command) -> TrackResult { + track_command_impl(command, true) +} + +fn track_command_impl(command: Command, track_rmap: bool) -> TrackResult { + let tracker = if track_rmap { + Tracker::new_without_allocators_with_rmap(true)? + } else { + Tracker::new()? + }; tracker.enable_tracking()?; let mut session = tracker.spawn(&command, None)?; diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_anon.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_anon.snap new file mode 100644 index 00000000..bd39e6cc --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_anon.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 64, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_file.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_file.snap new file mode 100644 index 00000000..2745b74d --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_file.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 0, + "RssFile:": 64, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 64, + "anon_mib": 0, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 64, + "anon_mib": 0, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_fork.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_fork.snap new file mode 100644 index 00000000..bc24de5b --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_fork.snap @@ -0,0 +1,42 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "ChildRssAnonKb:": 64, + "ParentRssAnonKb:": 32 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + }, + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + }, + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_fork_idle.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_fork_idle.snap new file mode 100644 index 00000000..3a97ec7d --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_fork_idle.snap @@ -0,0 +1,45 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "ChildRssAnon:": 32, + "MaxRssKb:": 32, + "RssAnon:": 32, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + }, + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + }, + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_madvise.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_madvise.snap new file mode 100644 index 00000000..bd39e6cc --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_madvise.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 64, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_mremap_move.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_mremap_move.snap new file mode 100644 index 00000000..6e2a2202 --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_mremap_move.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 32, + "RssAnon:": 32, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 32, + "shmem_mib": 0, + "max_rss_mib": 32 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_munmap_hole.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_munmap_hole.snap new file mode 100644 index 00000000..bd39e6cc --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_munmap_hole.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 64, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_shmem.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_shmem.snap new file mode 100644 index 00000000..19bb441e --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_shmem.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 0, + "RssFile:": 0, + "RssShmem:": 64 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 0, + "shmem_mib": 64, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 0, + "shmem_mib": 64, + "max_rss_mib": 64 + } + ] +} diff --git a/crates/memtrack/tests/snapshots/rss_tests__rss_triangle.snap b/crates/memtrack/tests/snapshots/rss_tests__rss_triangle.snap new file mode 100644 index 00000000..bd39e6cc --- /dev/null +++ b/crates/memtrack/tests/snapshots/rss_tests__rss_triangle.snap @@ -0,0 +1,30 @@ +--- +source: crates/memtrack/tests/rss_tests.rs +expression: summary +--- +{ + "report": { + "MaxRssKb:": 64, + "RssAnon:": 64, + "RssFile:": 0, + "RssShmem:": 0 + }, + "rss_stat": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ], + "rmap": [ + { + "pid": "[pid]", + "file_mib": 0, + "anon_mib": 64, + "shmem_mib": 0, + "max_rss_mib": 64 + } + ] +}