Skip to content

Commit aadab54

Browse files
test(profiling): prove native-heap ownership handoff in CI via gotter test-hook
Add a deterministic, cluster-independent test that proves the Phase 2 producer-side ownership handoff in a single process, replacing the flaky staging A/B dedup signal. With the gotter armed and the size partition on, a >512B managed allocation must be owned by exactly one producer: dropped by the in-process _memalloc sampler AND captured by the native gotter (its process-global hook-hit counter advances). A <=512B control stays pool-served and is still sampled in-process. The proof uses the gotter's built-in test-support hook-hit counter (ddtrace_heap_gotter_test_hook_hits), which increments on every intercepted raw malloc/free (not sampling-gated), so no eBPF/Full-Host attach is needed: - heap_gotter activator: add a defensive, test-only test_hook_hits() that binds the counter symbol if present and returns None otherwise (absent cdylib, non-Linux, or a non-test-support build). - setup.py: add env-gated DD_PROFILING_NATIVE_HEAP_TEST_SUPPORT=1 to build the cdylib with --features test-support. Additive, never set for shipped wheels. - The test is @subprocess (install() patches the GOT permanently; the partition flag is process-global) and skips unless a Linux 64-bit test-support gotter build is loaded. It therefore skips in the standard CI wheel (no gotter) and runs in a dedicated test-support build.
1 parent dd40d42 commit aadab54

3 files changed

Lines changed: 202 additions & 0 deletions

File tree

ddtrace/internal/datadog/profiling/heap_gotter/__init__.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,12 @@
5353
# Stays False when the cdylib is absent or was built allocation-only.
5454
_live_heap_available: bool = False
5555

56+
# Whether the loaded cdylib exports the test-only hook-hit counter symbol
57+
# (``ddtrace_heap_gotter_test_hook_hits``). Only true for a ``test-support``
58+
# build (never a shipped wheel). Lets deterministic CI tests prove the patched
59+
# GOT actually ran without a live eBPF attach; see ``test_hook_hits`` below.
60+
_test_hook_available: bool = False
61+
5662
_lib: ctypes.CDLL | None = None # kept alive for process lifetime; never dlclose'd
5763

5864

@@ -95,6 +101,16 @@ def _library_path() -> str:
95101
except AttributeError:
96102
_live_heap_available = False
97103

