Skip to content

Commit b7f1281

Browse files
committed
chore(heap-profiling): address review feedback and general cleanup
1 parent e026a3c commit b7f1281

16 files changed

Lines changed: 150 additions & 10 deletions

File tree

libdd-profiling-heap-allocator/benches/sampler_overhead.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ mod linux_bench {
3838
// Return a stable aligned pointer with mapped bytes before it. The sampler's free path
3939
// may inspect header-sized bytes immediately before the user pointer when
4040
// checking for sampled allocations.
41+
//
42+
// Always returns the same fixed pointer. This allocator isn't tracking real
43+
// capacity or state; it exists purely to eliminate the real allocator's cost
44+
// from the benchmark so it measures the sampler's own overhead.
4145
unsafe { ptr::addr_of_mut!(NOOP_BUFFER.0).cast::<u8>().add(4096) }
4246
}
4347

@@ -48,10 +52,21 @@ mod linux_bench {
4852
unsafe { ptr::addr_of_mut!(NOOP_BUFFER.0).cast::<u8>().add(4096) }
4953
}
5054

55+
/// # Safety
56+
///
57+
/// Must be called on a thread that isn't concurrently tearing down its
58+
/// TLS (i.e. not from a destructor); otherwise identical to
59+
/// `dd_tl_state_get_or_init`'s own safety contract.
5160
unsafe fn sampler_tl_state() -> *mut libdd_profiling_heap_sampler::dd_tl_state_t {
5261
unsafe { dd_tl_state_get_or_init() }
5362
}
5463

