Skip to content

Commit a7e1666

Browse files
committed
test(memtrack): add dlopen/on-demand allocator tests + CI matrix
1 parent 22eada2 commit a7e1666

12 files changed

Lines changed: 327 additions & 93 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ jobs:
9595
# Each memtrack integration test binary runs its cases serially
9696
# (eBPF tracker can't overlap with itself in one process), so we
9797
# shard at the test-binary level to parallelize across jobs.
98-
test: [c_tests, cpp_tests, rust_tests, spawn_tests]
98+
test: [c_tests, cpp_tests, rust_tests, spawn_tests, dlopen_tests]
9999
steps:
100100
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
101101
with:
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#include <dlfcn.h>
2+
#include <stdio.h>
3+
#include <stddef.h>
4+
5+
/* dlopen an allocator lib and IMMEDIATELY (no sleep) allocate through it. The
6+
* immediacy is the race assertion: every allocation must be captured, which
7+
* requires the on-demand watcher to have stopped-classified-attached-resumed
8+
* before dlopen returns. */
9+
int main(int argc, char** argv) {
10+
if (argc < 2) {
11+
fprintf(stderr, "usage: %s <lib>\n", argv[0]);
12+
return 1;
13+
}
14+
15+
void* handle = dlopen(argv[1], RTLD_NOW);
16+
if (!handle) {
17+
fprintf(stderr, "dlopen failed: %s\n", dlerror());
18+
return 1;
19+
}
20+
21+
void* (*mi_malloc)(size_t) = (void* (*)(size_t))dlsym(handle, "mi_malloc");
22+
void (*mi_free)(void*) = (void (*)(void*))dlsym(handle, "mi_free");
23+
if (!mi_malloc || !mi_free) {
24+
fprintf(stderr, "dlsym failed\n");
25+
return 1;
26+
}
27+
28+
for (int i = 0; i < 100; i++) {
29+
void* p = mi_malloc(4242);
30+
mi_free(p);
31+
}
32+
33+
return 0;
34+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
#include <stddef.h>
2+
3+
/* Self-contained bump allocator; classifies as Jemalloc via the je_malloc
4+
* symbol. See fake_mimalloc.c for why it does not call libc malloc. */
5+
static char arena[1 << 20];
6+
static size_t off = 0;
7+
8+
void* je_malloc(size_t size) {
9+
size_t aligned = (size + 15) & ~((size_t)15);
10+
if (off + aligned > sizeof(arena)) {
11+
return 0;
12+
}
13+
void* p = &arena[off];
14+
off += aligned;
15+
return p;
16+
}
17+
18+
void je_free(void* p) {
19+
(void)p;
20+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#include <stddef.h>
2+
3+
/* Self-contained bump allocator so mi_malloc does not delegate to libc malloc
4+
* (which is probed separately and would double-count). Classifies as Mimalloc
5+
* via the mi_malloc/mi_free symbols. */
6+
static char arena[1 << 20];
7+
static size_t off = 0;
8+
9+
void* mi_malloc(size_t size) {
10+
size_t aligned = (size + 15) & ~((size_t)15);
11+
if (off + aligned > sizeof(arena)) {
12+
return 0;
13+
}
14+
void* p = &arena[off];
15+
off += aligned;
16+
return p;
17+
}
18+
19+
void mi_free(void* p) {
20+
(void)p;
21+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
#include <dlfcn.h>
2+
#include <pthread.h>
3+
#include <stddef.h>
4+
#include <stdio.h>
5+
6+
/* Two threads each dlopen a distinct allocator lib concurrently and allocate
7+
* through it. Exercises concurrent stop-the-world: both threads trigger the
8+
* watcher, both must be stopped, both libs classified/attached, both resumed. */
9+
struct job {
10+
const char* path;
11+
const char* alloc_sym;
12+
const char* free_sym;
13+
size_t size;
14+
};
15+
16+
static void* run(void* arg) {
17+
struct job* j = (struct job*)arg;
18+
void* handle = dlopen(j->path, RTLD_NOW);
19+
if (!handle) {
20+
return NULL;
21+
}
22+
23+
void* (*alloc)(size_t) = (void* (*)(size_t))dlsym(handle, j->alloc_sym);
24+
void (*dealloc)(void*) = (void (*)(void*))dlsym(handle, j->free_sym);
25+
if (!alloc || !dealloc) {
26+
return NULL;
27+
}
28+
29+
for (int i = 0; i < 100; i++) {
30+
void* p = alloc(j->size);
31+
dealloc(p);
32+
}
33+
34+
return NULL;
35+
}
36+
37+
int main(int argc, char** argv) {
38+
if (argc < 3) {
39+
fprintf(stderr, "usage: %s <mimalloc-lib> <jemalloc-lib>\n", argv[0]);
40+
return 1;
41+
}
42+
43+
struct job j1 = {argv[1], "mi_malloc", "mi_free", 4242};
44+
struct job j2 = {argv[2], "je_malloc", "je_free", 4243};
45+
46+
pthread_t t1, t2;
47+
pthread_create(&t1, NULL, run, &j1);
48+
pthread_create(&t2, NULL, run, &j2);
49+
pthread_join(t1, NULL);
50+
pthread_join(t2, NULL);
51+
52+
return 0;
53+
}

crates/memtrack/tests/cpp_tests.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
#[macro_use]
22
mod shared;
33

4-
use memtrack::AllocatorLib;
54
use rstest::rstest;
65
use std::path::Path;
76
use std::process::Command;
87

8+
/// A cached cmake configure pins absolute library paths from a previous
9+
/// environment (e.g. garbage-collected nix store entries). On failure, wipe
10+
/// the build dir and retry once from a fresh configure.
11+
///
12+
/// Builds are serialized: parallel test cases share the build dir, and the
13+
/// retry path deletes it.
914
fn compile_cpp_project(project_dir: &Path, target: &str) -> anyhow::Result<std::path::PathBuf> {
15+
static BUILD_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
16+
let _guard = BUILD_LOCK.lock().unwrap();
17+
18+
match build_cpp_target(project_dir, target) {
19+
Ok(path) => Ok(path),
20+
Err(_) => {
21+
std::fs::remove_dir_all(project_dir.join("build"))?;
22+
build_cpp_target(project_dir, target)
23+
}
24+
}
25+
}
26+
27+
fn build_cpp_target(project_dir: &Path, target: &str) -> anyhow::Result<std::path::PathBuf> {
1028
let build_exists = project_dir.join("build").exists();
1129
if !build_exists {
1230
// Configure with cmake -B build
@@ -57,13 +75,7 @@ fn test_cpp_alloc_tracking(#[case] target: &str) -> Result<(), Box<dyn std::erro
5775
let project_path = Path::new("testdata/alloc_cpp");
5876
let binary = compile_cpp_project(project_path, target)?;
5977

60-
// Try to find a static allocator in the binary, then attach to it as well
61-
// This is needed because the CWD is different, which breaks the heuristics.
62-
let allocators = AllocatorLib::from_path_static(&binary)
63-
.map(|a| vec![a])
64-
.unwrap_or_default();
65-
66-
let (events, thread_handle) = shared::track_binary_with_opts(&binary, &allocators)?;
78+
let (events, thread_handle) = shared::track_binary(&binary)?;
6779
assert_events_with_marker!(target, &events);
6880

6981
thread_handle.join().unwrap();
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#[macro_use]
2+
mod shared;
3+
4+
use runner_shared::artifacts::MemtrackEventKind;
5+
use std::collections::HashSet;
6+
use std::path::{Path, PathBuf};
7+
use std::process::Command;
8+
use tempfile::TempDir;
9+
10+
fn compile_shared(source: &str, name: &str, dir: &Path) -> PathBuf {
11+
let src = dir.join(format!("{name}.c"));
12+
std::fs::write(&src, source).expect("write source");
13+
let out = dir.join(format!("{name}.so"));
14+
let ok = Command::new("gcc")
15+
.args(["-shared", "-fPIC", "-o"])
16+
.arg(&out)
17+
.arg(&src)
18+
.status()
19+
.expect("run gcc")
20+
.success();
21+
assert!(ok, "failed to compile {name}.so");
22+
out
23+
}
24+
25+
fn compile_exe(source: &str, name: &str, dir: &Path, libs: &[&str]) -> PathBuf {
26+
let src = dir.join(format!("{name}.c"));
27+
std::fs::write(&src, source).expect("write source");
28+
let out = dir.join(name);
29+
let ok = Command::new("gcc")
30+
.arg("-o")
31+
.arg(&out)
32+
.arg(&src)
33+
.args(libs)
34+
.status()
35+
.expect("run gcc")
36+
.success();
37+
assert!(ok, "failed to compile {name}");
38+
out
39+
}
40+
41+
/// dlopen -> fentry -> SIGSTOP -> resolve -> classify -> attach -> SIGCONT ->
42+
/// first allocation captured. The dlopen'd allocator's 100 allocations must all
43+
/// be captured, proving the attach completes before the child's first alloc.
44+
#[test_with::env(GITHUB_ACTIONS)]
45+
#[test_log::test]
46+
fn test_dlopen_allocator() -> Result<(), Box<dyn std::error::Error>> {
47+
let dir = TempDir::new()?;
48+
let lib = compile_shared(
49+
include_str!("../testdata/dlopen/fake_mimalloc.c"),
50+
"libfake_mimalloc",
51+
dir.path(),
52+
);
53+
let exe = compile_exe(
54+
include_str!("../testdata/dlopen/dlopen_alloc.c"),
55+
"dlopen_alloc",
56+
dir.path(),
57+
&["-ldl"],
58+
);
59+
60+
let mut cmd = Command::new(&exe);
61+
cmd.arg(&lib);
62+
let (events, thread_handle) = shared::track_command(cmd)?;
63+
64+
let malloc_addrs: HashSet<u64> = events
65+
.iter()
66+
.filter_map(|e| match e.kind {
67+
MemtrackEventKind::Malloc { size: 4242 } => Some(e.addr),
68+
_ => None,
69+
})
70+
.collect();
71+
let malloc_count = events
72+
.iter()
73+
.filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4242 }))
74+
.count();
75+
let free_count = events
76+
.iter()
77+
.filter(|e| matches!(e.kind, MemtrackEventKind::Free) && malloc_addrs.contains(&e.addr))
78+
.count();
79+
80+
assert_eq!(malloc_count, 100, "expected 100 mi_malloc(4242) events");
81+
assert_eq!(
82+
free_count, 100,
83+
"expected 100 mi_free events for tracked addrs"
84+
);
85+
86+
thread_handle.join().unwrap();
87+
Ok(())
88+
}
89+
90+
/// Two threads dlopen distinct allocator libs concurrently. Both libs' 100
91+
/// allocations each must be captured, exercising concurrent stop-the-world.
92+
#[test_with::env(GITHUB_ACTIONS)]
93+
#[test_log::test]
94+
fn test_thread_dlopen() -> Result<(), Box<dyn std::error::Error>> {
95+
let dir = TempDir::new()?;
96+
let mimalloc = compile_shared(
97+
include_str!("../testdata/dlopen/fake_mimalloc.c"),
98+
"libfake_mimalloc",
99+
dir.path(),
100+
);
101+
let jemalloc = compile_shared(
102+
include_str!("../testdata/dlopen/fake_jemalloc.c"),
103+
"libfake_jemalloc",
104+
dir.path(),
105+
);
106+
let exe = compile_exe(
107+
include_str!("../testdata/dlopen/thread_dlopen.c"),
108+
"thread_dlopen",
109+
dir.path(),
110+
&["-ldl", "-lpthread"],
111+
);
112+
113+
let mut cmd = Command::new(&exe);
114+
cmd.arg(&mimalloc).arg(&jemalloc);
115+
let (events, thread_handle) = shared::track_command(cmd)?;
116+
117+
let m4242 = events
118+
.iter()
119+
.filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4242 }))
120+
.count();
121+
let m4243 = events
122+
.iter()
123+
.filter(|e| matches!(e.kind, MemtrackEventKind::Malloc { size: 4243 }))
124+
.count();
125+
126+
assert_eq!(m4242, 100, "expected 100 mi_malloc(4242) events");
127+
assert_eq!(m4243, 100, "expected 100 je_malloc(4243) events");
128+
129+
thread_handle.join().unwrap();
130+
Ok(())
131+
}

crates/memtrack/tests/rust_tests.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
#[macro_use]
22
mod shared;
33

4-
use memtrack::AllocatorLib;
54
use rstest::rstest;
65
use std::path::Path;
76

@@ -18,13 +17,8 @@ fn test_rust_alloc_tracking(
1817
let crate_path = Path::new("testdata/alloc_rust");
1918
let binary = shared::compile_rust_binary(crate_path, "alloc_rust", features)?;
2019

21-
// Try to find a static allocator in the binary, then attach to it as well
22-
// This is needed because the CWD is different, which breaks the heuristics.
23-
let allocators = AllocatorLib::from_path_static(&binary)
24-
.map(|a| vec![a])
25-
.unwrap_or_default();
26-
27-
let (events, thread_handle) = shared::track_binary_with_opts(&binary, &allocators)?;
20+
// No extra allocators: the watcher must discover the static allocator itself.
21+
let (events, thread_handle) = shared::track_binary(&binary)?;
2822
assert_events_with_marker!(name, &events);
2923

3024
thread_handle.join().unwrap();

0 commit comments

Comments
 (0)