Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
47 commits
Select commit Hold shift + click to select a range
c82fb4e
walkVM attribution addresses (PROF-15955) + one shared DWARF step (PR…
rkennke Sep 29, 2026
86d7f63
Implement reference-chain tracking and the leak-signal engine
jbachorik Sep 17, 2026
6f9cc39
Deallocate GetObjectsWithTags results in hopLabelClassFor
jbachorik Sep 17, 2026
e1a7c77
Fix clang scan-build findings in the tracker and engine
jbachorik Sep 17, 2026
fc619d8
Fix review findings in the tracker and liveness engine
jbachorik Sep 17, 2026
0f29f45
Clear the klass-population scratch in the test-reset seam
jbachorik Sep 18, 2026
e08d40b
Replace uncommitted-plan and line-number references with symbol refs
jbachorik Sep 18, 2026
52c235a
Adapt tracker to merged ReferenceChainHop; drop Jira refs
jbachorik Sep 18, 2026
2686ab7
Make comments layer-local and drop stale plan refs from tests
jbachorik Sep 18, 2026
7b2fc87
Drop forward references to the profiler-side writer
jbachorik Sep 21, 2026
bc74e0f
Restore the merged-hop event API in the reference-chain tests
jbachorik Sep 21, 2026
e77fdf5
Drop design-doc reference from the population table comment
jbachorik Sep 21, 2026
7bec5aa
Drop the last cross-layer references from the test comments
jbachorik Sep 21, 2026
7171646
Fix epoch rollback, class-tag races and weak-ref handling in the live…
jbachorik Sep 23, 2026
c201210
Fix reference-chain pass termination, leak-tag root handling and batc…
jbachorik Sep 23, 2026
573ce31
Wire the reference-chain tracker into the profiler lifecycle
jbachorik Sep 23, 2026
b6c11b8
Split referenceChains into focused translation units with concise com…
jbachorik Sep 23, 2026
af1b04c
Reject out-of-range frontier tags and publish size under the write lock
jbachorik Sep 25, 2026
7376221
Serialize the leak-tag pool and bound per-epoch JNI resolution
jbachorik Sep 25, 2026
c61792c
Fail safe the canary chain walk and retire dead marker-tag plumbing
jbachorik Sep 25, 2026
212f07d
Close the TOCTOU window in the reference-chains debug-knob read
jbachorik Sep 28, 2026
8fbcacd
Chaos-test the leak-tag pool under concurrent epoch rotations
jbachorik Sep 28, 2026
829a903
Make the leak-tag chaos exhaustion pressure scheduling-independent
jbachorik Sep 28, 2026
5aefde3
Chaos-test admission gating against concurrent boost republication
jbachorik Sep 28, 2026
ee22a2b
Extract the reference-chain mock heap and accessors into gtest-free s…
jbachorik Sep 29, 2026
12a0b5e
Fuzz the reference-chain heap walk over synthetic graphs
jbachorik Sep 29, 2026
343cc8e
Fix the callTraceStorage fuzz target for the CountingAllocator CallTr…
jbachorik Sep 29, 2026
585e6d9
Make the mock-heap header include its accessors dependency
jbachorik Sep 29, 2026
170adfd
Drop dead phantom-tag bookkeeping from the heap fuzz target
jbachorik Sep 29, 2026
d903539
Fuzz PainBudget's clock arithmetic
jbachorik Sep 29, 2026
ea1dff3
Track gtest .inc includes as compile-task inputs
jbachorik Sep 29, 2026
68c4ff8
Chaos-test readRcDebugLevelFile against concurrent knob-file swaps
jbachorik Sep 29, 2026
6df9bc2
Fuzz FrontierTable against an exact semantic shadow model
jbachorik Sep 29, 2026
52311d9
Chaos-test tracker lifecycle churn against in-flight passes
jbachorik Sep 29, 2026
26d5498
Chaos-test OOM-urgency hysteresis and canary refill gating
jbachorik Sep 29, 2026
0c75887
Test recording-boundary hygiene across restart
jbachorik Sep 29, 2026
dd423df
Test CANARY_STUCK pass-limit escalation across restarts
jbachorik Sep 29, 2026
b43064a
Fix test-order pollution in the CANARY_STUCK escalation test
jbachorik Sep 29, 2026
ff8ab1f
CI: gate the fuzz targets and soak the chaos suites on branch pushes
jbachorik Sep 29, 2026
079050e
Fix fuzz compile-gate binary lookup depth
jbachorik Sep 29, 2026
39a8c3c
Make the lifecycle-churn abort overlap deterministic
jbachorik Sep 29, 2026
4b4c0fa
Fix resolve budget starvation, TTL units, batch completion and leak-t…
jbachorik Sep 30, 2026
a78ae4a
Add regression tests for review fixes
jbachorik Sep 30, 2026
9521394
Test resolve-budget backlog drain and slow-TSC TTL
jbachorik Sep 30, 2026
ada1ee2
Address review: monotonic clock, ASSERT_ONLY, cgroup publish, frontie…
jbachorik Oct 1, 2026
58d9656
Address review: lock guards, getOrMint, acquire enabled flag, cgroup …
jbachorik Oct 1, 2026
e1ecc02
Fix thread registration, stale chain caches, leak-tag budget and clas…
jbachorik Oct 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions .gitlab/fuzzing/.gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,47 @@ variables:
FUZZ_IMAGE: registry.ddbuild.io/java-profiler-fuzz
FUZZYDOG_VERSION: "0.28.0"

# Compile-gate the libFuzzer targets and soak the chaos suites.
#
# fuzz_infra below only runs on scheduled/manual pipelines and is
# allow_failure, so a fuzz target that stops compiling can survive an entire
# PR unnoticed - PR 797 broke fuzz_callTraceStorage exactly this way, caught
# only by a manual buildFuzz run. This job runs on every branch push like the
# sanitizer jobs: build the fuzz targets (asserting they were actually
# produced - hasFuzzer()'s probe failing would otherwise silently no-op the
# gate), then soak the concurrency-dependent chaos suites under TSan with
# --gtest_repeat/--gtest_shuffle, which is the only way race code gets
# statistical confidence from a single CI run.
fuzz-build-and-soak:
stage: sanitizer
extends: .cache-config
needs: []
timeout: 45m
interruptible: true
# TSan on amd64 requires a non-Kata runner - same constraint as the TSan
# sanitizer job (Kata maps host-guest communication structures into TSan's
# shadow region regardless of LLVM version or sysctl).
tags: [ "docker-in-docker:amd64" ]
image: $BUILD_IMAGE_X64
variables:
GRADLE_USER_HOME: .gradle
rules:
- if: '$JDK_VERSION != null || $DEBUG_LEVEL != null || $HASH != null || $DOWNSTREAM != null'
when: never
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
when: never
- when: on_success
script:
- ./gradlew :ddprof-lib:fuzz:buildFuzz --no-daemon --parallel --build-cache
- |
if ! find ddprof-lib/fuzz/build/bin/fuzz -maxdepth 2 -type f -executable | grep -q .; then
echo "No fuzz binaries produced - the libFuzzer availability probe must have failed; failing the compile gate"
exit 1
fi
- ./gradlew :ddprof-lib:buildGtestTsan --no-daemon --parallel --build-cache
- GTEST_DEATH_TEST_STYLE=threadsafe ./ddprof-lib/build/bin/gtest/tsan_referenceChains_ut/referenceChains_ut --gtest_repeat=50 --gtest_shuffle --gtest_filter='ReferenceChainsLifecycleTest.*:RcDebugLevelTest.ReadFileConcurrentSwapChaos:FrontierTableTest.*:OomUrgencyTest.*:ReferenceChainsCanaryRefillTest.*:ReferenceChainsBfsTest.CanaryStuckLimitDoublesPerRestartAndResetsOnCleanCompletion:PollWatchedTargetsTest.RestartDropsPriorRecordingsChainsAndAbandonedEvents'
- GTEST_DEATH_TEST_STYLE=threadsafe ./ddprof-lib/build/bin/gtest/tsan_livenessTracker_ut/livenessTracker_ut --gtest_repeat=50 --gtest_shuffle --gtest_filter='LeakTagPoolTest.*:AdmissionBoostTest.*'

fuzz_infra:
needs: []
extends: .retry-config
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,11 @@ object PlatformUtils {
val process = ProcessBuilder(compiler, "--version")
.redirectErrorStream(true)
.start()
process.waitFor(5, TimeUnit.SECONDS)
// Generous beyond the obvious: on a loaded macOS host the /usr/bin/clang++ Xcode
// shim can take several seconds to answer --version (Rosetta translation churn,
// oahd-helper at 100% CPU), and a 5s budget made this probe fail intermittently
// from invocations that accepted the same path minutes earlier.
process.waitFor(30, TimeUnit.SECONDS)
process.exitValue() == 0
} catch (e: Exception) {
false
Expand Down
3 changes: 3 additions & 0 deletions ddprof-lib/fuzz/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ fuzzTargets {
additionalIncludes.set(
listOf(
project(":malloc-shim").file("src/main/public").absolutePath,
// referenceChainsTestAccessors.h / referenceChainsMockHeap.h - the shared
// gtest-free harness the fuzz_referenceChainHeap target drives.
project(":ddprof-lib").file("src/test/cpp").absolutePath,
),
)
}
6 changes: 6 additions & 0 deletions ddprof-lib/src/main/cpp/arch.h
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ static inline long long atomicIncRelaxed(volatile T &var,
return __atomic_fetch_add(&var, increment, __ATOMIC_RELAXED);
}

// Atomically replaces var with value and returns the previous value (relaxed).
template <typename T>
static inline T atomicExchangeRelaxed(volatile T &var, T value) {
return __atomic_exchange_n(&var, value, __ATOMIC_RELAXED);
}

// Atomic load/store (unordered)
template <typename T>
static inline T load(volatile T& var) {
Expand Down
2 changes: 1 addition & 1 deletion ddprof-lib/src/main/cpp/callTraceHashTable.h
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ class CallTraceHashTable {
// - ACQUIRE loads in collect(), put(), and putWithExistingId()
// Required for correct visibility on weakly-ordered architectures (aarch64).
LongHashTable* _table;

volatile u64 _overflow;

u64 calcHash(int num_frames, ASGCT_CallFrame *frames, bool truncated);
Expand Down
103 changes: 103 additions & 0 deletions ddprof-lib/src/main/cpp/classTagAllocator.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/*
* Copyright 2026, Datadog, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

#ifndef _CLASS_TAG_ALLOCATOR_H
#define _CLASS_TAG_ALLOCATOR_H

#include "arch.h"
#include "mutex.h"
#include <jvmti.h>

// Process-wide, negative JVMTI class-object tag allocator, shared by
// ReferenceChainTracker (which tags every loaded class's own jclass object
// via SetTag - see resolveLoadedClasses(), referenceChains.cpp) and
Comment thread
jbachorik marked this conversation as resolved.
// LivenessTracker (which needs a stable per-class identifier independent of
// Profiler::classMap()'s dictionary id - see KlassPopulationEntry::
// stable_class_tag's own comment, livenessTracker.h, for why: that
// dictionary can be compacted/regenerated, silently reassigning the same
// class a different id at different points in the process's life, breaking
// any attempt to correlate a klass_id LivenessTracker reports as growing
// against ReferenceChainTracker::FrontierEntry::referrer_klass values
// recorded at a different time).
//
// A single shared counter, not one independently owned by each subsystem,
// for two reasons, both load-bearing:
// 1. Two independent counters could otherwise hand out the SAME numeric
// value to TWO DIFFERENT classes (one minted by each subsystem for a
// class the other has not seen yet), making any cross-subsystem
// comparison meaningless.
// 2. Class tags must stay strictly NEGATIVE:
// ReferenceChainTracker::heapReferenceCallback() (referenceChains.cpp)
// uses `*tag_ptr < 0` to distinguish "this heap-walk-visited object is a
// pre-tagged class object" from an ordinary admitted instance (always
// tagged with a positive value via nextTag()). A class tagged by a
// counter that does not preserve this sign convention would be
// misidentified as an ordinary object and incorrectly admitted into the
// frontier table - a real correctness bug, not just a matching
// inconvenience.
//
// Deliberately a plain header-only function (Meyer's-singleton pattern,
// exactly like LivenessTracker::instance()/ReferenceChainTracker::
// instance()'s own lazy-static singletons) rather than a member of either
// singleton class: ReferenceChainTracker already depends on LivenessTracker
// (referenceChains.cpp includes livenessTracker.h and calls into it), so
// putting this counter inside either one and having the other call into it
// would introduce a circular dependency between the two headers.
namespace ClassTagAllocator {

inline volatile jlong &magnitude() {
static volatile jlong m = 1;
return m;
}

// Hands out a fresh negative class tag - see this file's own header comment
// for why negative, and why this must be the only place in the process that
// mints one.
inline jlong next() { return -atomicIncRelaxed(magnitude(), (jlong)1); }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want to use __atomic_xxx consistently, see line #71

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have rather added a new helper function. We started using the helper functions then went back to __atomic, then again helper functions.
We will have to decide :) ATM, all the other code in this PR is using the helper functions so I decided to stay consistent here.


// Returns the class tag klass carries, first installing a fresh next() tag if
// it carries none. Returns 0 if GetTag or SetTag fails, or if klass carries a
// positive tag: that is never a class tag (heapReferenceCallback() admits a
// not-yet-class-tagged class object reached over a non-CLASS edge as an
// ordinary frontier object), and adopting it would break the sign convention
// above. JVMTI has no compare-and-set for tags, so two unserialized GetTag == 0
// -> SetTag sequences on one class would both install a tag and the caller
// whose SetTag landed first would keep a tag the class no longer carries. Every
// class-tag install goes through here, so this leaf mutex (no other lock is
// taken while it is held) closes that window. A blocking mutex rather than a
// SpinLock: it is held across JVMTI calls, and callers include application
// threads.
inline jlong getOrMint(jvmtiEnv *jvmti, jclass klass) {
static Mutex mint_lock;
MutexLocker locker(mint_lock);
jlong tag = 0;
if (jvmti->GetTag(klass, &tag) != JVMTI_ERROR_NONE) {
return 0;
}
if (tag != 0) {
return tag < 0 ? tag : 0;
}
tag = next();
return jvmti->SetTag(klass, tag) == JVMTI_ERROR_NONE ? tag : 0;
}

// Test-only: resets the shared counter back to its starting value. Without
// this, gtest cases that assert on exact tag values (e.g. "the first class
// tagged gets -1") would see values keep climbing across every TEST_F in the
// same gtest binary, since this counter is genuinely process-wide (shared
// with LivenessTracker) rather than per-ReferenceChainTracker-instance.
inline void resetForTest() {
// Atomic exchange, matching next()'s atomicIncRelaxed RMW on the same
// variable: a plain volatile store can tear or be lost against a concurrent
// RMW (e.g. a tracker thread from a prior TEST_F not fully quiesced), which
// would mint duplicate negative tags - the cross-subsystem collision this
// shared allocator exists to prevent. Callers must still ensure no tracker
// thread is live (reset in TearDown after tracker->stop()).
atomicExchangeRelaxed(magnitude(), (jlong)1);
}

} // namespace ClassTagAllocator

#endif
9 changes: 9 additions & 0 deletions ddprof-lib/src/main/cpp/common.h
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ constexpr size_t KNUTH_MULTIPLICATIVE_CONSTANT = 0x9e3779b97f4a7c15ULL;
#define TEST_LOG(fmt, ...) // No-op in non-debug mode
#endif

// Same gate as assert(): use for state that only assert() reads. Keying it on
// DEBUG instead would break builds that keep asserts but do not define DEBUG
// (the gtest release build strips -DNDEBUG without adding -DDEBUG).
#ifndef NDEBUG
#define ASSERT_ONLY(s) s
#else
#define ASSERT_ONLY(s)
#endif

#ifdef __FAULT_INJECTION__
#define FAULT_INJECTION_ONLY(s) s
#else
Expand Down
39 changes: 39 additions & 0 deletions ddprof-lib/src/main/cpp/counters.h
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,45 @@
* signal for spotting a recurrence. */ \
X(METADATA_TREE_NULL_CHILD, "metadata_tree_null_child") \
X(METADATA_TREE_DEPTH_EXCEEDED, "metadata_tree_depth_exceeded") \
/* A resolved datadog.ReferenceChain could not be cached in \
* ReferenceChainTracker::_resolved_chains (referenceChains.h): a brand-new \
* leak-candidate klass arrived with the cache already at \
* MAX_RESOLVED_CHAINS, so its chain is dropped rather than evicting some \
* other still-live sample's chain. See that constant's own comment. */ \
X(REFERENCE_CHAIN_EVENTS_DROPPED, "reference_chain_events_dropped") \
/* ReferenceChainTracker::releaseSearchTags() (referenceChains.cpp) failed \
* to call GetObjectsWithTags() for at least one batch - the search's tag \
* release is retried on a later call rather than proceeding, but this \
* counts how often that retry path is taken. */ \
X(REFERENCE_CHAIN_TAG_RELEASE_FAILED, "reference_chain_tag_release_failed") \
/* The profiler-side reference-chain writer could not acquire a \
* sample-record lock within its bounded retry budget and dropped the \
* already-dequeued datadog.ReferenceChain event for this dump - not \
* permanently lost, since ReferenceChainTracker::_resolved_chains (see \
* REFERENCE_CHAIN_EVENTS_DROPPED above) keeps the resolved chain cached \
* and re-emits it on a later dump while the leak candidate is still \
* live. */ \
X(REFERENCE_CHAIN_WRITE_DROPPED, "reference_chain_write_dropped") \
/* FrontierTable's own calloc/realloc-backed storage (referenceChains.cpp) - \
* outside NMT's visibility since it bypasses os::malloc, so this is the only \
* way to attribute its native RSS contribution. */ \
X(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, "reference_chain_frontier_table_bytes") \
X(REFERENCE_CHAIN_FRONTIER_TABLE_CAPACITY, "reference_chain_frontier_table_capacity") \
X(REFERENCE_CHAIN_CANDIDATE_COUNT, "reference_chain_candidate_count") \
X(REFERENCE_CHAIN_CANDIDATES_FOUND, "reference_chain_candidates_found") \
/* admitStaticFieldRoots() per-class non-static quota: non-STATIC_FIELD \
* edges (CONSTANT_POOL, INTERFACE, SUPERCLASS, CLASS_LOADER, ...) that \
* were dropped because the class already hit \
* STATIC_FIELD_SWEEP_NON_STATIC_CAP_PER_CLASS. Total drops across all \
* classes/laps — compare against kind_counts (k9 total) to gauge how \
* much CP pressure the quota is absorbing. */ \
X(REFERENCE_CHAIN_STATIC_SWEEP_NON_STATIC_DROPPED, "reference_chain_static_sweep_non_static_dropped") \
/* Incremented once per class that hit the non-static cap at least once \
* in a lap (on the first drop for that class). Distinguishes "a few fat \
* outlier classes dropping many edges" from "systematic drops across \
* almost all classes" — if this tracks the total class count per lap, \
* the cap is too low; if it stays near zero, the cap is fine. */ \
X(REFERENCE_CHAIN_STATIC_SWEEP_CLASSES_CAPPED, "reference_chain_static_sweep_classes_capped") \
DD_COUNTER_TABLE_FAULT_INJECTION(X) \
DD_COUNTER_TABLE_FI_DEBUG(X) \
DD_COUNTER_TABLE_SAMPLER_PERF(X) \
Expand Down
9 changes: 5 additions & 4 deletions ddprof-lib/src/main/cpp/flightRecorder.h
Original file line number Diff line number Diff line change
Expand Up @@ -593,20 +593,21 @@ class FlightRecorder {

// Mirrors recordHeapUsage()'s shape exactly - ReferenceChainAbandonedEvent
// is not stack-sample-shaped (no tid/call_trace_id), same as HeapUsage.
// Called from Profiler::writeReferenceChainAbandoned() (profiler.cpp),
// Called from the profiler's dump-time abandoned-event drain,
// wired from Profiler::dump() the same way LivenessTracker::flush() is.
void recordReferenceChainAbandoned(int lock_index,
ReferenceChainAbandonedEvent *event);

// Mirrors recordReferenceChainAbandoned() above exactly, for
// ReferenceChainEvent instead. Called from Profiler::writeReferenceChain()
// (profiler.cpp), itself called from Profiler::dump()'s drain loop over
// ReferenceChainEvent instead. Called from the profiler's dump()-time
// writer, itself called from Profiler::dump()'s drain loop over
// the engine's resolved-chain cache snapshot: the BFS
// scheduling thread only caches resolved chains and each dump re-emits
// the cache, so chain events
// are written on dump()'s own thread, not from the tracker thread, and
// unlike recordReferenceChainAbandoned() (unbounded retry budget per
// event) the batch shares one deadline (writeReferenceChain()'s comment).
// event) the batch shares one deadline (see the writer's contract in
// Profiler - the drain batch, not each event, owns the retry budget).
void recordReferenceChain(int lock_index, ReferenceChainEvent *event);
};

Expand Down
Loading
Loading