64+
/// Pins this thread's sampler state onto the fast (unsampled) path for
65+
/// the rest of the benchmark. `remaining_bytes` starts at a huge
66+
/// negative value and `sampling_interval` at a huge positive one, so
67+
/// benchmark-sized allocations can never drive `remaining_bytes`
68+
/// non-negative and trigger the slow/sampled path. Dividing by 4 keeps
69+
/// headroom against overflow while summing allocation sizes.
5570
unsafe fn pin_sampler_to_fast_path() {
5671
let tl = unsafe { sampler_tl_state() };
5772
if !tl.is_null() {
@@ -64,6 +79,10 @@ mod linux_bench {
6479
}
6580
}
6681

82+
/// Forces the next allocation on this thread onto the slow/sampled
83+
/// path. `512 * 1024` matches `DD_SAMPLING_INTERVAL_DEFAULT` (see
84+
/// tl_state.h), so this benchmarks the sampled path against the same
85+
/// interval used in production rather than an arbitrary value.
6786
unsafe fn force_next_allocation_to_sample() {
6887
let tl = unsafe { sampler_tl_state() };
6988
if !tl.is_null() {

libdd-profiling-heap-allocator/src/allocator.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,13 @@ unsafe impl<A: GlobalAlloc> GlobalAlloc for SampledAllocator<A> {
7171
return self.inner.dealloc(ptr, layout);
7272
}
7373
let freed = dd_allocation_freed(ptr.cast(), layout.size(), layout.align());
74+
// `layout.align()` is reused here rather than anything derived from
75+
// `freed`: alignment never changes between the original `alloc` and
76+
// this `dealloc`, so pairing `freed.size` with the caller's own
77+
// alignment is exactly what `GlobalAlloc::dealloc`'s safety contract
78+
// already requires (the layout passed here must match the one used
79+
// for the original allocation). This isn't a guarantee `SampledAllocator`
80+
// adds — it's just satisfying the contract our caller is on the hook for.
7481
let inner_layout = Layout::from_size_align_unchecked(freed.size, layout.align());
7582
self.inner.dealloc(freed.ptr.cast(), inner_layout);
7683
}

libdd-profiling-heap-allocator/src/lib.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@
1414
//!
1515
//! # Features
1616
//!
17-
//! * `live-heap` (off by default) — enables live-heap tracking: allocations are flagged and frees
18-
//! are sampled, so a profiler can balance allocs against frees. Off = allocation profiling only.
17+
//! * `live-heap` (off by default) - enables live-heap tracking: sampled allocations are flagged at
18+
//! alloc time, and that flag is detected again on free, so a profiler can pair each free back to
19+
//! its sample and balance allocs against frees. Off = allocation profiling only.
1920
//!
2021
//! # Example
2122
//!

libdd-profiling-heap-gotter-ffi/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ bench = false
1616
[features]
1717
default = ["cbindgen"]
1818
cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"]
19+
# See libdd-profiling-heap-gotter's test-support feature. Not for production use.
20+
test-support = ["libdd-profiling-heap-gotter/test-support"]
1921

2022
[build-dependencies]
2123
build_common = { path = "../build-common" }

libdd-profiling-heap-gotter-ffi/examples/cdylib_demo.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
//! The gotter-ffi crate is Linux-only; on other targets the example
1818
//! compiles to a no-op `main` so clippy/test on non-Linux don't fail
1919
//! with "configured out".
20+
//!
21+
//! This demo deliberately loads the library via `dlopen`/`dlsym` even
22+
//! though a real Rust binary would just link the crate directly. That's
23+
//! contrived for Rust specifically, but it's the pattern every other
24+
//! language's SDK actually has to use to consume this FFI surface, so
25+
//! exercising it here catches issues (symbol visibility, ABI mismatches)
26+
//! that a direct `extern crate` link would hide.
2027
2128
#[cfg(not(target_os = "linux"))]
2229
fn main() -> Result<(), String> {
@@ -54,6 +61,11 @@ mod linux {
5461
Ok(Self(handle))
5562
}
5663

64+
/// # Safety
65+
///
66+
/// `T` must be exactly the function pointer type of the symbol named
67+
/// `name` in the loaded library; a mismatch is transmuted silently
68+
/// and will call into the wrong signature.
5769
unsafe fn symbol<T>(&self, name: &CStr) -> Result<T, String>
5870
where
5971
T: Copy,
@@ -85,6 +97,11 @@ mod linux {
8597
}
8698
}
8799

100+
/// Infers the built cdylib's path from `cargo run --example`'s layout:
101+
/// the example binary lands at `target/<profile>/examples/<name>`, and
102+
/// the cdylib is a sibling of `examples/` at `target/<profile>/<lib>`.
103+
/// `DDOG_HEAP_GOTTER_FFI_CDYLIB` overrides this for anyone driving the
104+
/// build differently.
88105
fn cdylib_path() -> Result<PathBuf, String> {
89106
if let Some(path) = std::env::var_os("DDOG_HEAP_GOTTER_FFI_CDYLIB") {
90107
return Ok(PathBuf::from(path));

libdd-profiling-heap-gotter-ffi/src/lib.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,3 +62,14 @@ pub extern "C" fn ddog_heap_gotter_update() -> VoidResult {
6262
pub extern "C" fn ddog_heap_gotter_is_installed() -> bool {
6363
libdd_profiling_heap_gotter::heap_overrides_are_installed()
6464
}
65+
66+
/// Test-only: number of times a patched hook (`malloc`/`free`) has run in
67+
/// this process. Lets integration tests prove the patched GOT was actually
68+
/// exercised, not just that nothing crashed. Not part of the production API
69+
/// surface; only compiled in with the `test-support` feature.
70+
#[cfg(feature = "test-support")]
71+
#[no_mangle]
72+
#[must_use]
73+
pub extern "C" fn ddog_heap_gotter_test_hook_hits() -> u64 {
74+
libdd_profiling_heap_gotter::test_hook_hits()
75+
}

libdd-profiling-heap-gotter-ffi/tests/install.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
#![cfg(all(target_os = "linux", target_pointer_width = "64", not(miri)))]
1515

1616
use libdd_common_ffi::VoidResult;
17+
#[cfg(feature = "test-support")]
18+
use libdd_profiling_heap_gotter_ffi::ddog_heap_gotter_test_hook_hits;
1719
use libdd_profiling_heap_gotter_ffi::{ddog_heap_gotter_install, ddog_heap_gotter_is_installed};
1820

1921
#[track_caller]
@@ -37,9 +39,19 @@ fn install_patches_the_got() {
3739
"is_installed should be true after install"
3840
);
3941

40-
// Touch the heap while installed so the patched GOT actually gets
41-
// used. We just need the process to still be alive after this.
42+
// Touch the heap while installed so the patched GOT actually gets used.
43+
#[cfg(feature = "test-support")]
44+
let hits_before = ddog_heap_gotter_test_hook_hits();
45+
4246
let v: Vec<u8> = vec![0; 128];
4347
assert_eq!(v.len(), 128);
4448
drop(v);
49+
50+
// With test-support, prove the hooks actually ran rather than just that
51+
// the process survived.
52+
#[cfg(feature = "test-support")]
53+
assert!(
54+
ddog_heap_gotter_test_hook_hits() > hits_before,
55+
"expected the malloc/free hooks to run"
56+
);
4557
}

libdd-profiling-heap-gotter/Cargo.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ bench = false
1919
[features]
2020
# Forward live-heap (retained-heap) tracking down to the sampler
2121
live-heap = ["libdd-profiling-heap-sampler/live-heap"]
22+
# Exposes a hook-hit counter so integration tests in other crates (e.g.
23+
# libdd-profiling-heap-gotter-ffi) can prove the patched GOT was actually
24+
# used, not just that nothing crashed. Not for production use.
25+
test-support = []
2226

2327
[target.'cfg(target_os = "linux")'.dependencies]
2428
libc = "0.2"

libdd-profiling-heap-gotter/src/elf.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,13 @@ struct PatchedLibrary {
531531
/// Identifies the library at this base address, so we can detect
532532
/// base-address reuse after a `dlclose` + `dlopen` places a
533533
/// different library at the same load address.
534+
///
535+
/// Known limitation: detection keys on this name, not on library
536+
/// contents. A different version of the same library reloaded at the
537+
/// same base (identical path, changed contents, same base despite
538+
/// ASLR) would slip through and we would restore stale GOT values.
539+
/// This is judged unlikely enough in practice to document rather than
540+
/// guard against with additional fingerprinting.
534541
library_name: String,
535542
/// Set each pass in which this library was seen; used to drop entries
536543
/// for libraries that have since been unloaded.

libdd-profiling-heap-gotter/src/hooks.rs

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@ use core::ffi::{c_char, c_int, c_void};
1717
use std::sync::atomic::{AtomicUsize, Ordering};
1818

1919
use libdd_profiling_heap_sampler::{
20-
dd_alloc_req_t, dd_allocation_created, dd_allocation_freed, dd_allocation_realloc_commit,
21-
dd_allocation_realloc_prepare, dd_allocation_requested, dd_tl_state_init,
20+
dd_alloc_req_is_sampled, dd_alloc_req_t, dd_allocation_created, dd_allocation_freed,
21+
dd_allocation_realloc_commit, dd_allocation_realloc_prepare, dd_allocation_requested,
22+
dd_tl_state_init,
2223
};
2324

2425
/// Resolved address of the real `malloc`; filled by `install_heap_overrides`.
@@ -39,9 +40,27 @@ pub(crate) static ORIG_DLOPEN: AtomicUsize = AtomicUsize::new(0);
3940
/// Resolved address of the real `pthread_create`.
4041
pub(crate) static ORIG_PTHREAD_CREATE: AtomicUsize = AtomicUsize::new(0);
4142

43+
/// Counts hook invocations so integration tests outside this crate can
44+
/// prove the patched GOT was actually exercised, not just that nothing
45+
/// crashed. Only `malloc`/`free` increment it: that's enough to prove the
46+
/// hooks ran without adding bookkeeping to every symbol.
47+
#[cfg(feature = "test-support")]
48+
pub(crate) static HOOK_HITS: AtomicUsize = AtomicUsize::new(0);
49+
4250
/// Load a resolved function pointer from one of the `ORIG_*` slots.
51+
///
52+
/// # Safety
53+
///
54+
/// `T` must be exactly the `extern "C" fn(...)` pointer type that
55+
/// `apply_overrides` writes into `slot` (see the `ORIG_*` docs above); if
56+
/// `slot` is still `0`, this returns `None` rather than transmuting.
4357
#[inline]
4458
unsafe fn load_fn<T>(slot: &AtomicUsize) -> Option<T> {
59+
// `Acquire` pairs with the `store(Release)` in elf.rs's
60+
// `apply_overrides`, which runs before the GOT is ever patched to
61+
// route calls into these hooks. That gives a real happens-before
62+
// edge: once this observes a non-zero slot, it's guaranteed to see
63+
// the fully-published address, not just "no torn read".
4564
let v = slot.load(Ordering::Acquire);
4665
if v == 0 {
4766
None
@@ -95,17 +114,21 @@ type PthreadCreateFn = unsafe extern "C" fn(
95114

96115
#[no_mangle]
97116
pub unsafe extern "C" fn gotter_malloc(size: usize) -> *mut c_void {
117+
#[cfg(feature = "test-support")]
118+
HOOK_HITS.fetch_add(1, Ordering::Relaxed);
98119
let Some(real): Option<MallocFn> = load_fn(&ORIG_MALLOC) else {
99120
return std::ptr::null_mut();
100121
};
101122
// Default alignment for malloc on glibc is 2*sizeof(void*) == 16.
102-
let req = dd_allocation_requested(size, core::mem::align_of::<u64>() * 2);
123+
let req = dd_allocation_requested(size, core::mem::align_of::<*mut c_void>() * 2);
103124
let raw = real(req.size);
104125
dd_allocation_created(raw, req)
105126
}
106127

107128
#[no_mangle]
108129
pub unsafe extern "C" fn gotter_free(ptr: *mut c_void) {
130+
#[cfg(feature = "test-support")]
131+
HOOK_HITS.fetch_add(1, Ordering::Relaxed);
109132
let Some(real): Option<FreeFn> = load_fn(&ORIG_FREE) else {
110133
return;
111134
};
@@ -125,14 +148,14 @@ pub unsafe extern "C" fn gotter_calloc(nmemb: usize, size: usize) -> *mut c_void
125148
let Some(total) = nmemb.checked_mul(size) else {
126149
return real(nmemb, size);
127150
};
128-
let req = dd_allocation_requested(total, core::mem::align_of::<u64>() * 2);
151+
let req = dd_allocation_requested(total, core::mem::align_of::<*mut c_void>() * 2);
129152
// calloc takes (nmemb, size); when the sampler bumps `req.size` we
130153
// funnel the extra bytes into the size argument (nmemb stays 1's
131154
// worth conceptually). The simplest robust path is to switch to a
132155
// single (1, req.size) allocation when sampling kicks in, so the
133156
// underlying allocator zeroes everything we hand back. Unsampled
134157
// path keeps the user's (nmemb, size) verbatim.
135-
let raw = if req.weight == 0 {
158+
let raw = if !dd_alloc_req_is_sampled(req) {
136159
real(nmemb, size)
137160
} else {
138161
real(1, req.size)

0 commit comments

Comments
 (0)