From 7ccf7177833f5f94077cafc350e767614a361e11 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 12:20:30 +0100 Subject: [PATCH 01/20] Fix reference-chain collector running without a leak signal The collector ran whole-heap searches in healthy processes through two authorization paths that bypassed the candidate requirement: - hasLeakSignal() returned true unconditionally when generations tracking was off, so a liveheap-only session passed the gate with zero leak candidates; the search cannot exit early (the no-progress abandonment is suppressed while urgent) and ran to the auto-tuned TTL. - The urgent-OOM latch entitled one candidate-less search per episode, and a warmup heap-floor ramp projected imminent OOM from a seconds-long observation window. hasLeakSignal() now keys on an effective flag (generations OR liveheap tracking) and requires selectLeakCandidates() > 0; urgency only sharpens the OOM ramp while a candidate-driven search runs. secondsToOOM() returns no projection until the leak signal has been active for a burn-in period, so transient warmup growth cannot latch urgency. The per-search urgent-entitlement flag was removed (behaviorally dead). --- ddprof-lib/src/main/cpp/livenessTracker.cpp | 73 +++-- ddprof-lib/src/main/cpp/livenessTracker.h | 102 +++++-- .../src/main/cpp/referenceChainEvents.cpp | 8 +- ddprof-lib/src/main/cpp/referenceChains.cpp | 76 ++--- ddprof-lib/src/main/cpp/referenceChains.h | 11 +- .../src/test/cpp/livenessTracker_ut.cpp | 274 ++++++++++++++++++ .../src/test/cpp/referenceChainsBfsTests.inc | 43 ++- .../cpp/referenceChainsLifecycleTests.inc | 44 ++- .../src/test/cpp/referenceChainsOomTests.inc | 120 +++++++- .../test/cpp/referenceChainsTestAccessors.h | 6 +- .../test/cpp/referenceChainsTrackerTests.inc | 119 ++++++-- .../src/test/cpp/referenceChains_ut.cpp | 7 + doc/ReferenceChainsCollectionSummary.md | 8 +- .../LiveHeapReferenceChainsImplementation.md | 9 +- .../ReferenceChainsSignalsExplained.md | 21 +- 15 files changed, 770 insertions(+), 151 deletions(-) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index 538854d9f6..1b12ee7bb7 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -162,9 +162,10 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve, bool account_epoch) { u64 current = load(_last_gc_epoch); u64 target_gc_epoch = load(_gc_epoch); - TEST_LOG_SUMMARY("LivenessTracker::cleanup_table forced=%d gc_generations=%d current_epoch=%llu " + TEST_LOG_SUMMARY("LivenessTracker::cleanup_table forced=%d leak_signal=%d gc_generations=%d current_epoch=%llu " "target_epoch=%llu table_size=%d", - forced, _gc_generations.load(std::memory_order_relaxed), (unsigned long long)current, + forced, _leak_signal_enabled.load(std::memory_order_relaxed), + _gc_generations.load(std::memory_order_relaxed), (unsigned long long)current, (unsigned long long)target_gc_epoch, _table_size); // is_epoch_owner is true iff this call is the one that moves _last_gc_epoch @@ -275,12 +276,14 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve, } } - if (_gc_generations.load(std::memory_order_relaxed) && is_epoch_owner) { + if (_leak_signal_enabled.load(std::memory_order_relaxed) && is_epoch_owner) { // Per-klass population tracking (design doc's Open Question 3) - - // gated on _gc_generations so this new cost is paid only when the - // caller actually asked for generation/survival-shaped data - // (Arguments::_gc_generations), not for every liveness-tracking - // session. Gated on is_epoch_owner (not !forced) so a forced + // gated on the effective leak-signal availability: generations + // tracking and liveheap tracking drive the same mechanisms, so the + // cost is paid whenever either option is on. With both off there is + // no consumer for the data (the reference-chain collector stays + // idle, hasLeakSignal() -> false), and paying it would be pure + // overhead. Gated on is_epoch_owner (not !forced) so a forced // (table-overflow) sweep still contributes one population sample // per genuinely new GC epoch instead of silently dropping it. // Reuse an already-resolved id first so the per-sweep budget is @@ -359,7 +362,7 @@ void LivenessTracker::cleanup_table(bool forced, bool allow_resolve, 1.0f * (end - start) / 1000 / 1000, 1.0f * (end - start) / 1000 / sz); } - if (_gc_generations.load(std::memory_order_relaxed) && is_epoch_owner) { + if (_leak_signal_enabled.load(std::memory_order_relaxed) && is_epoch_owner) { // Outside the `sz > 0` block: a sweep that finds the table already empty // still owns this epoch. foldKlassCountsLocked() records zero population // samples for klasses whose every tracked instance died (they never @@ -1571,9 +1574,20 @@ double LivenessTracker::secondsToOOM() const { jlong max_heap = _max_heap_bytes; jlong container_limit = _container_memory_limit; #endif - if (!_gc_generations.load(std::memory_order_relaxed) || max_heap <= 0) { - TEST_LOG("LivenessTracker::secondsToOOM -> -1 (gc_generations=%d max_heap=%lld)", - (int)_gc_generations.load(std::memory_order_relaxed), (long long)max_heap); + if (!_leak_signal_enabled.load(std::memory_order_relaxed) || max_heap <= 0) { + TEST_LOG("LivenessTracker::secondsToOOM -> -1 (leak_signal=%d max_heap=%lld)", + (int)_leak_signal_enabled.load(std::memory_order_relaxed), (long long)max_heap); + return -1; + } + // Age-based burn-in (see OOM_PROJECTION_BURN_IN_NS): no projection until the + // leak signal has been active for the full burn-in, independent of how much + // wall-clock time the heap-floor ring happens to span. The subtraction is + // modular; production stamps are always <= now. + u64 activated_ns = _leak_signal_activated_ns.load(std::memory_order_acquire); + u64 since_ns = OS::nanotime() - activated_ns; + if (activated_ns == 0 || since_ns < OOM_PROJECTION_BURN_IN_NS) { + TEST_LOG("LivenessTracker::secondsToOOM -> -1 (BURN_IN activated_ns=%llu since_ns=%llu)", + (unsigned long long)activated_ns, (unsigned long long)since_ns); return -1; } @@ -1936,7 +1950,7 @@ void LivenessTracker::flush_table(std::set *tracked_thread_ids) { int class_id = 0; if (_table[i].cached_klass_id != 0 && class_map_generation_matches) { // Already resolved by cleanup_table()'s survivor loop this epoch - // (resolveKlassId(), only when _gc_generations is enabled) - reuse + // (resolveKlassId(), only when leak-signal tracking is enabled) - reuse // it instead of repeating the resolution JNI round-trip for the same // object. class_id = _table[i].cached_klass_id; @@ -2021,6 +2035,7 @@ Error LivenessTracker::initialize_table(JNIEnv *jni, int sampling_interval) { void LivenessTracker::resetSession() { _enabled = false; _gc_generations.store(false, std::memory_order_relaxed); + _leak_signal_enabled.store(false, std::memory_order_relaxed); } Error LivenessTracker::start(Arguments &args) { @@ -2095,13 +2110,19 @@ void LivenessTracker::stop() { Error LivenessTracker::initialize(Arguments &args) { _enabled = args._gc_generations || args._record_liveness; - // Gates per-klass population tracking (see the _gc_generations member's - // own comment in livenessTracker.h). Updated unconditionally alongside - // _record_heap_usage below, ahead of the _initialized guard, for the same - // reason: each profiler start should observe the flag it was actually - // started with, even though the tracking table itself persists across - // recordings. + // Gates per-klass population tracking (see the _gc_generations and + // _leak_signal_enabled members' own comments in livenessTracker.h). Updated + // unconditionally alongside _record_heap_usage below, ahead of the + // _initialized guard, for the same reason: each profiler start should + // observe the flag it was actually started with, even though the tracking + // table itself persists across recordings. _gc_generations.store(args._gc_generations, std::memory_order_relaxed); + // Either generations tracking or liveheap tracking is a sufficient source + // for the population-trend signal (they drive the same mechanisms here; + // liveheap additionally preserves stack traces for its samples) - see + // _leak_signal_enabled's own comment (livenessTracker.h). + _leak_signal_enabled.store(args._gc_generations || args._record_liveness, + std::memory_order_relaxed); if (!_enabled) { return Error::OK; @@ -2121,6 +2142,13 @@ Error LivenessTracker::initialize(Arguments &args) { __atomic_store_n(&_watched_tid_count, 0, __ATOMIC_RELEASE); __atomic_store_n(&_urgent_tracking, false, __ATOMIC_RELEASE); + // First activation stamps the burn-in clock (see _leak_signal_activated_ns). + // Only reached with _enabled == true, i.e. generations || liveness, so the + // leak signal is on. CAS from 0: later starts never re-stamp it. + u64 never_activated = 0; + _leak_signal_activated_ns.compare_exchange_strong( + never_activated, OS::nanotime(), std::memory_order_release, std::memory_order_relaxed); + if (_initialized) { // if the tracker was previously initialized return the stored result for // consistency this hack also means that if the profiler is started with @@ -2479,7 +2507,7 @@ void LivenessTracker::track(JNIEnv *env, AllocEvent &event, jint tid, } void LivenessTracker::maybeForceCleanup(u64 now_ns) { - if (!_enabled || !_gc_generations.load(std::memory_order_relaxed)) { + if (!_enabled || !_leak_signal_enabled.load(std::memory_order_relaxed)) { return; } constexpr u64 FORCE_CLEANUP_INTERVAL_NS = 30ULL * 1000 * 1000 * 1000; @@ -2535,10 +2563,11 @@ void LivenessTracker::onGC() { store(_used_after_last_gc, HeapUsage::get(false)._used); } - if (_gc_generations.load(std::memory_order_relaxed)) { + if (_leak_signal_enabled.load(std::memory_order_relaxed)) { // Feeds heapFloorRising()'s corroboration check (selectLeakCandidates()) - // - gated on _gc_generations, same as the per-klass population table - // itself, since this ring exists purely to support that feature. + // - gated on the effective leak-signal availability, same as the + // per-klass population table itself, since this ring exists purely to + // support that feature. // recordHeapFloorSample() itself checks _heap_floor_recording_disabled_for_test // (debug-only) so a test can seed the ring exclusively. size_t used = resolvePostGcHeapUsage(nullptr); diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 20afe41418..6185348355 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -38,7 +38,7 @@ typedef struct TrackingEntry { jlong leak_tag; // 0 = untagged; otherwise a tag from the leak tag pool Context ctx; // Set by cleanup_table()'s survivor loop via resolveKlassId() when - // _gc_generations is enabled (0 otherwise, or if resolution failed - 0 is + // leak-signal tracking is enabled (0 otherwise, or if resolution failed - 0 is // StringDictionary's own "no entry" sentinel, so a real id is never 0). // flush_table() reuses this instead of re-resolving the same object's // class via a second resolveKlassId() JNI round-trip - an object's class @@ -464,6 +464,30 @@ class alignas(alignof(SpinLock)) LivenessTracker { // still be reading it from a session that persists across a restart. std::atomic _gc_generations; + // Effective leak-signal availability: true when the population-trend signal + // consumed by ReferenceChainTracker::hasLeakSignal() can be constructed at + // all. generations tracking and liveheap tracking drive the same + // mechanisms here (per-klass population table, heap-floor ring, + // secondsToOOM() projection) - liveheap additionally preserves stack + // traces for its live samples - so either option is sufficient. When + // neither is on, there is no signal source and the reference-chain + // collector must stay idle (hasLeakSignal() -> false): running whole-heap + // searches with zero candidates "just in case" is the exact waste the + // leak-signal gate exists to prevent (observed as a frontier table grown + // to its cap with reference_chain_candidate_count == 0 in a healthy + // process when this used to fall back to "always true"). Set from + // args._gc_generations || args._record_liveness in initialize(). Same + // atomicity/reader argument as _gc_generations above. + std::atomic _leak_signal_enabled; + + // OS::nanotime() of the first initialize() that enabled the leak signal; 0 + // means never activated. Set once by a CAS from 0 and never reset by + // production code (not by stop()/resetSession(), not by the ring resets), so + // it is per process, not per recording. Drives secondsToOOM()'s age-based + // burn-in (see OOM_PROJECTION_BURN_IN_NS). Written with release, read with + // acquire. + std::atomic _leak_signal_activated_ns; + // Per-klass population history table (see KlassPopulationEntry above). // Populated only from cleanup_table()'s GC-epoch-advance pass, never from // track() (the allocation sampling hot path) - see @@ -614,7 +638,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // force=true is used by track()'s table-overflow branch to run a cleanup // synchronously from the allocation-sampling call stack, bypassing the // GC-epoch-changed check below. The per-klass population tracking below - // (_gc_generations) runs on both paths, once per genuinely new GC epoch + // (leak-signal gated) runs on both paths, once per genuinely new GC epoch // (see "is_epoch_owner" in livenessTracker.cpp). // // allow_resolve gates resolveKlassId() - GetClassSignature plus a class-map @@ -854,6 +878,8 @@ class alignas(alignof(SpinLock)) LivenessTracker { _max_heap_bytes(-1), _container_memory_limit(-1), _container_limit_resolved(false), _gc_generations(false), + _leak_signal_enabled(false), + _leak_signal_activated_ns(0), _klass_population_size(0), _klass_count_scratch_size(0), _last_class_map_generation(0), _leak_tag_free_count(LEAK_TAG_POOL_SIZE), @@ -935,7 +961,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // population trend is positive (growing), ranked by trend magnitude // descending, capped at MAX_LEAK_CANDIDATES regardless of `max` (design // doc's Open Question 3 "top 3-5" cutoff). Returns the number of - // candidates written (0 if _gc_generations was never enabled - + // candidates written (0 if leak-signal tracking was never enabled - // _klass_population stays empty in that case, since population tracking // is gated on it, so no separate guard is needed here). Called on demand // by the BFS-pass poll, not on any timer of its own; does no JNI work, so it is safe @@ -1014,16 +1040,23 @@ class alignas(alignof(SpinLock)) LivenessTracker { jobject resolveCandidateRepresentative(JNIEnv *env, u32 klass_id); // Exposes the _gc_generations gate (see that member's own comment) so a - // caller outside this class - ReferenceChainTracker::pollWatchedTargets() - // (referenceChains.cpp), the LivenessTracker-to-ReferenceChainTracker - // bridging step - can skip calling selectLeakCandidates() entirely when - // the feature isn't in use, - // rather than relying on that method's own "returns 0" fallback to make - // the no-op cheap. Read-only; this accessor never toggles the flag. + // caller outside this class can skip work that only makes sense for the + // explicit generations option. Read-only; this accessor never toggles the + // flag. bool gcGenerationsEnabled() const { return _gc_generations.load(std::memory_order_relaxed); } + // Exposes the effective leak-signal availability (see _leak_signal_enabled's + // own comment) - the gate ReferenceChainTracker::hasLeakSignal() keys on. + // True when generations tracking OR liveheap tracking is on; false means no + // population-trend signal can ever be constructed and the reference-chain + // collector must not search. Read-only; this accessor never toggles the + // flag. + bool leakSignalEnabled() const { + return _leak_signal_enabled.load(std::memory_order_relaxed); + } + // Time-to-OOM projection against whichever of two independent boundaries // is tighter: the JVM heap (_heap_floor_ring vs _max_heap_bytes) or the // container/cgroup memory limit (_container_mem_ring vs @@ -1063,8 +1096,23 @@ class alignas(alignof(SpinLock)) LivenessTracker { // this method additionally divides by elapsed time, so the same gap // understates the growth rate (overstates the projected time-to-OOM) // rather than the reverse - not solved here. + // Short-horizon linear extrapolation of the rising JVM heap-floor and + // container-memory trends against their limits (see the long comment in + // livenessTracker.h). Returns -1 while the leak signal is in its burn-in + // (see OOM_PROJECTION_BURN_IN_NS). double secondsToOOM() const; + // secondsToOOM() withholds its projection (returns -1) for this long after + // the leak signal first activated (_leak_signal_activated_ns): a fresh + // process's warmup heap growth projects an imminent OOM exactly like a leak + // does. The burn-in is age-based rather than based on the heap-floor ring's + // time span on purpose - the ring holds one sample per GC, so near OOM + // back-to-back GCs compress its span to seconds and a span-based guard + // would suppress urgency in exactly the imminent-OOM case. Must be >= + // ReferenceChainTracker::OOM_URGENT_THRESHOLD_S (static_assert in + // referenceChains.cpp). + static constexpr u64 OOM_PROJECTION_BURN_IN_NS = 300ULL * 1000000000ULL; + // Third trigger for cleanup_table(), alongside track()'s table-overflow // branch (forced) and flush_table()'s JFR-flush cadence (organic): those // two both depend on ObjectSampler's allocation-sampling callback firing @@ -1545,7 +1593,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { _test_klass_alias_count = 0; _table_lock.unlock(); // Also reset the heap-floor ring: it is a sibling piece of the same - // _gc_generations-gated feature, read by every selectLeakCandidates() + // leak-signal-gated feature, read by every selectLeakCandidates() // scan (heapFloorRising()), so leaving it populated across tests in the // same gtest binary would leak one test's heap-usage history into the // next test's hysteresis threshold. storeRelease() (not plain store()) @@ -1642,16 +1690,32 @@ class alignas(alignof(SpinLock)) LivenessTracker { } #endif - // Sets _gc_generations directly, bypassing initialize() (which requires a - // live JVM - VM::hotspot_version()/VM::jni(), see that method's own code - - // out of gtest's reach the same way foldKlassCountsLocked()'s + // Sets the two leak-signal gates directly, bypassing initialize() (which + // requires a live JVM - VM::hotspot_version()/VM::jni(), see that method's + // own code - out of gtest's reach the same way foldKlassCountsLocked()'s // representative-minting step is, per this seam block's own comment - // above). Callers outside this class that only need to exercise - // gcGenerationsEnabled()'s gate (e.g. referenceChains_ut.cpp's - // pollWatchedTargets() tests) use this instead of standing up a full - // initialize()/start() call. - void setGcGenerationsForTest(bool v) { - _gc_generations.store(v, std::memory_order_relaxed); + // above), with the same derivation initialize() applies: + // _gc_generations = generations, _leak_signal_enabled = generations || + // liveness. (generations=false, liveness=true) models a liveheap-only + // session (memory=N:l), so gates that must key on leakSignalEnabled() + // rather than gcGenerationsEnabled() are distinguishable under test. + void setLeakSignalSourcesForTest(bool generations, bool liveness) { + _gc_generations.store(generations, std::memory_order_relaxed); + _leak_signal_enabled.store(generations || liveness, std::memory_order_relaxed); + } + + // Kept for existing callers (javaApi.cpp, the test fixtures) that only need + // to exercise leakSignalEnabled()'s gate: sets BOTH flags to v - the + // generations option alone is a sufficient signal source, so the effective + // flag follows it. + void setGcGenerationsForTest(bool v) { setLeakSignalSourcesForTest(v, /*liveness=*/false); } + + // Overrides the burn-in stamp (see _leak_signal_activated_ns). initialize() + // needs a live JVM, so gtest never stamps it; 0 restores "never activated". + // Tests backdate it with modular u64 arithmetic, e.g. + // OS::nanotime() - OOM_PROJECTION_BURN_IN_NS - margin. + void setLeakSignalActivatedNsForTest(u64 ns) { + _leak_signal_activated_ns.store(ns, std::memory_order_release); } private: diff --git a/ddprof-lib/src/main/cpp/referenceChainEvents.cpp b/ddprof-lib/src/main/cpp/referenceChainEvents.cpp index 7290ce94a0..1a5de250ff 100644 --- a/ddprof-lib/src/main/cpp/referenceChainEvents.cpp +++ b/ddprof-lib/src/main/cpp/referenceChainEvents.cpp @@ -145,8 +145,12 @@ void ReferenceChainTracker::appendStaticFieldRootType( void ReferenceChainTracker::pollWatchedTargets(jvmtiEnv *jvmti, JNIEnv *jni) { if (!enabled() || jvmti == nullptr || jni == nullptr || - !LivenessTracker::instance()->gcGenerationsEnabled()) { - // Avoid candidate-table work when generation tracking is disabled. + !LivenessTracker::instance()->leakSignalEnabled()) { + // Avoid candidate-table work when no leak-signal source (generations or + // liveheap tracking) is active: the population table stays empty, so + // selectLeakCandidates() below would always return 0 anyway. Must match + // hasLeakSignal()'s gate - a search legitimately started on a liveheap + // source still needs its candidates polled here. return; } diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 211686301e..4d4ea42edb 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -388,7 +388,7 @@ void ReferenceChainTracker::stop() { // - _search_start_ns still holds the previous recording's clock, so the entire stopped gap // would count toward the new recording's TTL and could abandon "its" search on the first // pass with a spurious abandonment event for a search the new recording never started; - // - _urgent_latched/_urgent_release_ticks/_urgent_search_spent would carry the previous + // - _urgent_latched/_urgent_release_ticks would carry the previous // recording's urgency episode into the new one; // - the frontier's referrer_klass slots hold StringDictionary ids from a _class_map that // Profiler::start() is about to wipe (ids restart at 1) - they cannot be remapped, so a @@ -411,7 +411,6 @@ void ReferenceChainTracker::stop() { store(_search_start_ns, (u64)0); _urgent_latched = false; _urgent_release_ticks = 0; - _urgent_search_spent = false; // Same per-search candidate/discovered canary state runPass()'s terminal branch and // restartSearch() clear - zeroing the counts here closes the stale-chain windows in // pollWatchedTargets() (dead-representative canary path, discovered-instances path) for the @@ -652,9 +651,6 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { if (!afford) { return false; } - // This episode's one urgency-authorized search (_urgent_search_spent's own comment, - // referenceChains.h) is the one about to start. - _urgent_search_spent = _urgent_latched; TEST_LOG_SUMMARY("ReferenceChainTracker::shouldRunPass -> true (search not started yet)"); return true; // nothing has run yet - always worth taking the first pass } @@ -677,12 +673,10 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { _safepoint_pain_budget.spend(_search_pain_ms); _search_pain_ms = 0; // Restart (this class's own header comment) if the pain budget has drained and there is still - // (or again) a leak indication to chase - canAffordNewSearch() is always true when - // LivenessTracker's population trends are not in use at all, so this only ever changes behavior - // for a search that already ran once. + // (or again) a leak indication to chase - canAffordNewSearch() is always false when no + // leak-signal source (generations or liveheap tracking) is active at all, so a misconfigured + // tracker stays idle instead of searching without a signal. if (canAffordNewSearch(now_ns)) { - // Same entitlement bookkeeping as the first-search branch above. - _urgent_search_spent = _urgent_latched; restartSearch(); TEST_LOG_SUMMARY("ReferenceChainTracker::shouldRunPass -> true (restarting search)"); return true; @@ -772,13 +766,18 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { // threadLoop()'s own iteration and needs the *list* to poll each candidate's tag; this only needs // to know whether at least one exists. bool ReferenceChainTracker::isUrgent() const { + static_assert(LivenessTracker::OOM_PROJECTION_BURN_IN_NS / 1e9 >= OOM_URGENT_THRESHOLD_S, + "burn-in must cover the urgent horizon"); + // Warmup is filtered inside secondsToOOM(): no projection (-1) until + // OOM_PROJECTION_BURN_IN_NS after the leak signal first activated. Age-based + // on purpose - a guard on the heap-floor ring's time span shrinks with GC + // frequency and suppressed the latch in exactly the back-to-back-GC + // imminent-OOM case. double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); if (seconds_to_oom >= 0 && seconds_to_oom < OOM_URGENT_THRESHOLD_S) { _urgent_release_ticks = 0; if (!_urgent_latched) { _urgent_latched = true; - // A fresh episode gets a fresh entitlement to one search. - _urgent_search_spent = false; TEST_LOG_SUMMARY("ReferenceChainTracker::isUrgent latching urgency " "(secondsToOOM=%.1f < OOM_URGENT_THRESHOLD_S=%.1f)", seconds_to_oom, OOM_URGENT_THRESHOLD_S); @@ -786,13 +785,12 @@ bool ReferenceChainTracker::isUrgent() const { return true; } if (_urgent_latched) { - // Negative means "no rising trend to project from" (secondsToOOM()'s own unknown/NOT_RISING + // Negative means "no projection" (secondsToOOM()'s own unknown/NOT_RISING/BURN_IN // encoding), which counts toward release just like a comfortably distant projection does. if (seconds_to_oom < 0 || seconds_to_oom >= OOM_URGENT_RELEASE_S) { if (++_urgent_release_ticks >= URGENT_RELEASE_CONSECUTIVE) { _urgent_latched = false; _urgent_release_ticks = 0; - _urgent_search_spent = false; TEST_LOG_SUMMARY("ReferenceChainTracker::isUrgent releasing urgency " "(secondsToOOM=%.1f clear of OOM_URGENT_RELEASE_S=%.1f for " "%d consecutive observations)", @@ -811,32 +809,39 @@ bool ReferenceChainTracker::isUrgent() const { } bool ReferenceChainTracker::hasLeakSignal() { - if (!LivenessTracker::instance()->gcGenerationsEnabled()) { - // No population-trend signal to gate on at all - see this method's own header comment for why - // that means "always true" here. - return true; + if (!LivenessTracker::instance()->leakSignalEnabled()) { + // No population-trend signal can be constructed - generations tracking + // and liveheap tracking are both off, so the per-klass population table + // and the heap-floor ring never fill (LivenessTracker gates them on the + // same effective flag). Stay idle: without a signal there is nothing to + // chase, and running whole-heap searches with zero candidates "just in + // case" burns safepoint time and frontier-table memory for zero + // diagnostic value (observed: frontier grown to its cap with + // reference_chain_candidate_count == 0 in a healthy process when this + // branch used to return true). + return false; } double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); - // isUrgent() is called unconditionally, not short-circuited behind _urgent_search_spent: it is - // what maintains the latch/release counter, so skipping it would freeze the episode state (see - // _urgent_latched). + // isUrgent() is called unconditionally, not short-circuited behind any + // branch: it maintains the latch/release state (see _urgent_latched), which + // still drives the urgency ramp and the no-progress-abandonment suppression + // once a search runs. bool urgent = isUrgent(); - if (urgent && !_urgent_search_spent) { - // Heap-wide floor is rising fast enough that OOM is imminent - don't wait for a specific klass - // to clear selectLeakCandidates()'s own per-klass ring-fill/hysteresis gate; see - // OOM_URGENT_THRESHOLD_S's own comment (referenceChains.h) for why that gate alone is too slow - // here. - TEST_LOG_SUMMARY("ReferenceChainTracker::hasLeakSignal -> true (urgent, " - "secondsToOOM=%.1f)", - seconds_to_oom); - return true; - } KlassCandidate probe[1]; int n = LivenessTracker::instance()->selectLeakCandidates(probe, 1); + // The urgent latch does NOT entitle a candidate-less search. With nothing + // tagged the search can never emit a chain, and while urgent the 30-pass + // no-progress abandonment is suppressed (runPass()'s terminal gate), so a + // candidate-less search would run to the full auto-tuned TTL doing whole-heap + // BFS - pure cost. Observed in staging: a fresh process's warmup heap-floor + // rise tripped the 5-minute projection and a candidate-less search grew a + // 262,144-entry frontier (liveness_urgent_boost_admits=2495) with zero chains + // for the whole recording. Urgency still applies through the ramp (cadence, + // pause target, budget boost, 100% liveness admission) once a real candidate + // justifies the search. TEST_LOG_SUMMARY("ReferenceChainTracker::hasLeakSignal -> %s (secondsToOOM=%.1f, " - "candidates=%d, urgent=%d, urgentSearchSpent=%d)", - n > 0 ? "true" : "false", seconds_to_oom, n, urgent, - _urgent_search_spent); + "candidates=%d, urgent=%d)", + n > 0 ? "true" : "false", seconds_to_oom, n, urgent); return n > 0; } @@ -952,10 +957,9 @@ void ReferenceChainTracker::resetSearchStateForTest(jvmtiEnv *jvmti, // _safepoint_pain_budget above. _cpu_pain_budget = PainBudget(_pain_budget_refill_rate); // Same test-isolation rationale: a latched urgency episode left behind by an earlier test would - // otherwise deny this one its own urgency-authorized search (see _urgent_search_spent). + // otherwise ramp this one's cadence and pause target from a stale projection. _urgent_latched = false; _urgent_release_ticks = 0; - _urgent_search_spent = false; if (_frontier != nullptr) { // Rebuilds the table at this test's own _configured_frontier_cap, undoing any smaller framecap= diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index bfde3616ab..5b3615c993 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -338,7 +338,6 @@ class ReferenceChainTracker { // Hysteresis state behind isUrgent(). mutable bool _urgent_latched; mutable int _urgent_release_ticks; - mutable bool _urgent_search_spent; // Set (once) at the same point runPass() moves _search_state to ABANDONED - see // SearchAbandonReason's own comment for why this exists and buildAbandonedEvent()/abandonReason() @@ -738,7 +737,7 @@ class ReferenceChainTracker { _effective_budget(0), _effective_cadence_ns(PASS_CADENCE_NS), _pause_pid(1, 1.0, 1.0, 1.0, 1, 1.0), _search_started(false), _tags_released(true), _urgent_latched(false), - _urgent_release_ticks(0), _urgent_search_spent(false), + _urgent_release_ticks(0), _urgency_budget_boosted(false), _configured_budget(0), _search_state(SearchState::RUNNING), _abandon_reason(SearchAbandonReason::NONE), _search_start_ns(0), @@ -767,7 +766,9 @@ class ReferenceChainTracker { // Cheap probe (max=1, not the real poll pollWatchedTargets() makes) into LivenessTracker's // population-trend table: true if at least one klass shows a positive population slope worth - // chasing. + // chasing. Always false when no leak-signal source (generations or liveheap tracking) is + // active - the population table and heap-floor ring never fill without one, and a search + // would run whole-heap BFS passes with zero candidates for zero diagnostic value. bool hasLeakSignal(); // Latched, hysteretic view of LivenessTracker::secondsToOOM() crossing OOM_URGENT_THRESHOLD_S - @@ -1144,15 +1145,13 @@ class ReferenceChainTracker { void setSearchStartedForTest(bool v) { _search_started = v; } // OOM-urgency hysteresis state (isUrgent()'s latch/release machine, // referenceChains.cpp): tests drive it via isUrgentForTest() and assert on - // the raw latch/tick/entitlement reads. + // the raw latch/tick reads. bool isUrgentForTest() const { return isUrgent(); } bool urgentLatchedForTest() const { return _urgent_latched; } int urgentReleaseTicksForTest() const { return _urgent_release_ticks; } - bool urgentSearchSpentForTest() const { return _urgent_search_spent; } void resetUrgencyForTest() { _urgent_latched = false; _urgent_release_ticks = 0; - _urgent_search_spent = false; } // Applied vs configured CPU pain-budget refill rate: shouldRunPass() raises // the applied rate CANARY_PAIN_BUDGET_REFILL_MULTIPLIER-fold only while a diff --git a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp index 5b6e10f90f..d04ccf0950 100644 --- a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp +++ b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp @@ -19,6 +19,7 @@ #include "arguments.h" #include "counters.h" #include "objectSampler.h" +#include "os.h" #include "profiler.h" #include "referenceChainsTestAccessors.h" #include "vmEntry.h" @@ -1162,16 +1163,55 @@ class LivenessTrackerTestAccessor { return t->_last_gc_epoch; } + // Mirrors the generations value into the effective leak-signal flag, so it + // CANNOT model a liveheap-only session (generations off, signal on). Use + // initializeGatesOnlyForTest() (real initialize() derivation) or + // LivenessTracker::setLeakSignalSourcesForTest() for that. static void setSessionGatesForTest(LivenessTracker *t, bool enabled, bool generations) { t->_enabled = enabled; t->_gc_generations.store(generations, std::memory_order_relaxed); + t->_leak_signal_enabled.store(generations, std::memory_order_relaxed); } static bool enabledForTest(LivenessTracker *t) { return t->_enabled; } + static bool initializedForTest(LivenessTracker *t) { + return t->_initialized; + } + + static u64 leakSignalActivatedNsForTest(LivenessTracker *t) { + return t->_leak_signal_activated_ns.load(std::memory_order_acquire); + } + + // Runs the real (private) initialize() with _initialized forced true, so it + // takes the early return right after deriving the session gates - no + // VM/JNI/table init and no persistent _initialized flip on the singleton. + // Restores every other field initialize() touches ahead of that guard + // (_initialized, _enabled, _record_heap_usage, _watched_tid_count, + // _urgent_tracking and the burn-in stamp); only _gc_generations and + // _leak_signal_enabled keep the values initialize() derived. + static void initializeGatesOnlyForTest(LivenessTracker *t, Arguments &args) { + bool saved_initialized = t->_initialized; + bool saved_enabled = t->_enabled; + bool saved_heap_usage = t->_record_heap_usage; + int saved_watched = __atomic_load_n(&t->_watched_tid_count, __ATOMIC_ACQUIRE); + bool saved_urgent = __atomic_load_n(&t->_urgent_tracking, __ATOMIC_ACQUIRE); + u64 saved_stamp = t->_leak_signal_activated_ns.load(std::memory_order_acquire); + t->_initialized = true; + // Returns _stored_error from any prior real start - not under test. + Error ignored = t->initialize(args); + (void)ignored; + t->_initialized = saved_initialized; + t->_enabled = saved_enabled; + t->_record_heap_usage = saved_heap_usage; + __atomic_store_n(&t->_watched_tid_count, saved_watched, __ATOMIC_RELEASE); + __atomic_store_n(&t->_urgent_tracking, saved_urgent, __ATOMIC_RELEASE); + t->_leak_signal_activated_ns.store(saved_stamp, std::memory_order_release); + } + static bool entryAgeAndAdmissionForTest(LivenessTracker *t, u32 idx, jlong *age, u64 *admission_epoch) { @@ -1192,6 +1232,106 @@ TEST(LivenessTrackerSessionTest, ResetSessionClearsOnlyRecordingGates) { tracker->resetSession(); EXPECT_FALSE(LivenessTrackerTestAccessor::enabledForTest(tracker)); EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_FALSE(tracker->leakSignalEnabled()); +} + +// hasLeakSignal() must never authorize a search without a leak-signal source: +// with neither generations nor liveheap tracking active, the population table +// and heap-floor ring never fill, and searching would run whole-heap BFS +// passes with zero candidates (observed in staging as a frontier table grown +// to its cap with reference_chain_candidate_count == 0 in a healthy process). +TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { + LivenessTracker *tracker = LivenessTracker::instance(); + + LivenessTrackerTestAccessor::setSessionGatesForTest( + tracker, /*enabled=*/false, /*generations=*/false); + EXPECT_FALSE(tracker->leakSignalEnabled()); + + // Liveheap alone is a sufficient source: it drives the same population + // machinery, only additionally preserving stack traces for its samples. + // Driven through the real initialize() derivation from parsed arguments + // (setSessionGatesForTest cannot model liveheap-only). + Arguments live; + ASSERT_FALSE(live.parse("memory=1048576:l")); + ASSERT_TRUE(live._record_liveness); + ASSERT_FALSE(live._gc_generations); + u64 stamp_before = LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker); + bool initialized_before = LivenessTrackerTestAccessor::initializedForTest(tracker); + bool enabled_before = LivenessTrackerTestAccessor::enabledForTest(tracker); + LivenessTrackerTestAccessor::initializeGatesOnlyForTest(tracker, live); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_TRUE(tracker->leakSignalEnabled()) + << "initialize() must derive the leak signal from generations || liveheap"; + // The helper restores every non-gate field it touched. + EXPECT_EQ(stamp_before, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)); + EXPECT_EQ(initialized_before, LivenessTrackerTestAccessor::initializedForTest(tracker)); + EXPECT_EQ(enabled_before, LivenessTrackerTestAccessor::enabledForTest(tracker)); + + LivenessTrackerTestAccessor::setSessionGatesForTest( + tracker, /*enabled=*/false, /*generations=*/false); +} + +// initialize() derives the two gates from the parsed options: +// gcGenerationsEnabled() == generations, leakSignalEnabled() == generations || +// liveheap. The reference-chain collector keys on the effective flag, so +// liveheap tracking alone must arm it. +TEST(LivenessTrackerSessionTest, LeakSignalDerivedByInitialize) { + LivenessTracker *tracker = LivenessTracker::instance(); + struct Row { + const char *spec; + bool liveness; + bool generations; + bool expect_signal; + }; + const Row rows[] = { + {"memory=1048576:l", true, false, true}, + {"generations=true", false, true, true}, + {"generations=true,memory=1048576:l", true, true, true}, + {"memory=1048576:a", false, false, false}, + }; + for (const Row &row : rows) { + Arguments args; + ASSERT_FALSE(args.parse(row.spec)) << "row " << row.spec; + ASSERT_EQ(row.liveness, args._record_liveness) << "row " << row.spec; + ASSERT_EQ(row.generations, args._gc_generations) << "row " << row.spec; + bool initialized_before = LivenessTrackerTestAccessor::initializedForTest(tracker); + bool enabled_before = LivenessTrackerTestAccessor::enabledForTest(tracker); + u64 stamp_before = LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker); + LivenessTrackerTestAccessor::initializeGatesOnlyForTest(tracker, args); + EXPECT_EQ(row.generations, tracker->gcGenerationsEnabled()) << "row " << row.spec; + EXPECT_EQ(row.expect_signal, tracker->leakSignalEnabled()) << "row " << row.spec; + EXPECT_EQ(initialized_before, LivenessTrackerTestAccessor::initializedForTest(tracker)) + << "row " << row.spec; + EXPECT_EQ(enabled_before, LivenessTrackerTestAccessor::enabledForTest(tracker)) + << "row " << row.spec; + EXPECT_EQ(stamp_before, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)) + << "row " << row.spec; + } + LivenessTrackerTestAccessor::setSessionGatesForTest( + tracker, /*enabled=*/false, /*generations=*/false); + EXPECT_FALSE(tracker->leakSignalEnabled()); +} + +// setLeakSignalSourcesForTest() applies initialize()'s derivation, and +// setGcGenerationsForTest(v) keeps setting BOTH flags to v (existing callers +// depend on it). +TEST(LivenessTrackerSessionTest, LeakSignalSourcesSeamMatchesDerivation) { + LivenessTracker *tracker = LivenessTracker::instance(); + tracker->setLeakSignalSourcesForTest(/*generations=*/false, /*liveness=*/true); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_TRUE(tracker->leakSignalEnabled()); + tracker->setLeakSignalSourcesForTest(/*generations=*/true, /*liveness=*/false); + EXPECT_TRUE(tracker->gcGenerationsEnabled()); + EXPECT_TRUE(tracker->leakSignalEnabled()); + tracker->setLeakSignalSourcesForTest(/*generations=*/false, /*liveness=*/false); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_FALSE(tracker->leakSignalEnabled()); + tracker->setGcGenerationsForTest(true); + EXPECT_TRUE(tracker->gcGenerationsEnabled()); + EXPECT_TRUE(tracker->leakSignalEnabled()); + tracker->setGcGenerationsForTest(false); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_FALSE(tracker->leakSignalEnabled()); } // cleanup_table() calls VM::jni(), so install a fake JavaVM. @@ -1314,15 +1454,24 @@ class SecondsToOOMTest : public ::testing::Test { installGtestCrashHandler(); LivenessTracker::instance()->klassPopulationResetForTest(); LivenessTracker::instance()->setGcGenerationsForTest(true); + // Past the burn-in (gtest never runs initialize(), so nothing stamps it). + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(backdatedStamp()); } void TearDown() override { LivenessTracker::instance()->klassPopulationResetForTest(); LivenessTracker::instance()->setGcGenerationsForTest(false); LivenessTracker::instance()->setMaxHeapBytesForTest(-1); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); restoreDefaultSignalHandlers(); } + // A burn-in stamp one second past OOM_PROJECTION_BURN_IN_NS (modular u64). + static u64 backdatedStamp() { + return OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS; + } + // Ten samples, one second apart, growing by 100MiB each: earliest third // (indices 0-2) means to 1100MiB at t=1s, recent third (indices 7-9) // means to 1800MiB at t=8s - a 700MiB rise over 7s, i.e. exactly @@ -1461,6 +1610,67 @@ TEST_F(SecondsToOOMTest, FloorAtMaxHeapReturnsZero) { EXPECT_EQ(tracker->secondsToOOM(), 0.0); } +// Within OOM_PROJECTION_BURN_IN_NS of activation no projection is offered, even +// for a history that projects cleanly once the burn-in has passed. +TEST_F(SecondsToOOMTest, BurnInWithholdsProjection) { + LivenessTracker *tracker = LivenessTracker::instance(); + tracker->setLeakSignalActivatedNsForTest(OS::nanotime()); + tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); + seedRisingFloor(tracker); + EXPECT_LT(tracker->secondsToOOM(), 0.0) << "projection must be withheld during burn-in"; + + tracker->setLeakSignalActivatedNsForTest(backdatedStamp()); + EXPECT_NEAR(tracker->secondsToOOM(), 9.0, 1e-6); +} + +// A 0 stamp means "never activated", not "activated at the epoch" (which would +// make the modular age equal nanotime() and usually exceed the burn-in). +TEST_F(SecondsToOOMTest, NeverActivatedWithholdsProjection) { + LivenessTracker *tracker = LivenessTracker::instance(); + tracker->setLeakSignalActivatedNsForTest(0); + tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); + seedRisingFloor(tracker); + EXPECT_LT(tracker->secondsToOOM(), 0.0); +} + +// The burn-in stamp is per process: neither the ring resets nor +// resetSession() may zero or re-stamp it. +TEST_F(SecondsToOOMTest, StampSurvivesResetSeams) { + LivenessTracker *tracker = LivenessTracker::instance(); + bool enabled_before = LivenessTrackerTestAccessor::enabledForTest(tracker); + u64 stamp = LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker); + ASSERT_NE(0u, stamp); + + tracker->klassPopulationResetForTest(); +#ifdef DEBUG + tracker->setHeapFloorRecordingForTest(false); + tracker->heapFloorResetForTest(); + tracker->setHeapFloorRecordingForTest(true); +#endif + tracker->resetSession(); + // resetSession() clears _enabled and both gates; re-arm the signal and + // restore _enabled in one call so neither leaks into later tests. + LivenessTrackerTestAccessor::setSessionGatesForTest(tracker, enabled_before, + /*generations=*/true); + EXPECT_EQ(stamp, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)); + + tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); + seedRisingFloor(tracker); + EXPECT_NEAR(tracker->secondsToOOM(), 9.0, 1e-6); +} + +// onGC() records heap-floor samples whenever the effective leak signal is on - +// a liveheap-only session (generations off) must still feed secondsToOOM(). +TEST_F(SecondsToOOMTest, OnGCRecordsHeapFloorWithLiveheapOnlySource) { + LivenessTracker *tracker = LivenessTracker::instance(); + tracker->setLeakSignalSourcesForTest(/*generations=*/false, /*liveness=*/true); + ASSERT_FALSE(tracker->gcGenerationsEnabled()); + ASSERT_TRUE(tracker->leakSignalEnabled()); + // The helper EXPECTs that onGC() grew the heap-floor ring by one sample. + (void)LivenessTrackerTestAccessor::containerUsageRecordedByOnGC(tracker, + /*limit_resolved=*/false); +} + // --------------------------------------------------------------------------- // Leak tag pool (design A: direct tagging of leaking objects) // --------------------------------------------------------------------------- @@ -2507,6 +2717,8 @@ class LivenessTrackerMockJvmTest : public ::testing::Test { tracker->admissionResetForTest(); tracker->setSubsampleRatioForTest(0.1); tracker->setGcGenerationsForTest(false); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + tracker->setLeakSignalActivatedNsForTest(0); // start() resolved the heap limits from the mock JVM. tracker->heapLimitsResetForTest(); VMTestAccessor::setVm(saved_vm); @@ -2936,3 +3148,65 @@ TEST_F(LivenessTrackerMockJvmTest, SlotsPastTableSizeAreClearedAfterCompaction) } } } + +// Liveheap tracking alone (memory=...:l, no generations) must arm the +// effective leak signal through the real start() -> initialize() path, and +// every consumer gated on it (secondsToOOM(), maybeForceCleanup() and +// cleanup_table()'s population fold) must work without the generations +// option. The other fixtures arm the gate only through seams that mirror the +// generations value into it, so they cannot tell the two flags apart. +TEST_F(LivenessTrackerMockJvmTest, LiveheapOnlyStartArmsLeakSignalConsumers) { + constexpr u64 SEC_NS = 1000000000ULL; + constexpr u64 MiB = 1ULL << 20; + LivenessTracker *tracker = LivenessTracker::instance(); + + // 1. Re-start as liveheap-only. The gate stores (and the burn-in CAS) + // precede initialize()'s _initialized guard, so a repeated start() + // re-derives them. + tracker->setLeakSignalActivatedNsForTest(0); + Arguments args; + ASSERT_FALSE(args.parse("memory=1048576:l")); + ASSERT_TRUE(args._record_liveness); + ASSERT_FALSE(args._gc_generations); + ASSERT_FALSE(tracker->start(args)); + tracker->setSubsampleRatioForTest(1.0); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_TRUE(tracker->leakSignalEnabled()) + << "initialize() must derive the leak signal from generations || liveheap"; + + // 2. Burn-in stamp lifecycle: start() stamps it from 0 and never re-stamps. + EXPECT_NE(0u, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)) + << "the first enabled start() must stamp the burn-in clock"; + u64 backdated = OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS; + tracker->setLeakSignalActivatedNsForTest(backdated); + ASSERT_FALSE(tracker->start(args)); + EXPECT_EQ(backdated, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)) + << "a later start() must not re-stamp the burn-in clock"; + + // 3. secondsToOOM() projects in a liveheap-only session. Runs before any + // sweep(): onGC() records real-clock floor samples while the signal is on. + tracker->klassPopulationResetForTest(); // also clears the heap-floor ring + tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); + for (int i = 0; i < 10; i++) { + tracker->heapFloorRecordForTest(1000 * MiB + (u64)i * 100 * MiB, (u64)i * SEC_NS); + } + // GE rather than NEAR: start() resolved the real container limit. + EXPECT_GE(tracker->secondsToOOM(), 0.0) + << "secondsToOOM() must project in a liveheap-only session"; + tracker->klassPopulationResetForTest(); // drop the seeded ring before the sweep + tracker->setMaxHeapBytesForTest(-1); + + // 4. maybeForceCleanup() sweeps and folds the population. track() never + // folds, so the only route to a population entry is that sweep. + u32 retained_id = classIdOf(kRetainedSignature); + ASSERT_NE(0u, retained_id); + trackNew(&jvm.retained_class, /*tid=*/1); + KlassPopulationEntry entry{}; + ASSERT_FALSE(tracker->klassPopulationLookupForTest(retained_id, &entry)); + sweep(); // GarbageCollectionFinish + maybeForceCleanup(fake_now_ns) + EXPECT_TRUE(tracker->klassPopulationLookupForTest(retained_id, &entry)) + << "maybeForceCleanup() must sweep and fold the population in a liveheap-only session"; + + // 5. TearDown clears the gates via setGcGenerationsForTest(false). + tracker->setLeakSignalActivatedNsForTest(0); +} diff --git a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc index 6295e26850..6043216620 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc @@ -712,25 +712,30 @@ TEST_F(ReferenceChainsBfsTest, CanaryStuckLimitDoublesPerRestartAndResetsOnClean // frontier-stall precondition, checked first) is deliberately suppressed by isUrgent(), and // at the base limit both conditions would fire on the same pass. Seed a ~224s-to-OOM // projection the same way referenceChainsOomTests.inc does (linear ramp over synthetic - // timestamps); restored at the end so later tests see default heap state. + // timestamps; the 60s spacing is arbitrary) with secondsToOOM()'s age-based burn-in stamp + // backdated past OOM_PROJECTION_BURN_IN_NS; restored at the end so later tests see default + // heap state. constexpr u64 SEC_NS = 1000000000ULL; constexpr u64 MiB = 1ULL << 20; constexpr u64 MAX_HEAP_TEST = 1024 * MiB; LivenessTracker::instance()->setMaxHeapBytesForTest((jlong)MAX_HEAP_TEST); LivenessTracker::instance()->setGcGenerationsForTest(true); + LivenessTracker::instance()->setLeakSignalActivatedNsForTest( + OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS); #ifdef DEBUG LivenessTracker::instance()->setHeapFloorRecordingForTest(false); #endif { u64 clock_ns = 0; - u64 last_used = MAX_HEAP_TEST - 224 * MiB; + // remaining * 60s / 1MiB == 240 => remaining == 4MiB + u64 last_used = MAX_HEAP_TEST - 4 * MiB; for (int i = 0; i < 30; i++) { LivenessTracker::instance()->heapFloorRecordForTest( last_used - MiB * (u64)(29 - i), clock_ns, 0); - clock_ns += SEC_NS; + clock_ns += 60 * SEC_NS; } } - ASSERT_NEAR(LivenessTracker::instance()->secondsToOOM(), 224.0, 1.0); + ASSERT_NEAR(LivenessTracker::instance()->secondsToOOM(), 240.0, 1.0); constexpr int kChainLength = 10; std::vector nodes; @@ -804,6 +809,8 @@ TEST_F(ReferenceChainsBfsTest, CanaryStuckLimitDoublesPerRestartAndResetsOnClean // fixtures use in SetUp, applied to the leaked half. LivenessTracker::instance()->setMaxHeapBytesForTest(-1); LivenessTracker::instance()->setGcGenerationsForTest(false); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); tracker->stop(); ReferenceChainsTestAccessor::resetUrgencyForTest(); ReferenceChainsTestAccessor::setWatchedLeakKlassIdsForTest({}); @@ -888,6 +895,34 @@ TEST_F(ReferenceChainsBfsTest, CanaryLaneBacksOffWithoutProgressAndResetsOnProgr tracker->stop(); } +// With no leak-signal source active (neither generations nor liveheap +// tracking), the collector must stay completely idle: no first pass and no +// restart, ever. Searching without a signal runs whole-heap BFS passes with +// zero candidates - the exact waste the leak-signal gate exists to prevent +// (observed in staging: frontier grown to its cap with +// reference_chain_candidate_count == 0 in a healthy process, because the +// no-signal branch used to return true unconditionally). +TEST_F(ReferenceChainsBfsTest, NoSearchWithoutLeakSignalSource) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Force the no-signal state regardless of what earlier tests left behind: + // setGcGenerationsForTest(false) clears both the raw option flag and the + // effective leak-signal gate (initialize() would derive the effective + // flag from generations || liveheap; this seam bypasses initialize()). + LivenessTracker::instance()->setGcGenerationsForTest(false); + ASSERT_FALSE(LivenessTracker::instance()->leakSignalEnabled()); + + // First-search gate: a brand-new tracker must not start a pass. + EXPECT_FALSE(ReferenceChainsTestAccessor::canAffordNewSearch(OS::nanotime())); + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(OS::nanotime())) + << "no leak-signal source means no search, not a search without a gate"; + + tracker->stop(); +} + TEST_F(ReferenceChainsBfsTest, ChainCompletesWithoutAbandonment) { Arguments args; // budget=1 on a graph where each pass admits exactly one new edge until the chain is exhausted, diff --git a/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc b/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc index e3e58058c9..397c7c4328 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc @@ -316,8 +316,7 @@ TEST_F(ReferenceChainsRecordingBoundaryTest, // A search still in flight when the profiler stops: started, RUNNING, a TTL clock that // predates the stop, a latched urgency episode, and live canary candidate state. ReferenceChainsTestAccessor::setInFlightSearchForTest(1ULL << 40); - ReferenceChainsTestAccessor::setUrgencyEpisodeForTest(/*latched=*/true, /*ticks=*/2, - /*spent=*/true); + ReferenceChainsTestAccessor::setUrgencyEpisodeForTest(/*latched=*/true, /*ticks=*/2); ReferenceChainsTestAccessor::setCandidateCountForTest(2); ReferenceChainsTestAccessor::setCandidateFoundBitsForTest(0x1); ReferenceChainsTestAccessor::setCandidateFrontierTagForTest(0, 42); @@ -337,7 +336,6 @@ TEST_F(ReferenceChainsRecordingBoundaryTest, // The urgency episode does not carry over into the next recording. EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); EXPECT_EQ(0, ReferenceChainsTestAccessor::urgentReleaseTicksForTest()); - EXPECT_FALSE(ReferenceChainsTestAccessor::urgentSearchSpentForTest()); // The canary/discovered candidate state is cleared with the search that owned it. EXPECT_EQ(0, ReferenceChainsTestAccessor::candidateCountForTest()); EXPECT_EQ(0u, ReferenceChainsTestAccessor::candidateFoundBitsForTest()); @@ -375,8 +373,25 @@ TEST_F(ReferenceChainsRecordingBoundaryTest, // The release pass was the terminal no-op: no walk ran. EXPECT_EQ(0, tracker->passesRun()); - // With generations disabled (this fixture's SetUp), canAffordNewSearch() is unconditional, - // so the next shouldRunPass() restarts the search - exactly like a brand-new tracker. + // With no leak-signal source (this fixture's SetUp), canAffordNewSearch() always refuses, so + // the terminal search stays terminal. Arm the signal (generations tracking alone is a + // sufficient source) AND seed a growing candidate: the restart gate requires both the signal + // source and a live leak indication (hasLeakSignal()'s selectLeakCandidates() probe); + // TearDown restores the no-signal state. + LivenessTracker::instance()->setGcGenerationsForTest(true); + int fake_object_storage = 0; + { + int slot; + bool created; + for (u16 i = 1; i <= 20; i++) { + LivenessTracker::instance()->klassPopulationRecordForTest( + /*klass_id=*/1, i, i, &slot, &created); + LivenessTracker::instance()->tidTrendRecordForTest( + /*klass_id=*/1, /*tid=*/4242, (u32)i, (u64)i); + } + LivenessTracker::instance()->klassPopulationSetRepresentativeForTest( + nullptr, /*klass_id=*/1, (jweak)&fake_object_storage); + } EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2000)); EXPECT_EQ(SearchState::RUNNING, ReferenceChainsTestAccessor::searchStateForTest()); @@ -419,7 +434,24 @@ TEST_F(ReferenceChainsRecordingBoundaryTest, NoStaleFrontierIdsAfterRecordingBou EXPECT_TRUE(ReferenceChainsTestAccessor::tagsReleased()); // ... and the restart that follows resets the frontier content, so no chain can ever be // built from the previous _class_map generation's ids (which cannot be remapped: the - // dictionary contents are gone and its id namespace restarts at 1). + // dictionary contents are gone and its id namespace restarts at 1). Arm the leak signal AND + // seed a growing candidate for the restart step only - the restart gate requires both the + // signal source (generations or liveheap) and a live leak indication; the no-signal state + // this fixture runs in would keep the search terminal. TearDown restores it. + LivenessTracker::instance()->setGcGenerationsForTest(true); + { + int fake_object_storage = 0; + int slot; + bool created; + for (u16 i = 1; i <= 20; i++) { + LivenessTracker::instance()->klassPopulationRecordForTest( + /*klass_id=*/1, i, i, &slot, &created); + LivenessTracker::instance()->tidTrendRecordForTest( + /*klass_id=*/1, /*tid=*/4242, (u32)i, (u64)i); + } + LivenessTracker::instance()->klassPopulationSetRepresentativeForTest( + nullptr, /*klass_id=*/1, (jweak)&fake_object_storage); + } EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2000)); EXPECT_EQ(SearchState::RUNNING, ReferenceChainsTestAccessor::searchStateForTest()); diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index c21cbf9936..2b90c80739 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -3,17 +3,23 @@ // OOM-ramp hysteresis (C5): isUrgent()'s latch/release machine and // shouldRunPass()'s canary-refill raise, driven deterministically. // -// No threads and no wall-clock dependence: the heap trajectory is faked by +// No threads: the heap trajectory is faked by // seeding LivenessTracker's usage rings with synthetic timestamps // (heapFloorRecordForTest() + setMaxHeapBytesForTest() + setGcGenerationsForTest()), // and both machines under test take their clock as an argument (isUrgent() // reads live state; shouldRunPass(now_ns) takes now_ns). For a perfectly -// linear usage ramp of R bytes per 1s sample against max heap M, the -// projection (livenessTracker.cpp's regression form) reduces to -// secondsToOOM() == (M - last_used) / R, so each scenario seeds the exact -// urgency reading it wants and re-seeds between observations - the -// production loop re-reads the projection once per ~1s iteration the same -// way. +// linear usage ramp of RATE bytes per SAMPLE_SPACING_S sample against max +// heap M, the projection (livenessTracker.cpp's regression form) reduces to +// secondsToOOM() == (M - last_used) * SAMPLE_SPACING_S / RATE, so each +// scenario seeds the exact urgency reading it wants and re-seeds between +// observations - the production loop re-reads the projection once per ~1s +// iteration the same way. +// +// The 15s sample spacing is arbitrary, kept for readable arithmetic. Warmup +// is filtered by secondsToOOM()'s age-based burn-in (OOM_PROJECTION_BURN_IN_NS +// after the leak signal first activated), not by the time span of the sample +// ring, so SetUp() backdates the burn-in stamp against the real monotonic +// clock - the only wall-clock read in these scenarios. // --------------------------------------------------------------------------- #include @@ -25,13 +31,17 @@ protected: static constexpr u64 SEC_NS = 1000000000ULL; static constexpr u64 MiB = 1ULL << 20; static constexpr u64 MAX_HEAP = 1024 * MiB; - // Bytes of heap growth per 1s sample - the projection's slope. + // Bytes of heap growth per sample - the projection's slope is + // RATE / SAMPLE_SPACING_S per second. static constexpr u64 RATE = MiB; + // Arbitrary - see the file comment above. + static constexpr u64 SAMPLE_SPACING_S = 15; void SetUp() override { ReferenceChainsTestAccessor::resetUrgencyForTest(); LivenessTracker::instance()->setGcGenerationsForTest(true); LivenessTracker::instance()->setMaxHeapBytesForTest((jlong)MAX_HEAP); + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(backdatedStamp()); #ifdef DEBUG // No real GC may interleave a sample into the ring while a scenario's // synthetic trajectory is mid-description. @@ -43,6 +53,13 @@ protected: ReferenceChainsTestAccessor::resetUrgencyForTest(); LivenessTracker::instance()->setMaxHeapBytesForTest(-1); LivenessTracker::instance()->setGcGenerationsForTest(false); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); + } + + // A burn-in stamp one second past OOM_PROJECTION_BURN_IN_NS (modular u64). + static u64 backdatedStamp() { + return OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS; } // Overwrites the whole usage ring with a linear ramp whose projection is @@ -52,13 +69,16 @@ protected: // which secondsToOOM() reports as -1 (no rising trend - the // recent-half corroboration rejects the flat regression). double project(double seconds, bool flat = false) { - u64 last_used = MAX_HEAP - (u64)(seconds * (double)RATE); + // remaining * SAMPLE_SPACING_S / RATE == seconds => + // remaining == seconds * RATE / SAMPLE_SPACING_S + u64 last_used = MAX_HEAP - (u64)(seconds * (double)RATE / + (double)SAMPLE_SPACING_S); for (int i = 0; i < 30; i++) { u64 used = flat ? MAX_HEAP / 2 : last_used - RATE * (u64)(29 - i); LivenessTracker::instance()->heapFloorRecordForTest( used, _clock_ns, 0); - _clock_ns += SEC_NS; + _clock_ns += SAMPLE_SPACING_S * SEC_NS; } return LivenessTracker::instance()->secondsToOOM(); } @@ -142,8 +162,7 @@ TEST_F(OomUrgencyTest, UnknownProjectionCountsTowardRelease) { } // After a release, mid-band projections stay unlatched (no residual state), -// and a fresh sub-threshold crossing re-latches with a fresh search -// entitlement (_urgent_search_spent reset). +// and a fresh sub-threshold crossing re-latches cleanly. TEST_F(OomUrgencyTest, RelatchAfterReleaseGetsFreshEntitlement) { ASSERT_NEAR(project(299.0), 299.0, 1.0); ASSERT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()); @@ -160,12 +179,83 @@ TEST_F(OomUrgencyTest, RelatchAfterReleaseGetsFreshEntitlement) { EXPECT_FALSE(ReferenceChainsTestAccessor::isUrgentForTest()); EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); - // Re-cross the threshold: re-latch, and the entitlement to spend one - // urgency-authorized search is restored. + // Re-cross the threshold: re-latch. EXPECT_NEAR(project(299.0), 299.0, 1.0); EXPECT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()); EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()); - EXPECT_FALSE(ReferenceChainsTestAccessor::urgentSearchSpentForTest()); +} + +// During the burn-in (OOM_PROJECTION_BURN_IN_NS after the leak signal first +// activated) no projection is offered, so urgency never latches: a fresh +// process's warmup growth curve projects an imminent OOM just as a real leak +// does (the staging incident: a fresh process latched urgency and ran a +// candidate-less whole-heap search to the TTL). +TEST_F(OomUrgencyTest, BurnInDoesNotLatchUrgency) { + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(OS::nanotime()); + // Earlier scenarios advanced the shared seeding clock far past this test's + // 0-based timestamps - clear the ring so it holds exactly this test's + // 10 samples. + LivenessTracker::instance()->klassPopulationResetForTest(); + constexpr u64 RATE_1S = 100 * MiB; // 100MiB/s - projects ~10s once past burn-in + for (int i = 0; i < 10; i++) { + LivenessTracker::instance()->heapFloorRecordForTest( + MAX_HEAP - 1000 * MiB + (u64)i * RATE_1S, (u64)i * SEC_NS, 0); + } + EXPECT_LT(LivenessTracker::instance()->secondsToOOM(), 0.0) + << "projection must be withheld during burn-in"; + EXPECT_FALSE(ReferenceChainsTestAccessor::isUrgentForTest()); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); + + // Control: the same seeds are urgent-grade once the burn-in has passed, + // so the negative half above is not trivially satisfied. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(backdatedStamp()); + double seconds = LivenessTracker::instance()->secondsToOOM(); + ASSERT_GE(seconds, 0.0); + ASSERT_LT(seconds, 300.0) << "scenario must project inside the urgent band"; +} + +// Regression: near OOM, back-to-back GCs record heap-floor samples less than a +// second apart, so the whole ring spans only seconds. Past the burn-in such a +// GC-compressed history must still project and latch urgency on the first +// sub-threshold reading (the removed span-based guard compared a ~10s +// earliest-to-recent span against the 300s horizon and never latched here). +TEST_F(OomUrgencyTest, GcCompressedWindowLatchesAfterBurnIn) { + LivenessTracker::instance()->klassPopulationResetForTest(); + // project()'s linear ramp with the spacing compressed from 15s to 0.5s: + // RATE (1MiB) every 0.5s is 2MiB/s; 200MiB of headroom projects to 100s. + // project() itself is not used - its shared clock and 15s spacing would + // defeat the compression. + u64 last_used = MAX_HEAP - 200 * MiB; + for (int i = 0; i < 30; i++) { + LivenessTracker::instance()->heapFloorRecordForTest( + last_used - RATE * (u64)(29 - i), (u64)i * SEC_NS / 2, 0); + } + double seconds = LivenessTracker::instance()->secondsToOOM(); + ASSERT_GE(seconds, 0.0); + ASSERT_LT(seconds, 300.0) << "scenario must project inside the urgent band"; + EXPECT_NEAR(seconds, 100.0, 1.0); + + EXPECT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()) + << "a GC-compressed ring past the burn-in must latch urgency"; + EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()); +} + +// A burn-in -1 is "no projection" like any other negative reading: it counts +// toward release, neither holding the latch as urgent nor as mid-band. +TEST_F(OomUrgencyTest, BurnInProjectionCountsTowardRelease) { + ASSERT_NEAR(project(299.0), 299.0, 1.0); + ASSERT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()); + + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(OS::nanotime()); + for (int i = 1; i <= 4; i++) { + EXPECT_LT(project(299.0), 0.0); + EXPECT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()) + << "released after only " << i << " burn-in observations"; + EXPECT_EQ(i, ReferenceChainsTestAccessor::urgentReleaseTicksForTest()); + } + EXPECT_LT(project(299.0), 0.0); + EXPECT_FALSE(ReferenceChainsTestAccessor::isUrgentForTest()); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); } // --------------------------------------------------------------------------- diff --git a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h index a7045e8582..19c93ad761 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h +++ b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h @@ -170,9 +170,6 @@ class ReferenceChainsTestAccessor { static int urgentReleaseTicksForTest() { return ReferenceChainTracker::instance()->urgentReleaseTicksForTest(); } - static bool urgentSearchSpentForTest() { - return ReferenceChainTracker::instance()->urgentSearchSpentForTest(); - } static void resetUrgencyForTest() { ReferenceChainTracker::instance()->resetUrgencyForTest(); } @@ -361,11 +358,10 @@ class ReferenceChainsTestAccessor { // Recording-boundary tests: seed a latched urgency episode (the state the OOM-projection // machine leaves behind) without driving LivenessTracker's heap-floor history. - static void setUrgencyEpisodeForTest(bool latched, int ticks, bool spent) { + static void setUrgencyEpisodeForTest(bool latched, int ticks) { ReferenceChainTracker *t = ReferenceChainTracker::instance(); t->_urgent_latched = latched; t->_urgent_release_ticks = ticks; - t->_urgent_search_spent = spent; } static u64 searchStartNsForTest() { diff --git a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc index 44f68b6e8b..284ec7c3e4 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc @@ -435,8 +435,8 @@ TEST_F(PollWatchedTargetsTest, NoOpWhenGcGenerationsDisabled) { ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); ASSERT_FALSE(tracker->start(args)); - // Overrides SetUp(): exercises pollWatchedTargets()'s LivenessTracker _gc_generations guard, - // not just the tracker's own _enabled. + // Overrides SetUp(): exercises pollWatchedTargets()'s LivenessTracker leak-signal gate (the + // effective generations-or-liveheap flag), not just the tracker's own _enabled. LivenessTracker::instance()->setGcGenerationsForTest(false); int fake_object_storage = 0; @@ -453,6 +453,39 @@ TEST_F(PollWatchedTargetsTest, NoOpWhenGcGenerationsDisabled) { tracker->stop(); } +// pollWatchedTargets() must gate on the effective leak-signal flag, not the raw generations option: +// a search started on a liveheap-only source (hasLeakSignal() keys on leakSignalEnabled()) still +// needs its candidates polled here. +TEST_F(PollWatchedTargetsTest, PollsCandidatesWithLiveheapOnlySignalSource) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + // Overrides SetUp(): liveheap-only source - generations off, effective leak signal on. + LivenessTracker::instance()->setLeakSignalSourcesForTest(/*generations=*/false, + /*liveness=*/true); + ASSERT_FALSE(LivenessTracker::instance()->gcGenerationsEnabled()); + ASSERT_TRUE(LivenessTracker::instance()->leakSignalEnabled()); + + int fake_object_storage = 0; + jobject obj = reinterpret_cast(&fake_object_storage); + seedGrowingCandidate(/*source_tag=*/1, /*rep=*/(jweak)obj); + ASSERT_TRUE(tracker->frontierTable()->insert( + /*tag=*/7, /*parent_tag=*/0, /*referrer_klass=*/1, /*depth=*/0, + FrontierEntryState::EDGE)); + tags[obj] = 7; + ReferenceChainsTestAccessor::setCandidateFrontierTagForTest(0, 7); + + tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); + + EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()) + << "a liveheap-only leak signal must still poll candidates"; + EXPECT_TRUE(ReferenceChainsTestAccessor::hasResolvedChainForTag(7)); + + tracker->stop(); +} + class ResolvedChainCacheTest : public ::testing::Test { protected: void SetUp() override { @@ -1165,6 +1198,8 @@ protected: // Set by UrgentOOMProjectionBypassesCandidateGate; reset here so a failed ASSERT_* there // can't leak a stale max heap into the next test. LivenessTracker::instance()->setMaxHeapBytesForTest(-1); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); } // No loaded classes to resolve - resolveLoadedClasses() reports 0 and does nothing further. @@ -1214,9 +1249,12 @@ protected: } }; -TEST_F(SearchRestartTest, WithoutGenerationsSignalRestartStaysUnconditional) { - // gc_generations off (SetUp default): canAffordNewSearch() has no candidate signal, so a - // terminal search is immediately eligible to restart. +TEST_F(SearchRestartTest, WithoutLeakSignalSourceRestartStaysIdle) { + // No leak-signal source (SetUp default: neither generations nor liveheap + // tracking): canAffordNewSearch() can never see a candidate signal, so a + // terminal search stays terminal - the tracker is idle until a signal + // source exists. Searching without a signal runs whole-heap BFS passes + // with zero candidates, which is the waste this gate exists to prevent. Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); @@ -1225,8 +1263,8 @@ TEST_F(SearchRestartTest, WithoutGenerationsSignalRestartStaysUnconditional) { ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); - EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); - EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)); + EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); tracker->stop(); } @@ -1252,6 +1290,29 @@ TEST_F(SearchRestartTest, GenerationsEnabledButNoCandidateBlocksFirstSearch) { tracker->stop(); } +// hasLeakSignal() must gate on the effective leak-signal flag, not the raw generations option: a +// liveheap-only session (generations off) with a real candidate starts its first search. +TEST_F(SearchRestartTest, LiveheapOnlySourceWithCandidateStartsFirstSearch) { + LivenessTracker::instance()->setLeakSignalSourcesForTest(/*generations=*/false, + /*liveness=*/true); + ASSERT_FALSE(LivenessTracker::instance()->gcGenerationsEnabled()); + ASSERT_TRUE(LivenessTracker::instance()->leakSignalEnabled()); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)) << "no candidate yet"; + + int fake_object_storage = 0; + seedGrowingCandidate(/*source_tag=*/1, /*rep=*/(jweak)&fake_object_storage); + + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2)) + << "a liveheap-only leak signal with a candidate must start the search"; + + tracker->stop(); +} + TEST_F(SearchRestartTest, GenerationsEnabledButNoCandidateBlocksRestart) { LivenessTracker::instance()->setGcGenerationsForTest(true); Arguments args; @@ -1333,21 +1394,28 @@ TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { tracker->stop(); } -// hasLeakSignal()'s OOM_URGENT_THRESHOLD_S fast path (referenceChains.h/.cpp): a heap-wide leak -// growing fast enough to project exhaustion sooner than the threshold must start a search -// immediately, without waiting for any klass to clear selectLeakCandidates()'s per-klass -// ring-fill/hysteresis gate. -TEST_F(SearchRestartTest, UrgentOOMProjectionBypassesCandidateGate) { +// hasLeakSignal()'s urgent latch does NOT authorize a candidate-less search: with +// nothing tagged the search can never emit a chain, and while urgent the 30-pass +// no-progress abandonment is suppressed, so it would run to the full TTL doing +// whole-heap BFS (observed in staging: a warmup heap-floor rise latched urgency +// and a candidate-less search grew a 262,144-entry frontier for the whole +// recording). A search starts only when a klass clears selectLeakCandidates(); +// urgency then applies through the ramp. The burn-in stamp is backdated so +// secondsToOOM()'s age-based burn-in does not mask the latch - this test +// isolates the candidate requirement, not the burn-in. +TEST_F(SearchRestartTest, UrgentLatchWithoutCandidateDoesNotStartSearch) { LivenessTracker::instance()->setGcGenerationsForTest(true); constexpr u64 SEC_NS = 1000000000ULL; constexpr u64 MiB = 1ULL << 20; - // Same worked example as livenessTracker_ut.cpp's - // SecondsToOOMTest.RisingFloorProjectsExpectedSeconds: 700MiB rise over 7s against a 2800MiB - // max heap projects to 10s - comfortably under OOM_URGENT_THRESHOLD_S (5 minutes). - LivenessTracker::instance()->setMaxHeapBytesForTest((jlong)(2800 * MiB)); - for (int i = 0; i < 10; i++) { + LivenessTracker::instance()->setLeakSignalActivatedNsForTest( + OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS); + // Rising floor against a 4000MiB max heap, 29 samples x 60s (the spacing + // is arbitrary): slope = 100MiB/60s, remaining = 280MiB -> projection = + // 280 * 60 / 100 = 168s - inside the urgent band. + LivenessTracker::instance()->setMaxHeapBytesForTest((jlong)(4000 * MiB)); + for (int i = 0; i < 30; i++) { LivenessTracker::instance()->heapFloorRecordForTest( - 1000 * MiB + (u64)i * 100 * MiB, (u64)i * SEC_NS); + 820 * MiB + (u64)i * 100 * MiB, (u64)i * 60 * SEC_NS); } Arguments args; @@ -1355,9 +1423,20 @@ TEST_F(SearchRestartTest, UrgentOOMProjectionBypassesCandidateGate) { ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); ASSERT_FALSE(tracker->start(args)); - EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); - EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); + // Urgency latched, but no candidate has cleared the leak-signal probe. + ASSERT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()); + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)) + << "urgent without a candidate must not start a candidate-less search"; + EXPECT_EQ(0, tracker->passesRun()); + + // Once a klass clears the probe, the search starts - and urgency applies + // through the ramp from that point on. + int fake_object_storage = 0; + seedGrowingCandidate(/*source_tag=*/1, /*rep=*/(jweak)&fake_object_storage); + EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2)); + // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. + LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); tracker->stop(); } diff --git a/ddprof-lib/src/test/cpp/referenceChains_ut.cpp b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp index ebcae873a1..84ae571497 100644 --- a/ddprof-lib/src/test/cpp/referenceChains_ut.cpp +++ b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp @@ -46,6 +46,9 @@ static ReferenceChainsGlobalSetup global_setup; [[maybe_unused]] static const bool kRcDebugLevelPinnedForTests = setenv("DD_PROFILING_REFERENCE_CHAINS_DEBUG", "2", 1) == 0; +// NOTE: the .inc files below are NOT registered as compile inputs by the gtest +// plugin (GtestTaskBuilder wires only the .cpp into `sources`), so editing an +// .inc alone leaves this binary stale - touch this file to force recompilation. #include "referenceChainsCoreTests.inc" #include "referenceChainsBfsTests.inc" #include "referenceChainsLifecycleTests.inc" @@ -57,3 +60,7 @@ static ReferenceChainsGlobalSetup global_setup; #include "referenceChainsAnchorTests.inc" #include "referenceChainsEventTests.inc" #include "referenceChainsPesTests.inc" + +// Recompilation marker: .inc files are not compile inputs (see note above). + + diff --git a/doc/ReferenceChainsCollectionSummary.md b/doc/ReferenceChainsCollectionSummary.md index e2351f29ff..d1aebfb163 100644 --- a/doc/ReferenceChainsCollectionSummary.md +++ b/doc/ReferenceChainsCollectionSummary.md @@ -6,9 +6,11 @@ Reference-chain collection helps explain why a sampled object remains live. It records a bounded chain of referrer classes from a GC root to a sampled object. It does not replace a heap dump or compute an exact retained set. -The profiler disables this feature by default. Enable generation tracking and reference-chain -collection with `generations=true,referencechains=true`. The bare `referencechains=true` option -does not activate the allocation engine that supplies candidates. +The profiler disables this feature by default. Enable generation tracking or liveheap tracking +(either supplies the leak signal) together with reference-chain collection, e.g. +`generations=true,referencechains=true`. The bare `referencechains=true` option does not activate +the allocation engine that supplies candidates, and without a signal source the collector stays +idle: it never starts a search. ## Data flow diff --git a/doc/architecture/LiveHeapReferenceChainsImplementation.md b/doc/architecture/LiveHeapReferenceChainsImplementation.md index bf382ea2a3..5e8a14e31c 100644 --- a/doc/architecture/LiveHeapReferenceChainsImplementation.md +++ b/doc/architecture/LiveHeapReferenceChainsImplementation.md @@ -200,14 +200,17 @@ These values are tuning inputs. They do not state guaranteed overhead. ## Configuration -Reference-chain collection requires the allocation engine and generation tracking. Enable them in -the same command: +Reference-chain collection requires the allocation engine and a leak-signal source. The signal +source is generations tracking OR liveheap tracking (the two drive the same liveness mechanisms; +liveheap additionally preserves stack traces for its samples). With neither, the leak-signal gate +can never pass and the collector stays idle. Enable one of them in the same command: ```text generations=true,referencechains=true:hops=N:budget=N:ttl=N:framecap=N:pausetarget=N:painbudget=N:firstpassbudget=N ``` -- `generations=true` activates the allocation engine and supplies leak candidates. +- `generations=true` activates the allocation engine and supplies leak candidates. Liveheap + tracking (`memory=b:l|L`) is an equivalent signal source. - `hops` limits chain depth. - `budget` supplies the base edge-admission budget. - `ttl` sets search lifetime in milliseconds. A value at or below zero disables this time check. diff --git a/doc/architecture/ReferenceChainsSignalsExplained.md b/doc/architecture/ReferenceChainsSignalsExplained.md index 5da376657f..56fac83a52 100644 --- a/doc/architecture/ReferenceChainsSignalsExplained.md +++ b/doc/architecture/ReferenceChainsSignalsExplained.md @@ -18,14 +18,11 @@ The GC callback does not wake the thread. The normal idle loop checks the state ## Starting a search -Before the first search, `shouldRunPass()` calls `canAffordNewSearch()`. The gate requires a leak indication when GC-generation tracking is active. The indication is one of these conditions: +Before the first search, `shouldRunPass()` calls `canAffordNewSearch()`. The gate always requires a leak indication: `selectLeakCandidates()` finds a class with sustained population growth. The safepoint cost budget must also permit the search. -- `selectLeakCandidates()` finds a class with sustained population growth. -- The time-to-OOM estimate enters the urgent range and the current urgent episode has not started a search. +Urgency does not authorize a search. A candidate-less search can never emit a chain, and while urgent the 30-pass no-progress abandonment is suppressed, so it would run to the full TTL doing whole-heap BFS. Urgency applies through the ramp once a search is running. -The safepoint cost budget must also permit the search. - -If GC-generation tracking is not active, the leak-signal part of this gate does not apply. +The population-trend signal is constructed by the liveness tracker, which fills the per-klass population table and the heap-floor ring when generations tracking OR liveheap tracking is active (the two options drive the same tracking mechanisms; liveheap additionally preserves stack traces for its samples). When neither is active, no signal can ever be produced: `hasLeakSignal()` returns false, and the collector stays idle - it never runs a pass, restart, or search. ## Scheduling a running search @@ -113,16 +110,20 @@ threshold is a hard real-time guarantee. `secondsToOOM()` independently projects exhaustion from confirmed rising JVM heap-floor and container-memory trends. It returns the shorter valid estimate. A negative result means that -neither source provides a valid estimate. +neither source provides a valid estimate, or that the projection is still in its burn-in period. Two ranges have different purposes: - At less than 30 minutes, the OOM ramp increases the pause target, shortens the cadence, increases the edge-budget ceiling, and admits all liveness samples. -- At less than 5 minutes, `isUrgent()` latches an urgent episode. The episode can authorize one search without waiting for a class to complete normal hysteresis. +- At less than 5 minutes, `isUrgent()` latches an urgent episode. The latch does not start searches; it sharpens the ramp's response while a candidate-driven search runs. + +Both ranges apply only after the burn-in period. + +For `OOM_PROJECTION_BURN_IN_NS` (5 minutes) after the leak signal first activates in the process, `secondsToOOM()` returns -1, so neither the 30-minute ramp nor the urgent latch fires. A burn-in is needed because warmup growth projects an imminent OOM just as a real leak does. The burn-in depends on how long the leak signal has been active, not on the time span of the heap-floor samples. The sampler records one sample per GC, so near OOM back-to-back GCs compress the sample history to a few seconds. Once the burn-in has passed, that compressed history no longer blocks the latch. -The urgent latch clears after five consecutive checks at 10 minutes or more, or after five consecutive checks with no valid rising-trend estimate. This hysteresis prevents repeated search restarts when the estimate moves around the threshold. +The urgent latch clears after five consecutive checks at 10 minutes or more, or after five consecutive checks with no valid rising-trend estimate. This hysteresis prevents the ramp's sharpened response from flapping when the estimate moves around the threshold. -The ramp and the latch use different thresholds. Entering the ramp does not by itself activate the urgent search entitlement. +The ramp and the latch use different thresholds. Entering the ramp does not by itself latch urgency. ## Terminal state and restart From 900efad7d0a0ddf5897d5f71d39a72d95be4d546 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 12:20:30 +0100 Subject: [PATCH 02/20] Ignore AI-scratch directories --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 61e98e96a3..543983a2d6 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,7 @@ CLAUDE.md # AI review/agent tooling scratch state .sphinx/ +/.scratch/ .skill-builder-temp/ .claude/scheduled_tasks.lock @@ -64,3 +65,4 @@ CLAUDE.md # AI investigation scratch state (never committed) .investigations/ +/notes/ From 87f483924cf644948417a8b3a9d8646d59c83524 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 12:41:13 +0100 Subject: [PATCH 03/20] Update Java reference-chain tests for candidate-only admission AggressiveLeakReferenceChainTest asserted that the BFS thread runs passes from the urgent-OOM projection alone with zero per-klass candidates - the behavior the gate fix removed. It now asserts the opposite: with the leak signal armed and the projection deep in the urgent band, the gate stays closed (live-thread idle window plus a race-free direct shouldRunPass() evaluation - the removed one-shot entitlement was what made the direct call order-dependent). ReferenceChainTrackingTest.shouldReportAbandonedSearchOnTinyFrontierCap qualifies its candidate only through the debug-only population seams; in a release build no candidate exists and the search now (correctly) stays idle, so the method assumes a debug build. The profiler-command comment still described the removed hasLeakSignal() short-circuit; updated. --- .../AggressiveLeakReferenceChainTest.java | 103 ++++++++++-------- .../ReferenceChainTrackingTest.java | 15 ++- 2 files changed, 70 insertions(+), 48 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java index 187a715777..31d07fd94e 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -15,13 +15,19 @@ import static org.junit.jupiter.api.Assumptions.assumeTrue; /** - * E2E coverage for {@code LivenessTracker::secondsToOOM()}'s urgent-OOM bypass + * E2E coverage for the search-restart gate the reference-chain collector's pass loop polls * ({@code ReferenceChainTracker::hasLeakSignal()}, referenceChains.cpp). Complements * {@link ReferenceChainTrackingTest}'s organic coverage of a slow leak - a gradually-growing * population that clears {@code selectLeakCandidates()}'s per-klass hysteresis gate over many - * rounds - with the opposite, previously-unexercised case: a heap-wide leak growing fast enough - * that a real deployment would OOM before any single klass's own population history ever clears - * that same hysteresis gate. + * rounds - with the aggregate heap-floor case: a heap-wide growth fast enough that a real + * deployment would OOM before any single klass's own population history ever clears that same + * hysteresis gate. + * + *

This class is a regression guard for the candidate-gate contract: the aggregate urgent-OOM + * projection alone must NEVER open the gate while the per-klass population table is provably + * empty. Staging recordings showed the pre-fix behavior - the urgent latch entitled a + * candidate-less search that cannot exit early and ran whole-heap BFS passes to the auto-tuned + * TTL with zero chains to show for it. * *

Reproducing a genuine near-OOM heap in this shared, no-{@code forkEvery} test JVM is * impractical (see {@link ReferenceChainTrackingTest}'s own header comment on the JVM this class @@ -31,20 +37,20 @@ * directly, so {@code secondsToOOM()}'s projection can be driven under * {@code OOM_URGENT_THRESHOLD_S} (referenceChains.h) deterministically, decoupled from this JVM's * real {@code -Xmx} and from real GC timing. {@code shouldRunPassForTest0()} then evaluates - * {@code ReferenceChainTracker::shouldRunPass()} - the search-restart gate {@code hasLeakSignal()} - * feeds - directly, rather than running a real pass, so this class never depends on + * {@code ReferenceChainTracker::shouldRunPass()} - the gate {@code hasLeakSignal()} feeds - + * directly, rather than running a real pass, so this class never depends on * {@code selectLeakCandidates()} ranking anything, a real JVMTI heap walk, or a JFR event. */ public class AggressiveLeakReferenceChainTest extends AbstractProfilerTest { @Override protected String getProfilerCommand() { - // generations=true: gates LivenessTracker::gcGenerationsEnabled() - hasLeakSignal() (which - // shouldRunPassForTest0() ultimately reads) short-circuits to true immediately when this is - // unset (its own first check, referenceChains.cpp), which would make every test below - // trivially pass regardless of the urgent-OOM bypass this class exists to exercise. - // referencechains=true: constructs the FrontierTable/ReferenceChainTracker singleton at all - - // shouldRunPassForTest0() has nothing to read otherwise. + // generations=true: arms LivenessTracker's effective leak signal (generations OR liveheap + // tracking drive the same population mechanisms) - without it hasLeakSignal() returns false + // trivially, which would make every test below pass regardless of the candidate-gate + // contract this class exists to exercise. referencechains=true: constructs the + // FrontierTable/ReferenceChainTracker singleton at all - shouldRunPassForTest0() has + // nothing to read otherwise. return "generations=true,referencechains=true:hops=32:budget=500:ttl=60000:framecap=2000000"; } @@ -67,10 +73,9 @@ private static void assumeDebugBuild() { * (head/fill - {@code klassPopulationResetForTest()}, livenessTracker.h), so * {@code secondsToOOM()} reports "insufficient fill" until real GC samples build up again; it * runs while heap-floor recording is still disabled so no real sample can interleave. - * {@code resetReferenceChainSearchForTest0()} then clears any urgency latch and spent one-shot - * entitlement ({@code _urgent_latched}/{@code _urgent_search_spent}) plus the search state the - * urgent bypass started - after the ring reset, so the BFS thread cannot re-latch from the - * stale projection. Recording is re-enabled last. + * {@code resetReferenceChainSearchForTest0()} then clears any urgency latch plus the search + * state an earlier evaluation started - after the ring reset, so the BFS thread cannot + * re-latch from the stale projection. Recording is re-enabled last. */ private static void restoreHeapFloorDefaults() { ReferenceChainTestHooks.setMaxHeapBytesForTest0(-1); @@ -83,30 +88,25 @@ private static void restoreHeapFloorDefaults() { * Seeds ten heap-floor-ring samples rising fast enough that {@code secondsToOOM()}'s projection * lands well under {@code OOM_URGENT_THRESHOLD_S} (300s), with LivenessTracker's per-klass * population table left empty throughout - {@code selectLeakCandidateKlassIds0()} returns - * nothing at any point in this test. Then waits for the BFS thread to actually run passes, - * proving the search-restart gate opened from the urgent-OOM projection alone. + * nothing at any point in this test. Then asserts the search-restart gate stays closed on the + * live BFS thread: the aggregate urgent-OOM projection must never start a candidate-less + * search. * - *

The gate is asserted through the live BFS thread rather than by calling - * {@code shouldRunPassForTest0()} directly: opening the gate spends the episode's one-shot - * urgency entitlement ({@code _urgent_search_spent}) on the FIRST evaluation, and the BFS - * thread - whose cadence ramps down to ~10ms once urgency latches - races the test's own - * call for that first evaluation. A direct call therefore only returns true when it wins - * that race, which made this test flaky by construction. A pass only ever runs after - * {@code shouldRunPass()} returned true, and with {@code generations=true} plus a provably - * empty per-klass population table the urgent-OOM projection is the only possible trigger - - * so "passes ran while zero candidates existed" is the same assertion, without the race. + *

The gate is asserted both through the live BFS thread (no pass may run within the wait + * window) and through a direct {@code shouldRunPassForTest0()} call after a search reset. The + * direct call is race-free under the candidate-gate contract - the old one-shot urgency + * entitlement ({@code _urgent_search_spent}) that made a direct call lose a race against the + * BFS thread was removed together with the bypass itself, and the gate's answer no longer + * depends on evaluation order. */ @Test - public void shouldOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() + public void shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() throws InterruptedException { assumeDebugBuild(); ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); ReferenceChainTestHooks.resetKlassPopulationForTest0(); ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); try { - // Baseline BEFORE publishing any urgency input: the live BFS thread may run the one - // urgency-authorized search as soon as the samples below land, and that pass must count. - int passesBefore = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); // 1000 MiB fake max heap, rising from 100 MiB to 991 MiB over 9 (fake) seconds: ~99 MiB/s, // 9 MiB of headroom left at the last sample -> projected time-to-OOM ~= 9/99 s, far under // OOM_URGENT_THRESHOLD_S = 300s. @@ -116,27 +116,40 @@ public void shouldOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() long timestampNs = i * 1_000_000_000L; ReferenceChainTestHooks.heapFloorRecordForTest0(usedBytes, timestampNs); } + assertTrue(ReferenceChainTestHooks.heapFloorRisingForTest0(), + "Precondition: the seeded samples must read as a sustained rise, so the projection " + + "below is a genuine urgent-band one - the gate must stay closed for the " + + "candidate requirement, not because the projection is unknown"); int[] candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); assertTrue(candidates == null || candidates.length == 0, - "This test's own precondition: no per-klass candidate should exist, so passes running " - + "below can only come from the aggregate urgent-OOM bypass"); - - // Idle BFS cadence is ~1s/pass; urgency ramps it to ~10ms after the latch. 20s covers the - // first evaluation landing up to one idle cadence after the seeding loop. - long deadline = System.currentTimeMillis() + 20_000; - while (ReferenceChainTestHooks.referenceChainPassesRunForTest0() == passesBefore - && System.currentTimeMillis() < deadline) { + "This test's own precondition: no per-klass candidate should exist, so a pass running " + + "below could only come from the aggregate urgent-OOM projection"); + + // Idle BFS cadence is ~1s/pass. No pass may run: with zero per-klass candidates the gate + // has no indication, and urgency alone must not supply one. + int passesBefore = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + long deadline = System.currentTimeMillis() + 5_000; + while (System.currentTimeMillis() < deadline) { + assertTrue(ReferenceChainTestHooks.referenceChainPassesRunForTest0() == passesBefore, + "The BFS thread must stay idle: an urgent-OOM projection with zero per-klass leak " + + "candidates must not start a search"); Thread.sleep(50); } - assertTrue(ReferenceChainTestHooks.referenceChainPassesRunForTest0() > passesBefore, - "Expected the BFS thread to run passes from the urgent heap-wide OOM projection alone, " - + "with zero per-klass leak candidate"); + + // Same assertion through the predicate itself, on a freshly reset search so any mutation + // the live thread's evaluations made lands on clean state (see the flat-floor control + // below for why the reset must not touch the population). This direct call is race-free: + // the gate's answer no longer depends on evaluation order. + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + assertFalse(ReferenceChainTestHooks.shouldRunPassForTest0(), + "Expected the search-restart gate to stay closed: the urgent-OOM projection is not a " + + "leak indication - only selectLeakCandidates() is"); candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); assertTrue(candidates == null || candidates.length == 0, - "The passes that just ran must still have had zero per-klass candidates - the " - + "aggregate urgent-OOM projection was the only possible trigger"); + "The idle window must have kept the per-klass population table empty - the " + + "aggregate urgent-OOM projection was the only signal this test seeded"); } finally { restoreHeapFloorDefaults(); } @@ -166,7 +179,7 @@ public void shouldNotOpenSearchGateOnFlatHeapFloorWithNoLeakCandidate() { // shouldRunPassForTest0() is NOT a pure predicate (see JavaProfiler's own javadoc): under the // native engine lock it may account a finished search's pain, restart terminal search state, - // or spend the urgent one-shot entitlement. Reset the search right before it so any mutation + // or advance urgency bookkeeping. Reset the search right before it so any mutation // lands on a fresh tracker (_search_started == false) instead of whatever search an earlier // class in this shared JVM left behind. Only the search is reset here, not the population: // resetKlassPopulationForTest0() also zeroes the heap-floor ring seeded above, which would diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index 28de9d6789..e4f3ce717c 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -31,6 +31,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; /** * PROF-15341 (+ lifecycle-wiring follow-up): end-to-end coverage for @@ -47,7 +48,8 @@ *

Target-selection bridging: {@code pollWatchedTargets()} (referenceChains.cpp), called * from {@code threadLoop()} once per scheduling cycle after {@code runPass()}, polls * {@code LivenessTracker::selectLeakCandidates()} (positive population-slope ranking over a - * rolling per-klass survivor-count window, gated on {@code _gc_generations}) and, for each ranked + * rolling per-klass survivor-count window, gated on the effective leak signal - generations OR + * liveheap tracking) and, for each ranked * klass whose representative instance an ordinary {@code runPass()} walk has *already* tagged * ({@code getTag() > 0} - a read, never a {@code SetTag} seed), reconstructs and emits its chain * via {@code Profiler::writeReferenceChain()}. {@link #shouldReconstructReferrerChainToGcRoot()} @@ -194,8 +196,11 @@ protected String getProfilerCommand() { if ("shouldReportAbandonedSearchOnTinyFrontierCap".equals(testName)) { // memory=64:l:1.0 is load-bearing: Profiler::start() only starts the ReferenceChainTracker // when allocation sampling activated (activated & EM_ALLOC); a bare referencechains=... falls - // back to cpu-only and never runs the search. No generations=true: LivenessTracker::initialize() - // then clears _gc_generations, so hasLeakSignal() returns true without any population trend. + // back to cpu-only and never runs the search. No generations=true: the effective leak signal + // still arms from liveheap tracking alone, but hasLeakSignal() requires a per-klass + // candidate on top of it - which this test seeds through the debug-only seams (see the + // method's own javadoc). Release builds cannot seed a candidate deterministically, so the + // method assumes a debug build. return "memory=64:l:1.0,referencechains=true:hops=32:budget=500:framecap=1:ttl=100:painbudget=100"; } // shouldExposeReferenceChainsSettingWhenEnabled: a referencechains-only command. Arguments::parse() @@ -702,6 +707,10 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc @Test @Order(3) public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { + assumeTrue(ReferenceChainAssertions.isDebugInstrumentedBuild(), + "The qualifying-candidate seeding below rides the debug-only population seams; in a " + + "release build the search stays idle without a candidate (the candidate-gate " + + "contract), so no abandonment event could ever be produced"); if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { // See the javadoc above for why the seeding is needed despite no generations=true of this // method's own, and why both a gc() and a throwaway dump() must precede it. From 6cffe716da16822acb7d8fe02e05f40ea0a12c2d Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 14:14:01 +0100 Subject: [PATCH 04/20] Gate urgent liveness admission on fresh heap growth evidence The OOM ramp's 100% tracking admission followed the raw projection-band comparison, but the smoothed 30-sample regression keeps the projection inside the band for several recovery GCs after the heap has physically stopped growing. Outside the emergency band (<5min) the boost now requires the newest heap-floor sample to exceed the value observed at episode entry; a recovered spike never satisfies that. Emergency-band admission stays immediate, and the ramp's budget/cadence effects are unchanged. Also corrects two stale claims about the urgency latch: the ramp calculation never consults it (its only consumer is no-progress abandonment suppression), and the budget boost follows the raw band, not the latch. --- ddprof-lib/src/main/cpp/livenessTracker.cpp | 15 +++ ddprof-lib/src/main/cpp/livenessTracker.h | 15 ++- ddprof-lib/src/main/cpp/referenceChains.cpp | 53 +++++++-- ddprof-lib/src/main/cpp/referenceChains.h | 25 ++++ .../src/test/cpp/referenceChainsOomTests.inc | 111 ++++++++++++++++++ .../test/cpp/referenceChainsTestAccessors.h | 7 ++ .../ReferenceChainsSignalsExplained.md | 6 +- 7 files changed, 219 insertions(+), 13 deletions(-) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index 1b12ee7bb7..58044e8d71 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -1477,6 +1477,21 @@ void LivenessTracker::recordTidTrendSamplesLocked( } } +u64 LivenessTracker::newestHeapFloorSample() const { + // Matches recordHeapFloorSampleUnchecked()'s storeRelease() on both index + // fields - loadAcquire() here is what makes the payload read below visible. + u8 fill = loadAcquire(_heap_floor_ring_fill); + u8 head = loadAcquire(_heap_floor_ring_head); + if (fill == 0) { + return 0; + } + // head points one past the newest slot (recordHeapFloorSampleUnchecked() + // stores then advances) - step back one, wrapping over the ring size. + u8 newest = (u8)((head + KLASS_POPULATION_RING_SIZE - 1) % + KLASS_POPULATION_RING_SIZE); + return load(_heap_floor_ring[newest]); +} + void LivenessTracker::recordHeapFloorSample(u64 used, u64 timestamp_ns, u64 container_used) { TEST_LOG("LivenessTracker::recordHeapFloorSample called used=%llu disabled=%d", (unsigned long long)used, diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 6185348355..15c5663667 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -862,6 +862,14 @@ class alignas(alignof(SpinLock)) LivenessTracker { // and secondsToOOM(). See _max_heap_bytes's own comment above. jlong maxHeapBytes() const { return _max_heap_bytes; } + // Reads the used-bytes value of the newest _heap_floor_ring sample (0 when + // the ring is empty). Matches recordHeapFloorSampleUnchecked()'s SPSC + // publish order - loadAcquire() on the index fields is what makes the + // payload store visible. Consumed by ReferenceChainTracker's urgent-admission + // gate as the freshest physical growth evidence; it must observe the same + // samples the projection does, hence the same acquire pairing. + u64 newestHeapFloorSample() const; + constexpr static int MAX_LEAK_CANDIDATES = 5; // Delete copy constructor and assignment operator to prevent copies LivenessTracker(const LivenessTracker&) = delete; @@ -943,8 +951,11 @@ class alignas(alignof(SpinLock)) LivenessTracker { void noteSelectedCandidates(const KlassCandidate *candidates, int count); // Urgency raise toggle - see admitForTracking()'s comment above. Set every - // threadLoop() iteration with that iteration's urgency computation, so the - // boost tracks the ramp exactly (both engaging and releasing). + // threadLoop() iteration with that iteration's urgency computation, except + // that outside the emergency band (< OOM_URGENT_THRESHOLD_S) the boost + // additionally requires fresh heap growth evidence - see + // ReferenceChainTracker::updateUrgentAdmission() for why the raw ramp + // comparison alone is not safe to admit on. void setUrgentTracking(bool urgent); void flush(std::set &tracked_thread_ids); diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 4d4ea42edb..9d120a45af 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -573,12 +573,16 @@ void ReferenceChainTracker::threadLoop() { DEBUG_ONLY(rcDebugLevelRefresh()); // Hand this iteration's ramp state to shouldRunPass() before it decides - the canary-backoff - // gate is bypassed while the OOM urgency ramp is active (see _oom_ramp_active's own comment) - - // and raise LivenessTracker's tracking admission to 100% for the same ramp (see - // setUrgentTracking()'s own comment, livenessTracker.h): same state, same iteration, so the - // boost tracks the ramp exactly, engaging and releasing together. + // gate is bypassed while the OOM urgency ramp is active (see _oom_ramp_active's own comment). + // The 100% tracking admission (setUrgentTracking(), livenessTracker.h) follows the same ramp + // state, but outside the emergency band it is additionally gated on fresh heap growth evidence + // (updateUrgentAdmission()/the _admission_growth_baseline constraint): the smoothed projection + // can stay inside the band for many GCs after the heap has physically stopped growing, and + // boosting admission on a recovered spike only buys table churn. The budget/cadence ramp above + // stays keyed on the raw band comparison - without a running search (the candidate gate) those + // effects cost nothing, while mis-admission during a transient does. _oom_ramp_active = urgent; - LivenessTracker::instance()->setUrgentTracking(urgent); + LivenessTracker::instance()->setUrgentTracking(updateUrgentAdmission(seconds_to_oom, urgent)); // shouldRunPass()'s restart branch (restartSearch()) clears _pending_expand/_priority_expand // the same way a pass does - take _engine_lock around the call so that mutation is serialized @@ -808,6 +812,37 @@ bool ReferenceChainTracker::isUrgent() const { return false; } +bool ReferenceChainTracker::updateUrgentAdmission(double seconds_to_oom, bool urgent) { + if (!urgent) { + // Left set after an episode so the next episode re-snapshots against that + // episode's own entry state instead of inheriting a stale baseline. + _admission_growth_baseline = 0; + return false; + } + // Emergency band: the projection says minutes at most - capture what the + // last chapter can before the process dies, without waiting for growth + // evidence. OOM_URGENT_THRESHOLD_S is isUrgent()'s own latch threshold, so + // the emergency band and the hysteretic urgent episode cover the same range. + if (seconds_to_oom < OOM_URGENT_THRESHOLD_S) { + return true; + } + u64 newest_used = LivenessTracker::instance()->newestHeapFloorSample(); + if (newest_used == 0) { + // No sample to reason about (the ring cannot be empty while a projection + // exists - secondsToOOM() requires MIN_FILL samples - so this is purely + // defensive). Hold admission rather than admit on nothing. + return false; + } + if (_admission_growth_baseline == 0) { + // First observation of the episode: establish the entry baseline. Admit + // only once a newer sample has grown past it - one GC away for a real + // leak, never for a heap that has stopped growing. + _admission_growth_baseline = newest_used; + return false; + } + return newest_used > _admission_growth_baseline; +} + bool ReferenceChainTracker::hasLeakSignal() { if (!LivenessTracker::instance()->leakSignalEnabled()) { // No population-trend signal can be constructed - generations tracking @@ -823,9 +858,11 @@ bool ReferenceChainTracker::hasLeakSignal() { } double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); // isUrgent() is called unconditionally, not short-circuited behind any - // branch: it maintains the latch/release state (see _urgent_latched), which - // still drives the urgency ramp and the no-progress-abandonment suppression - // once a search runs. + // branch: it maintains the latch/release state (see _urgent_latched), whose + // only consumer is the no-progress-abandonment suppression once a search + // runs (runPass()'s terminal gate). The OOM ramp's budget/cadence/admission + // effects in threadLoop() follow the raw band comparison instead - they do + // not wait for this latch. bool urgent = isUrgent(); KlassCandidate probe[1]; int n = LivenessTracker::instance()->selectLeakCandidates(probe, 1); diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index 5b3615c993..f09ef2fd34 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -344,6 +344,19 @@ class ReferenceChainTracker { // below for how it is read. volatile u8 _abandon_reason; + // CONSTRAINT (urgent-admission growth gate): the heap-floor projection is a + // least-squares regression over a 30-sample window, so a single spike sample + // keeps the projection inside the ramp band for ~6-7 recovery GCs AFTER the + // heap has physically stopped growing - band residence alone does not + // establish growth. updateUrgentAdmission() therefore snapshots the newest + // heap-floor sample's used bytes when a non-emergency ramp episode begins + // and admits the 100% tracking boost only while a newer sample has grown + // past that snapshot; a recovered spike never does. Written and read only + // from threadLoop() on the BFS thread - no atomics. 0 = no baseline for the + // current episode (a used value of 0 cannot be a real sample: onGC() only + // records used > 0). + u64 _admission_growth_baseline; + // Wall-clock timestamp (OS::nanotime()) of the search's first pass - baseline for the TTL cutoff // above. volatile u64 _search_start_ns; @@ -739,6 +752,7 @@ class ReferenceChainTracker { _tags_released(true), _urgent_latched(false), _urgent_release_ticks(0), _urgency_budget_boosted(false), _configured_budget(0), + _admission_growth_baseline(0), _search_state(SearchState::RUNNING), _abandon_reason(SearchAbandonReason::NONE), _search_start_ns(0), _last_pass_gc_finish_epoch(0), _last_pass_ns(0), @@ -775,6 +789,14 @@ class ReferenceChainTracker { // see _urgent_latched for the latch/release rules and why the raw comparison flaps. bool isUrgent() const; + // Computes the 100% tracking-admission decision for one threadLoop() + // iteration and maintains _admission_growth_baseline. Raw `urgent` still + // drives the budget/cadence ramp; admission alone is additionally gated on + // heap growth evidence outside the emergency band - see + // _admission_growth_baseline's comment for why band residence is not + // sufficient, and OOM_URGENT_THRESHOLD_S for the emergency split. + bool updateUrgentAdmission(double seconds_to_oom, bool urgent); + // Search restart gate: true once _safepoint_pain_budget has // drained back to zero (canStartNow()) *and* hasLeakSignal() above reports at least one leak // candidate. @@ -1152,6 +1174,9 @@ class ReferenceChainTracker { void resetUrgencyForTest() { _urgent_latched = false; _urgent_release_ticks = 0; + // updateUrgentAdmission()'s episode baseline - a stale one across tests + // would make an episode's "first observation" branch unreachable. + _admission_growth_baseline = 0; } // Applied vs configured CPU pain-budget refill rate: shouldRunPass() raises // the applied rate CANARY_PAIN_BUDGET_REFILL_MULTIPLIER-fold only while a diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index 2b90c80739..fd2ddebde5 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -259,6 +259,117 @@ TEST_F(OomUrgencyTest, BurnInProjectionCountsTowardRelease) { } // --------------------------------------------------------------------------- +// --------------------------------------------------------------------------- +// Urgent-admission growth gate (updateUrgentAdmission()): inside the OOM ramp +// band, the 100% tracking boost additionally requires fresh heap growth +// evidence outside the emergency band (< OOM_URGENT_THRESHOLD_S). The +// projection is a least-squares regression over a 30-sample window, so a +// single spike sample keeps it inside the band for several recovery GCs after +// the heap has physically stopped growing - admitting on band residence alone +// would buy tracker-table churn for a recovered spike. threadLoop() computes +// the projection once per iteration and hands it in, so these scenarios pass +// the projection the same way and drive the ring directly through +// heapFloorRecordForTest(). +// --------------------------------------------------------------------------- + +// A non-emergency in-band projection admits only once the newest heap-floor +// sample has grown past the episode's entry snapshot - one GC later for a +// real leak - and stops admitting as soon as a newer sample falls back below +// it, even while the projection still reads in-band. +TEST_F(OomUrgencyTest, AdmissionBoostRequiresGrowthPastEntryBaseline) { + // Seeds a ramp projecting 1500s: inside the ramp band (1800s), well + // outside the emergency band. The ramp's last sample is the peak. + ASSERT_NEAR(project(1500.0), 1500.0, 1.0); + u64 peak = MAX_HEAP - (u64)(1500.0 * (double)RATE / (double)SAMPLE_SPACING_S); + + // First in-band observation establishes the baseline; no growth evidence + // relative to it yet, so no boost. + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + + // One more sample above the snapshot: real growth -> boost. + LivenessTracker::instance()->heapFloorRecordForTest(peak + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1490.0, true)); + + // A newer sample back below the entry snapshot: growth evidence gone, the + // boost must drop even though the (passed-in) projection still reads deep + // inside the band. + LivenessTracker::instance()->heapFloorRecordForTest(peak - RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1490.0, true)); +} + +// The regression-smoothing counterexample: a single spike sample keeps the +// smoothed projection inside the band for several recovery GCs after the heap +// has physically recovered. The scenario proves both halves: the projection +// genuinely keeps firing (asserted via secondsToOOM()) while the boost stays +// off for every recovery observation, because no sample grows past the +// entry snapshot (the spike's peak). +TEST_F(OomUrgencyTest, RecoveredSpikeStaysUnboostedWhileProjectionLags) { + ASSERT_NEAR(project(700.0), 700.0, 1.0); + u64 peak = MAX_HEAP - (u64)(700.0 * (double)RATE / (double)SAMPLE_SPACING_S); + + // Entry snapshot = the spike's peak. + ASSERT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(700.0, true)); + + // Recovery: each GC lands one RATE below the previous. The recent-half + // corroboration keeps the projection positive through these (the spike + // dominates the newest-15 regression until most of the recent half is + // recovery samples); the lagging projection must not admit any of them. + for (int r = 1; r <= 6; r++) { + LivenessTracker::instance()->heapFloorRecordForTest( + peak - (u64)r * RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + double seconds = LivenessTracker::instance()->secondsToOOM(); + ASSERT_GE(seconds, 0.0) + << "recovery sample " << r + << ": scenario requires the smoothed projection to lag the recovery"; + ASSERT_LT(seconds, 1800.0) + << "recovery sample " << r + << ": scenario requires the projection to stay inside the ramp band"; + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(seconds, true)) + << "recovery sample " << r << " must not be admitted on a lagging projection"; + } +} + +// The emergency band (< OOM_URGENT_THRESHOLD_S) waives the growth-evidence +// hold: minutes from death means capturing whatever the sampler offers, +// including the very first observation and a heap whose newest sample is +// below the entry snapshot. +TEST_F(OomUrgencyTest, EmergencyBandAdmitsWithoutGrowthEvidence) { + ASSERT_NEAR(project(299.0), 299.0, 1.0); + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(299.0, true)); + + // A declining heap does not flip the boost off inside the band. + u64 newest = MAX_HEAP - (u64)(299.0 * (double)RATE / (double)SAMPLE_SPACING_S); + LivenessTracker::instance()->heapFloorRecordForTest(newest - RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(299.0, true)); +} + +// Leaving the band clears the entry snapshot: the next episode re-snapshots +// against its own entry state instead of inheriting the previous episode's +// baseline (which could admit on no growth at all if the heap is now above +// the old snapshot for unrelated reasons). +TEST_F(OomUrgencyTest, BandExitResetsGrowthBaseline) { + ASSERT_NEAR(project(1500.0), 1500.0, 1.0); + u64 peak = MAX_HEAP - (u64)(1500.0 * (double)RATE / (double)SAMPLE_SPACING_S); + ASSERT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + LivenessTracker::instance()->heapFloorRecordForTest(peak + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + ASSERT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + + // An out-of-band observation clears the baseline. + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(-1.0, false)); + + // New episode: the first observation re-snapshots (no inherited growth), + // then one growing sample admits again. + ASSERT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + LivenessTracker::instance()->heapFloorRecordForTest(peak + 2 * RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); +} + // The canary-refill half: shouldRunPass() raises the CPU pain budget's // refill rate CANARY_PAIN_BUDGET_REFILL_MULTIPLIER-fold exactly while a // canary chase is open (candidates exist and not all are found), and puts it diff --git a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h index 19c93ad761..8d5d59ef4e 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h +++ b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h @@ -173,6 +173,13 @@ class ReferenceChainsTestAccessor { static void resetUrgencyForTest() { ReferenceChainTracker::instance()->resetUrgencyForTest(); } + // Urgent-admission growth gate: updateUrgentAdmission()'s per-iteration + // decision (referenceChains.cpp) - the admission half of threadLoop()'s + // ramp handling, testable without the BFS thread. + static bool updateUrgentAdmissionForTest(double seconds_to_oom, bool urgent) { + return ReferenceChainTracker::instance()->updateUrgentAdmission(seconds_to_oom, + urgent); + } // Applied vs configured CPU pain-budget refill rate (shouldRunPass()'s // canary-refill raise). static double cpuPainBudgetRefillRateForTest() { diff --git a/doc/architecture/ReferenceChainsSignalsExplained.md b/doc/architecture/ReferenceChainsSignalsExplained.md index 56fac83a52..c5a95f1347 100644 --- a/doc/architecture/ReferenceChainsSignalsExplained.md +++ b/doc/architecture/ReferenceChainsSignalsExplained.md @@ -114,14 +114,14 @@ neither source provides a valid estimate, or that the projection is still in its Two ranges have different purposes: -- At less than 30 minutes, the OOM ramp increases the pause target, shortens the cadence, increases the edge-budget ceiling, and admits all liveness samples. -- At less than 5 minutes, `isUrgent()` latches an urgent episode. The latch does not start searches; it sharpens the ramp's response while a candidate-driven search runs. +- At less than 30 minutes, the OOM ramp increases the pause target, shortens the cadence, and increases the edge-budget ceiling. The 100% liveness-sampling admission follows the same band, but outside the emergency band it additionally requires fresh growth evidence: the newest heap-floor sample must exceed the value observed when the ramp episode began. The projection is a smoothed regression over a 30-sample window, so a single spike sample keeps the projection inside the band for several recovery GCs after the heap has physically stopped growing. Growth evidence is what keeps the admission boost off for such a recovered spike. Inside the emergency band (less than 5 minutes), admission is immediate. +- At less than 5 minutes, `isUrgent()` latches an urgent episode. The latch does not start searches, and the ramp calculation does not consult it. Its only consumer is the no-progress abandonment of a running search, which it suppresses while the latch is held. Both ranges apply only after the burn-in period. For `OOM_PROJECTION_BURN_IN_NS` (5 minutes) after the leak signal first activates in the process, `secondsToOOM()` returns -1, so neither the 30-minute ramp nor the urgent latch fires. A burn-in is needed because warmup growth projects an imminent OOM just as a real leak does. The burn-in depends on how long the leak signal has been active, not on the time span of the heap-floor samples. The sampler records one sample per GC, so near OOM back-to-back GCs compress the sample history to a few seconds. Once the burn-in has passed, that compressed history no longer blocks the latch. -The urgent latch clears after five consecutive checks at 10 minutes or more, or after five consecutive checks with no valid rising-trend estimate. This hysteresis prevents the ramp's sharpened response from flapping when the estimate moves around the threshold. +The urgent latch clears after five consecutive checks at 10 minutes or more, or after five consecutive checks with no valid rising-trend estimate. This hysteresis keeps the urgent episode itself from flapping when the estimate moves around the threshold; the admission boost, which releases on the raw band comparison, does not follow this hysteresis. The ramp and the latch use different thresholds. Entering the ramp does not by itself latch urgency. From cbbaff486ee703bb1700298b6cb6fd4a8f0b6b29 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 17:08:18 +0100 Subject: [PATCH 05/20] Clear admission growth baseline at recording boundary; guard test seams stop() and resetSearchStateForTest() now clear _admission_growth_baseline alongside the urgency latch: a stale baseline would let the next episode's first urgent observation skip updateUrgentAdmission()'s entry re-snapshot and admit against the previous recording's heap level. hasLeakSignal() increments reference_chain_urgent_no_candidate when urgency is latched but no klass explains the growth, so the candidate-only gate's refused evaluations are visible in diagnostics instead of silent. The leak-signal test mutators (setLeakSignalSourcesForTest, setLeakSignalActivatedNsForTest) move behind DEBUG/UNIT_TEST with JNI seams (backdateLeakSignalActivationForTest0 and companions) so no release consumer can flip the gate or backdate the burn-in. Stale comments on OOM_URGENT_THRESHOLD_S, gcGenerationsEnabled() and secondsToOOM() corrected to match the candidate-only gate. --- ddprof-lib/src/main/cpp/counters.h | 6 ++ ddprof-lib/src/main/cpp/javaApi.cpp | 64 +++++++++++++++++++ ddprof-lib/src/main/cpp/livenessTracker.h | 42 ++++++++---- ddprof-lib/src/main/cpp/referenceChains.cpp | 18 +++++- ddprof-lib/src/main/cpp/referenceChains.h | 22 ++++--- .../com/datadoghq/profiler/JavaProfiler.java | 49 +++++++++++--- 6 files changed, 166 insertions(+), 35 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index ad58859eb3..c431285097 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -290,6 +290,12 @@ 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") \ + /* hasLeakSignal() gate evaluations refused while the OOM urgency latch is \ + * set and selectLeakCandidates() found zero candidates - a heap-wide leak \ + * no single klass explains, so no reference-chain search may start. \ + * Counts evaluations (about 1/s while latched and idle), not urgency \ + * episodes: read it as a rate. */ \ + X(REFERENCE_CHAIN_URGENT_NO_CANDIDATE, "reference_chain_urgent_no_candidate") \ /* admitStaticFieldRoots() per-class non-static quota: non-STATIC_FIELD \ * edges (CONSTANT_POOL, INTERFACE, SUPERCLASS, CLASS_LOADER, ...) that \ * were dropped because the class already hit \ diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 59ab1ec452..a689a23f30 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -1404,6 +1404,48 @@ Java_com_datadoghq_profiler_JavaProfiler_referenceChainPassesRunForTest0( return (jint)ReferenceChainTracker::instance()->passesRun(); } +// Moves the leak-signal burn-in stamp one second past OOM_PROJECTION_BURN_IN_NS so secondsToOOM() +// projects from seeded samples however long this JVM has been profiling. Returns the previous stamp +// (0 = never activated) so setLeakSignalActivatedNsForTest0() can restore it. +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_backdateLeakSignalActivationForTest0( + JNIEnv *env, jclass unused) { + ProfiledThread::initCurrentThreadSignalSafe(); + LivenessTracker *lt = LivenessTracker::instance(); + u64 previous = lt->leakSignalActivatedNsForTest(); + // One second past the burn-in (modular u64), mirroring the gtest fixtures' backdated stamp. + lt->setLeakSignalActivatedNsForTest(OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - + 1000000000ULL); + return (jlong)previous; +} + +// Restores a burn-in stamp saved by backdateLeakSignalActivationForTest0(). +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_setLeakSignalActivatedNsForTest0( + JNIEnv *env, jclass unused, jlong activatedNs) { + ProfiledThread::initCurrentThreadSignalSafe(); + LivenessTracker::instance()->setLeakSignalActivatedNsForTest((u64)activatedNs); +} + +// The current LivenessTracker::secondsToOOM() projection; negative means no projection. +extern "C" DLLEXPORT jdouble JNICALL +Java_com_datadoghq_profiler_JavaProfiler_secondsToOOMForTest0(JNIEnv *env, + jclass unused) { + ProfiledThread::initCurrentThreadSignalSafe(); + return (jdouble)LivenessTracker::instance()->secondsToOOM(); +} + +// isUrgent()'s latch. It is mutated under _engine_lock (threadLoop()/shouldRunPassForTest()), so +// read it under the same lock. +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_urgentLatchedForTest0(JNIEnv *env, + jclass unused) { + ProfiledThread::initCurrentThreadSignalSafe(); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + MutexLocker engine_guard(tracker->_engine_lock); + return tracker->urgentLatchedForTest() ? JNI_TRUE : JNI_FALSE; +} + #else // !DEBUG // Release builds: no-op stubs so the Java natives still link. @@ -1520,6 +1562,28 @@ Java_com_datadoghq_profiler_JavaProfiler_referenceChainPassesRunForTest0( return 0; } +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_backdateLeakSignalActivationForTest0( + JNIEnv *env, jclass unused) { + return 0; +} + +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_setLeakSignalActivatedNsForTest0( + JNIEnv *env, jclass unused, jlong activatedNs) {} + +extern "C" DLLEXPORT jdouble JNICALL +Java_com_datadoghq_profiler_JavaProfiler_secondsToOOMForTest0(JNIEnv *env, + jclass unused) { + return -1.0; +} + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_urgentLatchedForTest0(JNIEnv *env, + jclass unused) { + return JNI_FALSE; +} + #endif // DEBUG // ---- Test-only reads of the current thread's OTEP record ----------------------------------- diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 15c5663667..802a05118e 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -1050,10 +1050,13 @@ class alignas(alignof(SpinLock)) LivenessTracker { // pattern selectLeakCandidates()/getLiveTraceIds() already use. jobject resolveCandidateRepresentative(JNIEnv *env, u32 klass_id); - // Exposes the _gc_generations gate (see that member's own comment) so a - // caller outside this class can skip work that only makes sense for the - // explicit generations option. Read-only; this accessor never toggles the - // flag. + // Read-only observation point for the explicit generations option + // (_gc_generations; see that member's own comment), so tests and + // diagnostics can tell it apart from the effective leakSignalEnabled() gate + // (generations OR liveheap). No production code keys on it: since the + // reference-chain signal gates moved to leakSignalEnabled(), the + // population, heap-floor and search gates all read _leak_signal_enabled. + // This accessor never toggles the flag. bool gcGenerationsEnabled() const { return _gc_generations.load(std::memory_order_relaxed); } @@ -1083,16 +1086,15 @@ class alignas(alignof(SpinLock)) LivenessTracker { // under-warn right up until an OOM-kill from container memory pressure // that had nothing to do with heap occupancy. // - // Exists because selectLeakCandidates()'s per-klass gate - // (KLASS_POPULATION_MIN_FILL_FOR_TREND ring samples plus - // LEAK_TREND_HYSTERESIS_BASE/CORROBORATED consecutive qualifying epochs) - // can take longer to trust a candidate than a fast leak has left before - // OOM - this gives ReferenceChainTracker::hasLeakSignal() an independent, - // rate-based signal to start a search immediately instead of waiting on - // that gate. Not a claim that growth stays linear, only that a - // short-horizon linear extrapolation is a reasonable urgency signal. + // The projection feeds the urgency latch (ReferenceChainTracker::isUrgent()), + // the urgent admission ramp (ReferenceChainTracker::updateUrgentAdmission()) + // and threadLoop()'s OOM cadence/budget ramp. It does NOT start a search: + // ReferenceChainTracker::hasLeakSignal() still returns true only when + // selectLeakCandidates() yields a klass, urgency or not. Not a claim that + // growth stays linear, only that a short-horizon linear extrapolation is a + // reasonable urgency signal. // Returns a negative value if the chosen ring is not filled enough yet - // (gcGenerationsEnabled() is off, or too few GC epochs have happened), not + // (leakSignalEnabled() is off, or too few GC epochs have happened), not // rising, or neither _max_heap_bytes nor _container_memory_limit was ever // resolved - callers must treat any non-positive return as "no projection // available", not "zero seconds". Returns 0 if the chosen ring's recent @@ -1701,6 +1703,13 @@ class alignas(alignof(SpinLock)) LivenessTracker { } #endif +#if defined(DEBUG) || defined(UNIT_TEST) + // Test-only mutators of the leak-signal gates and the burn-in stamp. + // Compiled in for DEBUG builds (they back the DEBUG-only JNI seams in + // javaApi.cpp) and for gtest builds (GtestTaskBuilder adds -DUNIT_TEST to + // every gtest compile); never in the shipped release library, so no release + // consumer can flip the leak-signal gate or backdate the burn-in. + // Sets the two leak-signal gates directly, bypassing initialize() (which // requires a live JVM - VM::hotspot_version()/VM::jni(), see that method's // own code - out of gtest's reach the same way foldKlassCountsLocked()'s @@ -1729,6 +1738,13 @@ class alignas(alignof(SpinLock)) LivenessTracker { _leak_signal_activated_ns.store(ns, std::memory_order_release); } + // Read side of setLeakSignalActivatedNsForTest(): lets a DEBUG seam save the + // current stamp before backdating it and restore it afterwards. + u64 leakSignalActivatedNsForTest() const { + return _leak_signal_activated_ns.load(std::memory_order_acquire); + } +#endif // DEBUG || UNIT_TEST + private: void getLiveTraceIds(CallTraceIdSet& out_buffer); }; diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 9d120a45af..6286df5e7d 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -388,8 +388,10 @@ void ReferenceChainTracker::stop() { // - _search_start_ns still holds the previous recording's clock, so the entire stopped gap // would count toward the new recording's TTL and could abandon "its" search on the first // pass with a spurious abandonment event for a search the new recording never started; - // - _urgent_latched/_urgent_release_ticks would carry the previous - // recording's urgency episode into the new one; + // - _urgent_latched/_urgent_release_ticks/_admission_growth_baseline would carry the previous + // recording's urgency episode into the new one (a stale growth baseline would let the new + // recording's first urgent observation skip updateUrgentAdmission()'s entry re-snapshot and + // admit against the old recording's heap level); // - the frontier's referrer_klass slots hold StringDictionary ids from a _class_map that // Profiler::start() is about to wipe (ids restart at 1) - they cannot be remapped, so a // chain built from a surviving entry after the boundary would name whichever class the new @@ -411,6 +413,8 @@ void ReferenceChainTracker::stop() { store(_search_start_ns, (u64)0); _urgent_latched = false; _urgent_release_ticks = 0; + // BFS-thread owned like the two fields above; stopThread() has joined that thread. + _admission_growth_baseline = 0; // Same per-search candidate/discovered canary state runPass()'s terminal branch and // restartSearch() clear - zeroing the counts here closes the stale-chain windows in // pollWatchedTargets() (dead-representative canary path, discovered-instances path) for the @@ -879,6 +883,12 @@ bool ReferenceChainTracker::hasLeakSignal() { TEST_LOG_SUMMARY("ReferenceChainTracker::hasLeakSignal -> %s (secondsToOOM=%.1f, " "candidates=%d, urgent=%d)", n > 0 ? "true" : "false", seconds_to_oom, n, urgent); + if (urgent && n == 0) { + // Observability for the candidate-only gate (TEST_LOG_SUMMARY above is debug/test-only): + // urgency is latched but no klass explains the growth, so no search starts and no chain is + // produced. One increment per refused evaluation (see counters.h). + Counters::increment(REFERENCE_CHAIN_URGENT_NO_CANDIDATE); + } return n > 0; } @@ -994,9 +1004,11 @@ void ReferenceChainTracker::resetSearchStateForTest(jvmtiEnv *jvmti, // _safepoint_pain_budget above. _cpu_pain_budget = PainBudget(_pain_budget_refill_rate); // Same test-isolation rationale: a latched urgency episode left behind by an earlier test would - // otherwise ramp this one's cadence and pause target from a stale projection. + // otherwise ramp this one's cadence and pause target from a stale projection, and a stale + // admission growth baseline would skip this test's first-observation re-snapshot. _urgent_latched = false; _urgent_release_ticks = 0; + _admission_growth_baseline = 0; if (_frontier != nullptr) { // Rebuilds the table at this test's own _configured_frontier_cap, undoing any smaller framecap= diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index f09ef2fd34..b3a00819a9 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -635,11 +635,15 @@ class ReferenceChainTracker { // Fallback cadence between passes. static constexpr u64 PASS_CADENCE_NS = 1000000000ULL; // 1s - // Heap-wide time-to-OOM urgency threshold (LivenessTracker::secondsToOOM()) - hasLeakSignal() - // below forces a search to start immediately once the projection drops under this, rather than - // waiting for a klass to clear selectLeakCandidates()'s own per-klass ring-fill/hysteresis gate - // (KLASS_POPULATION_MIN_FILL_FOR_TREND plus LEAK_TREND_HYSTERESIS_BASE/ CORROBORATED epochs, - // livenessTracker.h). + // Heap-wide time-to-OOM urgency threshold (LivenessTracker::secondsToOOM()). It has two uses: + // (a) isUrgent()'s latch threshold (released via OOM_URGENT_RELEASE_S below); the latch is + // consumed by runPass()'s no-progress abandonment branch, which it suppresses while latched; + // (b) updateUrgentAdmission()'s emergency band, where tracking admission goes to 100% without + // heap-growth evidence. + // It never authorizes starting a search: hasLeakSignal() still requires selectLeakCandidates() + // to return at least one klass past its per-klass ring-fill/hysteresis gate + // (KLASS_POPULATION_MIN_FILL_FOR_TREND plus LEAK_TREND_HYSTERESIS_BASE/CORROBORATED epochs, + // livenessTracker.h), and urgency never stands in for that. static constexpr double OOM_URGENT_THRESHOLD_S = 300.0; // 5 minutes // Release side of OOM_URGENT_THRESHOLD_S's hysteresis (see _urgent_latched). @@ -1416,10 +1420,10 @@ class ReferenceChainTracker { // Test seam - not part of the production API. Evaluates the real private scheduling gate without // executing a BFS pass. This is not read-only: while holding _engine_lock like threadLoop(), the - // gate may account a completed search's pain and restart/reset terminal search state. It also - // exposes whether LivenessTracker::secondsToOOM()'s urgent-OOM bypass opens the gate even with no - // per-klass candidate; runReferenceChainPass0() cannot show that because it calls runPass() - // directly and never consults the gate. + // gate may account a completed search's pain and restart/reset terminal search state. It lets a + // test confirm the gate stays closed with no per-klass candidate, urgency latched or not; + // runReferenceChainPass0() cannot show that because it calls runPass() directly and never + // consults the gate. bool shouldRunPassForTest(u64 now_ns) { MutexLocker engine_guard(_engine_lock); return shouldRunPass(now_ns); diff --git a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java index aae738a477..1f91d50f0c 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -731,8 +731,9 @@ private static native void setTraceContext0(long localRootSpanId, long spanId, l /** * Test seam (debug native builds only): reports whether the seeded heap-floor ring * currently shows a sustained rise ({@code heapFloorRising()}) - the aggregate growth - * read {@code hasLeakSignal()}'s gate uses alongside {@code secondsToOOM()}'s urgent - * projection. Returns {@code false} both before any seeding and after + * read {@code selectLeakCandidates()} uses to pick the required per-klass hysteresis + * (corroborated vs base); neither {@code secondsToOOM()} nor {@code hasLeakSignal()} reads + * it directly. Returns {@code false} both before any seeding and after * {@link #heapFloorResetForTest0()} emptied the ring, which is how tests assert the * reset actually cleared the indices. */ @@ -741,14 +742,13 @@ private static native void setTraceContext0(long localRootSpanId, long spanId, l /** * Test seam (debug native builds only): reports whether ReferenceChainTracker's search-restart * gate ({@code canAffordNewSearch()} -> {@code hasLeakSignal()}) would currently allow a - * fresh/terminal search to start - in particular, whether {@code secondsToOOM()}'s urgent-OOM - * bypass opens this gate even with zero per-klass leak candidate (confirmable in the same test - * via {@link #selectLeakCandidateKlassIds0()}). Unlike {@link #runReferenceChainPass0()}, which - * calls {@code runPass()} unconditionally, this evaluates the real gate without executing a - * BFS pass - but it is NOT a read-only query: under the native engine lock it may account a - * completed search's pain and restart/reset terminal search state, and opening the gate on - * the urgent-OOM bypass spends the episode's one-shot urgency entitlement. Do not treat it - * as a pure predicate (e.g. inside an assertion) when a scenario's search state matters. + * fresh/terminal search to start now. Opening it requires a per-klass leak candidate + * (confirmable in the same test via {@link #selectLeakCandidateKlassIds0()}), whether or not + * OOM urgency is latched. Unlike {@link #runReferenceChainPass0()}, which calls + * {@code runPass()} unconditionally, this evaluates the real gate without executing a BFS + * pass - but it is NOT a read-only query: under the native engine lock it may account a + * completed search's pain and restart/reset terminal search state. Do not treat it as a pure + * predicate (e.g. inside an assertion) when a scenario's search state matters. */ static native boolean shouldRunPassForTest0(); @@ -762,6 +762,35 @@ private static native void setTraceContext0(long localRootSpanId, long spanId, l */ static native int referenceChainPassesRunForTest0(); + /** + * Test seam (debug native builds only): backdates LivenessTracker's leak-signal burn-in stamp + * one second past {@code OOM_PROJECTION_BURN_IN_NS}, so {@code secondsToOOM()} projects from + * seeded heap-floor samples however long this JVM has been profiling. Returns the previous + * stamp, which the caller must hand back to {@link #setLeakSignalActivatedNsForTest0(long)} + * in a finally block. The release stub returns 0 and changes nothing. + */ + static native long backdateLeakSignalActivationForTest0(); + + /** + * Test seam (debug native builds only): restores a leak-signal burn-in stamp saved by + * {@link #backdateLeakSignalActivationForTest0()} (0 means "never activated"). + */ + static native void setLeakSignalActivatedNsForTest0(long activatedNs); + + /** + * Test seam (debug native builds only): the current {@code secondsToOOM()} projection. A + * negative value means "no projection" (burn-in, insufficient ring fill, not rising, or no + * resolved limit). The release stub returns -1. + */ + static native double secondsToOOMForTest0(); + + /** + * Test seam (debug native builds only): ReferenceChainTracker's OOM-urgency latch + * ({@code _urgent_latched}), read under the native engine lock. The release stub returns + * {@code false}. + */ + static native boolean urgentLatchedForTest0(); + // ---- Test-only reads of the current thread's OTEP record ---------------------------------- // Each resolves the current carrier's record directly (like the write primitives above) with // no cached buffer and no per-thread Java object; introspection/test use only. From 76e7f9ab2bc4ec3e4ecf83e5c6e1891f33f23bfd Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 17:08:34 +0100 Subject: [PATCH 06/20] Pin baseline reset and vacuous-negative guards in reference-chain tests - StopClearsAdmissionGrowthBaseline pins the stop() baseline reset. - AggressiveLeak: the negative search-gate test now asserts the projection is actually engaged (backdated burn-in stamp via the new seam), so its assertions cannot pass vacuously while secondsToOOM() is inside burn-in; cleanup goes through the shared restoreHeapFloorDefaults() helper. - ReferenceChainTrackingTest: the abandonment retry loop is capped at 3 dumps with a passesRun probe and paced last dump, bounding the O(recording-size) dump I/O; the dead if-guard behind the debug-build assumption is removed; a growth-gate equality scenario pins > vs >=. --- .../src/test/cpp/referenceChainsBfsTests.inc | 6 +- .../src/test/cpp/referenceChainsOomTests.inc | 84 ++++++ .../test/cpp/referenceChainsTrackerTests.inc | 33 ++- .../profiler/JavaProfilerApiSurfaceTest.java | 4 +- .../profiler/ReferenceChainTestHooks.java | 23 ++ .../AggressiveLeakReferenceChainTest.java | 277 +++++++++++++----- .../ReferenceChainTrackingTest.java | 94 ++++-- 7 files changed, 406 insertions(+), 115 deletions(-) diff --git a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc index 6043216620..d5bf85f16d 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc @@ -802,9 +802,9 @@ TEST_F(ReferenceChainsBfsTest, CanaryStuckLimitDoublesPerRestartAndResetsOnClean ReferenceChainsTestAccessor::canaryStuckPassLimitForTest()); // This test deliberately reaches states the fixtures' SetUp reset() cannot fully neutralize - // for a *latched* tracker: the urgency latch (isUrgent() keeps bypassing the candidate gate - // until it releases on its own, breaking later tests' gate expectations under shuffled - // order), the watched-leak-klass list (restartSearch() does not clear it), and whatever + // for a *latched* tracker: the urgency latch (the latched tracker keeps suppressing + // runPass()'s no-progress abandonment until it releases on its own, breaking later tests' + // terminal-state expectations under shuffled order), the watched-leak-klass list (restartSearch() does not clear it), and whatever // candidate/found state the last runPass() left. Restore all of it - the same seam the // fixtures use in SetUp, applied to the leaked half. LivenessTracker::instance()->setMaxHeapBytesForTest(-1); diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index fd2ddebde5..297d7e6b3c 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -299,6 +299,32 @@ TEST_F(OomUrgencyTest, AdmissionBoostRequiresGrowthPastEntryBaseline) { EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1490.0, true)); } +// Boundary of the growth gate: a newer sample exactly equal to the entry +// snapshot is not growth evidence (a plateaued heap re-recording the same +// floor). Only a strictly larger sample admits, so a `>` -> `>=` slip in +// updateUrgentAdmission() fails here. +TEST_F(OomUrgencyTest, AdmissionBoostRejectsSampleEqualToEntryBaseline) { + ASSERT_NEAR(project(1500.0), 1500.0, 1.0); + u64 peak = MAX_HEAP - (u64)(1500.0 * (double)RATE / (double)SAMPLE_SPACING_S); + ASSERT_EQ(peak, LivenessTracker::instance()->newestHeapFloorSample()) + << "scenario requires the entry snapshot to be the ramp peak"; + + // Entry snapshot = peak. + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + + // Follow-up sample exactly at the snapshot: equality is not growth. + LivenessTracker::instance()->heapFloorRecordForTest(peak, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)) + << "a sample equal to the entry baseline is not growth evidence"; + + // Control: one RATE past the snapshot admits, so the negative above is + // not trivially satisfied. + LivenessTracker::instance()->heapFloorRecordForTest(peak + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); +} + // The regression-smoothing counterexample: a single spike sample keeps the // smoothed projection inside the band for several recovery GCs after the heap // has physically recovered. The scenario proves both halves: the projection @@ -451,6 +477,64 @@ TEST_F(ReferenceChainsCanaryRefillTest, RefillRaiseTracksOpenChaseExactly) { ReferenceChainsTestAccessor::reset(); } +// stop() ends the urgency episode at a recording boundary, so it must clear +// updateUrgentAdmission()'s growth baseline along with the latch: the +// heap-floor ring survives the boundary, and a baseline left over from the +// previous recording would let the new recording's first urgent observation +// skip its entry re-snapshot and admit against the old recording's heap level. +// updateUrgentAdmission() reads only the newest heap-floor sample, so no max +// heap is set - and must not be before start(): with a max heap > 0, start()'s +// autoTuneDefaults() calls GetAvailableProcessors, which this stub jvmti lacks. +TEST_F(ReferenceChainsCanaryRefillTest, StopClearsAdmissionGrowthBaseline) { + + constexpr u64 MiB = 1ULL << 20; + LivenessTracker *lt = LivenessTracker::instance(); + ReferenceChainsTestAccessor::reset(); // also resetUrgencyForTest() +#ifdef DEBUG + lt->setHeapFloorRecordingForTest(false); +#endif + lt->heapFloorResetForTest(); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true")); + ASSERT_FALSE(tracker->start(args)); + + // Restores the shared singletons even when an ASSERT below returns early. + // stop() on an already-stopped tracker is a no-op (it returns unless + // enabled()). + struct Cleanup { + ReferenceChainTracker *tracker; + LivenessTracker *lt; + ~Cleanup() { + tracker->stop(); + ReferenceChainsTestAccessor::resetUrgencyForTest(); + lt->heapFloorResetForTest(); // while recording is still disabled +#ifdef DEBUG + lt->setHeapFloorRecordingForTest(true); +#endif + ReferenceChainsTestAccessor::reset(); + } + } cleanup{tracker, lt}; + + // Recording 1: the first urgent non-emergency observation snapshots + // 500MiB and does not admit. + lt->heapFloorRecordForTest(500 * MiB, 1, 0); + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + + tracker->stop(); + ASSERT_FALSE(tracker->start(args)); + + // Recording 2: the heap is now above the OLD baseline. A fresh episode + // must re-snapshot (false); a baseline leaked through stop() would admit + // on 600 > 500. + lt->heapFloorRecordForTest(600 * MiB, 2, 0); + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)) + << "stop() must clear _admission_growth_baseline"; + // Positive control: growth past the new snapshot admits. + lt->heapFloorRecordForTest(700 * MiB, 3, 0); + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); +} + TEST_F(ReferenceChainsCanaryRefillTest, BackoffGateHoldsUnlessOomRampActive) { ReferenceChainsTestAccessor::reset(); ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); diff --git a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc index 284ec7c3e4..8980e096de 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc @@ -1195,8 +1195,9 @@ protected: VMTestAccessor::setJvmti(orig_jvmti); LivenessTracker::instance()->klassPopulationResetForTest(); LivenessTracker::instance()->setGcGenerationsForTest(false); - // Set by UrgentOOMProjectionBypassesCandidateGate; reset here so a failed ASSERT_* there - // can't leak a stale max heap into the next test. + // Max heap, leak-signal gates and burn-in stamp are set by individual tests (e.g. + // UrgentLatchWithoutCandidateDoesNotStartSearch); restored here so a failed ASSERT_* in + // one of them can't leak them into the next test. LivenessTracker::instance()->setMaxHeapBytesForTest(-1); // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); @@ -1210,8 +1211,8 @@ protected: return JVMTI_ERROR_NONE; } - // start() -> autoTuneDefaults() queries this when max heap > 0 - // (UrgentOOMProjectionBypassesCandidateGate). + // start() -> autoTuneDefaults() queries this when max heap > 0 (tests that set a fake max + // heap before start(), e.g. UrgentLatchWithoutCandidateDoesNotStartSearch). static jvmtiError JNICALL mock_GetAvailableProcessors(jvmtiEnv *, jint *nprocs) { *nprocs = 1; @@ -1425,15 +1426,39 @@ TEST_F(SearchRestartTest, UrgentLatchWithoutCandidateDoesNotStartSearch) { // Urgency latched, but no candidate has cleared the leak-signal probe. ASSERT_TRUE(ReferenceChainsTestAccessor::isUrgentForTest()); +#ifdef COUNTERS + long long urgent_no_cand = Counters::getCounter(REFERENCE_CHAIN_URGENT_NO_CANDIDATE); +#endif EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)) << "urgent without a candidate must not start a candidate-less search"; EXPECT_EQ(0, tracker->passesRun()); +#ifdef COUNTERS + // The refusal is observable in production diagnostics, not only in the + // debug-only TEST_LOG_SUMMARY. + EXPECT_EQ(urgent_no_cand + 1, Counters::getCounter(REFERENCE_CHAIN_URGENT_NO_CANDIDATE)) + << "one refused latched/zero-candidate evaluation counts exactly once"; + urgent_no_cand = Counters::getCounter(REFERENCE_CHAIN_URGENT_NO_CANDIDATE); +#endif + + // Disabled leak-signal source: hasLeakSignal() returns before isUrgent() + // and the counter, so nothing is counted. + LivenessTracker::instance()->setGcGenerationsForTest(false); + EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(1)); +#ifdef COUNTERS + EXPECT_EQ(urgent_no_cand, Counters::getCounter(REFERENCE_CHAIN_URGENT_NO_CANDIDATE)) + << "a disabled leak-signal source must not count"; +#endif + LivenessTracker::instance()->setGcGenerationsForTest(true); // Once a klass clears the probe, the search starts - and urgency applies // through the ramp from that point on. int fake_object_storage = 0; seedGrowingCandidate(/*source_tag=*/1, /*rep=*/(jweak)&fake_object_storage); EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(2)); +#ifdef COUNTERS + EXPECT_EQ(urgent_no_cand, Counters::getCounter(REFERENCE_CHAIN_URGENT_NO_CANDIDATE)) + << "a gate opened by a real candidate must not count"; +#endif // 0 = "never activated"; the next start() re-stamps it through the CAS from 0. LivenessTracker::instance()->setLeakSignalActivatedNsForTest(0); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java index 6ce81762cc..6dde526f50 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -38,7 +38,9 @@ public void ownedBlockHooksAreNotPublicApiBeforeTaskBlockInstrumentation() throw "getReferenceChainFrontierStateForTest0", "getReferenceChainResolvedTargetTagForTest0", "heapFloorRecordForTest0", "setMaxHeapBytesForTest0", "setHeapFloorRecordingForTest0", "heapFloorResetForTest0", "heapFloorRisingForTest0", - "shouldRunPassForTest0", "referenceChainPassesRunForTest0")); + "shouldRunPassForTest0", "referenceChainPassesRunForTest0", + "backdateLeakSignalActivationForTest0", "setLeakSignalActivatedNsForTest0", + "secondsToOOMForTest0", "urgentLatchedForTest0")); @Test public void referenceChainTestSeamsAreNotPublicApi() { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java index fb5d5f427c..285894c7a5 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java @@ -117,4 +117,27 @@ public static boolean shouldRunPassForTest0() { public static int referenceChainPassesRunForTest0() { return JavaProfiler.referenceChainPassesRunForTest0(); } + + /** + * Backdates the leak-signal burn-in stamp (debug native builds only; see JavaProfiler). + * Returns the previous stamp for {@link #setLeakSignalActivatedNsForTest0(long)}. + */ + public static long backdateLeakSignalActivationForTest0() { + return JavaProfiler.backdateLeakSignalActivationForTest0(); + } + + /** Restores a saved leak-signal burn-in stamp (debug native builds only; see JavaProfiler). */ + public static void setLeakSignalActivatedNsForTest0(long activatedNs) { + JavaProfiler.setLeakSignalActivatedNsForTest0(activatedNs); + } + + /** The current {@code secondsToOOM()} projection (debug native builds only; see JavaProfiler). */ + public static double secondsToOOMForTest0() { + return JavaProfiler.secondsToOOMForTest0(); + } + + /** The OOM-urgency latch (debug native builds only; see JavaProfiler). */ + public static boolean urgentLatchedForTest0() { + return JavaProfiler.urgentLatchedForTest0(); + } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java index 31d07fd94e..352fc36f2c 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -10,8 +10,14 @@ import com.datadoghq.profiler.ReferenceChainTestHooks; import org.junit.jupiter.api.Test; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; /** @@ -27,7 +33,8 @@ * projection alone must NEVER open the gate while the per-klass population table is provably * empty. Staging recordings showed the pre-fix behavior - the urgent latch entitled a * candidate-less search that cannot exit early and ran whole-heap BFS passes to the auto-tuned - * TTL with zero chains to show for it. + * TTL with zero chains to show for it. That bypass and its one-shot urgency entitlement were + * removed. * *

Reproducing a genuine near-OOM heap in this shared, no-{@code forkEvery} test JVM is * impractical (see {@link ReferenceChainTrackingTest}'s own header comment on the JVM this class @@ -36,13 +43,28 @@ * seed {@code LivenessTracker}'s aggregate heap-floor-ring history and its max-heap-bytes cache * directly, so {@code secondsToOOM()}'s projection can be driven under * {@code OOM_URGENT_THRESHOLD_S} (referenceChains.h) deterministically, decoupled from this JVM's - * real {@code -Xmx} and from real GC timing. {@code shouldRunPassForTest0()} then evaluates - * {@code ReferenceChainTracker::shouldRunPass()} - the gate {@code hasLeakSignal()} feeds - - * directly, rather than running a real pass, so this class never depends on - * {@code selectLeakCandidates()} ranking anything, a real JVMTI heap walk, or a JFR event. + * real {@code -Xmx} and from real GC timing. {@code secondsToOOM()} withholds its projection for + * {@code OOM_PROJECTION_BURN_IN_NS} (5 minutes) after the leak signal first activated, and this + * class may run inside that window, so each gate test backdates the burn-in stamp with + * {@code backdateLeakSignalActivationForTest0()} and restores it in its finally block. Positive + * controls ({@code secondsToOOMForTest0()} for the projection, {@code urgentLatchedForTest0()} for + * the urgency latch) prove the urgency machine is engaged (or provably not, for the flat-floor + * control) before any negative gate assertion is trusted. {@code shouldRunPassForTest0()} then + * evaluates {@code ReferenceChainTracker::shouldRunPass()} - the gate {@code hasLeakSignal()} + * feeds - directly on a freshly reset search, rather than running a real pass or waiting on the + * live BFS thread, so this class never depends on {@code selectLeakCandidates()} ranking + * anything, a real JVMTI heap walk, or a JFR event. */ public class AggressiveLeakReferenceChainTest extends AbstractProfilerTest { + // Mirrors ReferenceChainTracker::OOM_URGENT_THRESHOLD_S (referenceChains.h). + private static final double OOM_URGENT_THRESHOLD_S = 300.0; + + // Sentinel for restoreHeapFloorDefaults(): skip the burn-in stamp restore (the caller never + // backdated it). A real stamp is an OS::nanotime() u64 cast to a jlong, so Long.MIN_VALUE + // cannot collide with one in practice. + private static final long ACTIVATION_STAMP_UNCHANGED = Long.MIN_VALUE; + @Override protected String getProfilerCommand() { // generations=true: arms LivenessTracker's effective leak signal (generations OR liveheap @@ -69,43 +91,102 @@ private static void assumeDebugBuild() { /** * Undoes this class's synthetic seeding so later tests in this shared, no-forkEvery JVM never - * see it. {@code resetKlassPopulationForTest0()} also zeroes LivenessTracker's heap-floor ring - * (head/fill - {@code klassPopulationResetForTest()}, livenessTracker.h), so + * see it. This helper is the only place the cleanup ordering lives; every test's finally block + * ends with it. {@code resetKlassPopulationForTest0()} also zeroes LivenessTracker's heap-floor + * ring (head/fill - {@code klassPopulationResetForTest()}, livenessTracker.h), so * {@code secondsToOOM()} reports "insufficient fill" until real GC samples build up again; it - * runs while heap-floor recording is still disabled so no real sample can interleave. - * {@code resetReferenceChainSearchForTest0()} then clears any urgency latch plus the search - * state an earlier evaluation started - after the ring reset, so the BFS thread cannot - * re-latch from the stale projection. Recording is re-enabled last. + * runs while heap-floor recording is still disabled so no real sample can interleave. The + * burn-in stamp is restored next (unless {@code savedActivationNs} is + * {@link #ACTIVATION_STAMP_UNCHANGED}) - after the ring reset and before the search reset, so + * the restarted BFS thread can neither re-latch from a stale ring nor project against a + * backdated clock. {@code resetReferenceChainSearchForTest0()} then clears any urgency latch + * plus the search state an earlier evaluation started. Recording is re-enabled last. */ - private static void restoreHeapFloorDefaults() { + private static void restoreHeapFloorDefaults(long savedActivationNs) { ReferenceChainTestHooks.setMaxHeapBytesForTest0(-1); ReferenceChainTestHooks.resetKlassPopulationForTest0(); + if (savedActivationNs != ACTIVATION_STAMP_UNCHANGED) { + ReferenceChainTestHooks.setLeakSignalActivatedNsForTest0(savedActivationNs); + } ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); ReferenceChainTestHooks.setHeapFloorRecordingForTest0(true); } + /** Result of one bracketed direct gate evaluation. */ + private static final class GateEvaluation { + boolean gateOpen; + boolean candidateSeen; + int passesAfter; + String detail; + } + + /** + * Evaluates the search gate directly on a freshly reset search. The reset clears the urgency + * latch and the pain budget and leaves {@code _search_started} false, so + * {@code canAffordNewSearch()} reaches {@code hasLeakSignal()}; the gate's answer does not + * depend on evaluation order relative to the BFS thread. The evaluation is bracketed by + * candidate and pass-count reads so organic activity from the rest of this shared JVM can be + * told apart from a contract violation (see {@link #checkNoCandidateFreeActivity}). + */ + private static GateEvaluation evaluateGateAfterSearchReset() { + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + int[] candidatesBefore = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); + int passesBefore = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + GateEvaluation e = new GateEvaluation(); + e.gateOpen = ReferenceChainTestHooks.shouldRunPassForTest0(); + int[] candidatesAfter = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); + e.passesAfter = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + e.candidateSeen = !isEmpty(candidatesBefore) || !isEmpty(candidatesAfter); + e.detail = "candidatesBefore=" + Arrays.toString(candidatesBefore) + " candidatesAfter=" + + Arrays.toString(candidatesAfter) + " passesBefore=" + passesBefore + + " passesAfter=" + e.passesAfter; + return e; + } + + /** + * Aborts only on an observed per-klass candidate: a candidate legitimately opens the gate, so + * the candidate-gate contract cannot be isolated in that run. A BFS pass since the search reset + * (the reset zeroes the pass count) with no candidate in either read means the gate opened + * without a candidate - e.g. a re-introduced urgency bypass on the restarted BFS thread - and + * must FAIL, not skip. + */ + private static void checkNoCandidateFreeActivity(GateEvaluation e) { + assumeFalse(e.candidateSeen, "Aborting: organic per-klass candidate around the direct " + + "evaluation (" + e.detail + ") - the candidate-gate contract cannot be isolated in " + + "this run"); + if (e.passesAfter != 0) { + fail("A BFS pass ran after the search reset although no per-klass candidate was seen in " + + "either read - the gate opened without a candidate (" + e.detail + ")"); + } + } + + private static boolean isEmpty(int[] a) { + return a == null || a.length == 0; + } + /** * Seeds ten heap-floor-ring samples rising fast enough that {@code secondsToOOM()}'s projection * lands well under {@code OOM_URGENT_THRESHOLD_S} (300s), with LivenessTracker's per-klass - * population table left empty throughout - {@code selectLeakCandidateKlassIds0()} returns - * nothing at any point in this test. Then asserts the search-restart gate stays closed on the - * live BFS thread: the aggregate urgent-OOM projection must never start a candidate-less - * search. + * population table left empty throughout. The burn-in stamp is backdated first, so the + * projection is live in this shared JVM regardless of class ordering. Two positive controls + * run before the negative assertion: the projection must be inside the urgent band, and the + * gate evaluation must latch urgency - otherwise a closed gate would prove nothing. Then the + * directly evaluated search gate must stay closed: the aggregate urgent-OOM projection must + * never start a candidate-less search. * - *

The gate is asserted both through the live BFS thread (no pass may run within the wait - * window) and through a direct {@code shouldRunPassForTest0()} call after a search reset. The - * direct call is race-free under the candidate-gate contract - the old one-shot urgency - * entitlement ({@code _urgent_search_spent}) that made a direct call lose a race against the - * BFS thread was removed together with the bypass itself, and the gate's answer no longer - * depends on evaluation order. + *

The live BFS thread is deliberately not watched over a wait window: its pass counter also + * counts passes legitimately started by unrelated per-klass candidates in this shared JVM. The + * run is aborted (not passed) when an organic candidate appears around the direct evaluation, + * and fails when a BFS pass ran since the search reset without any candidate - the gate opened + * without one. */ @Test - public void shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() - throws InterruptedException { + public void shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() { assumeDebugBuild(); ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); ReferenceChainTestHooks.resetKlassPopulationForTest0(); ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + long savedActivationNs = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); try { // 1000 MiB fake max heap, rising from 100 MiB to 991 MiB over 9 (fake) seconds: ~99 MiB/s, // 9 MiB of headroom left at the last sample -> projected time-to-OOM ~= 9/99 s, far under @@ -121,45 +202,46 @@ public void shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate + "below is a genuine urgent-band one - the gate must stay closed for the " + "candidate requirement, not because the projection is unknown"); - int[] candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); - assertTrue(candidates == null || candidates.length == 0, - "This test's own precondition: no per-klass candidate should exist, so a pass running " - + "below could only come from the aggregate urgent-OOM projection"); - - // Idle BFS cadence is ~1s/pass. No pass may run: with zero per-klass candidates the gate - // has no indication, and urgency alone must not supply one. - int passesBefore = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); - long deadline = System.currentTimeMillis() + 5_000; - while (System.currentTimeMillis() < deadline) { - assertTrue(ReferenceChainTestHooks.referenceChainPassesRunForTest0() == passesBefore, - "The BFS thread must stay idle: an urgent-OOM projection with zero per-klass leak " - + "candidates must not start a search"); - Thread.sleep(50); - } + // Positive control 1: past the backdated burn-in, the seeded rise must project inside the + // urgent band. Fails fast, with the value, if the burn-in still masks the projection. + double secondsToOom = ReferenceChainTestHooks.secondsToOOMForTest0(); + assertTrue(secondsToOom >= 0 && secondsToOom < OOM_URGENT_THRESHOLD_S, + "Positive control: urgent band [0, " + OOM_URGENT_THRESHOLD_S + ")s required; got " + + secondsToOom + " - the burn-in backdate did not take effect or the seeds no " + + "longer project, so the closed gate below would prove nothing"); + + GateEvaluation e = evaluateGateAfterSearchReset(); + checkNoCandidateFreeActivity(e); + + // Positive control 2: the gate evaluation must have latched urgency from the seeded + // projection, so the gate is closed by the candidate requirement and not by a dormant + // urgency machine. + assertTrue(ReferenceChainTestHooks.urgentLatchedForTest0(), + "Urgency must be latched after evaluating the gate on an urgent-band projection (" + + secondsToOom + "s)"); - // Same assertion through the predicate itself, on a freshly reset search so any mutation - // the live thread's evaluations made lands on clean state (see the flat-floor control - // below for why the reset must not touch the population). This direct call is race-free: - // the gate's answer no longer depends on evaluation order. - ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); - assertFalse(ReferenceChainTestHooks.shouldRunPassForTest0(), - "Expected the search-restart gate to stay closed: the urgent-OOM projection is not a " - + "leak indication - only selectLeakCandidates() is"); - - candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); - assertTrue(candidates == null || candidates.length == 0, - "The idle window must have kept the per-klass population table empty - the " - + "aggregate urgent-OOM projection was the only signal this test seeded"); + assertFalse(e.gateOpen, + "Expected the search gate to stay closed: urgency is latched but no per-klass " + + "candidate exists - only selectLeakCandidates() is a leak indication (" + + e.detail + ")"); } finally { - restoreHeapFloorDefaults(); + restoreHeapFloorDefaults(savedActivationNs); } } /** - * Same empty per-klass population table as above, but a flat heap floor (no growth at all) - - * {@code secondsToOOM()} has nothing to project from, so this test's own gate must stay closed. - * Without this control, a stubbed-out {@code secondsToOOM()} that always signals urgency would - * pass the test above just as well. + * Control for the test above: same empty per-klass population table and same backdated + * burn-in, but a flat heap floor (no growth). {@code secondsToOOM()} has nothing to project + * from - asserted directly through {@code secondsToOOMForTest0()}, and because the burn-in is + * backdated the "no projection" comes from the flat ring, not from the burn-in - so urgency + * must not latch and the gate must stay closed. This complements the primary test's + * urgent-band case with the no-projection case. + * + *

The gate's closed answer alone cannot tell the two cases apart: under the candidate-gate + * contract {@code hasLeakSignal()} answers only from {@code selectLeakCandidates()}, so with an + * empty population table the gate stays closed whatever the projection says. A stubbed or + * dormant {@code secondsToOOM()} is caught instead by the direct projection reads (urgent band + * above, none here) and by the urgency-latch reads after each gate evaluation. */ @Test public void shouldNotOpenSearchGateOnFlatHeapFloorWithNoLeakCandidate() { @@ -167,31 +249,68 @@ public void shouldNotOpenSearchGateOnFlatHeapFloorWithNoLeakCandidate() { ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); ReferenceChainTestHooks.resetKlassPopulationForTest0(); ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + long savedActivationNs = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); try { ReferenceChainTestHooks.setMaxHeapBytesForTest0(1000L * 1024 * 1024); for (int i = 0; i < 10; i++) { ReferenceChainTestHooks.heapFloorRecordForTest0(200L * 1024 * 1024, i * 1_000_000_000L); } - int[] candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); - assertTrue(candidates == null || candidates.length == 0, - "This test's own precondition: no per-klass candidate should exist either"); - - // shouldRunPassForTest0() is NOT a pure predicate (see JavaProfiler's own javadoc): under the - // native engine lock it may account a finished search's pain, restart terminal search state, - // or advance urgency bookkeeping. Reset the search right before it so any mutation - // lands on a fresh tracker (_search_started == false) instead of whatever search an earlier - // class in this shared JVM left behind. Only the search is reset here, not the population: - // resetKlassPopulationForTest0() also zeroes the heap-floor ring seeded above, which would - // turn this control into a trivial "insufficient fill" pass. - ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); - boolean gateOpen = ReferenceChainTestHooks.shouldRunPassForTest0(); - assertFalse(gateOpen, + double secondsToOom = ReferenceChainTestHooks.secondsToOOMForTest0(); + assertTrue(secondsToOom < 0, + "Control precondition: past the backdated burn-in, a flat heap floor must yield no " + + "projection; got secondsToOOM=" + secondsToOom); + + // Only the search is reset right before the gate call (inside + // evaluateGateAfterSearchReset()), not the population: resetKlassPopulationForTest0() also + // zeroes the heap-floor ring seeded above, which would turn this control into a trivial + // "insufficient fill" pass. + GateEvaluation e = evaluateGateAfterSearchReset(); + checkNoCandidateFreeActivity(e); + + assertFalse(ReferenceChainTestHooks.urgentLatchedForTest0(), + "A flat heap floor gives no projection, so it must leave urgency unlatched"); + assertFalse(e.gateOpen, "Expected the search-restart gate to stay closed: no heap growth and no per-klass " - + "candidate give hasLeakSignal() nothing to trust"); + + "candidate give hasLeakSignal() nothing to trust (" + e.detail + ")"); + } finally { + restoreHeapFloorDefaults(savedActivationNs); + } + } + + /** + * Guards the burn-in stamp handling in {@link #restoreHeapFloorDefaults(long)}: the + * {@link #ACTIVATION_STAMP_UNCHANGED} sentinel must leave the stamp alone (and never be written + * as a stamp), and a saved stamp must be restored exactly. Fails if the helper drops the + * stamp-restore step, restores on the sentinel, or the JNI setter is a no-op. + */ + @Test + public void shouldRestoreActivationStampOnlyWhenRequested() { + assumeDebugBuild(); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + long savedActivationNs = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); + long afterSentinel; + try { + // Must leave the backdate in place. + restoreHeapFloorDefaults(ACTIVATION_STAMP_UNCHANGED); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); + // Reads the stamp the sentinel path left behind (and backdates again; the finally block + // below restores the original). + afterSentinel = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); } finally { - restoreHeapFloorDefaults(); + restoreHeapFloorDefaults(savedActivationNs); } + long afterRestore = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); + // Undo the probe's own backdate. + ReferenceChainTestHooks.setLeakSignalActivatedNsForTest0(afterRestore); + assertNotEquals(ACTIVATION_STAMP_UNCHANGED, afterSentinel, + "The sentinel must never be written as a stamp"); + assertNotEquals(savedActivationNs, afterSentinel, + "The sentinel path must not restore the stamp"); + assertEquals(savedActivationNs, afterRestore, + "restoreHeapFloorDefaults(saved) must restore the stamp"); } /** @@ -199,8 +318,8 @@ public void shouldNotOpenSearchGateOnFlatHeapFloorWithNoLeakCandidate() { * a finally block that no assertion observes, so without this test a reset whose body was * dropped (or whose JNI entry was left as the release stub) would leave the whole suite * green while leaking one test's seeded samples into the next test's JVM. The rising-then- - * empty pair is asserted directly through {@code heapFloorRising()} - the same read the - * aggregate gate uses - so the reset's effect is checked, not assumed. + * empty pair is asserted directly through {@code heapFloorRising()}, the ring's trend read, so + * the reset's effect is checked, not assumed. */ @Test public void shouldEmptyHeapFloorRingOnReset() { @@ -209,7 +328,7 @@ public void shouldEmptyHeapFloorRingOnReset() { ReferenceChainTestHooks.resetKlassPopulationForTest0(); ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); try { - // Same rising seeds as shouldOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate: + // Same rising seeds as shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate: // 100 MiB to 991 MiB over 9 fake seconds, which clears heapFloorRising()'s growth and // floor bars at exactly the min-fill trend window (fill = 10 samples). for (int i = 0; i < 10; i++) { @@ -224,9 +343,11 @@ public void shouldEmptyHeapFloorRingOnReset() { "heapFloorResetForTest0() must empty the ring: fill = 0 falls below the min-fill " + "trend gate heapFloorRising() requires"); } finally { - // Same order as the other tests: ring reset first, still disabled. + // Ring reset first, while recording is still disabled; the shared helper owns the rest of + // the ordering (max heap, population ring, search reset, re-enable recording). This test + // never backdates the burn-in stamp. ReferenceChainTestHooks.heapFloorResetForTest0(); - ReferenceChainTestHooks.setHeapFloorRecordingForTest0(true); + restoreHeapFloorDefaults(ACTIVATION_STAMP_UNCHANGED); } } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index e4f3ce717c..4230200214 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -112,6 +112,15 @@ public class ReferenceChainTrackingTest extends AbstractProfilerTest { // for the same cheap-insurance-against-collision reason those two are distinct from each other. private static final int ABANDON_TEST_KLASS_ID = 987203; + // shouldReportAbandonedSearchOnTinyFrontierCap()'s dump pacing. Each dump() rewrites this shared + // JVM's whole recording (O(recording size) I/O), so the test caps its dumps, waits for the first + // post-reset pass without I/O first, and paces the remaining dumps so the last one still lands + // ABANDON_LAST_DUMP_MIN_DELAY_MS after the grace period started. + private static final int ABANDON_MAX_DUMPS = 3; + private static final long ABANDON_PASS_WAIT_MS = 6_000; + private static final long ABANDON_DUMP_RETRY_SPACING_MS = 1_000; + private static final long ABANDON_LAST_DUMP_MIN_DELAY_MS = 8_000; + /** * The {@code datadog.ReferenceChain} ActiveSetting's {@code id} (flightRecorder.cpp's * T_REFERENCE_CHAIN). ActiveSetting events from cpu/wall/memory/nativemem also read @@ -711,30 +720,30 @@ public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { "The qualifying-candidate seeding below rides the debug-only population seams; in a " + "release build the search stays idle without a candidate (the candidate-gate " + "contract), so no abandonment event could ever be produced"); - if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { - // See the javadoc above for why the seeding is needed despite no generations=true of this - // method's own, and why both a gc() and a throwaway dump() must precede it. - System.gc(); - Path warmupDumpPath = Files.createTempFile("referencechains-abandoned-test-warmup-", ".jfr"); + // Release builds never get here (the JUnit assumption above skips them): the seeding below + // needs the debug-only population seams. See the javadoc above for why the seeding is needed + // despite no generations=true of this method's own, and why both a gc() and a throwaway + // dump() must precede it. + System.gc(); + Path warmupDumpPath = Files.createTempFile("referencechains-abandoned-test-warmup-", ".jfr"); + Files.deleteIfExists(warmupDumpPath); + try { + dump(warmupDumpPath); + } finally { Files.deleteIfExists(warmupDumpPath); - try { - dump(warmupDumpPath); - } finally { - Files.deleteIfExists(warmupDumpPath); - } - ReferenceChainTestHooks.resetKlassPopulationForTest0(); - // Gate-only usage: this test drives the search to ABANDONED and asserts on the abandon - // marker, no tagged instance needs to match - so the tid is this thread's real one but any - // distinct tid would do. - int abandonTid = ReferenceChainTestHooks.getTid(); - for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { - ReferenceChainTestHooks.seedKlassPopulationSample0( - ABANDON_TEST_KLASS_ID, epoch * 10, epoch); - ReferenceChainTestHooks.seedTidTrendSample0( - ABANDON_TEST_KLASS_ID, abandonTid, epoch * 3, epoch); - } - ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); } + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + // Gate-only usage: this test drives the search to ABANDONED and asserts on the abandon + // marker, no tagged instance needs to match - so the tid is this thread's real one but any + // distinct tid would do. + int abandonTid = ReferenceChainTestHooks.getTid(); + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0( + ABANDON_TEST_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedTidTrendSample0( + ABANDON_TEST_KLASS_ID, abandonTid, epoch * 3, epoch); + } + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); List gcRootHolder = new ArrayList<>(); gcRootHolder.add(new ChainLink("middle", new ChainLink("leaf"))); @@ -746,28 +755,55 @@ public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { System.gc(); Thread.sleep(100); } + long graceStartNs = System.nanoTime(); Thread.sleep(1500); + // Dump-free probe: every dump() rewrites this shared JVM's whole recording, so wait in memory + // for the BFS thread's first post-reset pass (the one that hits the framecap/ttl cutoff and + // enqueues the abandoned event) before taking the first dump. An accelerator only, never an + // assertion: a search restart also zeroes passesRun, so on timeout just fall through to the + // dumps. + long probeDeadlineNs = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(ABANDON_PASS_WAIT_MS); + int passesRun = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + while (passesRun <= 0 && System.nanoTime() - probeDeadlineNs < 0) { + Thread.sleep(100); + passesRun = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + } + Path dumpPath = Files.createTempFile("referencechains-abandoned-test-", ".jfr"); Files.deleteIfExists(dumpPath); try { // Even with the seeding above clearing the gate, the first pass still has to actually reach // the frontier cap/ttl cutoff inside whatever wall-clock time this shared JVM's scheduling - // grants it - a single fixed-length sleep-then-dump-then-assert previously failed whenever - // that one attempt landed a beat early. Retry across a few more dumps before concluding the - // search genuinely never abandoned. + // grants it. The abandoned event only reaches the recording through a dump(), so each check + // needs a fresh dump - and each dump rewrites the whole shared recording. Dumps are + // therefore capped at ABANDON_MAX_DUMPS and paced so the last one lands at least + // ABANDON_LAST_DUMP_MIN_DELAY_MS after the grace period started; the passesRun probe above + // absorbs most of the scheduling slack without any I/O. + long lastDumpEarliestNs = + graceStartNs + TimeUnit.MILLISECONDS.toNanos(ABANDON_LAST_DUMP_MIN_DELAY_MS); JfrEvents abandoned = null; - for (int attempt = 0; attempt < 8; attempt++) { + int dumpsTaken = 0; + for (int attempt = 0; attempt < ABANDON_MAX_DUMPS; attempt++) { + if (attempt > 0) { + int attemptsLeft = ABANDON_MAX_DUMPS - attempt; // includes the dump about to be taken + long remainingMs = + TimeUnit.NANOSECONDS.toMillis(lastDumpEarliestNs - System.nanoTime()); + Thread.sleep(Math.max(ABANDON_DUMP_RETRY_SPACING_MS, remainingMs / attemptsLeft)); + } dump(dumpPath); + dumpsTaken++; abandoned = verifyEvents(dumpPath, "datadog.ReferenceChainAbandoned", false); if (abandoned.hasItems()) { break; } - Thread.sleep(1000); } assertTrue(abandoned != null && abandoned.hasItems(), - "Expected at least one datadog.ReferenceChainAbandoned event after 3 GCs, an initial " - + "1500ms grace period, and 8 retries 1s apart"); + "Expected a datadog.ReferenceChainAbandoned event within " + dumpsTaken + " of at most " + + ABANDON_MAX_DUMPS + " dumps (after 3 GCs and a 1500ms grace period; the pass " + + "probe waited up to " + ABANDON_PASS_WAIT_MS + "ms, passesRun=" + + ReferenceChainTestHooks.referenceChainPassesRunForTest0() + "; last dump >= " + + ABANDON_LAST_DUMP_MIN_DELAY_MS + "ms after grace start)"); } finally { Files.deleteIfExists(dumpPath); } From 754cb140db4929ce20a0e7fd324aaa26a6bed214 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Thu, 29 Oct 2026 17:08:40 +0100 Subject: [PATCH 07/20] Document urgency latch scope and urgent-no-candidate counter The latch neither starts searches nor feeds the ramp calculation; its only consumer is no-progress-abandonment suppression. The admission boost releases on the raw band comparison, not the latch hysteresis. The candidate-only gate's refused urgent evaluations are now counted. --- doc/architecture/ReferenceChainsSignalsExplained.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/architecture/ReferenceChainsSignalsExplained.md b/doc/architecture/ReferenceChainsSignalsExplained.md index c5a95f1347..8ef3295b56 100644 --- a/doc/architecture/ReferenceChainsSignalsExplained.md +++ b/doc/architecture/ReferenceChainsSignalsExplained.md @@ -22,6 +22,8 @@ Before the first search, `shouldRunPass()` calls `canAffordNewSearch()`. The gat Urgency does not authorize a search. A candidate-less search can never emit a chain, and while urgent the 30-pass no-progress abandonment is suppressed, so it would run to the full TTL doing whole-heap BFS. Urgency applies through the ramp once a search is running. +The tradeoff of this candidate-only gate: a heap-wide leak spread across many classes, none of which individually clears `selectLeakCandidates()`'s per-klass ring-fill/hysteresis gate, never opens the gate, even in the emergency band (projected OOM under 5 minutes). No chains are produced in that case. Each such refused evaluation increments the `reference_chain_urgent_no_candidate` counter (about once per second while urgency is latched and no candidate exists), so the gap is visible in the diagnostics rather than silent, and it can be told apart from an idle healthy process. + The population-trend signal is constructed by the liveness tracker, which fills the per-klass population table and the heap-floor ring when generations tracking OR liveheap tracking is active (the two options drive the same tracking mechanisms; liveheap additionally preserves stack traces for its samples). When neither is active, no signal can ever be produced: `hasLeakSignal()` returns false, and the collector stays idle - it never runs a pass, restart, or search. ## Scheduling a running search From 443aef5000e0f2df10d16bb5293c1cfc26b3a1b7 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 10:56:58 +0100 Subject: [PATCH 08/20] Harden the fix-round test seams against review-delta findings - setLeakSignalActivatedNsForTest0 rejects negative arguments instead of casting them to a near-UINT64_MAX burn-in stamp that would read as burn-in-never-passed. - evaluateGateAfterSearchReset samples the per-klass candidate table before the search reset: a pass decided on an organic candidate can straddle the reset (the pass counter increments at pass end), and the candidate stays selectable from the not-reset population table, so the pre-reset read keeps the candidateSeen bracket honest. - The abandonment test's warmup dump path is deleted inside the try/finally guard, closing the leak window between createTempFile and guard entry. --- ddprof-lib/src/main/cpp/javaApi.cpp | 8 ++++++++ .../AggressiveLeakReferenceChainTest.java | 9 ++++++++- .../referencechains/ReferenceChainTrackingTest.java | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index a689a23f30..f506215f41 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -1423,6 +1423,14 @@ Java_com_datadoghq_profiler_JavaProfiler_backdateLeakSignalActivationForTest0( extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_setLeakSignalActivatedNsForTest0( JNIEnv *env, jclass unused, jlong activatedNs) { + if (activatedNs < 0) { + // A raw jlong->u64 cast would turn a negative argument into a stamp near UINT64_MAX, + // which secondsToOOM() reads as burn-in-never-passed - a mis-wired test would then look + // like a dormant projection instead of an invalid input. + throwNew(env, "java/lang/IllegalArgumentException", + "activatedNs must be >= 0 (0 = never activated)"); + return; + } ProfiledThread::initCurrentThreadSignalSafe(); LivenessTracker::instance()->setLeakSignalActivatedNsForTest((u64)activatedNs); } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java index 352fc36f2c..3140c2c431 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -129,8 +129,15 @@ private static final class GateEvaluation { * told apart from a contract violation (see {@link #checkNoCandidateFreeActivity}). */ private static GateEvaluation evaluateGateAfterSearchReset() { - ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + // Sampled BEFORE the reset: a pass whose gate decision was made on an organic candidate + // executes under the engine lock, so the decision and its pass counter increment can + // straddle the reset (the counter is zeroed by the reset, and only at pass END - a long + // in-flight pass is invisible to a stable-counter check). The candidate that legitimized + // such a pass stays selectable from the population table - deliberately not reset here - + // so reading it before the reset, while the decision is fresh, is what keeps the + // candidateSeen bracket honest. int[] candidatesBefore = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); int passesBefore = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); GateEvaluation e = new GateEvaluation(); e.gateOpen = ReferenceChainTestHooks.shouldRunPassForTest0(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index 4230200214..b13b7478b0 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -726,8 +726,10 @@ public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { // dump() must precede it. System.gc(); Path warmupDumpPath = Files.createTempFile("referencechains-abandoned-test-warmup-", ".jfr"); - Files.deleteIfExists(warmupDumpPath); try { + // Inside the guard: createTempFile() already created the file, so a throw between + // creation and the finally below would otherwise leak it. + Files.deleteIfExists(warmupDumpPath); dump(warmupDumpPath); } finally { Files.deleteIfExists(warmupDumpPath); From 951111761b99ae9474fd56694f9e1bd51ef15f2c Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 12:33:51 +0100 Subject: [PATCH 09/20] Clarify concurrency-legality comments in reference-chain stop path - The stop()-side reset of the urgency/baseline fields now states the full claim: production writes go through the BFS thread only, and the join in stopThread() is what makes the control-thread plain stores safe. - The leak-signal test-seam block now records why UNIT_TEST is required beyond DEBUG (gtestRelease compiles with -DNDEBUG and no -DDEBUG) and which consumer class needs each arm. --- ddprof-lib/src/main/cpp/livenessTracker.h | 12 ++++++++---- ddprof-lib/src/main/cpp/referenceChains.cpp | 7 ++++++- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 802a05118e..4e595eeb94 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -1705,10 +1705,14 @@ class alignas(alignof(SpinLock)) LivenessTracker { #if defined(DEBUG) || defined(UNIT_TEST) // Test-only mutators of the leak-signal gates and the burn-in stamp. - // Compiled in for DEBUG builds (they back the DEBUG-only JNI seams in - // javaApi.cpp) and for gtest builds (GtestTaskBuilder adds -DUNIT_TEST to - // every gtest compile); never in the shipped release library, so no release - // consumer can flip the leak-signal gate or backdate the burn-in. + // Two consumer classes, neither sufficient alone: DEBUG backs the JNI seams + // in javaApi.cpp used by ddprof-test against the debug library build (not a + // gtest build, so no UNIT_TEST there), and UNIT_TEST keeps the symbols in + // the gtest binaries built from non-debug configurations - gtestRelease + // compiles with -DNDEBUG and no -DDEBUG (ConfigurationPresets), and its + // gtests link against these directly. The shipped release library defines + // neither, so no release consumer can flip the leak-signal gate or backdate + // the burn-in. // Sets the two leak-signal gates directly, bypassing initialize() (which // requires a live JVM - VM::hotspot_version()/VM::jni(), see that method's diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 6286df5e7d..ae00c455e2 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -410,10 +410,15 @@ void ReferenceChainTracker::stop() { store(_abandon_reason, (u8)SearchAbandonReason::RECORDING_END); storeRelease(_search_state, (u8)SearchState::ABANDONED); } + // The four fields reset below are written in production only by the BFS thread + // (isUrgent(), updateUrgentAdmission(), restartSearch()), as plain non-atomic + // stores. Resetting them from this control thread is safe only because + // stopThread() has already joined that thread: the join both removes the only + // concurrent writer and orders these resets after everything the BFS thread + // ever wrote. store(_search_start_ns, (u64)0); _urgent_latched = false; _urgent_release_ticks = 0; - // BFS-thread owned like the two fields above; stopThread() has joined that thread. _admission_growth_baseline = 0; // Same per-search candidate/discovered canary state runPass()'s terminal branch and // restartSearch() clear - zeroing the counts here closes the stale-chain windows in From a8d58aa3bb38637065befc0b442aa33aa93e1db1 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 12:49:55 +0100 Subject: [PATCH 10/20] Trim comment verbosity in the two review-follow-up blocks --- ddprof-lib/src/main/cpp/livenessTracker.h | 12 ++++-------- ddprof-lib/src/main/cpp/referenceChains.cpp | 9 +++------ 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 4e595eeb94..eadc23bc81 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -1705,14 +1705,10 @@ class alignas(alignof(SpinLock)) LivenessTracker { #if defined(DEBUG) || defined(UNIT_TEST) // Test-only mutators of the leak-signal gates and the burn-in stamp. - // Two consumer classes, neither sufficient alone: DEBUG backs the JNI seams - // in javaApi.cpp used by ddprof-test against the debug library build (not a - // gtest build, so no UNIT_TEST there), and UNIT_TEST keeps the symbols in - // the gtest binaries built from non-debug configurations - gtestRelease - // compiles with -DNDEBUG and no -DDEBUG (ConfigurationPresets), and its - // gtests link against these directly. The shipped release library defines - // neither, so no release consumer can flip the leak-signal gate or backdate - // the burn-in. + // DEBUG backs the JNI seams javaApi.cpp exposes to ddprof-test (debug + // library build); UNIT_TEST covers the gtest binaries, whose release + // configuration compiles with -DNDEBUG and no -DDEBUG. The shipped + // release library defines neither. // Sets the two leak-signal gates directly, bypassing initialize() (which // requires a live JVM - VM::hotspot_version()/VM::jni(), see that method's diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index ae00c455e2..22653f5ac3 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -410,12 +410,9 @@ void ReferenceChainTracker::stop() { store(_abandon_reason, (u8)SearchAbandonReason::RECORDING_END); storeRelease(_search_state, (u8)SearchState::ABANDONED); } - // The four fields reset below are written in production only by the BFS thread - // (isUrgent(), updateUrgentAdmission(), restartSearch()), as plain non-atomic - // stores. Resetting them from this control thread is safe only because - // stopThread() has already joined that thread: the join both removes the only - // concurrent writer and orders these resets after everything the BFS thread - // ever wrote. + // Written in production only by the BFS thread (isUrgent(), + // updateUrgentAdmission(), restartSearch()) as plain stores; safe to reset + // from this thread only because stopThread() has joined that thread. store(_search_start_ns, (u64)0); _urgent_latched = false; _urgent_release_ticks = 0; From 9af062ceb4f8c2f21f7c6b806acfadb500f2eb91 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 12:56:47 +0100 Subject: [PATCH 11/20] Deduplicate comment content across the leak-signal gate seams Each fact now has one home: the generations/liveheap equivalence lives on _leak_signal_enabled, the SPSC acquire pairing on newestHeapFloorSample()'s definition, the burn-in rationale on OOM_PROJECTION_BURN_IN_NS; the other sites point instead of restating. --- ddprof-lib/src/main/cpp/livenessTracker.cpp | 5 +- ddprof-lib/src/main/cpp/livenessTracker.h | 57 +++++++-------------- ddprof-lib/src/main/cpp/referenceChains.cpp | 7 +-- ddprof-lib/src/main/cpp/referenceChains.h | 6 +-- 4 files changed, 23 insertions(+), 52 deletions(-) diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index 58044e8d71..4c207bd152 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -2132,10 +2132,7 @@ Error LivenessTracker::initialize(Arguments &args) { // observe the flag it was actually started with, even though the tracking // table itself persists across recordings. _gc_generations.store(args._gc_generations, std::memory_order_relaxed); - // Either generations tracking or liveheap tracking is a sufficient source - // for the population-trend signal (they drive the same mechanisms here; - // liveheap additionally preserves stack traces for its samples) - see - // _leak_signal_enabled's own comment (livenessTracker.h). + // See _leak_signal_enabled's own comment for why either source suffices. _leak_signal_enabled.store(args._gc_generations || args._record_liveness, std::memory_order_relaxed); diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index eadc23bc81..876daf60d5 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -466,18 +466,13 @@ class alignas(alignof(SpinLock)) LivenessTracker { // Effective leak-signal availability: true when the population-trend signal // consumed by ReferenceChainTracker::hasLeakSignal() can be constructed at - // all. generations tracking and liveheap tracking drive the same - // mechanisms here (per-klass population table, heap-floor ring, - // secondsToOOM() projection) - liveheap additionally preserves stack - // traces for its live samples - so either option is sufficient. When - // neither is on, there is no signal source and the reference-chain - // collector must stay idle (hasLeakSignal() -> false): running whole-heap - // searches with zero candidates "just in case" is the exact waste the - // leak-signal gate exists to prevent (observed as a frontier table grown - // to its cap with reference_chain_candidate_count == 0 in a healthy - // process when this used to fall back to "always true"). Set from - // args._gc_generations || args._record_liveness in initialize(). Same - // atomicity/reader argument as _gc_generations above. + // all. Generations tracking and liveheap tracking drive the same mechanisms + // here (per-klass population table, heap-floor ring, secondsToOOM() + // projection) - liveheap additionally preserves stack traces - so either + // option suffices; with neither on, hasLeakSignal() -> false and the + // reference-chain collector must stay idle instead of searching with zero + // candidates. Set from args._gc_generations || args._record_liveness in + // initialize(). Same atomicity/reader argument as _gc_generations above. std::atomic _leak_signal_enabled; // OS::nanotime() of the first initialize() that enabled the leak signal; 0 @@ -863,11 +858,8 @@ class alignas(alignof(SpinLock)) LivenessTracker { jlong maxHeapBytes() const { return _max_heap_bytes; } // Reads the used-bytes value of the newest _heap_floor_ring sample (0 when - // the ring is empty). Matches recordHeapFloorSampleUnchecked()'s SPSC - // publish order - loadAcquire() on the index fields is what makes the - // payload store visible. Consumed by ReferenceChainTracker's urgent-admission - // gate as the freshest physical growth evidence; it must observe the same - // samples the projection does, hence the same acquire pairing. + // the ring is empty) - the urgent-admission gate's freshest physical growth + // evidence. See the definition for the SPSC acquire pairing. u64 newestHeapFloorSample() const; constexpr static int MAX_LEAK_CANDIDATES = 5; @@ -1050,23 +1042,14 @@ class alignas(alignof(SpinLock)) LivenessTracker { // pattern selectLeakCandidates()/getLiveTraceIds() already use. jobject resolveCandidateRepresentative(JNIEnv *env, u32 klass_id); - // Read-only observation point for the explicit generations option - // (_gc_generations; see that member's own comment), so tests and - // diagnostics can tell it apart from the effective leakSignalEnabled() gate - // (generations OR liveheap). No production code keys on it: since the - // reference-chain signal gates moved to leakSignalEnabled(), the - // population, heap-floor and search gates all read _leak_signal_enabled. - // This accessor never toggles the flag. + // Read-only observation point for the explicit generations option (see + // _gc_generations's own comment); no production code keys on it. bool gcGenerationsEnabled() const { return _gc_generations.load(std::memory_order_relaxed); } - // Exposes the effective leak-signal availability (see _leak_signal_enabled's - // own comment) - the gate ReferenceChainTracker::hasLeakSignal() keys on. - // True when generations tracking OR liveheap tracking is on; false means no - // population-trend signal can ever be constructed and the reference-chain - // collector must not search. Read-only; this accessor never toggles the - // flag. + // The effective gate ReferenceChainTracker::hasLeakSignal() keys on (see + // _leak_signal_enabled's own comment). bool leakSignalEnabled() const { return _leak_signal_enabled.load(std::memory_order_relaxed); } @@ -1088,11 +1071,9 @@ class alignas(alignof(SpinLock)) LivenessTracker { // // The projection feeds the urgency latch (ReferenceChainTracker::isUrgent()), // the urgent admission ramp (ReferenceChainTracker::updateUrgentAdmission()) - // and threadLoop()'s OOM cadence/budget ramp. It does NOT start a search: - // ReferenceChainTracker::hasLeakSignal() still returns true only when - // selectLeakCandidates() yields a klass, urgency or not. Not a claim that - // growth stays linear, only that a short-horizon linear extrapolation is a - // reasonable urgency signal. + // and threadLoop()'s OOM cadence/budget ramp; it never starts a search (see + // OOM_URGENT_THRESHOLD_S). It is a short-horizon linear extrapolation, not a + // claim that growth stays linear. // Returns a negative value if the chosen ring is not filled enough yet // (leakSignalEnabled() is off, or too few GC epochs have happened), not // rising, or neither _max_heap_bytes nor _container_memory_limit was ever @@ -1109,10 +1090,8 @@ class alignas(alignof(SpinLock)) LivenessTracker { // this method additionally divides by elapsed time, so the same gap // understates the growth rate (overstates the projected time-to-OOM) // rather than the reverse - not solved here. - // Short-horizon linear extrapolation of the rising JVM heap-floor and - // container-memory trends against their limits (see the long comment in - // livenessTracker.h). Returns -1 while the leak signal is in its burn-in - // (see OOM_PROJECTION_BURN_IN_NS). + // Returns -1 while the leak signal is in its burn-in (see + // OOM_PROJECTION_BURN_IN_NS); otherwise see the comment above. double secondsToOOM() const; // secondsToOOM() withholds its projection (returns -1) for this long after diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 22653f5ac3..b125df970e 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -778,11 +778,8 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { bool ReferenceChainTracker::isUrgent() const { static_assert(LivenessTracker::OOM_PROJECTION_BURN_IN_NS / 1e9 >= OOM_URGENT_THRESHOLD_S, "burn-in must cover the urgent horizon"); - // Warmup is filtered inside secondsToOOM(): no projection (-1) until - // OOM_PROJECTION_BURN_IN_NS after the leak signal first activated. Age-based - // on purpose - a guard on the heap-floor ring's time span shrinks with GC - // frequency and suppressed the latch in exactly the back-to-back-GC - // imminent-OOM case. + // Warmup is filtered inside secondsToOOM(): no projection (-1) until the + // burn-in passes (see OOM_PROJECTION_BURN_IN_NS for why it is age-based). double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); if (seconds_to_oom >= 0 && seconds_to_oom < OOM_URGENT_THRESHOLD_S) { _urgent_release_ticks = 0; diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index b3a00819a9..9e2ba53637 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -640,10 +640,8 @@ class ReferenceChainTracker { // consumed by runPass()'s no-progress abandonment branch, which it suppresses while latched; // (b) updateUrgentAdmission()'s emergency band, where tracking admission goes to 100% without // heap-growth evidence. - // It never authorizes starting a search: hasLeakSignal() still requires selectLeakCandidates() - // to return at least one klass past its per-klass ring-fill/hysteresis gate - // (KLASS_POPULATION_MIN_FILL_FOR_TREND plus LEAK_TREND_HYSTERESIS_BASE/CORROBORATED epochs, - // livenessTracker.h), and urgency never stands in for that. + // It never authorizes starting a search - urgency never stands in for + // selectLeakCandidates()'s per-klass gate (see hasLeakSignal()). static constexpr double OOM_URGENT_THRESHOLD_S = 300.0; // 5 minutes // Release side of OOM_URGENT_THRESHOLD_S's hysteresis (see _urgent_latched). From d698771d54fc3e56703e2c480211821204748cb9 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 13:30:11 +0100 Subject: [PATCH 12/20] Update architecture indexes for the leak-signal gate The reference-chain subsystem contract changed: collection stays idle without a generations or liveheap leak-signal source. Reflect that in the AGENTS.md subsystem row and the doc/README.md index entries. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index fec4357392..94080e0985 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,7 +151,7 @@ before working on a subsystem. | TLS context (thread-local tracing context, distributed tracing) | [doc/architecture/TLSContext.md](doc/architecture/TLSContext.md) | | Profiling engines (CPU, wall-clock, allocation, native memory, liveness) | [doc/architecture/ProfilingEngines.md](doc/architecture/ProfilingEngines.md) | | JFR integration (FlightRecorder, metadata, constant pools, buffers) | [doc/architecture/JfrIntegration.md](doc/architecture/JfrIntegration.md) | -| Live-heap reference chains (implementation, signal scheduling, design) | [doc/architecture/LiveHeapReferenceChainsImplementation.md](doc/architecture/LiveHeapReferenceChainsImplementation.md), [doc/architecture/ReferenceChainsSignalsExplained.md](doc/architecture/ReferenceChainsSignalsExplained.md), [doc/ReferenceChainsDesign.md](doc/ReferenceChainsDesign.md), [doc/ReferenceChainsCollectionSummary.md](doc/ReferenceChainsCollectionSummary.md) | +| Live-heap reference chains (implementation, leak-signal gate and scheduling, design) — collection stays idle without a generations or liveheap signal | [doc/architecture/LiveHeapReferenceChainsImplementation.md](doc/architecture/LiveHeapReferenceChainsImplementation.md), [doc/architecture/ReferenceChainsSignalsExplained.md](doc/architecture/ReferenceChainsSignalsExplained.md), [doc/ReferenceChainsDesign.md](doc/ReferenceChainsDesign.md), [doc/ReferenceChainsCollectionSummary.md](doc/ReferenceChainsCollectionSummary.md) | | Native memory profiling (malloc/free interception, sampling) | [doc/architecture/NativeMemoryProfiling.md](doc/architecture/NativeMemoryProfiling.md) | | Startup sanity checks | [doc/architecture/SanityChecks.md](doc/architecture/SanityChecks.md) | | SIGSEGV-safe library patching | [doc/architecture/SigsegvPatching.md](doc/architecture/SigsegvPatching.md) | From 2c4566e398005f0199bdd0fe5776295be974798e Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 13:37:44 +0100 Subject: [PATCH 13/20] Drop gate semantics from the index entries Indexes only name the subsystem; activation semantics belong to the linked pages. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 94080e0985..ab6ca56e2f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -151,7 +151,7 @@ before working on a subsystem. | TLS context (thread-local tracing context, distributed tracing) | [doc/architecture/TLSContext.md](doc/architecture/TLSContext.md) | | Profiling engines (CPU, wall-clock, allocation, native memory, liveness) | [doc/architecture/ProfilingEngines.md](doc/architecture/ProfilingEngines.md) | | JFR integration (FlightRecorder, metadata, constant pools, buffers) | [doc/architecture/JfrIntegration.md](doc/architecture/JfrIntegration.md) | -| Live-heap reference chains (implementation, leak-signal gate and scheduling, design) — collection stays idle without a generations or liveheap signal | [doc/architecture/LiveHeapReferenceChainsImplementation.md](doc/architecture/LiveHeapReferenceChainsImplementation.md), [doc/architecture/ReferenceChainsSignalsExplained.md](doc/architecture/ReferenceChainsSignalsExplained.md), [doc/ReferenceChainsDesign.md](doc/ReferenceChainsDesign.md), [doc/ReferenceChainsCollectionSummary.md](doc/ReferenceChainsCollectionSummary.md) | +| Live-heap reference chains (implementation, leak-signal gate, scheduling, design) | [doc/architecture/LiveHeapReferenceChainsImplementation.md](doc/architecture/LiveHeapReferenceChainsImplementation.md), [doc/architecture/ReferenceChainsSignalsExplained.md](doc/architecture/ReferenceChainsSignalsExplained.md), [doc/ReferenceChainsDesign.md](doc/ReferenceChainsDesign.md), [doc/ReferenceChainsCollectionSummary.md](doc/ReferenceChainsCollectionSummary.md) | | Native memory profiling (malloc/free interception, sampling) | [doc/architecture/NativeMemoryProfiling.md](doc/architecture/NativeMemoryProfiling.md) | | Startup sanity checks | [doc/architecture/SanityChecks.md](doc/architecture/SanityChecks.md) | | SIGSEGV-safe library patching | [doc/architecture/SigsegvPatching.md](doc/architecture/SigsegvPatching.md) | From b4daf9d666dd81903fb47abd4d264f8d71042da0 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 13:42:49 +0100 Subject: [PATCH 14/20] Track gtest .inc/.h includes in the up-to-date check The per-test compile task wired only the test .cpp into sources, while the .inc test partitions and test-local headers resolve via quoted includes relative to the test directory and appear in no include path. An .inc-only edit therefore reported UP-TO-DATE and silently ran a stale binary with the old tests. Register them as task inputs (not sources: they must not be compiled standalone) and drop the touch-the-.cpp workaround comment. --- .../datadoghq/native/gtest/GtestTaskBuilder.kt | 15 +++++++++++++++ ddprof-lib/src/test/cpp/referenceChains_ut.cpp | 5 ----- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt index 3a0ea225cc..da47ac79cb 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt @@ -122,6 +122,21 @@ class GtestTaskBuilder( testFile ) } + + // The test .cpp pulls sibling .inc/.h files in via quoted includes, + // which resolve relative to the test file's directory without any -I + // flag, so they appear in no include path. They must not be compile + // sources (each .inc is a test partition included exactly once; + // compiling them standalone would emit duplicate symbols), but they + // are real compile dependencies. Registered as task inputs instead, + // so an .inc/.h-only edit invalidates the up-to-date check instead + // of silently running a stale binary with the old tests. + inputs.files( + project.fileTree(testFile.parentFile) { + include("**/*.inc", "**/*.h") + } + ) + includes.from(includeFiles) objectFileDir.set(objDir) } diff --git a/ddprof-lib/src/test/cpp/referenceChains_ut.cpp b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp index 84ae571497..4bf35ef141 100644 --- a/ddprof-lib/src/test/cpp/referenceChains_ut.cpp +++ b/ddprof-lib/src/test/cpp/referenceChains_ut.cpp @@ -46,9 +46,6 @@ static ReferenceChainsGlobalSetup global_setup; [[maybe_unused]] static const bool kRcDebugLevelPinnedForTests = setenv("DD_PROFILING_REFERENCE_CHAINS_DEBUG", "2", 1) == 0; -// NOTE: the .inc files below are NOT registered as compile inputs by the gtest -// plugin (GtestTaskBuilder wires only the .cpp into `sources`), so editing an -// .inc alone leaves this binary stale - touch this file to force recompilation. #include "referenceChainsCoreTests.inc" #include "referenceChainsBfsTests.inc" #include "referenceChainsLifecycleTests.inc" @@ -61,6 +58,4 @@ static ReferenceChainsGlobalSetup global_setup; #include "referenceChainsEventTests.inc" #include "referenceChainsPesTests.inc" -// Recompilation marker: .inc files are not compile inputs (see note above). - From 08267451568ae32756f7f6479c3e9a2715da489c Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 13:49:16 +0100 Subject: [PATCH 15/20] Shorten the gtest .inc inputs comment --- .../com/datadoghq/native/gtest/GtestTaskBuilder.kt | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt index da47ac79cb..5c2a561207 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt @@ -123,14 +123,10 @@ class GtestTaskBuilder( ) } - // The test .cpp pulls sibling .inc/.h files in via quoted includes, - // which resolve relative to the test file's directory without any -I - // flag, so they appear in no include path. They must not be compile - // sources (each .inc is a test partition included exactly once; - // compiling them standalone would emit duplicate symbols), but they - // are real compile dependencies. Registered as task inputs instead, - // so an .inc/.h-only edit invalidates the up-to-date check instead - // of silently running a stale binary with the old tests. + // Test .inc/.h files resolve via quoted includes relative to the test + // directory and appear in no include path, so register them here or an + // .inc-only edit reports UP-TO-DATE and runs a stale binary. Inputs, + // not sources: compiling an .inc standalone would emit duplicate symbols. inputs.files( project.fileTree(testFile.parentFile) { include("**/*.inc", "**/*.h") From 476f5d2639a541650d07c4abdcda464c4bd7fa67 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 14:11:36 +0100 Subject: [PATCH 16/20] Remove internal episode references from comments Comments must state the mechanism, not how it was observed. Drop the environment, counter values and recording-size anecdotes; the factual claim each comment makes is unchanged. --- ddprof-lib/src/main/cpp/referenceChains.cpp | 10 ++++------ ddprof-lib/src/test/cpp/livenessTracker_ut.cpp | 4 ++-- ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc | 5 +---- ddprof-lib/src/test/cpp/referenceChainsOomTests.inc | 8 ++++---- .../src/test/cpp/referenceChainsTrackerTests.inc | 6 +++--- 5 files changed, 14 insertions(+), 19 deletions(-) diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index b125df970e..82c6c5b7c2 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -873,12 +873,10 @@ bool ReferenceChainTracker::hasLeakSignal() { // tagged the search can never emit a chain, and while urgent the 30-pass // no-progress abandonment is suppressed (runPass()'s terminal gate), so a // candidate-less search would run to the full auto-tuned TTL doing whole-heap - // BFS - pure cost. Observed in staging: a fresh process's warmup heap-floor - // rise tripped the 5-minute projection and a candidate-less search grew a - // 262,144-entry frontier (liveness_urgent_boost_admits=2495) with zero chains - // for the whole recording. Urgency still applies through the ramp (cadence, - // pause target, budget boost, 100% liveness admission) once a real candidate - // justifies the search. + // BFS - pure cost. A warmup heap-floor rise can trip the 5-minute projection + // with no tagged klass, so the refusal is unconditional, not a heuristic. + // Urgency still applies through the ramp (cadence, pause target, budget + // boost, 100% liveness admission) once a real candidate justifies the search. TEST_LOG_SUMMARY("ReferenceChainTracker::hasLeakSignal -> %s (secondsToOOM=%.1f, " "candidates=%d, urgent=%d)", n > 0 ? "true" : "false", seconds_to_oom, n, urgent); diff --git a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp index d04ccf0950..f276b0af07 100644 --- a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp +++ b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp @@ -1238,8 +1238,8 @@ TEST(LivenessTrackerSessionTest, ResetSessionClearsOnlyRecordingGates) { // hasLeakSignal() must never authorize a search without a leak-signal source: // with neither generations nor liveheap tracking active, the population table // and heap-floor ring never fill, and searching would run whole-heap BFS -// passes with zero candidates (observed in staging as a frontier table grown -// to its cap with reference_chain_candidate_count == 0 in a healthy process). +// passes with zero candidates, growing the frontier table to its cap while +// emitting nothing. TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { LivenessTracker *tracker = LivenessTracker::instance(); diff --git a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc index d5bf85f16d..96c82590e2 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsBfsTests.inc @@ -898,10 +898,7 @@ TEST_F(ReferenceChainsBfsTest, CanaryLaneBacksOffWithoutProgressAndResetsOnProgr // With no leak-signal source active (neither generations nor liveheap // tracking), the collector must stay completely idle: no first pass and no // restart, ever. Searching without a signal runs whole-heap BFS passes with -// zero candidates - the exact waste the leak-signal gate exists to prevent -// (observed in staging: frontier grown to its cap with -// reference_chain_candidate_count == 0 in a healthy process, because the -// no-signal branch used to return true unconditionally). +// zero candidates - the exact waste the leak-signal gate exists to prevent. TEST_F(ReferenceChainsBfsTest, NoSearchWithoutLeakSignalSource) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index 297d7e6b3c..2080bc541c 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -186,10 +186,10 @@ TEST_F(OomUrgencyTest, RelatchAfterReleaseGetsFreshEntitlement) { } // During the burn-in (OOM_PROJECTION_BURN_IN_NS after the leak signal first -// activated) no projection is offered, so urgency never latches: a fresh -// process's warmup growth curve projects an imminent OOM just as a real leak -// does (the staging incident: a fresh process latched urgency and ran a -// candidate-less whole-heap search to the TTL). +// activated) no projection is offered, so urgency never latches: a warmup +// growth curve projects an imminent OOM just as a real leak does, and a +// burn-in-less gate would latch urgency and start a candidate-less +// whole-heap search that runs to the TTL. TEST_F(OomUrgencyTest, BurnInDoesNotLatchUrgency) { LivenessTracker::instance()->setLeakSignalActivatedNsForTest(OS::nanotime()); // Earlier scenarios advanced the shared seeding clock far past this test's diff --git a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc index 8980e096de..b43e48d6c9 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc @@ -1398,9 +1398,9 @@ TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { // hasLeakSignal()'s urgent latch does NOT authorize a candidate-less search: with // nothing tagged the search can never emit a chain, and while urgent the 30-pass // no-progress abandonment is suppressed, so it would run to the full TTL doing -// whole-heap BFS (observed in staging: a warmup heap-floor rise latched urgency -// and a candidate-less search grew a 262,144-entry frontier for the whole -// recording). A search starts only when a klass clears selectLeakCandidates(); +// whole-heap BFS. A warmup heap-floor rise can latch urgency with nothing +// tagged, so the candidate requirement must be unconditional. A search starts +// only when a klass clears selectLeakCandidates(); // urgency then applies through the ramp. The burn-in stamp is backdated so // secondsToOOM()'s age-based burn-in does not mask the latch - this test // isolates the candidate requirement, not the burn-in. From 65a0d3a12752819dd49df3d5d3a6b9666233c1b5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 15:19:27 +0100 Subject: [PATCH 17/20] Stabilize flaky ReferenceChainTrackingTest success paths (PROF-16383) The cache-leak and gc-root tests flake in-order because the search's root walk runs once per search (no re-visit of walked regions), the fixtures' HashMap/ArrayList resize churn invalidates the walked path mid-test, and the BFS thread's own backoff/cooldown scheduling leaves too few passes to retry discovery. Fix, test-only: - Pre-size the fixture cache (1<<17) so the walked table/Node path never dies to resize churn. - Drive bounded passes via the runReferenceChainPass0() debug seams in both success-path tests' retry loops (release stubs are no-ops), so discovery depends on cumulative frontier progress, not BFS cadence. - Widen both grace loops to 10 attempts; add failure diagnostics (leak-signal candidates, BFS pass count, representative frontier state, observed chain[0]s) plus the observedChainHeads() helper. - Correct stale comments (dump() drain semantics, nonexistent writeReferenceChain symbol, MIN_EFFECTIVE_BUDGET floor). Verified: 6/6 + 7/7 forced genuine in-order sweeps (cleanTestSlowDebug), isolated pass; the unmodified baseline reproduces the original flake. --- .../ReferenceChainAssertions.java | 26 +++ .../ReferenceChainTrackingTest.java | 215 ++++++++++++++---- 2 files changed, 196 insertions(+), 45 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java index 28717b4046..36b5295202 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -154,6 +154,32 @@ private static ChainMatch findMatch(IItemCollection events, Class targetClass return null; } + /** + * {@code chain[0]} (full name, same notation as {@link JfrChainMatch#chain}) of every observed + * {@code datadog.ReferenceChain} event in {@code events}, in file order. Diagnostic helper for + * failure messages: a non-empty result whose heads all differ from the target class means the + * candidate mechanism fired for OTHER klasses, while an empty result means no chain was ever + * built. + */ + public static List observedChainHeads(JfrEvents events) { + List heads = new ArrayList<>(); + if (events == null || !events.hasItems()) { + return heads; + } + for (JfrEvent item : events) { + Object chainValue = item.get("chain"); + if (!(chainValue instanceof Object[])) { + continue; + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0) { + continue; + } + heads.add(classFullName(rawChain[0])); + } + return heads; + } + /** * jafar/{@code JfrEvents}-backed counterpart to {@link #findMatchForClass(IItemCollection, Class)} - * see this class's own header comment for why these are two separate overloads rather than one. diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index b13b7478b0..2f25d8cc95 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -24,6 +24,7 @@ import java.util.concurrent.atomic.AtomicReference; import java.nio.file.Path; import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -51,8 +52,10 @@ * rolling per-klass survivor-count window, gated on the effective leak signal - generations OR * liveheap tracking) and, for each ranked * klass whose representative instance an ordinary {@code runPass()} walk has *already* tagged - * ({@code getTag() > 0} - a read, never a {@code SetTag} seed), reconstructs and emits its chain - * via {@code Profiler::writeReferenceChain()}. {@link #shouldReconstructReferrerChainToGcRoot()} + * ({@code getTag() > 0} - a read, never a {@code SetTag} seed), reconstructs and caches its chain + * ({@code buildChainEvent()}/{@code cacheResolvedChain()}); {@code Profiler::dump()} copies that + * cache into the recording via {@code FlightRecorder::recordReferenceChain()}. + * {@link #shouldReconstructReferrerChainToGcRoot()} * exercises this end to end against a real JVM, real GCs, and a real JVMTI heap walk - the * synthetic-frontier reconstruction-correctness proof lives in * {@code referenceChainJfrRoundtrip_ut.cpp}/{@code ReferenceChainJfrParserTest}. @@ -146,17 +149,28 @@ protected String getProfilerCommand() { // root-reachable graph, and a long ttl avoids a TTL abandonment mid-test - either would call // releaseSearchTags() and permanently zero every tag this test depends on. // + // budget=4000: each pass admits at most this many new frontier edges, resuming the persisted + // frontier from the previous pass. A fully-populated 69k-entry cache puts its CachedPayload + // values several hundred thousand admitted edges deep into the walk (roughly three edges per + // entry - Node, key, value - plus the test's own 69k-element keys array hanging off the + // same stack root), and the traversal position of that subtree varies with heap layout - so + // the number of passes (not the per-pass budget) decides when the leak-tag interception and + // the representative tagging finally see a CachedPayload. The retry loops below therefore + // drive bounded passes via the runReferenceChainPass0() seam instead of relying on the BFS + // thread's own cadence, and the budget stays small so each driven pass is cheap. + // // pausetarget=5000: the pacing controller measures this JVM's real FollowReferences call // latency at ~150ms early on, climbing past a full second as this test's own allocation // keeps handing the search more to discover (each pass re-scans the whole already-discovered // set, so cost grows with cumulative progress, not with this test's allocation rate). Against // the default 5ms ceiling every observed pass is "over ceiling even at the floor", so the - // controller settles at MIN_EFFECTIVE_BUDGET=50 edges/pass and 4s between passes - a real but - // glacial rate this test's wall-clock budget cannot outwait, and one a merely-generous - // ceiling (e.g. 500ms) only postpones: once cumulative progress pushes the real per-pass cost - // past *that* ceiling too, the same throttling recurs. A ceiling comfortably above the - // highest per-call cost this test's population growth is expected to reach lets the - // controller keep the configured budget=4000 for the whole run. + // controller collapses to its MIN_EFFECTIVE_BUDGET floor (2000 edges/pass) and widens the + // gap between passes up to 4s - a real but glacial rate this test's wall-clock budget cannot + // outwait, and one a merely-generous ceiling (e.g. 500ms) only postpones: once cumulative + // progress pushes the real per-pass cost past *that* ceiling too, the same throttling + // recurs. A ceiling comfortably above the highest per-call cost this test's population + // growth is expected to reach lets the controller keep the configured budget (plus its + // borrowing headroom) for the whole run. // // painbudget=100: the cache-leak test shares this singleton search and needs a restart once // the first search reaches a terminal state. PainBudget gates that restart on a cooldown of @@ -164,13 +178,14 @@ protected String getProfilerCommand() { // search this large - far past this test's bounded retry loop. Nothing else in this JVM // competes for the pain budget, so keep the gate affordable immediately. // - // firstpassbudget=200000: root/stack-ref enumeration shares budget=4000 with expandFrontier() - // unless overridden - and in this JVM's real root-reachable graph that left this method's own - // cache HashMap (a direct stack-local root) at the mercy of whatever order JVMTI enumerates - // roots in: budget=4000 was observed exhausting itself on other roots' subtrees every single - // attempt, never once admitting a single edge from this thread's own stack. Overriding just - // root enumeration's budget gives every enumeration attempt enough headroom to reach this - // thread's stack without slowing the steady-state expansion passes that follow. + // firstpassbudget=200000: root/stack-ref enumeration gets its own budget, separate from + // expandFrontier()'s per-pass edge budget - in this JVM's real root-reachable graph a small + // root-enumeration budget left this method's own cache HashMap (a direct stack-local root) + // at the mercy of whatever order JVMTI enumerates roots in: the whole budget was observed + // exhausting itself on other roots' subtrees every single attempt, never once admitting a + // single edge from this thread's own stack. Giving root enumeration its own generous budget + // lets every enumeration attempt reach this thread's stack without slowing the steady-state + // expansion passes that follow. // memory=64:l:1.0 - the explicit 100% live-sample ratio is load-bearing: without a ratio // segment the default is 10% and LivenessTracker::track() drops 90% of tracked instances // probabilistically, thinning the pool the representative-minting and candidate trend rings @@ -346,10 +361,10 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { // forked test JVM every other ddprof-test class shares (-Xmx512m, no forkEvery) - a prior // CI run hit "Java heap space" with the cap at 25. // - // Per-round growth is clamped to round 10: rounds past 10 exist only to give - // pollWatchedTargets() more retries against the lock-contention race below, not to keep - // building the population trend (already eligible by round 10) - unclamped rounds 11-16 - // each add strictly more retained garbage for no trend benefit. + // Per-round growth is clamped to round 10: rounds past 10 exist only to give the BFS thread + // more scheduling cycles to tag the representative and cache its chain (see the retry + // comment below), not to keep building the population trend (already eligible by round 10) + // - unclamped rounds 11-16 each add strictly more retained garbage for no trend benefit. // // Instance count is sized against ObjectSampler::check(), not this test's "memory=64" // request: the *actual* sampling interval is 256KiB regardless of the small value requested @@ -428,12 +443,19 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { } if (match == null) { - // A quiet window with no dump() in flight: Profiler::dump() holds every entry of _locks[] - // exclusively for its own _jfr.dump() call - the same array writeReferenceChain() needs a - // slot from. Back-to-back rounds leave pollWatchedTargets() little to no window to win - // that race; sleeping briefly here, then dumping again with no intervening gc() (so - // cleanup_table()'s epoch-advance pass stays a no-op and the population trend is - // undisturbed), gives it one. + // A chain only reaches a recording after the BFS thread's passes have tagged the + // representative (or leak-tag-correlated a discovered instance) and cached it in + // _resolved_chains - Profiler::dump() copies that cache out itself, so once cached, the + // very next dump emits the event. Sleeping briefly here, then dumping again with no + // intervening gc() (so cleanup_table()'s epoch-advance pass stays a no-op and the + // population trend is undisturbed) buys wall-clock for that; the debug-only seam passes + // make the frontier progress deterministic on top (same rationale as the cache-leak + // test's grace loop below - this fixture's own ArrayList resizes invalidate the walked + // element path the same way, and the BFS thread's pass availability is governed by its + // own backoff/pain-budget scheduling). + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + driveReferenceChainPasses(2); + } Thread.sleep(300); dump(scratchDumpPath); match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); @@ -441,18 +463,42 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { } // Grace period: the trend only became eligible (ring_fill >= 10) partway through the loop - // above, and the same lock contention can still delay the write past this method's last - // dump() - retry a few more times before concluding the mechanism genuinely did not fire. - for (int attempt = 0; match == null && attempt < 5; attempt++) { + // above, and the BFS thread may still need a few more pass/schedule cycles to tag the + // representative and cache its chain past this method's last round - retry a few more times + // before concluding the mechanism genuinely did not fire. Each attempt also drives bounded + // passes via the runReferenceChainPass0() seam (see the cache-leak test's grace-loop comment + // for why that is load-bearing; the seam stubs are no-ops in release builds). Ten attempts, + // matching the cache-leak test's loop: this fixture's ArrayList resizes churn the walked + // element path just like the cache's HashMap resizes, so late-round discovery needs the + // same scheduling-independent margin. + JfrEvents lastEvents = null; + for (int attempt = 0; match == null && attempt < 10; attempt++) { + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + driveReferenceChainPasses(8); + } Thread.sleep(1000); dump(scratchDumpPath); - match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + lastEvents = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(lastEvents, ChainLink.class); } + // Same failure diagnostics as the cache-leak test's assertion below: empty leak-signal + // candidates mean the trend gate never opened; a non-empty list with no cached chain means + // the walk never admitted/tagged the representative. Debug-only seams; release failures + // keep the plain message. + String failureDiagnostics = ""; + if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { + failureDiagnostics = " [leak-signal candidates=" + + Arrays.toString(ReferenceChainTestHooks.selectLeakCandidateKlassIds0()) + + ", bfsPassesRun=" + ReferenceChainTestHooks.referenceChainPassesRunForTest0() + + ", representativeFrontierState=" + + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(gcRootHolder.get(0)) + + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)]"; + } assertNotNull(match, "Never observed a datadog.ReferenceChain event whose chain[0] is " + ChainLink.class + " after " + gcRootHolder.size() + " retained ChainLink instances across up to " - + totalRounds + " population-growth rounds plus a grace period"); + + totalRounds + " population-growth rounds plus a grace period" + failureDiagnostics); // chain[0] is the *target* object's own class, not its holder's: reconstructChain() appends // each visited FrontierEntry's referrer_klass leaf-to-root, and referrer_klass is populated // from the *discovered object's own* GetClassSignature at insertion time. The target here is @@ -496,11 +542,19 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { @Test @Order(2) public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exception { - // Seed cache with one live entry *before* the resets below: HashMap's backing table also - // starts as a shared, lazily-replaced empty sentinel (allocated on the first put()), so the - // same one-shot-BFS-freeze hazard documented in shouldReconstructReferrerChainToGcRoot() - // applies here too. - Map cache = new HashMap<>(); + // Pre-sized to hold every round's entries without ever crossing HashMap's 0.75 load factor: + // 131072 buckets hold up to 98304 entries, and this fixture caps at 69001 + the seed below. + // This is load-bearing, not a tuning nicety. Every ordinary resize replaces the table and + // rehashes every entry into NEW HashMap$Node objects - the table/Node path the BFS walk + // admitted (and the reconstructed chain routes through) dies, the walk prunes the retired + // entries, and the replacement table is never admitted because the cache HashMap's own + // frontier entry is already EXPANDED (the frozen-children hazard documented in the class + // javadoc above) - traced live as a representative permanently stuck at frontier state -1 + // (never admitted / entry pruned) with zero leak-tag interceptions for the rest of the + // search. A fixed table keeps every Node ever admitted (and the representative's parent + // chain) alive for the whole test, so the representative path and the rotation re-walk see a + // stable, growing container. + Map cache = new HashMap<>(1 << 17); cache.put("cache-leak-seed", new CachedPayload("cache-leak-seed")); if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { // Force a genuine fresh search rather than depend on the natural restartSearch() cycle being @@ -618,9 +672,17 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc } if (match == null) { - // Same lock-contention race shouldReconstructReferrerChainToGcRoot()'s own comment - // describes - a quiet retry gives pollWatchedTargets() a window to win a _locks[] - // slot against Profiler::dump()'s own exclusive hold. + // Same retry rationale as shouldReconstructReferrerChainToGcRoot()'s: a chain only + // reaches a recording after the BFS thread's passes have tagged the representative (or + // leak-tag-correlated a discovered instance) and cached it in _resolved_chains - and the + // BFS thread's own cadence is not something this test controls (see the pass-driving + // comment in the grace loop below). Sleeping briefly, then dumping again with no + // intervening gc() (so cleanup_table()'s epoch-advance pass stays a no-op and the + // population trend is undisturbed) gives it a window; the debug-only seam passes make + // the frontier progress deterministic on top. + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + driveReferenceChainPasses(2); + } Thread.sleep(300); dump(scratchDumpPath); JfrEvents events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); @@ -628,17 +690,57 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc } } - for (int attempt = 0; match == null && attempt < 5; attempt++) { + // Grace period, same shape as the gc-root test's: ten 1s attempts cover the slower + // scheduling of a loaded machine. lastEvents keeps the final dump's parsed events for the + // failure diagnostics below. + // + // Each attempt also drives bounded passes via the runReferenceChainPass0() seam (debug + // builds only; the release stubs are no-ops and the loop degrades to the organic cadence). + // This is load-bearing, not an accelerator: a chain only reaches a recording once the walk + // has admitted the tagged instances' heap region, and how many admitted edges that takes + // varies with heap layout (this fixture's own 69k-element keys array hangs off the same + // stack root as the cache). The BFS thread's pass availability, in turn, is governed by its + // own scheduling - canary-backoff spacing escalates once a pass stops growing the frontier, + // a drained frontier can complete the search, and a completed search restarts only after + // the pain-budget cooldown - any of which can stall passes for tens of seconds on a graph + // this size (trace evidence of failed runs shows bfsPassesRun=3 for an entire test + // duration). Driving passes here makes discovery depend on cumulative frontier progress, + // which is deterministic, instead of on that scheduling. pollReferenceChainTargets0() after + // each pass is what tags the candidate's tracked instances and polls the representative - + // the same per-iteration tail the BFS thread runs. + JfrEvents lastEvents = null; + for (int attempt = 0; match == null && attempt < 10; attempt++) { + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + driveReferenceChainPasses(8); + } Thread.sleep(1000); dump(scratchDumpPath); - JfrEvents events4 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); - match = ReferenceChainAssertions.findMatchForClass(events4, CachedPayload.class); + lastEvents = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(lastEvents, CachedPayload.class); } + // On failure, distinguish the ways this test can come up empty: the leak-candidate gate + // never opened (empty leak-signal candidates - the seeded/organic population trend failed to + // qualify), the gate opened but the walk never admitted the representative (frontier state + // -1 - the cumulative-frontier-progress shortfall the seam-driven passes above exist to + // prevent), or chains were emitted for a DIFFERENT klass (observedChain0s non-empty - the + // candidate mechanism picked another growing klass, e.g. an incidental byte[]/String trend, + // and CachedPayload's own tags were never intercepted). Debug-only seams: release-build + // stubs cannot report any of these states, so release failures keep the plain message. + String failureDiagnostics = ""; + if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { + failureDiagnostics = " [leak-signal candidates=" + + Arrays.toString(ReferenceChainTestHooks.selectLeakCandidateKlassIds0()) + + ", bfsPassesRun=" + ReferenceChainTestHooks.referenceChainPassesRunForTest0() + + ", representativeFrontierState=" + + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(cache.get(keys[0])) + + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)" + + ", observedChain0s=" + ReferenceChainAssertions.observedChainHeads(lastEvents) + "]"; + } assertNotNull(match, "Never observed a datadog.ReferenceChain event whose chain[0] is " + CachedPayload.class + " after " + cache.size() + " cached entries across up to " + totalRounds - + " population-growth rounds plus a grace period"); + + " population-growth rounds plus a grace period" + failureDiagnostics); assertEquals(CachedPayload.class.getName(), match.chain.get(0)); assertTrue(match.targetTag > 0, "targetTag should be a valid, non-zero JVMTI tag"); assertTrue(match.depth >= 0, "depth should be a non-negative hop count"); @@ -672,9 +774,9 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc * object is discovered, so {@code runPass()} abandons the search rather than silently * truncating it - or, if this method lost the race to size the shared {@code FrontierTable}, * its {@code ttl=100} fallback abandons the search instead (by the time this method runs, that - * clock always has). Either path exercises the same {@code buildAbandonedEvent()}/ - * {@code Profiler::writeReferenceChainAbandoned()}/{@code recordReferenceChainAbandoned()} - * write triggered by {@code Profiler::dump()} - this method only asserts that a + * clock always has). Either path exercises the same {@code buildAbandonedEvent()} → + * {@code FlightRecorder::recordReferenceChainAbandoned()} write triggered by + * {@code Profiler::dump()} - this method only asserts that a * {@code datadog.ReferenceChainAbandoned} event exists, not which reason produced it. * *

{@code @Order(3)}: this permanently exhausts the singleton's one-and-only search, so it @@ -837,6 +939,29 @@ private static void awaitAllocatorTid(Thread allocator, AtomicLong allocatorTid, } } + /** + * Runs up to {@code maxPasses} bounded reference-chain search passes synchronously via the + * debug-only {@link ReferenceChainTestHooks#runReferenceChainPass0()} seam, each followed by the + * per-iteration target poll ({@link ReferenceChainTestHooks#pollReferenceChainTargets0()}) - + * the same pass + poll pair one BFS-thread loop iteration performs. A no-op in release builds + * (the seam stubs return immediately); release discovery falls back to the organic BFS-thread + * cadence. + * + *

Passes against a terminal (completed/abandoned) search are no-ops until the BFS thread's + * shouldRunPass() restarts the search - the restart gate belongs to that thread - so a burst of + * seam passes never fights the search lifecycle; it only front-loads the frontier progress the + * BFS thread's own cadence may otherwise deliver late or never (see the grace-loop comment in + * {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()}). + */ + private static void driveReferenceChainPasses(int maxPasses) { + for (int i = 0; i < maxPasses; i++) { + if (!ReferenceChainTestHooks.runReferenceChainPass0()) { + return; // reference chains disabled or the tracker never started + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + } + } + /** Bounded wait for one allocator round; same fail-fast policy as awaitAllocatorTid(). */ private static void awaitAllocatorRound(Semaphore roundDone, Thread allocator, AtomicReference allocatorFailure) throws InterruptedException { From 9f0aecad9a58df310c404a594f677295ac6a32bb Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 16:11:50 +0100 Subject: [PATCH 18/20] Centralize reference-chain failure diagnostics per review findings Review follow-ups (sphinx, 4 accepted LOW findings): - Extract ReferenceChainAssertions.referenceChainFailureDiagnostics(): one canonical failure fingerprint shared by both discovery tests, closing the gc-root test's missing observedChain0s segment. - Move the debug-build gate inside driveReferenceChainPasses(); call sites no longer repeat it. - observedChainHeads() now reports / sentinels instead of silently skipping malformed events; javadoc matches. - Pin the fingerprint format in ReferenceChainTestSeamsTest. --- .../ReferenceChainAssertions.java | 43 +++++++++-- .../ReferenceChainTestSeamsTest.java | 35 +++++++++ .../ReferenceChainTrackingTest.java | 73 ++++++++----------- 3 files changed, 104 insertions(+), 47 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java index 36b5295202..aa6f0ba7b1 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -8,6 +8,7 @@ import com.datadoghq.profiler.JfrEvent; import com.datadoghq.profiler.JfrEvents; import com.datadoghq.profiler.NativeBuildConfig; +import com.datadoghq.profiler.ReferenceChainTestHooks; import org.openjdk.jmc.common.IMCType; import org.openjdk.jmc.common.item.IAccessorKey; import org.openjdk.jmc.common.item.IItem; @@ -18,6 +19,7 @@ import org.openjdk.jmc.common.unit.IQuantity; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; @@ -42,6 +44,9 @@ private ReferenceChainAssertions() {} public static final long LEAK_TAG_POOL_SIZE = 256; + /** {@link #observedChainHeads} entry for a {@code datadog.ReferenceChain} event with an empty chain. */ + private static final String EMPTY_CHAIN_SENTINEL = ""; + /** * True on every native build that compiles javaApi.cpp's {@code #ifdef DEBUG} test seams: debug, * coverage, asan and tsan (all add -DDEBUG, ConfigurationPresets.kt). Release builds get no-op seam stubs. @@ -155,11 +160,14 @@ private static ChainMatch findMatch(IItemCollection events, Class targetClass } /** - * {@code chain[0]} (full name, same notation as {@link JfrChainMatch#chain}) of every observed - * {@code datadog.ReferenceChain} event in {@code events}, in file order. Diagnostic helper for - * failure messages: a non-empty result whose heads all differ from the target class means the - * candidate mechanism fired for OTHER klasses, while an empty result means no chain was ever - * built. + * One entry per observed {@code datadog.ReferenceChain} event in {@code events}, in file order: + * {@code chain[0]} (full name, same notation as {@link JfrChainMatch#chain}), or the sentinel + * {@code ""} for an event with an empty {@code chain} array (or + * {@code ""} if the field did not resolve to an array). Diagnostic helper for + * failure messages: an empty result means no chain event was ever emitted; only sentinels means + * events were emitted but carried no chain; real heads that all differ from the target class mean + * the candidate mechanism fired for OTHER klasses. Never throws on malformed events, unlike + * {@link #findMatchForClass(JfrEvents, Class)}. */ public static List observedChainHeads(JfrEvents events) { List heads = new ArrayList<>(); @@ -169,10 +177,12 @@ public static List observedChainHeads(JfrEvents events) { for (JfrEvent item : events) { Object chainValue = item.get("chain"); if (!(chainValue instanceof Object[])) { + heads.add(""); continue; } Object[] rawChain = (Object[]) chainValue; if (rawChain.length == 0) { + heads.add(EMPTY_CHAIN_SENTINEL); continue; } heads.add(classFullName(rawChain[0])); @@ -180,6 +190,29 @@ public static List observedChainHeads(JfrEvents events) { return heads; } + /** + * Canonical debug-build failure fingerprint for a reference-chain discovery test that came up + * empty: empty leak-signal candidates mean the trend gate never opened; a non-empty list with + * representative frontier state -1 means the walk never admitted the representative; + * observedChain0s with real (non-sentinel) heads that all differ from the target mean chains + * fired for a DIFFERENT klass, while observedChain0s holding only sentinels means events were + * emitted but carried no chain (see {@link #observedChainHeads}). Returns "" on release builds - + * the seam stubs cannot report any of these states. Callers invoke it only when the match is + * missing, so the seam queries run on failure only. + */ + public static String referenceChainFailureDiagnostics(Object representative, JfrEvents lastEvents) { + if (!isDebugInstrumentedBuild()) { + return ""; + } + return " [leak-signal candidates=" + + Arrays.toString(ReferenceChainTestHooks.selectLeakCandidateKlassIds0()) + + ", bfsPassesRun=" + ReferenceChainTestHooks.referenceChainPassesRunForTest0() + + ", representativeFrontierState=" + + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(representative) + + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)" + + ", observedChain0s=" + observedChainHeads(lastEvents) + "]"; + } + /** * jafar/{@code JfrEvents}-backed counterpart to {@link #findMatchForClass(IItemCollection, Class)} - * see this class's own header comment for why these are two separate overloads rather than one. diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java index bf8c073c06..c86a1aaf58 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java @@ -10,6 +10,7 @@ import com.datadoghq.profiler.ReferenceChainTestHooks; import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -159,4 +160,38 @@ public void shouldReconstructChainForDirectlyTaggedRoot() { chainTargetHolder = null; // shared-JVM hygiene } } + + /** The shared discovery-test failure fingerprint is empty on release builds (the gate lives in + * the helper) and, on debug builds, carries the canonical segments in the canonical order. Read + * only on the seams - no reset/seed calls. */ + @Test + public void failureDiagnosticsFingerprintHasCanonicalSegments() { + String d = ReferenceChainAssertions.referenceChainFailureDiagnostics(new Object(), null); + if (!ReferenceChainAssertions.isDebugInstrumentedBuild()) { + assertEquals("", d, "release builds must keep the plain failure message"); + return; + } + assertTrue(d.startsWith(" [leak-signal candidates=["), d); + assertTrue(d.endsWith(", observedChain0s=[]]"), d); + String[] segments = { + "leak-signal candidates=", + ", bfsPassesRun=", + ", representativeFrontierState=", + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)", + ", observedChain0s=" + }; + int previous = -1; + for (String segment : segments) { + int index = d.indexOf(segment); + assertTrue(index > previous, "segment '" + segment + "' missing or out of order in: " + d); + previous = index; + } + assertTrue(d.matches("(?s).*representativeFrontierState=-?\\d+ \\(-1=never admitted.*"), d); + } + + /** observedChainHeads tolerates absent events: never throws, never returns null. */ + @Test + public void observedChainHeadsIsEmptyForNoEvents() { + assertTrue(ReferenceChainAssertions.observedChainHeads(null).isEmpty()); + } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java index 2f25d8cc95..be68959a03 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -24,7 +24,6 @@ import java.util.concurrent.atomic.AtomicReference; import java.nio.file.Path; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -453,9 +452,7 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { // test's grace loop below - this fixture's own ArrayList resizes invalidate the walked // element path the same way, and the BFS thread's pass availability is governed by its // own backoff/pain-budget scheduling). - if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { - driveReferenceChainPasses(2); - } + driveReferenceChainPasses(2); Thread.sleep(300); dump(scratchDumpPath); match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); @@ -473,28 +470,24 @@ public void shouldReconstructReferrerChainToGcRoot() throws Exception { // same scheduling-independent margin. JfrEvents lastEvents = null; for (int attempt = 0; match == null && attempt < 10; attempt++) { - if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { - driveReferenceChainPasses(8); - } + driveReferenceChainPasses(8); Thread.sleep(1000); dump(scratchDumpPath); lastEvents = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); match = ReferenceChainAssertions.findMatchForClass(lastEvents, ChainLink.class); } - // Same failure diagnostics as the cache-leak test's assertion below: empty leak-signal - // candidates mean the trend gate never opened; a non-empty list with no cached chain means - // the walk never admitted/tagged the representative. Debug-only seams; release failures - // keep the plain message. - String failureDiagnostics = ""; - if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { - failureDiagnostics = " [leak-signal candidates=" - + Arrays.toString(ReferenceChainTestHooks.selectLeakCandidateKlassIds0()) - + ", bfsPassesRun=" + ReferenceChainTestHooks.referenceChainPassesRunForTest0() - + ", representativeFrontierState=" - + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(gcRootHolder.get(0)) - + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)]"; - } + // Same failure diagnostics as the cache-leak test's assertion below (one shared helper, + // ReferenceChainAssertions.referenceChainFailureDiagnostics): empty leak-signal candidates + // mean the trend gate never opened; a non-empty list with representative frontier state -1 + // means the walk never admitted the representative; observedChain0s (from lastEvents, the + // final grace-loop dump) with real (non-sentinel) heads that all differ from ChainLink means + // chains fired for a different klass, while only / + // sentinels mean events were emitted but carried no chain. Debug-only seams; release + // failures keep the plain message. + String failureDiagnostics = match == null + ? ReferenceChainAssertions.referenceChainFailureDiagnostics(gcRootHolder.get(0), lastEvents) + : ""; assertNotNull(match, "Never observed a datadog.ReferenceChain event whose chain[0] is " + ChainLink.class + " after " + gcRootHolder.size() + " retained ChainLink instances across up to " @@ -680,9 +673,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // intervening gc() (so cleanup_table()'s epoch-advance pass stays a no-op and the // population trend is undisturbed) gives it a window; the debug-only seam passes make // the frontier progress deterministic on top. - if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { - driveReferenceChainPasses(2); - } + driveReferenceChainPasses(2); Thread.sleep(300); dump(scratchDumpPath); JfrEvents events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); @@ -710,9 +701,7 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // the same per-iteration tail the BFS thread runs. JfrEvents lastEvents = null; for (int attempt = 0; match == null && attempt < 10; attempt++) { - if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { - driveReferenceChainPasses(8); - } + driveReferenceChainPasses(8); Thread.sleep(1000); dump(scratchDumpPath); lastEvents = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); @@ -723,20 +712,17 @@ public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exc // never opened (empty leak-signal candidates - the seeded/organic population trend failed to // qualify), the gate opened but the walk never admitted the representative (frontier state // -1 - the cumulative-frontier-progress shortfall the seam-driven passes above exist to - // prevent), or chains were emitted for a DIFFERENT klass (observedChain0s non-empty - the - // candidate mechanism picked another growing klass, e.g. an incidental byte[]/String trend, - // and CachedPayload's own tags were never intercepted). Debug-only seams: release-build - // stubs cannot report any of these states, so release failures keep the plain message. - String failureDiagnostics = ""; - if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { - failureDiagnostics = " [leak-signal candidates=" - + Arrays.toString(ReferenceChainTestHooks.selectLeakCandidateKlassIds0()) - + ", bfsPassesRun=" + ReferenceChainTestHooks.referenceChainPassesRunForTest0() - + ", representativeFrontierState=" - + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(cache.get(keys[0])) - + " (-1=never admitted, 0=admitted pending expansion, 1=expanded, 3=abandoned)" - + ", observedChain0s=" + ReferenceChainAssertions.observedChainHeads(lastEvents) + "]"; - } + // prevent), chains were emitted for a DIFFERENT klass (observedChain0s holds real, + // non-sentinel heads that all differ from CachedPayload - the candidate mechanism picked + // another growing klass, e.g. an incidental byte[]/String trend, and CachedPayload's own tags + // were never intercepted), or events were emitted but carried no chain (observedChain0s holds + // only / sentinels). The fingerprint comes from the shared + // ReferenceChainAssertions.referenceChainFailureDiagnostics helper (same as the gc-root + // test's). Debug-only seams: release-build stubs cannot report any of these states, so + // release failures keep the plain message. + String failureDiagnostics = match == null + ? ReferenceChainAssertions.referenceChainFailureDiagnostics(cache.get(keys[0]), lastEvents) + : ""; assertNotNull(match, "Never observed a datadog.ReferenceChain event whose chain[0] is " + CachedPayload.class + " after " + cache.size() + " cached entries across up to " + totalRounds @@ -944,8 +930,8 @@ private static void awaitAllocatorTid(Thread allocator, AtomicLong allocatorTid, * debug-only {@link ReferenceChainTestHooks#runReferenceChainPass0()} seam, each followed by the * per-iteration target poll ({@link ReferenceChainTestHooks#pollReferenceChainTargets0()}) - * the same pass + poll pair one BFS-thread loop iteration performs. A no-op in release builds - * (the seam stubs return immediately); release discovery falls back to the organic BFS-thread - * cadence. + * (checked here, so callers need not gate it); release discovery falls back to the organic + * BFS-thread cadence. * *

Passes against a terminal (completed/abandoned) search are no-ops until the BFS thread's * shouldRunPass() restarts the search - the restart gate belongs to that thread - so a burst of @@ -954,6 +940,9 @@ private static void awaitAllocatorTid(Thread allocator, AtomicLong allocatorTid, * {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()}). */ private static void driveReferenceChainPasses(int maxPasses) { + if (!ReferenceChainAssertions.isDebugInstrumentedBuild()) { + return; // release builds: the seams are no-op stubs; discovery uses the organic BFS cadence + } for (int i = 0; i < maxPasses; i++) { if (!ReferenceChainTestHooks.runReferenceChainPass0()) { return; // reference chains disabled or the tracker never started From d8e594993eb62a9a173d75aabf871fd11f3b7dc5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 18:09:50 +0100 Subject: [PATCH 19/20] Address rkennke review findings on leak-signal gating and admission - Derive the leak signal from generations || (liveheap && referencechains): the collector is the signal's only consumer, so a plain liveheap session keeps its cheaper profile (no per-epoch klass folding, heap-floor ring or forced sweeps). - Seed the urgent-admission growth baseline at emergency-band entry so the first ramp-band observation compares against the episode entry level instead of re-snapshotting and dropping the boost mid-growth. - Advance the baseline per admitted sample (high-water mark): a plateau above the entry level loses the boost until growth resumes. - Guard gcGenerationsEnabled() as test-only; rewrite comments that narrated history or an internal observation instead of current behavior. - Add copyright headers to the files this PR modifies that lacked one. --- .../native/gtest/GtestTaskBuilder.kt | 4 + ddprof-lib/src/main/cpp/livenessTracker.cpp | 5 +- ddprof-lib/src/main/cpp/livenessTracker.h | 39 +++---- ddprof-lib/src/main/cpp/referenceChains.cpp | 26 +++-- ddprof-lib/src/main/cpp/referenceChains.h | 15 +-- .../src/test/cpp/livenessTracker_ut.cpp | 100 ++++++++++++------ .../cpp/referenceChainsLifecycleTests.inc | 4 + .../src/test/cpp/referenceChainsOomTests.inc | 57 ++++++++++ .../AggressiveLeakReferenceChainTest.java | 6 +- doc/ReferenceChainsCollectionSummary.md | 8 +- .../LiveHeapReferenceChainsImplementation.md | 13 ++- .../ReferenceChainsSignalsExplained.md | 2 +- 12 files changed, 193 insertions(+), 86 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt index 5c2a561207..b267e0c037 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ package com.datadoghq.native.gtest diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index 4c207bd152..2850a8d569 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -2132,8 +2132,9 @@ Error LivenessTracker::initialize(Arguments &args) { // observe the flag it was actually started with, even though the tracking // table itself persists across recordings. _gc_generations.store(args._gc_generations, std::memory_order_relaxed); - // See _leak_signal_enabled's own comment for why either source suffices. - _leak_signal_enabled.store(args._gc_generations || args._record_liveness, + // See _leak_signal_enabled's comment for the derivation. + _leak_signal_enabled.store(args._gc_generations + || (args._record_liveness && args._reference_chains), std::memory_order_relaxed); if (!_enabled) { diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index 876daf60d5..582713c572 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -464,15 +464,11 @@ class alignas(alignof(SpinLock)) LivenessTracker { // still be reading it from a session that persists across a restart. std::atomic _gc_generations; - // Effective leak-signal availability: true when the population-trend signal - // consumed by ReferenceChainTracker::hasLeakSignal() can be constructed at - // all. Generations tracking and liveheap tracking drive the same mechanisms - // here (per-klass population table, heap-floor ring, secondsToOOM() - // projection) - liveheap additionally preserves stack traces - so either - // option suffices; with neither on, hasLeakSignal() -> false and the - // reference-chain collector must stay idle instead of searching with zero - // candidates. Set from args._gc_generations || args._record_liveness in - // initialize(). Same atomicity/reader argument as _gc_generations above. + // True when a leak-signal source is active: generations, or liveheap with + // referencechains enabled - the collector is the signal's only consumer, so + // a plain liveheap session keeps the flag (and its per-epoch klass folding, + // heap-floor ring and forced sweeps) off. Derived in initialize(); same + // atomicity/reader argument as _gc_generations above. std::atomic _leak_signal_enabled; // OS::nanotime() of the first initialize() that enabled the leak signal; 0 @@ -1042,11 +1038,14 @@ class alignas(alignof(SpinLock)) LivenessTracker { // pattern selectLeakCandidates()/getLiveTraceIds() already use. jobject resolveCandidateRepresentative(JNIEnv *env, u32 klass_id); - // Read-only observation point for the explicit generations option (see - // _gc_generations's own comment); no production code keys on it. + // Test-only observation point for the explicit generations option: no + // production code keys on it (the gate is leakSignalEnabled()), so it is + // compiled out of the shipped release library. +#if defined(DEBUG) || defined(UNIT_TEST) bool gcGenerationsEnabled() const { return _gc_generations.load(std::memory_order_relaxed); } +#endif // The effective gate ReferenceChainTracker::hasLeakSignal() keys on (see // _leak_signal_enabled's own comment). @@ -1690,23 +1689,17 @@ class alignas(alignof(SpinLock)) LivenessTracker { // release library defines neither. // Sets the two leak-signal gates directly, bypassing initialize() (which - // requires a live JVM - VM::hotspot_version()/VM::jni(), see that method's - // own code - out of gtest's reach the same way foldKlassCountsLocked()'s - // representative-minting step is, per this seam block's own comment - // above), with the same derivation initialize() applies: - // _gc_generations = generations, _leak_signal_enabled = generations || - // liveness. (generations=false, liveness=true) models a liveheap-only - // session (memory=N:l), so gates that must key on leakSignalEnabled() - // rather than gcGenerationsEnabled() are distinguishable under test. + // requires a live JVM, out of gtest's reach - see this seam block's own + // comment above), with the same derivation initialize() applies. + // (generations=false, liveness=true) represents liveheap + referencechains + // - the only production-reachable state where liveness alone arms the signal. void setLeakSignalSourcesForTest(bool generations, bool liveness) { _gc_generations.store(generations, std::memory_order_relaxed); _leak_signal_enabled.store(generations || liveness, std::memory_order_relaxed); } - // Kept for existing callers (javaApi.cpp, the test fixtures) that only need - // to exercise leakSignalEnabled()'s gate: sets BOTH flags to v - the - // generations option alone is a sufficient signal source, so the effective - // flag follows it. + // Convenience for callers that only need the generations-only configuration: + // sets BOTH flags to v (the generations option alone arms the signal). void setGcGenerationsForTest(bool v) { setLeakSignalSourcesForTest(v, /*liveness=*/false); } // Overrides the burn-in stamp (see _leak_signal_activated_ns). initialize() diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 82c6c5b7c2..77af09a90e 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -817,8 +817,8 @@ bool ReferenceChainTracker::isUrgent() const { bool ReferenceChainTracker::updateUrgentAdmission(double seconds_to_oom, bool urgent) { if (!urgent) { - // Left set after an episode so the next episode re-snapshots against that - // episode's own entry state instead of inheriting a stale baseline. + // Cleared when the episode ends so the next episode re-snapshots against + // its own entry state instead of inheriting a stale baseline. _admission_growth_baseline = 0; return false; } @@ -827,6 +827,15 @@ bool ReferenceChainTracker::updateUrgentAdmission(double seconds_to_oom, bool ur // evidence. OOM_URGENT_THRESHOLD_S is isUrgent()'s own latch threshold, so // the emergency band and the hysteretic urgent episode cover the same range. if (seconds_to_oom < OOM_URGENT_THRESHOLD_S) { + if (_admission_growth_baseline == 0) { + // Seed at emergency-band entry: the burn-in can hide the projection + // until it is already short, and without a seed the first ramp-band + // sample would re-snapshot and drop the boost mid-growth. + u64 newest_used = LivenessTracker::instance()->newestHeapFloorSample(); + if (newest_used != 0) { + _admission_growth_baseline = newest_used; + } + } return true; } u64 newest_used = LivenessTracker::instance()->newestHeapFloorSample(); @@ -843,7 +852,14 @@ bool ReferenceChainTracker::updateUrgentAdmission(double seconds_to_oom, bool ur _admission_growth_baseline = newest_used; return false; } - return newest_used > _admission_growth_baseline; + // High-water mark: every admitted sample raises the baseline, so a heap + // that plateaus above the entry level loses the boost until growth resumes + // past the previous peak - a frozen entry snapshot would keep admitting it. + if (newest_used > _admission_growth_baseline) { + _admission_growth_baseline = newest_used; + return true; + } + return false; } bool ReferenceChainTracker::hasLeakSignal() { @@ -854,9 +870,7 @@ bool ReferenceChainTracker::hasLeakSignal() { // same effective flag). Stay idle: without a signal there is nothing to // chase, and running whole-heap searches with zero candidates "just in // case" burns safepoint time and frontier-table memory for zero - // diagnostic value (observed: frontier grown to its cap with - // reference_chain_candidate_count == 0 in a healthy process when this - // branch used to return true). + // diagnostic value. return false; } double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index 9e2ba53637..887892e274 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -348,13 +348,14 @@ class ReferenceChainTracker { // least-squares regression over a 30-sample window, so a single spike sample // keeps the projection inside the ramp band for ~6-7 recovery GCs AFTER the // heap has physically stopped growing - band residence alone does not - // establish growth. updateUrgentAdmission() therefore snapshots the newest - // heap-floor sample's used bytes when a non-emergency ramp episode begins - // and admits the 100% tracking boost only while a newer sample has grown - // past that snapshot; a recovered spike never does. Written and read only - // from threadLoop() on the BFS thread - no atomics. 0 = no baseline for the - // current episode (a used value of 0 cannot be a real sample: onGC() only - // records used > 0). + // establish growth. updateUrgentAdmission() therefore admits the 100% + // tracking boost only while a newer heap-floor sample grows past the + // running baseline: every admitted sample raises it (a plateau loses the + // boost, a recovered spike never passes it), and the emergency band seeds + // the same baseline at entry so the ramp-band comparison stays continuous. + // Written and read only from threadLoop() on the BFS thread - no atomics. + // 0 = no baseline for the current episode (a used value of 0 cannot be a + // real sample: onGC() only records used > 0). u64 _admission_growth_baseline; // Wall-clock timestamp (OS::nanotime()) of the search's first pass - baseline for the TTL cutoff diff --git a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp index f276b0af07..b91190d977 100644 --- a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp +++ b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp @@ -1236,8 +1236,8 @@ TEST(LivenessTrackerSessionTest, ResetSessionClearsOnlyRecordingGates) { } // hasLeakSignal() must never authorize a search without a leak-signal source: -// with neither generations nor liveheap tracking active, the population table -// and heap-floor ring never fill, and searching would run whole-heap BFS +// with neither generations nor liveheap+referencechains active, the population +// table and heap-floor ring never fill, and searching would run whole-heap BFS // passes with zero candidates, growing the frontier table to its cap while // emitting nothing. TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { @@ -1247,13 +1247,13 @@ TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { tracker, /*enabled=*/false, /*generations=*/false); EXPECT_FALSE(tracker->leakSignalEnabled()); - // Liveheap alone is a sufficient source: it drives the same population - // machinery, only additionally preserving stack traces for its samples. - // Driven through the real initialize() derivation from parsed arguments - // (setSessionGatesForTest cannot model liveheap-only). + // Liveheap plus referencechains is a sufficient source: driven through the + // real initialize() derivation from parsed arguments, which + // setSessionGatesForTest cannot model. Arguments live; - ASSERT_FALSE(live.parse("memory=1048576:l")); + ASSERT_FALSE(live.parse("memory=1048576:l,referencechains=true")); ASSERT_TRUE(live._record_liveness); + ASSERT_TRUE(live._reference_chains); ASSERT_FALSE(live._gc_generations); u64 stamp_before = LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker); bool initialized_before = LivenessTrackerTestAccessor::initializedForTest(tracker); @@ -1261,7 +1261,7 @@ TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { LivenessTrackerTestAccessor::initializeGatesOnlyForTest(tracker, live); EXPECT_FALSE(tracker->gcGenerationsEnabled()); EXPECT_TRUE(tracker->leakSignalEnabled()) - << "initialize() must derive the leak signal from generations || liveheap"; + << "initialize() must derive the leak signal from generations || (liveheap && referencechains)"; // The helper restores every non-gate field it touched. EXPECT_EQ(stamp_before, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)); EXPECT_EQ(initialized_before, LivenessTrackerTestAccessor::initializedForTest(tracker)); @@ -1271,29 +1271,31 @@ TEST(LivenessTrackerSessionTest, LeakSignalRequiresGenerationsOrLiveness) { tracker, /*enabled=*/false, /*generations=*/false); } -// initialize() derives the two gates from the parsed options: -// gcGenerationsEnabled() == generations, leakSignalEnabled() == generations || -// liveheap. The reference-chain collector keys on the effective flag, so -// liveheap tracking alone must arm it. +// initialize() derives the gates: gcGenerationsEnabled() == generations, +// leakSignalEnabled() == generations || (liveheap && referencechains). The +// collector is the signal's only consumer, so liveheap alone must not arm it. TEST(LivenessTrackerSessionTest, LeakSignalDerivedByInitialize) { LivenessTracker *tracker = LivenessTracker::instance(); struct Row { const char *spec; bool liveness; bool generations; + bool refchains; bool expect_signal; }; const Row rows[] = { - {"memory=1048576:l", true, false, true}, - {"generations=true", false, true, true}, - {"generations=true,memory=1048576:l", true, true, true}, - {"memory=1048576:a", false, false, false}, + {"memory=1048576:l", true, false, false, false}, + {"memory=1048576:l,referencechains=true", true, false, true, true}, + {"generations=true", false, true, false, true}, + {"generations=true,memory=1048576:l", true, true, false, true}, + {"memory=1048576:a", false, false, false, false}, }; for (const Row &row : rows) { Arguments args; ASSERT_FALSE(args.parse(row.spec)) << "row " << row.spec; ASSERT_EQ(row.liveness, args._record_liveness) << "row " << row.spec; ASSERT_EQ(row.generations, args._gc_generations) << "row " << row.spec; + ASSERT_EQ(row.refchains, args._reference_chains) << "row " << row.spec; bool initialized_before = LivenessTrackerTestAccessor::initializedForTest(tracker); bool enabled_before = LivenessTrackerTestAccessor::enabledForTest(tracker); u64 stamp_before = LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker); @@ -3149,32 +3151,61 @@ TEST_F(LivenessTrackerMockJvmTest, SlotsPastTableSizeAreClearedAfterCompaction) } } -// Liveheap tracking alone (memory=...:l, no generations) must arm the -// effective leak signal through the real start() -> initialize() path, and -// every consumer gated on it (secondsToOOM(), maybeForceCleanup() and -// cleanup_table()'s population fold) must work without the generations -// option. The other fixtures arm the gate only through seams that mirror the -// generations value into it, so they cannot tell the two flags apart. -TEST_F(LivenessTrackerMockJvmTest, LiveheapOnlyStartArmsLeakSignalConsumers) { +// The leak signal's only consumer is the reference-chain collector: a plain +// liveheap session must keep the signal and its consumers disarmed through +// the real start() -> initialize() path, and liveheap + referencechains must +// arm them. The other fixtures arm the gate only through seams, so they +// cannot tell the two flags apart. +TEST_F(LivenessTrackerMockJvmTest, LiveheapLeakSignalArmingRequiresReferenceChainConsumer) { constexpr u64 SEC_NS = 1000000000ULL; constexpr u64 MiB = 1ULL << 20; LivenessTracker *tracker = LivenessTracker::instance(); - // 1. Re-start as liveheap-only. The gate stores (and the burn-in CAS) - // precede initialize()'s _initialized guard, so a repeated start() - // re-derives them. + // 1. Liveheap-only: the gate stores (and the burn-in CAS) precede + // initialize()'s _initialized guard, so a repeated start() re-derives them. tracker->setLeakSignalActivatedNsForTest(0); Arguments args; ASSERT_FALSE(args.parse("memory=1048576:l")); ASSERT_TRUE(args._record_liveness); ASSERT_FALSE(args._gc_generations); + ASSERT_FALSE(args._reference_chains); ASSERT_FALSE(tracker->start(args)); tracker->setSubsampleRatioForTest(1.0); EXPECT_FALSE(tracker->gcGenerationsEnabled()); + EXPECT_FALSE(tracker->leakSignalEnabled()) + << "a plain liveheap session has no signal consumer - the gate must stay off"; + + // The consumers keyed on the gate stay idle: no projection even with a + // rising ring, and no forced sweep folding the population. + tracker->klassPopulationResetForTest(); // also clears the heap-floor ring + tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); + for (int i = 0; i < 10; i++) { + tracker->heapFloorRecordForTest(1000 * MiB + (u64)i * 100 * MiB, (u64)i * SEC_NS); + } + EXPECT_LT(tracker->secondsToOOM(), 0.0) + << "secondsToOOM() must not project in a plain liveheap session"; + tracker->klassPopulationResetForTest(); + tracker->setMaxHeapBytesForTest(-1); + + u32 retained_id = classIdOf(kRetainedSignature); + ASSERT_NE(0u, retained_id); + trackNew(&jvm.retained_class, /*tid=*/1); + KlassPopulationEntry entry{}; + ASSERT_FALSE(tracker->klassPopulationLookupForTest(retained_id, &entry)); + sweep(); // GarbageCollectionFinish + maybeForceCleanup(fake_now_ns) + EXPECT_FALSE(tracker->klassPopulationLookupForTest(retained_id, &entry)) + << "maybeForceCleanup() must not fold the population in a plain liveheap session"; + + // 2. Restart as liveheap + referencechains: the collector will run, so + // the same derivation arms the signal. + ASSERT_FALSE(args.parse("memory=1048576:l,referencechains=true")); + ASSERT_TRUE(args._reference_chains); + ASSERT_FALSE(tracker->start(args)); + EXPECT_FALSE(tracker->gcGenerationsEnabled()); EXPECT_TRUE(tracker->leakSignalEnabled()) - << "initialize() must derive the leak signal from generations || liveheap"; + << "liveheap with referencechains must arm the leak signal"; - // 2. Burn-in stamp lifecycle: start() stamps it from 0 and never re-stamps. + // 3. Burn-in stamp lifecycle: start() stamps it from 0 and never re-stamps. EXPECT_NE(0u, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)) << "the first enabled start() must stamp the burn-in clock"; u64 backdated = OS::nanotime() - LivenessTracker::OOM_PROJECTION_BURN_IN_NS - SEC_NS; @@ -3183,7 +3214,7 @@ TEST_F(LivenessTrackerMockJvmTest, LiveheapOnlyStartArmsLeakSignalConsumers) { EXPECT_EQ(backdated, LivenessTrackerTestAccessor::leakSignalActivatedNsForTest(tracker)) << "a later start() must not re-stamp the burn-in clock"; - // 3. secondsToOOM() projects in a liveheap-only session. Runs before any + // 4. secondsToOOM() projects in the armed session. Runs before any // sweep(): onGC() records real-clock floor samples while the signal is on. tracker->klassPopulationResetForTest(); // also clears the heap-floor ring tracker->setMaxHeapBytesForTest((jlong)(2800 * MiB)); @@ -3192,21 +3223,20 @@ TEST_F(LivenessTrackerMockJvmTest, LiveheapOnlyStartArmsLeakSignalConsumers) { } // GE rather than NEAR: start() resolved the real container limit. EXPECT_GE(tracker->secondsToOOM(), 0.0) - << "secondsToOOM() must project in a liveheap-only session"; + << "secondsToOOM() must project once the signal is armed"; tracker->klassPopulationResetForTest(); // drop the seeded ring before the sweep tracker->setMaxHeapBytesForTest(-1); - // 4. maybeForceCleanup() sweeps and folds the population. track() never + // 5. maybeForceCleanup() sweeps and folds the population. track() never // folds, so the only route to a population entry is that sweep. - u32 retained_id = classIdOf(kRetainedSignature); + retained_id = classIdOf(kRetainedSignature); ASSERT_NE(0u, retained_id); trackNew(&jvm.retained_class, /*tid=*/1); - KlassPopulationEntry entry{}; ASSERT_FALSE(tracker->klassPopulationLookupForTest(retained_id, &entry)); sweep(); // GarbageCollectionFinish + maybeForceCleanup(fake_now_ns) EXPECT_TRUE(tracker->klassPopulationLookupForTest(retained_id, &entry)) - << "maybeForceCleanup() must sweep and fold the population in a liveheap-only session"; + << "maybeForceCleanup() must sweep and fold the population once the signal is armed"; - // 5. TearDown clears the gates via setGcGenerationsForTest(false). + // 6. TearDown clears the gates via setGcGenerationsForTest(false). tracker->setLeakSignalActivatedNsForTest(0); } diff --git a/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc b/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc index 397c7c4328..9c13bdc7ae 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsLifecycleTests.inc @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ // --------------------------------------------------------------------------- // Lifecycle churn (C4): stop/restart-shaped interleavings against an in-flight diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index 2080bc541c..d6bd727404 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -1,3 +1,7 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ // --------------------------------------------------------------------------- // OOM-ramp hysteresis (C5): isUrgent()'s latch/release machine and @@ -373,6 +377,59 @@ TEST_F(OomUrgencyTest, EmergencyBandAdmitsWithoutGrowthEvidence) { EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(299.0, true)); } +// The emergency band seeds the growth baseline at entry: the burn-in can hide +// the projection until it is already short, and without a seed the first +// ramp-band observation would re-snapshot and drop the boost mid-growth. +TEST_F(OomUrgencyTest, EmergencyBandEntrySeedsGrowthBaseline) { + ASSERT_NEAR(project(299.0), 299.0, 1.0); + u64 entry = MAX_HEAP - (u64)(299.0 * (double)RATE / (double)SAMPLE_SPACING_S); + ASSERT_EQ(entry, LivenessTracker::instance()->newestHeapFloorSample()); + + // Emergency-band entry: admitted, and the baseline is seeded at `entry`. + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(299.0, true)); + + // The projection rises above the threshold into the ramp band while the + // heap keeps growing: one RATE above the seeded baseline admits. + LivenessTracker::instance()->heapFloorRecordForTest(entry + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(400.0, true)) + << "ramp-band observation after emergency entry must compare against the " + "seeded baseline, not re-snapshot"; + + // Control: with the heap flat (no growth past the seeded baseline), the + // same ramp-band projection does not admit. + LivenessTracker::instance()->heapFloorRecordForTest(entry, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(400.0, true)); +} + +// The growth baseline is a high-water mark: every admitted sample raises it, +// so a heap that plateaus above the episode's entry level loses the boost - +// comparing against a frozen entry snapshot would keep admitting a heap that +// has stopped growing. +TEST_F(OomUrgencyTest, PlateauAboveEntryBaselineLosesBoost) { + ASSERT_NEAR(project(1500.0), 1500.0, 1.0); + u64 peak = MAX_HEAP - (u64)(1500.0 * (double)RATE / (double)SAMPLE_SPACING_S); + + // Entry snapshot = peak; one RATE of growth admits and raises the bar. + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + LivenessTracker::instance()->heapFloorRecordForTest(peak + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); + + // The heap settles flat at a level above the entry snapshot but below the + // raised bar: no growth, no boost. + LivenessTracker::instance()->heapFloorRecordForTest(peak + RATE / 2, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_FALSE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)) + << "a plateau above the entry level is not growth evidence"; + + // Growth resuming past the previous peak re-admits. + LivenessTracker::instance()->heapFloorRecordForTest(peak + 2 * RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + EXPECT_TRUE(ReferenceChainsTestAccessor::updateUrgentAdmissionForTest(1500.0, true)); +} + // Leaving the band clears the entry snapshot: the next episode re-snapshots // against its own entry state instead of inheriting the previous episode's // baseline (which could admit on no growth at all if the heap is now above diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java index 3140c2c431..9e7112e0d9 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -31,10 +31,8 @@ * *

This class is a regression guard for the candidate-gate contract: the aggregate urgent-OOM * projection alone must NEVER open the gate while the per-klass population table is provably - * empty. Staging recordings showed the pre-fix behavior - the urgent latch entitled a - * candidate-less search that cannot exit early and ran whole-heap BFS passes to the auto-tuned - * TTL with zero chains to show for it. That bypass and its one-shot urgency entitlement were - * removed. + * empty: a candidate-less search cannot exit early and would run whole-heap BFS passes to the + * auto-tuned TTL with zero chains to show for it. * *

Reproducing a genuine near-OOM heap in this shared, no-{@code forkEvery} test JVM is * impractical (see {@link ReferenceChainTrackingTest}'s own header comment on the JVM this class diff --git a/doc/ReferenceChainsCollectionSummary.md b/doc/ReferenceChainsCollectionSummary.md index d1aebfb163..0cf16f5f20 100644 --- a/doc/ReferenceChainsCollectionSummary.md +++ b/doc/ReferenceChainsCollectionSummary.md @@ -7,10 +7,10 @@ Reference-chain collection helps explain why a sampled object remains live. It records a bounded chain of referrer classes from a GC root to a sampled object. It does not replace a heap dump or compute an exact retained set. The profiler disables this feature by default. Enable generation tracking or liveheap tracking -(either supplies the leak signal) together with reference-chain collection, e.g. -`generations=true,referencechains=true`. The bare `referencechains=true` option does not activate -the allocation engine that supplies candidates, and without a signal source the collector stays -idle: it never starts a search. +(either supplies the leak signal; liveheap requires `referencechains=true` in the same command) +together with reference-chain collection, e.g. `generations=true,referencechains=true`. The bare +`referencechains=true` option does not activate the allocation engine that supplies candidates, +and without a signal source the collector stays idle: it never starts a search. ## Data flow diff --git a/doc/architecture/LiveHeapReferenceChainsImplementation.md b/doc/architecture/LiveHeapReferenceChainsImplementation.md index 5e8a14e31c..79860b6805 100644 --- a/doc/architecture/LiveHeapReferenceChainsImplementation.md +++ b/doc/architecture/LiveHeapReferenceChainsImplementation.md @@ -201,16 +201,21 @@ These values are tuning inputs. They do not state guaranteed overhead. ## Configuration Reference-chain collection requires the allocation engine and a leak-signal source. The signal -source is generations tracking OR liveheap tracking (the two drive the same liveness mechanisms; -liveheap additionally preserves stack traces for its samples). With neither, the leak-signal gate -can never pass and the collector stays idle. Enable one of them in the same command: +source is generations tracking OR liveheap tracking with reference chains enabled (the two drive +the same liveness mechanisms; liveheap additionally preserves stack traces for its samples). The +reference-chain collector is the signal's only consumer, so a plain liveheap session +(`memory=b:l` without `referencechains`) keeps the signal disarmed: the per-epoch +klass folding, the heap-floor ring and the forced sweeps stay off. With no signal source, the +leak-signal gate can never pass and the collector stays idle. Enable one of them in the same +command: ```text generations=true,referencechains=true:hops=N:budget=N:ttl=N:framecap=N:pausetarget=N:painbudget=N:firstpassbudget=N ``` - `generations=true` activates the allocation engine and supplies leak candidates. Liveheap - tracking (`memory=b:l|L`) is an equivalent signal source. + tracking (`memory=b:l|L`) is an equivalent signal source when combined with + `referencechains=true`. - `hops` limits chain depth. - `budget` supplies the base edge-admission budget. - `ttl` sets search lifetime in milliseconds. A value at or below zero disables this time check. diff --git a/doc/architecture/ReferenceChainsSignalsExplained.md b/doc/architecture/ReferenceChainsSignalsExplained.md index 8ef3295b56..b71b61981b 100644 --- a/doc/architecture/ReferenceChainsSignalsExplained.md +++ b/doc/architecture/ReferenceChainsSignalsExplained.md @@ -24,7 +24,7 @@ Urgency does not authorize a search. A candidate-less search can never emit a ch The tradeoff of this candidate-only gate: a heap-wide leak spread across many classes, none of which individually clears `selectLeakCandidates()`'s per-klass ring-fill/hysteresis gate, never opens the gate, even in the emergency band (projected OOM under 5 minutes). No chains are produced in that case. Each such refused evaluation increments the `reference_chain_urgent_no_candidate` counter (about once per second while urgency is latched and no candidate exists), so the gap is visible in the diagnostics rather than silent, and it can be told apart from an idle healthy process. -The population-trend signal is constructed by the liveness tracker, which fills the per-klass population table and the heap-floor ring when generations tracking OR liveheap tracking is active (the two options drive the same tracking mechanisms; liveheap additionally preserves stack traces for its samples). When neither is active, no signal can ever be produced: `hasLeakSignal()` returns false, and the collector stays idle - it never runs a pass, restart, or search. +The population-trend signal is constructed by the liveness tracker, which fills the per-klass population table and the heap-floor ring when generations tracking OR liveheap tracking with reference chains enabled is active (the two options drive the same tracking mechanisms; liveheap additionally preserves stack traces for its samples). The reference-chain collector is the signal's only consumer, so a plain liveheap session without `referencechains` keeps the signal disarmed. When no source is active, no signal can ever be produced: `hasLeakSignal()` returns false, and the collector stays idle - it never runs a pass, restart, or search. ## Scheduling a running search From e3410fcaaccdf81eedd53133bffc6efb58abe3c5 Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Fri, 30 Oct 2026 20:01:14 +0100 Subject: [PATCH 20/20] Fix review findings from the quick sphinx review round - StopClearsAdmissionGrowthBaseline no longer races the BFS thread: the test pins bfsThreadRunning() == false before driving the updateUrgentAdmission accessor directly. - threadLoop() computes secondsToOOM() once per iteration and threads the value through shouldRunPass/canAffordNewSearch/hasLeakSignal/ isUrgent; one-arg wrappers read once and forward. - New publishUrgencyState() test drives the real admission gate and asserts _urgent_tracking follows the gated decision. - AggressiveLeak's candidate-free check retries once before failing, tolerating a transient candidate in the shared test JVM. - setLeakSignalActivatedNsForTest0 negative-argument guard now has a test; the stamp is restored in finally. - GtestTaskBuilder declares PathSensitivity.RELATIVE on the test include file tree as a named task input. - Ownership comments on _admission_growth_baseline/_oom_ramp_active amended to name the non-BFS writers. --- .../native/gtest/GtestTaskBuilder.kt | 9 ++ ddprof-lib/src/main/cpp/referenceChains.cpp | 53 +++++--- ddprof-lib/src/main/cpp/referenceChains.h | 38 +++++- .../src/test/cpp/referenceChainsOomTests.inc | 127 ++++++++++++++++++ .../test/cpp/referenceChainsTestAccessors.h | 31 +++++ .../AggressiveLeakReferenceChainTest.java | 86 ++++++++++-- .../ReferenceChainTestSeamsTest.java | 29 ++++ 7 files changed, 340 insertions(+), 33 deletions(-) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt index b267e0c037..31ac9cf8eb 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt @@ -13,6 +13,7 @@ import com.datadoghq.native.util.PlatformUtils import org.gradle.api.Project import org.gradle.api.file.FileCollection import org.gradle.api.tasks.Exec +import org.gradle.api.tasks.PathSensitivity import org.gradle.api.tasks.TaskProvider import java.io.File @@ -131,11 +132,19 @@ class GtestTaskBuilder( // directory and appear in no include path, so register them here or an // .inc-only edit reports UP-TO-DATE and runs a stale binary. Inputs, // not sources: compiling an .inc standalone would emit duplicate symbols. + // The sweep is deliberately broad (every test-dir .inc/.h for every gtest + // task): the test dir is flat with a handful of shared headers, mapping + // headers to the tests that include them would need include parsing, and + // over-invalidation is preferred to running a stale binary. The input is + // named and RELATIVE so relocating the checkout/build dir does not change + // its fingerprint. inputs.files( project.fileTree(testFile.parentFile) { include("**/*.inc", "**/*.h") } ) + .withPropertyName("testIncludeInputs") + .withPathSensitivity(PathSensitivity.RELATIVE) includes.from(includeFiles) objectFileDir.set(objDir) diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index 77af09a90e..7c5ba992bb 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -523,6 +523,8 @@ void ReferenceChainTracker::threadLoop() { while (_running.load(std::memory_order_acquire)) { // Fixed ~1s cadence, no early wake on GC (see onGCFinish()'s own comment) - stopThread() still // interrupts this via its own pthread_kill so shutdown stays prompt. + // The iteration's only projection read - consumed by the ramp below, publishUrgencyState() and + // shouldRunPass() (restart gate / isUrgent() latch), so every decision sees one sample. double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); bool urgent = seconds_to_oom >= 0 && seconds_to_oom < OOM_RAMP_START_S; long target_ms = _pause_target_ms; @@ -586,9 +588,9 @@ void ReferenceChainTracker::threadLoop() { // can stay inside the band for many GCs after the heap has physically stopped growing, and // boosting admission on a recovered spike only buys table churn. The budget/cadence ramp above // stays keyed on the raw band comparison - without a running search (the candidate gate) those - // effects cost nothing, while mis-admission during a transient does. - _oom_ramp_active = urgent; - LivenessTracker::instance()->setUrgentTracking(updateUrgentAdmission(seconds_to_oom, urgent)); + // effects cost nothing, while mis-admission during a transient does. publishUrgencyState() + // performs both hand-offs. + publishUrgencyState(seconds_to_oom, urgent); // shouldRunPass()'s restart branch (restartSearch()) clears _pending_expand/_priority_expand // the same way a pass does - take _engine_lock around the call so that mutation is serialized @@ -597,7 +599,7 @@ void ReferenceChainTracker::threadLoop() { bool should_run; { MutexLocker engine_guard(_engine_lock); - should_run = shouldRunPass(now_ns); + should_run = shouldRunPass(now_ns, seconds_to_oom); } // Only sleep when idle (no pass will run). When a canary search is active or a pass is about to // run, skip the sleep to run passes back-to-back. @@ -652,10 +654,14 @@ void ReferenceChainTracker::onGCFinish() { } bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { + return shouldRunPass(now_ns, LivenessTracker::instance()->secondsToOOM()); +} + +bool ReferenceChainTracker::shouldRunPass(u64 now_ns, double seconds_to_oom) { if (!_search_started) { // Same gate as a restart (canAffordNewSearch() below) - a brand-new tracker must not pay for // the first whole-heap walk/tagging pass either when there is no leak candidate to justify it. - bool afford = canAffordNewSearch(now_ns); + bool afford = canAffordNewSearch(now_ns, seconds_to_oom); TEST_LOG_SUMMARY("ReferenceChainTracker::shouldRunPass search_not_started " "canAffordNewSearch=%d", (int)afford); if (!afford) { @@ -686,7 +692,7 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { // (or again) a leak indication to chase - canAffordNewSearch() is always false when no // leak-signal source (generations or liveheap tracking) is active at all, so a misconfigured // tracker stays idle instead of searching without a signal. - if (canAffordNewSearch(now_ns)) { + if (canAffordNewSearch(now_ns, seconds_to_oom)) { restartSearch(); TEST_LOG_SUMMARY("ReferenceChainTracker::shouldRunPass -> true (restarting search)"); return true; @@ -776,11 +782,15 @@ bool ReferenceChainTracker::shouldRunPass(u64 now_ns) { // threadLoop()'s own iteration and needs the *list* to poll each candidate's tag; this only needs // to know whether at least one exists. bool ReferenceChainTracker::isUrgent() const { + return isUrgent(LivenessTracker::instance()->secondsToOOM()); +} + +bool ReferenceChainTracker::isUrgent(double seconds_to_oom) const { static_assert(LivenessTracker::OOM_PROJECTION_BURN_IN_NS / 1e9 >= OOM_URGENT_THRESHOLD_S, "burn-in must cover the urgent horizon"); - // Warmup is filtered inside secondsToOOM(): no projection (-1) until the - // burn-in passes (see OOM_PROJECTION_BURN_IN_NS for why it is age-based). - double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); + // Warmup is filtered inside secondsToOOM(), which produced the caller's projection: no + // projection (-1) until the burn-in passes (see OOM_PROJECTION_BURN_IN_NS for why it is + // age-based). if (seconds_to_oom >= 0 && seconds_to_oom < OOM_URGENT_THRESHOLD_S) { _urgent_release_ticks = 0; if (!_urgent_latched) { @@ -862,7 +872,12 @@ bool ReferenceChainTracker::updateUrgentAdmission(double seconds_to_oom, bool ur return false; } -bool ReferenceChainTracker::hasLeakSignal() { +void ReferenceChainTracker::publishUrgencyState(double seconds_to_oom, bool urgent) { + _oom_ramp_active = urgent; + LivenessTracker::instance()->setUrgentTracking(updateUrgentAdmission(seconds_to_oom, urgent)); +} + +bool ReferenceChainTracker::hasLeakSignal(double seconds_to_oom) { if (!LivenessTracker::instance()->leakSignalEnabled()) { // No population-trend signal can be constructed - generations tracking // and liveheap tracking are both off, so the per-klass population table @@ -873,14 +888,16 @@ bool ReferenceChainTracker::hasLeakSignal() { // diagnostic value. return false; } - double seconds_to_oom = LivenessTracker::instance()->secondsToOOM(); // isUrgent() is called unconditionally, not short-circuited behind any // branch: it maintains the latch/release state (see _urgent_latched), whose // only consumer is the no-progress-abandonment suppression once a search - // runs (runPass()'s terminal gate). The OOM ramp's budget/cadence/admission - // effects in threadLoop() follow the raw band comparison instead - they do - // not wait for this latch. - bool urgent = isUrgent(); + // runs (runPass()'s terminal gate). It evaluates the caller's projection + // (threadLoop()'s single per-iteration read), not a fresh secondsToOOM(). + // The OOM ramp's budget/cadence effects in threadLoop() follow the raw band + // comparison, and the 100% tracking admission follows + // updateUrgentAdmission()'s growth gate (via publishUrgencyState()) - + // neither waits for this latch. + bool urgent = isUrgent(seconds_to_oom); KlassCandidate probe[1]; int n = LivenessTracker::instance()->selectLeakCandidates(probe, 1); // The urgent latch does NOT entitle a candidate-less search. With nothing @@ -904,6 +921,10 @@ bool ReferenceChainTracker::hasLeakSignal() { } bool ReferenceChainTracker::canAffordNewSearch(u64 now_ns) { + return canAffordNewSearch(now_ns, LivenessTracker::instance()->secondsToOOM()); +} + +bool ReferenceChainTracker::canAffordNewSearch(u64 now_ns, double seconds_to_oom) { if (!_safepoint_pain_budget.canStartNow(now_ns)) { TEST_LOG_SUMMARY("ReferenceChainTracker::canAffordNewSearch blocked by " "safepoint_pain_budget balance=%.1fms refill_rate=%.4f", @@ -911,7 +932,7 @@ bool ReferenceChainTracker::canAffordNewSearch(u64 now_ns) { _pain_budget_refill_rate); return false; // still cooling down from the last search's own cost } - return hasLeakSignal(); + return hasLeakSignal(seconds_to_oom); } // Search restart (this class's own header comment). Called only from shouldRunPass() once diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index 887892e274..e8a26361eb 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -147,8 +147,10 @@ class ReferenceChainTracker { // End-of-pass timestamp of the last pass that ran with a canary chase still open (the reference // point for the spacing above). u64 _last_canary_pass_ns; - // Whether threadLoop()'s OOM urgency ramp is currently active, set each loop iteration BEFORE - // shouldRunPass() (same thread, no atomics needed). + // Whether threadLoop()'s OOM urgency ramp is currently active, set each loop iteration by + // publishUrgencyState() BEFORE shouldRunPass() (same BFS thread), and otherwise only by test + // seams (setOomRampActiveForTest(), publishUrgencyStateForTest()) while no BFS thread is live - + // no atomics needed. bool _oom_ramp_active; // How many consecutive times in a row runPass()'s canary-stuck check @@ -353,7 +355,12 @@ class ReferenceChainTracker { // running baseline: every admitted sample raises it (a plateau loses the // boost, a recovered spike never passes it), and the emergency band seeds // the same baseline at entry so the ramp-band comparison stays continuous. - // Written and read only from threadLoop() on the BFS thread - no atomics. + // Written and read by updateUrgentAdmission() on the BFS thread (threadLoop() + // via publishUrgencyState()); also reset by stop() and resetSearchStateForTest() + // (both only after stopThread() has joined the BFS thread) and by test seams + // (resetUrgencyForTest(), updateUrgentAdmissionForTest(), + // publishUrgencyStateForTest()) that run only while no BFS thread is live - + // so there is no concurrent access and no atomics are needed. // 0 = no baseline for the current episode (a used value of 0 cannot be a // real sample: onGC() only records used > 0). u64 _admission_growth_baseline; @@ -778,19 +785,28 @@ class ReferenceChainTracker { } void threadLoop(); - // Runs a pass when the GC-finish epoch advanced or the cadence elapsed. + // Runs a pass when the GC-finish epoch advanced or the cadence elapsed. The two-arg form + // consumes the caller's per-iteration OOM projection (threadLoop()'s single secondsToOOM() + // read, passed down to the restart gate and the urgency latch so every decision of one + // iteration sees the same sample); the one-arg form reads secondsToOOM() once and forwards + // (JNI shouldRunPassForTest(), gtest accessors). bool shouldRunPass(u64 now_ns); + bool shouldRunPass(u64 now_ns, double seconds_to_oom); // Cheap probe (max=1, not the real poll pollWatchedTargets() makes) into LivenessTracker's // population-trend table: true if at least one klass shows a positive population slope worth // chasing. Always false when no leak-signal source (generations or liveheap tracking) is // active - the population table and heap-floor ring never fill without one, and a search // would run whole-heap BFS passes with zero candidates for zero diagnostic value. - bool hasLeakSignal(); + // seconds_to_oom is the caller's projection (passed to isUrgent()), never re-read here. + bool hasLeakSignal(double seconds_to_oom); // Latched, hysteretic view of LivenessTracker::secondsToOOM() crossing OOM_URGENT_THRESHOLD_S - - // see _urgent_latched for the latch/release rules and why the raw comparison flaps. + // see _urgent_latched for the latch/release rules and why the raw comparison flaps. The + // one-arg form evaluates the caller's projection; the zero-arg form reads secondsToOOM() and + // forwards (runPass()'s no-progress check in referenceChainTraversal.cpp, isUrgentForTest()). bool isUrgent() const; + bool isUrgent(double seconds_to_oom) const; // Computes the 100% tracking-admission decision for one threadLoop() // iteration and maintains _admission_growth_baseline. Raw `urgent` still @@ -800,10 +816,18 @@ class ReferenceChainTracker { // sufficient, and OOM_URGENT_THRESHOLD_S for the emergency split. bool updateUrgentAdmission(double seconds_to_oom, bool urgent); + // Publishes one threadLoop() iteration's urgency: _oom_ramp_active follows the raw band + // (`urgent`) for shouldRunPass()'s canary-backoff bypass; LivenessTracker's 100% tracking boost + // (setUrgentTracking()) follows updateUrgentAdmission()'s growth-gated decision, never raw + // `urgent`. Extracted from threadLoop() so the wiring is testable without a BFS thread. + void publishUrgencyState(double seconds_to_oom, bool urgent); + // Search restart gate: true once _safepoint_pain_budget has // drained back to zero (canStartNow()) *and* hasLeakSignal() above reports at least one leak - // candidate. + // candidate. The two-arg form consumes the caller's projection; the one-arg form reads + // secondsToOOM() once and forwards. bool canAffordNewSearch(u64 now_ns); + bool canAffordNewSearch(u64 now_ns, double seconds_to_oom); // Resets every per-search field back to its just-constructed value so the next runPass() call // takes the "first pass of a search" branch again, exactly like a fresh ReferenceChainTracker diff --git a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc index d6bd727404..6724c91132 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsOomTests.inc @@ -262,6 +262,65 @@ TEST_F(OomUrgencyTest, BurnInProjectionCountsTowardRelease) { EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); } +// threadLoop() reads secondsToOOM() once per iteration and passes that value +// down the restart gate: shouldRunPass(u64, double) -> canAffordNewSearch(u64, +// double) -> hasLeakSignal(double) -> isUrgent(double). Each overload must +// latch on the PASSED projection, never re-read the live ring, so every +// decision of one iteration sees the same sample. Each block seeds a live +// projection that disagrees with the passed one. Return values of the +// candidate-dependent gates are not asserted - only the latch they drive. +TEST_F(OomUrgencyTest, UrgencyDecisionsConsumeThePassedProjection) { + // Restores the tracker's search/pain-budget state on every exit path. + struct ResetTracker { + ~ResetTracker() { ReferenceChainsTestAccessor::reset(); } + } reset_tracker; + + // isUrgent(double). + ASSERT_LT(project(0.0, /*flat=*/true), 0.0) << "live ring must report no projection"; + EXPECT_TRUE(ReferenceChainsTestAccessor::isUrgentWithProjectionForTest(200.0)); + EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()) + << "isUrgent(double) must latch on the passed projection"; + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ASSERT_NEAR(project(200.0), 200.0, 1.0); + EXPECT_FALSE(ReferenceChainsTestAccessor::isUrgentWithProjectionForTest(-1.0)); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()) + << "isUrgent(double) must not re-read the live (urgent) projection"; + + // hasLeakSignal(double). + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ASSERT_LT(project(0.0, /*flat=*/true), 0.0); + ReferenceChainsTestAccessor::hasLeakSignalForTest(200.0); + EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()) + << "hasLeakSignal() must latch on the passed projection, not re-read secondsToOOM()"; + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ASSERT_NEAR(project(200.0), 200.0, 1.0); + ReferenceChainsTestAccessor::hasLeakSignalForTest(-1.0); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); + + // canAffordNewSearch(u64, double) and shouldRunPass(u64, double): reset() + // leaves a fresh PainBudget (balance 0, canStartNow() true) and the search + // not started, so both reach hasLeakSignal(). + ReferenceChainsTestAccessor::reset(); + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ASSERT_LT(project(0.0, /*flat=*/true), 0.0); + ReferenceChainsTestAccessor::canAffordNewSearch(OS::nanotime(), 200.0); + EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()) + << "canAffordNewSearch() must forward the passed projection"; + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ReferenceChainsTestAccessor::shouldRunPass(OS::nanotime(), 200.0); + EXPECT_TRUE(ReferenceChainsTestAccessor::urgentLatchedForTest()) + << "shouldRunPass() must forward the passed projection"; + + ReferenceChainsTestAccessor::reset(); + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ASSERT_NEAR(project(200.0), 200.0, 1.0); + ReferenceChainsTestAccessor::canAffordNewSearch(OS::nanotime(), -1.0); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); + ReferenceChainsTestAccessor::resetUrgencyForTest(); + ReferenceChainsTestAccessor::shouldRunPass(OS::nanotime(), -1.0); + EXPECT_FALSE(ReferenceChainsTestAccessor::urgentLatchedForTest()); +} + // --------------------------------------------------------------------------- // --------------------------------------------------------------------------- // Urgent-admission growth gate (updateUrgentAdmission()): inside the OOM ramp @@ -362,6 +421,62 @@ TEST_F(OomUrgencyTest, RecoveredSpikeStaysUnboostedWhileProjectionLags) { } } +// threadLoop()'s publish step (publishUrgencyState()) must feed +// setUrgentTracking() the growth-gated admission decision, not the raw band +// comparison: on a recovered spike the ramp stays active while the 100% +// boost stays off. Observed through LivenessTracker's real admission gate: +// after admissionResetForTest() (ratio 0, no watched tids) only the urgency +// boost can admit a tid. +TEST_F(OomUrgencyTest, PublishedBoostFollowsGrowthGateNotRawBand) { + LivenessTracker *lt = LivenessTracker::instance(); + struct Restore { + ~Restore() { + ReferenceChainsTestAccessor::publishUrgencyStateForTest(-1.0, false); + LivenessTracker::instance()->admissionResetForTest(); + LivenessTracker::instance()->setSubsampleRatioForTest(0.1); + } + } restore; + lt->admissionResetForTest(); + const jint TID = 1234; + + ASSERT_NEAR(project(700.0), 700.0, 1.0); + u64 peak = MAX_HEAP - (u64)(700.0 * (double)RATE / (double)SAMPLE_SPACING_S); + + // Entry: ramp active (raw band), boost off (snapshot only). + ReferenceChainsTestAccessor::publishUrgencyStateForTest(700.0, true); + EXPECT_TRUE(ReferenceChainsTestAccessor::oomRampActiveForTest()); + EXPECT_FALSE(lt->admitForTrackingForTest(TID)) << "entry observation must not boost"; + + // Lagging recovery: ramp stays on, boost stays off. + for (int r = 1; r <= 6; r++) { + lt->heapFloorRecordForTest(peak - (u64)r * RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + double seconds = lt->secondsToOOM(); + ASSERT_GE(seconds, 300.0) + << "recovery sample " << r << ": must stay out of the emergency band"; + ASSERT_LT(seconds, 1800.0) + << "recovery sample " << r << ": must stay inside the ramp band"; + ReferenceChainsTestAccessor::publishUrgencyStateForTest(seconds, true); + EXPECT_TRUE(ReferenceChainsTestAccessor::oomRampActiveForTest()) << r; + EXPECT_FALSE(lt->admitForTrackingForTest(TID)) + << "recovery sample " << r << ": boost must follow the growth gate, not the raw band"; + } + + // Non-vacuity control: growth past the peak boosts. An explicit in-band, + // non-emergency projection is passed - any value < OOM_URGENT_THRESHOLD_S + // (including -1 with urgent=true) would admit through the emergency band + // regardless of growth. + lt->heapFloorRecordForTest(peak + RATE, _clock_ns, 0); + _clock_ns += SAMPLE_SPACING_S * SEC_NS; + ReferenceChainsTestAccessor::publishUrgencyStateForTest(1490.0, true); + EXPECT_TRUE(lt->admitForTrackingForTest(TID)) << "growth past the high-water mark must boost"; + + // Episode end: both drop. + ReferenceChainsTestAccessor::publishUrgencyStateForTest(-1.0, false); + EXPECT_FALSE(ReferenceChainsTestAccessor::oomRampActiveForTest()); + EXPECT_FALSE(lt->admitForTrackingForTest(TID)); +} + // The emergency band (< OOM_URGENT_THRESHOLD_S) waives the growth-evidence // hold: minutes from death means capturing whatever the sampler offers, // including the very first observation and a heap whose newest sample is @@ -542,6 +657,14 @@ TEST_F(ReferenceChainsCanaryRefillTest, RefillRaiseTracksOpenChaseExactly) { // updateUrgentAdmission() reads only the newest heap-floor sample, so no max // heap is set - and must not be before start(): with a max heap > 0, start()'s // autoTuneDefaults() calls GetAvailableProcessors, which this stub jvmti lacks. +// +// updateUrgentAdmissionForTest() writes the BFS-thread-only +// _admission_growth_baseline from the gtest main thread. That is race-free only +// because start() does not spawn the BFS thread (threadLoop() needs an attached +// VM - see the end of ReferenceChainTracker::start()), so no threadLoop() +// calls updateUrgentAdmission() concurrently here. The ASSERTs on +// bfsThreadRunning() pin that precondition: a future start() that spawns the +// thread fails here instead of racing. TEST_F(ReferenceChainsCanaryRefillTest, StopClearsAdmissionGrowthBaseline) { constexpr u64 MiB = 1ULL << 20; @@ -572,6 +695,8 @@ TEST_F(ReferenceChainsCanaryRefillTest, StopClearsAdmissionGrowthBaseline) { ReferenceChainsTestAccessor::reset(); } } cleanup{tracker, lt}; + ASSERT_FALSE(ReferenceChainsTestAccessor::bfsThreadRunning()) + << "updateUrgentAdmissionForTest() would race threadLoop()"; // Recording 1: the first urgent non-emergency observation snapshots // 500MiB and does not admit. @@ -580,6 +705,8 @@ TEST_F(ReferenceChainsCanaryRefillTest, StopClearsAdmissionGrowthBaseline) { tracker->stop(); ASSERT_FALSE(tracker->start(args)); + ASSERT_FALSE(ReferenceChainsTestAccessor::bfsThreadRunning()) + << "updateUrgentAdmissionForTest() would race threadLoop()"; // Recording 2: the heap is now above the OLD baseline. A fresh episode // must re-snapshot (false); a baseline leaked through stop() would admit diff --git a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h index 8d5d59ef4e..ec5774a696 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h +++ b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h @@ -124,6 +124,16 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->shouldRunPass(now_ns); } + // Projection-taking overloads: threadLoop()'s single per-iteration secondsToOOM() read is + // passed through instead of re-read. + static bool canAffordNewSearch(u64 now_ns, double seconds_to_oom) { + return ReferenceChainTracker::instance()->canAffordNewSearch(now_ns, seconds_to_oom); + } + + static bool shouldRunPass(u64 now_ns, double seconds_to_oom) { + return ReferenceChainTracker::instance()->shouldRunPass(now_ns, seconds_to_oom); + } + static void setSearchPainMs(u64 ms) { ReferenceChainTracker::instance()->_search_pain_ms = ms; } @@ -180,6 +190,27 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->updateUrgentAdmission(seconds_to_oom, urgent); } + // True while the BFS thread (startThread()/threadLoop()) is live. start() never spawns it + // (only startThread() does - profiler.cpp and the JNI-only resetSearchStateForTest()), so + // gtests may drive BFS-thread-only seams after start(); tests that rely on that assert this + // is false. + static bool bfsThreadRunning() { + return ReferenceChainTracker::instance()->_running.load(std::memory_order_acquire); + } + // threadLoop()'s publish step: ramp flag + growth-gated tracking boost. + static void publishUrgencyStateForTest(double seconds_to_oom, bool urgent) { + ReferenceChainTracker::instance()->publishUrgencyState(seconds_to_oom, urgent); + } + static bool oomRampActiveForTest() { + return ReferenceChainTracker::instance()->_oom_ramp_active; + } + // Projection-taking urgency decisions (consume the passed value, never re-read). + static bool isUrgentWithProjectionForTest(double seconds_to_oom) { + return ReferenceChainTracker::instance()->isUrgent(seconds_to_oom); + } + static bool hasLeakSignalForTest(double seconds_to_oom) { + return ReferenceChainTracker::instance()->hasLeakSignal(seconds_to_oom); + } // Applied vs configured CPU pain-budget refill rate (shouldRunPass()'s // canary-refill raise). static double cpuPainBudgetRefillRateForTest() { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java index 9e7112e0d9..5fb113074b 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -9,12 +9,16 @@ import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.ReferenceChainTestHooks; import org.junit.jupiter.api.Test; +import org.opentest4j.AssertionFailedError; +import org.opentest4j.TestAbortedException; import java.util.Arrays; +import java.util.function.Supplier; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; import static org.junit.jupiter.api.Assumptions.assumeFalse; @@ -149,20 +153,81 @@ private static GateEvaluation evaluateGateAfterSearchReset() { } /** - * Aborts only on an observed per-klass candidate: a candidate legitimately opens the gate, so - * the candidate-gate contract cannot be isolated in that run. A BFS pass since the search reset - * (the reset zeroes the pass count) with no candidate in either read means the gate opened - * without a candidate - e.g. a re-introduced urgency bypass on the restarted BFS thread - and - * must FAIL, not skip. + * Aborts on an observed per-klass candidate: a candidate legitimately opens the gate, so the + * candidate-gate contract cannot be isolated in that run. A BFS pass since the search reset + * (the reset zeroes the pass count) with no candidate in either read is re-evaluated once on a + * fresh reset: it FAILS if the candidate-free pass reproduces - the gate opened without a + * candidate, e.g. a re-introduced urgency bypass on the restarted BFS thread - and ABORTS if it + * does not reproduce (or the retry sees a candidate), because the bracket alone cannot tell a + * transient organic candidate apart from a gate regression. Never a silent pass. */ private static void checkNoCandidateFreeActivity(GateEvaluation e) { + checkNoCandidateFreeActivity(e, + AggressiveLeakReferenceChainTest::evaluateGateAfterSearchReset); + } + + /** Decision table of {@link #checkNoCandidateFreeActivity(GateEvaluation)}, retry injectable. */ + private static void checkNoCandidateFreeActivity(GateEvaluation e, + Supplier retry) { assumeFalse(e.candidateSeen, "Aborting: organic per-klass candidate around the direct " + "evaluation (" + e.detail + ") - the candidate-gate contract cannot be isolated in " + "this run"); - if (e.passesAfter != 0) { - fail("A BFS pass ran after the search reset although no per-klass candidate was seen in " - + "either read - the gate opened without a candidate (" + e.detail + ")"); + if (e.passesAfter == 0) { + return; + } + // One candidate-free pass is not proof: the restarted BFS thread's gate decision is not + // atomic with the two candidate reads, so an organic candidate can authorize the pass and + // leave selectLeakCandidates() between them (each change needs a population fold, at most + // one per GC epoch). That cannot plausibly repeat back-to-back; a gate that opens without a + // candidate does. Re-evaluate once on a fresh reset and fail only if it reproduces. + GateEvaluation r = retry.get(); + String both = "first: " + e.detail + "; retry: " + r.detail; + assumeFalse(r.candidateSeen, "Aborting: a BFS pass ran with no per-klass candidate seen, " + + "and the re-evaluation then saw an organic candidate (" + both + ") - the " + + "candidate-gate contract cannot be isolated in this run"); + if (r.passesAfter != 0) { + fail("A BFS pass ran after the search reset with no per-klass candidate in any read, on " + + "two independent evaluations - the gate opened without a candidate (" + both + ")"); } + assumeTrue(false, "Aborting: a candidate-free BFS pass after the search reset did not " + + "reproduce on a re-evaluation (" + both + ") - a transient organic candidate in this " + + "shared JVM cannot be ruled out, so the run cannot be isolated"); + } + + private static GateEvaluation syntheticEvaluation(boolean candidateSeen, int passesAfter) { + GateEvaluation e = new GateEvaluation(); + e.candidateSeen = candidateSeen; + e.passesAfter = passesAfter; + e.detail = "synthetic(candidateSeen=" + candidateSeen + ", passesAfter=" + passesAfter + ")"; + return e; + } + + /** + * Pure-logic check of {@link #checkNoCandidateFreeActivity}'s abort/fail table (no native + * seams): a candidate aborts; a clean evaluation returns without consuming the retry; a + * candidate-free pass fails only when it reproduces on the retry and otherwise aborts - never + * a silent pass, and never a hard fail on a single unreproduced observation. + */ + @Test + public void checkNoCandidateFreeActivityRequiresReproductionBeforeFailing() { + Supplier noRetry = () -> { + throw new IllegalStateException("retry must not be consumed"); + }; + checkNoCandidateFreeActivity(syntheticEvaluation(false, 0), noRetry); + assertThrows(TestAbortedException.class, + () -> checkNoCandidateFreeActivity(syntheticEvaluation(true, 0), noRetry)); + assertThrows(AssertionFailedError.class, + () -> checkNoCandidateFreeActivity(syntheticEvaluation(false, 1), + () -> syntheticEvaluation(false, 1)), + "a reproduced candidate-free pass must fail"); + assertThrows(TestAbortedException.class, + () -> checkNoCandidateFreeActivity(syntheticEvaluation(false, 1), + () -> syntheticEvaluation(false, 0)), + "an unreproduced candidate-free pass must abort, not fail or pass"); + assertThrows(TestAbortedException.class, + () -> checkNoCandidateFreeActivity(syntheticEvaluation(false, 1), + () -> syntheticEvaluation(true, 1)), + "a retry that sees a candidate must abort"); } private static boolean isEmpty(int[] a) { @@ -182,8 +247,9 @@ private static boolean isEmpty(int[] a) { *

The live BFS thread is deliberately not watched over a wait window: its pass counter also * counts passes legitimately started by unrelated per-klass candidates in this shared JVM. The * run is aborted (not passed) when an organic candidate appears around the direct evaluation, - * and fails when a BFS pass ran since the search reset without any candidate - the gate opened - * without one. + * and fails when a BFS pass since the search reset, with no candidate in any read, reproduces on + * a second independent evaluation - the gate opened without one (a single unreproduced + * occurrence aborts the run). */ @Test public void shouldNotOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java index c86a1aaf58..4fe1d3ff16 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java @@ -11,6 +11,7 @@ import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assumptions.assumeTrue; @@ -40,6 +41,9 @@ public class ReferenceChainTestSeamsTest extends AbstractProfilerTest { // genuine history outranks the seeded ramp. private static final int SLOPE_TEST_KLASS_ID = 987001; private static final int CHAIN_TEST_KLASS_ID = 987002; + // Arbitrary non-zero burn-in stamp: LivenessTracker::initialize() only CASes the stamp from 0, + // so a non-zero value cannot be overwritten by a concurrent profiler start mid-test. + private static final long KNOWN_ACTIVATION_STAMP_NS = 42L; private static final class ChainTarget {} @@ -194,4 +198,29 @@ public void failureDiagnosticsFingerprintHasCanonicalSegments() { public void observedChainHeadsIsEmptyForNoEvents() { assertTrue(ReferenceChainAssertions.observedChainHeads(null).isEmpty()); } + + /** + * Kills a dropped negative-argument guard in setLeakSignalActivatedNsForTest0 (javaApi.cpp): + * without it a negative jlong would be stored as a u64 near UINT64_MAX, which secondsToOOM() + * reads as burn-in-never-passed. Asserts the throw AND that the stored stamp is unchanged; the + * original stamp is restored on every exit path (shared, no-forkEvery JVM). + */ + @Test + public void shouldRejectNegativeLeakSignalActivationStamp() { + assumeDebugBuild(); + long savedActivationNs = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); + long observed; + try { + ReferenceChainTestHooks.setLeakSignalActivatedNsForTest0(KNOWN_ACTIVATION_STAMP_NS); + assertThrows(IllegalArgumentException.class, + () -> ReferenceChainTestHooks.setLeakSignalActivatedNsForTest0(-1L), + "A negative burn-in stamp must be rejected, not cast to a huge u64"); + // Returns the current stamp (and backdates; the finally block restores the original). + observed = ReferenceChainTestHooks.backdateLeakSignalActivationForTest0(); + } finally { + ReferenceChainTestHooks.setLeakSignalActivatedNsForTest0(savedActivationNs); + } + assertEquals(KNOWN_ACTIVATION_STAMP_NS, observed, + "A rejected negative stamp must leave the previous stamp in place"); + } }