Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
7ccf717
Fix reference-chain collector running without a leak signal
jbachorik Oct 29, 2026
900efad
Ignore AI-scratch directories
jbachorik Oct 29, 2026
87f4839
Update Java reference-chain tests for candidate-only admission
jbachorik Oct 29, 2026
6cffe71
Gate urgent liveness admission on fresh heap growth evidence
jbachorik Oct 29, 2026
cbbaff4
Clear admission growth baseline at recording boundary; guard test seams
jbachorik Oct 29, 2026
76e7f9a
Pin baseline reset and vacuous-negative guards in reference-chain tests
jbachorik Oct 29, 2026
754cb14
Document urgency latch scope and urgent-no-candidate counter
jbachorik Oct 29, 2026
443aef5
Harden the fix-round test seams against review-delta findings
jbachorik Oct 30, 2026
9511117
Clarify concurrency-legality comments in reference-chain stop path
jbachorik Oct 30, 2026
a8d58aa
Trim comment verbosity in the two review-follow-up blocks
jbachorik Oct 30, 2026
9af062c
Deduplicate comment content across the leak-signal gate seams
jbachorik Oct 30, 2026
d698771
Update architecture indexes for the leak-signal gate
jbachorik Oct 30, 2026
2c4566e
Drop gate semantics from the index entries
jbachorik Oct 30, 2026
b4daf9d
Track gtest .inc/.h includes in the up-to-date check
jbachorik Oct 30, 2026
0826745
Shorten the gtest .inc inputs comment
jbachorik Oct 30, 2026
476f5d2
Remove internal episode references from comments
jbachorik Oct 30, 2026
65a0d3a
Stabilize flaky ReferenceChainTrackingTest success paths (PROF-16383)
jbachorik Oct 30, 2026
9f0aeca
Centralize reference-chain failure diagnostics per review findings
jbachorik Oct 30, 2026
d8e5949
Address rkennke review findings on leak-signal gating and admission
jbachorik Oct 30, 2026
e3410fc
Fix review findings from the quick sphinx review round
jbachorik Oct 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ CLAUDE.md

# AI review/agent tooling scratch state
.sphinx/
/.scratch/
.skill-builder-temp/
.claude/scheduled_tasks.lock

Expand All @@ -64,3 +65,4 @@ CLAUDE.md

# AI investigation scratch state (never committed)
.investigations/
/notes/
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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, 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) |
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
/*
* Copyright 2026, Datadog, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

package com.datadoghq.native.gtest

Expand All @@ -9,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

Expand Down Expand Up @@ -122,6 +127,25 @@ class GtestTaskBuilder(
testFile
)
}

// Test .inc/.h files resolve via quoted includes relative to the test

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit (AGENTS.md: "If modifying a file that does not contain Datadog copyright, add one"): this file has no Datadog copyright header. The same applies to ddprof-lib/src/test/cpp/referenceChainsOomTests.inc and referenceChainsLifecycleTests.inc, which this PR also modifies.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added, same for the two .inc files (d8e594993).

// 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)
}
Expand Down
6 changes: 6 additions & 0 deletions ddprof-lib/src/main/cpp/counters.h
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
72 changes: 72 additions & 0 deletions ddprof-lib/src/main/cpp/javaApi.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1404,6 +1404,56 @@ 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) {
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);
}

// 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.
Expand Down Expand Up @@ -1520,6 +1570,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 -----------------------------------
Expand Down
86 changes: 64 additions & 22 deletions ddprof-lib/src/main/cpp/livenessTracker.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1474,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,
Expand Down Expand Up @@ -1571,9 +1589,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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Question on the burn-in tradeoff: since _leak_signal_activated_ns is stamped on first activation, for the first 5 minutes secondsToOOM() returns -1. That disables everything keyed on it, not just the urgent latch: the threadLoop() cadence/pause-target ramp, the budget boost, updateUrgentAdmission() (including the emergency band, whose comment says it admits "without waiting"), and the no-progress-abandonment suppression.

So attaching to a JVM that is already leaking, or a process that OOMs 3–4 minutes after start, gets none of the end-of-life capture. I get why warmup growth needs suppressing for the urgent latch. Could the burn-in apply only to isUrgent() and leave the ramp running? Alternatively, it could be released early once the projection is backed by a real candidate.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Burn-in stays as is. The two exposure cases do not justify a change: we do not attach mid-life today, and if we add it, a 5-minute blind window on an already-leaking heap is an acceptable tradeoff. Early release on candidates fails either way — a warming cache produces the same monotone per-klass survivor growth a leak does, so it re-opens warmup-driven searching. A process OOMing within ~4 minutes of start is not salvageable by widening the search window; that case belongs to a different mechanism — heap dump on OOME plus post-hoc extraction of the interesting parts — which is separate work.

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;
}

Expand Down Expand Up @@ -1936,7 +1965,7 @@ void LivenessTracker::flush_table(std::set<int> *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;
Expand Down Expand Up @@ -2021,6 +2050,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) {
Expand Down Expand Up @@ -2095,13 +2125,17 @@ 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);
// 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) {
return Error::OK;
Expand All @@ -2121,6 +2155,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
Expand Down Expand Up @@ -2479,7 +2520,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;
Expand Down Expand Up @@ -2535,10 +2576,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);
Expand Down
Loading
Loading