104+
# Bind the test-only hook-hit counter defensively: it exists ONLY on a
105+
# `test-support` build (never a shipped wheel). A missing symbol simply
106+
# leaves the counter reported as unavailable rather than failing the load.
107+
try:
108+
_lib.ddtrace_heap_gotter_test_hook_hits.argtypes = []
109+
_lib.ddtrace_heap_gotter_test_hook_hits.restype = ctypes.c_uint64
110+
_test_hook_available = True
111+
except AttributeError:
112+
_test_hook_available = False
113+
98114
except Exception as e:
99115
failure_msg = str(e)
100116
_lib = None
@@ -134,3 +150,26 @@ def live_heap_enabled() -> bool:
134150
does not change over the process lifetime.
135151
"""
136152
return _live_heap_available
153+
154+
155+
def test_hook_hits() -> int | None:
156+
"""Test-only: number of times the patched GOT hooks have run in this process.
157+
158+
Returns the process-global ``gotter_malloc``/``gotter_free`` hit counter,
159+
which increments on every intercepted raw (glibc) ``malloc``/``free`` — it is
160+
NOT sampling-gated — so a deterministic single-process test can prove the
161+
native gotter actually captured the raw-domain allocations that the
162+
in-process ``_memalloc`` sampler dropped under the ownership partition,
163+
without needing a live eBPF/Full-Host attach.
164+
165+
Returns ``None`` when the counter is unavailable: on a non-Linux platform,
166+
when the cdylib is absent, or (the common CI case) when the shipped cdylib
167+
was NOT built with the ``test-support`` cargo feature. Tests must treat
168+
``None`` as "skip: no test-support gotter build".
169+
"""
170+
if not is_available or _lib is None or not _test_hook_available:
171+
return None
172+
try:
173+
return int(_lib.ddtrace_heap_gotter_test_hook_hits())
174+
except Exception:
175+
return None

setup.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,19 @@
138138
# staging A/B harness sets this to bake the artifact into its custom wheels;
139139
# runtime install is separately gated by DD_PROFILING_NATIVE_HEAP_ENABLED.
140140
BUILD_NATIVE_HEAP_GOTTER: bool = os.getenv("DD_PROFILING_NATIVE_HEAP_BUILD", "0").lower() in ("1", "yes", "on", "true")
141+
# Opt-in: also compile the gotter cdylib with the `test-support` cargo feature so
142+
# it exports `ddtrace_heap_gotter_test_hook_hits()` (a process-global hook-hit
143+
# counter). This lets a deterministic, cluster-independent CI test prove the
144+
# producer-side ownership handoff (the native gotter actually captures the raw
145+
# glibc-malloc tail the in-process sampler drops) without a live eBPF attach.
146+
# Never set for shipped wheels — it is strictly a test build knob and only has
147+
# any effect when BUILD_NATIVE_HEAP_GOTTER is also on.
148+
BUILD_NATIVE_HEAP_GOTTER_TEST_SUPPORT = os.getenv("DD_PROFILING_NATIVE_HEAP_TEST_SUPPORT", "0").lower() in (
149+
"1",
150+
"yes",
151+
"on",
152+
"true",
153+
)
141154

142155
CURRENT_OS = platform.system()
143156
SERVERLESS_BUILD = os.getenv("DD_SERVERLESS_BUILD", "0").lower() in ("1", "yes", "on", "true")
@@ -1062,6 +1075,14 @@ def build_heap_gotter(self) -> None:
10621075
# `live-heap` is a default cargo feature (see Cargo.toml), so the built
10631076
# cdylib always emits both the `ddheap:alloc` and `ddheap:free` USDTs
10641077
# (verifiable via `readelf -n`) and stamps per-allocation retain flags.
1078+
#
1079+
# Test builds may also opt into `test-support`, which forwards to the
1080+
# upstream crate and exports `ddtrace_heap_gotter_test_hook_hits()` so a
1081+
# deterministic single-process test can prove the patched GOT actually
1082+
# ran. This is additive (default features stay on) and is never enabled
1083+
# for shipped wheels.
1084+
if BUILD_NATIVE_HEAP_GOTTER_TEST_SUPPORT:
1085+
cargo_cmd += ["--features", "test-support"]
10651086
proc: subprocess.CompletedProcess[str] = subprocess.run(
10661087
cargo_cmd, check=True, stdout=subprocess.PIPE, text=True
10671088
)

tests/profiling/test_native_heap_gotter.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,22 @@
1919
import pytest
2020

2121

22+
# Evaluated in the PARENT interpreter (subprocess bodies cannot express a skip:
23+
# an in-body ``pytest.skip`` would surface as a non-zero exit and FAIL the outer
24+
# test). ``test_hook_hits()`` is a read-only counter query with no side effects —
25+
# it does NOT install the gotter — so it is safe to call at import time. It
26+
# returns ``None`` unless the loaded cdylib was built with the ``test-support``
27+
# cargo feature (Linux 64-bit, ``DD_PROFILING_NATIVE_HEAP_TEST_SUPPORT=1``),
28+
# which the standard CI wheel is not, so the end-to-end handoff proof below skips
29+
# everywhere except a dedicated test-support build.
30+
try:
31+
from ddtrace.internal.datadog.profiling import heap_gotter as _heap_gotter
32+
33+
_GOTTER_TEST_HOOK_AVAILABLE: bool = _heap_gotter.test_hook_hits() is not None
34+
except Exception:
35+
_GOTTER_TEST_HOOK_AVAILABLE = False
36+
37+
2238
@pytest.mark.skipif(sys.platform != "linux", reason="native heap gotter is Linux-only")
2339
@pytest.mark.subprocess
2440
def test_native_heap_gotter_smoke() -> None:
@@ -228,3 +244,129 @@ def test_profiler_keeps_managed_heap_when_native_heap_disabled() -> None:
228244
set_partition.assert_called_once_with(False)
229245
finally:
230246
prof.stop(flush=False)
247+
248+
249+
# ---------------------------------------------------------------------------
250+
# End-to-end producer-side ownership handoff (Phase 2 de-dup)
251+
#
252+
# The tests above prove the *wiring* (arming turns the partition on) and
253+
# tests/profiling/collector/test_memalloc.py proves the *in-process* half of the
254+
# partition (> 512B managed allocations are dropped, <= 512B kept, and with the
255+
# partition off everything is sampled). What neither can prove without a live
256+
# eBPF/Full-Host attach is the *other* half of the handoff: that the native
257+
# gotter actually captures the > 512B raw glibc-malloc tail the in-process
258+
# sampler drops — i.e. that exactly one producer owns each allocation.
259+
#
260+
# The test below closes that gap deterministically in a single process using the
261+
# gotter's built-in ``test-support`` hook-hit counter, replacing the flaky
262+
# staging A/B dedup signal with an in-CI assertion. It requires a Linux 64-bit
263+
# ``test-support`` gotter build (see the module-level skip note); it skips in the
264+
# standard CI wheel, which ships no gotter at all.
265+
# ---------------------------------------------------------------------------
266+
267+
268+
@pytest.mark.skipif(
269+
sys.platform != "linux" or not _GOTTER_TEST_HOOK_AVAILABLE,
270+
reason=(
271+
"needs a Linux 64-bit test-support gotter build exposing "
272+
"ddtrace_heap_gotter_test_hook_hits() (build with "
273+
"DD_PROFILING_NATIVE_HEAP_BUILD=1 DD_PROFILING_NATIVE_HEAP_TEST_SUPPORT=1); "
274+
"the standard CI wheel ships no gotter"
275+
),
276+
)
277+
@pytest.mark.subprocess
278+
def test_native_heap_ownership_handoff_end_to_end() -> None:
279+
"""Deterministic, cluster-independent proof of the Phase 2 ownership handoff.
280+
281+
With the gotter armed and the producer-side size partition on, a > 512B
282+
managed OBJ allocation must be owned by *exactly one* producer:
283+
284+
(a) it is NOT sampled by the in-process ``_memalloc`` heap profiler (the
285+
partition drops the > 512B tail), AND
286+
(b) it IS seen by the native gotter — the process-global hook-hit counter
287+
advances by at least one per large allocation, proving the patched GOT
288+
captured the raw glibc ``malloc`` the in-process sampler dropped.
289+
290+
A <= 512B control allocation stays pymalloc-pool-served and is still sampled
291+
in-process, confirming the partition splits by size rather than dropping
292+
everything. Runs in a subprocess because ``install()`` patches the process
293+
GOT permanently and the partition flag is process-global.
294+
"""
295+
import os
296+
import tempfile
297+
298+
from ddtrace.internal.datadog.profiling import ddup
299+
from ddtrace.internal.datadog.profiling import heap_gotter
300+
from ddtrace.profiling.collector import memalloc
301+
from tests.profiling.collector import pprof_utils
302+
from tests.profiling.collector.test_memalloc import _PARTITION_LARGE_ALLOC_COUNT
303+
from tests.profiling.collector.test_memalloc import _allocate_large_buffers
304+
from tests.profiling.collector.test_memalloc import _allocate_small_objects
305+
from tests.profiling.collector.test_memalloc import _count_heap_samples_with_function
306+
307+
# Defensive: the module-level skipif already gated on these, but assert so a
308+
# mis-configured skip can never let this test pass vacuously.
309+
assert heap_gotter.is_available, "test requires the gotter cdylib to be present"
310+
assert heap_gotter.test_hook_hits() is not None, "test requires a test-support gotter build"
311+
312+
# Arm the native producer (permanent + process-global; hence @subprocess).
313+
assert heap_gotter.install() is True
314+
assert heap_gotter.is_installed() is True
315+
316+
prefix = os.path.join(tempfile.mkdtemp(), "handoff")
317+
output_filename = prefix + "." + str(os.getpid())
318+
ddup.config(
319+
service="test_native_heap_ownership_handoff",
320+
version="test",
321+
env="test",
322+
output_filename=prefix,
323+
)
324+
ddup.start()
325+
326+
store: list[object] = []
327+
mc = memalloc.MemoryCollector(heap_sample_size=64 * 1024)
328+
memalloc.set_native_heap_partition(True)
329+
try:
330+
with mc:
331+
# Measure the native counter strictly around the > 512B allocations.
332+
# The counter is process-global and increments on EVERY intercepted
333+
# raw malloc (it is NOT sampling-gated), so background allocations
334+
# can only inflate the delta — never shrink it below the number of
335+
# large buffers we deliberately allocate.
336+
hits_before = heap_gotter.test_hook_hits()
337+
_allocate_large_buffers(store)
338+
hits_after = heap_gotter.test_hook_hits()
339+
340+
_allocate_small_objects(store)
341+
mc.snapshot()
342+
ddup.upload()
343+
344+
profile = pprof_utils.parse_newest_profile(output_filename)
345+
heap_samples = pprof_utils.get_samples_with_value_type(profile, "heap-space")
346+
347+
# (a) In-process producer dropped the > 512B tail ...
348+
large_count = _count_heap_samples_with_function(profile, heap_samples, "_allocate_large_buffers")
349+
assert large_count == 0, (
350+
f"partition ON: > 512B managed allocations must NOT be sampled in-process (got {large_count})"
351+
)
352+
353+
# (b) ... and the native producer captured it. Each > 512B bytes object
354+
# is a single raw malloc routed through the patched GOT, so the hook-hit
355+
# counter must advance by at least the number of large buffers.
356+
assert hits_before is not None and hits_after is not None
357+
delta = hits_after - hits_before
358+
assert delta >= _PARTITION_LARGE_ALLOC_COUNT, (
359+
"native gotter must capture the > 512B raw-malloc tail the in-process sampler dropped "
360+
f"(hook-hit delta {delta} < {_PARTITION_LARGE_ALLOC_COUNT} large allocations)"
361+
)
362+
363+
# Control: <= 512B pool-served allocations are invisible to the gotter
364+
# and must still be sampled in-process — the partition splits by size.
365+
small_count = _count_heap_samples_with_function(profile, heap_samples, "_allocate_small_objects")
366+
assert small_count > 0, "partition ON: <= 512B managed allocations must still be sampled in-process"
367+
finally:
368+
# Reset the process-global flag so it cannot bleed into other tests
369+
# sharing this interpreter (belt-and-braces; the subprocess exits anyway).
370+
memalloc.set_native_heap_partition(False)
371+
372+
del store

0 commit comments

Comments
 (0)