Skip to content

Commit 2369b7c

Browse files
committed
chore(heap-profiling): more review feedback cleanup
1 parent 10bd200 commit 2369b7c

22 files changed

Lines changed: 508 additions & 182 deletions

File tree

Cargo.lock

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,3 +33,6 @@ libdd-profiling-heap-gotter = { path = "../libdd-profiling-heap-gotter" }
3333

3434
[target.'cfg(target_os = "linux")'.dev-dependencies]
3535
libc = "0.2"
36+
# For the USDT-note ELF inspection test (tests/usdt_notes.rs); mirrors how
37+
# libdd-otel-thread-ctx-ffi pulls in its dep's `sanity-check` feature.
38+
libdd-profiling-heap-sampler = { path = "../libdd-profiling-heap-sampler", features = ["sanity-check"] }
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright 2026-Present Datadog, Inc. https://www.datadoghq.com/
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Verify the ddheap USDT probes appear exactly once in the built shared
5+
//! library's `.note.stapsdt`. A duplicate entry (e.g. from `static inline` +
6+
//! bindgen's `wrap_static_fns`, or LTO inlining across TUs) would make an
7+
//! attached consumer fire twice, so this guards the "one note per probe"
8+
//! invariant documented in the sampler's `probes.c`.
9+
//!
10+
//! Inspecting the linked cdylib (rather than running the check in-process)
11+
//! validates the real shipped artifact. Mirrors
12+
//! libdd-otel-thread-ctx-ffi/tests/elf_properties.rs.
13+
//!
14+
//! The cdylib path is derived at runtime from the test executable location.
15+
//! Both the test binary and the cdylib live in `target/<[triple/]profile>/deps/`.
16+
17+
#![cfg(target_os = "linux")]
18+
19+
use std::path::PathBuf;
20+
21+
fn cdylib_path() -> PathBuf {
22+
let exe = std::env::current_exe().expect("failed to read current executable path");
23+
exe.parent()
24+
.expect("unexpected test executable path structure")
25+
.join("liblibdd_profiling_heap_gotter_ffi.so")
26+
}
27+
28+
#[test]
29+
#[cfg_attr(miri, ignore)]
30+
fn ddheap_probes_have_one_note_each() {
31+
let path = cdylib_path();
32+
libdd_profiling_heap_sampler::usdt_check::check_usdt_probes_in(&path).unwrap();
33+
}

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

Lines changed: 38 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ mod elf;
6161
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6262
mod hooks;
6363

64+
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
65+
use std::sync::atomic::{AtomicBool, Ordering};
6466
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
6567
use std::sync::{Mutex, MutexGuard, TryLockError};
6668

@@ -73,10 +75,29 @@ use elf::SymbolOverrides;
7375
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
7476
static GLOBAL_OVERRIDES: Mutex<Option<SymbolOverrides>> = Mutex::new(None);
7577

78+
/// Set when a `dlopen` hook loses the `try_lock` race in
79+
/// `update_heap_overrides`: its newly-loaded library wasn't scanned, so the
80+
/// thread holding the lock must re-scan before releasing. Prevents a
81+
/// concurrent `dlopen` from being missed until the next unrelated scan.
82+
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
83+
static RESCAN_PENDING: AtomicBool = AtomicBool::new(false);
84+
85+
/// Run `update_overrides` while the lock is held until no rescan is pending.
86+
/// Called by the lock holder after its own scan so a `dlopen` that raced the
87+
/// lock still gets picked up. `update_overrides` is cheap when `dlpi_adds`
88+
/// hasn't changed, so a spurious drain is nearly free.
89+
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
90+
fn drain_pending_rescans(so: &mut SymbolOverrides) {
91+
while RESCAN_PENDING.swap(false, Ordering::AcqRel) {
92+
so.update_overrides();
93+
}
94+
}
95+
7696
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
7797
fn lock_global_overrides() -> MutexGuard<'static, Option<SymbolOverrides>> {
7898
GLOBAL_OVERRIDES
7999
.lock()
100+
// Recover from poison: hot path never takes this lock, registry re-applies idempotently.
80101
.unwrap_or_else(|poisoned| poisoned.into_inner())
81102
}
82103

