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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,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, dlopen_tests, rss_tests]
test: [c_tests, cpp_tests, rust_tests, spawn_tests, dlopen_tests, rss_tests, stack_tests]
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
Expand Down
11 changes: 11 additions & 0 deletions Cargo.lock

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

4 changes: 3 additions & 1 deletion crates/memtrack/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,13 @@ Control plane: `src/ipc.rs` exposes an out-of-band `ipc-channel` protocol (`Enab

Allocator discovery (`src/allocators/`): `AllocatorLib::find_all()` = dynamic (glob shared libs incl. `/nix/store/*` hints) + static-linked (scan build-dir ELF symbols) + env (`CODSPEED_MEMTRACK_BINARIES`). Each `AllocatorKind` (`Libc`/`LibCpp`/`Jemalloc`/`Mimalloc`/`Tcmalloc`) maps to best-effort attach helpers; only libc must succeed.

Mapping recorder (`src/ebpf/c/mappings.bpf.h`, `src/ebpf/mappings/`): an LSM program on `mmap_file` resolves each mapped file's path once per inode into `path_by_inode` (`bpf_path_d_path` on kernels >= 6.12, `bpf_d_path` on the sleepable hook from 5.11), and an `fentry/perf_event_mmap` program emits executable-mapping geometry (`dev`/`ino`/`file_offset`/`start`/`end`) on the `mappings` ring buffer. Userspace joins the two into a `MemtrackMappings` artifact, which the runner turns into `unwind_data`/`symbols.map` files and a `memtrack.metadata` so allocation stacks unwind off-box. `MappingSupport::detect()` gates the programs on the kernel release **and** `bpf` being in `/sys/kernel/security/lsm`; with neither available, stack capture is disabled since nothing could attribute the stacks.

> Note: the "on-demand attach" design in `.agents/docs/` (AttachWorker, `CODSPEED_MEMTRACK_ONDEMAND`, SIGSTOP/SIGCONT) is a **plan, not yet in source**. Current behavior is upfront attach + `sched_fork` auto-tracking.

## Key Directories

- `src/ebpf/` — BPF stack (feature-gated `ebpf`): `tracker.rs` (facade), `memtrack/` (libbpf-rs wrapper + generated skeleton, split into `mod.rs`/`macros.rs`/`maps.rs`/`allocator.rs`/`tracking.rs`), `poller.rs`, `events.rs`, `c/main.bpf.c` + `c/event.h` + `c/utils/*.h` + `c/allocator.h`.
- `src/ebpf/` — BPF stack (feature-gated `ebpf`): `tracker.rs` (facade), `memtrack/` (libbpf-rs wrapper + generated skeleton, split into `mod.rs`/`macros.rs`/`maps.rs`/`allocator.rs`/`tracking.rs`), `mappings/` (records/resolve/support), `poller.rs`, `events.rs`, `c/main.bpf.c` + `c/event.h` + `c/mappings.bpf.h` + `c/utils/*.h` + `c/allocator.h`.
- `src/allocators/` — allocator classification: `mod.rs`, `dynamic.rs`, `static_linked.rs`.
- `tests/` — integration tests + `snapshots/` (insta).
- `testdata/` — allocation fixtures: `*.c` (gcc), `alloc_cpp/` (cmkr/CMake), `alloc_rust/` + `spawn_wrapper/` (standalone Cargo workspaces).
Expand Down
25 changes: 18 additions & 7 deletions crates/memtrack/src/ebpf/c/allocator.h
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
BPF_HASH_MAP(name##_arg, __u64, __u64, 10000); \
SEC(UPROBE_SEC) \
int uprobe_##name(struct pt_regs* ctx) { \
stash_stack_hash(capture_stack(ctx)); \
return store_param(&name##_arg, arg_expr); \
} \
SEC(URETPROBE_SEC) \
Expand All @@ -17,6 +18,7 @@
if (!arg_ptr) { \
return 0; \
} \
__u64 stack_hash = take_stack_hash(); \
__u64 ret_val = PT_REGS_RC(ctx); \
if (ret_val == 0) { \
return 0; \
Expand All @@ -32,6 +34,7 @@
if (arg0 == 0) { \
return 0; \
} \
__u64 stack_hash = capture_stack(ctx); \
submit_block; \
}

Expand All @@ -50,6 +53,8 @@
return 0; \
} \
\
stash_stack_hash(capture_stack(ctx)); \
\
struct name##_args_t args = {.arg0 = arg0_expr, .arg1 = arg1_expr}; \
\
bpf_map_update_elem(&name##_args, &tid, &args, BPF_ANY); \
Expand All @@ -63,6 +68,7 @@
if (!args) { \
return 0; \
} \
__u64 stack_hash = take_stack_hash(); \
\
struct name##_args_t a = *args; \
bpf_map_delete_elem(&name##_args, &tid); \
Expand All @@ -77,20 +83,22 @@
submit_block; \
}

UPROBE_ARG_RET(malloc, PT_REGS_PARM1(ctx), { return submit_alloc_event(arg0, ret_val); })
UPROBE_ARG_RET(malloc, PT_REGS_PARM1(ctx),
{ return submit_alloc_event(arg0, ret_val, stack_hash); })

UPROBE_RET(free, PT_REGS_PARM1(ctx), { return submit_free_event(arg0); })
UPROBE_RET(free, PT_REGS_PARM1(ctx), { return submit_free_event(arg0, stack_hash); })

UPROBE_ARG_RET(calloc, PT_REGS_PARM1(ctx) * PT_REGS_PARM2(ctx),
{ return submit_calloc_event(arg0, ret_val); })
{ return submit_calloc_event(arg0, ret_val, stack_hash); })

UPROBE_ARGS_RET(realloc, PT_REGS_PARM2(ctx), PT_REGS_PARM1(ctx),
{ return submit_realloc_event(arg1, ret_val, arg0); })
{ return submit_realloc_event(arg1, ret_val, arg0, stack_hash); })

UPROBE_ARG_RET(aligned_alloc, PT_REGS_PARM2(ctx),
{ return submit_aligned_alloc_event(arg0, ret_val); })
{ return submit_aligned_alloc_event(arg0, ret_val, stack_hash); })

UPROBE_ARG_RET(memalign, PT_REGS_PARM2(ctx), { return submit_aligned_alloc_event(arg0, ret_val); })
UPROBE_ARG_RET(memalign, PT_REGS_PARM2(ctx),
{ return submit_aligned_alloc_event(arg0, ret_val, stack_hash); })

/*
* posix_memalign(void** memptr, size_t alignment, size_t size)
Expand All @@ -115,6 +123,8 @@ int uprobe_posix_memalign(struct pt_regs* ctx) {
return 0;
}

stash_stack_hash(capture_stack(ctx));

struct posix_memalign_args_t args = {.memptr = PT_REGS_PARM1(ctx), .size = PT_REGS_PARM3(ctx)};
bpf_map_update_elem(&posix_memalign_args, &tid, &args, BPF_ANY);
return 0;
Expand All @@ -127,6 +137,7 @@ int uretprobe_posix_memalign(struct pt_regs* ctx) {
if (!args) {
return 0;
}
__u64 stack_hash = take_stack_hash();

struct posix_memalign_args_t a = *args;
bpf_map_delete_elem(&posix_memalign_args, &tid);
Expand All @@ -140,7 +151,7 @@ int uretprobe_posix_memalign(struct pt_regs* ctx) {
return 0;
}

return submit_aligned_alloc_event(a.size, addr);
return submit_aligned_alloc_event(a.size, addr, stack_hash);
}

struct mmap_args {
Expand Down
5 changes: 0 additions & 5 deletions crates/memtrack/src/ebpf/c/attach.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,6 @@
#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 */
Expand Down
78 changes: 72 additions & 6 deletions crates/memtrack/src/ebpf/c/event.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,48 @@
#define EVENT_TYPE_RSS 12
#define EVENT_TYPE_RMAP 13

/* Largest user-stack copy one definition can carry. The scratch buffer holding
* header plus bytes is a per-CPU map value, capped at PCPU_MIN_UNIT_SIZE
* (32 KiB) by the kernel allocator. */
#define MEMTRACK_MAX_STACK_COPY (32 * 1024 - 512)

/* Registers, indexed by the capturing architecture's DWARF register number
* (x86_64: 0=rax .. 7=rsp, 8..15=r8-r15, 16=rip; aarch64: 0..30=x0-x30,
* 31=sp, 32=pc). Slots the architecture does not define stay zero. An offline
* DWARF unwinder needs the callee-saved ones to evaluate CFA rules, not just
* ip/sp/bp. */
#define MEMTRACK_STACK_REGS 33

/* Counter slots in the stack_counters array map. */
#define MEMTRACK_STACK_COUNTER_COPY_FAILED 0
#define MEMTRACK_STACK_COUNTER_HASH_MAP_FULL 1
/* bpf_get_stackid() has several negative outcomes (no user callchain,
* hash-bucket collision, or no free bucket), so this counts only missing ids. */
#define MEMTRACK_STACK_COUNTER_STACKID_FAILED 2
#define MEMTRACK_STACK_COUNTER_TRUNCATED 3
#define MEMTRACK_STACK_COUNTER_RING_FULL 4
#define MEMTRACK_STACK_COUNTER_PREEMPTED 5
#define MEMTRACK_STACK_COUNTER_COUNT 6

struct stack_regs {
uint64_t reg[MEMTRACK_STACK_REGS];
};

/* Head of a stack record; `copy_len` raw stack bytes read upwards from `sp`
* follow it. */
struct stack_header {
uint64_t hash;
uint64_t timestamp; /* monotonic time in nanoseconds (CLOCK_MONOTONIC) */
int64_t stackid; /* bpf_get_stackid() result; negative means unavailable */
uint64_t sp; /* user stack pointer the copy starts at */
uint32_t pid;
uint32_t tid;
uint32_t copy_len;
uint8_t truncated; /* the copy hit the size cap */
uint8_t _pad[3];
struct stack_regs regs;
};

/* Common header shared by all event types */
struct event_header {
uint8_t event_type; /* See EVENT_TYPE_* constants above */
Expand All @@ -29,20 +71,23 @@ struct event {
union {
/* Allocation events (malloc, calloc, aligned_alloc) */
struct {
uint64_t addr; /* address returned */
uint64_t size; /* size requested */
uint64_t addr; /* address returned */
uint64_t size; /* size requested */
uint64_t stack_hash; /* caller stack identity; 0 = not captured */
} alloc;

/* Deallocation event (free) */
struct {
uint64_t addr; /* address to free */
uint64_t addr; /* address to free */
uint64_t stack_hash; /* caller stack identity; 0 = not captured */
} free;

/* Reallocation event - includes both old and new addresses */
struct {
uint64_t old_addr; /* previous address (can be NULL) */
uint64_t new_addr; /* new address returned */
uint64_t size; /* new size requested */
uint64_t old_addr; /* previous address (can be NULL) */
uint64_t new_addr; /* new address returned */
uint64_t size; /* new size requested */
uint64_t stack_hash; /* caller stack identity; 0 = not captured */
} realloc;

/* Memory mapping events (mmap, munmap, brk) */
Expand All @@ -69,11 +114,32 @@ struct event {
} data;
};

/* Identifies a mapped file across both the attach watcher and the mapping
* recorder. `dev` uses the kernel's s_dev encoding: (major << 20) | minor. */
struct inode_key {
uint64_t dev;
uint64_t ino;
};

/* 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;
};

/* One executable file mapping, mirroring PERF_RECORD_MMAP2. The path is not
* here: it is resolved once per inode into a BPF map that userspace joins
* against, since every mapping of the same file shares it. */
struct mapping_record {
uint64_t dev;
uint64_t ino;
uint64_t file_offset; /* offset of the mapping's first byte in the file */
uint64_t start;
uint64_t end;
uint64_t timestamp; /* monotonic time in nanoseconds (CLOCK_MONOTONIC) */
uint32_t pid;
uint32_t _pad;
};

#endif /* __EVENT_H__ */
2 changes: 2 additions & 0 deletions crates/memtrack/src/ebpf/c/main.bpf.c
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
#include "allocator.h"
#include "attach.h"
#include "event.h"
#include "mappings.bpf.h"
#include "process_tracking.bpf.h"
#include "rmap.bpf.h"
#include "rss.bpf.h"
#include "stack_capture.bpf.h"
#include "utils/event_helpers.h"
#include "utils/folio.h"
#include "utils/map_helpers.h"
Expand Down
Loading