diff --git a/libdd-profiling-heap-gotter-ffi/Cargo.toml b/libdd-profiling-heap-gotter-ffi/Cargo.toml index 062a64c20b..3b594d97e5 100644 --- a/libdd-profiling-heap-gotter-ffi/Cargo.toml +++ b/libdd-profiling-heap-gotter-ffi/Cargo.toml @@ -18,6 +18,8 @@ default = ["cbindgen"] cbindgen = ["build_common/cbindgen", "libdd-common-ffi/cbindgen"] # See libdd-profiling-heap-gotter's test-support feature. Not for production use. test-support = ["libdd-profiling-heap-gotter/test-support"] +# Forward live-heap (retained-heap) tracking down to the sampler +live-heap = ["libdd-profiling-heap-gotter/live-heap"] [build-dependencies] build_common = { path = "../build-common" } diff --git a/libdd-profiling-heap-gotter-ffi/tests/usdt_notes.rs b/libdd-profiling-heap-gotter-ffi/tests/usdt_notes.rs index 33213c2eb4..219a0d9d9d 100644 --- a/libdd-profiling-heap-gotter-ffi/tests/usdt_notes.rs +++ b/libdd-profiling-heap-gotter-ffi/tests/usdt_notes.rs @@ -7,6 +7,13 @@ //! attached consumer fire twice, so this guards the "one note per probe" //! invariant documented in the sampler's `probes.c`. //! +//! Split into two tests because the two probes have different lifetimes: +//! `ddheap:alloc` is expected in every build, while `ddheap:free` only +//! exists when compiled with live-heap tracking (the `live-heap` feature) — +//! its absence otherwise is intentional, so it's covered by its own +//! feature-gated test rather than folded into a single "all probes present" +//! assertion. +//! //! Inspecting the linked cdylib (rather than running the check in-process) //! validates the real shipped artifact. Mirrors //! libdd-otel-thread-ctx-ffi/tests/elf_properties.rs. @@ -25,9 +32,22 @@ fn cdylib_path() -> PathBuf { .join("liblibdd_profiling_heap_gotter_ffi.so") } +/// `ddheap:alloc` is unconditional: every build samples allocations, so its +/// note must always be present exactly once. +#[test] +#[cfg_attr(miri, ignore)] +fn ddheap_alloc_probe_has_one_note() { + let path = cdylib_path(); + libdd_profiling_heap_sampler::usdt_check::check_usdt_probes_in(&path, &["alloc"]).unwrap(); +} + +/// `ddheap:free` only exists when built with live-heap tracking. Gated on the +/// `live-heap` feature (forwarded to the sampler crate) so this test only +/// runs, and only asserts the note is present, in builds that opt in. +#[cfg(feature = "live-heap")] #[test] #[cfg_attr(miri, ignore)] -fn ddheap_probes_have_one_note_each() { +fn ddheap_free_probe_has_one_note() { let path = cdylib_path(); - libdd_profiling_heap_sampler::usdt_check::check_usdt_probes_in(&path).unwrap(); + libdd_profiling_heap_sampler::usdt_check::check_usdt_probes_in(&path, &["free"]).unwrap(); } diff --git a/libdd-profiling-heap-sampler/include/datadog/heap/probes.h b/libdd-profiling-heap-sampler/include/datadog/heap/probes.h index a67066ecc0..28913194bc 100644 --- a/libdd-profiling-heap-sampler/include/datadog/heap/probes.h +++ b/libdd-profiling-heap-sampler/include/datadog/heap/probes.h @@ -34,6 +34,11 @@ void dd_probe_alloc(void *user, uint64_t size, uint64_t weight); /* * Emits the `ddheap:free` USDT. * ptr - user-visible pointer being freed + * + * The symbol always exists, but the USDT is only emitted when compiled + * with live-heap tracking. The absence of the `ddheap:free` note in + * .note.stapsdt signals to external profilers that this binary does not + * support live-heap correlation. */ void dd_probe_free(void *ptr); diff --git a/libdd-profiling-heap-sampler/src/allocation_freed.c b/libdd-profiling-heap-sampler/src/allocation_freed.c index b8f6b89f0f..33355da31b 100644 --- a/libdd-profiling-heap-sampler/src/allocation_freed.c +++ b/libdd-profiling-heap-sampler/src/allocation_freed.c @@ -12,7 +12,8 @@ * dd_sample_flag_check_and_clear confirmed that ptr carries the sample flag, * meaning this allocation was previously sampled. * - * Fires the ddheap:free USDT with the user-visible pointer, then returns + * Fires the ddheap:free USDT (when live-heap tracking is enabled) with the + * user-visible pointer, then returns * the raw pointer and adjusted size that the caller must forward to the * real deallocator. On x86-64 the size grows by DD_HEADER_BYTES to cover * the header that was reserved at allocation time; on arm64 the size is @@ -21,7 +22,9 @@ dd_alloc_freed_t dd_allocation_freed_slow(void *ptr, void *raw, size_t size, size_t alignment) { /* Fire with the user-visible pointer, matching what was reported at alloc - * time, so the profiler can correlate the two events by address. */ + * time, so the profiler can correlate the two events by address. Safe to + * call unconditionally: dd_probe_free is a no-op USDT-wise when live-heap + * tracking is off. */ dd_probe_free(ptr); dd_alloc_freed_t out = { diff --git a/libdd-profiling-heap-sampler/src/lib.rs b/libdd-profiling-heap-sampler/src/lib.rs index fd21af1447..7015e3bd56 100644 --- a/libdd-profiling-heap-sampler/src/lib.rs +++ b/libdd-profiling-heap-sampler/src/lib.rs @@ -216,6 +216,38 @@ mod tests { } } + // With live-heap tracking compiled out, dd_allocation_freed is a + // straight passthrough: it never inspects the sample-flag header and + // never fires ddheap:free. Verify this even when the underlying + // memory happens to contain the magic pattern that *would* trigger + // the slow path if live-heap were enabled. + #[cfg(not(feature = "live-heap"))] + #[test] + fn freed_is_passthrough_when_live_heap_disabled() { + const MAGIC: u64 = 0xfab1eddec0dedca7; + const HEADER_BYTES: usize = 16; + + // Set up a buffer with the magic header so that, if the flag-check + // were compiled in, it would consider this allocation sampled. + let mut buf = vec![0u8; 128]; + let base = buf.as_mut_ptr() as usize; + let user_addr = base + HEADER_BYTES; + + // Stamp magic at ptr - HEADER_BYTES + buf[0..8].copy_from_slice(&MAGIC.to_ne_bytes()); + // Stamp a plausible offset + buf[8..16].copy_from_slice(&(HEADER_BYTES as u64).to_ne_bytes()); + + let ptr = user_addr as *mut c_void; + unsafe { + let freed = dd_allocation_freed(ptr, 64, 8); + // Without live-heap the pointer is returned unchanged (no + // flag-check, no raw-pointer recovery). + assert_eq!(freed.ptr, ptr); + assert_eq!(freed.size, 64); + } + } + #[test] fn zero_sampling_interval_disables_sampling() { let mut tl = dd_tl_state_t { diff --git a/libdd-profiling-heap-sampler/src/probes.c b/libdd-profiling-heap-sampler/src/probes.c index b4bc5612a6..b82930fe4b 100644 --- a/libdd-profiling-heap-sampler/src/probes.c +++ b/libdd-profiling-heap-sampler/src/probes.c @@ -18,6 +18,7 @@ */ #include +#include #include @@ -29,7 +30,11 @@ void dd_probe_alloc(void *user, uint64_t size, uint64_t weight) { } void dd_probe_free(void *ptr) { +#if DD_HEAP_LIVE_TRACKING int saved_errno = errno; USDT(ddheap, free, ptr); errno = saved_errno; +#else + (void)ptr; +#endif } diff --git a/libdd-profiling-heap-sampler/src/usdt_check.rs b/libdd-profiling-heap-sampler/src/usdt_check.rs index fbf35ad9da..d7da14109c 100644 --- a/libdd-profiling-heap-sampler/src/usdt_check.rs +++ b/libdd-profiling-heap-sampler/src/usdt_check.rs @@ -10,6 +10,13 @@ //! note; a regression (e.g. `static inline` + bindgen's `wrap_static_fns`, or //! LTO inlining across TUs) could duplicate the entry. This check catches that. //! +//! `ddheap:alloc` is expected in every build. `ddheap:free` is only expected +//! when compiled with live-heap tracking (the `live-heap` feature): its +//! absence is how external profilers detect that a binary doesn't support +//! live-heap correlation (see `probes.h`), so callers must pass the probe +//! list that matches the build under test rather than assume both are always +//! present. +//! //! Call [`sanity_check`] from within a shared object or statically linked //! executable, or point [`check_usdt_probes_in`] at a built artifact. //! @@ -23,23 +30,28 @@ use std::path::{Path, PathBuf}; /// USDT provider name emitted by `probes.c` (`USDT(ddheap, ...)`). const PROVIDER: &str = "ddheap"; -/// Probes we expect, each exactly once. -const EXPECTED_PROBES: &[&str] = &["alloc", "free"]; /// As [`sanity_check`], but takes the object file as an argument. Useful for a -/// test setting where the test code is separate from the artifact to validate. -pub fn check_usdt_probes_in(path: &Path) -> anyhow::Result<()> { +/// test setting where the test code is separate from the artifact to +/// validate. `expected_probes` lists the probe names (without the `ddheap:` +/// provider prefix) that must each appear exactly once. +pub fn check_usdt_probes_in(path: &Path, expected_probes: &[&str]) -> anyhow::Result<()> { let data = std::fs::read(path).with_context(|| format!("failed to read {}", path.display()))?; let elf = ElfBytes::::minimal_parse(&data) .with_context(|| format!("failed to parse ELF at {}", path.display()))?; - check_one_note_per_probe(&elf)?; + check_one_note_per_probe(&elf, expected_probes)?; Ok(()) } /// Check that the current running module carries exactly one `.note.stapsdt` -/// entry per ddheap probe. +/// entry per ddheap probe expected in this build (`alloc` always, `free` +/// only when the `live-heap` feature is enabled). pub fn sanity_check() -> anyhow::Result<()> { - check_usdt_probes_in(&own_elf_path()?) + let mut expected = vec!["alloc"]; + if cfg!(feature = "live-heap") { + expected.push("free"); + } + check_usdt_probes_in(&own_elf_path()?, &expected) } /// Locate the current running module (shared or not) via `/proc/self/maps`. @@ -79,7 +91,10 @@ fn own_elf_path() -> anyhow::Result { } /// Parse `.note.stapsdt` and assert each expected probe appears exactly once. -fn check_one_note_per_probe(elf: &ElfBytes<'_, AnyEndian>) -> anyhow::Result<()> { +fn check_one_note_per_probe( + elf: &ElfBytes<'_, AnyEndian>, + expected_probes: &[&str], +) -> anyhow::Result<()> { let shdr = elf .section_header_by_name(".note.stapsdt") .context("failed to read section headers")? @@ -118,7 +133,7 @@ fn check_one_note_per_probe(elf: &ElfBytes<'_, AnyEndian>) -> anyhow::Result<()> } } - for &probe in EXPECTED_PROBES { + for &probe in expected_probes { match counts.get(probe).copied().unwrap_or(0) { 1 => {} 0 => bail!("USDT probe '{PROVIDER}:{probe}' has no .note.stapsdt entry"),