@@ -111,6 +132,7 @@ pub fn install_heap_overrides() -> bool {
111132
}
112133
let so = guard.as_mut().unwrap();
113134
so.apply_overrides();
135+
drain_pending_rescans(so);
114136
// Heuristic: at least one ORIG slot resolved.
115137
any_orig_resolved()
116138
}
@@ -131,11 +153,18 @@ pub fn update_heap_overrides() {
131153
// outer apply_overrides, which already walks every library.
132154
let mut guard = match GLOBAL_OVERRIDES.try_lock() {
133155
Ok(guard) => guard,
156+
// Recover from poison: same rationale as lock_global_overrides.
134157
Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(),
135-
Err(TryLockError::WouldBlock) => return,
158+
// Lock held by another install/update: flag a rescan so that thread
159+
// picks up the library we just loaded before it releases the lock.
160+
Err(TryLockError::WouldBlock) => {
161+
RESCAN_PENDING.store(true, Ordering::Release);
162+
return;
163+
}
136164
};
137165
if let Some(so) = guard.as_mut() {
138166
so.update_overrides();
167+
drain_pending_rescans(so);
139168
}
140169
}
141170

@@ -179,55 +208,31 @@ pub fn test_hook_hits() -> u64 {
179208
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
180209
fn register_all(so: &mut SymbolOverrides) {
181210
use hooks::*;
182-
use std::sync::atomic::AtomicUsize;
183211

184212
// Register one entry per supported symbol. The install path stores
185213
// via `store(Release)` and hooks read via `load(Acquire)`; both go
186214
// through the typed atomic to avoid racing plain writes against
187215
// atomic loads.
188-
fn reg(so: &mut SymbolOverrides, name: &str, hook_addr: usize, slot: &'static AtomicUsize) {
189-
so.register(name, hook_addr, slot);
190-
}
191-
192-
reg(
193-
so,
194-
"malloc",
195-
gotter_malloc as *const () as usize,
196-
&ORIG_MALLOC,
197-
);
198-
reg(so, "free", gotter_free as *const () as usize, &ORIG_FREE);
199-
reg(
200-
so,
201-
"calloc",
202-
gotter_calloc as *const () as usize,
203-
&ORIG_CALLOC,
204-
);
205-
reg(
206-
so,
216+
so.register("malloc", gotter_malloc as *const () as usize, &ORIG_MALLOC);
217+
so.register("free", gotter_free as *const () as usize, &ORIG_FREE);
218+
so.register("calloc", gotter_calloc as *const () as usize, &ORIG_CALLOC);
219+
so.register(
207220
"realloc",
208221
gotter_realloc as *const () as usize,
209222
&ORIG_REALLOC,
210223
);
211-
reg(
212-
so,
224+
so.register(
213225
"posix_memalign",
214226
gotter_posix_memalign as *const () as usize,
215227
&ORIG_POSIX_MEMALIGN,
216228
);
217-
reg(
218-
so,
229+
so.register(
219230
"aligned_alloc",
220231
gotter_aligned_alloc as *const () as usize,
221232
&ORIG_ALIGNED_ALLOC,
222233
);
223-
reg(
224-
so,
225-
"dlopen",
226-
gotter_dlopen as *const () as usize,
227-
&ORIG_DLOPEN,
228-
);
229-
reg(
230-
so,
234+
so.register("dlopen", gotter_dlopen as *const () as usize, &ORIG_DLOPEN);
235+
so.register(
231236
"pthread_create",
232237
gotter_pthread_create as *const () as usize,
233238
&ORIG_PTHREAD_CREATE,

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

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,11 +24,30 @@ use std::ffi::c_void;
2424
use libdd_profiling_heap_sampler::{dd_sample_flag_peek, dd_tl_state_get_or_init};
2525
use serial_test::serial;
2626

27+
/// Warn (once) when these tests aren't running under nextest. The GOT install
28+
/// is permanent, so isolation depends on each test getting its own process;
29+
/// nextest sets `NEXTEST=1`, plain `cargo test` shares one process and leaks
30+
/// a prior test's install into later ones.
31+
fn warn_if_not_isolated() {
32+
use std::sync::Once;
33+
static ONCE: Once = Once::new();
34+
ONCE.call_once(|| {
35+
if std::env::var_os("NEXTEST").is_none() {
36+
eprintln!(
37+
"warning: heap-gotter install tests need per-test process isolation; \
38+
run with `cargo nextest run`. Under `cargo test` a prior test's \
39+
permanent GOT install leaks into later ones."
40+
);
41+
}
42+
});
43+
}
44+
2745
/// After install the heap should still be functional and no recursive
2846
/// crash should occur when malloc/free go through the patched GOT.
2947
#[test]
3048
#[serial]
3149
fn install_keeps_heap_functional() {
50+
warn_if_not_isolated();
3251
extern "C" {
3352
fn malloc(size: usize) -> *mut c_void;
3453
}
@@ -40,9 +59,17 @@ fn install_keeps_heap_functional() {
4059
);
4160

4261
unsafe {
43-
let p = malloc(64);
62+
let p = malloc(64) as *mut u8;
4463
assert!(!p.is_null(), "malloc returned NULL post-install");
45-
libc::free(p);
64+
// Write then read back the whole buffer to prove it's real, usable
65+
// memory, not just a non-NULL (possibly mis-offset) pointer.
66+
for i in 0..64 {
67+
p.add(i).write((i as u8) ^ 0xA5);
68+
}
69+
for i in 0..64 {
70+
assert_eq!(p.add(i).read(), (i as u8) ^ 0xA5, "byte {i} corrupted");
71+
}
72+
libc::free(p as *mut c_void);
4673
}
4774
}
4875

@@ -54,6 +81,7 @@ fn install_keeps_heap_functional() {
5481
#[test]
5582
#[serial]
5683
fn install_produces_sampled_allocations() {
84+
warn_if_not_isolated();
5785
let installed = libdd_profiling_heap_gotter::install_heap_overrides();
5886
assert!(installed);
5987

@@ -97,6 +125,7 @@ fn install_produces_sampled_allocations() {
97125
#[test]
98126
#[serial]
99127
fn realloc_null_produces_sampled_allocation() {
128+
warn_if_not_isolated();
100129
let installed = libdd_profiling_heap_gotter::install_heap_overrides();
101130
assert!(installed);
102131

@@ -130,6 +159,7 @@ fn realloc_null_produces_sampled_allocation() {
130159
#[test]
131160
#[serial]
132161
fn page_aligned_allocations_are_unsampled() {
162+
warn_if_not_isolated();
133163
let installed = libdd_profiling_heap_gotter::install_heap_overrides();
134164
assert!(installed);
135165

@@ -162,6 +192,7 @@ fn page_aligned_allocations_are_unsampled() {
162192
#[test]
163193
#[serial]
164194
fn realloc_of_sampled_allocation_preserves_data() {
195+
warn_if_not_isolated();
165196
let installed = libdd_profiling_heap_gotter::install_heap_overrides();
166197
assert!(installed);
167198

@@ -246,6 +277,7 @@ unsafe fn alloc_aligned(align: usize, size: usize) -> *mut c_void {
246277
#[test]
247278
#[serial]
248279
fn realloc_stress_across_alignments_preserves_data() {
280+
warn_if_not_isolated();
249281
// Mirrors the demo's menu, plus 2048 to bracket the 1024 cap on both
250282
// sides. Small alignments sample; those above the cap pass through.
251283
const ALIGNMENTS: &[usize] = &[1, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192];
@@ -336,3 +368,36 @@ fn realloc_stress_across_alignments_preserves_data() {
336368
#[cfg(not(feature = "live-heap"))]
337369
let _ = saw_sampled;
338370
}
371+
372+
/// A failing allocation must surface the real allocator's error/errno through
373+
/// our hooks unchanged. Avoids relying on `aligned_alloc` rejecting a bad
374+
/// alignment, since older glibc (e.g. CentOS 7) doesn't validate that.
375+
#[test]
376+
#[serial]
377+
fn invalid_alignment_passes_through_error() {
378+
warn_if_not_isolated();
379+
let installed = libdd_profiling_heap_gotter::install_heap_overrides();
380+
assert!(installed);
381+
382+
unsafe {
383+
// posix_memalign with a non-power-of-two alignment must return EINVAL.
384+
// POSIX-specified, so reliable across glibc versions; exercises our
385+
// hook forwarding the real return code unchanged.
386+
let mut out: *mut c_void = std::ptr::null_mut();
387+
let rc = libc::posix_memalign(&mut out, 3, 64);
388+
assert_eq!(rc, libc::EINVAL, "posix_memalign should return EINVAL");
389+
390+
// A failing allocation must preserve the real allocator's errno through
391+
// our post-call bookkeeping. A huge aligned_alloc reliably fails with
392+
// ENOMEM on every libc (size is a multiple of the alignment).
393+
*libc::__errno_location() = 0;
394+
let huge = usize::MAX & !0xfff;
395+
let p = libc::aligned_alloc(16, huge);
396+
assert!(p.is_null(), "huge aligned_alloc should fail");
397+
assert_eq!(
398+
std::io::Error::last_os_error().raw_os_error(),
399+
Some(libc::ENOMEM),
400+
"errno must survive our post-call bookkeeping"
401+
);
402+
}
403+
}

libdd-profiling-heap-sampler/Cargo.toml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,14 @@ default = []
2222
# against allocs for retained/live-heap profiling, and sample
2323
# frees to emit ddheap:free.
2424
live-heap = []
25+
# Runtime ELF self-inspection of the USDT notes (see src/usdt_check.rs). Opt-in
26+
# so the crate keeps zero runtime deps by default; only pulls in elf/anyhow.
27+
sanity-check = ["dep:elf", "dep:anyhow"]
28+
29+
# Only used by the `sanity-check` feature; the crate has no runtime deps otherwise.
30+
[dependencies]
31+
anyhow = { version = "1.0", optional = true }
32+
elf = { version = "0.7", optional = true }
2533

2634
[build-dependencies]
2735
cc = "1.1.31"

libdd-profiling-heap-sampler/build.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ mod linux {
9595
// Translate the `live-heap` cargo feature into a C define.
9696
let live_heap = env::var_os("CARGO_FEATURE_LIVE_HEAP").is_some();
9797

98+
// Define NDEBUG for optimized builds so C `assert()`s are stripped in
99+
// release and live in dev/test (incl. `cargo test`). cc doesn't set
100+
// this itself; `.debug()` only controls `-g`. Key off OPT_LEVEL, not
101+
// DEBUG: this workspace's release profile keeps line-table debuginfo,
102+
// so cargo reports DEBUG=true there too; OPT_LEVEL is "0" only for the
103+
// unoptimized dev/test profile.
104+
let optimized = env::var("OPT_LEVEL").as_deref() != Ok("0");
105+
98106
let mut build = cc::Build::new();
99107
build
100108
.files(SOURCES)
@@ -112,6 +120,9 @@ mod linux {
112120
// loads it works on both glibc and musl without allocation
113121
// concerns (see tl_state.h for the full analysis).
114122
.flag_if_supported("-mtls-dialect=gnu2");
123+
if optimized {
124+
build.define("NDEBUG", None);
125+
}
115126
build.compile("dd_heap_sampler");
116127

117128
// `allocation_requested.c` calls `log()`; glibc keeps it in libm,

libdd-profiling-heap-sampler/docs/tagging.md

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ this case.
2929
Although x86-64 typically supports pointer tagging and it was (briefly) enabled
3030
in the kernel, it was pulled back out around Spectre due to security concerns.
3131

32-
Instead we steal, bytes from the allocation itself - when we decide to sample, we ask the
32+
Instead we steal bytes from the allocation itself - when we decide to sample, we ask the
3333
underlying allocator for more memory than the caller requested, then place
3434
the user-visible pointer some way into that block, leaving room for a
3535
16-byte header just before it.
@@ -69,15 +69,16 @@ an unsampled allocation, and if that allocation's contents happen to look
6969
like our magic at the right offset, we'd wrongly treat it as sampled. Zeroing
7070
prevents that.
7171

72-
**Keeping the three sites in sync**
72+
**Keeping the sizing in one place**
7373

74-
The formula for the bumped allocation size has to be computed identically in
75-
three places: when we decide how much extra to ask for (`bumped_alloc_size`
76-
in `allocation_requested.c`), when we place the header and user pointer
77-
(`x86_apply` in `sample_flag.h`), and when we work out how big the original
78-
allocation was so we can free the right amount (`dd_allocation_freed_slow`
79-
in `allocation_freed.c`). If any of these disagree we corrupt memory. Touch
80-
one, check the other two.
74+
The bumped allocation size is computed by a single shared inline,
75+
`x86_bumped_size` in `sample_flag.h`. Both the alloc side (`bumped_alloc_size`
76+
in `allocation_requested.c`, deciding how much extra to ask for) and the free
77+
side (`dd_allocation_freed_slow` in `allocation_freed.c`, recovering the size
78+
for sized-free) call it, so the formula can't drift between them. The header
79+
placement (`x86_apply`, also in `sample_flag.h`) shares the same
80+
`x86_base_offset` helper for the reserve. All the inlines are
81+
`always_inline`, so this centralisation adds no call overhead.
8182

8283
**Alignment cap**
8384

0 commit comments

Comments
 (0)