diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index ac522fd238..ff194cccf6 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -72,6 +72,38 @@ gtest { failFast.set(true) } +// referenceChainJfrRoundtrip_ut writes a .jfr that :ddprof-test's ReferenceChainJfrParserTest +// reads back. Both sides get the path from this env var (set here and in ddprof-test's +// build.gradle.kts). The path is scoped per build configuration (derived from the gtest task +// name, which encodes it) so a multi-configuration invocation cannot delete or overwrite +// another configuration's recording, and the producer deletes the file before it runs, so +// the consumer never parses a recording left behind by an older binary. +val roundtripSuiteSuffix = "_referenceChainJfrRoundtrip_ut" +val roundtripProducers = tasks.withType().matching { + it.name.startsWith("gtest") && it.name.endsWith(roundtripSuiteSuffix) +} +// The gtest plugin registers one producer per build configuration; zero matches can only +// mean the suite id or the gtest task naming changed. Fail the build instead of letting +// ReferenceChainJfrParserTest silently degrade to a skip. +gradle.projectsEvaluated { + check(roundtripProducers.isNotEmpty()) { + "No gtest task matching 'gtest*$roundtripSuiteSuffix' was registered - the " + + "reference-chain JFR roundtrip wiring is stale (suite renamed?)" + } +} +roundtripProducers.configureEach { + val config = name.removePrefix("gtest").removeSuffix(roundtripSuiteSuffix).lowercase() + val jfr = rootProject.layout.buildDirectory + .file("tmp/reference-chain-roundtrip/$config/datadog_reference_chain_roundtrip.jfr") + .get() + .asFile + environment("DDPROF_REFERENCE_CHAIN_ROUNDTRIP_JFR", jfr.absolutePath) + doFirst { + jfr.parentFile.mkdirs() + jfr.delete() + } +} + java { sourceCompatibility = JavaVersion.VERSION_1_8 targetCompatibility = JavaVersion.VERSION_1_8 diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index cf6c90d342..1e8dc91b5e 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -94,6 +94,11 @@ class ObjectLivenessEvent : public Event { // flush_table() overwrites it from the entry's own tag (track() zeroes it // at insert). int64_t leak_tag = 0; + // LivenessTracker::leakTagIncarnation(leak_tag), read by flush_table() under the exclusive + // _table_lock (so it is the owner's stamp); 0 when untagged. Serialized as + // HeapLiveObject.leakTagIncarnation: leak tags are recycled, so (leakTag, leakTagIncarnation) + // identifies the object across chunks. + u32 leak_tag_incarnation = 0; Context _ctx; }; @@ -139,10 +144,17 @@ class ReferenceChainEvent : public Event { u64 _target_tag; u32 _depth; u8 _root_kind; + // LivenessTracker's ownership-period stamp of _target_tag when it is a leak tag, captured when the + // tag was bound to the target's frontier entry; 0 otherwise. Serialized as + // ReferenceChain.leakTagIncarnation. Leak tags are recycled, so consumers join + // ReferenceChain(targetTag, leakTagIncarnation) to HeapLiveObject(leakTag, leakTagIncarnation): + // the tag alone is only unambiguous within one chunk. + u32 _leak_tag_incarnation; std::vector _hops; ReferenceChainEvent() - : Event(), _start_time(0), _target_tag(0), _depth(0), _root_kind(0) {} + : Event(), _start_time(0), _target_tag(0), _depth(0), _root_kind(0), + _leak_tag_incarnation(0) {} }; // Search-level abandonment signal: reports why the reference-chain search diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 98455483d8..e5c432603c 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -2423,12 +2423,14 @@ void Recording::recordHeapLiveObject(Buffer *buf, int tid, u64 call_trace_id, ? ((event->_alloc._weight * event->_alloc._size) + event->_skipped) / event->_alloc._size : 0); - // leak_tag precedes the context snapshot to match the metadata field - // order (jfrMetadata.cpp: leakTag is declared between weight and spanId, - // before || contextAttributes) - a field written on the opposite side of - // writeContextSnapshot() than where the metadata declares it makes every - // parser read it from the first context-attribute byte. + // leak_tag and leak_tag_incarnation precede the context snapshot to match + // the metadata field order (jfrMetadata.cpp: leakTag, leakTagIncarnation + // are declared between weight and spanId, before || contextAttributes) - a + // field written on the opposite side of writeContextSnapshot() than where + // the metadata declares it makes every parser read it from the first + // context-attribute byte. buf->putVar64(event->leak_tag); + buf->putVar64((u64)event->leak_tag_incarnation); writeContextSnapshot(buf, event->_ctx); writeEventSizePrefix(buf, start); flushIfNeeded(buf); @@ -2524,6 +2526,7 @@ void Recording::recordReferenceChain(Buffer *buf, ReferenceChainEvent *event) { buf->putVar64(T_REFERENCE_CHAIN); buf->putVar64(event->_start_time); buf->putVar64(event->_target_tag); + buf->putVar64((u64)event->_leak_tag_incarnation); buf->putVar32(event->_depth); buf->putUtf8(root_kind_name); // Original (pre-truncation) chain length, so a consumer can tell a full diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index bee39b415e..f660e85c3b 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -68,7 +68,8 @@ const int MAX_VAR32_LENGTH = 5; // a release-mode write past the buffer). const int REFERENCE_CHAIN_EVENT_FIXED_BYTES = MAX_VAR32_LENGTH /* multi-byte event size prefix */ + - 3 * MAX_VAR64_LENGTH /* type id, start_time, target_tag */ + + 4 * MAX_VAR64_LENGTH /* type id, start_time, target_tag, + leak_tag_incarnation */ + 3 * MAX_VAR32_LENGTH /* depth, totalHops, chain count */ + 32 /* rootKind string, generous */ + MAX_VAR32_LENGTH /* edges count */; diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 904a7b6e37..f9365be145 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -1109,6 +1109,7 @@ Java_com_datadoghq_profiler_JavaProfiler_dumpContext(JNIEnv* env, jclass unused) // no-op stubs below. #ifdef DEBUG #include "livenessTracker.h" +#include "objectSampler.h" #include "referenceChains.h" #include @@ -1173,6 +1174,20 @@ Java_com_datadoghq_profiler_JavaProfiler_resetKlassPopulationForTest0( JNIEnv *env, jclass unused) { ProfiledThread::initCurrentThreadSignalSafe(); LivenessTracker::instance()->klassPopulationResetForTest(); + // Re-baseline the class-map generation: selectLeakCandidates() returns + // nothing while classMap()->generation() differs from the tracker's + // baseline, and that baseline is only re-synced by cleanup_table()'s + // GC-driven sweep - but this JVM has typically already run several + // recordings (each Profiler::start() clearAll() bumps the generation) + // before this test's seeding, and no GC may land between start() and + // the seeded selectLeakCandidates() call. After the wipe above there is + // no stale-namespace population data left, and + // classMapGenerationSetForTest() itself invalidates every tracking entry's + // cached_klass_id when the generation changes (the same thing + // cleanup_table()'s reset branch does), so adopting the current + // generation is safe. + LivenessTracker::instance()->classMapGenerationSetForTest( + Profiler::instance()->classMap()->generation()); } extern "C" DLLEXPORT jintArray JNICALL @@ -1227,13 +1242,48 @@ Java_com_datadoghq_profiler_JavaProfiler_pollReferenceChainTargets0( ReferenceChainTracker::instance()->finishLoopIterationSerialized(jvmti, env); } +// Counts only the drained chains whose leaf hop (hops are stored leaf-first) is `leaf`'s class, +// so a chain left cached by an earlier test in the same JVM cannot satisfy the caller. The class +// id is resolved with the same GetClassSignature + normalizeClassSignature() + lookupClass() +// sequence the chain collector uses (LivenessTracker::resolveKlassId()). extern "C" DLLEXPORT jint JNICALL -Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCount0( - JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCountForLeaf0( + JNIEnv *env, jclass unused, jobject leaf) { ProfiledThread::initCurrentThreadSignalSafe(); + jvmtiEnv *jvmti = VM::jvmti(); + if (leaf == nullptr || jvmti == nullptr) { + return 0; + } + u32 leaf_id = 0; + jclass clz = env->GetObjectClass(leaf); + if (clz != nullptr) { + char *sig = nullptr; + if (jvmti->GetClassSignature(clz, &sig, nullptr) == JVMTI_ERROR_NONE && + sig != nullptr) { + const char *name = nullptr; + size_t name_len = 0; + if (ObjectSampler::normalizeClassSignature(sig, &name, &name_len)) { + int id = Profiler::instance()->lookupClass(name, name_len); + if (id > 0) { + leaf_id = (u32)id; + } + } + jvmti->Deallocate((unsigned char *)sig); + } + env->DeleteLocalRef(clz); + } + if (leaf_id == 0) { + return 0; + } std::vector events; ReferenceChainTracker::instance()->drainPendingChainEvents(&events); - return (jint)events.size(); + jint count = 0; + for (const ReferenceChainEvent &event : events) { + if (!event._hops.empty() && event._hops[0].klass_id == leaf_id) { + count++; + } + } + return count; } extern "C" DLLEXPORT void JNICALL @@ -1249,6 +1299,13 @@ Java_com_datadoghq_profiler_JavaProfiler_resetReferenceChainSearchForTest0( // Reads target's existing tag without tagging it. Return values are described // at pendingExpandPositionForTest(). +// +// The tag read and the lookup both run under _engine_lock: the BFS thread +// releases and reissues frontier tags (releaseSearchTags()/restartSearch()) +// only while holding it, so the tag cannot be reassigned to another object +// between GetTag() and the lookup. The lock is recursive (mutex.cpp), so the +// tracker seam's own nested acquisition is fine. The same applies to the two +// seams below. extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingPositionForTest0( JNIEnv *env, jclass unused, jobject target) { @@ -1257,13 +1314,14 @@ Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingPositionForTest if (jvmti == nullptr || target == nullptr) { return -2; } + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + MutexLocker engine_guard(tracker->_engine_lock); jlong tag = 0; jvmtiError err = jvmti->GetTag(target, &tag); if (err != JVMTI_ERROR_NONE) { return -2; } - return (jlong)ReferenceChainTracker::instance()->pendingExpandPositionForTest( - tag); + return (jlong)tracker->pendingExpandPositionForTest(tag); } extern "C" DLLEXPORT jlong JNICALL @@ -1273,6 +1331,45 @@ Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingSizeForTest0( return (jlong)ReferenceChainTracker::instance()->pendingExpandSizeForTest(); } +// Reads target's existing tag without tagging it, under _engine_lock (see +// getReferenceChainPendingPositionForTest0()); see frontierStateForTest(). +extern "C" DLLEXPORT jint JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainFrontierStateForTest0( + JNIEnv *env, jclass unused, jobject target) { + ProfiledThread::initCurrentThreadSignalSafe(); + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr || target == nullptr) { + return -1; + } + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + MutexLocker engine_guard(tracker->_engine_lock); + jlong tag = 0; + if (jvmti->GetTag(target, &tag) != JVMTI_ERROR_NONE) { + return -1; + } + return (jint)tracker->frontierStateForTest(tag); +} + +// Reads target's existing tag without tagging it, under _engine_lock (see +// getReferenceChainPendingPositionForTest0()); see +// resolvedChainTargetTagForTest(). +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainResolvedTargetTagForTest0( + JNIEnv *env, jclass unused, jobject target) { + ProfiledThread::initCurrentThreadSignalSafe(); + jvmtiEnv *jvmti = VM::jvmti(); + if (jvmti == nullptr || target == nullptr) { + return 0; + } + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + MutexLocker engine_guard(tracker->_engine_lock); + jlong tag = 0; + if (jvmti->GetTag(target, &tag) != JVMTI_ERROR_NONE) { + return 0; + } + return tracker->resolvedChainTargetTagForTest(tag); +} + // Seeds one heap-floor sample for secondsToOOM() without waiting for a GC. // Timestamps are only compared with each other, so any increasing sequence works. extern "C" DLLEXPORT void JNICALL @@ -1299,6 +1396,24 @@ Java_com_datadoghq_profiler_JavaProfiler_setHeapFloorRecordingForTest0( LivenessTracker::instance()->setHeapFloorRecordingForTest(enabled == JNI_TRUE); } +// Clears the heap-floor rings seeded by heapFloorRecordForTest0() so the +// next test in the same JVM starts from an empty ring. +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_heapFloorResetForTest0( + JNIEnv *env, jclass unused) { + ProfiledThread::initCurrentThreadSignalSafe(); + LivenessTracker::instance()->heapFloorResetForTest(); +} + +// Reports whether the seeded heap-floor ring currently shows a sustained rise +// (heapFloorRising()) - the direct read over the ring the reset seam clears. +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_heapFloorRisingForTest0( + JNIEnv *env, jclass unused) { + ProfiledThread::initCurrentThreadSignalSafe(); + return LivenessTracker::instance()->heapFloorRisingForTest() ? JNI_TRUE : JNI_FALSE; +} + // Evaluates the real scheduling gate without executing a BFS pass. This is not read-only: under // the engine lock it may account completed-search pain and restart/reset terminal search state. extern "C" DLLEXPORT jboolean JNICALL @@ -1368,8 +1483,8 @@ Java_com_datadoghq_profiler_JavaProfiler_pollReferenceChainTargets0( JNIEnv *env, jclass unused) {} extern "C" DLLEXPORT jint JNICALL -Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCount0( - JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCountForLeaf0( + JNIEnv *env, jclass unused, jobject leaf) { return 0; } @@ -1389,6 +1504,18 @@ Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainPendingSizeForTest0( return 0; } +extern "C" DLLEXPORT jint JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainFrontierStateForTest0( + JNIEnv *env, jclass unused, jobject target) { + return -1; +} + +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_getReferenceChainResolvedTargetTagForTest0( + JNIEnv *env, jclass unused, jobject target) { + return 0; +} + extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_heapFloorRecordForTest0( JNIEnv *env, jclass unused, jlong usedBytes, jlong timestampNs) {} @@ -1401,6 +1528,16 @@ extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_setHeapFloorRecordingForTest0( JNIEnv *env, jclass unused, jboolean enabled) {} +extern "C" DLLEXPORT void JNICALL +Java_com_datadoghq_profiler_JavaProfiler_heapFloorResetForTest0( + JNIEnv *env, jclass unused) {} + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_heapFloorRisingForTest0( + JNIEnv *env, jclass unused) { + return JNI_FALSE; +} + extern "C" DLLEXPORT jboolean JNICALL Java_com_datadoghq_profiler_JavaProfiler_shouldRunPassForTest0(JNIEnv *env, jclass unused) { diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index 51dd36dd92..86b0e8ebe7 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -202,6 +202,8 @@ void JfrMetadata::initialize( << field("size", T_LONG, "Original Size", F_BYTES) << field("weight", T_FLOAT, "Sample weight") << field("leakTag", T_LONG, "Leak Tag", F_UNSIGNED) + << field("leakTagIncarnation", T_LONG, + "Leak Tag Incarnation", F_UNSIGNED) << field("spanId", T_LONG, "Span ID") << field("localRootSpanId", T_LONG, "Local Root Span ID") || contextAttributes) @@ -211,6 +213,8 @@ void JfrMetadata::initialize( << category("Datadog", "Profiling") << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) << field("targetTag", T_LONG, "Frontier Tag", F_UNSIGNED) + << field("leakTagIncarnation", T_LONG, "Leak Tag Incarnation", + F_UNSIGNED) << field("depth", T_INT, "Depth") << field("rootKind", T_STRING, "GC Root Kind") << field("totalHops", T_INT, diff --git a/ddprof-lib/src/main/cpp/livenessTracker.cpp b/ddprof-lib/src/main/cpp/livenessTracker.cpp index a3a1a6afe1..538854d9f6 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.cpp +++ b/ddprof-lib/src/main/cpp/livenessTracker.cpp @@ -451,13 +451,17 @@ jlong LivenessTracker::acquireLeakTag() { // other, but any future second shared-lock mutator would corrupt the LIFO // free list. The dedicated lock keeps the pool correct independent of which // table lock mode the caller holds. Lock order: _table_lock (any mode) is - // always acquired BEFORE _leak_tag_pool_lock, never the reverse. + // always acquired BEFORE _leak_tag_pool_lock, never the reverse. The same + // lock serializes the ownership-period stamp writes (_leak_tag_incarnation), + // which are bumped on every ownership transition so a reader can tell this + // owner of the tag from an earlier, dead one; stamp reads are lock-free. ExclusiveLockGuard guard(&_leak_tag_pool_lock); if (_leak_tag_free_count <= 0) { return 0; // pool exhausted } int idx = _leak_tag_free_list[--_leak_tag_free_count]; _leak_tag_in_use[idx] = true; + bumpLeakTagIncarnationLocked(idx); // free -> in use: a new ownership period return LEAK_TAG_BASE + idx; } @@ -476,6 +480,9 @@ void LivenessTracker::releaseLeakTag(jlong tag) { return; } _leak_tag_in_use[idx] = false; + // in use -> free: chains captured during the ending ownership period no longer + // describe whoever owns the tag next. + bumpLeakTagIncarnationLocked(idx); _leak_tag_free_list[_leak_tag_free_count++] = idx; } @@ -1922,6 +1929,9 @@ void LivenessTracker::flush_table(std::set *tracked_thread_ids) { event._skipped = _table[i].skipped; event._ctx = _table[i].ctx; event.leak_tag = _table[i].leak_tag; + // The exclusive _table_lock keeps the tag's owner fixed (release only happens in + // cleanup_table()), so this is the current owner's stamp. + event.leak_tag_incarnation = leakTagIncarnation(_table[i].leak_tag); int class_id = 0; if (_table[i].cached_klass_id != 0 && class_map_generation_matches) { @@ -2039,6 +2049,11 @@ Error LivenessTracker::start(Arguments &args) { _leak_tag_pool_lock.lock(); int free_w = 0; for (int i = 0; i < LEAK_TAG_POOL_SIZE; i++) { + if (_leak_tag_in_use[i] != tag_owned[i]) { + // Ownership of this slot changes in the rebuild; a slot whose owner is + // unchanged keeps its stamp, so its cached chains stay attributable. + bumpLeakTagIncarnationLocked(i); + } _leak_tag_in_use[i] = tag_owned[i]; if (!tag_owned[i]) { _leak_tag_free_list[free_w++] = i; diff --git a/ddprof-lib/src/main/cpp/livenessTracker.h b/ddprof-lib/src/main/cpp/livenessTracker.h index a10fb017cc..20afe41418 100644 --- a/ddprof-lib/src/main/cpp/livenessTracker.h +++ b/ddprof-lib/src/main/cpp/livenessTracker.h @@ -14,6 +14,7 @@ #include "event.h" #include "spinLock.h" #include "xorshift.h" +#include #include #include #include @@ -571,13 +572,38 @@ class alignas(alignof(SpinLock)) LivenessTracker { // holder excludes the exclusive one, but only this dedicated lock makes the // pool safe for any future second shared-lock mutator. Lock order: _table_lock // (any mode) is always acquired BEFORE _leak_tag_pool_lock, never the - // reverse; no path acquires _table_lock while holding the pool lock. + // reverse; no path acquires _table_lock while holding the pool lock. It also + // serializes every write of _leak_tag_incarnation (reads of the stamp take no + // lock - see leakTagIncarnation()). mutable SpinLock _leak_tag_pool_lock; int _leak_tag_free_list[LEAK_TAG_POOL_SIZE]; int _leak_tag_free_count; // Per-tag ownership: set by acquireLeakTag(), cleared by releaseLeakTag(), // so a repeated release of a tag that was not re-acquired is ignored. bool _leak_tag_in_use[LEAK_TAG_POOL_SIZE]; + // Per-slot ownership-period stamp. A leak tag is recycled once its object + // dies, so the tag alone does not identify one object: (tag, stamp) does. + // The stamp changes on every ownership transition of the slot - acquire + // (free -> in use), a real release (in use -> free), and every slot whose + // in-use bit flips in start()'s rebuild or in leakTagPoolResetForTest() - + // and never on a no-op (rejected or out-of-range release, unchanged slot). + // Written only under _leak_tag_pool_lock (atomic release stores), read + // lock-free with acquire semantics so heap callbacks and reference-chain + // code never take the pool lock. The u32 wraps after 2^32 transitions of one + // slot; a false "same owner" needs exactly that many transitions between a + // stamp's capture and its check, which is accepted as negligible. + u32 _leak_tag_incarnation[LEAK_TAG_POOL_SIZE]; + + // Advances slot `idx`'s ownership-period stamp. Caller holds + // _leak_tag_pool_lock. The read is atomic as well: heap callbacks and + // reference-chain code read the same slot lock-free (leakTagIncarnation()), + // and a plain read racing those loads is UB under the C++ memory model even + // though the store itself is a release store. + void bumpLeakTagIncarnationLocked(int idx) { + __atomic_store_n(&_leak_tag_incarnation[idx], + __atomic_load_n(&_leak_tag_incarnation[idx], __ATOMIC_RELAXED) + 1, + __ATOMIC_RELEASE); + } jlong acquireLeakTag(); void releaseLeakTag(jlong tag); @@ -631,10 +657,8 @@ class alignas(alignof(SpinLock)) LivenessTracker { // --- Per-klass population tracking (cleanup_table()'s epoch-advance pass only) --- // Resolves the StringDictionary id for `ref`'s class (GetObjectClass + - // GetClassSignature + Profiler::lookupClass(), also used by flush_table()) - // - this is the "genuinely new - // cost on an existing pass" the design doc flags, previously paid only at - // JFR-flush time. Returns 0 (StringDictionary's own "no entry" sentinel) + // GetClassSignature + Profiler::lookupClass(), also used by flush_table()). + // Returns 0 (StringDictionary's own "no entry" sentinel) // if the name could not be resolved or interned. u32 resolveKlassId(JNIEnv *env, jobject ref); @@ -760,8 +784,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // The sustained-trend gate (this class's own header comment above, // "Sustained-trend gate") - both-required growth-magnitude and floor-rise - // tests, design doc's original "mean of thirds" choice since replaced by - // full-window least-squares regression (see ringWindowStats, + // tests, computed by full-window least-squares regression (see ringWindowStats, // livenessTracker.cpp - cheap, allocation-free, one pass over the // ring, no sorting or extra storage). A single scan // (ringWindowStats(), livenessTracker.cpp) both derives the pass/fail @@ -771,8 +794,7 @@ class alignas(alignof(SpinLock)) LivenessTracker { // false (leaving entry.cached_slope untouched) if entry.ring_fill is below // KLASS_POPULATION_MIN_FILL_FOR_TREND - not enough history yet to trust a // trend; callers must check ring_fill themselves before trusting - // cached_slope, exactly as they checked this method's own return value - // before cached_slope existed. + // cached_slope. // // Called from recordKlassPopulationSampleLocked() every time a new sample // is pushed (both the production path, @@ -834,7 +856,26 @@ class alignas(alignof(SpinLock)) LivenessTracker { _gc_generations(false), _klass_population_size(0), _klass_count_scratch_size(0), _last_class_map_generation(0), - _leak_tag_free_count(LEAK_TAG_POOL_SIZE) {} + _leak_tag_free_count(LEAK_TAG_POOL_SIZE), + // Value-initialized alongside the stamps: start() rebuilds the pool by + // comparing every _leak_tag_in_use[i] against the table's owners, so a + // non-statically constructed tracker must read all-false there. (The + // singleton is static-storage and zero-initialized regardless.) + _leak_tag_in_use(), // value-initialized: every slot starts free + _leak_tag_incarnation() {} // value-initialized: every stamp starts at 0 + + // Ownership-period stamp of leak tag `tag` (see _leak_tag_incarnation), or 0 + // when `tag` is outside the pool range. Lock-free (one acquire load): safe + // from heap callbacks and while holding reference-chain locks. A cached + // reference chain whose captured stamp equals this value was built for the + // tag's current owner. + u32 leakTagIncarnation(jlong tag) const { + if (tag < LEAK_TAG_BASE || tag >= LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE) { + return 0; + } + return __atomic_load_n(&_leak_tag_incarnation[tag - LEAK_TAG_BASE], + __ATOMIC_ACQUIRE); + } // Reset recording-scoped gates at every accepted profiler start. Persistent tracking and // population state intentionally survives recording boundaries. @@ -1075,8 +1116,13 @@ class alignas(alignof(SpinLock)) LivenessTracker { // tagLeakInstances() itself needs a live JVM (SetTag/NewLocalRef) and stays // out of gtest's reach. void leakTagPoolResetForTest() { + ExclusiveLockGuard guard(&_leak_tag_pool_lock); for (int i = 0; i < LEAK_TAG_POOL_SIZE; i++) { _leak_tag_free_list[i] = i; + if (_leak_tag_in_use[i]) { + // Freeing an owned slot is an ownership transition. + bumpLeakTagIncarnationLocked(i); + } _leak_tag_in_use[i] = false; } _leak_tag_free_count = LEAK_TAG_POOL_SIZE; @@ -1371,9 +1417,8 @@ class alignas(alignof(SpinLock)) LivenessTracker { removeKlassPopulationEntryLocked(syn_slot); } } - // Find-or-create the entry under real_id (previously a silent no-op - // when absent - but with aliasing, set-representative-first is the - // load-bearing order: scenarios must establish the alias BEFORE any + // Find-or-create the entry under real_id: with aliasing, + // set-representative-first is the load-bearing order: scenarios must establish the alias BEFORE any // seeding, and with no entry yet there is nothing to store the // representative into. Creating via a count-0, epoch-0 ring sample // reuses recordKlassPopulationSampleLocked()'s own creation branch @@ -1457,8 +1502,18 @@ class alignas(alignof(SpinLock)) LivenessTracker { } // Lets a test that seeds _klass_population directly (no cleanup_table() // sweep) mark it as belonging to the given class-map generation. + // Adopting a new generation here bypasses cleanup_table()'s class-map-reset + // branch, so it must do that branch's tracking-table half itself: every + // cached_klass_id was resolved against the old StringDictionary namespace + // and could alias an unrelated class in the new one. (_klass_population is + // the caller's job - see klassPopulationResetForTest().) void classMapGenerationSetForTest(u64 generation) { _table_lock.lock(); + if (generation != _last_class_map_generation && _table != NULL) { + for (u32 i = 0; i < (u32)_table_size; i++) { + _table[i].cached_klass_id = 0; + } + } _last_class_map_generation = generation; _table_lock.unlock(); } @@ -1516,6 +1571,34 @@ class alignas(alignof(SpinLock)) LivenessTracker { recordHeapFloorSampleUnchecked(used, timestamp_ns, container_used); } bool heapFloorRisingForTest() const { return heapFloorRising(); } + // Ring-only companion to the heapFloorRecordForTest() seam: clears + // _heap_floor_ring/_heap_floor_time_ring/_container_mem_ring via their + // shared head/fill - all three become empty once fill is 0 (see + // ringWindowStats()'s min-fill gate in heapFloorRising()). Seeding via + // heapFloorRecordForTest() leaves "used" values behind that the next + // test in the same JVM would otherwise inherit into its secondsToOOM() + // projection. storeRelease() matches recordHeapFloorSampleUnchecked()'s + // own publication of these indices. The two index stores are not atomic + // as a pair - a concurrent reader could observe a torn old-fill/new-head + // window, and no store ordering closes that (seeing the first store's new + // value while the second is still old is possible either way) - so the + // documented precondition, heap-floor recording disabled, is what + // excludes every reader (onGC() skips recordHeapFloorSample() and no + // selectLeakCandidates() scan runs mid-test). Enforce it in DEBUG builds + // (where the flag exists): a caller that resets while recording is + // enabled fails the assert instead of racing a live reader. Non-DEBUG + // builds compile the reset bare - the flag does not exist there and the + // JNI entry that reaches this seam is compiled out. + void heapFloorResetForTest() { +#ifdef DEBUG + if (!_heap_floor_recording_disabled_for_test.load(std::memory_order_acquire)) { + assert(!"heapFloorResetForTest() called while heap-floor recording is enabled"); + return; + } +#endif + storeRelease(_heap_floor_ring_head, (u8)0); + storeRelease(_heap_floor_ring_fill, (u8)0); + } // Bypasses initialize_table()'s JNI-dependent HeapUsage::getMaxHeap() call // (out of gtest's reach, same reason setGcGenerationsForTest() exists) so // secondsToOOM() can be exercised directly against a fake max heap size. diff --git a/ddprof-lib/src/main/cpp/referenceChainEvents.cpp b/ddprof-lib/src/main/cpp/referenceChainEvents.cpp index de60c15403..7290ce94a0 100644 --- a/ddprof-lib/src/main/cpp/referenceChainEvents.cpp +++ b/ddprof-lib/src/main/cpp/referenceChainEvents.cpp @@ -25,8 +25,6 @@ #include #include -// Target-selection bridging step - LivenessTracker's leak-candidate ranking feeds - void ReferenceChainTracker::requeueChainRootForRotation(jlong tag) { if (_frontier == nullptr || tag <= 0) { return; @@ -105,6 +103,9 @@ bool ReferenceChainTracker::buildChainEvent(jvmtiEnv *jvmti, JNIEnv *jni, (long long)target_tag, chain.size(), chain.empty() ? 0u : chain[0], entry.depth, (unsigned)root_kind, (long long)entry.leak_tag); out->_target_tag = entry.leak_tag != 0 ? (u64)entry.leak_tag : (u64)target_tag; + // Ownership period of the leak tag at binding time (0 without a leak tag); see + // chainTargetsCurrentLeakOwner(). + out->_leak_tag_incarnation = entry.leak_tag_incarnation; out->_depth = entry.depth; out->_root_kind = root_kind; out->_hops.resize(chain.size()); @@ -446,10 +447,21 @@ void ReferenceChainTracker::pollWatchedTargets(jvmtiEnv *jvmti, JNIEnv *jni) { // Inserts or refreshes klass_id's resolved chain - see _resolved_chains' comment // (referenceChains.h) for why a resolved chain is cached and re-emitted rather than emitted once. +// Returns false when the chain was not stored: the cache is full, or the chain targets a leak tag +// whose owner already died. bool ReferenceChainTracker::cacheResolvedChain(jlong source_tag, ReferenceChainEvent &&event, jlong source_tag_val, u64 source_search_ns) { + if (isLeakTag((jlong)event._target_tag) && !chainTargetsCurrentLeakOwner(event)) { + // Built from a frontier binding whose leak-tag owner already died. Ownership stamps only + // advance, so it can never become valid again; caching it would only take a slot until the + // next dump evicts it. A release racing past this check is evicted by that dump. + TEST_LOG("ReferenceChainTracker::cacheResolvedChain rejected dead-owner chain " + "source_tag=%lld target_tag=%lld", + (long long)source_tag, (long long)event._target_tag); + return false; + } { ExclusiveLockGuard guard(&_resolved_chains_lock); auto it = _resolved_chains.find(source_tag); @@ -499,6 +511,52 @@ void ReferenceChainTracker::invalidateResolvedChain(jlong source_tag) { } } +jlong ReferenceChainTracker::resolvedChainTargetTagForTest(jlong object_tag) { + if (object_tag <= 0) { + return 0; + } + // _search_start_ns is only rewritten by runPass()/restartSearch() under _engine_lock (BFS thread) + // or after stopThread() - NOT under _resolved_chains_lock - so hold _engine_lock too, then sample + // the generation only once both locks are held: a concurrent restart/new pass cannot change it + // between the load and the source_search_ns comparison below. Lock order _engine_lock -> + // _resolved_chains_lock matches finishLoopIterationSerialized() -> pollWatchedTargets(). + MutexLocker engine_guard(_engine_lock); + ExclusiveLockGuard guard(&_resolved_chains_lock); + if (isLeakTag(object_tag)) { + // Leak-tag fallback. An object carries its leak tag exactly when no live frontier entry + // exists for it - before the current search's first interception, or after a restart's + // releaseSearchTags() cleared the frontier tags and the next GC re-tagged it - which is + // precisely when the frontier-tag lookup below must miss. A leak tag is NOT an object + // identity on its own: LivenessTracker releases it when its object dies and recycles it for + // another object, while _resolved_chains keeps the dead object's chain (restartSearch() keeps + // the cache too). Identity is the pair (leak tag, ownership-period stamp): buildChainEvent() + // copies the frontier entry's leak tag and the stamp captured when that tag was bound, and + // chainTargetsCurrentLeakOwner() accepts the chain only if the stamp is still the tag's + // current one. Within one ownership period no generation check applies, so a chain any + // earlier search resolved for this object stays reachable through this scan even after the + // frontier tags were wiped; a chain cached for an earlier owner of the tag never matches. The + // caller holds a strong reference to the target, so its own tag cannot be released (and its + // stamp cannot change) during this call. + for (const auto &cached : _resolved_chains) { + if ((jlong)cached.second.event._target_tag == object_tag && + chainTargetsCurrentLeakOwner(cached.second.event)) { + return object_tag; + } + } + return 0; + } + const u64 current_search_ns = load(_search_start_ns); + if (current_search_ns == 0) { + return 0; + } + auto it = _resolved_chains.find(object_tag); + if (it == _resolved_chains.end() || + it->second.source_search_ns != current_search_ns) { + return 0; + } + return (jlong)it->second.event._target_tag; +} + int ReferenceChainTracker::snapshotResolvedChainTags(jlong *out) { if (out == nullptr) { return 0; @@ -737,7 +795,10 @@ bool ReferenceChainTracker::correlateAdmittedLeakTag(jlong frontier_tag, // re-admission. return true; } - _frontier->setLeakTag(frontier_tag, leak_tag); + // The caller's row owns leak_tag now (tagLeakInstances() holds the shared _table_lock, which + // excludes cleanup_table()'s release), so this is the owner's ownership-period stamp. + _frontier->setLeakTag(frontier_tag, leak_tag, + LivenessTracker::instance()->leakTagIncarnation(leak_tag)); TEST_LOG_SUMMARY("ReferenceChainTracker::correlateAdmittedLeakTag " "frontier_tag=%lld leak_tag=%lld depth=%u parent_tag=%lld", (long long)frontier_tag, (long long)leak_tag, entry.depth, @@ -747,6 +808,12 @@ bool ReferenceChainTracker::correlateAdmittedLeakTag(jlong frontier_tag, return true; } +bool ReferenceChainTracker::chainTargetsCurrentLeakOwner(const ReferenceChainEvent &e) { + jlong target = (jlong)e._target_tag; + return isLeakTag(target) && + e._leak_tag_incarnation == LivenessTracker::instance()->leakTagIncarnation(target); +} + void ReferenceChainTracker::drainPendingChainEvents( std::vector *out) { if (out == nullptr) { @@ -760,9 +827,22 @@ void ReferenceChainTracker::drainPendingChainEvents( u64 now = TSC::ticks(); { ExclusiveLockGuard guard(&_resolved_chains_lock); - for (const auto &kv : _resolved_chains) { - out->push_back(kv.second.event); + for (auto it = _resolved_chains.begin(); it != _resolved_chains.end();) { + const ReferenceChainEvent &event = it->second.event; + if (isLeakTag((jlong)event._target_tag) && !chainTargetsCurrentLeakOwner(event)) { + // The leak-tagged target died (its tag was released, and possibly re-acquired by another + // object), so the chain no longer describes the object (targetTag, leakTagIncarnation) + // names. Ownership stamps only advance, so the mismatch is permanent: erase the chain now + // instead of letting it occupy a MAX_RESOLVED_CHAINS slot for the rest of the search. + TEST_LOG("ReferenceChainTracker::drainPendingChainEvents evicted dead-owner chain " + "source_tag=%lld target_tag=%lld", + (long long)it->first, (long long)event._target_tag); + it = _resolved_chains.erase(it); + continue; + } + out->push_back(event); out->back()._start_time = now; + ++it; } } TEST_LOG_SUMMARY("ReferenceChainTracker::drainPendingChainEvents re-emitted=%d", diff --git a/ddprof-lib/src/main/cpp/referenceChainFrontier.cpp b/ddprof-lib/src/main/cpp/referenceChainFrontier.cpp index d56044658c..aaaebd5f28 100644 --- a/ddprof-lib/src/main/cpp/referenceChainFrontier.cpp +++ b/ddprof-lib/src/main/cpp/referenceChainFrontier.cpp @@ -80,8 +80,7 @@ bool FrontierTable::growLocked(int required_cap) { "ReferenceChains: frontier table resize to %d entries failed", newcap); return false; } - // realloc() does not zero the newly grown region - clear it so lookup() never returns garbage - // state for a slot that hasn't been inserted yet. + // Zero the grown region so lookup() never returns garbage for a not-yet-inserted slot. memset(tmp + _table_cap, 0, sizeof(FrontierEntry) * (newcap - _table_cap)); Counters::increment(REFERENCE_CHAIN_FRONTIER_TABLE_BYTES, (jlong)(newcap - _table_cap) * sizeof(FrontierEntry)); @@ -115,6 +114,7 @@ bool FrontierTable::insert(jlong tag, jlong parent_tag, u32 referrer_klass, _table[idx].root_kind = root_kind; _table[idx].class_tag = class_tag; _table[idx].leak_tag = 0; + _table[idx].leak_tag_incarnation = 0; _table[idx].referrer_field_index = referrer_field_index; _table[idx].edge_kind = edge_kind; _table[idx].referrer_class_tag = referrer_class_tag; @@ -130,8 +130,6 @@ bool FrontierTable::insert(jlong tag, jlong parent_tag, u32 referrer_klass, while (sz < idx + 1 && !_table_size.compare_exchange_weak(sz, idx + 1, std::memory_order_relaxed)) { - // sz reloaded with the current value by compare_exchange_weak on failure; retry until - // either this thread wins or another thread already advanced _table_size past idx + 1. } return true; } @@ -224,8 +222,6 @@ bool FrontierTable::improveChain(jlong tag, jlong parent_tag, u32 referrer_klass, u32 depth, u8 root_kind, jint referrer_field_index, u8 edge_kind, jlong referrer_class_tag) { - // Replace a shallow root-attached entry (parent_tag == 0, depth == 0) with a deeper - // chain-attached entry when the object is reached via a longer path. if (tag <= 0 || tag - 1 >= (jlong)INT_MAX) { return false; } @@ -289,8 +285,8 @@ bool FrontierTable::reparentToDurableRoot(jlong tag, jlong new_parent_tag, u32 referrer_klass, jint referrer_field_index, u8 edge_kind) { - // See the declaration's own comment (referenceChains.h) for why this exists as a sibling of - // improveChain(): equal-depth depth-1 noise->real re-parenting. + // See the declaration's comment (referenceChainFrontier.h) for why this exists as a sibling of + // improveChain(): equal-depth depth-1 transient->durable re-parenting. if (tag <= 0 || tag - 1 >= (jlong)INT_MAX || new_parent_tag <= 0 || new_parent_tag - 1 >= (jlong)INT_MAX || new_parent_tag == tag) { return false; @@ -339,7 +335,7 @@ bool FrontierTable::reconstructChain(jlong target_tag, u8 root_kind = 0; int hops = 0; // Bounded by maxCapacity(): every tag maps to a distinct slot (this table's "tags/slots are never - // reused" invariant, see the class comment above), so a well-formed parent_tag chain can visit at + // reused" invariant, see _table_size's comment), so a well-formed parent_tag chain can visit at // most maxCapacity() slots before either reaching parent_tag == 0 or repeating a slot. const int hop_bound = maxCapacity(); for (; hops <= hop_bound && tag != 0; hops++) { diff --git a/ddprof-lib/src/main/cpp/referenceChainFrontier.h b/ddprof-lib/src/main/cpp/referenceChainFrontier.h index 093ed11ffc..666a71cb18 100644 --- a/ddprof-lib/src/main/cpp/referenceChainFrontier.h +++ b/ddprof-lib/src/main/cpp/referenceChainFrontier.h @@ -21,7 +21,9 @@ namespace FrontierEntryState { constexpr u8 FRONTIER = 0; // discovered, not yet expanded by FollowReferences constexpr u8 EXPANDED = 1; // expanded; children (if any) are in the table -constexpr u8 EDGE = 2; // on a path toward a target sample (EdgeStore) +// EDGE is retired: never set by the production walk (see reconstructChain()'s comment in +// referenceChainFrontier.cpp); only markEdge() and unit/fuzz fixtures set it. +constexpr u8 EDGE = 2; constexpr u8 ABANDONED = 3; // tag released; entry kept only to avoid reuse } // namespace FrontierEntryState @@ -53,6 +55,12 @@ typedef struct FrontierEntry { u32 referrer_klass; // StringDictionary id, 0 = unresolved/none u32 depth; // hop count from the frontier's seed, for the hop cap u8 state; // one of FrontierEntryState's constants + // LivenessTracker::leakTagIncarnation(leak_tag) captured when leak_tag was bound to this entry + // (setLeakTag()): the ownership period of the tag this entry's object held. A leak tag is + // recycled after its object dies, so (leak_tag, leak_tag_incarnation) - not leak_tag alone - + // identifies the object. 0 while leak_tag == 0. Sits in the padding after `state`, so + // sizeof(FrontierEntry) is unchanged (static_assert below). + u32 leak_tag_incarnation; // The leak tag assigned by LivenessTracker to this specific tracked object, copied from the JVMTI // tag at admission time. jlong leak_tag; @@ -86,6 +94,9 @@ typedef struct FrontierEntry { jlong referrer_class_tag; } FrontierEntry; +static_assert(sizeof(FrontierEntry) == 64, + "FrontierEntry::leak_tag_incarnation must stay in the padding after `state`"); + // Per-hop retention-edge identity collected by reconstructChain() alongside the class chain: // everything needed at emission to label HOW chain[i] is retained (the field of chain[i+1] pointing // at it, or the root edge for the last hop). @@ -177,13 +188,14 @@ class alignas(alignof(SpinLock)) FrontierTable { } // Same as lookup() above, but assumes the caller already holds the shared lock via - // withSharedLock() below. + // withSharedLock(). bool lookupLocked(jlong tag, FrontierEntry *out) const; // Marks metadata abandoned; the caller must clear the JVMTI tag. void clear(jlong tag); - // Marks the slot as part of a resolved path. + // Marks the slot EDGE. Retired: no production caller (only the fuzz harness's shadow model and + // unit-test fixtures use EDGE). void markEdge(jlong tag); // Marks the slot after all outgoing edges have been visited. @@ -193,8 +205,15 @@ class alignas(alignof(SpinLock)) FrontierTable { void updateRootKind(jlong tag, u8 root_kind); // Set the leak tag on a frontier entry (the JVMTI tag assigned by LivenessTracker to this - // specific tracked leaking object). - void setLeakTag(jlong tag, jlong leak_tag) { + // specific tracked leaking object) together with that tag's ownership-period stamp + // (LivenessTracker::leakTagIncarnation(leak_tag)) read at the binding site. The stamp is required + // (no default): a forgotten 0 would make drainPendingChainEvents() drop the live object's chain. + // The stamp read at each binding site is the current owner's: heapReferenceCallback() and + // heapRootCallback() intercept the object live inside FollowReferences, so cleanup_table() + // cannot release its tag concurrently; correlateAdmittedLeakTag() runs under + // LivenessTracker::tagLeakInstances()' shared _table_lock, which excludes cleanup_table()'s + // release. + void setLeakTag(jlong tag, jlong leak_tag, u32 leak_tag_incarnation) { if (tag <= 0 || tag - 1 >= (jlong)INT_MAX) { return; } @@ -202,6 +221,7 @@ class alignas(alignof(SpinLock)) FrontierTable { ExclusiveLockGuard guard(&_table_lock); if (idx < _table_size) { _table[idx].leak_tag = leak_tag; + _table[idx].leak_tag_incarnation = leak_tag_incarnation; } } @@ -226,7 +246,7 @@ class alignas(alignof(SpinLock)) FrontierTable { std::vector *out_edges = nullptr, FrontierEntry *out_terminal = nullptr); - // Search restart (ReferenceChainTracker::restartSearch(), this class's own header comment): marks + // Search restart (ReferenceChainTracker::restartSearch()): marks // every slot unoccupied again without releasing _table's allocation - a new search's nextTag() // sequence restarts at 1, reusing these same slot indices, so lookup()/insert() must not read // back the previous search's now-irrelevant entries for them. diff --git a/ddprof-lib/src/main/cpp/referenceChainTraversal.cpp b/ddprof-lib/src/main/cpp/referenceChainTraversal.cpp index 8d6e87d9d9..ceb9fb1292 100644 --- a/ddprof-lib/src/main/cpp/referenceChainTraversal.cpp +++ b/ddprof-lib/src/main/cpp/referenceChainTraversal.cpp @@ -91,7 +91,9 @@ jvmtiIterationControl JNICALL ReferenceChainTracker::heapRootCallback( if (ctx->frontier->insert(frontier_tag, /*parent_tag=*/0, referrer_klass, /*depth=*/0, FrontierEntryState::FRONTIER, translated_root_kind, class_tag)) { - ctx->frontier->setLeakTag(frontier_tag, leak_tag); + // Capture the current ownership period, as in heapReferenceCallback()'s interception. + ctx->frontier->setLeakTag(frontier_tag, leak_tag, + LivenessTracker::instance()->leakTagIncarnation(leak_tag)); *tag_ptr = frontier_tag; ctx->edges_admitted++; ctx->tracker->trackLeakAccumulation(ctx->frontier, class_tag, 0, @@ -380,7 +382,7 @@ void ReferenceChainTracker::runPassManualWalk(jvmtiEnv *jvmti, JNIEnv *jni, ? OS::nanotime() + (u64)_effective_pause_target_ms * 1000000ULL : 0; bool rotation_frontier_cap_hit = false; - // Prong 2 static-anchor descend walks run FIRST inside rotation's slice: they are the + // Static-anchor descend walks run FIRST inside rotation's slice: they are the // highest-value rotation work (bounded, targeted, and the only rotation tier that can reach a // collection-shaped static holder's internals in one pass), and their edges draw down the same // rotation budget the queue-tier batch below uses - a pass whose anchor walks admit the holder's @@ -398,7 +400,7 @@ void ReferenceChainTracker::runPassManualWalk(jvmtiEnv *jvmti, JNIEnv *jni, rotation_edges_admitted += static_anchor_edges_admitted; rotation_budget -= static_anchor_edges_admitted; *truncated = *truncated || static_anchor_truncated; - // B' requeue: resolved-but-unwalked anchors that came from this pass's FIFO drain go back to + // Requeue: resolved-but-unwalked anchors that came from this pass's FIFO drain go back to // the FIFO front, order-preserving, so a pass whose budget died mid-batch walks them first next // pass instead of waiting for the next sweep lap's re-push. if (!static_anchor_unwalked.empty() && @@ -1235,8 +1237,6 @@ bool ReferenceChainTracker::runPass(jvmtiEnv *jvmti, JNIEnv *jni, return err == JVMTI_ERROR_NONE; } -// Pause-time-SLO feedback loop (see this method's declaration in - void ReferenceChainTracker::updatePacing(u64 pass_wall_ns) { // Truncating to whole milliseconds matches every other PidController usage in this codebase // (ObjectSampler/MallocTracer/NativeSocketSampler all feed it integer counts, pidController.h's diff --git a/ddprof-lib/src/main/cpp/referenceChainWalk.cpp b/ddprof-lib/src/main/cpp/referenceChainWalk.cpp index 320e9048b9..5acd946065 100644 --- a/ddprof-lib/src/main/cpp/referenceChainWalk.cpp +++ b/ddprof-lib/src/main/cpp/referenceChainWalk.cpp @@ -296,7 +296,10 @@ jint JNICALL ReferenceChainTracker::heapReferenceCallback( (u8)reference_kind, parent_tag == 0 ? edge_referrer_class_tag : 0)) { // Store the leak tag in the frontier entry - ctx->frontier->setLeakTag(frontier_tag, leak_tag); + // Capture the tag's current ownership period with it (one lock-free load): this live object + // owns leak_tag now, so a chain built from this entry describes it and not an earlier owner. + ctx->frontier->setLeakTag(frontier_tag, leak_tag, + LivenessTracker::instance()->leakTagIncarnation(leak_tag)); *tag_ptr = frontier_tag; ctx->edges_admitted++; // Queue for expandFrontier() like admitObject() does: FollowReferences usually descends into @@ -461,7 +464,7 @@ jint JNICALL ReferenceChainTracker::heapReferenceCallback( *tag_ptr, ctx->resolved_chain_tags, ctx->resolved_chain_tag_count); if (was_root_attached_durable) { - // Demotion push (B'): the replaced entry's static/JNI-global attribution was its only + // Demotion push: the replaced entry's static/JNI-global attribution was its only // anchor-tier eligibility, and it is gone now. ctx->tracker->pushAtRiskStaticAnchor( *tag_ptr, ctx->tracker->classTags()->resolve(class_tag)); diff --git a/ddprof-lib/src/main/cpp/referenceChains.cpp b/ddprof-lib/src/main/cpp/referenceChains.cpp index c755b119e1..211686301e 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.cpp +++ b/ddprof-lib/src/main/cpp/referenceChains.cpp @@ -1063,6 +1063,24 @@ long ReferenceChainTracker::pendingExpandPositionForTest(jlong tag) const { return -1; } +int ReferenceChainTracker::frontierStateForTest(jlong object_tag) { + if (object_tag <= 0 || isLeakTag(object_tag)) { + return -1; + } + // Same lock as the pendingExpand*ForTest() seams above: the BFS thread mutates the frontier + // (insert/expand/abandon in runPass(), resetForRestart() via shouldRunPass()'s restart branch) + // only under _engine_lock, so this snapshot never observes a pass or restart mid-flight. + MutexLocker engine_guard(_engine_lock); + if (_frontier == nullptr) { + return -1; + } + FrontierEntry entry{}; + if (!_frontier->lookup(object_tag, &entry)) { + return -1; + } + return (int)entry.state; +} + size_t ReferenceChainTracker::pendingExpandSizeForTest() const { MutexLocker engine_guard(_engine_lock); return _pending_expand.size() + _priority_expand.size(); diff --git a/ddprof-lib/src/main/cpp/referenceChains.h b/ddprof-lib/src/main/cpp/referenceChains.h index 6e1da0dac8..bfde3616ab 100644 --- a/ddprof-lib/src/main/cpp/referenceChains.h +++ b/ddprof-lib/src/main/cpp/referenceChains.h @@ -44,9 +44,8 @@ class ReferenceChainTracker { volatile bool _enabled; // Frontier metadata table. Constructed lazily on the first start() with the flag enabled, sized - // from args._reference_chains_frontier_cap; like LivenessTracker's table (LivenessTracker's own - // table does the same) it survives stop() so it persists across multiple start/stop recording - // cycles. + // from args._reference_chains_frontier_cap; like LivenessTracker's table it survives stop() so it + // persists across multiple start/stop recording cycles. FrontierTable *_frontier; // args._reference_chains_frontier_cap as of the most recent start() call - recorded @@ -215,7 +214,7 @@ class ReferenceChainTracker { // klass_ids from LivenessTracker::topKlassesByGenerationCount() (a faster, un-hysteresis-gated // ranking than the canary candidate set above - see that method's own comment), refreshed once // per BFS-thread tick but only once hasLeakSignal() has already fired via the slower, - // hysteresis-gated selectLeakCandidates() path (per design discussion: this whole mechanism only + // hysteresis-gated selectLeakCandidates() path (this whole mechanism only // cranks once the trend detector has already triggered, so it never needs to wait out that same // hysteresis a second time on its own). u32 _watched_leak_klass_ids[MAX_WATCHED_LEAK_KLASSES]; @@ -336,8 +335,7 @@ class ReferenceChainTracker { // block there and _configured_budget's own comment). bool _urgency_budget_boosted; - // Hysteresis state behind isUrgent(), which used to be a bare `secondsToOOM() < - // OOM_URGENT_THRESHOLD_S` comparison. + // Hysteresis state behind isUrgent(). mutable bool _urgent_latched; mutable int _urgent_release_ticks; mutable bool _urgent_search_spent; @@ -414,7 +412,6 @@ class ReferenceChainTracker { return false; } i = (i + 1) & SLOT_MASK; - // Wrapped around: table full. if (i == start) { return false; } @@ -437,8 +434,7 @@ class ReferenceChainTracker { } } _priority_expand_set; - // B' (find-anchor-holder-eviction / find-anchor-live-feed-design): a static holder richly - // referenced from the running graph is EXCLUDED from the static-anchor tier forever once its + // A static holder richly referenced from the running graph is EXCLUDED from the static-anchor tier forever once its // frontier entry is chain-attached (first admitted via a non-root path, or demoted by // improveChain) - maybeUpgradeRootAttachedRootKind() refuses entries with parent_tag != 0 by // design, so collectStaticFieldAnchorsForRotation() (parent_tag == 0 filter) can never select it. @@ -550,15 +546,10 @@ class ReferenceChainTracker { // GetObjectsWithTags call for the batch + a depth-bounded interface walk per class). static constexpr int ANCHOR_SHAPE_RECONCILE_BUDGET = 128; - // Per-pass cap on how many AT-RISK static holders (frontier entries with parent_tag != 0 - the - // find-anchor-holder-eviction population) drainStaticAnchorFifo() pops for the same - // walkStaticFieldAnchors() batch. + // Per-pass cap on how many AT-RISK static holders (frontier entries with parent_tag != 0) + // drainStaticAnchorFifo() pops for the same walkStaticFieldAnchors() batch. static constexpr int STATIC_ANCHOR_FIFO_DRAIN = 16; - // (Removed: the old single wrapping-index-cursor selection was replaced by tiered selection with - // per-tier cursors — see collectStaticFieldAnchorsForRotation()'s own comment and - // _anchor_container_cursor/_anchor_other_cursor.) - // Cursor over the flattened (slot, tid) enumeration of _candidate_qualifying_tids above, so // walkCandidateThreadLocals()'s THREAD_WALK_MAX_ANCHORS-per-pass cap rotates fairly instead of // always walking the same first candidates' tids. @@ -655,8 +646,6 @@ class ReferenceChainTracker { static constexpr long URGENT_PAUSE_TARGET_MS = 100; // ceiling STW ms per pass static constexpr u64 URGENT_CADENCE_NS = 10000000ULL; // 10ms floor between passes - // True when LivenessTracker::secondsToOOM() projects exhaustion sooner - // Auto-scaled default for _first_pass_budget when Arguments::_reference_chains_first_pass_budget // is unset (0) - see _first_pass_budget's own comment for why plain _budget is the wrong // fallback. @@ -677,7 +666,7 @@ class ReferenceChainTracker { static constexpr u64 MAX_EFFECTIVE_CADENCE_NS = PASS_CADENCE_NS * 4; // 4s // Conversion factor from "edges of budget signal updatePacing()'s clamp could not absorb" to a - // cadence adjustment in nanoseconds - the two are different units (edge count vs. + // cadence adjustment in nanoseconds. static constexpr u64 CADENCE_NS_PER_EDGE_OVERFLOW = 1000000ULL; // 1ms/edge // Budget-borrowing (see _borrowed_budget's own comment): how many consecutive @@ -704,12 +693,9 @@ class ReferenceChainTracker { // Agent-owned BFS thread; startThread()/stopThread() own its lifecycle. pthread_t _thread; - // std::atomic rather than plain volatile bool - volatile alone gives no C++ memory-model - // acquire/release guarantees (it only prevents the compiler from eliding/reordering that one - // variable's own accesses), so a weakly-ordered CPU (e.g. arm64) could let the BFS thread's - // stopThread()- side write (see stopThread()'s own comment) become visible to threadLoop() later - // than intended, missing the shutdown request on one wakeup and sleeping/looping an extra cycle - // before pthread_join() unblocks it. + // Atomic, not volatile: on a weakly-ordered CPU (e.g. arm64) a volatile stopThread() write could + // become visible to threadLoop() late, missing the shutdown request on one wakeup and looping an + // extra cycle before pthread_join() unblocks it. std::atomic _running; // Cooperative-cancellation flag for an in-flight JVMTI FollowReferences walk: stopThread() sets @@ -788,7 +774,7 @@ class ReferenceChainTracker { // see _urgent_latched for the latch/release rules and why the raw comparison flaps. bool isUrgent() const; - // Search restart gate (this class's own header comment): true once _safepoint_pain_budget has + // Search restart gate: true once _safepoint_pain_budget has // drained back to zero (canStartNow()) *and* hasLeakSignal() above reports at least one leak // candidate. bool canAffordNewSearch(u64 now_ns); @@ -896,9 +882,6 @@ class ReferenceChainTracker { const jlong *resolved_chain_tags, int resolved_chain_tag_count); - // Correlate a leak tag with an instance the BFS admitted BEFORE tagLeakInstances() tagged it (its - // JVMTI tag is a frontier tag, its frontier entry has leak_tag == 0). - // One-time retroactive catch-up for a klass_id the moment it FIRST enters _watched_leak_klass_ids // (pollWatchedTargets() calls this only for the newly-added ids in each refresh, never for ones // already being watched). @@ -942,8 +925,8 @@ class ReferenceChainTracker { int *edges_admitted, bool *truncated, bool *frontier_cap_hit, u64 *safepoint_ns); - // Prong 1 of the candidate-scoped reach design (thread-retained taxonomy: ThreadLocal-held caches - // and thread-owned collections): per pass, walk up to THREAD_WALK_MAX_ANCHORS of the current + // Candidate-scoped reach for thread-retained leaks (ThreadLocal-held caches and thread-owned + // collections): per pass, walk up to THREAD_WALK_MAX_ANCHORS of the current // candidates' qualifying tids' live Thread objects (registerThreadObject()'s map above) with // descendFromAnchor() (anchor-gated to ThreadLocalMap, see above). void walkCandidateThreadLocals(jvmtiEnv *jvmti, JNIEnv *jni, int budget, @@ -1015,7 +998,9 @@ class ReferenceChainTracker { // Inserts (or refreshes) klass_id's resolved chain in _resolved_chains, recording the // source_tag/source_search_ns it was reconstructed from so a later poll can tell a stale entry - // from a current one. + // from a current one. Returns false when nothing was stored: the cache is full (counted in + // REFERENCE_CHAIN_EVENTS_DROPPED), or the chain targets a leak tag whose owner already died + // (fails chainTargetsCurrentLeakOwner(); not counted - it is a correctness reject). bool cacheResolvedChain(jlong source_tag, ReferenceChainEvent &&event, jlong source_tag_val, u64 source_search_ns); @@ -1044,6 +1029,15 @@ class ReferenceChainTracker { // comment for why this cannot be deferred to dump()-time). void enqueuePendingAbandonedEvent(); + // True iff `e` targets a leak tag AND was built during that tag's current ownership period: + // e._leak_tag_incarnation == LivenessTracker::leakTagIncarnation(e._target_tag). Leak tags are + // recycled once their object dies, so tag equality alone would attribute a dead object's chain to + // the tag's next owner; the (tag, stamp) pair is the identity, in memory and in the serialized + // ReferenceChain/HeapLiveObject leakTagIncarnation fields. False for a non-leak (frontier-tag) + // target - each caller decides what that means for it. Lock-free (one acquire load); never takes + // LivenessTracker's pool lock, so it is safe under _resolved_chains_lock. + static bool chainTargetsCurrentLeakOwner(const ReferenceChainEvent &e); + public: static ReferenceChainTracker *instance() { static ReferenceChainTracker instance; @@ -1119,8 +1113,6 @@ class ReferenceChainTracker { return tag >= LEAK_TAG_BASE && tag < LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE; } - // Max candidates LivenessTracker::selectLeakCandidates() can return. - // Test accessor for _passes_since_last_progress. int passesSinceLastProgressForTest() const { return _passes_since_last_progress; } // Canary-lane backoff state - see _canary_backoff_mult's own comment. @@ -1151,9 +1143,8 @@ class ReferenceChainTracker { void setCandidateFoundBitsForTest(u64 bits) { _candidate_found_bits = bits; } void setSearchStartedForTest(bool v) { _search_started = v; } // OOM-urgency hysteresis state (isUrgent()'s latch/release machine, - // referenceChains.cpp) - the whole point of C5's chaos assertions is this - // state's reaction to a projected-exhaustion sequence, so tests need both - // the drive (isUrgentForTest()) and the raw latch/tick/entitlement reads. + // referenceChains.cpp): tests drive it via isUrgentForTest() and assert on + // the raw latch/tick/entitlement reads. bool isUrgentForTest() const { return isUrgent(); } bool urgentLatchedForTest() const { return _urgent_latched; } int urgentReleaseTicksForTest() const { return _urgent_release_ticks; } @@ -1204,9 +1195,11 @@ class ReferenceChainTracker { // Held by runPassSerialized() and finishLoopIterationSerialized() around the engine work, by // threadLoop() around shouldRunPass() (whose restart branch mutates _pending_expand/ - // _priority_expand the same way a pass does), and by the pendingExpand*ForTest() seams below so - // they never observe those deques mid-mutation from the BFS thread. Mutable because the test - // seams are const (read-only from the caller's perspective) but still need to take the lock. + // _priority_expand the same way a pass does), and by the pendingExpand*ForTest()/ + // frontierStateForTest()/resolvedChainTargetTagForTest() test seams below so they never observe + // engine state mid-mutation from the BFS thread. Mutable because the test seams are const + // (read-only from the caller's perspective) but still need to take the lock. Recursive + // (mutex.cpp); never acquire it while holding _resolved_chains_lock. mutable Mutex _engine_lock; jlong tagObject(jvmtiEnv *jvmti, jobject obj); jlong getTag(jvmtiEnv *jvmti, jobject obj); @@ -1302,8 +1295,7 @@ class ReferenceChainTracker { // Reports a search's termination state without needing a target tag. bool buildAbandonedEvent(ReferenceChainAbandonedEvent *out) { - // Acquire-load, not a plain relaxed load - see searchState()'s own comment for why: this is the - // same guard-then-read-details pattern. + // Acquire-load: the detail fields below are read only after observing ABANDONED. if (out == nullptr || loadAcquire(_search_state) != SearchState::ABANDONED) { return false; } @@ -1329,7 +1321,11 @@ class ReferenceChainTracker { // Appends a copy of every currently-cached resolved chain to *out, re-stamped with a fresh // _start_time so it lands in the dumping chunk's time window, WITHOUT clearing the cache - a // repeatable snapshot, not a drain, so the same live sample's chain is re-emitted into every JFR - // chunk it survives into (see _resolved_chains' own comment). + // chunk it survives into (see _resolved_chains' own comment). Exception: a chain whose target is + // a leak tag but which fails chainTargetsCurrentLeakOwner() (its object died and the tag may have + // a new owner) is not emitted - it no longer describes the object that + // HeapLiveObject(leakTag, leakTagIncarnation) names. Such a chain is erased from the cache: + // ownership stamps only advance, so it can never become valid again. void drainPendingChainEvents(std::vector *out); // Appends every abandoned-search event queued since the last call and clears the queue - a true @@ -1368,6 +1364,32 @@ class ReferenceChainTracker { // for computing a position's fraction of the current backlog. size_t pendingExpandSizeForTest() const; + // Test seam - not part of the production API. The raw FrontierEntryState (canonical values: + // referenceChainFrontier.h) of the current search's frontier entry for the object carrying JVMTI + // tag `object_tag` - FRONTIER(0)/EXPANDED(1)/ABANDONED(3); EDGE(2) is retired and never produced + // by the production walk (FrontierTable::markEdge() has no production caller) - or -1 if there is + // no such entry: untagged, leak-tagged (not yet intercepted by the walk), or no frontier table / + // slot. Lets a test synchronize on an object's actual expansion rather than inferring it from + // pass counters. Serialized with the BFS thread via _engine_lock. + int frontierStateForTest(jlong object_tag); + + // Test seam - not part of the production API. Returns the targetTag a datadog.ReferenceChain + // event for the object currently carrying JVMTI tag `object_tag` would report. Two lookups: + // a frontier tag resolves only against the CURRENT search's cached chain (_resolved_chains entry + // keyed by that frontier tag with source_search_ns == _search_start_ns; restartSearch() keeps + // _resolved_chains and re-mints frontier tags from 1, so raw frontier-tag equality across + // generations could match a stale, unrelated chain); a leak tag resolves against any search + // generation's cached chain whose event target tag equals it AND whose captured ownership-period + // stamp equals the tag's current one (chainTargetsCurrentLeakOwner()). A leak tag is released + // when its object dies and then recycled for another object, so leak-tag identity is the pair + // (leak tag, ownership-period stamp), not the tag alone; within one ownership period it does + // identify the same object across search generations, and the object carries it exactly when + // no live frontier entry exists (never intercepted yet, or a restart cleared the frontier tags + // and the next GC re-tagged it). 0 otherwise: untagged, or no cached chain for that object's + // current ownership period in any generation. Samples _search_start_ns under _engine_lock and + // _resolved_chains_lock. + jlong resolvedChainTargetTagForTest(jlong object_tag); + // 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 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 15d7601f9f..ccd57efee0 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -463,8 +463,12 @@ public Map getDebugCounters() { private static native int getTid0(); /** - * Returns the calling thread's profiler tid. Tests seeding per-tid trends - * via {@link #seedTidTrendSample0} must call this on the leaking thread. + * Test seam: returns the calling thread's profiler tid + * (ProfiledThread::currentTid()), the tid space {@code seedTidTrendSample0} matches + * against. Unlike the tracker-mutating seams it only reads thread state, so it is + * compiled unconditionally and works in release builds too. Must be called ON the + * leaking thread, or the seeded tid will match no tracked instance and no leak tag + * will be assigned. */ static int getTid() { return getTid0(); @@ -539,107 +543,207 @@ private static native void setTraceContext0(long localRootSpanId, long spanId, l public static native boolean testTlsPrimingAvailable(); /** - * Test seam. The test seams below are implemented in debug native builds only; - * in release builds they are no-ops. + * Test seam (debug native builds only - a no-op returning {@code false}/{@code 0}/an + * empty array in release builds): lets LivenessTracker's leak-candidate detection be tested + * without depending on the probabilistic JVMTI heap sampler. *

- * Enables/disables per-klass population tracking without a live-JVM - * {@code initialize()}. Returns {@code true} on debug builds. + * Enables/disables LivenessTracker's per-klass population tracking directly, + * bypassing {@code initialize()}'s live-JVM requirement. Returns {@code true} on + * debug builds. */ static native boolean setGcGenerationsEnabled0(boolean enabled); /** - * Test seam: seeds one epoch of population history for {@code klassId}. + * Test seam (debug native builds only): seeds one epoch's worth of population + * history for {@code klassId} directly into LivenessTracker's ring buffer, + * bypassing real allocation sampling. Repeated calls (with distinct + * {@code epoch} values) build up a trend {@link #selectLeakCandidateKlassIds0()} + * can then rank. */ static native void seedKlassPopulationSample0(int klassId, int count, long epoch); /** - * Test seam: seeds one epoch of per-tid trend history for {@code klassId}. - * {@code tid} should come from {@link #getTid()}. + * Test seam (debug native builds only): seeds one epoch's worth of per-tid trend + * history for {@code klassId}, the (klass, tid) qualification + * selectLeakCandidates() requires on top of the klass-level ramp seeded by + * {@link #seedKlassPopulationSample0}. Repeated calls with the same rising shape + * (and the same {@code tid}) qualify {@code tid} as a leak-site thread. {@code tid} + * must come from {@link #getTid()} whenever the scenario relies on leak tagging of + * real tracked instances. */ static native void seedTidTrendSample0(int klassId, int tid, int count, long epoch); /** - * Test seam: makes {@code representative} the leak-candidate representative - * of {@code klassId}. + * Test seam (debug native builds only): wires {@code representative} in as {@code klassId}'s + * leak-candidate representative directly (a fresh weak global ref owned by LivenessTracker), + * bypassing the real allocation-sampling path. Combined with + * {@link #seedKlassPopulationSample0} and {@link #tagAsReferenceChainRoot0}, lets + * {@link #pollReferenceChainTargets0()}'s bridging step run without the real sampler or + * root-seeded walk. */ static native void setKlassPopulationRepresentativeForTest0(int klassId, Object representative); /** - * Test seam: clears the per-klass population table. + * Test seam (debug native builds only): clears LivenessTracker's per-klass population table, + * so a later test in the same JVM does not observe leak candidates seeded by an earlier one. */ static native void resetKlassPopulationForTest0(); /** - * Test seam: the klass ids currently selected as leak candidates (top 5). + * Test seam (debug native builds only): returns the klass ids LivenessTracker's + * real leak-candidate ranking (positive population slope, top 5) currently + * selects - the same call ReferenceChainTracker's restart gate and target-polling + * bridge use in production. Does not require a reference-chain search to be running. */ static native int[] selectLeakCandidateKlassIds0(); /** - * Test seam: tags {@code target} as a reference-chain frontier root. - * Returns the frontier tag, or {@code 0} on failure. + * Test seam (debug native builds only): tags {@code target} and inserts it + * directly as a reference-chain frontier root, bypassing ReferenceChainTracker's + * normal discovery path (a root-seeded FollowReferences walk) and + * LivenessTracker's leak-candidate selection entirely. Returns the assigned + * frontier tag (matching the {@code target_tag} a resulting + * {@code datadog.ReferenceChain} event reports), or {@code 0} on failure (reference + * chains disabled, or the frontier table is at capacity). */ static native long tagAsReferenceChainRoot0(Object target); /** - * Test seam: runs one reference-chain search pass synchronously. Returns - * {@code false} if reference chains are disabled or not started. + * Test seam (debug native builds only): runs exactly one bounded BFS pass of the + * reference-chain search synchronously, rather than waiting on the tracker's own + * background thread/cadence. Returns {@code false} if reference chains are + * disabled or the tracker was never started. */ static native boolean runReferenceChainPass0(); /** - * Test seam: runs one poll of the leak-candidate to chain bridge synchronously. + * Test seam (debug native builds only): runs one poll of + * ReferenceChainTracker's LivenessTracker-to-chain-reconstruction bridging step + * synchronously - for each current leak candidate already discovered by a prior + * {@link #runReferenceChainPass0()} walk, reconstructs and queues its chain + * event, rather than waiting on the background thread's own scheduling cycle. */ static native void pollReferenceChainTargets0(); /** - * Test seam: drains queued reference-chain events and returns their count. + * Test seam (debug native builds only): drains the reference-chain events queued by + * {@link #pollReferenceChainTargets0()} so far (the same queue {@code Profiler.dump()} drains + * in production to write {@code datadog.ReferenceChain} JFR events) and returns how many of + * them have {@code leaf}'s class as their leaf (first) hop. Chains for other targets, e.g. left + * cached by an earlier test in the same JVM, are not counted. */ - static native int drainReferenceChainEventCount0(); + static native int drainReferenceChainEventCountForLeaf0(Object leaf); /** - * Test seam: resets the search state and releases held tags, so the next - * pass walks from the roots again. + * Test seam (debug native builds only): resets ReferenceChainTracker's search/frontier state + * back to a brand-new tracker's, releasing any tags a previous search still held. The tracker + * is a process-wide singleton and runPass() walks from the roots only once per search, so an + * in-process test needing its own root-seeded walk must call this first. */ static native void resetReferenceChainSearchForTest0(); /** - * Test seam: position of {@code target} in the pending-expansion queue - * ({@code 0} = next), {@code -1} if already expanded, {@code -2} if never - * admitted. Does not tag {@code target}. + * Test seam (debug native builds only): diagnostic-only, does not tag {@code target}. Reads + * target's existing JVMTI tag (0 if the real search has never admitted it) and reports its + * FIFO distance from the front of ReferenceChainTracker's pending-expansion queue: {@code >=0} + * (0 = expands next) if still queued, {@code -1} if tagged but no longer queued (already + * expanded), or {@code -2} if never admitted at all. */ static native long getReferenceChainPendingPositionForTest0(Object target); /** - * Test seam: size of the pending-expansion queue. + * Test seam (debug native builds only): the current size of ReferenceChainTracker's + * pending-expansion queue, for computing {@link #getReferenceChainPendingPositionForTest0}'s + * position as a fraction of the current backlog. */ static native long getReferenceChainPendingSizeForTest0(); /** - * Test seam: seeds one heap-floor sample for the time-to-OOM projection. - * Timestamps only need to be increasing. + * Test seam (-DDEBUG native builds only - debug/asan/tsan; returns -1 in release): does not + * tag {@code target}. The raw {@code FrontierEntryState} (canonical values: + * referenceChainFrontier.h) of the current search's frontier entry for target - 0 = FRONTIER + * (admitted, not yet expanded), 1 = EXPANDED, 2 = EDGE (retired: never produced by the + * production walk, so this seam does not return it in practice), 3 = ABANDONED - or + * {@code -1} if target has no frontier entry (never admitted, or still carrying only a leak + * tag). + */ + static native int getReferenceChainFrontierStateForTest0(Object target); + + /** + * Test seam (-DDEBUG native builds only - debug/asan/tsan; returns 0 in release): does not + * tag {@code target}. The targetTag the {@code datadog.ReferenceChain} event for target + * carries, if a cached chain exists for target; 0 otherwise. When target carries a frontier + * tag, only the current search's cached chain counts. When target carries a leak tag, a chain + * cached by any search generation counts, but only if it was built during target's current + * ownership of that leak tag: leak tags are recycled after their object dies, so identity is + * the (leak tag, ownership-period stamp) pair, not the tag alone. + */ + static native long getReferenceChainResolvedTargetTagForTest0(Object target); + + /** + * Test seam (debug native builds only): seeds one heap-floor-ring sample - the input to + * {@code LivenessTracker::secondsToOOM()}'s time-to-OOM projection - directly, bypassing the + * real {@code GarbageCollectionFinish} callback. {@code timestampNs} values are only ever + * compared against each other, never against a real wall clock, so any strictly increasing + * sequence is valid. */ static native void heapFloorRecordForTest0(long usedBytes, long timestampNs); /** - * Test seam: overrides the max heap size used by the time-to-OOM projection. + * Test seam (debug native builds only): overrides the max-heap-size {@code secondsToOOM()} + * projects against, bypassing the real {@code Runtime.maxMemory()} resolution, so the + * projection does not depend on the test JVM's {@code -Xmx}. */ static native void setMaxHeapBytesForTest0(long maxHeapBytes); /** - * Test seam: {@code false} stops GCs from recording heap-floor samples, - * {@code true} restores it. + * Test seam (debug native builds only): temporarily disables {@code onGC()}'s own + * {@code recordHeapFloorSample()} call so a test can seed the heap-floor ring exclusively + * via {@link #heapFloorRecordForTest0(long, long)} without a real GC interleaving a sample + * with a real {@code OS::nanotime()} timestamp and real heap usage, corrupting + * {@code secondsToOOM()}'s projection. Pass {@code false} to disable, {@code true} to restore. */ static native void setHeapFloorRecordingForTest0(boolean enabled); /** - * Test seam: evaluates the real search scheduling gate without executing a BFS pass. - * This 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. + * Test seam (debug native builds only): clears the heap-floor ring seeded via + * {@link #heapFloorRecordForTest0(long, long)} so the next test in the same JVM does not + * inherit this test's samples into its {@code secondsToOOM()} projection. Must be called + * while heap-floor recording is disabled (i.e. before the + * {@link #setHeapFloorRecordingForTest0(boolean)} restore in a finally block) - the seam + * asserts when called while recording is enabled, because a concurrent sample cannot be + * excluded from the two non-atomic index stores otherwise. + */ + static native void heapFloorResetForTest0(); + + /** + * 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 + * {@link #heapFloorResetForTest0()} emptied the ring, which is how tests assert the + * reset actually cleared the indices. + */ + static native boolean heapFloorRisingForTest0(); + + /** + * 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 candidates. Unlike + * {@link #runReferenceChainPass0()}, which calls {@code runPass()} unconditionally, this + * evaluates the gate without running a pass. This 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. */ static native boolean shouldRunPassForTest0(); /** - * Test seam: number of passes run for the current search. + * Test seam (debug native builds only): the number of BFS passes run for the current/most + * recent reference-chain search. Waiting for it to advance past the value read before creating + * an object guarantees a later match came from a pass whose {@code expandFrontier()} (and + * therefore {@code collectStaleExpandedEntriesForRotation()}) ran strictly after the object + * existed, not from a pass racing its creation. */ static native int referenceChainPassesRunForTest0(); diff --git a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp index cb59c87ca5..5b6e10f90f 100644 --- a/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp +++ b/ddprof-lib/src/test/cpp/livenessTracker_ut.cpp @@ -761,11 +761,10 @@ TEST_F(SelectLeakCandidatesTest, HeapFloorCorroborationLowersRequiredHysteresis) } // --- Per-(klass, tid) qualification gate (TidTrend, livenessTracker.h) --- -// The disjoint-tagged-vs-frontier pod finding: a whole-klass rising -// generation count can come from churn spread across MANY allocating -// threads, each retaining a STABLE handful of instances. A klass with a -// qualifying klass-level trend but NO thread whose own trend qualifies is -// NOT a leak candidate. +// A whole-klass rising generation count can come from churn spread across +// MANY allocating threads, each retaining a STABLE handful of instances. A +// klass with a qualifying klass-level trend but NO thread whose own trend +// qualifies is NOT a leak candidate. TEST_F(SelectLeakCandidatesTest, KlassTrendWithoutQualifyingTidIsNotSelected) { LivenessTracker *tracker = LivenessTracker::instance(); @@ -839,9 +838,7 @@ TEST_F(SelectLeakCandidatesTest, RetainedCountBarQualifiesOneCohortShape) { } // A rising per-tid trend alone (below the retained-count bar) qualifies: -// small leaks grow their age span long before their count clears the bar - -// the hotdog pod's simulated-memory-leak thread (12 tracked [B instances, -// age_count rising 2->3) is exactly this shape. +// small leaks grow their age span long before their count clears the bar. TEST_F(SelectLeakCandidatesTest, RisingTidTrendQualifiesBelowCountBar) { LivenessTracker *tracker = LivenessTracker::instance(); @@ -1129,6 +1126,17 @@ class LivenessTrackerTestAccessor { t->_table_max_cap = table_max_cap; } + // Writes a canary cached class id into a table row (possibly one past + // _table_size, still within _table_cap) so tests can pin whole-table + // clear-loop bounds. Returns false when idx is outside the allocation. + static bool setCachedKlassIdForTest(LivenessTracker *t, u32 idx, u32 id) { + if (t->_table == nullptr || idx >= (u32)t->_table_cap) { + return false; + } + t->_table[idx].cached_klass_id = id; + return true; + } + static int tableSizeFieldForTest(LivenessTracker *t) { return t->_table_size; } @@ -1560,6 +1568,87 @@ TEST_F(LeakTagPoolTest, DoubleReleaseWithFreeRoomListsTagOnce) { EXPECT_NE(first, second) << "the same tag was handed out twice"; } +// Ownership-period stamp (leakTagIncarnation): (tag, stamp) is the identity of +// a leak-tagged object, because the tag itself is recycled after its object +// dies. The stamp must change on every ownership transition and on nothing +// else. +TEST_F(LeakTagPoolTest, IncarnationChangesOnAcquireAndRelease) { + LivenessTracker *tracker = LivenessTracker::instance(); + jlong base = tracker->leakTagBaseForTest(); + // LIFO: after the reset the next acquire pops this slot. + jlong tag = tracker->acquireLeakTagForTest(); + ASSERT_GE(tag, base); + u32 owned = tracker->leakTagIncarnation(tag); + tracker->releaseLeakTagForTest(tag); + u32 released = tracker->leakTagIncarnation(tag); + EXPECT_NE(owned, released) << "release must start a new ownership period"; + jlong re_tag = tracker->acquireLeakTagForTest(); + ASSERT_EQ(tag, re_tag) << "released tag should be recycled first (LIFO)"; + u32 reowned = tracker->leakTagIncarnation(re_tag); + EXPECT_NE(released, reowned) << "acquire must start a new ownership period"; + EXPECT_NE(owned, reowned) + << "the tag's new owner must not share the dead owner's stamp"; +} + +TEST_F(LeakTagPoolTest, IncarnationUnchangedByNoOpReleases) { + LivenessTracker *tracker = LivenessTracker::instance(); + jlong base = tracker->leakTagBaseForTest(); + int pool_size = tracker->leakTagPoolSizeForTest(); + jlong tag = tracker->acquireLeakTagForTest(); + ASSERT_GE(tag, base); + tracker->releaseLeakTagForTest(tag); + u32 after_release = tracker->leakTagIncarnation(tag); + // Rejected repeated release: no ownership transition. + tracker->releaseLeakTagForTest(tag); + EXPECT_EQ(after_release, tracker->leakTagIncarnation(tag)); + + // Out-of-range releases touch no slot. + jlong held = tracker->acquireLeakTagForTest(); + ASSERT_GE(held, base); + u32 held_stamp = tracker->leakTagIncarnation(held); + u32 first_stamp = tracker->leakTagIncarnation(base); + u32 last_stamp = tracker->leakTagIncarnation(base + pool_size - 1); + tracker->releaseLeakTagForTest(base - 1); + tracker->releaseLeakTagForTest(base + pool_size); + tracker->releaseLeakTagForTest(0); + tracker->releaseLeakTagForTest(-1); + EXPECT_EQ(held_stamp, tracker->leakTagIncarnation(held)); + EXPECT_EQ(first_stamp, tracker->leakTagIncarnation(base)); + EXPECT_EQ(last_stamp, tracker->leakTagIncarnation(base + pool_size - 1)); +} + +TEST_F(LeakTagPoolTest, IncarnationIsZeroOutOfRange) { + LivenessTracker *tracker = LivenessTracker::instance(); + jlong base = tracker->leakTagBaseForTest(); + int pool_size = tracker->leakTagPoolSizeForTest(); + // Bump an in-range stamp first, so out-of-range reads cannot pass by + // accidentally aliasing a still-zero slot. + jlong tag = tracker->acquireLeakTagForTest(); + ASSERT_GE(tag, base); + EXPECT_EQ(0u, tracker->leakTagIncarnation(0)); + EXPECT_EQ(0u, tracker->leakTagIncarnation(1)); + EXPECT_EQ(0u, tracker->leakTagIncarnation(-1)); + EXPECT_EQ(0u, tracker->leakTagIncarnation(base - 1)); + EXPECT_EQ(0u, tracker->leakTagIncarnation(base + pool_size)); +} + +TEST_F(LeakTagPoolTest, ResetForTestBumpsOnlyOwnedSlots) { + LivenessTracker *tracker = LivenessTracker::instance(); + jlong base = tracker->leakTagBaseForTest(); + jlong owned = tracker->acquireLeakTagForTest(); + jlong freed = tracker->acquireLeakTagForTest(); + ASSERT_GE(owned, base); + ASSERT_GE(freed, base); + tracker->releaseLeakTagForTest(freed); + u32 owned_before = tracker->leakTagIncarnation(owned); + u32 freed_before = tracker->leakTagIncarnation(freed); + tracker->leakTagPoolResetForTest(); + EXPECT_NE(owned_before, tracker->leakTagIncarnation(owned)) + << "freeing an owned slot is an ownership transition"; + EXPECT_EQ(freed_before, tracker->leakTagIncarnation(freed)) + << "an already-free slot keeps its stamp"; +} + // --------------------------------------------------------------------------- // C1 chaos: concurrent acquire/release across forced epoch rotations. The // single-threaded tests above pin the sequential mechanics; this one attacks @@ -2604,6 +2693,102 @@ TEST_F(LivenessTrackerMockJvmTest, SharedObjectLeakTagHasSingleOwner) { << "the object's leak tag must go back to the pool exactly once"; } +// start() rebuilds the leak-tag pool from the surviving tracking table. A tag +// whose owner is unchanged (a table row still holds it) must keep its stamp, so +// chains cached for that object stay attributable; a tag whose in-use bit flips +// (held without a row owning it) starts a new ownership period; a tag that +// stays free keeps its stamp. +TEST_F(LivenessTrackerMockJvmTest, StartRebuildBumpsStampOnlyWhenOwnershipFlips) { + LivenessTracker *tracker = LivenessTracker::instance(); + u32 retained_id = classIdOf(kRetainedSignature); + ASSERT_NE(0u, retained_id); + + constexpr jint kTid = 7; + jobject obj = trackNew(&jvm.retained_class, kTid); + jobject reclaimed_obj = trackNew(&jvm.retained_class, kTid); + // Resolves the rows' cached class id, which tagLeakInstances() matches on. + sweep(); + KlassCandidate candidate{}; + candidate.klass_id = retained_id; + candidate.qualifying_tids[0] = kTid; + candidate.qualifying_tid_count = 1; + ASSERT_EQ(2, tracker->tagLeakInstances(&jvm.jvmti, &candidate, 1)); + jlong row_tag = 0; + mockGetTag(&jvm.jvmti, obj, &row_tag); + ASSERT_GE(row_tag, tracker->leakTagBaseForTest()); + + // In use, but no table row owns it: the rebuild frees it. + jlong orphan_tag = tracker->acquireLeakTagForTest(); + ASSERT_GE(orphan_tag, tracker->leakTagBaseForTest()); + // Free before and after the rebuild. + jlong free_tag = tracker->acquireLeakTagForTest(); + ASSERT_GE(free_tag, tracker->leakTagBaseForTest()); + tracker->releaseLeakTagForTest(free_tag); + + // A row still holds it, but the pool lists it free: the rebuild reclaims it. + // Released last, so no acquire above pops it back off the LIFO free list. + jlong reclaimed_tag = 0; + mockGetTag(&jvm.jvmti, reclaimed_obj, &reclaimed_tag); + ASSERT_GE(reclaimed_tag, tracker->leakTagBaseForTest()); + ASSERT_NE(row_tag, reclaimed_tag); + tracker->releaseLeakTagForTest(reclaimed_tag); + ASSERT_FALSE(tracker->leakTagInUseForTest(reclaimed_tag)); + + u32 row_stamp = tracker->leakTagIncarnation(row_tag); + u32 reclaimed_stamp = tracker->leakTagIncarnation(reclaimed_tag); + u32 orphan_stamp = tracker->leakTagIncarnation(orphan_tag); + u32 free_stamp = tracker->leakTagIncarnation(free_tag); + + Arguments args; + ASSERT_FALSE(args.parse("generations=true")); + ASSERT_FALSE(tracker->start(args)); + + EXPECT_TRUE(tracker->leakTagInUseForTest(row_tag)); + EXPECT_EQ(row_stamp, tracker->leakTagIncarnation(row_tag)) + << "a tag whose owning row survived the rebuild must keep its stamp"; + EXPECT_TRUE(tracker->leakTagInUseForTest(reclaimed_tag)); + EXPECT_NE(reclaimed_stamp, tracker->leakTagIncarnation(reclaimed_tag)) + << "reclaiming a free-listed tag for its row is an ownership transition"; + EXPECT_FALSE(tracker->leakTagInUseForTest(orphan_tag)); + EXPECT_NE(orphan_stamp, tracker->leakTagIncarnation(orphan_tag)) + << "freeing an in-use tag in the rebuild is an ownership transition"; + EXPECT_FALSE(tracker->leakTagInUseForTest(free_tag)); + EXPECT_EQ(free_stamp, tracker->leakTagIncarnation(free_tag)) + << "a tag that stays free keeps its stamp"; +} + +// classMapGenerationSetForTest()'s whole-table cached_klass_id clear: every row +// below _table_size loses its cached id (those ids resolved against the old +// class-map namespace and could alias an unrelated class), and the row at +// _table_size - a canary inside the allocated table but outside the valid +// rows - keeps its value, pinning the loop bound (<, not <=). +TEST_F(LivenessTrackerMockJvmTest, GenerationChangeClearsCachedKlassIdsWholeTable) { + LivenessTracker *tracker = LivenessTracker::instance(); + trackNew(&jvm.retained_class, /*tid=*/7); + trackNew(&jvm.retained_class, /*tid=*/7); + sweep(); + ASSERT_EQ(2u, tracker->tableSizeForTest()); + ASSERT_NE(0u, cachedKlassIdAt(0)); + ASSERT_NE(0u, cachedKlassIdAt(1)); + + constexpr u32 kCanaryId = 0xC0FFEE; + // Row 2 is one past the valid rows but inside the allocated table (the + // canary write fails the test if the allocation is exactly 2 rows). + ASSERT_TRUE(LivenessTrackerTestAccessor::setCachedKlassIdForTest( + tracker, /*idx=*/2, kCanaryId)); + + // Two calls make the generation change deterministic even if a previous + // test already adopted generation + 1: first aligns, second changes. + u64 generation = Profiler::instance()->classMap()->generation(); + tracker->classMapGenerationSetForTest(generation); + tracker->classMapGenerationSetForTest(generation + 1); + + EXPECT_EQ(0u, cachedKlassIdAt(0)) << "row 0's cached id must clear on a generation change"; + EXPECT_EQ(0u, cachedKlassIdAt(1)) << "row 1's cached id must clear on a generation change"; + EXPECT_EQ(kCanaryId, cachedKlassIdAt(2)) + << "the clear loop must stop at _table_size, not write the row past it"; +} + // Profiler::start() resets the class map, restarting its ids at 1, while the // tracking table and its cached class ids survive. A flush before the next GC // must resolve the class again instead of emitting the cached id, which now diff --git a/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp b/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp index 9ff0562a71..b69987128f 100644 --- a/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp +++ b/ddprof-lib/src/test/cpp/referenceChainJfrRoundtrip_ut.cpp @@ -234,12 +234,15 @@ class ReferenceChainJfrRoundtripTest : public ::testing::Test { }; // Path agreed with the companion Java parser test in ddprof-test, which -// reads the same file back via JMC's -// JfrLoaderToolkit. Both sides resolve it via the OS temp dir so the -// producer (this gtest) and the consumer (the Java test, run afterwards by -// the same operator/CI job on the same machine) agree without either side -// needing to know the other module's build directory layout. +// reads the same file back via JMC's JfrLoaderToolkit. The build passes the +// same DDPROF_REFERENCE_CHAIN_ROUNDTRIP_JFR to both sides (ddprof-lib and +// ddprof-test build.gradle.kts) and deletes the file before this gtest runs; +// the OS temp dir is only a fallback for running the binary by hand. static std::string chainRoundtripJfrPath() { + const char *configured = getenv("DDPROF_REFERENCE_CHAIN_ROUNDTRIP_JFR"); + if (configured != nullptr && *configured != 0) { + return configured; + } const char *tmp = getenv("TMPDIR"); std::string dir = (tmp != nullptr && *tmp != 0) ? tmp : "/tmp"; if (dir.back() != '/') { @@ -318,12 +321,18 @@ TEST_F(ReferenceChainJfrRoundtripTest, ProducesValidStandaloneJfrWithChainEvent) // serializes into the T_CLASS checkpoint - this is the exact API // Profiler::lookupClass() already-existing tests use (e.g. // referenceChains_ut.cpp's ReconstructsChainForSyntheticGraph). + // The constants feed both the lookup and the .expected sidecar below, so + // the companion Java parser test reads the seeded values from a single + // source instead of duplicating them. + static constexpr const char *kLeafClass = "com/test/ChainLeaf"; + static constexpr const char *kMiddleClass = "com/test/ChainMiddle"; + static constexpr const char *kRootClass = "com/test/ChainRoot"; int leafKlass = Profiler::instance()->lookupClass( - "com/test/ChainLeaf", strlen("com/test/ChainLeaf")); + kLeafClass, strlen(kLeafClass)); int middleKlass = Profiler::instance()->lookupClass( - "com/test/ChainMiddle", strlen("com/test/ChainMiddle")); + kMiddleClass, strlen(kMiddleClass)); int rootKlass = Profiler::instance()->lookupClass( - "com/test/ChainRoot", strlen("com/test/ChainRoot")); + kRootClass, strlen(kRootClass)); ASSERT_NE(-1, leafKlass); ASSERT_NE(-1, middleKlass); ASSERT_NE(-1, rootKlass); @@ -345,6 +354,8 @@ TEST_F(ReferenceChainJfrRoundtripTest, ProducesValidStandaloneJfrWithChainEvent) // pins the JFR encoding, so the event is built directly). ReferenceChainEvent event; event._target_tag = 3; + // Non-zero so the Java parser test can pin the serialized field. + event._leak_tag_incarnation = 5; event._depth = 2; event._root_kind = 21; // JVMTI_HEAP_REFERENCE_JNI_GLOBAL - exercises the // rootKind field end to end (the engine's admission @@ -383,6 +394,29 @@ TEST_F(ReferenceChainJfrRoundtripTest, ProducesValidStandaloneJfrWithChainEvent) // same finalization every real recording chunk goes through. } + // The values this test seeds, written next to the recording: the companion + // Java parser test (ReferenceChainJfrParserTest) asserts against this + // sidecar instead of hard-coding them, so a change to the seeding shows + // up as a deliberate gtest-only diff rather than an opaque assertion + // failure in the other module. The Java side maps the slashed internal + // names to JMC's dotted IMCType full names. + { + std::string expectedPath = path + ".expected"; + FILE *expected = fopen(expectedPath.c_str(), "w"); + ASSERT_NE(nullptr, expected) << "could not open " << expectedPath; + fprintf(expected, "chain.length=%d\n", 3); + fprintf(expected, "chain.0=%s\n", kLeafClass); + fprintf(expected, "chain.1=%s\n", kMiddleClass); + fprintf(expected, "chain.2=%s\n", kRootClass); + fprintf(expected, "edges.0=field\n"); + fprintf(expected, "edges.1=element\n"); + fprintf(expected, "edges.2=jni_global\n"); + fprintf(expected, "targetTag=%lld\n", (long long)event._target_tag); + fprintf(expected, "depth=%u\n", event._depth); + fprintf(expected, "leakTagIncarnation=%u\n", event._leak_tag_incarnation); + fclose(expected); + } + FILE *f = fopen(path.c_str(), "rb"); ASSERT_NE(nullptr, f) << "expected " << path << " to have been written"; char magic[4] = {0, 0, 0, 0}; @@ -481,6 +515,7 @@ TEST_F(ReferenceChainJfrRoundtripTest, TruncatesOversizeChainAndKeepsEmittedLabe off += sizeOfVar64((u64)T_REFERENCE_CHAIN); // type id off += sizeOfVar64(event._start_time); off += sizeOfVar64(event._target_tag); + off += sizeOfVar64((u64)event._leak_tag_incarnation); off += sizeOfVar64(event._depth); // depth, small by construction // rootKind putUtf8: 1 encoding-tag byte + var32 length + payload. const char *root_kind_name = "jni_global"; // rootKindName(21) @@ -511,4 +546,5 @@ TEST_F(ReferenceChainJfrRoundtripTest, TruncatesOversizeChainAndKeepsEmittedLabe TEST_F(ReferenceChainJfrRoundtripTest, DefaultLivenessEventHasZeroLeakTag) { ObjectLivenessEvent event; EXPECT_EQ(0, event.leak_tag); + EXPECT_EQ(0u, event.leak_tag_incarnation); } diff --git a/ddprof-lib/src/test/cpp/referenceChainsAnchorTests.inc b/ddprof-lib/src/test/cpp/referenceChainsAnchorTests.inc index af0dbdf434..d39be8edff 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsAnchorTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsAnchorTests.inc @@ -79,10 +79,9 @@ TEST_F(ReferenceChainsBfsTest, StaticAnchorRotationWalksRootAttachedStaticHolder tracker->stop(); } -// Round-14 tiered selection: a container-shaped anchor (its own class implements Collection/Map - -// the LEAK_BUFFER wrapper shape) admitted at a LATE index position must leap the queue of ~28k -// other-tier anchors (the round-13 hotdog measurement: admission-order selection put the leak -// holder at position ~12-21k against ~4k walk coverage per search - deterministically unreachable). +// Tiered selection: a container-shaped anchor (its own class implements Collection/Map) admitted +// at a LATE index position must leap the queue of ~28k other-tier anchors - admission-order +// selection put the leak holder at position ~12-21k against ~4k walk coverage per search. TEST_F(ReferenceChainsBfsTest, ContainerAnchorLeapsQueueAcrossLargeIndex) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); @@ -110,7 +109,7 @@ TEST_F(ReferenceChainsBfsTest, ContainerAnchorLeapsQueueAcrossLargeIndex) { ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( frontier, leak_anchor, 0, 0, FrontierEntryState::FRONTIER, JVMTI_HEAP_REFERENCE_STATIC_FIELD)); - frontier->setLeakTag(leak_anchor, 777); + frontier->setLeakTag(leak_anchor, 777, /*leak_tag_incarnation=*/0); ReferenceChainsTestAccessor::addToStaticAnchorIndexForTest( leak_anchor, 599999, JVMTI_HEAP_REFERENCE_STATIC_FIELD); ReferenceChainsTestAccessor::primeClassShapeForTest( @@ -137,10 +136,9 @@ TEST_F(ReferenceChainsBfsTest, ContainerAnchorLeapsQueueAcrossLargeIndex) { tracker->stop(); } -// Round-15 fresh-admission priority: the hotdog wrapper is admitted LATE in a search (its holder -// class sits at sweep index 24627 of 33270, so admission lands at the anchor-index TAIL) - behind -// the whole fair container backlog measured at 1633 entries against 44-75-pass search lifetimes -// (the fair container cursor would reach it at pass ~102+, after the search is dead). +// Fresh-admission priority: a container admitted LATE in a search lands at the anchor-index TAIL, +// behind the whole fair container backlog (measured at 1633 entries against 44-75-pass search +// lifetimes - the fair cursor would reach it at pass ~102+, after the search is dead). TEST_F(ReferenceChainsBfsTest, FreshContainerWalkedBeforeFairBacklog) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); @@ -233,7 +231,7 @@ TEST_F(ReferenceChainsBfsTest, FreshContainerWalkedBeforeFairBacklog) { tracker->stop(); } -// Round-14 tier fairness: the other tier (everything not leak-tagged, not container-shaped) still +// Tier fairness: the other tier (everything not leak-tagged, not container-shaped) still // reaches full coverage across wraps - a 40-anchor tier at a 16 budget covers all 40 in exactly 3 // calls with no duplicates within a call. TEST_F(ReferenceChainsBfsTest, AnchorOtherTierFairCoverageAcrossWraps) { @@ -278,9 +276,9 @@ TEST_F(ReferenceChainsBfsTest, AnchorOtherTierFairCoverageAcrossWraps) { tracker->stop(); } -// Round-13/14 restart hygiene: discovered-instance tags are FRONTIER tags; restartSearch() resets -// the frontier table and _next_tag=1, so any surviving discovered slot either fails -// reconstructChain (observed on-pod: 'buildChainEvent failed ... +// Restart hygiene: discovered-instance tags are FRONTIER tags; restartSearch() resets the frontier +// table and _next_tag=1, so a surviving discovered slot would fail reconstructChain() or resolve +// to whatever new entry reuses its tag. TEST_F(ReferenceChainsBfsTest, RestartSearchClearsDiscoveredInstanceTags) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); @@ -313,9 +311,9 @@ TEST_F(ReferenceChainsBfsTest, RestartSearchClearsDiscoveredInstanceTags) { tracker->stop(); } -// Round-19 (pod 289f8 — three JVMs of "canary search, 0/1 candidates found" while leak-tagged -// instances WERE intercepted and chains re-emitted): the marker->leak-tag design migration never -// updated the found criterion. +// A chain whose target carries a leak tag must mark its canary candidate found - otherwise the +// search reports "0/1 candidates found" while leak-tagged instances ARE intercepted and chains +// re-emitted. TEST_F(ReferenceChainsBfsTest, LeakTagChainMarksCanaryFound) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); @@ -332,7 +330,8 @@ TEST_F(ReferenceChainsBfsTest, LeakTagChainMarksCanaryFound) { // (suppressChainEvent: depth==0, or depth==1 at a transient root) keeps both chains. ASSERT_TRUE(frontier->insert(4242, 0, 7, 1, FrontierEntryState::FRONTIER, 8, /*class_tag=*/0, -1, /*kind=*/0)); - frontier->setLeakTag(4242, 1073742079); + frontier->setLeakTag(4242, 1073742079, + LivenessTracker::instance()->leakTagIncarnation(1073742079)); ReferenceChainsTestAccessor::recordDiscoveredInstanceForTest(7, 4242, true); ASSERT_EQ(4242, ReferenceChainsTestAccessor::candidateDiscoveredTagForTest(0, 0)); ASSERT_TRUE(frontier->insert(5353, 0, 9, 1, FrontierEntryState::FRONTIER, @@ -353,7 +352,7 @@ TEST_F(ReferenceChainsBfsTest, LeakTagChainMarksCanaryFound) { tracker->stop(); } -// B' push site 1 - DEMOTION TIME (find-anchor-holder-eviction / _static_anchor_fifo): when +// At-risk FIFO (_static_anchor_fifo) push site 1 - DEMOTION TIME: when // improveChain() replaces a root-attached durable (STATIC_FIELD/ JNI_GLOBAL) entry with a deeper // chain-attached path, the entry is leaving the anchor tier's eligible population at exactly that // moment - the push must fire right there. @@ -407,7 +406,7 @@ TEST_F(ReferenceChainsBfsTest, DemotionPushFiresWhenImproveChainEvictsRootAttach tracker->stop(); } -// B' push site 2 - SWEEP TIME: the static sweep's class->field edge onto an already-admitted +// At-risk FIFO push site 2 - SWEEP TIME: the static sweep's class->field edge onto an already-admitted // CHAIN-ATTACHED entry (the admission-order eviction shape: born as a non-root child, never // root-attached at all) must feed the FIFO. TEST_F(ReferenceChainsBfsTest, SweepPushFiresOnStaticEdgeOntoChainAttachedHolder) { @@ -449,10 +448,8 @@ TEST_F(ReferenceChainsBfsTest, SweepPushFiresOnStaticEdgeOntoChainAttachedHolder bool truncated = true; ASSERT_TRUE(tracker->runPass(&mock_jvmti, &mock_jni, &truncated)); - // The rotation phase of the same pass drained the pushed tag into the anchor walk, and the walk - // admitted the holder's child - the push itself left no residue in the FIFO (drained empty) and - // never re-attributed the holder's entry (re-rooting is the documented refusal that motivated - // the FIFO in the first place). + // The same pass's rotation phase drained the pushed tag into the anchor walk, which admitted + // the holder's child; the FIFO is left empty. EXPECT_EQ(0u, ReferenceChainsTestAccessor::staticAnchorFifoSizeForTest()); jlong childTag = tags_ever_assigned[holderChildNode]; ASSERT_GT(childTag, 0) << "the FIFO-drained anchor walk never admitted " @@ -461,8 +458,7 @@ TEST_F(ReferenceChainsBfsTest, SweepPushFiresOnStaticEdgeOntoChainAttachedHolder ASSERT_TRUE(frontier->lookup(childTag, &childEntry)); EXPECT_EQ(105, childEntry.parent_tag); EXPECT_EQ(2u, childEntry.depth); - // The holder's entry is untouched by the push - the B' feed records the at-risk shape, it never - // re-attributes the entry. + // The push records the at-risk shape; it never re-attributes the holder's entry. FrontierEntry entry{}; ASSERT_TRUE(frontier->lookup(105, &entry)); EXPECT_EQ(104, entry.parent_tag); @@ -471,7 +467,7 @@ TEST_F(ReferenceChainsBfsTest, SweepPushFiresOnStaticEdgeOntoChainAttachedHolder tracker->stop(); } -// B' mechanics: a chain-attached holder drained from the at-risk FIFO is descend-walked and +// At-risk FIFO mechanics: a chain-attached holder drained from the at-risk FIFO is descend-walked and // intercepts a leak chunk 3 hops below it - the repair for the population the root-attached // collector demonstrably cannot select (the negative control below). TEST_F(ReferenceChainsBfsTest, AtRiskAnchorFifoDrainAndWalkIntercept) { @@ -514,13 +510,12 @@ TEST_F(ReferenceChainsBfsTest, AtRiskAnchorFifoDrainAndWalkIntercept) { JVMTI_HEAP_REFERENCE_STACK_LOCAL)); // Negative control: the root-attached collector selects NOTHING from a table holding only a - // chain-attached holder and a transient root - the pre-B' behavior that stranded the - // dual-reachable population. + // chain-attached holder and a transient root. std::vector selected = ReferenceChainsTestAccessor::collectStaticFieldAnchorsForRotationForTest(4); ASSERT_TRUE(selected.empty()); - // B': push + drain + walk reaches what the collector cannot. + // Push + drain + walk reaches what the collector cannot. ReferenceChainsTestAccessor::pushStaticAnchorFifoForTest(105, 28366); std::vector drained; // 16 = ReferenceChainTracker::STATIC_ANCHOR_FIFO_DRAIN (private), the same per-pass drain cap @@ -557,7 +552,7 @@ TEST_F(ReferenceChainsBfsTest, AtRiskAnchorFifoDrainAndWalkIntercept) { tracker->stop(); } -// B' requeue mechanics: a truncated anchor walk reports exactly the RESOLVED-but-unwalked tags, and +// At-risk FIFO requeue mechanics: a truncated anchor walk reports exactly the RESOLVED-but-unwalked tags, and // requeueStaticAnchorFifoFront() restores them to the FIFO front in order with a consistent // membership set - so an at-risk holder that lost its budget turn keeps it for the next pass // instead of waiting for the next sweep lap. @@ -641,7 +636,7 @@ TEST_F(ReferenceChainsBfsTest, TruncatedAnchorWalkRequeuesUnwalkedFifoTags) { tracker->stop(); } -// a this-field self-edge is REAL in the heap - every java.util.Collections$Synchronized* holder +// A this-field self-edge is REAL in the heap - every java.util.Collections$Synchronized* holder // carries mutex == this - so walking such a holder's own subtree (rotation anchor walk or BFS // descent) re-reports the holder as its own child through that field. TEST_F(ReferenceChainsBfsTest, @@ -695,9 +690,8 @@ TEST_F(ReferenceChainsBfsTest, tracker->stop(); } -// the B' at-risk FIFO sat cap-pinned at 1024 because three classes flooded it (klass 1: 1396 -// pushes, klass 215: 1063, klass 1733: 988+), so the LEAK_BUFFER wrapper's pushes (klass 28366) -// were dropped at the cap check and the lane designed to repair exactly its demotion never carried +// Per-class quota: without it the at-risk FIFO sat cap-pinned at 1024 because three classes +// flooded it, so a leak holder's pushes were dropped at the cap check and the lane never carried // it. TEST_F(ReferenceChainsBfsTest, AtRiskFifoPerClassQuotaKeepsFloodOut) { Arguments args; @@ -722,8 +716,7 @@ TEST_F(ReferenceChainsBfsTest, AtRiskFifoPerClassQuotaKeepsFloodOut) { EXPECT_FALSE(ReferenceChainsTestAccessor::staticAnchorFifoContainsForTest( 1000 + (int)quota)); - // The wrapper's push (a different class) lands despite the flood - exactly the push the pod - // dropped. + // A different class's push lands despite the flood. ReferenceChainsTestAccessor::pushStaticAnchorFifoForTest(2000, 28366); EXPECT_EQ(quota + 1, ReferenceChainsTestAccessor::staticAnchorFifoSizeForTest()); @@ -802,9 +795,7 @@ TEST_F(ReferenceChainsBfsTest, AtRiskFifoPerClassQuotaKeepsFloodOut) { EXPECT_FALSE( ReferenceChainsTestAccessor::staticAnchorFifoContainsForTest(20000)); - // Leave the FIFO drained: this test is the one that saturates it, and the reset() seam above - // now clears it for the next test regardless - but a drained ending also keeps this test - // order-independent even if that seam ever regresses again. + // Leave the saturated FIFO drained so later tests do not depend on the reset() seam alone. std::vector final_drain; ASSERT_EQ(1024, ReferenceChainsTestAccessor::drainStaticAnchorFifoForTest( diff --git a/ddprof-lib/src/test/cpp/referenceChainsEventTests.inc b/ddprof-lib/src/test/cpp/referenceChainsEventTests.inc index 2559036d1c..2c41da0ace 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsEventTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsEventTests.inc @@ -9,13 +9,14 @@ TEST_F(ReferenceChainsBfsTest, HopEdgeLabelsDecodeSpecFieldOrdinals) { ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); ASSERT_FALSE(tracker->start(args)); - // Hierarchy mirroring the spec's own numbering example shape: interface IBase { int p; } 1 own - // field interface ISub extends IBase { int x; } 1 own field class Base { int base_f; } 1 own - // field, no super class Holder extends Base implements ISub { int holder_a; Object leakList; } - // interface ISink { Object CONST_A; Object CONST_B; } Spec ordinal spaces - // (jvmtiHeapReferenceInfoField): Holder (class branch): base = ISub(1) + IBase(1) = 2 - // (transitive interfaces, each once); then the superclass chain root-first: base_f@2; then own - // fields in GetClassFields order: holder_a@3, leakList@4. + // Hierarchy mirroring the spec's numbering example: + // interface IBase { int p; } interface ISub extends IBase { int x; } + // class Base { int base_f; } + // class Holder extends Base implements ISub { int holder_a; Object leakList; } + // interface ISink { Object CONST_A; Object CONST_B; } + // Holder's ordinal space (jvmtiHeapReferenceInfoField): transitive interfaces once each + // (ISub + IBase = 2), then the superclass chain root-first (base_f@2), then own fields in + // GetClassFields order (holder_a@3, leakList@4). void *ibase = (void *)0x5001, *isub = (void *)0x5002, *base = (void *)0x5003, *holder = (void *)0x5004, *isink = (void *)0x5005; addClass(ibase, "Lcom/rc/labels/IBase;"); @@ -194,7 +195,6 @@ TEST_F(ReferenceChainsBfsTest, PriorityExpandCapStopsRotationCollectorPushes) { ASSERT_FALSE(tracker->start(args)); FrontierTable *frontier = tracker->frontierTable(); - // One eligible stale-EXPANDED entry. ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( frontier, 1, 0, 0, FrontierEntryState::EXPANDED, JVMTI_HEAP_REFERENCE_STATIC_FIELD)); @@ -260,8 +260,8 @@ TEST_F(ReferenceChainsBfsTest, StaleRotationPrefersLeakParentsOverBlindLap) { // reparentToDurableRoot: a depth-1 entry first admitted through a transient root (stack/JNI local) // is re-parented to a durable root-attached parent at equal depth - the case improveChain() cannot -// express (it requires a strictly deeper path), and exactly the hotdog shape where the singleton -// collection is a depth-0 static root and its elements depth 1. +// express (it requires a strictly deeper path): a singleton collection as a depth-0 static root +// with its elements at depth 1. TEST_F(ReferenceChainsBfsTest, ReparentToDurableRootSwapsTransientForDurable) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); @@ -305,9 +305,8 @@ TEST_F(ReferenceChainsBfsTest, ReparentToDurableRootSwapsTransientForDurable) { } // recordDiscoveredInstance eviction: noise instances fill discovery slots first-come-first-served, -// but a leak-correlated discovery must evict a noise slot when all are full - without eviction, the -// 8 noise instances observed on-pod permanently blocked every later leak-tagged instance of the -// watched class. +// but a leak-correlated discovery must evict a noise slot when all are full - without eviction, +// noise instances permanently block every later leak-tagged instance of the watched class. TEST_F(ReferenceChainsBfsTest, RecordDiscoveredInstanceEvictsNoiseSlots) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); @@ -358,13 +357,17 @@ TEST_F(ReferenceChainsBfsTest, RecordDiscoveredInstanceEvictsNoiseSlots) { // signal). ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( frontier, 200, 0, 1, FrontierEntryState::EXPANDED, /*root_kind=*/0)); - frontier->setLeakTag(200, ReferenceChainsTestAccessor::leakTagBase() + 1); + frontier->setLeakTag(200, ReferenceChainsTestAccessor::leakTagBase() + 1, + LivenessTracker::instance()->leakTagIncarnation( + ReferenceChainsTestAccessor::leakTagBase() + 1)); // Entries for the remaining noise slots so the eviction scan finds all slots leak-tagged. for (int d = 1; d < cap; d++) { jlong tag = 101 + (d - 1); ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( frontier, tag, 0, 1, FrontierEntryState::EXPANDED, /*root_kind=*/0)); - frontier->setLeakTag(tag, ReferenceChainsTestAccessor::leakTagBase() + 2); + frontier->setLeakTag(tag, ReferenceChainsTestAccessor::leakTagBase() + 2, + LivenessTracker::instance()->leakTagIncarnation( + ReferenceChainsTestAccessor::leakTagBase() + 2)); } ReferenceChainsTestAccessor::recordDiscoveredInstanceForTest( kKlass, 201, true); @@ -398,6 +401,9 @@ TEST_F(ReferenceChainsBfsTest, CorrelateAdmittedLeakTagSetsEntryAndDiscovers) { EXPECT_TRUE(tracker->correlateAdmittedLeakTag(300, kLeakTag, kKlass)); EXPECT_EQ(kLeakTag, (jlong)ReferenceChainsTestAccessor::frontierLeakTag(300)); + // The binding captures the tag's current ownership-period stamp with it. + EXPECT_EQ(LivenessTracker::instance()->leakTagIncarnation(kLeakTag), + ReferenceChainsTestAccessor::frontierLeakTagIncarnation(300)); EXPECT_EQ(1, ReferenceChainsTestAccessor::candidateDiscoveredCountForTest(0)); EXPECT_EQ((jlong)300, ReferenceChainsTestAccessor::candidateDiscoveredTagForTest(0, 0)); @@ -413,6 +419,40 @@ TEST_F(ReferenceChainsBfsTest, CorrelateAdmittedLeakTagSetsEntryAndDiscovers) { tracker->stop(); } +// The ownership-period stamp bound with a leak tag travels with the entry into the chain event +// (buildChainEvent()), and a fresh insert() into the slot clears it together with the leak tag. +TEST_F(ReferenceChainsBfsTest, BuildChainEventPropagatesLeakTagIncarnation) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=64")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + + constexpr jlong kLeakTag = 0x40000000LL + 7; + constexpr u32 kStamp = 12345u; + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 400, 0, 0, FrontierEntryState::EXPANDED, + JVMTI_HEAP_REFERENCE_STATIC_FIELD)); + EXPECT_EQ(0u, ReferenceChainsTestAccessor::frontierLeakTagIncarnation(400)); + frontier->setLeakTag(400, kLeakTag, kStamp); + EXPECT_EQ(kStamp, ReferenceChainsTestAccessor::frontierLeakTagIncarnation(400)); + + ReferenceChainEvent event; + ASSERT_TRUE(tracker->buildChainEvent(&mock_jvmti, &mock_jni, 400, &event)); + EXPECT_EQ((u64)kLeakTag, event._target_tag); + EXPECT_EQ(kStamp, event._leak_tag_incarnation); + + // Re-inserting the slot (a new object under that frontier tag) drops both the leak tag and its + // stamp. + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 400, 0, 0, FrontierEntryState::FRONTIER, + JVMTI_HEAP_REFERENCE_STATIC_FIELD)); + EXPECT_EQ(0, ReferenceChainsTestAccessor::frontierLeakTag(400)); + EXPECT_EQ(0u, ReferenceChainsTestAccessor::frontierLeakTagIncarnation(400)); + + tracker->stop(); +} + // Retention-explanation gate on the discovered-instance chains (depth==0 always suppressed; // depth==1 suppressed only for TRANSIENT roots - a depth-1 chain from a durable root is the real // direct-retention shape): transient depth-1 must NOT be cached, durable depth-1 and depth-2 must. diff --git a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h index ef2a0d6817..a7045e8582 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h +++ b/ddprof-lib/src/test/cpp/referenceChainsTestAccessors.h @@ -3,11 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -// Test accessors for the reference-chain tracker, gtest-free by design: -// extracted verbatim from referenceChainsCoreTests.inc so the libFuzzer -// heap-graph target (src/test/fuzz/fuzz_referenceChainHeap.cpp) resets and -// observes the process-wide ReferenceChainTracker singleton through the exact -// same friend-class surface as the gtest suite instead of a diverging copy. +// Test accessors for the reference-chain tracker, gtest-free so the libFuzzer +// heap-graph target (src/test/fuzz/fuzz_referenceChainHeap.cpp) shares the +// gtest suite's friend-class surface instead of a diverging copy. // friend declarations: vmEntry.h (VMTestAccessor), referenceChains.h // (ReferenceChainsTestAccessor). @@ -22,7 +20,6 @@ #include "referenceChains.h" #include "vmEntry.h" -// VMTestAccessor - friend of VM (vmEntry.h), lets tests swap VM::_jvmti for a class VMTestAccessor { public: static jvmtiEnv* getJvmti() { return VM::_jvmti; } @@ -37,7 +34,6 @@ class VMTestAccessor { static void setHotspotVersion(int v) { VM::_hotspot_version = v; } }; -// ReferenceChainsTestAccessor - same pattern as VMTestAccessor above, for the class ReferenceChainsTestAccessor { public: static void reset() { @@ -46,18 +42,14 @@ class ReferenceChainsTestAccessor { t->_frontier = nullptr; t->_class_tags = ClassTagTable(); t->_last_resolved_class_count = 0; - // Without these two, a prior test's fully-swept (or partially-swept) - // admitStaticFieldRoots() state survives in this process-wide singleton and can wrongly - // skip the sweep entirely on this test's first pass if its resolved class count happens to - // match whatever an earlier test last left behind - see resetForRestart()'s identical reset - // of these same fields for the production-restart equivalent of this same contract. + // Otherwise a prior test's admitStaticFieldRoots() sweep state survives in the singleton + // and can skip this test's first sweep if the resolved class count happens to match + // (resetForRestart() resets the same fields in production). t->_last_static_field_class_count = -1; t->_static_field_sweep_cursor = 0; t->_static_field_sweep_cycle_truncated = false; t->_next_tag = 1; - // Shared with LivenessTracker (classTagAllocator.h) - process-wide, not - // per-ReferenceChainTracker-instance, so it needs its own reset seam rather than being a - // plain member write. + // Process-wide and shared with LivenessTracker (classTagAllocator.h), not a member. ClassTagAllocator::resetForTest(); t->_search_started = false; t->_tags_released = true; @@ -70,11 +62,9 @@ class ReferenceChainsTestAccessor { t->_pending_expand.clear(); t->_priority_expand.clear(); t->_priority_expand_set.clear(); - // B' at-risk FIFO + its indexes/counters, and the round-15 fresh lane: production clears - // all of these on every restartSearch()/ resetSearchStateForTest(), but this seam predates - // the FIFO and was never given the clears - until round 16 a test left at-risk entries - // behind and only survived because LATER tests' own runPass()s drained the residue with the - // full Bfs mock. + // At-risk FIFO + its indexes/counters and the fresh lane: production clears these on + // every restartSearch()/resetSearchStateForTest(); left behind, at-risk entries leak + // into later tests. t->_static_anchor_fifo.clear(); t->_static_anchor_fifo_set.clear(); t->_static_anchor_fifo_klass_counts.clear(); @@ -117,19 +107,15 @@ class ReferenceChainsTestAccessor { t->_leak_parent_fanout.clear(); t->_borrowed_budget = 0; t->_consecutive_under_target_passes = 0; - // Adaptive batch + lane state: NOT covered by anything above, and a prior test that drove - // expansion leaves a non-zero EMA, a live batch size, a stale pass deadline, and/or a - // mid-alternation lane toggle behind - all of which silently change the next test's - // expandFrontier() arithmetic (exact-value asserts on batch sizing only pass standalone - // otherwise). + // Adaptive batch + lane state: a prior test that drove expansion leaves a non-zero EMA, + // batch size, pass deadline, or lane toggle behind, silently changing the next test's + // expandFrontier() arithmetic. t->_gotw_ema_call_ns = 0; t->_gotw_batch_size = 0; t->_pass_deadline_ns = 0; t->_expand_lane_prefer_priority = true; } - // Search restart + pain budget (SearchRestartTest below) - same rationale as the pacing - // accessors above: private state a test needs to drive/observe directly. static bool canAffordNewSearch(u64 now_ns) { return ReferenceChainTracker::instance()->canAffordNewSearch(now_ns); } @@ -174,9 +160,7 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->canaryStuckRestartCountForTest(); } - // OOM-urgency hysteresis (C5): drive isUrgent()'s latch/release machine - // and read its raw state - same friend-accessor rationale as the canary - // wrappers above. + // OOM-urgency hysteresis: isUrgent()'s latch/release machine and raw state. static bool isUrgentForTest() { return ReferenceChainTracker::instance()->isUrgentForTest(); } @@ -211,12 +195,17 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->_search_pain_ms; } - // Resolved-chain cache: read-only size peek and a pass-through to the private snapshot - // (drainPendingChainEvents()) and insert (cacheResolvedChain()), for ResolvedChainCacheTest - // below - same rationale as hasResolvedChainForTag()/resolvedChainCount() below. static size_t resolvedChainCount() { return ReferenceChainTracker::instance()->_resolved_chains.size(); } + // Holds/releases the private _resolved_chains_lock, so a test can prove a seam takes + // _engine_lock BEFORE _resolved_chains_lock (and never the reverse). + static void lockResolvedChainsForTest() { + ReferenceChainTracker::instance()->_resolved_chains_lock.lock(); + } + static void unlockResolvedChainsForTest() { + ReferenceChainTracker::instance()->_resolved_chains_lock.unlock(); + } // Source tags of every resolved chain - the fuzz target iterates these to // check for phantom/negative-tag chains (I2's sign discrimination). static std::vector resolvedChainSourceTags() { @@ -232,9 +221,11 @@ class ReferenceChainsTestAccessor { ReferenceChainTracker::instance()->drainPendingChainEvents(out); } - static void cacheChain(jlong source_tag, ReferenceChainEvent event, + // Returns cacheResolvedChain()'s own verdict so tests can assert a + // dead-owner chain is refused, not merely absent from the snapshot. + static bool cacheChain(jlong source_tag, ReferenceChainEvent event, jlong source_tag_val, u64 source_search_ns) { - ReferenceChainTracker::instance()->cacheResolvedChain( + return ReferenceChainTracker::instance()->cacheResolvedChain( source_tag, std::move(event), source_tag_val, source_search_ns); } @@ -279,9 +270,6 @@ class ReferenceChainsTestAccessor { ReferenceChainTracker::instance()->drainPendingChainInvalidations(); } - // Target-selection bridging step: read-only peeks into the resolved-chain cache, for asserting - // exactly which klass a chain was resolved for and the tag it was reconstructed from - see - // PollWatchedTargetsTest below. static bool hasResolvedChainForTag(jlong tag) { ReferenceChainTracker *t = ReferenceChainTracker::instance(); return t->_resolved_chains.find(tag) != t->_resolved_chains.end(); @@ -293,9 +281,7 @@ class ReferenceChainsTestAccessor { return it == t->_resolved_chains.end() ? 0 : it->second.source_tag; } - // Leak-tag correlation (design C): read a frontier entry's stored leak tag, and a pass-through - // to the private buildChainEvent(), for LeakTagInterceptionTest below - same friend-accessor - // rationale as hasResolvedChainForTag() above. + // Frontier entry's stored leak tag, or -1 when the entry does not exist. static jlong frontierLeakTag(jlong tag) { ReferenceChainTracker *t = ReferenceChainTracker::instance(); FrontierEntry entry{}; @@ -305,13 +291,22 @@ class ReferenceChainsTestAccessor { return entry.leak_tag; } + // Ownership-period stamp captured with the entry's leak tag (FrontierEntry:: + // leak_tag_incarnation), or 0 when the entry does not exist. + static u32 frontierLeakTagIncarnation(jlong tag) { + ReferenceChainTracker *t = ReferenceChainTracker::instance(); + FrontierEntry entry{}; + if (t->_frontier == nullptr || !t->_frontier->lookup(tag, &entry)) { + return 0; + } + return entry.leak_tag_incarnation; + } + static void setCandidateKlassIdForTest(int idx, u32 klass_id) { ReferenceChainTracker::instance()->setCandidateKlassIdForTest(idx, klass_id); } - // Round-19: the leak-tag canary found criterion (pod 289f8 — the chase was structurally - // unresolvable after the marker->leak-tag migration; see buildDiscoveredInstanceChains' own - // comment). + // Leak-tag canary found criterion - see buildDiscoveredInstanceChains(). static u64 candidateFoundBitsForTest() { return ReferenceChainTracker::instance()->_candidate_found_bits; } @@ -328,7 +323,7 @@ class ReferenceChainsTestAccessor { nullptr, nullptr, klass_id, current_search_ns); } - // ---- pod-in-a-jar system harness (design-pod-in-a-jar-harness) ---- + // ---- system harness ---- static u8 searchStateForTest() { return ReferenceChainTracker::instance()->_search_state; } @@ -442,8 +437,6 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->candidateDiscoveredCountForTest(slot); } - // recordDiscoveredInstance()/correlateAdmittedLeakTag() are the production paths for the - // leak-correlation tests below. static void recordDiscoveredInstanceForTest(u32 klass_id, jlong tag, bool leak_correlated) { ReferenceChainTracker::instance()->recordDiscoveredInstance( @@ -474,7 +467,6 @@ class ReferenceChainsTestAccessor { t->_static_anchor_index_tags.empty(); } - // Read back discovered-instance slots (frontier tags recorded by recordDiscoveredInstance). static jlong discoveredTagForTest(int slot, int idx) { return ReferenceChainTracker::instance() ->_candidate_discovered_tags[slot][idx]; @@ -535,12 +527,7 @@ class ReferenceChainsTestAccessor { ReferenceChainTracker::instance()->_search_start_ns = ns; } - // Pause-time pacing controller: read-only peeks at the controller's derived values, and a - // pass-through to the private updatePacing() itself, for ReferenceChainsPacingTest below - same - // rationale as hasResolvedChainForTag()/resolvedChainCount() above (the target-selection - // bridging step): private state a test needs to drive/ observe directly, exposed via this - // existing friend accessor rather than adding public getters/setters to ReferenceChainTracker - // itself. + // Pause-time pacing controller. static int effectiveBudget() { return ReferenceChainTracker::instance()->_effective_budget; } @@ -555,11 +542,8 @@ class ReferenceChainsTestAccessor { static u64 baselineCadenceNs() { return ReferenceChainTracker::PASS_CADENCE_NS; } - // Test-only seams for PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeiling below, which needs - // to start from a controlled below-ceiling/above- baseline point with a freshly reset - // controller (see that test's own comment for why chaining directly off a prior constant-input - // sequence would leave _pause_pid's integral state mid-recovery from that sequence's windup, - // muddying this method's per-step direction assertions with a transient the test is not about). + // Start the controller from a chosen below-ceiling/above-baseline point with a fresh + // _pause_pid, so a prior sequence's integral windup cannot leak into per-step assertions. static void setEffectiveBudget(int v) { ReferenceChainTracker::instance()->_effective_budget = v; } @@ -572,9 +556,7 @@ class ReferenceChainsTestAccessor { ReferenceChainTracker::instance()->_pause_pid.reset(); } - // Budget-borrowing (referenceChains.h's _borrowed_budget comment): the configured multiplier - // PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeiling below asserts convergence against, - // instead of hardcoding it a second time in the test itself. + // Budget-borrowing (see _borrowed_budget in referenceChains.h). static int borrowCeilingMultiplier() { return ReferenceChainTracker::BORROW_CEILING_MULTIPLIER; } @@ -583,9 +565,6 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->_borrowed_budget; } - // MaybeRevokeBorrowForRootEnumPass* tests below: drive the borrow state directly into "already - // granted" before exercising the revocation-only seam, and a pass-through to that seam itself - - // same rationale as updatePacing()'s own accessor above. static void setBorrowedBudget(int64_t v) { ReferenceChainTracker::instance()->_borrowed_budget = v; } @@ -603,8 +582,6 @@ class ReferenceChainsTestAccessor { pass_wall_ns); } - // ReleaseSearchTagsFailureTest below: read-only peek at whether the tracker still owes a tag - // release before it can allow a restart - see _tags_released's own comment. static bool tagsReleased() { return ReferenceChainTracker::instance()->_tags_released; } @@ -618,11 +595,9 @@ class ReferenceChainsTestAccessor { v, std::memory_order_relaxed); } - // LifecycleChurn test: read side of the flag above - lets the pass thread - // record that a runPass() overlapped a stopThread-style abort request - // (the request is held until the pass boundary, so a post-pass read is - // conservative: it can only over-count by never, under-count by a pulse - // whose set raced the pass's very last callback batch). + // LifecycleChurn test: read side of the flag above. The request is held + // until the pass boundary, so a post-pass read can only miss a pulse whose + // set raced the pass's last callback batch. static bool abortPassRequested() { return ReferenceChainTracker::instance()->_abort_pass_requested.load( std::memory_order_relaxed); @@ -637,11 +612,6 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->_leak_tags_resolved; } - // ResolveLoadedClassesRescansAfterClassCountShrinksAndPartiallyRegrows below: direct - // pass-through to the private resolveLoadedClasses(), plus a read-only peek at the count it - // stashes - the same rationale as tagsReleased() above (private state/behavior a test needs to - // drive/observe directly, without going through a full runPass()/search lifecycle that - // resolveLoadedClasses() alone does not need). static void resolveLoadedClasses(jvmtiEnv *jvmti, JNIEnv *jni) { ReferenceChainTracker::instance()->resolveLoadedClasses(jvmti, jni); } @@ -650,12 +620,9 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->_last_resolved_class_count; } - // Durability re-verification test seams: direct pass-throughs to the private tie-break/rotation - // methods, plus FrontierTable::insert() itself (also private-by-convention here in the sense - // that production code only ever calls it via admitObject()) so tests can set up a frontier - // entry's exact starting root_kind/state/parent_tag without needing a live JVMTI mock for - // IterateOverReachableObjects/FollowReferences (neither is mocked in this file - see the file - // header's FollowReferences- only mock rationale). + // Durability re-verification seams: insertFrontierEntry() sets up an entry's exact + // root_kind/state/parent_tag without a JVMTI heap-walk mock (production only inserts via + // admitObject()). static bool insertFrontierEntry(FrontierTable *frontier, jlong tag, jlong parent_tag, u32 depth, u8 state, @@ -733,8 +700,7 @@ class ReferenceChainsTestAccessor { : (u8)ReferenceChainTracker::AnchorClassShape::NON_CONTAINER; } - // B' at-risk static-anchor FIFO (see _static_anchor_fifo's declaration comment in - // referenceChains.h). + // At-risk static-anchor FIFO (see _static_anchor_fifo in referenceChains.h). using AtRiskAnchor = ReferenceChainTracker::AtRiskAnchor; static constexpr u32 kAtRiskPerKlassCap = ReferenceChainTracker::STATIC_ANCHOR_ATRISK_PER_KLASS_CAP; @@ -833,21 +799,16 @@ class ReferenceChainsTestAccessor { t->_priority_expand.end()); } - // StaleExpandedRotationSkipsPreexistingQueueEntries below: simulates a tag left in - // _priority_expand by a prior pass's truncated expandFrontier() batch (expandFrontier()'s own - // "leave the batch at the front of the source queue for a later pass to retry" comment) without - // driving a full expandFrontier()/JVMTI round-trip to produce one. + // Simulates a tag left in _priority_expand by a prior pass's truncated expandFrontier() + // batch without a full expandFrontier()/JVMTI round-trip. static void pushPriorityExpand(jlong tag) { ReferenceChainTracker *t = ReferenceChainTracker::instance(); t->_priority_expand.push_back(tag); t->_priority_expand_set.insert(tag); } - // Simulates expandFrontier() having fully drained _priority_expand at the end of a pass (the - // common case: rotation's whole selection fit within that pass's rotation_budget slice) - see - // StaleExpandedRotationStarvesHighTagEntryBehindLowTagPopulation below, which needs this to - // model collectStaleExpandedEntriesForRotation() being called fresh on each of several - // simulated passes, the way runPassManualWalk() actually does it once per real pass. + // Simulates expandFrontier() having fully drained _priority_expand at the end of a pass, so + // collectStaleExpandedEntriesForRotation() can be called fresh per simulated pass. static void clearPriorityExpand() { ReferenceChainTracker *t = ReferenceChainTracker::instance(); t->_priority_expand.clear(); @@ -947,8 +908,6 @@ class ReferenceChainsTestAccessor { return ReferenceChainTracker::instance()->_expand_lane_prefer_priority; } - // Leak-tag pool range base (private static) - same friend-access rationale as the AIMD - // constants above. static jlong leakTagBase() { return ReferenceChainTracker::LEAK_TAG_BASE; } diff --git a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc index 3fdecb8a36..44f68b6e8b 100644 --- a/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc +++ b/ddprof-lib/src/test/cpp/referenceChainsTrackerTests.inc @@ -64,17 +64,15 @@ protected: if (active_fixture->dead_refs.count(ref) > 0) { return nullptr; } - return ref; // identity passthrough - see this fixture's own comment + return ref; // identity passthrough: fake jobjects are not real JNI refs } static void JNICALL mock_DeleteLocalRef(JNIEnv *, jobject) { // no-op: this fixture's fake jobject values are not real JNI refs. } - // pollWatchedTargets()'s diagnostic class-name lookup on the candidate's representative: this - // fixture's fake jobjects carry no real class identity, so a fixed non-null jclass plus a fixed - // signature is all GetObjectClass()/GetClassSignature() need to return for that lookup to - // complete without touching a real JVM. + // Fake jobjects carry no class identity; a fixed jclass and signature let + // pollWatchedTargets()'s diagnostic class-name lookup complete. static jclass JNICALL mock_GetObjectClass(JNIEnv *, jobject) { return (jclass)0xC1A55; } @@ -94,11 +92,9 @@ protected: return JVMTI_ERROR_NONE; } - // Seeds LivenessTracker's real population table with a growing series for `klass_id` (20 - // strictly-increasing samples - satisfies selectLeakCandidates()'s min-fill, growth/floor - // magnitude, and sustained-trend hysteresis requirements, livenessTracker.h; 20 rather than the - // 10-sample minimum fill leaves comfortable margin past the hysteresis threshold rather than - // sitting exactly on its boundary) and points its representative at `rep`. + // Seeds a growing population for `klass_id` with representative `rep`. 20 strictly-increasing + // samples clear selectLeakCandidates()'s min-fill and hysteresis gates with margin rather than + // sitting on the 10-sample boundary. void seedGrowingCandidate(u32 klass_id, jweak rep) { int slot; bool created; @@ -179,8 +175,8 @@ TEST_F(PollWatchedTargetsTest, NoDuplicateOnRepeatPoll) { tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); ASSERT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); - // Klass 1 is still flagged (LivenessTracker's ranking doesn't know an event was already emitted - // for it) - a second, third, ... + // Klass 1 stays flagged (the ranking doesn't know a chain was emitted); repeat polls must not + // add duplicates. tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); tracker->pollWatchedTargets(&mock_jvmti, &mock_jni); EXPECT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); @@ -212,12 +208,6 @@ TEST_F(PollWatchedTargetsTest, SkipsCandidateWhoseWeakReferenceDied) { tracker->stop(); } -// A cached chain must not re-emit forever: once the klass's representative stops resolving -// (collected, or LRU-evicted from LivenessTracker's population table - -// klassPopulationSetRepresentativeForTest()'s ref is the stand-in for either), the very next poll -// must prune it from _resolved_chains rather than leaving a dump keep re-emitting a chain for a -// sample that is gone (see _resolved_chains' own comment, referenceChains.h, and -// pollWatchedTargets()'s "candidate died, or was evicted" branch). TEST_F(PollWatchedTargetsTest, ChainPersistsAfterRepresentativeDies) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); @@ -251,12 +241,9 @@ TEST_F(PollWatchedTargetsTest, ChainPersistsAfterRepresentativeDies) { tracker->stop(); } -// Recording-boundary hygiene (start()'s comment, referenceChains.cpp): a new recording must not -// inherit the previous one's cached resolved chains or queued abandoned events - both would -// re-emit into the new recording's JFR chunks with StringDictionary ids from the wiped class -// dictionary generation. The test seeds both states, restarts through the real stop()/start() -// path (NOT resetSearchStateForTest(), which is an out-of-band reset that also clears this state -// itself), and asserts both are gone while the resolve path still works in the new recording. +// A new recording must not inherit the previous one's cached chains or queued abandoned events: +// both would carry StringDictionary ids from the wiped class dictionary. Restarts through the real +// stop()/start() path, not resetSearchStateForTest(), which would clear this state itself. TEST_F(PollWatchedTargetsTest, RestartDropsPriorRecordingsChainsAndAbandonedEvents) { Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); @@ -448,9 +435,8 @@ TEST_F(PollWatchedTargetsTest, NoOpWhenGcGenerationsDisabled) { ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); ASSERT_FALSE(tracker->start(args)); - // Overrides this fixture's own SetUp() default - exercises the pollWatchedTargets() guard - // covering LivenessTracker's own _gc_generations gate (population tracking's own gate), not - // just this tracker's own _enabled. + // Overrides SetUp(): exercises pollWatchedTargets()'s LivenessTracker _gc_generations guard, + // not just the tracker's own _enabled. LivenessTracker::instance()->setGcGenerationsForTest(false); int fake_object_storage = 0; @@ -467,16 +453,36 @@ TEST_F(PollWatchedTargetsTest, NoOpWhenGcGenerationsDisabled) { tracker->stop(); } -// Resolved-chain cache (ReferenceChainTracker::cacheResolvedChain()/ - class ResolvedChainCacheTest : public ::testing::Test { protected: void SetUp() override { ReferenceChainsTestAccessor::reset(); + // Leak-tag ownership-period tests below drive the shared pool; keep its state isolated. + LivenessTracker::instance()->leakTagPoolResetForTest(); } void TearDown() override { ReferenceChainsTestAccessor::reset(); + LivenessTracker::instance()->leakTagPoolResetForTest(); + } + + // A cached chain targeting leak tag `leak_tag`, stamped with the ownership period `stamp` + // (what buildChainEvent() copies from the frontier entry). + static ReferenceChainEvent makeLeakEvent(jlong leak_tag, u32 stamp) { + ReferenceChainEvent event = makeEvent((u64)leak_tag); + event._leak_tag_incarnation = stamp; + return event; + } + + static bool drainContainsTarget(u64 target_tag) { + std::vector out; + ReferenceChainsTestAccessor::drain(&out); + for (const auto &e : out) { + if (e._target_tag == target_tag) { + return true; + } + } + return false; } static ReferenceChainEvent makeEvent(u64 target_tag) { @@ -618,10 +624,9 @@ TEST_F(ResolvedChainCacheTest, RestartDropsPendingTagsBeforeNumericTagReuse) { EXPECT_EQ(101u, dump[0]._target_tag); } -// A brand-new klass arriving with the cache already at MAX_RESOLVED_CHAINS is dropped (and counted -// via REFERENCE_CHAIN_EVENTS_DROPPED, this codebase's own "dropped-event-without-counter" review -// lens) rather than evicting some other still-live sample's chain - but refreshing a klass that is -// already cached still succeeds even at capacity. +// At MAX_RESOLVED_CHAINS a brand-new klass is dropped and counted in +// REFERENCE_CHAIN_EVENTS_DROPPED rather than evicting a live sample's chain; refreshing an +// already-cached klass still succeeds. TEST_F(ResolvedChainCacheTest, OverflowDropsNewKlassButAllowsRefresh) { const int cap = ReferenceChainsTestAccessor::maxResolvedChains(); long long droppedBefore = Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED); @@ -642,8 +647,7 @@ TEST_F(ResolvedChainCacheTest, OverflowDropsNewKlassButAllowsRefresh) { EXPECT_EQ(droppedBefore + 1, Counters::getCounter(REFERENCE_CHAIN_EVENTS_DROPPED)); EXPECT_FALSE(ReferenceChainsTestAccessor::hasResolvedChainForTag((u32)cap)); - // Refreshing an already-cached klass at capacity must still succeed - it reuses that klass's - // existing slot rather than needing a free one. + // A refresh reuses the klass's existing slot. ReferenceChainsTestAccessor::cacheChain(/*source_tag=*/0, makeEvent(999), /*source_tag=*/999, 0); EXPECT_EQ((size_t)cap, ReferenceChainsTestAccessor::resolvedChainCount()); @@ -676,6 +680,294 @@ TEST_F(ResolvedChainCacheTest, FullCacheEvictsPreviousGenerationForCurrentChain) EXPECT_EQ((u64)kNewTag, dump[0]._target_tag); } +// Test-seam locking (frontierStateForTest()/resolvedChainTargetTagForTest()): both seams are +// called from a Java test thread while the BFS thread mutates the frontier / search generation +// under _engine_lock, so they must serialize on that same lock. _engine_lock is recursive, so the +// blocking is only observable from a second thread. +namespace { +// Polls `done` for up to `timeout_ms`; true once it is set. +bool waitForSeamThread(const std::atomic &done, int timeout_ms) { + for (int waited = 0; waited < timeout_ms; waited += 10) { + if (done.load()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return done.load(); +} +} // namespace + +// Every value the frontier-state seam can return, and every -1 sentinel path. +TEST_F(ReferenceChainsBfsTest, FrontierStateSeamReportsStatesAndSentinels) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + ASSERT_NE(nullptr, frontier); + + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 1, 0, 0, FrontierEntryState::FRONTIER, 0)); + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 2, 0, 0, FrontierEntryState::EXPANDED, 0)); + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 3, 0, 0, FrontierEntryState::ABANDONED, 0)); + + EXPECT_EQ(0, tracker->frontierStateForTest(1)); + EXPECT_EQ(1, tracker->frontierStateForTest(2)); + EXPECT_EQ(3, tracker->frontierStateForTest(3)); + + EXPECT_EQ(-1, tracker->frontierStateForTest(0)) << "untagged"; + EXPECT_EQ(-1, tracker->frontierStateForTest(-7)) << "negative tag"; + EXPECT_EQ(-1, tracker->frontierStateForTest(999)) << "no frontier entry"; + EXPECT_EQ(-1, tracker->frontierStateForTest(ReferenceChainTracker::LEAK_TAG_BASE)) + << "leak tag"; + EXPECT_EQ(-1, tracker->frontierStateForTest(ReferenceChainTracker::LEAK_TAG_BASE + 5)) + << "leak tag"; + + tracker->stop(); + // No frontier table at all: -1, not a crash. + ReferenceChainsTestAccessor::reset(); + EXPECT_EQ(-1, tracker->frontierStateForTest(1)); +} + +// frontierStateForTest() must wait for a holder of _engine_lock (a running pass) and report the +// state that holder left behind - while the pure tag guard stays a non-blocking fast path. +TEST_F(ReferenceChainsBfsTest, FrontierStateSeamSerializesWithEngineLock) { + Arguments args; + ASSERT_FALSE(args.parse("referencechains=true:hops=64:budget=1000")); + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ASSERT_FALSE(tracker->start(args)); + FrontierTable *frontier = tracker->frontierTable(); + ASSERT_NE(nullptr, frontier); + ASSERT_TRUE(ReferenceChainsTestAccessor::insertFrontierEntry( + frontier, 1, 0, 0, FrontierEntryState::FRONTIER, 0)); + + tracker->_engine_lock.lock(); + std::atomic done{false}; + std::atomic result{-2}; + std::thread seam([&] { + result.store(tracker->frontierStateForTest(1)); + done.store(true); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_FALSE(done.load()) << "seam must block while _engine_lock is held"; + + // Fast path: the tag guard never touches engine state, so it must not block. + std::atomic fast_done{false}; + std::atomic fast_result{-2}; + std::thread fast([&] { + fast_result.store(tracker->frontierStateForTest(0)); + fast_done.store(true); + }); + EXPECT_TRUE(waitForSeamThread(fast_done, 2000)) << "tag guard must not take _engine_lock"; + + // Simulates a pass expanding the entry while it holds _engine_lock. + frontier->markExpanded(1); + tracker->_engine_lock.unlock(); + + EXPECT_TRUE(waitForSeamThread(done, 10000)); + seam.join(); + fast.join(); + EXPECT_EQ(1, result.load()); + EXPECT_EQ(-1, fast_result.load()); + + tracker->stop(); +} + +// The search-generation guard of resolvedChainTargetTagForTest(): every 0 path and the match. +TEST_F(ResolvedChainCacheTest, ResolvedTargetTagSeamChecksGeneration) { + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeEvent(77), 5, 1000); + EXPECT_EQ(77, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(5)); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(6)) + << "no cached chain"; + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(0)); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(-1)); + + const jlong leakTag = ReferenceChainTracker::LEAK_TAG_BASE; + ReferenceChainsTestAccessor::cacheChain(leakTag, makeEvent(88), leakTag, 1000); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(leakTag)) + << "leak tags are never resolved-chain keys for this seam"; + + ReferenceChainsTestAccessor::setSearchStartNsForTest(2000); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(5)) + << "chain cached by an earlier search generation"; + + ReferenceChainsTestAccessor::setSearchStartNsForTest(0); + ReferenceChainsTestAccessor::cacheChain(7, makeEvent(99), 7, 0); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(7)) + << "no search in flight: 0 even though source_search_ns == 0 matches"; +} + +// A generation change made under _engine_lock (restartSearch()/runPass()) while the seam waits must +// be observed: the seam samples _search_start_ns only once it holds _engine_lock. +TEST_F(ResolvedChainCacheTest, ResolvedTargetTagSeamSamplesGenerationUnderEngineLock) { + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeEvent(77), 5, 1000); + + tracker->_engine_lock.lock(); + std::atomic done{false}; + std::atomic result{-2}; + std::thread seam([&] { + result.store(tracker->resolvedChainTargetTagForTest(5)); + done.store(true); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_FALSE(done.load()) << "seam must block while _engine_lock is held"; + ReferenceChainsTestAccessor::setSearchStartNsForTest(2000); + tracker->_engine_lock.unlock(); + + EXPECT_TRUE(waitForSeamThread(done, 10000)); + seam.join(); + EXPECT_EQ(0, result.load()) << "stale-generation chain returned after a concurrent restart"; +} + +// Lock order _engine_lock -> _resolved_chains_lock: with only _resolved_chains_lock held, the seam +// takes _engine_lock, waits on the cache lock, and completes once it is released. +TEST_F(ResolvedChainCacheTest, ResolvedTargetTagSeamLockOrder) { + ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeEvent(77), 5, 1000); + + ReferenceChainsTestAccessor::lockResolvedChainsForTest(); + std::atomic done{false}; + std::atomic result{-2}; + std::thread seam([&] { + result.store(tracker->resolvedChainTargetTagForTest(5)); + done.store(true); + }); + std::this_thread::sleep_for(std::chrono::milliseconds(200)); + EXPECT_FALSE(done.load()) << "seam must wait on _resolved_chains_lock"; + ReferenceChainsTestAccessor::unlockResolvedChainsForTest(); + + EXPECT_TRUE(waitForSeamThread(done, 10000)); + seam.join(); + EXPECT_EQ(77, result.load()); +} + +// Leak-tag identity is (tag, ownership-period stamp): a chain cached during the tag's current +// ownership is answered by the seam and emitted by the dump. +TEST_F(ResolvedChainCacheTest, LeakTagChainOfCurrentOwnerIsResolvedAndEmitted) { + LivenessTracker *lt = LivenessTracker::instance(); + jlong leak = lt->acquireLeakTagForTest(); + ASSERT_TRUE(ReferenceChainTracker::isLeakTag(leak)); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeLeakEvent(leak, lt->leakTagIncarnation(leak)), + 5, 1000); + EXPECT_EQ(leak, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(leak)); + EXPECT_TRUE(drainContainsTarget((u64)leak)); +} + +// ABA: the chain's object dies (tag released), and the tag is recycled for a new +// object. The old chain must not be attributed to the new owner by the seam nor emitted into the +// dump, and the dump evicts it (ownership stamps only advance, so it can never become valid). +TEST_F(ResolvedChainCacheTest, LeakTagChainOfDeadOwnerIsNeitherResolvedNorEmitted) { + LivenessTracker *lt = LivenessTracker::instance(); + jlong leak = lt->acquireLeakTagForTest(); + ASSERT_TRUE(ReferenceChainTracker::isLeakTag(leak)); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeLeakEvent(leak, lt->leakTagIncarnation(leak)), + 5, 1000); + ASSERT_EQ(1u, ReferenceChainsTestAccessor::resolvedChainCount()); + + // Owner dies: released, not yet re-acquired. + lt->releaseLeakTagForTest(leak); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(leak)); + EXPECT_FALSE(drainContainsTarget((u64)leak)); + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()) + << "the dump must evict the dead owner's chain"; + + // A new object receives the recycled tag (LIFO). + jlong reused = lt->acquireLeakTagForTest(); + ASSERT_EQ(leak, reused); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(reused)) + << "the dead owner's chain was attributed to the tag's new owner"; + EXPECT_FALSE(drainContainsTarget((u64)reused)); + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); + + // Once the new owner's own chain is cached, it is the one that matches. + ReferenceChainsTestAccessor::cacheChain( + 6, makeLeakEvent(reused, lt->leakTagIncarnation(reused)), 6, 1000); + EXPECT_EQ(reused, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(reused)); + std::vector out; + ReferenceChainsTestAccessor::drain(&out); + ASSERT_EQ(1u, out.size()) << "only the current owner's chain is emitted"; + EXPECT_EQ(lt->leakTagIncarnation(reused), out[0]._leak_tag_incarnation); +} + +// A chain built from a binding whose leak-tag owner already died is refused by the cache: it +// would only occupy a MAX_RESOLVED_CHAINS slot until the next dump evicts it. Recycling the tag +// for a new owner must not revive the dead owner's chain either - the new ownership period's +// stamp does not match the stale one the chain carries. +TEST_F(ResolvedChainCacheTest, DeadOwnerChainIsNotCached) { + LivenessTracker *lt = LivenessTracker::instance(); + jlong leak = lt->acquireLeakTagForTest(); + ASSERT_TRUE(ReferenceChainTracker::isLeakTag(leak)); + u32 stale_stamp = lt->leakTagIncarnation(leak); + lt->releaseLeakTagForTest(leak); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + EXPECT_FALSE(ReferenceChainsTestAccessor::cacheChain( + 5, makeLeakEvent(leak, stale_stamp), 5, 1000)) + << "a chain whose leak-tag owner already died must be refused by the cache"; + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); + EXPECT_FALSE(drainContainsTarget((u64)leak)); + + // The recycled tag's new owner must not inherit the dead owner's stamp. + jlong reused = lt->acquireLeakTagForTest(); + ASSERT_EQ(leak, reused); + EXPECT_FALSE(ReferenceChainsTestAccessor::cacheChain( + 5, makeLeakEvent(reused, stale_stamp), 5, 1000)) + << "a chain carrying the dead owner's stamp must be refused for the new owner too"; + EXPECT_EQ(0u, ReferenceChainsTestAccessor::resolvedChainCount()); +} + +// Only the current search's cached chain counts: after a restart moved _search_start_ns, a +// frontier chain cached by the superseded search must not answer the seam (the +// source_search_ns != current gate), and with no active search (start ns 0) nothing answers. +TEST_F(ResolvedChainCacheTest, FrontierChainFromSupersededSearchIsRejected) { + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ASSERT_TRUE(ReferenceChainsTestAccessor::cacheChain(5, makeEvent(77), 5, 1000)); + EXPECT_EQ(77, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(5)); + + // Restart: the cached chain belongs to the superseded search. + ReferenceChainsTestAccessor::setSearchStartNsForTest(2000); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(5)) + << "a frontier chain cached by an earlier search must not answer the seam"; + + // No active search: the gate stays closed even though a chain is cached. + ReferenceChainsTestAccessor::setSearchStartNsForTest(0); + EXPECT_EQ(0, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(5)); +} + +// Within one ownership period the leak-tag path is generation-agnostic: a chain an earlier search +// cached for the same owner still answers after a restart. +TEST_F(ResolvedChainCacheTest, LeakTagChainMatchesAcrossGenerationsForSameOwner) { + LivenessTracker *lt = LivenessTracker::instance(); + jlong leak = lt->acquireLeakTagForTest(); + ASSERT_TRUE(ReferenceChainTracker::isLeakTag(leak)); + ReferenceChainsTestAccessor::setSearchStartNsForTest(1000); + ReferenceChainsTestAccessor::cacheChain(5, makeLeakEvent(leak, lt->leakTagIncarnation(leak)), + 5, 1000); + ReferenceChainsTestAccessor::setSearchStartNsForTest(2000); + EXPECT_EQ(leak, ReferenceChainTracker::instance()->resolvedChainTargetTagForTest(leak)); + EXPECT_TRUE(drainContainsTarget((u64)leak)); +} + +// Chains whose target is a frontier tag (no leak tag) are not subject to the ownership check: +// emitted regardless of what the leak-tag pool does. +TEST_F(ResolvedChainCacheTest, FrontierTargetChainsAreEmittedRegardlessOfPoolState) { + LivenessTracker *lt = LivenessTracker::instance(); + ReferenceChainsTestAccessor::cacheChain(1, makeEvent(7), 7, 0); + EXPECT_TRUE(drainContainsTarget(7)); + jlong leak = lt->acquireLeakTagForTest(); + ASSERT_TRUE(ReferenceChainTracker::isLeakTag(leak)); + lt->releaseLeakTagForTest(leak); + lt->leakTagPoolResetForTest(); + EXPECT_TRUE(drainContainsTarget(7)); +} + // Pause-time pacing controller: pause-time-SLO feedback loop TEST_F(ReferenceChainsTest, PacingHoldsSteadyWhenPassesLandExactlyOnCeiling) { @@ -688,9 +980,7 @@ TEST_F(ReferenceChainsTest, PacingHoldsSteadyWhenPassesLandExactlyOnCeiling) { u64 startCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); ASSERT_EQ(4000, startBudget); // starts pinned at the configured ceiling - // A pass landing exactly on the pause-time target is a zero error every call - the controller - // should never move away from its starting point, regardless of how many such passes are - // observed in a row. + // Passes landing exactly on the target are zero error: the controller must never move. for (int i = 0; i < 10; i++) { ReferenceChainsTestAccessor::updatePacing(5 * 1000000ULL); // 5ms EXPECT_EQ(startBudget, ReferenceChainsTestAccessor::effectiveBudget()); @@ -709,8 +999,7 @@ TEST_F(ReferenceChainsTest, PacingShrinksBudgetAndWidensCadenceWhenOverCeiling) int initialBudget = ReferenceChainsTestAccessor::effectiveBudget(); u64 initialCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); - // A pass taking 10x the pause-time ceiling, fed repeatedly (a constant input - the plan's own - // "does not oscillate indefinitely" scenario). + // Constant input at 10x the pause-time ceiling: must converge, not oscillate. int lastBudget = initialBudget; u64 lastCadence = initialCadence; for (int i = 0; i < 20; i++) { @@ -726,8 +1015,7 @@ TEST_F(ReferenceChainsTest, PacingShrinksBudgetAndWidensCadenceWhenOverCeiling) // Moved in the correct direction... EXPECT_LT(lastBudget, initialBudget); EXPECT_GT(lastCadence, initialCadence); - // ...and converged to a fixed point rather than oscillating: one more identical input produces - // no further change. + // ...and converged: one more identical input produces no further change. ReferenceChainsTestAccessor::updatePacing(50 * 1000000ULL); EXPECT_EQ(lastBudget, ReferenceChainsTestAccessor::effectiveBudget()); EXPECT_EQ(lastCadence, ReferenceChainsTestAccessor::effectiveCadenceNs()); @@ -741,13 +1029,9 @@ TEST_F(ReferenceChainsTest, PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeili ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); ASSERT_FALSE(tracker->start(args)); - // Start from a controlled below-ceiling/above-baseline point (as if an earlier over-ceiling run - // had already shrunk/widened them - see the previous test) with a freshly reset controller, - // rather than chaining directly off a constant-input sequence like the previous test's own: - // _pause_pid's integral state would otherwise still be recovering from that sequence's windup - // for many iterations after switching to a smaller-magnitude error, muddying this test's - // per-step "moves in the correct direction every step" assertions with a transient this test is - // not about. + // Start from a shrunk budget / widened cadence with a freshly reset controller: chaining off an + // over-ceiling sequence would leave _pause_pid's integral windup breaking the per-step + // direction assertions below. ReferenceChainsTestAccessor::setEffectiveBudget(2400); ReferenceChainsTestAccessor::setEffectiveCadenceNs( 2 * ReferenceChainsTestAccessor::baselineCadenceNs()); @@ -755,8 +1039,6 @@ TEST_F(ReferenceChainsTest, PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeili int shrunkBudget = ReferenceChainsTestAccessor::effectiveBudget(); u64 widenedCadence = ReferenceChainsTestAccessor::effectiveCadenceNs(); - // Now feed passes comfortably under the ceiling, repeatedly (a constant input, to check - // convergence rather than oscillation). int lastBudget = shrunkBudget; u64 lastCadence = widenedCadence; for (int i = 0; i < 200; i++) { @@ -769,10 +1051,8 @@ TEST_F(ReferenceChainsTest, PacingGrowsBudgetBackAndRelaxesCadenceWhenUnderCeili lastCadence = cadence; } - // Moved in the correct direction... and, since 50 identical comfortably-under-target passes is - // well past BORROW_WARMUP_PASSES, past the configured ceiling too - budget-borrowing lets it - // converge at the borrowed ceiling (configured budget * multiplier) instead of stalling at the - // plain configured budget. + // Moved in the correct direction, and past the configured ceiling: 200 under-target passes is + // well past BORROW_WARMUP_PASSES, so it converges at the borrowed ceiling (budget * multiplier). EXPECT_GT(lastBudget, shrunkBudget); EXPECT_EQ(4000 * ReferenceChainsTestAccessor::borrowCeilingMultiplier(), lastBudget); EXPECT_LT(lastCadence, widenedCadence); @@ -814,9 +1094,8 @@ TEST_F(ReferenceChainsTest, MaybeRevokeBorrowForRootEnumPassRevokesJustPastBound ReferenceChainsTestAccessor::setConsecutiveUnderTargetPasses(5); ReferenceChainsTestAccessor::setEffectiveBudget(1500); // as if borrow had raised the ceiling - // Just past the boundary: no longer comfortably under target, so the grant is revoked - // immediately, including re-clamping _effective_budget down to the plain (non-borrowed) budget - // rather than leaving it borrow-inflated until the next ordinary pass's updatePacing() call. + // Just past the boundary: the grant is revoked and _effective_budget re-clamped immediately, + // not left borrow-inflated until the next updatePacing(). ReferenceChainsTestAccessor::maybeRevokeBorrowForRootEnumPass(6 * 1000000ULL); EXPECT_EQ(0, ReferenceChainsTestAccessor::borrowedBudget()); EXPECT_EQ(0, ReferenceChainsTestAccessor::consecutiveUnderTargetPasses()); @@ -836,7 +1115,6 @@ TEST(PainBudgetTest, SpendCreatesDebtThatBlocksAnImmediateSecondCall) { PainBudget budget(0.01); // 1% ASSERT_TRUE(budget.canStartNow(1000)); // establishes the drain baseline budget.spend(100); // 100ms of debt - // No time has elapsed since the baseline call above - the debt cannot have drained at all yet. EXPECT_FALSE(budget.canStartNow(1000)); } @@ -852,12 +1130,9 @@ TEST(PainBudgetTest, ZeroRefillRateNeverClearsDebt) { PainBudget budget(0.0); ASSERT_TRUE(budget.canStartNow(0)); budget.spend(1); - // An enormous elapsed time still drains nothing at a 0 refill rate. EXPECT_FALSE(budget.canStartNow(1000000000000ULL)); } -// Search restart (referenceChains.h's own header comment: gating a - class SearchRestartTest : public ::testing::Test { protected: jvmtiInterface_1_ jvmti_tbl{}; @@ -887,10 +1162,8 @@ protected: VMTestAccessor::setJvmti(orig_jvmti); LivenessTracker::instance()->klassPopulationResetForTest(); LivenessTracker::instance()->setGcGenerationsForTest(false); - // UrgentOOMProjectionBypassesCandidateGate below sets this - reset it here (TearDown always - // runs, even after a fatal ASSERT_* return) rather than as a trailing statement in that - // test body, so a failed assertion can't leak a stale max-heap value into the next test - // sharing this singleton. + // 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); } @@ -902,8 +1175,8 @@ protected: return JVMTI_ERROR_NONE; } - // ReferenceChainTracker::start() -> autoTuneDefaults() queries this whenever LivenessTracker - // reports a max heap > 0 - which UrgentOOMProjectionBypassesCandidateGate below sets. + // start() -> autoTuneDefaults() queries this when max heap > 0 + // (UrgentOOMProjectionBypassesCandidateGate). static jvmtiError JNICALL mock_GetAvailableProcessors(jvmtiEnv *, jint *nprocs) { *nprocs = 1; @@ -918,18 +1191,14 @@ protected: return JVMTI_ERROR_NONE; } - // runPassManualWalk()'s root enumeration - never invokes the root callback, same "nothing - // reachable from any root" heap model as mock_FollowReferences() above, so the first pass still - // completes immediately with 0 admitted edges. + // runPassManualWalk()'s root enumeration; same empty-heap model as mock_FollowReferences(). static jvmtiError JNICALL mock_IterateOverReachableObjects( jvmtiEnv *, jvmtiHeapRootCallback, jvmtiStackReferenceCallback, jvmtiObjectReferenceCallback, const void *) { return JVMTI_ERROR_NONE; } - // Same seeding helper as PollWatchedTargetsTest above (20 strictly- increasing samples - - // satisfies selectLeakCandidates()'s min-fill, growth/floor magnitude, and sustained-trend - // hysteresis requirements). + // Same as PollWatchedTargetsTest::seedGrowingCandidate(). void seedGrowingCandidate(u32 klass_id, jweak rep) { int slot; bool created; @@ -946,10 +1215,8 @@ protected: }; TEST_F(SearchRestartTest, WithoutGenerationsSignalRestartStaysUnconditional) { - // gc_generations off (this fixture's SetUp default): canAffordNewSearch() has no candidate - // signal to gate on at all, so a terminal search is immediately eligible to restart - preserves - // this tracker's pre-restart behavior for a referencechains-without-generations setup (this - // class's own header comment, last paragraph). + // gc_generations off (SetUp default): canAffordNewSearch() has no candidate signal, so a + // terminal search is immediately eligible to restart. Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); ReferenceChainTracker *tracker = ReferenceChainTracker::instance(); @@ -965,9 +1232,8 @@ TEST_F(SearchRestartTest, WithoutGenerationsSignalRestartStaysUnconditional) { } TEST_F(SearchRestartTest, GenerationsEnabledButNoCandidateBlocksFirstSearch) { - // A brand-new tracker must not pay for the initial whole-heap walk/tagging pass either when - // there is no leak candidate yet - shouldRunPass()'s !_search_started branch now shares - // canAffordNewSearch() with the restart gate below (this class's own header comment). + // No leak candidate yet: even the first whole-heap walk is skipped, because + // shouldRunPass()'s !_search_started branch shares canAffordNewSearch() with the restart gate. LivenessTracker::instance()->setGcGenerationsForTest(true); Arguments args; ASSERT_FALSE(args.parse("referencechains=true")); @@ -1021,8 +1287,7 @@ TEST_F(SearchRestartTest, RestartsOnceACandidateAppearsAndResetsPerSearchState) EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); EXPECT_EQ(0, tracker->passesRun()); // restartSearch() zeroed per-search state - // The next runPass() call takes the "first pass of a search" branch again, exactly like a - // brand-new tracker. + // The next runPass() takes the first-pass branch again. ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); EXPECT_EQ(1, tracker->passesRun()); @@ -1040,24 +1305,18 @@ TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { int fake_object_storage = 0; seedGrowingCandidate(/*source_tag=*/1, /*rep=*/(jweak)&fake_object_storage); - // First-ever search: called via runPass() directly here, bypassing shouldRunPass()'s - // canAffordNewSearch() gate entirely - the candidate seeded above would satisfy that gate - // anyway (this class's own header comment). + // First-ever search via runPass() directly; the seeded candidate would pass the gate anyway. ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); - // Restart #1: _safepoint_pain_budget has never had anything spent into it yet, so this is - // always immediately affordable regardless of this first search's own cost - the cost a search - // incurs only debits the *next* restart's affordability (restartSearch()'s own spend-then-reset - // order), not its own. + // Restart #1: nothing has been spent into _safepoint_pain_budget yet, so it is immediately + // affordable. ASSERT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1)); ASSERT_EQ(SearchState::RUNNING, tracker->searchState()); ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); ASSERT_EQ(SearchState::COMPLETED, tracker->searchState()); - // Pretend this second search cost 1000ms of safepoint time - a mocked FollowReferences call in - // this fixture takes ~0 real wall-clock time, so this accessor stands in for what a real, - // expensive pass would have accumulated into _search_pain_ms on its own. + // The mocked walk costs ~0ms; stand in for an expensive search's _search_pain_ms. ReferenceChainsTestAccessor::setSearchPainMs(1000); // Restart #2: the terminal gate charges the finished search's OWN 1000ms cost BEFORE checking @@ -1066,7 +1325,6 @@ TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { EXPECT_FALSE(ReferenceChainsTestAccessor::shouldRunPass(2)); EXPECT_EQ(SearchState::COMPLETED, tracker->searchState()); - // Well past the drain point - the debt has cleared, restart #2 proceeds. EXPECT_TRUE(ReferenceChainsTestAccessor::shouldRunPass(1ULL + 200000000000ULL)); EXPECT_EQ(SearchState::RUNNING, tracker->searchState()); ASSERT_TRUE(tracker->runPass(&mock_jvmti, nullptr)); @@ -1077,9 +1335,8 @@ TEST_F(SearchRestartTest, PainBudgetBlocksARestartUntilItDrains) { // 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 own per-klass -// ring-fill/hysteresis gate - this is the aggressive-leak gap -// GenerationsEnabledButNoCandidateBlocksFirstSearch above documents for the non-urgent case. +// immediately, without waiting for any klass to clear selectLeakCandidates()'s per-klass +// ring-fill/hysteresis gate. TEST_F(SearchRestartTest, UrgentOOMProjectionBypassesCandidateGate) { LivenessTracker::instance()->setGcGenerationsForTest(true); constexpr u64 SEC_NS = 1000000000ULL; @@ -1104,5 +1361,3 @@ TEST_F(SearchRestartTest, UrgentOOMProjectionBypassesCandidateGate) { tracker->stop(); } -// Durability re-verification (correctness hardening). - diff --git a/ddprof-lib/src/test/fuzz/fuzz_referenceChainFrontier.cpp b/ddprof-lib/src/test/fuzz/fuzz_referenceChainFrontier.cpp index 4163c875e6..9f5c9b12ec 100644 --- a/ddprof-lib/src/test/fuzz/fuzz_referenceChainFrontier.cpp +++ b/ddprof-lib/src/test/fuzz/fuzz_referenceChainFrontier.cpp @@ -448,7 +448,7 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { shadow[idx].leak_tag = leak; } } - table.setLeakTag(tag, leak); + table.setLeakTag(tag, leak, /*leak_tag_incarnation=*/0); break; } case 9: { // LOOKUP - I2 checked by the sweep below diff --git a/ddprof-test/build.gradle.kts b/ddprof-test/build.gradle.kts index cbbb573b57..e0fa1e7a23 100644 --- a/ddprof-test/build.gradle.kts +++ b/ddprof-test/build.gradle.kts @@ -63,9 +63,26 @@ dependencies { // Additional test task configuration beyond what the plugin provides // The plugin creates Test tasks on glibc/macOS and Exec tasks on musl // Both need the native test library to be built first +val skipGtest = project.hasProperty("skip-gtest") tasks.matching { it.name.startsWith("test") && it.name != "test" }.configureEach { // Ensure native test library is built before running tests dependsOn(":ddprof-test-native:linkLib") + + // Read back the .jfr written by :ddprof-lib's referenceChainJfrRoundtrip_ut gtest for the + // same configuration (the task name encodes it; the path is scoped per configuration in + // ddprof-lib/build.gradle.kts, which see). The profiler-test plugin's test -> + // gtest wiring already orders that producer ahead of this task. + val config = name.removePrefix("test").removePrefix("Slow").lowercase() + val jfr = rootProject.layout.buildDirectory + .file("tmp/reference-chain-roundtrip/$config/datadog_reference_chain_roundtrip.jfr") + .get() + .asFile + (this as? ProcessForkOptions)?.environment("DDPROF_REFERENCE_CHAIN_ROUNDTRIP_JFR", jfr.absolutePath) + if (skipGtest) { + // The producing gtest does not run in this invocation: drop any file from an earlier one + // so the parser test skips (Assumptions gate) instead of checking a stale recording. + doFirst { jfr.delete() } + } } // Disable the default 'test' task - we use config-specific tasks instead diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java index 6c0bc0685f..e0f79c7046 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java @@ -1,3 +1,8 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + package com.datadoghq.profiler; import java.io.BufferedReader; @@ -15,6 +20,11 @@ public abstract class AbstractProcessProfilerTest { + // Upper bound for joining each output reader thread once the child has exited (or been + // destroyed). The readers normally hit EOF right after the child ends; the bound only stops a + // descendant that inherited the pipes from keeping launch() from returning. + private static final long READER_JOIN_TIMEOUT_MS = 10_000; + public static final class LaunchResult { public final boolean inTime; public final int exitCode; @@ -31,11 +41,29 @@ public enum LineConsumerResult { IGNORE } + /** + * Scales a child-process wait budget for sanitizer builds (asan/tsan), whose native code runs + * several times slower than debug/release. + */ + protected static long childTimeoutSeconds(long baseSeconds) { + String config = System.getProperty("ddprof_test.config"); + return config != null && config.endsWith("san") ? baseSeconds * 3 : baseSeconds; + } + protected final LaunchResult launch(String target, List jvmArgs, String commands, Function onStdoutLine, Function onStderrLine) throws Exception { return launch(target, jvmArgs, commands, Collections.emptyMap(), onStdoutLine, onStderrLine); } protected final LaunchResult launch(String target, List jvmArgs, String commands, Map env, Function onStdoutLine, Function onStderrLine) throws Exception { + return launch(target, jvmArgs, commands, env, 10, onStdoutLine, onStderrLine); + } + + /** + * Same as the 6-arg overload above, but with a caller-supplied wait timeout instead of the + * default 10 seconds, for child scenarios that run substantially longer than an attach/init + * check. + */ + protected final LaunchResult launch(String target, List jvmArgs, String commands, Map env, long timeoutSeconds, Function onStdoutLine, Function onStderrLine) throws Exception { String javaHome = System.getenv("JAVA_TEST_HOME"); if (javaHome == null) { javaHome = System.getenv("JAVA_HOME"); @@ -59,6 +87,17 @@ protected final LaunchResult launch(String target, List jvmArgs, String ProcessBuilder pb = new ProcessBuilder(args); pb.environment().putAll(env); Process p = pb.start(); + try { + return awaitChild(p, timeoutSeconds, onStdoutLine, onStderrLine); + } finally { + // Covers an interrupted launch() as well as a child that outlived destroyForcibly(). + if (p.isAlive()) { + p.destroyForcibly(); + } + } + } + + private static LaunchResult awaitChild(Process p, long timeoutSeconds, Function onStdoutLine, Function onStderrLine) throws Exception { Thread stdoutReader = new Thread(() -> { Function lineProcessor = onStdoutLine != null ? onStdoutLine : l -> LineConsumerResult.CONTINUE; try (BufferedReader br = new BufferedReader(new InputStreamReader(p.getInputStream()))) { @@ -130,11 +169,20 @@ protected final LaunchResult launch(String target, List jvmArgs, String stdoutReader.start(); stderrReader.start(); - boolean val = p.waitFor(10, TimeUnit.SECONDS); + boolean val = p.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!val) { p.destroyForcibly(); p.waitFor(5, TimeUnit.SECONDS); } - return new LaunchResult(val, p.exitValue()); + // Process.waitFor() only orders the child's exit, not the readers' last callbacks. Child modes + // that call System.exit(0) skip the [ready] handshake, so nothing else guarantees the final + // marker lines have been handed to the callbacks before the caller asserts on the state they + // set. Thread.join() makes every callback write visible to the caller; the join is bounded + // and a reader still alive after the bound is left behind. + stdoutReader.join(READER_JOIN_TIMEOUT_MS); + stderrReader.join(READER_JOIN_TIMEOUT_MS); + // exitValue() throws while the child is still alive (it survived destroyForcibly()); report + // -1 instead so the caller still sees inTime=false rather than an IllegalThreadStateException. + return new LaunchResult(val, p.isAlive() ? -1 : p.exitValue()); } -} \ No newline at end of file +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java index b059bb655c..acecb8fef4 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java @@ -5,6 +5,7 @@ package com.datadoghq.profiler; +import java.lang.management.ManagementFactory; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -73,6 +74,10 @@ public static double scaledSize(JfrEvent item) { protected JavaProfiler profiler; private Path jfrDump; + // Set before getProfilerCommand() is consulted, so a subclass can vary its + // command per @Test method instead of needing one test class per configuration. + protected TestInfo testInfo; + private Duration cpuInterval; private Duration wallInterval; @@ -143,7 +148,7 @@ protected final boolean isDebugBuild() { private static long getPid() { try { - String name = java.lang.management.ManagementFactory.getRuntimeMXBean().getName(); + String name = ManagementFactory.getRuntimeMXBean().getName(); return Long.parseLong(name.split("@")[0]); } catch (NumberFormatException e) { return 0L; @@ -200,6 +205,7 @@ protected void withTestAssumptions() {} @BeforeEach public void setupProfiler(TestInfo testInfo) throws Exception { + this.testInfo = testInfo; Assumptions.assumeTrue(isPlatformSupported()); withTestAssumptions(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 2dbb429668..6681ddc1d8 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -5,10 +5,16 @@ package com.datadoghq.profiler; +import com.datadoghq.profiler.referencechains.LeakTagCorrelationScenario; +import com.datadoghq.profiler.referencechains.LeakingCacheScenario; +import com.datadoghq.profiler.referencechains.StaticFieldGrowingCollectionScenario; +import com.datadoghq.profiler.referencechains.ThreadLocalLeakScenario; + import java.io.IOException; import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.nio.file.Paths; import java.util.Random; import java.util.concurrent.atomic.LongAdder; @@ -30,6 +36,20 @@ * CPU concurrently on the main thread, on a plain {@code new Thread(Runnable)} and on a * two-level {@link Thread} subclass, and stops the profiler again. The resulting recording * holds samples rooted at each of the three thread entry points; see {@code EntryFrameTest} + *

  • leak-cache "<start command>|||<scratch dump path>" - starts the profiler with + * the given start command, then runs {@link LeakingCacheScenario} in this process and exits + * directly (no readiness/stdin-signal handshake, unlike the other modes above) once it + * prints its one result line. Used by {@code ExternalProcessReferenceChainTest}; a separate + * JVM is required because the one-shot root-seeded BFS walk is a process-wide, once-ever + * resource.
  • + *
  • leak-static-field "<start command>|||<scratch dump path>" - same packing and + * lifecycle as {@code leak-cache} above, but runs {@link StaticFieldGrowingCollectionScenario} + * instead: a {@code static final List} field appended to (never reassigned) after + * its owning class has already been swept once by {@code admitStaticFieldRoots()}.
  • + *
  • leak-correlation "<start command>|||<scratch dump path>" - same packing and + * lifecycle as {@code leak-cache} above, but runs {@link LeakTagCorrelationScenario} + * instead, asserting the emitted chain's targetTag is a leak-tag-pool tag matching a + * HeapLiveObject's leakTag.
  • * */ public class ExternalLauncher { @@ -119,6 +139,20 @@ private static void entryFrameBurn(long millis) { entryFrameSink = acc; } + /** + * Splits a scenario mode's single {@code "|||"} argument + * (packed so launch()'s (target, commands) contract needs no extra argument). + */ + private static String[] unpackScenarioArgs(String mode, String[] args) { + String packed = args.length == 2 ? args[1] : ""; + int sep = packed.indexOf("|||"); + if (sep < 0) { + throw new IllegalArgumentException( + mode + " requires \"|||\", got: " + packed); + } + return new String[] {packed.substring(0, sep), packed.substring(sep + "|||".length())}; + } + public static void main(String[] args) throws Exception { Thread worker = null; try { @@ -201,6 +235,55 @@ public static void main(String[] args) throws Exception { worker.start(); } } + } else if (args[0].equals("leak-cache")) { + String[] packed = unpackScenarioArgs("leak-cache", args); + String commands = packed[0]; + String scratchPath = packed[1]; + JavaProfiler instance = JavaProfiler.getInstance(); + // run() starts the profiler itself: it must seed its cache fixture first. + LeakingCacheScenario.run(instance, commands, Paths.get(scratchPath)); + System.out.flush(); + // Skips the "[ready]" + stdin handshake below: the mode is one-shot and its + // result line has already been printed. + System.exit(0); + } else if (args[0].equals("leak-static-field")) { + // Same packing as leak-cache (unpackScenarioArgs()). + String[] packed = unpackScenarioArgs("leak-static-field", args); + String commands = packed[0]; + String scratchPath = packed[1]; + JavaProfiler instance = JavaProfiler.getInstance(); + StaticFieldGrowingCollectionScenario.run(instance, commands, Paths.get(scratchPath)); + System.out.flush(); + System.exit(0); + } else if (args[0].equals("leak-correlation")) { + // Same packing as leak-cache (unpackScenarioArgs()). + String[] packed = unpackScenarioArgs("leak-correlation", args); + String commands = packed[0]; + String scratchPath = packed[1]; + JavaProfiler instance = JavaProfiler.getInstance(); + LeakTagCorrelationScenario.run(instance, commands, Paths.get(scratchPath)); + System.out.flush(); + System.exit(0); + } else if (args[0].equals("threadlocal-leak")) { + // Same packing as leak-cache (unpackScenarioArgs()). + String[] packed = unpackScenarioArgs("threadlocal-leak", args); + String commands = packed[0]; + String scratchPath = packed[1]; + JavaProfiler instance = JavaProfiler.getInstance(); + ThreadLocalLeakScenario.run(instance, commands, Paths.get(scratchPath)); + System.out.flush(); + System.exit(0); + } else if (args[0].equals("threadlocal-leak-late")) { + // Same packing as threadlocal-leak, but the leak thread is + // started only AFTER the recording began, exercising + // Profiler::onThreadStart's registerThreadObject() wiring. + String[] packed = unpackScenarioArgs("threadlocal-leak-late", args); + String commands = packed[0]; + String scratchPath = packed[1]; + JavaProfiler instance = JavaProfiler.getInstance(); + ThreadLocalLeakScenario.run(instance, commands, Paths.get(scratchPath), true); + System.out.flush(); + System.exit(0); } } finally { System.out.println("[ready]"); 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 b3052f3a20..55766f1890 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -9,6 +9,9 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -22,6 +25,22 @@ public void ownedBlockHooksAreNotPublicApiBeforeTaskBlockInstrumentation() throw assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockExit", long.class)); } + @Test + public void referenceChainTestSeamsAreNotPublicApi() { + Set seams = new HashSet<>(Arrays.asList( + "getTid", "setGcGenerationsEnabled0", "seedKlassPopulationSample0", + "seedTidTrendSample0", "selectLeakCandidateKlassIds0", "tagAsReferenceChainRoot0", + "runReferenceChainPass0", "pollReferenceChainTargets0", + "drainReferenceChainEventCountForLeaf0")); + for (Method m : JavaProfiler.class.getDeclaredMethods()) { + if (seams.contains(m.getName()) || m.getName().endsWith("ForTest0")) { + assertFalse(Modifier.isPublic(m.getModifiers()), + m.getName() + " is a test seam and must not be public API" + + " (tests reach it through ReferenceChainTestHooks)"); + } + } + } + private static void assertNotPublic(Method method) { assertFalse(Modifier.isPublic(method.getModifiers()), method.getName() + " must remain non-public until PR2 wires TaskBlock instrumentation"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java new file mode 100644 index 0000000000..c37170fa2a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ReferenceChainTestHooks.java @@ -0,0 +1,124 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler; + +/** + * Test bridge for the package-private reference-chain/liveness test seams on {@link JavaProfiler}, + * so tests outside {@code com.datadoghq.profiler} can call them without the seams becoming public + * API of the shipped jar. The native seams exist only in -DDEBUG builds (debug/asan/tsan); release + * builds get no-op stubs. + */ +public final class ReferenceChainTestHooks { + private ReferenceChainTestHooks() {} + + /** The calling thread's profiler tid. Call it on the allocating (leaking) thread. */ + public static int getTid() { + return JavaProfiler.getTid(); + } + + public static boolean setGcGenerationsEnabled0(boolean enabled) { + return JavaProfiler.setGcGenerationsEnabled0(enabled); + } + + public static void seedKlassPopulationSample0(int klassId, int count, long epoch) { + JavaProfiler.seedKlassPopulationSample0(klassId, count, epoch); + } + + public static void seedTidTrendSample0(int klassId, int tid, int count, long epoch) { + JavaProfiler.seedTidTrendSample0(klassId, tid, count, epoch); + } + + public static void setKlassPopulationRepresentativeForTest0(int klassId, Object representative) { + JavaProfiler.setKlassPopulationRepresentativeForTest0(klassId, representative); + } + + public static void resetKlassPopulationForTest0() { + JavaProfiler.resetKlassPopulationForTest0(); + } + + public static int[] selectLeakCandidateKlassIds0() { + return JavaProfiler.selectLeakCandidateKlassIds0(); + } + + public static long tagAsReferenceChainRoot0(Object target) { + return JavaProfiler.tagAsReferenceChainRoot0(target); + } + + public static boolean runReferenceChainPass0() { + return JavaProfiler.runReferenceChainPass0(); + } + + public static void pollReferenceChainTargets0() { + JavaProfiler.pollReferenceChainTargets0(); + } + + public static int drainReferenceChainEventCountForLeaf0(Object leaf) { + return JavaProfiler.drainReferenceChainEventCountForLeaf0(leaf); + } + + public static void resetReferenceChainSearchForTest0() { + JavaProfiler.resetReferenceChainSearchForTest0(); + } + + public static long getReferenceChainPendingPositionForTest0(Object target) { + return JavaProfiler.getReferenceChainPendingPositionForTest0(target); + } + + public static long getReferenceChainPendingSizeForTest0() { + return JavaProfiler.getReferenceChainPendingSizeForTest0(); + } + + public static int getReferenceChainFrontierStateForTest0(Object target) { + return JavaProfiler.getReferenceChainFrontierStateForTest0(target); + } + + public static long getReferenceChainResolvedTargetTagForTest0(Object target) { + return JavaProfiler.getReferenceChainResolvedTargetTagForTest0(target); + } + + public static void heapFloorRecordForTest0(long usedBytes, long timestampNs) { + JavaProfiler.heapFloorRecordForTest0(usedBytes, timestampNs); + } + + public static void setMaxHeapBytesForTest0(long maxHeapBytes) { + JavaProfiler.setMaxHeapBytesForTest0(maxHeapBytes); + } + + public static void setHeapFloorRecordingForTest0(boolean enabled) { + JavaProfiler.setHeapFloorRecordingForTest0(enabled); + } + + /** + * Clears the heap-floor ring seeded via {@link #heapFloorRecordForTest0(long, long)} so + * the next test in the same JVM starts from an empty ring. Call before the + * {@link #setHeapFloorRecordingForTest0(boolean)} restore, while recording is still + * disabled - the native seam asserts when called while recording is enabled. + */ + public static void heapFloorResetForTest0() { + JavaProfiler.heapFloorResetForTest0(); + } + + /** + * Reports whether the seeded heap-floor ring currently shows a sustained rise + * ({@code heapFloorRising()}). {@code false} before any seeding and after + * {@link #heapFloorResetForTest0()} emptied the ring. + */ + public static boolean heapFloorRisingForTest0() { + return JavaProfiler.heapFloorRisingForTest0(); + } + + /** + * Not a read-only query: under the native engine lock the gate may account a completed + * search's pain and restart/reset terminal search state. + */ + public static boolean shouldRunPassForTest0() { + return JavaProfiler.shouldRunPassForTest0(); + } + + public static int referenceChainPassesRunForTest0() { + return JavaProfiler.referenceChainPassesRunForTest0(); + } +} 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 new file mode 100644 index 0000000000..db78767791 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/AggressiveLeakReferenceChainTest.java @@ -0,0 +1,200 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * E2E coverage for {@code LivenessTracker::secondsToOOM()}'s urgent-OOM bypass + * ({@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. + * + *

    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 + * shares with every other {@code ddprof-test} class). The debug-only + * {@code heapFloorRecordForTest0()}/{@code setMaxHeapBytesForTest0()} seams (javaApi.cpp) instead + * 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 reads + * {@code ReferenceChainTracker::shouldRunPass()} - the search-restart 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. + return "generations=true,referencechains=true:hops=32:budget=500:ttl=60000:framecap=2000000"; + } + + @Override + protected boolean isPlatformSupported() { + // Mirrors ReferenceChainTrackingTest's own guard - FollowReferences/tag-based frontier walking + // assumes a HotSpot-shaped JVMTI heap implementation. + return !(Platform.isJavaVersion(8) || Platform.isJ9() || Platform.isZing()); + } + + private static void assumeDebugBuild() { + assumeTrue(ReferenceChainAssertions.isDebugInstrumentedBuild(), + "heapFloorRecordForTest0/setMaxHeapBytesForTest0/shouldRunPassForTest0 only exist in a " + + "-DDEBUG native build - debug/asan/tsan (javaApi.cpp's #ifdef DEBUG guard)"); + } + + /** + * 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. + * + *

    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. + */ + @Test + public void shouldOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate() + 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. + ReferenceChainTestHooks.setMaxHeapBytesForTest0(1000L * 1024 * 1024); + for (int i = 0; i < 10; i++) { + long usedBytes = (100L + i * 99L) * 1024 * 1024; + long timestampNs = i * 1_000_000_000L; + ReferenceChainTestHooks.heapFloorRecordForTest0(usedBytes, timestampNs); + } + + 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) { + 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"); + + 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"); + } finally { + // Reset the ring while recording is still disabled so the restore below cannot + // let a real GC interleave a sample with the reset, and so the next test in this + // JVM does not inherit this test's seeded samples into its secondsToOOM(). + ReferenceChainTestHooks.heapFloorResetForTest0(); + ReferenceChainTestHooks.setMaxHeapBytesForTest0(-1); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(true); + } + } + + /** + * 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. + */ + @Test + public void shouldNotOpenSearchGateOnFlatHeapFloorWithNoLeakCandidate() { + assumeDebugBuild(); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + 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"); + + assertFalse(ReferenceChainTestHooks.shouldRunPassForTest0(), + "Expected the search-restart gate to stay closed: no heap growth and no per-klass " + + "candidate give hasLeakSignal() nothing to trust"); + } finally { + // Same order as the seeding test above: ring reset first, still disabled. + ReferenceChainTestHooks.heapFloorResetForTest0(); + ReferenceChainTestHooks.setMaxHeapBytesForTest0(-1); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(true); + } + } + + /** + * Kills a no-op'd {@code heapFloorResetForTest0()} mutation: every other call site resets in + * 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. + */ + @Test + public void shouldEmptyHeapFloorRingOnReset() { + assumeDebugBuild(); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(false); + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + try { + // Same rising seeds as shouldOpenSearchGateOnAggressiveHeapWideGrowthWithNoLeakCandidate: + // 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++) { + long usedBytes = (100L + i * 99L) * 1024 * 1024; + long timestampNs = i * 1_000_000_000L; + ReferenceChainTestHooks.heapFloorRecordForTest0(usedBytes, timestampNs); + } + assertTrue(ReferenceChainTestHooks.heapFloorRisingForTest0(), + "Precondition: the seeded samples must read as a sustained rise before the reset"); + ReferenceChainTestHooks.heapFloorResetForTest0(); + assertFalse(ReferenceChainTestHooks.heapFloorRisingForTest0(), + "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. + ReferenceChainTestHooks.heapFloorResetForTest0(); + ReferenceChainTestHooks.setHeapFloorRecordingForTest0(true); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java new file mode 100644 index 0000000000..55da7b61ff --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ExternalProcessReferenceChainTest.java @@ -0,0 +1,202 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +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.assumeFalse; + +/** + * Separate-process end-to-end coverage for the reference-chains target-selection mechanism, + * complementing {@link ReferenceChainTrackingTest}'s in-process coverage: runs a "leaking Java + * app" (see {@link LeakingCacheScenario}) in a child JVM and asserts on the child's reported + * result. + * + *

    Why a separate process, not another {@code @Test} in {@code ReferenceChainTrackingTest}: + * {@code runPass()} (referenceChains.cpp) performs exactly one root-seeded {@code FollowReferences} + * walk per search's *entire lifetime* ({@code _search_started}); every later pass only expands + * frontier entries that walk already discovered ({@code expandFrontier()}) - it never + * re-examines GC roots or revisits an already-{@code EXPANDED} entry for newly-added children. + * Since {@code ReferenceChainTracker} is a process-wide singleton, only the *first* test to call + * {@code runPass()} in a given JVM gets a root-seeded walk; later tests' independently-rooted + * locals are invisible to it. A child JVM per scenario gets its own fresh singleton and + * therefore its own first root walk. + */ +@Tag("slow") +public class ExternalProcessReferenceChainTest extends AbstractProcessProfilerTest { + + @Test + void shouldReconstructReferrerChainInSeparateProcess() throws Exception { + // Mirrors ReferenceChainTrackingTest.isPlatformSupported()'s own guard - FollowReferences/ + // tag-based frontier walking assumes a HotSpot-shaped JVMTI heap implementation. + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + + Path scratchDumpPath = Files.createTempFile("referencechains-external-process", ".jfr"); + // LeakingCacheScenario.run() (inside the child) creates this file on its own first dump() - + // an empty placeholder here would make that first dump() attempt fail confusingly. + Files.deleteIfExists(scratchDumpPath); + // "start" requires a "jfr,file=..." clause (JavaProfiler.execute() throws "Flight Recorder + // output file is not specified" without one) even though its content is never read. + Path continuousJfrPath = Files.createTempFile("referencechains-external-process-continuous", ".jfr"); + try { + // budget=200000 (up from the in-process test's 4000): a cold external JVM's + // reachable-from-roots graph (full JUnit/JMC/Gradle-worker classpath) is far larger than + // the in-process test's, and each pass's JVMTI walk cost scales with cumulative frontier + // size - budget=4000 stayed truncated=1 at 76000+ admitted edges after 19 passes and never + // reached the cache before timeout. _budget is the pacing controller's hard ceiling (see + // updatePacing(), referenceChains.cpp) - pausetarget only paces *within* it. + // memory=64:l:1.0 - the 100% live-sample ratio removes the default 10% subsampling + // lottery from the tracked-instance pool; total volume stays capped by the 256KiB + // interval floor, so the tracking table remains tiny. + String startCommand = "start,memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:pausetarget=60000" + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + // Packed into one args[1] string to avoid extending AbstractProcessProfilerTest.launch()'s + // generic (target, commands) contract with a 3rd argument. + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + // ddprof_test.config is set on the Test task by ProfilerTestPlugin.kt and is not inherited + // by a ProcessBuilder-launched child; the scenario needs it for its debug-only seeding. + // requiredTestConfig() also fails loud when the property is unset instead of forwarding + // the literal "null", which the child would classify as a release build. + List jvmArgs = Collections.singletonList( + "-Dddprof_test.config=" + ReferenceChainAssertions.requiredTestConfig()); + + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch("leak-cache", jvmArgs, packedCommand, + Collections.emptyMap(), + // Generous: a separate JVM's startup/classloading cost on top of the up-to-25-round + // growth loop plus grace period (~20s in-process). + childTimeoutSeconds(90), + line -> { + if (line.startsWith(LeakingCacheScenario.FOUND_MARKER) + || line.equals(LeakingCacheScenario.NOT_FOUND_MARKER) + || line.startsWith(LeakingCacheScenario.NO_HASHMAP_INTERNALS_MARKER) + || line.startsWith(LeakingCacheScenario.CHAIN_NOT_PERSISTED_MARKER)) { + resultLine.set(line); + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout"); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + assertNotNull(resultLine.get(), + "Child process never printed a recognizable result marker on stdout"); + assertEquals( + LeakingCacheScenario.FOUND_MARKER + LeakingCacheScenario.CachedPayload.class.getName(), + resultLine.get(), + "Expected a successfully reconstructed chain whose leaf is CachedPayload, threading " + + "through java.util.HashMap's own internal storage - got: " + resultLine.get()); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } + + /** + * Mirrors {@link #shouldReconstructReferrerChainInSeparateProcess()}, but against {@link + * StaticFieldGrowingCollectionScenario} instead of {@link LeakingCacheScenario}: a {@code + * static final List} field appended to (never reassigned) after its owning class has + * already been swept once by {@code admitStaticFieldRoots()} (referenceChains.cpp), as opposed + * to {@code LeakingCacheScenario}'s local-variable (stack-root) cache. Checks that + * {@code collectStaleExpandedEntriesForRotation()} re-expands an already-{@code EXPANDED} + * static-field-rooted collection to pick up elements added after its one-time sweep. + */ + @Test + void shouldReconstructReferrerChainForGrowingStaticFieldCollection() throws Exception { + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + + Path scratchDumpPath = Files.createTempFile("referencechains-static-field-external-process", ".jfr"); + Files.deleteIfExists(scratchDumpPath); + Path continuousJfrPath = Files.createTempFile("referencechains-static-field-external-process-continuous", ".jfr"); + try { + // budget/pausetarget/memory: see shouldReconstructReferrerChainInSeparateProcess(). + // painbudget=100 - the safepoint pain budget exists to protect a production + // app from back-to-back expensive passes, but here it starves the engine + // on a loaded box: a cold external JVM's search costs seconds of measured + // safepoint pain, the default cap (5% of wall-clock) clears that debt in + // ~a minute, and the tail then watches a terminal engine with all tags + // released and no new search for its whole window (observed as 1900+ + // consecutive blocked-iteration evaluations and the watched chunk reading + // tag=0 at every poll - the pass/fail line between otherwise identical + // runs). 100% only lets debt clear as fast as it accrues; cadence stays + // bounded by each search's own ~seconds of work, so this cannot spin. + String startCommand = "start,memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:pausetarget=60000" + + ":painbudget=100" + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + List jvmArgs = new ArrayList<>(); + jvmArgs.add("-Dddprof_test.config=" + ReferenceChainAssertions.requiredTestConfig()); + // --add-opens: the scenario reflects into Collections$SynchronizedList.list and + // ArrayList.elementData to check its holders' expansion state before creating lateChunk. + // JDK 8 has no module system (that reflection needs no opening) and rejects the option. + if (Platform.isJavaVersionAtLeast(9)) { + jvmArgs.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + } + + AtomicReference resultLine = new AtomicReference<>(); + AtomicReference seamPathLine = new AtomicReference<>(); + LaunchResult result = launch("leak-static-field", jvmArgs, packedCommand, + Collections.emptyMap(), + // Wider than shouldReconstructReferrerChainInSeparateProcess()'s 90s: this scenario's + // own tail wait (StaticFieldGrowingCollectionScenario.run()) needs up to ~60s on top of + // the round loop for a cold JVM's first full pass to actually complete - see that + // method's own comment. + childTimeoutSeconds(150), + line -> { + if (line.startsWith(StaticFieldGrowingCollectionScenario.FOUND_MARKER) + || line.equals(StaticFieldGrowingCollectionScenario.NOT_FOUND_MARKER)) { + resultLine.set(line); + } else if (line.startsWith(StaticFieldGrowingCollectionScenario.SEAM_PATH_MARKER)) { + seamPathLine.set(line); + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout"); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + // The scenario's native test seams are compiled into every -DDEBUG build (debug/asan/tsan), + // so the strong seam path must be taken on exactly those configs - the same single + // encoding the child itself uses, not an independent string match here. + boolean expectSeams = ReferenceChainAssertions.isDebugInstrumentedBuild(); + assertEquals(StaticFieldGrowingCollectionScenario.SEAM_PATH_MARKER + expectSeams, + seamPathLine.get(), + "scenario's seam-path predicate must match the native -DDEBUG build set"); + assertNotNull(resultLine.get(), + "Child process never printed a recognizable result marker on stdout"); + assertEquals( + // "byte[]", not "[B": JMC's IMCType renders array types in Java source notation. + StaticFieldGrowingCollectionScenario.FOUND_MARKER + "byte[]", + resultLine.get(), + "Expected a successfully reconstructed chain to a byte[] chunk appended to the static " + + "field's list after its one-time admitStaticFieldRoots() sweep - got: " + resultLine.get()); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationReferenceChainTest.java new file mode 100644 index 0000000000..d292f78753 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationReferenceChainTest.java @@ -0,0 +1,251 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; + +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_BASE; +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_POOL_SIZE; +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.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeFalse; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * End-to-end coverage of the leak-tag correlation use case in a genuinely separate child + * JVM (same rationale as {@code ExternalProcessReferenceChainTest}'s own class comment - + * the one-shot root-seeded BFS walk is a per-process resource): a growing static-collection + * leak amid ephemeral stack-local noise and a large live filler graph must be reported as a + * {@code datadog.ReferenceChain} whose {@code targetTag} is in the leak-tag pool's range + * AND matches a {@code datadog.HeapLiveObject}'s {@code leakTag}. See + * {@link LeakTagCorrelationScenario}'s own header for what each ingredient reproduces. + * + *

    Diagnostics on failure: the child's {@code TEST_LOG} stream (queue depths, batch + * sizes, interception counts) arrives on this process's stdout consumer. A failing run embeds + * a filtered tail of it in the assertion message, so the failure mode is diagnosable from the + * test report alone. + */ +@Tag("slow") +public class LeakTagCorrelationReferenceChainTest extends AbstractProcessProfilerTest { + + @Test + void shouldCorrelateLeakChainTargetTagWithLiveObjectLeakTag() throws Exception { + assertCorrelationFound("pausetarget=60000"); + } + + /** + * The tight-window variant of the correlation use case: the same scenario under a tight + * pass window (pausetarget in single-digit ms, narrower than a large tag map's + * GetObjectsWithTags cost floor). Full correlation cannot be the pass criterion at this + * window width: the scenario's trend gates are tuned for the loose window's crawl/GC + * interleaving, and under tight windows the leak klass's real-fold trend can flip within a + * single run (chains arrive via the plain crawl path while pool tagging/interception loses + * the race, reported as [correlation-tag-out-of-pool]). What this variant asserts + * deterministically is the machinery the window exists to exercise: the leak-accumulation + * rotation must engage and select FRONTIER-state (un-expanded backlog) holders. Without + * that, the rotation tier selects nothing while known parent candidates pile up in the + * backlog. GetObjectsWithTags batch control is + * covered unit-level (gotwWindowNs() widening): a real child JVM's local tag map + * (~100k filler) keeps the per-call floor under the nominal window, so the widening + * regime cannot be reproduced end-to-end locally without a 242k-entry tag map. + */ + @Test + void shouldRotateFrontierLeakHoldersUnderPodLikeTightWindows() throws Exception { + assertCorrelationFound("pausetarget=10", /*requireFrontierRotation=*/ true); + } + + private void assertCorrelationFound(String pauseTargetOption) throws Exception { + assertCorrelationFound(pauseTargetOption, /*requireFrontierRotation=*/ false); + } + + /** + * Drives the leak-correlation child JVM and asserts the correlated chain, with the + * referencechains pausetarget given by {@code pauseTargetOption} (everything else held + * fixed across the two callers so the variant differs in exactly the pass-window regime). + * With {@code requireFrontierRotation} (the tight-window variant), the + * leak-accumulation rotation's FRONTIER-state selection is additionally asserted, and + * the correlation result markers that the tight window legitimately produces without the + * full correlation completing (not-found, tag-out-of-pool - see the tight variant's own + * comment) are accepted instead of failed. This variant runs only on DEBUG-instrumented + * native builds and forces reference-chains debug level 2 in the child; the loose variant + * runs on every build. + */ + private void assertCorrelationFound(String pauseTargetOption, + boolean requireFrontierRotation) throws Exception { + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + if (requireFrontierRotation) { + // The FRONTIER-rotation signal is a level-2 TEST_LOG line (referenceChainWalk.cpp, + // collectLeakAccumulationCandidatesForRotation) - compiled out without -DDEBUG + // (debug/asan/tsan only) and silent unless DD_PROFILING_REFERENCE_CHAINS_DEBUG=2. + assumeTrue(ReferenceChainAssertions.isDebugInstrumentedBuild(), + "FRONTIER-rotation assertion needs the reference-chains TEST_LOG stream, only " + + "compiled into DEBUG-instrumented builds (debug/asan/tsan)"); + } + + Path scratchDumpPath = Files.createTempFile("referencechains-correlation-", ".jfr"); + Files.deleteIfExists(scratchDumpPath); + Path continuousJfrPath = Files.createTempFile("referencechains-correlation-continuous-", ".jfr"); + // Rolling tail of the child's TEST_LOG stream for failure diagnostics - bounded so a + // long run cannot grow this without limit. + Deque testLogTail = new ArrayDeque<>(); + AtomicBoolean frontierRotationSeen = new AtomicBoolean(false); + try { + // Same raised budget as ExternalProcessReferenceChainTest's own scenarios: a genuinely + // fresh, cold external JVM's reachable graph is far larger than the in-process tests', + // and this scenario adds ~100k filler objects on top. The pausetarget varies by caller. + // memory=64:l:1.0 - the explicit liveness-sample ratio is load-bearing: the default + // (10%, arguments.h's _live_samples_ratio) makes LivenessTracker::track() drop 90% of + // tracked instances, and with only tens of 1MB leak chunks the (klass, tid) population + // becomes a per-run lottery (observed as intermittent "tagLeakInstances ZERO" runs). + // 1.0 makes LeakTagCorrelationScenario's "every allocation is sampled, tracked, and + // taggable" assumption true. + String startCommand = "start,memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:" + + pauseTargetOption + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + List jvmArgs = Collections.singletonList( + "-Dddprof_test.config=" + ReferenceChainAssertions.requiredTestConfig()); + + AtomicReference resultLine = new AtomicReference<>(); + // Level 2 is required for the FRONTIER-selection TEST_LOG line (rcDebugLevel.h); the + // loose variant keeps the default (silent) level. + Map childEnv = requireFrontierRotation + ? Collections.singletonMap("DD_PROFILING_REFERENCE_CHAINS_DEBUG", "2") + : Collections.emptyMap(); + LaunchResult result = launch("leak-correlation", jvmArgs, packedCommand, + childEnv, + // The scenario's own round loop (up to 25 rounds plus a 5-attempt grace period, + // ~40-60s observed) on top of a cold external JVM's startup/classloading cost and + // this scenario's larger graph - same margin logic as the existing scenarios. The + // stale-owner check after a debug-build correlation adds up to ~10s. + childTimeoutSeconds(150), + line -> { + if (line.startsWith(LeakTagCorrelationScenario.FOUND_MARKER) + || line.equals(LeakTagCorrelationScenario.NOT_FOUND_MARKER) + || line.startsWith(LeakTagCorrelationScenario.TAG_OUT_OF_POOL_MARKER) + || line.startsWith(LeakTagCorrelationScenario.NO_LIVE_OBJECT_MARKER) + || line.startsWith(LeakTagCorrelationScenario.TRANSIENT_CHAIN_MARKER) + || line.startsWith(LeakTagCorrelationScenario.NOISE_CHAIN_MARKER) + || line.startsWith(LeakTagCorrelationScenario.NOT_OWNER_VERIFIED_MARKER) + || line.startsWith(LeakTagCorrelationScenario.STALE_OWNER_CHAIN_MARKER)) { + resultLine.set(line); + } + if (line.startsWith("[TEST::INFO]")) { + if (line.contains("collectLeakAccumulationCandidatesForRotation selected") + && line.contains("state=FRONTIER")) { + frontierRotationSeen.set(true); + } + // The reader thread can still be running after a join timeout, so every access + // to the tail is synchronized (diagnostics() reads a snapshot). + synchronized (testLogTail) { + testLogTail.addLast(line); + while (testLogTail.size() > 2000) { + testLogTail.removeFirst(); + } + } + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout" + + diagnostics(testLogTail)); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + assertNotNull(resultLine.get(), "Child process never printed a recognizable result marker" + + " on stdout" + diagnostics(testLogTail)); + String line = resultLine.get(); + if (requireFrontierRotation) { + assertTrue(frontierRotationSeen.get(), + "leak-accumulation rotation never selected a FRONTIER-state (un-expanded) " + + "holder under the tight window - a dead rotation tier (zero selections) " + + "looks exactly like this" + diagnostics(testLogTail)); + } + if (line.startsWith(LeakTagCorrelationScenario.FOUND_MARKER)) { + long targetTag = Long.parseLong( + line.substring(LeakTagCorrelationScenario.FOUND_MARKER.length()).trim()); + assertTrue(targetTag >= LEAK_TAG_BASE && targetTag < LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE, + "Reported targetTag " + targetTag + " is outside the leak-tag pool range [" + + LEAK_TAG_BASE + ", " + (LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE) + ")"); + return; + } + if (requireFrontierRotation && (line.equals(LeakTagCorrelationScenario.NOT_FOUND_MARKER) + || line.startsWith(LeakTagCorrelationScenario.TAG_OUT_OF_POOL_MARKER))) { + // The tight window legitimately produces these without full correlation completing + // (see the tight variant's comment); the loose variant still fails on them. + return; + } + fail("Leak-tag correlation did not succeed: " + line + diagnostics(testLogTail)); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } + + /** + * A filtered summary of the child's TEST_LOG stream for failure messages: the last few + * pass summaries (queue depths are the first thing to check on any crawl stall), the + * gotw batch-size trace (batch control regressions), and any leak-tag interception or + * correlation lines. Capped so the message stays readable. + */ + private static String diagnostics(Deque testLogTailRef) { + List testLogTail; + synchronized (testLogTailRef) { + testLogTail = new ArrayList<>(testLogTailRef); + } + if (testLogTail.isEmpty()) { + return "\n(no TEST_LOG output captured - non-debug build or no reference-chain passes ran)"; + } + List runPassDone = new ArrayList<>(); + List gotw = new ArrayList<>(); + List leakTagLines = new ArrayList<>(); + for (String s : testLogTail) { + if (s.contains("runPass done:")) { + runPassDone.add(s); + } else if (s.contains("expandFrontier gotw")) { + gotw.add(s); + } else if (s.contains("intercepted") || s.contains("correlateAdmittedLeakTag") + || s.contains("recordDiscoveredInstance") || s.contains("auto-mark") + || s.contains("rotation_candidates") + || s.contains("collectLeakAccumulationCandidatesForRotation selected")) { + leakTagLines.add(s); + } + } + StringBuilder sb = new StringBuilder("\n--- child diagnostics (filtered TEST_LOG) ---"); + appendLast(sb, "runPass done", runPassDone, 5); + appendLast(sb, "gotw", gotw, 5); + appendLast(sb, "leak-tag activity", leakTagLines, 20); + return sb.toString(); + } + + private static void appendLast(StringBuilder sb, String label, List lines, int max) { + sb.append("\n").append(label).append(" (last ").append(max).append(" of ") + .append(lines.size()).append("):"); + int from = Math.max(0, lines.size() - max); + for (int i = from; i < lines.size(); i++) { + sb.append("\n ").append(lines.get(i)); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationScenario.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationScenario.java new file mode 100644 index 0000000000..346728f387 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakTagCorrelationScenario.java @@ -0,0 +1,732 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_BASE; +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_POOL_SIZE; + +/** + * The "leak-tag correlation" use case, run inside a genuinely separate child JVM by + * {@code ExternalLauncher}'s {@code leak-correlation} mode: a growing static-field + * collection leak, coexisting with (a) concurrent, ephemeral, stack-local allocations of + * another class and (b) a large live object graph, must end up reported as a + * {@code datadog.ReferenceChain} event whose {@code targetTag} is a leak-tag-pool tag, + * matching a {@code datadog.HeapLiveObject} event's {@code leakTag} for the same object - + * the chain-to-live-heap-sample correlation the whole leak-tag pool exists to provide. + * + *

    Each ingredient reproduces, in miniature, a condition of large deployments that + * smaller local tests do not reach: + *

      + *
    • Scale ({@link #FILLER}): ~100k live filler objects grow the JVMTI tag map + * enough that {@code GetObjectsWithTags} has a real per-call cost floor, the BFS + * frontier/backlog has real depth, and per-pass deadlines actually bind - the regime + * where batch-size control, fair-share queue drain, and rotation starvation bugs live. + * Built (like the leak itself) BEFORE the profiler starts, per + * {@code LeakingCacheScenario}'s own comment: the one-shot root-seeded walk fires ~1s + * after start and must never catch the fixture half-built.
    • + *
    • Noise ({@link NoisePayload} on the noise thread): short-lived allocations + * held only by a live thread's stack frame during that same one-shot walk - admitted + * as {@code stack_local}-rooted depth-1 frontier entries, exactly the transient-root + * noise shape the discovered-chain gate exists to suppress. The scenario asserts no + * such chain is ever emitted; a regression in the gate emits one and fails this + * scenario with {@link #TRANSIENT_CHAIN_MARKER}.
    • + *
    • Correlation (the actual acceptance signal): a leaked {@code byte[]} + * chunk's chain must carry a {@code targetTag} in the leak-tag pool's range - + * mirrored here from {@code LEAK_TAG_BASE}/{@code LEAK_TAG_POOL_SIZE} + * (referenceChains.h) - and at least one {@code datadog.HeapLiveObject} event for a + * {@code byte[]} must carry the same value in {@code leakTag} (joined on + * {@code (tag, leakTagIncarnation)}, since leak tags are recycled). On -DDEBUG builds the + * chain must also be the one {@code getReferenceChainResolvedTargetTagForTest0} resolves for + * a live {@link #LEAK_SINK} chunk (instance identity, not just class), and after that owner + * chunk is dropped and its tag released, its chain must stop being emitted under that + * {@code (tag, leakTagIncarnation)}.
    • + *
    + * + *

    Same separate-process rationale as {@code LeakingCacheScenario}'s own class comment + * (the one-shot root-seeded walk is a per-process resource), and same seed-before-start + * and debug-seam patterns as {@code StaticFieldGrowingCollectionScenario}. + */ +public final class LeakTagCorrelationScenario { + private LeakTagCorrelationScenario() {} + + /** + * The leak: a static collection appended to for the process's whole life - the + * canonical unmaintained-singleton retention shape, DELIBERATELY a growing + * {@code ArrayList} and not a fixed holder. An ArrayList's backing + * {@code elementData} array is REPLACED on growth, and an EXPANDED frontier node's + * children set is frozen at expansion time (expandFrontier()'s own comment), so + * discovering contents added after a resize is exactly the hard production case + * this scenario must prove works: the live holder's re-walk has to admit each new + * backing array, and only then can its new chunks be admitted and leak-tag + * intercepted. + */ + static final List LEAK_SINK = new ArrayList<>(); + + /** + * Scale ingredient: a static tree of plain objects, unreachable from anywhere but this + * field - exists only to give the frontier/backlog and the JVMTI tag map real depth. + */ + static final Object[][] FILLER = new Object[1500][]; + + // Arbitrary, scenario-chosen klass ids - see LeakingCacheScenario.CACHED_PAYLOAD_TEST_KLASS_ID's + // own comment; distinct from the other scenarios' ids (987301/987302) since all may run in the + // same suite, each in its own separate child JVM. + private static final int LEAK_KLASS_ID = 987303; + private static final int NOISE_KLASS_ID = 987304; + + // See LeakingCacheScenario.SEED_EPOCHS_FOR_HYSTERESIS's own comment for the full derivation. + private static final int SEED_EPOCHS_FOR_HYSTERESIS = 15; + + private static final int SEED_CHUNKS = 10; + + /** + * Per-round trend-maintenance epoch counter (debug builds only) - same + * pattern and rationale as StaticFieldGrowingCollectionScenario's own: + * a one-shot seeded ramp ages out of LivenessTracker's hysteresis once + * enough real fold samples interleave (observed live: the byte[] candidate + * dropped right after the first leak-tag interceptions, stranding the + * correlated discoveries with no poll loop to build their chains), so + * each round pushes one fresh rising epoch to keep the candidates alive. + */ + private static int hysteresisEpoch = 0; + + private static void addChunk() { + LEAK_SINK.add(new byte[CHUNK_BYTES]); + } + + /** + * Transient handoff channel for the noise representative - deliberately a local of + * {@code run()} (captured by the noise thread's lambda), NOT a static field: any static + * field holding a {@link NoisePayload} would make that one instance genuinely, + * durably static-retained, and the profiler would be RIGHT to emit a static-rooted chain + * for it - found the hard way on this scenario's very first run, which correctly reported + * {@code NoisePayload@depth0:static_field}. After the handoff the only JVM-heap reference + * to the representative is the noise thread's live frame, which is exactly the + * transient-rooted retention this class exists to exercise. + */ + private static final class NoiseHandoff { + volatile NoisePayload representative; + // The noise thread's own profiler tid - published alongside the payload so + // the seeding below can qualify exactly that thread for the noise klass + // (selectLeakCandidates()'s per-(klass, tid) gate matches the tid that + // actually allocated the noise instances, and that is this thread). + volatile int tid; + + void publish(NoisePayload payload, int tid) { + // tid FIRST (volatile ordering): a caller that sees `representative` set + // is then also guaranteed to see the tid published with it. + this.tid = tid; + representative = payload; + } + + NoisePayload take() { + NoisePayload payload = representative; + representative = null; // drop this path's only reference - retention stays transient + return payload; + } + } + + /** Printed to stdout, followed by the correlated targetTag, on full success. */ + public static final String FOUND_MARKER = "[correlation-found] "; + + /** Printed to stdout if no leaked byte[] chain was ever observed. */ + public static final String NOT_FOUND_MARKER = "[correlation-not-found]"; + + /** + * Printed to stdout (with the offending tag) if a leaked byte[] chain was emitted but its + * targetTag never landed in the leak-tag pool's range - the chain-to-live-heap + * correlation's first half failing. + */ + public static final String TAG_OUT_OF_POOL_MARKER = "[correlation-tag-out-of-pool] "; + + /** + * Printed to stdout (with the targetTag) if a correlated byte[] chain was emitted but + * no datadog.HeapLiveObject event for a byte[] ever carried the same leakTag - the + * correlation's second half failing (tagging and live-heap recording disagreeing on + * which instances are the tracked leak candidates). + */ + public static final String NO_LIVE_OBJECT_MARKER = "[correlation-no-live-object] "; + + /** + * Printed to stdout (with a summary of every violation) if any ReferenceChain event was + * emitted whose root kind is a transient root (stack/jni local) at depth <= 1 - the + * discovered-chain gate's exact suppression contract (depth==0 always, depth==1 when + * transient-rooted); anything matching it reaching the output is a gate regression. + */ + public static final String TRANSIENT_CHAIN_MARKER = "[correlation-transient-chain-emitted] "; + + /** + * Printed to stdout (with a summary of the offending chains) if the deliberately ephemeral + * {@link NoisePayload} - held only by the noise thread's stack frame - ever got a chain + * emitted, whether via the transient gate or any other path: nothing about it is durably + * retained, so it must never be reported as a leak. + */ + public static final String NOISE_CHAIN_MARKER = "[correlation-noise-chain-emitted] "; + + /** + * Printed (with the tag) if no correlated in-pool byte[] chain was the chain the identity seam + * resolves for a live {@link #LEAK_SINK} chunk. -DDEBUG builds only. + */ + public static final String NOT_OWNER_VERIFIED_MARKER = "[correlation-not-owner-verified] "; + + /** + * Printed (with the tag) if, after the verified owner chunk was dropped, every dump still + * emitted a byte[] chain under the owner's {@code (tag, leakTagIncarnation)} - the dead + * owner's cached chain outliving its tag (ownership-stamp regression). -DDEBUG builds only. + */ + public static final String STALE_OWNER_CHAIN_MARKER = "[correlation-stale-owner-chain] "; + + /** Dumps the stale-owner check waits through for the dropped chunk's tag to be released. */ + private static final int STALE_CHECK_ATTEMPTS = 10; + + /** + * Scan outcome: payload fields from one dump; transientViolations/noiseChains accumulated + * across all scans by keepBest(). + */ + private static final class ScanResult { + ReferenceChainAssertions.ChainMatch payloadChain; + boolean payloadChainInPool; + // In-pool and (on -DDEBUG builds) resolved by the identity seam for a live LEAK_SINK chunk. + boolean payloadChainOwnerVerified; + // LEAK_SINK index of that chunk at scan time; -1 when not verified or on release builds. + int payloadOwnerIndex = -1; + // ReferenceChain.leakTagIncarnation of payloadChain; -1 when the field is absent. + long payloadChainIncarnation = -1; + int liveObjectMatchesForChainTag; + final List transientViolations = new ArrayList<>(); + final List noiseChains = new ArrayList<>(); + } + + public static void run(JavaProfiler profiler, String startCommand, Path scratchDumpPath) + throws Exception { + // Build the whole fixture before the profiler starts - see the class comment and + // LeakingCacheScenario's own seed-before-start rationale. + for (int i = 0; i < SEED_CHUNKS; i++) { + addChunk(); + } + for (int row = 0; row < FILLER.length; row++) { + Object[] column = new Object[64]; + for (int col = 0; col < column.length; col++) { + column[col] = new FillerNode(); + } + FILLER[row] = column; + } + NoiseHandoff handoff = new NoiseHandoff(); + Thread noiseThread = new Thread(() -> { + // Held ONLY by this live frame across the process's whole life, so the one-shot + // root-seeded walk (a few seconds away) admits them as stack_local-rooted entries. + NoisePayload[] held = new NoisePayload[64]; + for (int i = 0; i < held.length; i++) { + held[i] = new NoisePayload(); + } + // The tid must be captured on THIS thread: the per-(klass, tid) seeding below + // qualifies the allocating thread, and the noise instances are all allocated here. + handoff.publish(held[0], ReferenceChainTestHooks.getTid()); + long slot = 0; + while (true) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + return; + } + // Churn one slot at a time: the population stays live-but-ephemeral (constant live + // count, nothing durably retained beyond the frame) while still allocating. + // held[0] is reserved: it is the published noise representative, and after + // handoff.take() this frame is its only strong reference (the tracker holds just a + // weak ref) - churning it would let the representative be collected. + held[1 + (int) (slot++ % (held.length - 1))] = new NoisePayload(); + } + }, "leak-correlation-noise"); + noiseThread.setDaemon(true); + noiseThread.start(); + + if (startCommand != null && !startCommand.isEmpty()) { + profiler.execute(startCommand); + } + + boolean debugBuild = ReferenceChainAssertions.isDebugInstrumentedBuild(); + int leakTid = 0; + int noiseTid = 0; + if (debugBuild) { + // Representatives FIRST, seeds second: since the leak-tag pool redesign, candidate + // matching, leak-tag assignment (tagLeakInstances scanning the live-heap tracking + // table), and discovered-instance recording are all keyed on the REAL klass id - + // setKlassPopulationRepresentativeForTest0 resolves each representative's real id + // and aliases the synthetic id to it, and the hysteresis seeds below then land in + // the real entry (seeding first would leave them under the synthetic id, where they + // authorize nothing but hasLeakSignal's candidate-count check). + // The leak representative is durably reachable via LEAK_SINK for the rest of the + // process's life; the noise representative only via the noise thread's frame (the + // handoff's own reference is dropped by take()) - exactly what makes one a valid + // leak candidate and the other a gate test. + // The noise thread publishes asynchronously: wait for it, or take() below could + // return null (no alias to the real NoisePayload klass) and handoff.tid could be 0 + // (no real allocating tid qualified) - noise suppression would then go unexercised. + // Plain volatile read, NOT take(): take() drops the handoff's reference. + long noisePublicationDeadline = System.nanoTime() + 30_000_000_000L; + while (handoff.representative == null && System.nanoTime() < noisePublicationDeadline) { + Thread.sleep(10); + } + if (handoff.representative == null || handoff.tid <= 0) { + throw new IllegalStateException("Noise thread did not publish a representative and tid"); + } + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(LEAK_KLASS_ID, LEAK_SINK.get(0)); + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(NOISE_KLASS_ID, handoff.take()); + // Per-(klass, tid) qualification seeds: selectLeakCandidates() now also + // requires a qualifying ALLOCATING THREAD, and tagLeakInstances() scopes leak + // tags to exactly those tids' instances - so the seeded qualification must + // name the REAL allocating tids (this thread for the leak chunks, the noise + // thread for the noise payloads) or no tracked instance would ever match + // the tagging scope. Same epoch-aligned ramp shape as the klass seeds + // (scaled to fit the per-tid u8 ring); the synthetic flag keeps these + // ramps immune to the real fold's absent-tid decay between rounds. + leakTid = ReferenceChainTestHooks.getTid(); + noiseTid = handoff.tid; + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(LEAK_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedKlassPopulationSample0(NOISE_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(LEAK_KLASS_ID, leakTid, epoch * 3, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(NOISE_KLASS_ID, noiseTid, epoch * 3, epoch); + } + // Maintenance continues the seeded ramp instead of restarting at 1. + hysteresisEpoch = SEED_EPOCHS_FOR_HYSTERESIS; + // Wait for the BFS thread to fully complete its first pass (static sweep AND frontier + // expansion) before any growth round - same load-bearing wait and rationale as + // StaticFieldGrowingCollectionScenario: the late chunks added afterward must be + // discovered via rotation re-walks, not by a first pass that has not even run yet. + int initialPasses = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + for (int i = 0; i < 300; i++) { + if (ReferenceChainTestHooks.referenceChainPassesRunForTest0() != initialPasses) { + break; + } + Thread.sleep(100); + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + } else { + // No test seams outside -DDEBUG builds (debug/asan/tsan) - give the real allocation sampler and the + // pass loop time to notice the seeded population on their own. + Thread.sleep(5000); + } + + // Each dump re-emits every resolved chain (drainPendingChainEvents()'s + // snapshot-and-keep contract), so a later dump's scan is a superset state - the + // latest scan with a payload chain observed is always the best payload evidence. Gate + // violations are NOT taken from the latest scan alone: keepBest() accumulates them + // across every scan (a violating resolved chain may be invalidated/evicted before a + // later dump, and the gates are 'ever emitted' contracts). + ScanResult best = null; + int totalRounds = 25; + // Slow growth per round is deliberate: each chunk clears the allocation + // sampler's floor on its own (see CHUNK_BYTES), so every allocation is + // sampled, tracked, and taggable - the per-round population growth is + // only the trend signal, not a sampling-volume requirement. + for (int round = 1; round <= totalRounds; round++) { + addChunk(); + if (debugBuild) { + hysteresisEpoch++; + ReferenceChainTestHooks.seedKlassPopulationSample0( + LEAK_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedKlassPopulationSample0( + NOISE_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0( + LEAK_KLASS_ID, leakTid, hysteresisEpoch * 3, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0( + NOISE_KLASS_ID, noiseTid, hysteresisEpoch * 3, hysteresisEpoch); + } + System.gc(); + profiler.dump(scratchDumpPath); + // Scan only every 3rd round: each scan's JMC parse allocates megabytes + // on this same JVM, and that churn races the leak chunks for space in + // LivenessTracker's tracking table (the age-priority tagging then + // selects old JVM-machinery survivors instead of the young chunks - + // observed live as tagged=5 stable but zero interceptions). Less + // scan churn gives the chunks time to accumulate surviving ages and + // win the tagging priority. Rounds are still dumped every time so + // the final dump always holds the freshest chains. + if (round % 3 == 0) { + // The identity seam is read after the dump: a chain in the dump for a live chunk stays + // cached (snapshot-and-keep), and chains built since only add keys the dump lacks. + best = keepBest(best, scan(scratchDumpPath, debugBuild ? liveOwnerTags() : null)); + if (correlated(best)) { + break; // full correlation achieved - no need to grow further + } + } + Thread.sleep(300); + } + + for (int attempt = 0; attempt < 5; attempt++) { + if (correlated(best)) { + break; + } + if (debugBuild) { + hysteresisEpoch++; + ReferenceChainTestHooks.seedKlassPopulationSample0( + LEAK_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedKlassPopulationSample0( + NOISE_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0( + LEAK_KLASS_ID, leakTid, hysteresisEpoch * 3, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0( + NOISE_KLASS_ID, noiseTid, hysteresisEpoch * 3, hysteresisEpoch); + } + Thread.sleep(1000); + System.gc(); + profiler.dump(scratchDumpPath); + best = keepBest(best, scan(scratchDumpPath, debugBuild ? liveOwnerTags() : null)); + } + + // Gate violations are 'ever emitted' contracts, independent of whether a payload chain + // was found - report them first so a NOT_FOUND result (accepted by the tight-window + // variant) can never mask a suppression regression. + if (best != null && !best.transientViolations.isEmpty()) { + System.out.println(TRANSIENT_CHAIN_MARKER + best.transientViolations); + return; + } + if (best != null && !best.noiseChains.isEmpty()) { + System.out.println(NOISE_CHAIN_MARKER + best.noiseChains); + return; + } + // Marker order mirrors the evidence ranking in evidenceScore() below: + // out-of-pool (0) < unverified in-pool (1) < owner-verified in-pool (2) - + // report the weakest evidence first, and keep this cascade in lockstep with + // evidenceScore() if the ranking ever changes. + if (best == null || best.payloadChain == null) { + System.out.println(NOT_FOUND_MARKER); + return; + } + if (!best.payloadChainInPool) { + System.out.println(TAG_OUT_OF_POOL_MARKER + best.payloadChain.targetTag); + return; + } + if (!best.payloadChainOwnerVerified) { + System.out.println(NOT_OWNER_VERIFIED_MARKER + best.payloadChain.targetTag); + return; + } + if (best.liveObjectMatchesForChainTag == 0) { + System.out.println(NO_LIVE_OBJECT_MARKER + best.payloadChain.targetTag); + return; + } + if (debugBuild) { + long ownedTag = best.payloadChain.targetTag; + long ownedIncarnation = best.payloadChainIncarnation; + // Drop the verified owner: its tracked entry dies, cleanup_table() releases the leak tag + // (stamp bump), and the next acquire may hand the same tag to another object. The dead + // owner's chain must never be emitted under its old (tag, stamp) again. The removed chunk + // is deliberately not kept in a local. + LEAK_SINK.remove(best.payloadOwnerIndex); + boolean clean = false; + for (int attempt = 0; attempt < STALE_CHECK_ATTEMPTS && !clean; attempt++) { + System.gc(); + Thread.sleep(500); + profiler.dump(scratchDumpPath); + clean = !hasByteArrayChain(scratchDumpPath, ownedTag, ownedIncarnation) + // Without the stamp field the tag alone cannot tell a new owner from the dead one. + || (ownedIncarnation < 0 && liveOwnerTags().containsKey(ownedTag)); + } + if (!clean) { + System.out.println(STALE_OWNER_CHAIN_MARKER + ownedTag); + return; + } + } + System.out.println(FOUND_MARKER + best.payloadChain.targetTag); + } + + private static boolean correlated(ScanResult r) { + return r != null && r.payloadChainInPool && r.payloadChainOwnerVerified + && r.liveObjectMatchesForChainTag > 0; + } + + /** + * Leak tag -> LEAK_SINK index of the chunk whose own cached chain carries it, via the identity + * seam (0 means no chain resolved for that chunk). Stores indices, never chunk references, so + * an owner can still die. + */ + private static Map liveOwnerTags() { + Map owners = new HashMap<>(); + for (int i = 0; i < LEAK_SINK.size(); i++) { + long tag = + ReferenceChainTestHooks.getReferenceChainResolvedTargetTagForTest0(LEAK_SINK.get(i)); + if (tag != 0) { + owners.put(tag, i); + } + } + return owners; + } + + /** + * Whether the dump holds a byte[] chain with this targetTag and leakTagIncarnation (-1 matches + * a chain without the field). + */ + private static boolean hasByteArrayChain(Path scratchDumpPath, long tag, long incarnation) + throws Exception { + if (!Files.exists(scratchDumpPath)) { + return false; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chains = events.apply( + ItemFilters.type("datadog.ReferenceChain")); + for (IItemIterable iterable : chains) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = ReferenceChainAssertions.findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = + ReferenceChainAssertions.findAccessor(type, "targetTag"); + IMemberAccessor incarnationAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTagIncarnation"); + if (chainAccessor == null || targetTagAccessor == null) { + continue; + } + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (!(chainValue instanceof Object[])) { + continue; + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0 || !(rawChain[0] instanceof IMCType) + || !"byte[]".equals(((IMCType) rawChain[0]).getFullName())) { + continue; + } + long chainIncarnation = incarnationAccessor != null + ? ReferenceChainAssertions.numberValue(incarnationAccessor.getMember(item)) : -1; + if (ReferenceChainAssertions.numberValue(targetTagAccessor.getMember(item)) == tag + && chainIncarnation == incarnation) { + return true; + } + } + } + return false; + } + + /** + * Payload state comes from the latest scan that saw a payload chain (a scan without one + * never displaces one that had it). Gate violations (transient/noise chains) are + * accumulated across ALL scans - they are 'ever emitted' contracts, and a violating + * resolved chain may be invalidated/evicted before a later dump. + */ + private static ScanResult keepBest(ScanResult best, ScanResult scan) { + if (scan == null) { + return best; + } + if (best == null) { + return scan; + } + // Same evidence ranking scan() applies within one dump: an owner-verified + // in-pool chain (2) outranks an unverified in-pool one (1), which outranks + // any out-of-pool chain (0). A later scan only wins on equal or better + // evidence, so noise churn and tag recycling cannot displace the + // best-evidenced correlation mid-run. + int bestScore = evidenceScore(best); + int scanScore = evidenceScore(scan); + ScanResult result = scanScore > bestScore || (scanScore == bestScore + && scan.payloadChain != null) ? scan : best; + ScanResult other = result == scan ? best : scan; + // Both non-null here: the early returns above already handled a null scan + // (kept best) and a null best (kept scan), so the loser always exists. + mergeDistinct(result.transientViolations, other.transientViolations); + mergeDistinct(result.noiseChains, other.noiseChains); + return result; + } + + private static int evidenceScore(ScanResult scan) { + if (scan.payloadChain == null) { + return -1; + } + return evidenceRank(scan.payloadChainInPool, scan.payloadChainOwnerVerified); + } + + // The single definition of the scan-evidence ranking: owner-verified in-pool (2) + // > unverified in-pool (1) > out-of-pool (0). scan()'s within-dump candidate + // selection and keepBest()'s cross-dump merge both derive from this, so a + // ranking change cannot leave one site disagreeing with the others. + private static int evidenceRank(boolean inPool, boolean ownerVerified) { + return inPool ? (ownerVerified ? 2 : 1) : 0; + } + + private static void mergeDistinct(List into, List from) { + for (String s : from) { + if (!into.contains(s)) { + into.add(s); + } + } + } + + /** + * Scans one dump: the best leaked {@code byte[]} chain (if any), whether its targetTag is + * in the leak-tag pool and owned by a live LEAK_SINK chunk, how many + * {@code datadog.HeapLiveObject} events for a leaked {@code byte[]} carry that same + * {@code (leakTag, leakTagIncarnation)}, plus the two gate regressions (transient-rooted + * depth<=1 chains, any {@link NoisePayload} chain). + * + * @param liveOwners {@link #liveOwnerTags()} read after this dump, or null on release builds + * (no identity seam: any in-pool chain counts as owner-verified) + */ + private static ScanResult scan(Path scratchDumpPath, Map liveOwners) + throws Exception { + if (!Files.exists(scratchDumpPath)) { + return null; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + ScanResult result = new ScanResult(); + + IItemCollection chains = events.apply( + ItemFilters.type("datadog.ReferenceChain")); + for (IItemIterable iterable : chains) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = ReferenceChainAssertions.findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = + ReferenceChainAssertions.findAccessor(type, "targetTag"); + IMemberAccessor depthAccessor = ReferenceChainAssertions.findAccessor(type, "depth"); + IMemberAccessor rootKindAccessor = + ReferenceChainAssertions.findAccessor(type, "rootKind"); + IMemberAccessor incarnationAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTagIncarnation"); + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (!(chainValue instanceof Object[])) { + continue; + } + Object[] rawChain = (Object[]) chainValue; + String leaf = rawChain.length > 0 && rawChain[0] instanceof IMCType + ? ((IMCType) rawChain[0]).getFullName() : ""; + long targetTag = targetTagAccessor != null + ? ReferenceChainAssertions.numberValue(targetTagAccessor.getMember(item)) : -1; + int depth = depthAccessor != null + ? (int) ReferenceChainAssertions.numberValue(depthAccessor.getMember(item)) : -1; + Object rootKind = rootKindAccessor != null ? rootKindAccessor.getMember(item) : null; + String rootKindName = rootKind != null ? rootKind.toString() : ""; + if (rootKindName.startsWith("first_observed_via:") && depth <= 1) { + result.transientViolations.add(leaf + "@depth" + depth + ":" + rootKindName); + } + if (NoisePayload.class.getName().equals(leaf)) { + result.noiseChains.add(leaf + "@depth" + depth + ":" + rootKindName); + } + if ("byte[]".equals(leaf)) { + boolean inPool = + targetTag >= LEAK_TAG_BASE && targetTag < LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE; + // Multiple byte[] chains exist in a single dump: every discovered + // instance of the candidate class gets one, and only the tracked-and- + // tagged instances carry a pool-range targetTag - the rest legitimately + // carry their frontier tag (the correlation is additive on top of the + // chain, not a property of every chain). Prefer the first owner-verified + // in-pool chain, then any in-pool one, over an out-of-pool one seen earlier. + boolean ownerVerified = + inPool && (liveOwners == null || liveOwners.containsKey(targetTag)); + int score = evidenceRank(inPool, ownerVerified); + int currentScore = result.payloadChain == null ? -1 + : evidenceRank(result.payloadChainInPool, result.payloadChainOwnerVerified); + if (score > currentScore) { + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add((IMCType) element); + } + result.payloadChain = new ReferenceChainAssertions.ChainMatch( + chain, targetTag, depth); + result.payloadChainInPool = inPool; + result.payloadChainOwnerVerified = ownerVerified; + result.payloadOwnerIndex = + ownerVerified && liveOwners != null ? liveOwners.get(targetTag) : -1; + result.payloadChainIncarnation = incarnationAccessor != null + ? ReferenceChainAssertions.numberValue(incarnationAccessor.getMember(item)) : -1; + } + } + } + } + + if (result.payloadChainInPool) { + IItemCollection liveObjects = events.apply( + ItemFilters.type("datadog.HeapLiveObject")); + for (IItemIterable iterable : liveObjects) { + IType type = iterable.getType(); + IMemberAccessor leakTagAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTag"); + IMemberAccessor objectClassAccessor = + ReferenceChainAssertions.findAccessor(type, "objectClass"); + IMemberAccessor incarnationAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTagIncarnation"); + // Fail loud if either identity field is missing, mirroring + // ThreadLocalLeakScenario's guard: without the tag every live object + // reads -1 and never matches, without the incarnation the match + // degrades to tag-only - both must surface as errors, not as an empty + // correlation with no diagnostic. + if (leakTagAccessor == null) { + throw new IllegalStateException( + "datadog.HeapLiveObject events have no leakTag accessor"); + } + if (incarnationAccessor == null) { + throw new IllegalStateException( + "datadog.HeapLiveObject events have no leakTagIncarnation accessor"); + } + for (IItem item : iterable) { + long leakTag = + ReferenceChainAssertions.numberValue(leakTagAccessor.getMember(item)); + long incarnation = + ReferenceChainAssertions.numberValue(incarnationAccessor.getMember(item)); + // Leak tags are recycled: the tag alone is ambiguous across chunks. + if (leakTag != result.payloadChain.targetTag + || incarnation != result.payloadChainIncarnation) { + continue; + } + Object objectClass = objectClassAccessor != null + ? objectClassAccessor.getMember(item) : null; + if (objectClass instanceof IMCType + && "byte[]".equals(((IMCType) objectClass).getFullName())) { + result.liveObjectMatchesForChainTag++; + } + } + } + } + return result; + } + + /** + * Chunk size that clears ObjectSampler's real 256KiB sampling floor on its own - same + * rationale as StaticFieldGrowingCollectionScenario.CHUNK_BYTES. Load-bearing here: + * every chunk allocation must be allocation-sampled so it becomes a live-heap tracked + * instance (the only instances LivenessTracker's leak-tag pool tags, and the only ones + * that get datadog.HeapLiveObject events to correlate against). A small leaked object + * only gets sampled probabilistically, which made the correlation dependent on + * sampling luck - observed live as pass-standalone/fail-in-suite before this. + */ + private static final int CHUNK_BYTES = 1_000_000; + + /** + * The noise object: deliberately ephemeral - held only by the noise thread's live + * frame, never by any field, so nothing about it is durably retained. + */ + static final class NoisePayload { + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + } + + /** Scale-only object: no references, just weight - one frontier entry each. */ + static final class FillerNode { + long p0, p1, p2, p3, p4, p5, p6, p7; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java new file mode 100644 index 0000000000..f23d5f2312 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/LeakingCacheScenario.java @@ -0,0 +1,300 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; + +/** + * The "leaking Java app" body run inside a separate child JVM by {@code ExternalLauncher}'s + * {@code leak-cache} mode, driven by {@code ExternalProcessReferenceChainTest}. Deliberately not a + * JUnit test: it must run alone in its own process so the process-wide {@code ReferenceChainTracker} + * singleton's one-shot root-seeded BFS walk belongs to this scenario only (see + * {@code ExternalProcessReferenceChainTest}'s class comment). The leak shape is an ever-growing, + * never-evicted {@code HashMap}-backed cache. + * + *

    Why {@code cache} is populated before the profiler is started: + * {@code threadLoop()} (referenceChains.cpp) sleeps {@code _effective_cadence_ns} - 1 second, + * initially - before its first {@code shouldRunPass()} check, which always returns {@code true} + * on that first check ({@code !_search_started}). So the one-and-only root-seeded walk fires + * roughly one second after {@code startThread()}, or earlier if woken by a GC-finish signal. On a + * cold JVM, allocating only after starting the profiler lost that race essentially every time: + * the walk caught {@code cache} still empty, permanently blocking discovery of everything added + * afterward (a frontier entry once marked {@code EXPANDED} is never revisited for new children - + * see {@code expandFrontier()}). Seeding before {@code profiler.execute()} removes the race. + */ +public final class LeakingCacheScenario { + private LeakingCacheScenario() {} + + // Arbitrary klass id - LivenessTracker treats it as an opaque key (see KlassPopulationEntry) + // and this process runs no other reference-chains scenario, so it cannot collide. + private static final int CACHED_PAYLOAD_TEST_KLASS_ID = 987301; + + // LivenessTracker's hysteresis gate (livenessTracker.h's KLASS_POPULATION_MIN_FILL_FOR_TREND=10 / + // LEAK_TREND_HYSTERESIS_BASE=5) only starts incrementing consecutive_positive once ring_fill + // reaches 10, so exactly 10 seedKlassPopulationSample0() calls cap consecutive_positive at 1 - + // short of what selectLeakCandidates() requires before hasLeakSignal() reports a candidate, which + // generations=true needs to authorize the BFS thread's first real pass. + private static final int SEED_EPOCHS_FOR_HYSTERESIS = 15; + + /** + * Durable holder for the cache fixture. The discovered-chain gate suppresses + * chains shallower than the first real holder hop that are rooted at a TRANSIENT + * root (stack/JNI local) - a frame-held "leak" is not a leak - so the cache must + * be retained through a static field rather than only {@code run()}'s local. + */ + private static final Map CACHE = new HashMap<>(); + + /** Printed to stdout, followed by the matched leaf class's name, on success. */ + public static final String FOUND_MARKER = "[chain-found] "; + + /** Printed to stdout (with no class name suffix) if no match was ever observed. */ + public static final String NOT_FOUND_MARKER = "[chain-not-found]"; + + /** + * Printed to stdout (with the offending chain appended) if a match was found but its + * chain did not pass through {@code java.util.HashMap}'s internal storage. + */ + public static final String NO_HASHMAP_INTERNALS_MARKER = "[chain-found-no-hashmap-internals] "; + + /** + * Printed to stdout (with an "N/M" re-emitted-dump count appended) if the chain was + * reconstructed but did not re-emit into one or more subsequent JFR dumps - i.e. the + * "snapshot-and-keep" contract of {@code ReferenceChainTracker::drainPendingChainEvents()} + * (referenceChains.cpp) failed to hold across dumps. + */ + public static final String CHAIN_NOT_PERSISTED_MARKER = "[chain-not-persisted] "; + + /** + * Seeds {@code cache}, *then* starts the profiler (see the class comment for why that order is + * load-bearing), then grows the cache, dumping to {@code scratchDumpPath} after every round + * until a {@code datadog.ReferenceChain} event whose {@code chain[0]} is {@link CachedPayload} + * appears, and prints exactly one result line to stdout. The profiler is started here rather + * than in {@code ExternalLauncher} so {@code cache} can be seeded first. + */ + public static void run(JavaProfiler profiler, String startCommand, Path scratchDumpPath) throws Exception { + Map cache = CACHE; + seedInitialBatch(cache, 300); + System.out.println("[debug] startCommand=" + startCommand); + System.out.println("[debug] scratchDumpPath=" + scratchDumpPath); + if (startCommand != null && !startCommand.isEmpty()) { + profiler.execute(startCommand); + } + System.out.println("[debug] profiler.execute() returned"); + + boolean debugBuild = ReferenceChainAssertions.isDebugInstrumentedBuild(); + boolean seededTestKlassTrend = false; + ReferenceChainAssertions.ChainMatch match = null; + int totalRounds = 25; + for (int round = 1; round <= totalRounds && match == null; round++) { + int newEntries = round * 300; + int roundNumber = round; + Thread allocator = new Thread(() -> { + for (int i = 0; i < newEntries; i++) { + String key = "leak-" + roundNumber + "-" + i; + cache.put(key, new CachedPayload(key)); + } + }); + allocator.start(); + allocator.join(); + System.gc(); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + + if (match == null && debugBuild) { + // Debug-only seeded short-circuit: decouples the assertion from whether probabilistic + // allocation sampling notices CachedPayload's trend within the fixed round budget, by + // clearing hasLeakSignal()'s hysteresis gate outright. "seed-0" stays reachable via + // cache for the whole run, so it is a valid representative on any round. + // Representative BEFORE seeding: setKlassPopulationRepresentativeForTest0 aliases the + // synthetic id to the representative's real klass id, so the seeds below land in the + // real population entry (candidate matching is keyed by real id). + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(CACHED_PAYLOAD_TEST_KLASS_ID, cache.get("seed-0")); + if (!seededTestKlassTrend) { + // The qualifying tid is THIS thread's - the creator of "seed-0". The per-round + // allocator threads are one-shot, so none of their tids carries a sustained + // per-tid signal. + int leakTid = ReferenceChainTestHooks.getTid(); + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(CACHED_PAYLOAD_TEST_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(CACHED_PAYLOAD_TEST_KLASS_ID, leakTid, epoch * 3, epoch); + } + seededTestKlassTrend = true; + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + + if (match == null) { + Thread.sleep(300); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + if (round == 1 || round == 5 || round == 10 || round == 25) { + System.out.println("[debug] round=" + round + " cache.size()=" + cache.size()); + debugDumpAllChainLeaves(scratchDumpPath); + } + } + + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath); + } + + if (match == null) { + debugDumpAllChainLeaves(scratchDumpPath); + debugDumpCounters(profiler); + System.out.println(NOT_FOUND_MARKER); + return; + } + boolean sawHashMapInternals = false; + for (IMCType type : match.chain) { + if (type.getFullName().startsWith("java.util.HashMap")) { + sawHashMapInternals = true; + break; + } + } + if (!sawHashMapInternals) { + System.out.println(NO_HASHMAP_INTERNALS_MARKER + match.chain); + return; + } + + // Across-dumps persistence: Profiler::dump() re-snapshots the resolved-chain cache without + // clearing it (drainPendingChainEvents(), "snapshot-and-keep, not a drain"), so a still-live + // sample's chain must re-emit into every dump. `cache` keeps the sample live; each dump + // targets a brand-new file, so a match there can only come from re-emission. + int persistChecks = 3; + int persisted = 0; + for (int i = 1; i <= persistChecks; i++) { + Path reDumpPath = Files.createTempFile("referencechains-persist-" + i + "-", ".jfr"); + Files.deleteIfExists(reDumpPath); + try { + profiler.dump(reDumpPath); + if (findMatch(reDumpPath) != null) { + persisted++; + } else { + System.out.println("[debug] across-dumps: CachedPayload chain missing from fresh dump #" + i); + } + } finally { + Files.deleteIfExists(reDumpPath); + } + } + if (persisted != persistChecks) { + System.out.println(CHAIN_NOT_PERSISTED_MARKER + persisted + "/" + persistChecks); + return; + } + System.out.println("[debug] across-dumps: CachedPayload chain re-emitted in all " + + persistChecks + " fresh dumps"); + System.out.println(FOUND_MARKER + match.chain.get(0).getFullName()); + } + + /** Key namespace "seed-" is disjoint from the round loop's "leak-". */ + private static void seedInitialBatch(Map cache, int count) { + for (int i = 0; i < count; i++) { + String key = "seed-" + i; + cache.put(key, new CachedPayload(key)); + } + } + + private static void debugDumpCounters(JavaProfiler profiler) { + try { + Map counters = profiler.getDebugCounters(); + System.out.println("[debug] counters (" + counters.size() + " total):"); + counters.entrySet().stream() + .filter(e -> e.getValue() != 0) + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> System.out.println("[debug] " + e.getKey() + " = " + e.getValue())); + } catch (Exception e) { + System.out.println("[debug] exception while dumping counters: " + e); + } + } + + private static void debugDumpAllChainLeaves(Path scratchDumpPath) { + try { + if (!Files.exists(scratchDumpPath)) { + System.out.println("[debug] scratch dump path does not exist: " + scratchDumpPath); + return; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chains = events.apply(ItemFilters.type("datadog.ReferenceChain")); + long total = chains.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.ReferenceChain total events: " + total); + IItemCollection liveObjects = events.apply(ItemFilters.type("datadog.HeapLiveObject")); + long totalLive = liveObjects.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.HeapLiveObject total events: " + totalLive); + IItemCollection abandoned = events.apply(ItemFilters.type("datadog.ReferenceChainAbandoned")); + long totalAbandoned = abandoned.stream().mapToLong(it -> it.getItemCount()).sum(); + System.out.println("[debug] datadog.ReferenceChainAbandoned total events: " + totalAbandoned); + for (IItemIterable iterable : chains) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = + ReferenceChainAssertions.findAccessor(type, "chain"); + if (chainAccessor == null) { + System.out.println("[debug] no chain accessor found"); + continue; + } + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (chainValue instanceof Object[]) { + Object[] raw = (Object[]) chainValue; + String leaf = raw.length > 0 && raw[0] instanceof IMCType + ? ((IMCType) raw[0]).getFullName() : ""; + System.out.println("[debug] chain leaf: " + leaf); + } + } + } + } catch (Exception e) { + System.out.println("[debug] exception while dumping chain leaves: " + e); + } + } + + private static ReferenceChainAssertions.ChainMatch findMatch(Path scratchDumpPath) throws Exception { + if (!Files.exists(scratchDumpPath)) { + return null; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + return ReferenceChainAssertions.findMatchForClass( + events.apply(ItemFilters.type("datadog.ReferenceChain")), CachedPayload.class); + } + + /** + * Leak fixture. The 32 {@code long} fields exist so far fewer entries reach + * {@code ObjectSampler}'s 256KiB sampling floor - plain fields, not a nested array, because a + * companion array would be a second allocation competing for the size-weighted sampler's + * attention. + */ + static final class CachedPayload { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedPayload(String key) { + this.key = key; + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/PersistentAllocator.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/PersistentAllocator.java new file mode 100644 index 0000000000..02f7b08b9d --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/PersistentAllocator.java @@ -0,0 +1,196 @@ +/* + * Copyright 2026, Datadog, Inc. All rights reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.ReferenceChainTestHooks; + +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.SynchronousQueue; +import java.util.concurrent.TimeUnit; + +/** + * A single long-lived thread that runs caller-supplied allocation batches on demand. + * + *

    Tests use one persistent thread so every instance of the leak klass is allocated by the + * same thread: a fresh short-lived thread per round gets a new tid each time, and the per-tid + * trend qualification ages out before the population trend can establish itself (the flake the + * {@code @Order(1)} test documents). Callers must also seed with this thread's tid, obtained + * from {@link #tid()} - captured on the allocator thread itself, never the caller's. + * + *

    {@link #run(Runnable)} blocks until the batch completes and rethrows any failure from the + * allocator thread, so allocations are finished (and any exception surfaced) before the caller + * proceeds to {@code System.gc()} or a dump. + */ +final class PersistentAllocator implements AutoCloseable { + // Poison task handed to the worker by close(); identity compared, never run. + private static final Runnable CLOSE = () -> { }; + + private static final long HANDOFF_TIMEOUT_SECONDS = 30; + + // One unit of work handed to the worker; carries the caller-side generation + // so the worker can echo it back in the Outcome. The CLOSE poison rides in a + // Job too (generation -1, never a real generation). + private static final class Job { + final Runnable task; + final int generation; + + Job(Runnable task, int generation) { + this.task = task; + this.generation = generation; + } + } + + // The worker's completion entry; the generation identifies which run() + // produced it, so a stale outcome left by an abandoned (timed-out) run is + // discarded by the caller instead of being misattributed to the next task. + private static final class Outcome { + final int generation; + final Object result; // Object marker = success, a Throwable = the batch's failure + + Outcome(int generation, Object result) { + this.generation = generation; + this.result = result; + } + } + + private final SynchronousQueue handoff = new SynchronousQueue<>(); + // Worker-to-caller completion slot: an Outcome with the caller's generation + // = this task's verdict (Object marker = success, a Throwable = the batch's + // failure). It is buffered (one slot) because the protocol is single-in-flight, + // and the worker's offer() must retain the signal even when the batch finishes + // in the window between the handoff rendezvous and the caller entering poll(): + // a SynchronousQueue would drop that bare offer and turn a completed batch + // (or its rethrown failure) into a 30-second poll timeout. + private final ArrayBlockingQueue completion = new ArrayBlockingQueue<>(1); + private final Thread thread; + private volatile int tid; + private volatile boolean closed; + // Monotonic per-run() token; only the caller thread runs tasks through run(), + // so plain int arithmetic is sufficient. + private int nextGeneration; + + PersistentAllocator(String name) { + thread = new Thread(this::loop, name); + // Daemon: a test that dies before close() must not hold the JVM open on handoff.take(). + thread.setDaemon(true); + thread.start(); + // The caller may call tid() immediately after construction (seed-before-wait ordering); + // do not return until the allocator thread has published it. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(HANDOFF_TIMEOUT_SECONDS); + while (tid == 0 && thread.isAlive()) { + if (System.nanoTime() - deadline > 0) { + throw new IllegalStateException("allocator thread " + name + " never published its tid"); + } + Thread.yield(); + } + if (tid == 0) { + throw new IllegalStateException("allocator thread " + name + " died before publishing its tid"); + } + } + + /** The allocator thread's OS tid, captured by that thread itself at startup. */ + int tid() { + return tid; + } + + /** + * Runs {@code task} on the allocator thread, blocking until it completes. A task that throws + * is rethrown on the caller's thread, so a failed batch fails the test instead of stalling + * the next round on partially-allocated state. + */ + void run(Runnable task) { + if (closed || !thread.isAlive()) { + throw new IllegalStateException("allocator thread " + thread.getName() + " is no longer running"); + } + try { + // A previous run() that timed out may still have its outcome parked in + // the slot (the worker finishes the abandoned task and offers into the + // then-empty queue). Drop what is already there; the generation check + // below then discards any stale outcome that lands after this clear - + // a plain clear alone cannot close that window, because the worker only + // parks at handoff.take() AFTER offering the abandoned task's outcome. + completion.clear(); + int generation = nextGeneration++; + if (!handoff.offer(new Job(task, generation), HANDOFF_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + throw new IllegalStateException("allocator thread " + thread.getName() + " did not accept a task"); + } + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(HANDOFF_TIMEOUT_SECONDS); + while (true) { + Outcome outcome = completion.poll(deadline - System.nanoTime(), TimeUnit.NANOSECONDS); + if (outcome == null) { + throw new IllegalStateException("allocator thread " + thread.getName() + " never finished a task"); + } + // Discard an abandoned earlier run()'s outcome and keep waiting for this + // generation's verdict. The stale entry is always offered before our own: + // the worker offers it before looping back to handoff.take(), through + // which alone our Job can be accepted. + if (outcome.generation != generation) { + continue; + } + if (outcome.result instanceof RuntimeException) { + throw (RuntimeException) outcome.result; + } + if (outcome.result instanceof Error) { + throw (Error) outcome.result; + } + return; + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("interrupted while waiting for allocator thread " + thread.getName(), e); + } + } + + /** Stops the allocator thread and waits for it to exit. Idempotent. */ + @Override + public void close() { + if (closed) { + return; + } + closed = true; + try { + if (thread.isAlive() && handoff.offer(new Job(CLOSE, -1), HANDOFF_TIMEOUT_SECONDS, TimeUnit.SECONDS)) { + // Drain the exit acknowledgement so close() returns only after the thread is done. + completion.poll(HANDOFF_TIMEOUT_SECONDS, TimeUnit.SECONDS); + } + thread.join(TimeUnit.SECONDS.toMillis(HANDOFF_TIMEOUT_SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + private void loop() { + tid = ReferenceChainTestHooks.getTid(); + try { + while (true) { + Job job = handoff.take(); + if (job.task == CLOSE) { + // put (blocking), not offer: after an abandoned run() the slot can still + // hold that run's stale outcome, and a dropped exit acknowledgement would + // leave close()'s poll draining the stale entry instead. close() always + // drains exactly one entry after the handoff, which unblocks this put. + completion.put(new Outcome(job.generation, new Object())); // exit acknowledgement; no further work arrives + return; + } + Object outcome = null; + try { + job.task.run(); + } catch (RuntimeException | Error t) { + outcome = t; + } + // put (blocking), not offer: with one slot, a stale outcome left by an + // abandoned (timed-out) run() can still occupy the queue when this + // task finishes, and a dropped outcome would turn a completed task into + // the caller's 30s poll timeout. The caller's generation loop drains the + // stale entry (discard-by-generation), which unblocks this put; if the + // caller already gave up, the next run()'s clear() or close()'s poll does. + completion.put(new Outcome(job.generation, outcome != null ? outcome : new Object())); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} 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 new file mode 100644 index 0000000000..735720170f --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainAssertions.java @@ -0,0 +1,258 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrEvents; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IAccessorKey; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.unit.IQuantity; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** + * Shared {@code datadog.ReferenceChain} JFR-parsing helpers. + * + *

    Two overloads because callers load recordings differently: + * {@code ReferenceChainTrackingTest} uses {@code AbstractProfilerTest}'s {@code JfrEvents} + * helpers ({@link #findMatchForClass(JfrEvents, Class)}), while {@link LeakingCacheScenario} loads + * via JMC's {@code JfrLoaderToolkit} ({@link #findMatchForClass(IItemCollection, Class)}). + */ +public final class ReferenceChainAssertions { + private ReferenceChainAssertions() {} + + /** + * Leak-tag pool range, mirrored from referenceChains.h / livenessTracker.h (LEAK_TAG_BASE, + * LEAK_TAG_POOL_SIZE): a correlated chain's targetTag and its HeapLiveObject's leakTag must fall + * into [LEAK_TAG_BASE, LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE). Asserted against runtime event + * values. + */ + public static final long LEAK_TAG_BASE = 0x40000000L; + + public static final long LEAK_TAG_POOL_SIZE = 256; + + /** + * True on every native build that compiles javaApi.cpp's {@code #ifdef DEBUG} test seams: debug, + * asan and tsan (all add -DDEBUG, ConfigurationPresets.kt). Release builds get no-op seam stubs. + * Child JVMs see the same answer because the launchers forward {@code -Dddprof_test.config}. + */ + public static boolean isDebugInstrumentedBuild() { + String config = System.getProperty("ddprof_test.config"); + return config != null && ("debug".equals(config) || config.endsWith("san")); + } + + /** + * The {@code ddprof_test.config} value ProfilerTestPlugin.kt sets on every test<config> task. + * Launchers forwarding it to a child JVM must fail loud when it is unset (a test run directly + * from an IDE, outside the Gradle tasks) instead of concatenating the literal "null", which the + * child would silently classify as a release build and take the unseeded, flakier path. + */ + public static String requiredTestConfig() { + String config = System.getProperty("ddprof_test.config"); + if (config == null) { + throw new IllegalStateException( + "ddprof_test.config is not set; run via a test Gradle task so the child " + + "JVM sees the right build configuration"); + } + return config; + } + + /** Result of {@link #findMatchForClass(IItemCollection, Class)}: one resolved chain event's fields. */ + public static final class ChainMatch { + public final List chain; + public final long targetTag; + public final int depth; + + ChainMatch(List chain, long targetTag, int depth) { + this.chain = chain; + this.targetTag = targetTag; + this.depth = depth; + } + } + + /** Result of {@link #findMatchForClass(JfrEvents, Class)}: one resolved chain event's fields. */ + public static final class JfrChainMatch { + public final List chain; + public final long targetTag; + public final int depth; + + JfrChainMatch(List chain, long targetTag, int depth) { + this.chain = chain; + this.targetTag = targetTag; + this.depth = depth; + } + } + + /** + * Scans {@code events} for a {@code datadog.ReferenceChain} item whose {@code chain[0]} is + * {@code targetClass} specifically, ignoring any events for other klasses this same + * leak-candidate mechanism may have legitimately flagged (e.g. "[B"/byte[] - see each caller's + * own comment). Returns {@code null} if {@code events} is empty or none match. + */ + public static ChainMatch findMatchForClass(IItemCollection events, Class targetClass) { + return findMatch(events, targetClass, false, 0); + } + + /** + * Same as {@link #findMatchForClass(IItemCollection, Class)}, but identifies one specific + * instance: only an item whose {@code targetTag} equals {@code requiredTargetTag} matches (an + * item without a readable {@code targetTag} never does). + */ + public static ChainMatch findMatchForClassAndTargetTag(IItemCollection events, + Class targetClass, long requiredTargetTag) { + return findMatch(events, targetClass, true, requiredTargetTag); + } + + private static ChainMatch findMatch(IItemCollection events, Class targetClass, + boolean requireTag, long requiredTargetTag) { + if (events == null || !events.hasItems()) { + return null; + } + for (IItemIterable iterable : events) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = findAccessor(type, "targetTag"); + IMemberAccessor depthAccessor = findAccessor(type, "depth"); + if (chainAccessor == null) { + throw new IllegalStateException("No accessor for 'chain' field on datadog.ReferenceChain"); + } + + String targetName = jmcStyleName(targetClass); + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (!(chainValue instanceof Object[])) { + throw new IllegalStateException( + "'chain' field resolved to " + chainValue + ", expected an array"); + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0 || !(rawChain[0] instanceof IMCType) + || !targetName.equals(((IMCType) rawChain[0]).getFullName())) { + continue; + } + long targetTag = targetTagAccessor != null ? numberValue(targetTagAccessor.getMember(item)) : -1; + if (requireTag && targetTag != requiredTargetTag) { + continue; + } + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add((IMCType) element); + } + int depth = depthAccessor != null ? (int) numberValue(depthAccessor.getMember(item)) : -1; + return new ChainMatch(chain, targetTag, depth); + } + } + return null; + } + + /** + * 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. + */ + public static JfrChainMatch findMatchForClass(JfrEvents events, Class targetClass) { + if (events == null || !events.hasItems()) { + return null; + } + String targetName = jmcStyleName(targetClass); + for (JfrEvent item : events) { + Object chainValue = item.get("chain"); + if (!(chainValue instanceof Object[])) { + throw new IllegalStateException( + "'chain' field resolved to " + chainValue + ", expected an array"); + } + Object[] rawChain = (Object[]) chainValue; + if (rawChain.length == 0 || !targetName.equals(classFullName(rawChain[0]))) { + continue; + } + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add(classFullName(element)); + } + long targetTag = item.getLong("targetTag", -1); + int depth = (int) item.getLong("depth", -1); + return new JfrChainMatch(chain, targetTag, depth); + } + return null; + } + + /** + * {@code targetClass}'s name in {@code IMCType.getFullName()} format: {@code Class.getName()} + * renders {@code byte[].class} as {@code "[B"}, but JMC renders it as {@code "byte[]"}, so a + * direct comparison never matches an array-typed leaf. Non-array names are unchanged. + */ + private static String jmcStyleName(Class targetClass) { + int dimensions = 0; + Class component = targetClass; + while (component.isArray()) { + dimensions++; + component = component.getComponentType(); + } + if (dimensions == 0) { + return targetClass.getName(); + } + StringBuilder name = new StringBuilder(component.getName()); + for (int i = 0; i < dimensions; i++) { + name.append("[]"); + } + return name.toString(); + } + + /** + * The full name (e.g. {@code java.lang.String}) of a resolved {@code chain[]} array element - + * mirrors {@code JfrEvent.getClassName(String)}'s own class-reference-map unwrapping, applied + * to an array element rather than a named field. + */ + @SuppressWarnings("unchecked") + private static String classFullName(Object element) { + if (!(element instanceof Map)) { + throw new IllegalStateException( + "chain[] element resolved to " + element + ", expected a class reference map"); + } + Object name = ((Map) element).get("name"); + String s; + if (name instanceof Map) { + Object v = ((Map) name).get("string"); + s = v != null ? v.toString() : null; + } else { + s = name != null ? name.toString() : null; + } + return s != null ? s.replace('/', '.') : null; + } + + /** + * Looks up a field's accessor by identifier rather than via {@code Attribute.attr(...)}: JMC's + * v1 chunk parser (internal.parser.v1.ValueReaders.ArrayReader#getContentType()) registers + * {@code UnitLookup.UNKNOWN} as the declared content type for every array field regardless of + * what its element reader resolves to, so an {@code F_ARRAY} field like {@code chain} + * (T_CLASS, F_CPOOL|F_ARRAY, jfrMetadata.cpp) cannot be bound via a compile-time-typed + * {@code Attribute}. Mirrors {@code ReferenceChainJfrParserTest}'s identical lookup, which + * already proves this resolves {@code chain}'s array elements to real {@link IMCType}s. + */ + public static IMemberAccessor findAccessor(IType type, String identifier) { + for (IAccessorKey key : type.getAccessorKeys().keySet()) { + if (identifier.equals(key.getIdentifier())) { + return type.getAccessor(key); + } + } + return null; + } + + public static long numberValue(Object value) { + if (value instanceof Number) { + return ((Number) value).longValue(); + } + if (value instanceof IQuantity) { + return ((IQuantity) value).longValue(); + } + return -1; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java new file mode 100644 index 0000000000..30e7537c53 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainJfrParserTest.java @@ -0,0 +1,233 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IAccessorKey; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.common.unit.IQuantity; +import org.openjdk.jmc.flightrecorder.CouldNotLoadRecordingException; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +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.Assertions.fail; + +/** + * Verifies JMC resolves {@code datadog.ReferenceChain}'s {@code chain} field - declared in + * jfrMetadata.cpp as {@code field("chain", T_CLASS, ..., F_CPOOL | F_ARRAY)}, an array of + * constant-pool {@code T_CLASS} indices. Neither scalar F_CPOOL fields (e.g. {@code objectClass}) + * nor F_ARRAY-of-struct fields (e.g. {@code jdk.StackTrace#frames}) cover this combination. + * + *

    Loads the .jfr produced by the companion gtest ({@code referenceChainJfrRoundtrip_ut.cpp}) + * directly, with no live profiler, and asserts the seeded class names in leaf-to-root order. + * + *

    Why a generic accessor, not {@code Attribute.attr(...)}: JMC's v1 chunk parser + * ({@code ArrayReader#getContentType()}) registers {@code UnitLookup.UNKNOWN} as the content type + * of every array field, so it cannot be bound via a typed {@code Attribute}. Looking it up by + * identifier via {@code IType#getAccessorKeys()} still goes through {@code ArrayReader}'s + * per-element {@code PoolReader} resolution - the thing this test exists to prove. + */ +public class ReferenceChainJfrParserTest { + + private static final String EVENT_TYPE = "datadog.ReferenceChain"; + + /** Set by the build for both this test and the companion gtest (see build.gradle.kts). */ + private static final String ROUNDTRIP_JFR_ENV = "DDPROF_REFERENCE_CHAIN_ROUNDTRIP_JFR"; + + @Test + public void chainFieldResolvesToSeededClassNamesInLeafToRootOrder() + throws IOException, CouldNotLoadRecordingException { + String configured = System.getenv(ROUNDTRIP_JFR_ENV); + Assumptions.assumeTrue(configured != null && !configured.isEmpty(), + ROUNDTRIP_JFR_ENV + " is not set - this test only runs from the Gradle test tasks"); + Path jfrPath = Paths.get(configured); + // Assumption, not assertion: the producing gtest does not run on CI runners without Google + // Test (e.g. the el7 testRelease job) or with -Pskip-gtest. The build deletes the file before + // the gtest runs, and when the gtest is skipped, so an existing file is always this build's. + Assumptions.assumeTrue(Files.exists(jfrPath), + "Expected " + jfrPath + " to exist - run " + + ":ddprof-lib:gtestDebug_referenceChainJfrRoundtrip_ut first " + + "(referenceChainJfrRoundtrip_ut.cpp produces this file)."); + // The seeded values the companion gtest wrote next to the recording - the + // single source of truth for this test's expectations, so the producer and + // this consumer cannot drift apart silently. + Path expectedPath = Paths.get(configured + ".expected"); + Assumptions.assumeTrue(Files.exists(expectedPath), + "Expected " + expectedPath + " to exist - run " + + ":ddprof-lib:gtestDebug_referenceChainJfrRoundtrip_ut first " + + "(referenceChainJfrRoundtrip_ut.cpp produces this sidecar)."); + Properties expected = new Properties(); + try (InputStream in = Files.newInputStream(expectedPath)) { + expected.load(in); + } + + IItemCollection events; + try (InputStream in = Files.newInputStream(jfrPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chainEvents = events.apply(ItemFilters.type(EVENT_TYPE)); + assertTrue(chainEvents.hasItems(), "Expected at least one " + EVENT_TYPE + " event"); + + List resolvedChain = null; + long targetTag = -1; + long leakTagIncarnation = -1; + int depth = -1; + for (IItemIterable iterable : chainEvents) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = findAccessor(type, "chain"); + IMemberAccessor edgesAccessor = findAccessor(type, "edges"); + IMemberAccessor targetTagAccessor = findAccessor(type, "targetTag"); + IMemberAccessor depthAccessor = findAccessor(type, "depth"); + IMemberAccessor incarnationAccessor = findAccessor(type, "leakTagIncarnation"); + assertNotNull(incarnationAccessor, "No accessor for 'leakTagIncarnation' field on " + + EVENT_TYPE); + assertNotNull(chainAccessor, "No accessor for 'chain' field on " + EVENT_TYPE); + assertNotNull(edgesAccessor, "No accessor for 'edges' field on " + EVENT_TYPE + + " - the new retention-edge label array (T_STRING|F_ARRAY) did not make it " + + "into the recorded metadata"); + + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + assertNotNull(chainValue, "'chain' field resolved to null"); + assertTrue(chainValue instanceof Object[], + "'chain' field resolved to " + chainValue.getClass() + ", expected an array"); + + Object[] rawChain = (Object[]) chainValue; + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + assertNotNull(element, + "chain[] element resolved to null - the constant-pool reference for this " + + "T_CLASS array entry did not resolve to a class"); + assertTrue(element instanceof IMCType, + "chain[] element resolved to " + element.getClass() + + ", expected " + IMCType.class + " (a resolved class, not a raw cpool index)"); + chain.add((IMCType) element); + } + resolvedChain = chain; + // Retention-edge labels, one per hop. The gtest has no JNIEnv, so it seeds kind labels + // instead of decoded ones; this checks the array field parses, not the decode + // (covered natively by referenceChains_ut). + Object edgesValue = edgesAccessor.getMember(item); + assertNotNull(edgesValue, "'edges' field resolved to null"); + assertTrue(edgesValue instanceof Object[], + "'edges' field resolved to " + edgesValue.getClass() + + ", expected an array"); + Object[] rawEdges = (Object[]) edgesValue; + assertEquals(expectedInt(expected, "chain.length"), rawEdges.length, + "edges must align with the chain, one label per hop"); + for (Object e : rawEdges) { + assertTrue(e instanceof String, + "edges[] element resolved to " + (e == null ? "null" : e.getClass()) + + ", expected a String"); + } + assertEquals(expected.getProperty("edges.0"), rawEdges[0]); + assertEquals(expected.getProperty("edges.1"), rawEdges[1]); + assertEquals(expected.getProperty("edges.2"), rawEdges[2]); + if (targetTagAccessor != null) { + Object v = targetTagAccessor.getMember(item); + if (v instanceof Number) { + targetTag = ((Number) v).longValue(); + } else if (v instanceof IQuantity) { + targetTag = ((IQuantity) v).longValue(); + } + } + leakTagIncarnation = longValue(incarnationAccessor.getMember(item)); + if (depthAccessor != null) { + Object v = depthAccessor.getMember(item); + if (v instanceof Number) { + depth = ((Number) v).intValue(); + } else if (v instanceof IQuantity) { + depth = (int) ((IQuantity) v).longValue(); + } + } + break; // referenceChainJfrRoundtrip_ut.cpp writes exactly one event + } + if (resolvedChain != null) { + break; + } + } + + assertNotNull(resolvedChain, "Never iterated a " + EVENT_TYPE + " item"); + assertEquals(expectedInt(expected, "chain.length"), resolvedChain.size(), + "Expected leaf/middle/root - as many entries as the gtest seeded"); + // Leaf-to-root order, matching ReferenceChainTracker::buildChainEvent()'s + // FrontierTable::reconstructChain() contract and the gtest's insert() calls + // (tag=3 leaf -> tag=2 middle -> tag=1 root). The sidecar carries the gtest's + // slashed internal names; JMC renders IMCType full names with dots. + for (int i = 0; i < resolvedChain.size(); i++) { + assertEquals(expected.getProperty("chain." + i).replace('/', '.'), + resolvedChain.get(i).getFullName(), + "chain[" + i + "] must be the gtest's seeded class in leaf-to-root order"); + } + assertEquals(expectedLong(expected, "targetTag"), targetTag, + "targetTag should be the leaf's frontier tag the gtest seeded"); + assertEquals(expectedInt(expected, "depth"), depth, + "depth should be the leaf's own depth the gtest seeded"); + assertEquals(expectedLong(expected, "leakTagIncarnation"), leakTagIncarnation, + "leakTagIncarnation should carry the event's ownership stamp the gtest seeded"); + } + + // Both sidecar readers report the offending key with the value: a bare parse + // exception names neither, and a MISSING key surfaces as a null parse whose + // built-in message ("null") says nothing about which sidecar entry is absent. + private static int expectedInt(Properties expected, String key) { + String value = expected.getProperty(key); + try { + return Integer.parseInt(value); + } catch (RuntimeException e) { + throw new IllegalStateException( + "malformed or missing '" + key + "' in the roundtrip sidecar (value: " + value + ")", e); + } + } + + private static long expectedLong(Properties expected, String key) { + String value = expected.getProperty(key); + try { + return Long.parseLong(value); + } catch (RuntimeException e) { + throw new IllegalStateException( + "malformed or missing '" + key + "' in the roundtrip sidecar (value: " + value + ")", e); + } + } + + private static long longValue(Object v) { + if (v instanceof Number) { + return ((Number) v).longValue(); + } else if (v instanceof IQuantity) { + return ((IQuantity) v).longValue(); + } + return -1; + } + + private static IMemberAccessor findAccessor(IType type, String identifier) { + Map, ?> keys = type.getAccessorKeys(); + for (IAccessorKey key : keys.keySet()) { + if (identifier.equals(key.getIdentifier())) { + return type.getAccessor(key); + } + } + return null; + } +} 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 new file mode 100644 index 0000000000..d50f79c647 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTestSeamsTest.java @@ -0,0 +1,171 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * {@code ReferenceChainTrackingTest} exercises {@code LivenessTracker}'s probabilistic slope + * detection and {@code ReferenceChainTracker}'s root-seeded BFS discovery together, which is + * reliable only when both line up within the same retry window. This class decouples them via the + * debug-build-only seams on {@link JavaProfiler} (javaApi.cpp), verifying each in isolation: + *

      + *
    • {@link #shouldSelectSeededKlassAsLeakCandidateOnPositiveSlope()} - asserts a slope signal + * would fire from directly-seeded population history, with no allocation sampling involved.
    • + *
    • {@link #shouldReconstructChainForDirectlyTaggedRoot()} - asserts the BFS/chain- + * reconstruction/event-emission path fires for a directly-tagged, known live object, with no + * dependency on {@code selectLeakCandidates()} organically picking the right klass.
    • + *
    + * + *

    Only runs under -DDEBUG native builds (debug/asan/tsan) - the backing native methods are + * no-op stubs in a release build (javaApi.cpp's {@code #ifdef DEBUG} guard). + */ +public class ReferenceChainTestSeamsTest extends AbstractProfilerTest { + + // Arbitrary klass ids, opaque to the population table except when a representative is set: + // that aliases the representative's real class id onto the synthetic id, so the chain test + // targets a unique nested class - a real entry with existing (noisy) history, e.g. + // java.lang.Object's, would outrank the seeded ramp. + private static final int SLOPE_TEST_KLASS_ID = 987001; + private static final int CHAIN_TEST_KLASS_ID = 987002; + + /** Unique class for {@link #shouldReconstructChainForDirectlyTaggedRoot()}'s target. */ + private static final class ChainTarget {} + + /** + * Durable root for {@link #shouldReconstructChainForDirectlyTaggedRoot()}'s target: the + * noise gate (suppressChainEvent) drops depth<2 chains whose root is transient (stack/JNI + * local), so a static holder is required for the chain event to be emitted at all. + */ + private static Object chainTargetHolder; + + @Override + protected String getProfilerCommand() { + // generations=true: required for selectLeakCandidates() to return anything, real or seeded. + // referencechains=true: constructs the FrontierTable the seams insert into. + // framecap=2000000: the first pass's IterateOverReachableObjects admits every GC root in the + // JVM, and a small framecap fills with them before the tagged root is expanded, abandoning + // with FRONTIER_CAP and no chain event. + return "generations=true,referencechains=true:hops=32:budget=500:ttl=60000:framecap=2000000"; + } + + @Override + protected boolean isPlatformSupported() { + return !(Platform.isJavaVersion(8) || Platform.isJ9() || Platform.isZing()); + } + + private static void assumeDebugBuild() { + assumeTrue(ReferenceChainAssertions.isDebugInstrumentedBuild()); + } + + /** + * Seeds twenty epochs of strictly increasing population counts for {@link #SLOPE_TEST_KLASS_ID} + * directly into LivenessTracker's ring buffer - bypassing real allocation sampling entirely - + * then asserts {@code selectLeakCandidates()} ranks it as a leak candidate. This is the "assert + * a slope signal would be generated" seam: it proves the ranking logic itself works without + * depending on the real JVMTI heap sampler ever surfacing this specific klass. + */ + @Test + public void shouldSelectSeededKlassAsLeakCandidateOnPositiveSlope() { + assumeDebugBuild(); + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.setGcGenerationsEnabled0(true); + + // KLASS_POPULATION_MIN_FILL_FOR_TREND = 10 only makes a trend eligible; selectLeakCandidates() + // also needs LEAK_TREND_HYSTERESIS_BASE = 5 consecutive qualifying epochs, so at least + // 10 + (5 - 1) = 14 increasing samples are needed; 20 gives headroom. + for (int epoch = 1; epoch <= 20; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(SLOPE_TEST_KLASS_ID, epoch * 10, epoch); + // selectLeakCandidates() also requires a qualifying allocating thread. No tracked instance + // needs to match here, so a fixed synthetic tid suffices. + ReferenceChainTestHooks.seedTidTrendSample0(SLOPE_TEST_KLASS_ID, 4242, epoch * 3, epoch); + } + + int[] candidates = ReferenceChainTestHooks.selectLeakCandidateKlassIds0(); + boolean found = false; + for (int klassId : candidates) { + if (klassId == SLOPE_TEST_KLASS_ID) { + found = true; + break; + } + } + assertTrue(found, "Expected klass id " + SLOPE_TEST_KLASS_ID + + " to be selected as a leak candidate after a seeded positive-slope population history"); + } + + /** + * Tags a real, live, caller-chosen object directly as a reference-chain frontier root + * (bypassing ReferenceChainTracker's normal root-seeded discovery walk), wires it in as a + * seeded leak candidate's representative, then drives one BFS pass and one poll cycle + * synchronously. This is the "trigger the refchain on a known live heap sample" seam: it + * proves {@code runPass()}/{@code pollWatchedTargets()}/{@code buildChainEvent()} correctly + * produce a chain event whose leaf is this test's {@link ChainTarget}, decoupled from whether + * LivenessTracker's probabilistic sampler would have picked the same object on its own. Only + * chains with a {@code ChainTarget} leaf count, so a chain another test left cached in this + * shared JVM cannot satisfy the assertion. + */ + @Test + public void shouldReconstructChainForDirectlyTaggedRoot() { + assumeDebugBuild(); + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.setGcGenerationsEnabled0(true); + // Guards against inheriting a full/tiny FrontierTable from an earlier test in this + // no-forkEvery JVM (e.g. ReferenceChainTrackingTest's framecap=1 abandonment test), against + // which tagAsReferenceChainRoot0()'s insert() could fail. + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + + // Referenced only through the static holder, never a local: a local would make the target a + // stack-local GC root for the whole test body, and the noise gate suppresses chains rooted in + // a transient stack slot. + chainTargetHolder = new ChainTarget(); + + // Representative before seeding: setting it registers the synthetic->real id alias, so the + // seeds land under the target's real class id. + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(CHAIN_TEST_KLASS_ID, chainTargetHolder); + + // 20 epochs to clear the hysteresis gate - see the slope test above. + for (int epoch = 1; epoch <= 20; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(CHAIN_TEST_KLASS_ID, epoch * 10, epoch); + // This thread's real tid, since the target was allocated here. Belt-and-braces: the + // chain's primary path is the directly-tagged root plus the seeded representative. + ReferenceChainTestHooks.seedTidTrendSample0( + CHAIN_TEST_KLASS_ID, ReferenceChainTestHooks.getTid(), epoch * 3, epoch); + } + + // Poll before the direct tag: it admits the seeded klass as a watched candidate, and + // tagAsRootForTest's recordDiscoveredInstance only lands when a candidate slot already + // watches the klass. + ReferenceChainTestHooks.pollReferenceChainTargets0(); + + long tag = ReferenceChainTestHooks.tagAsReferenceChainRoot0(chainTargetHolder); + assertTrue(tag > 0, "Expected tagAsReferenceChainRoot0 to assign a valid frontier tag"); + + // One pass is not enough: admitStaticFieldRoots() sweeps loaded classes in budget-bounded + // chunks, and the target's durable root_kind is only discovered on the pass whose chunk + // covers ChainTarget's holder class. 40 passes give several full sweep laps of headroom + // (~10 passes for ~4000 classes). The engine lock (runPassSerialized/ + // pollWatchedTargetsSerialized) keeps the BFS thread from racing these calls. + int eventCount = 0; + for (int pass = 0; pass < 40 && eventCount == 0; pass++) { + boolean sawPassRun = ReferenceChainTestHooks.runReferenceChainPass0(); + assertTrue(sawPassRun || pass > 0, + "Expected the first runReferenceChainPass0 to run (reference chains enabled)"); + ReferenceChainTestHooks.pollReferenceChainTargets0(); + eventCount = ReferenceChainTestHooks.drainReferenceChainEventCountForLeaf0(chainTargetHolder); + } + assertTrue(eventCount > 0, + "Expected pollWatchedTargets() to have queued at least one chain event whose leaf is " + + "ChainTarget (the directly-tagged, seeded-representative target)"); + chainTargetHolder = null; // shared-JVM hygiene + } +} 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 new file mode 100644 index 0000000000..867e02af18 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ReferenceChainTrackingTest.java @@ -0,0 +1,579 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; + +import java.nio.file.Files; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.lang.reflect.Method; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * End-to-end coverage for {@code ReferenceChainTracker} (referenceChains.h/.cpp) against a real + * JVM, real GCs and a real JVMTI heap walk. {@code Profiler::dump()} emits + * {@code datadog.ReferenceChainAbandoned} once the search has ended in + * {@code SearchState::ABANDONED}; {@code pollWatchedTargets()} emits {@code datadog.ReferenceChain} + * for ranked {@code selectLeakCandidates()} klasses whose representative a {@code runPass()} walk + * has already tagged ({@code getTag() > 0} - a read, never a {@code SetTag} seed). + * + *

    Why {@code @TestMethodOrder}: {@code ReferenceChainTracker} is a process-wide + * singleton. Leaving {@code SearchState::RUNNING} releases every tag the search assigned + * ({@code releaseSearchTags()}), and its {@code FrontierTable} is sized only by the first + * {@code start()} in this JVM ({@code if (_frontier == nullptr)} guard; no {@code forkEvery}, see + * {@code ProfilerTestPlugin.kt}). {@link #shouldReportAbandonedSearchOnTinyFrontierCap()} drives + * the search to {@code ABANDONED}, while both success-path tests need it {@code RUNNING} to see + * non-zero tags and have no fallback for losing that race, so they run first + * ({@code @Order(1)}/{@code @Order(2)}). Their relative order does not matter: they target + * different klasses ({@link ChainLink} vs {@link CachedPayload}) and neither exhausts the search. + */ +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Tag("slow") +public class ReferenceChainTrackingTest extends AbstractProfilerTest { + + // Arbitrary klass ids for the debug-only population-seeding seams. Distinct from each other and + // from ReferenceChainTestSeamsTest's ids to avoid collisions in the shared, no-forkEvery JVM. + private static final int CHAIN_LINK_TEST_KLASS_ID = 987201; + private static final int CACHED_PAYLOAD_TEST_KLASS_ID = 987202; + + // Static, not local: the discovered-chain gate suppresses shallow chains rooted at a transient + // root (stack/JNI local), and ChainLinks at depth 1 of a locally-held list are exactly that + // shape. (CachedPayload chains go three hops through HashMap internals, which the gate lets + // through regardless of root kind.) Cleared in the test's finally so it does not keep its + // population reachable for later tests in this shared JVM. + private static List gcRootHolder; + + // consecutive_positive only starts incrementing once ring_fill reaches + // KLASS_POPULATION_MIN_FILL_FOR_TREND=10 (ringThirdsStats()'s fill < min_fill guard resets it to + // 0 before that), and selectLeakCandidates() needs it at LEAK_TREND_HYSTERESIS_BASE=5 (or 3 when + // heapFloorRising() corroborates). hasLeakSignal() - which shouldRunPass() gates even the first + // pass on under generations=true - consults that. 10 + 5 - 1 = 14 seeded epochs is the minimum; + // +1 for margin. + private static final int SEED_EPOCHS_FOR_HYSTERESIS = 15; + + // Dedicated id for shouldReportAbandonedSearchOnTinyFrontierCap()'s seeding, for the same + // collision-avoidance reason as the ids above. + private static final int ABANDON_TEST_KLASS_ID = 987203; + + // T_REFERENCE_CHAIN in jfrMetadata.h - the jdk.ActiveSetting "id" written by flightRecorder.cpp's + // writeBoolSetting(buf, T_REFERENCE_CHAIN, "enabled", ...). Must stay in sync with that enum. + private static final long REFERENCE_CHAIN_EVENT_ID = 129; + + @Override + protected String getProfilerCommand() { + String testName = testInfo != null + ? testInfo.getTestMethod().map(Method::getName).orElse("") + : ""; + if ("shouldReconstructReferrerChainToGcRoot".equals(testName) + || "shouldReconstructReferrerChainThroughUnboundedCacheLeak".equals(testName)) { + // generations=true: pollWatchedTargets() does nothing unless + // LivenessTracker::gcGenerationsEnabled() is true; referencechains=... alone stays + // whole-graph-only. A large framecap avoids FRONTIER_CAP abandonment against this JVM's + // real root-reachable graph, and a long ttl avoids TTL abandonment mid-test - either would + // call releaseSearchTags() and zero every tag this test depends on. + // + // pausetarget=5000: measured FollowReferences/GetObjectsWithTags latency starts ~150ms and + // climbs past 1s as cumulative progress grows (each pass re-scans the discovered set). + // Against the default 5ms ceiling (DEFAULT_REFERENCE_CHAINS_PAUSE_TARGET_MS) updatePacing() + // settles at MIN_EFFECTIVE_BUDGET=50 edges/pass and MAX_EFFECTIVE_CADENCE_NS=4s, too slow + // for this test's wall-clock budget; a merely generous ceiling only postpones that. A + // ceiling above the expected peak per-pass cost keeps _effective_budget at budget=4000. + // + // painbudget=100: the @Order(2) test needs a search restart (canAffordNewSearch()) after + // the @Order(1) search terminates. PainBudget (painBudget.h) gates that on + // pain_spent_ms / refill_rate of cooldown, which at the default 1% refill can run to + // minutes - past this test's bounded retry loop. Nothing else here competes for the budget. + // + // firstpassbudget=200000: root enumeration (runPassManualWalk()'s + // IterateOverReachableObjects) otherwise shares budget=4000, which was observed exhausting + // on other roots' subtrees before ever admitting an edge from this thread's stack (where + // the cache test's HashMap lives). Raising only the root-enumeration budget leaves the + // steady-state expansion pacing (updatePacing()) untouched. + // + // memory=64:l:1.0 - the explicit 100% live-sample ratio is load-bearing: the default 10% + // (_live_samples_ratio, arguments.h) makes LivenessTracker::track() drop 90% of tracked + // instances, thinning the representative and trend pools (observed as intermittent + // "representative died/evicted" and zero-tag runs). Tracking volume is still capped by the + // 256KiB sampling-interval floor, so the table stays small. + return "memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=4000:ttl=120000:framecap=2000000:pausetarget=5000:painbudget=100:firstpassbudget=200000"; + } + // shouldReportAbandonedSearchOnTinyFrontierCap needs the frontier table, not the per-pass + // budget, to run out first: heapReferenceCallback() checks the budget before + // FrontierTable::insert(), so budget<=framecap completes normally instead of abandoning. A + // larger budget lets a second root-referenced object hit the full table, which runPass() + // abandons on. + // + // framecap=1 only sizes the table if this is the first start() in the JVM + // (`if (_frontier == nullptr)` guard); otherwise @Order(1)'s framecap=2000000 wins. ttl=100 is + // the fallback trigger: _search_start_ns is set when the shared search first started, so on an + // inherited oversized table the ttl check fires almost immediately. Either path produces + // datadog.ReferenceChainAbandoned, which is all this method asserts on. + // + // painbudget=100: restartSearch()/resetSearchStateForTest() both call + // _pain_budget.spend(_search_pain_ms) unconditionally, so the reset below inherits the + // previous long-running search's pain debt; at the default 1% refill, draining it back to + // canStartNow()==true takes minutes, past this method's retry budget. + // + // 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. + if ("shouldReportAbandonedSearchOnTinyFrontierCap".equals(testName)) { + 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() + // implicitly falls back to cpu profiling, whose ActiveSetting is also "enabled"="true", so the + // test also matches on the ActiveSetting "id" (T_REFERENCE_CHAIN, REFERENCE_CHAIN_EVENT_ID). + return "referencechains=true:hops=32:budget=500:ttl=2000:framecap=256"; + } + + @Override + protected boolean isPlatformSupported() { + // FollowReferences/tag-based frontier walking (referenceChains.cpp) assumes a + // HotSpot-shaped JVMTI heap implementation; excluded platforms mirror + // LivenessTrackingTest's own guard (memleak/LivenessTrackingTest.java). + return !(Platform.isJavaVersion(8) || Platform.isJ9() || Platform.isZing()); + } + + /** + * Verifies the {@code referencechains=...} flag round-trips through + * {@code Arguments} parsing (arguments.cpp's {@code CASE("referencechains")}) into the + * {@code datadog.ReferenceChain} JFR setting (flightRecorder.cpp's + * {@code writeBoolSetting(buf, T_REFERENCE_CHAIN, "enabled", args._reference_chains)}). + */ + @Test + public void shouldExposeReferenceChainsSettingWhenEnabled() { + stopProfiler(); + JfrEvents settings = verifyEvents("jdk.ActiveSetting"); + boolean sawEnabledSetting = false; + for (JfrEvent item : settings) { + if (item.getLong("id", -1) == REFERENCE_CHAIN_EVENT_ID + && "enabled".equals(item.getString("name")) + && "true".equals(item.getString("value"))) { + sawEnabledSetting = true; + break; + } + } + assertTrue(sawEnabledSetting, "datadog.ReferenceChain#enabled setting was not found"); + } + + /** + * Allocates a growing, statically-held population of {@link ChainLink} instances with a GC and + * dump between rounds until {@code selectLeakCandidates()} trusts the trend, then waits for + * {@code pollWatchedTargets()} to emit the candidate's chain once a {@code runPass()} walk has + * tagged it. + * + *

    Each round needs an explicit {@link #dump(Path)}: {@code cleanup_table()}'s per-klass + * population accounting only runs (unforced) via {@code flush_table()} from + * {@code LivenessTracker::flush()}, which is called only from {@code Profiler::dump()} - there + * is no timer-driven flush. {@code System.gc()} bumps {@code _gc_epoch} synchronously in the + * {@code GarbageCollectionFinish} callback, so no sleep is needed between the GC and the dump. + */ + @Test + @Order(1) + public void shouldReconstructReferrerChainToGcRoot() throws Exception { + // Seed one element *before* resetting the search: ArrayList's backing array starts as the + // shared empty sentinel, and expandFrontier() freezes a node's children when it is expanded + // and never re-examines it. An unrelated GC can wake the restarted BFS thread before round 1's + // addAll(); expanding the holder while still empty would leave every later ChainLink + // unreachable (seen in CI as tag=0 on every poll). Every later grow() copies index 0, so + // whichever array the walk snapshots is non-empty. + gcRootHolder = new ArrayList<>(); + gcRootHolder.add(new ChainLink("gc-root-seed")); + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + // @Order(1) only orders this class: another test class in this no-forkEvery JVM may have + // left the singleton search non-RUNNING, after which runPass() never takes its root-seeded + // walk again. The population reset is needed for the same reason: _klass_population + // survives stop()/start(), and a leftover klass already past the hysteresis gate would let + // the first pass start regardless of this method's own seeding, masking whether it is + // sufficient. Debug-only: these native seams do not exist in a release build. + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + } + // Every ChainLink is allocated by one persistent thread - see PersistentAllocator. + PersistentAllocator allocator = new PersistentAllocator("togcroot-leak-allocator"); + + Path scratchDumpPath = Paths.get("referencechains-population-scratch.jfr"); + try { + // KLASS_POPULATION_MIN_FILL_FOR_TREND = 10 needs 10 epochs that actually observe a surviving + // ChainLink sample, and sampling is probabilistic, so the loop grows per-round counts and + // checks every round instead of committing to a fixed count. Staying under + // KLASS_POPULATION_RING_SIZE = 30 keeps every round within the slope window. Capped at 16 + // because the shared fork (-Xmx512m, no forkEvery) hit "Java heap space" at 25. + // + // Growth is clamped at round 10: later rounds only add retries against the lock-contention + // race below (the trend is already eligible), and unclamped growth was what exhausted the + // heap. + // + // Sized against ObjectSampler::check()'s 256KiB minimum interval, not "memory=64" (which + // only affects LivenessTracker's table capacity). ChainLink's padding keeps the instance + // count - and so the graph the BFS walk must traverse - small. Other klasses (e.g. byte[]) + // may also be flagged, so matching is by ChainLink specifically. + ReferenceChainAssertions.JfrChainMatch match = null; + boolean seededTestKlassTrend = false; + int totalRounds = 16; + for (int round = 1; round <= totalRounds && match == null; round++) { + int newInstances = Math.min(round, 10) * 600; + List newLinks = new ArrayList<>(newInstances); + allocator.run(() -> { + for (int i = 0; i < newInstances; i++) { + newLinks.add(new ChainLink("leak-" + newInstances + "-" + i)); + } + }); + gcRootHolder.addAll(newLinks); + System.gc(); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + + if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { + // Under generations=true, shouldRunPass() gates even the first pass on hasLeakSignal(), + // which this early falls through to selectLeakCandidates()'s hysteresis gate for some + // klass. Seeding SEED_EPOCHS_FOR_HYSTERESIS epochs clears it, authorizing the BFS + // thread's next wake to run the root-seeded walk. The poll below is therefore + // best-effort: it only reconstructs once the representative's getTag() > 0, so the + // retries give the background thread's own post-pass pollWatchedTargets() time to run. + // + // Seed exactly once: recordKlassPopulationSampleLocked() always appends a ring slot, so + // reseeding would turn the ramp into a sawtooth and destroy the positive slope. + // + // Representative before seeding: setting it aliases the synthetic id to the + // representative's real klass id, so the seeds land in the real population entry + // (seeds under a bare synthetic id satisfy only hasLeakSignal()'s candidate count). + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(CHAIN_LINK_TEST_KLASS_ID, gcRootHolder.get(0)); + if (!seededTestKlassTrend) { + // The allocator thread owns every ChainLink allocation, so its tid is the qualifying + // one; the seeds accelerate the organic per-tid growth it produces every round. + int leakTid = allocator.tid(); + // Seed counts match the organic ramp (+1 per epoch): hasQualifyingGrowth() compares + // third-window means over seeds followed by real folds, and inflated seeds (epoch*10) + // made the slope go negative (-20..-40 observed) once real samples displaced them. + // With matched scale ([1..15, 1, 2, 3, ...]) every window pairing stays positive. + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(CHAIN_LINK_TEST_KLASS_ID, epoch, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(CHAIN_LINK_TEST_KLASS_ID, leakTid, epoch, epoch); + } + seededTestKlassTrend = true; + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + + if (match == null) { + // A quiet window with no dump() in flight: Profiler::dump() holds every _locks[] entry + // exclusively during _jfr.dump() (rotateDictsAndRun()'s lockAll()/unlockAll()), and + // writeReferenceChain() needs a slot to record anything, so back-to-back dumps leave + // pollWatchedTargets() no window. No gc() before this dump, so cleanup_table()'s + // epoch-advance stays a no-op and the trend is undisturbed. + Thread.sleep(300); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + } + + // Grace period: the trend only became eligible partway through the loop, and the same lock + // contention can delay the write past the last dump(). + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + dump(scratchDumpPath); + match = ReferenceChainAssertions.findMatchForClass(verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false), ChainLink.class); + } + + 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"); + // chain[0] is the target's own class, not its holder's: reconstructChain() appends each + // entry's referrer_klass leaf-to-root, and referrer_klass is the discovered object's own + // class (see referenceChainJfrRoundtrip_ut.cpp). Hops above chain[0] are JDK collection + // internals this test does not control, so they are not asserted. + assertEquals(ChainLink.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"); + assertTrue(!gcRootHolder.isEmpty()); // keeps every allocated ChainLink reachable until here + } finally { + gcRootHolder = null; // shared-JVM hygiene - see the static holder's own comment + allocator.close(); + Files.deleteIfExists(scratchDumpPath); + } + } + + /** + * Same target-selection mechanism as {@link #shouldReconstructReferrerChainToGcRoot()}, against + * a common real-world leak shape: an ever-growing, never-evicted {@link java.util.HashMap} cache + * of {@link CachedPayload} values. Also asserts the chain threads through HashMap's internal + * storage, proving the walk handles real JDK collection internals. + * + *

    Why {@code cache} is a local variable, not a {@code static} field: + * {@code expandFrontier()} freezes a node's children when it is expanded and never re-examines + * it. A {@code static} field is a root from class-load time, so an early pass would almost + * certainly expand it while still empty and block discovery of everything added later. + */ + @Test + @Order(2) + public void shouldReconstructReferrerChainThroughUnboundedCacheLeak() throws Exception { + // Seed one entry before the resets, for the same empty-sentinel/frozen-children reason as + // gcRootHolder's seeding: HashMap's table is only allocated on the first put(). + Map cache = new HashMap<>(); + cache.put("cache-leak-seed", new CachedPayload("cache-leak-seed")); + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + // The natural restart after the @Order(1) search ends is gated on canAffordNewSearch() and + // not guaranteed within this method's retry budget, so force a fresh search. The population + // reset is also needed: resetReferenceChainSearchForTest0() leaves LivenessTracker's rings + // alone, and ChainLink's leftover positive slope could outrank CachedPayload. + // Debug-only: these native seams do not exist in a release build. + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + ReferenceChainTestHooks.resetReferenceChainSearchForTest0(); + } + // Every CachedPayload is allocated by one persistent thread - see PersistentAllocator. + PersistentAllocator allocator = new PersistentAllocator("cache-leak-allocator"); + Path scratchDumpPath = Paths.get("referencechains-cache-leak-scratch.jfr"); + try { + // Same round-growth/retry shape (and caps) as shouldReconstructReferrerChainToGcRoot(). + ReferenceChainAssertions.JfrChainMatch match = null; + boolean seededTestKlassTrend = false; + int totalRounds = 16; + + // Keys are pre-generated: a String key (and its byte[]) allocated alongside each + // CachedPayload would compete for the size-weighted sampler. Allocating them all up front + // lets their own population trend flatten before CachedPayload's starts growing. + int maxEntries = 0; + for (int round = 1; round <= totalRounds; round++) { + maxEntries += Math.min(round, 10) * 600; + } + String[] keys = new String[maxEntries]; + for (int i = 0; i < keys.length; i++) { + keys[i] = "leak-" + i; + } + + int nextKey = 0; + for (int round = 1; round <= totalRounds && match == null; round++) { + // Same *600 rate as the ChainLink test: the two classes are near-identical in size, and a + // lower rate let CachedPayload's ring stall short of KLASS_POPULATION_MIN_FILL_FOR_TREND. + int newEntries = Math.min(round, 10) * 600; + int keyOffset = nextKey; + allocator.run(() -> { + for (int i = 0; i < newEntries; i++) { + String key = keys[keyOffset + i]; + cache.put(key, new CachedPayload(key)); + } + }); + nextKey += newEntries; + System.gc(); + dump(scratchDumpPath); + JfrEvents events1 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events1, CachedPayload.class); + + if (match == null && ReferenceChainAssertions.isDebugInstrumentedBuild()) { + // Same seed-once, representative-first, best-effort-poll rationale as + // shouldReconstructReferrerChainToGcRoot(). + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(CACHED_PAYLOAD_TEST_KLASS_ID, cache.get(keys[0])); + if (!seededTestKlassTrend) { + // The allocator thread owns every CachedPayload allocation, and the seeds use the + // organic ramp's scale - both for the reasons shouldReconstructReferrerChainToGcRoot() + // gives. + int leakTid = allocator.tid(); + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(CACHED_PAYLOAD_TEST_KLASS_ID, epoch, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(CACHED_PAYLOAD_TEST_KLASS_ID, leakTid, epoch, epoch); + } + seededTestKlassTrend = true; + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + dump(scratchDumpPath); + JfrEvents events2 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events2, CachedPayload.class); + } + + 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. + Thread.sleep(300); + dump(scratchDumpPath); + JfrEvents events3 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events3, CachedPayload.class); + } + } + + for (int attempt = 0; match == null && attempt < 5; attempt++) { + Thread.sleep(1000); + dump(scratchDumpPath); + JfrEvents events4 = verifyEvents(scratchDumpPath, "datadog.ReferenceChain", false); + match = ReferenceChainAssertions.findMatchForClass(events4, CachedPayload.class); + } + + 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"); + 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"); + + // The point of this test over shouldReconstructReferrerChainToGcRoot(): confirm the walk + // actually passed through the cache's own internal storage, not some other, coincidental + // retainer - cache is the only thing keeping any CachedPayload instance reachable. + boolean sawHashMapInternals = false; + for (String type : match.chain) { + if (type.startsWith("java.util.HashMap")) { + sawHashMapInternals = true; + break; + } + } + assertTrue(sawHashMapInternals, + "Expected the reconstructed chain to pass through java.util.HashMap's own internal " + + "storage (cache is a HashMap) - chain was: " + match.chain); + assertTrue(!cache.isEmpty()); // keeps every cached CachedPayload reachable until here + } finally { + allocator.close(); + Files.deleteIfExists(scratchDumpPath); + } + } + + /** + * Drives the shared search to {@code SearchState::ABANDONED} - via {@code framecap=1} when this + * method sizes the {@code FrontierTable}, otherwise via the {@code ttl=100} fallback (see + * {@code getProfilerCommand()}) - and asserts a {@code datadog.ReferenceChainAbandoned} event is + * written on {@code dump()}, without asserting which reason produced it. + * + *

    {@code @Order(3)}: abandoning the singleton search would break the two success-path tests. + * The debug-build reset makes the fresh-search precondition explicit: if an earlier test's + * trend never fired, the search may still be {@code RUNNING} with a large frontier. + * + *

    Why the population seeding: {@code resetReferenceChainSearchForTest0()} alone was + * observed never running a first pass. With this command ({@code memory=64:l:1.0}, no + * {@code generations=true}) {@code LivenessTracker::initialize()} clears generations, so + * {@code hasLeakSignal()} returns true unconditionally; the seeding is defensive in case the + * command re-enables generations, in which case {@code shouldRunPass()} falls through to + * {@code selectLeakCandidates()} with nothing left from the cache test's population reset. No + * representative is set: a qualifying entry counts toward {@code hasLeakSignal()} regardless, + * and {@code resolveCandidateRepresentative()} is null-safe. + * + *

    Why a {@code System.gc()} + throwaway {@code dump()} precede the seeding: + * {@code cleanup_table()} wipes {@code _klass_population} once when the class map's + * generation changes (which this method's fresh {@code start()} causes), but only after the + * {@code if (!is_epoch_owner && !forced) return;} guard, which an unforced dump-triggered call + * passes only when a new GC epoch is pending. Without a GC first, the wipe would be deferred to + * the next forced cleanup (on the BFS thread's wake after the GCs below) and discard the seed. + * The success-path tests get this for free from their round-1 GC + dump. + */ + @Test + @Order(3) + public void shouldReportAbandonedSearchOnTinyFrontierCap() throws Exception { + if (ReferenceChainAssertions.isDebugInstrumentedBuild()) { + // GC + throwaway dump before seeding - see javadoc. + System.gc(); + Path warmupDumpPath = Paths.get("referencechains-abandoned-test-warmup.jfr"); + try { + dump(warmupDumpPath); + } finally { + Files.deleteIfExists(warmupDumpPath); + } + ReferenceChainTestHooks.resetKlassPopulationForTest0(); + // Gate-only seeding: no tagged instance needs to match, so any 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"))); + + // GarbageCollectionFinish (onGCFinish(), referenceChains.cpp) wakes the BFS thread + // early, but that wakeup can race the thread's own startup (VM::attachThread() + // completing before its first OS::sleep() call) and be missed. Don't rely on the + // signal alone: sleep comfortably past ReferenceChainTracker::PASS_CADENCE_NS (1s, + // referenceChains.h) too, so the thread's fixed-cadence fallback trigger + // (shouldRunPass()) guarantees at least one pass runs regardless of that race. + for (int i = 0; i < 3; i++) { + System.gc(); + Thread.sleep(100); + } + Thread.sleep(1500); + + Path dumpPath = Paths.get("referencechains-abandoned-test.jfr"); + try { + // The pass still has to be woken and reach the frontier-cap/ttl cutoff within whatever time + // the shared JVM grants it; a single fixed sleep-then-assert failed when it landed a beat + // early. Retry spaced far enough apart to tolerate a slow wake or a larger inherited heap. + JfrEvents abandoned = null; + for (int attempt = 0; attempt < 8; attempt++) { + dump(dumpPath); + 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"); + } finally { + Files.deleteIfExists(dumpPath); + } + assertTrue(!gcRootHolder.isEmpty()); // keeps gcRootHolder reachable until the dump above + } + + /** + * Referrer-type fixture for the success-path and abandonment-path tests. The 32 {@code long} + * fields cut the instance count needed per sampled megabyte. Plain fields, not an array: an + * array would be a separate allocation competing for the size-weighted sampler. + */ + private static final class ChainLink { + final String name; + final Object next; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + ChainLink(String name) { + this(name, null); + } + + ChainLink(String name, Object next) { + this.name = name; + this.next = next; + } + } + + /** + * Cache-value fixture for {@link #shouldReconstructReferrerChainThroughUnboundedCacheLeak()}. + * Padded with plain {@code long} fields for the same reason as {@link ChainLink}; a + * {@code byte[]} field was observed taking every sample away from {@code CachedPayload}. + */ + private static final class CachedPayload { + final String key; + long p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, p10, p11, p12, p13, p14, p15; + long p16, p17, p18, p19, p20, p21, p22, p23, p24, p25, p26, p27, p28, p29, p30, p31; + + CachedPayload(String key) { + this.key = key; + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/StaticFieldGrowingCollectionScenario.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/StaticFieldGrowingCollectionScenario.java new file mode 100644 index 0000000000..5f5e3f2fb6 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/StaticFieldGrowingCollectionScenario.java @@ -0,0 +1,438 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.InputStream; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +/** + * Mimics the real leak shape found in the {@code prof-analyzer-hotdog-jb} pod's leak generator + * ({@code ProfileAnalyzer}'s {@code LEAK_BUFFER}, profiling-backend repo, branch-local diff): a + * {@code static final List} field whose *instance* is admitted exactly once by + * {@code admitStaticFieldRoots()} (referenceChains.cpp) when the owning class first loads, and + * which is then only ever mutated in place (elements appended) - never reassigned - for the rest + * of the process's life. This is the "new elements added to an already-discovered collection" + * shape, as opposed to {@code LeakingCacheScenario}'s local-variable cache (a stack-local GC + * root, not a static field) - see this scenario's own use to confirm whether + * {@code collectStaleExpandedEntriesForRotation()}'s rotation mechanism actually re-expands + * {@link #LEAK_BUFFER} to pick up elements added after its one-time static-field sweep. + * + *

    Same seed-before-start rationale as {@code LeakingCacheScenario}'s own comment: {@link + * #LEAK_BUFFER} is seeded with a few chunks before the profiler (and therefore {@code + * ReferenceChainTracker}'s BFS thread) is even started, so the process's one-shot root-seeded + * walk can never catch it empty. + */ +public final class StaticFieldGrowingCollectionScenario { + private StaticFieldGrowingCollectionScenario() {} + + /** + * Mirrors production's {@code ProfileAnalyzer.LEAK_BUFFER}: a static field holding a + * {@code synchronizedList}, appended to (never reassigned) for the process's whole + * life. The backing ArrayList is pre-sized so it never reallocates during the scenario: the + * elementData array checked as EXPANDED before lateChunk is appended is the very array that ends + * up holding lateChunk. + */ + static final List LEAK_BUFFER = + Collections.synchronizedList(new ArrayList<>(StaticFieldGrowingCollectionScenario.LEAK_BUFFER_CAPACITY)); + + // Arbitrary, scenario-chosen klass id - see LeakingCacheScenario.CACHED_PAYLOAD_TEST_KLASS_ID's + // own comment; distinct from that scenario's id since both may run in the same suite (each in + // its own separate child JVM/LivenessTracker population table, but kept distinct regardless). + private static final int LEAK_BUFFER_TEST_KLASS_ID = 987302; + + // See LeakingCacheScenario.SEED_EPOCHS_FOR_HYSTERESIS's own comment for the full derivation: + // LivenessTracker's hysteresis gate needs this many qualifying epochs before + // selectLeakCandidates() reports a candidate at all. + private static final int SEED_EPOCHS_FOR_HYSTERESIS = 15; + + // Clears ObjectSampler's real sampling floor on its own - unlike LeakingCacheScenario's + // CachedPayload (long fields only, no byte[]), this scenario's leaked object *is* the byte[] + // itself, so there is no second, competing companion allocation. + private static final int CHUNK_BYTES = 300_000; + + private static final int SEED_CHUNKS = 10; + + private static final int TOTAL_ROUNDS = 25; + + // Each tail rotation appends a chunk to LEAK_BUFFER, so LEAK_BUFFER_CAPACITY covers these too. + private static final int TAIL_ATTEMPTS = 40; + + // Consecutive tail attempts the same chunk stays the watched target before rotating - see the + // tail loop. + private static final int TAIL_CHUNK_ATTEMPTS = 8; + + // Seeds + lateChunk + one per round + one per tail attempt (worst case), so LEAK_BUFFER's + // elementData never reallocates. + private static final int LEAK_BUFFER_CAPACITY = + SEED_CHUNKS + 1 + TOTAL_ROUNDS + TAIL_ATTEMPTS; + + // FrontierEntryState::EXPANDED (referenceChainFrontier.h), as reported by + // ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(). + private static final int FRONTIER_STATE_EXPANDED = 1; + + // ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0()'s "no frontier entry" sentinel. + private static final int FRONTIER_STATE_NONE = -1; + + /** Printed to stdout, followed by the matched leaf class's name, on success. */ + public static final String FOUND_MARKER = "[chain-found] "; + + /** + * Printed to stdout, followed by {@code true}/{@code false}, once the scenario has decided + * whether it takes the native test-seam path (every -DDEBUG build: debug/asan/tsan). + */ + public static final String SEAM_PATH_MARKER = "[seam-path] "; + + /** Printed to stdout (with no class name suffix) if no match was ever observed. */ + public static final String NOT_FOUND_MARKER = "[chain-not-found]"; + + /** + * Seeds {@link #LEAK_BUFFER}, starts the profiler, then keeps appending new {@code byte[]} + * chunks to the same list instance - the exact shape implicated in the live hotdog-pod stall - + * watching specifically for a chain to a chunk appended *after* the static field's one-time + * sweep, until a {@code datadog.ReferenceChain} event for it appears or {@code TOTAL_ROUNDS} is + * exhausted. + */ + public static void run(JavaProfiler profiler, String startCommand, Path scratchDumpPath) throws Exception { + for (int i = 0; i < SEED_CHUNKS; i++) { + LEAK_BUFFER.add(new byte[CHUNK_BYTES]); + } + if (startCommand != null && !startCommand.isEmpty()) { + profiler.execute(startCommand); + } + + // The seams used below are compiled into every -DDEBUG native build - debug, asan and tsan + // (javaApi.cpp's #ifdef DEBUG block; ConfigurationPresets.kt) - so the seam path must be + // enabled on all three. Same predicate as LeakTagCorrelationReferenceChainTest's rotation + // gate; release builds get the no-op stubs. + boolean debugBuild = ReferenceChainAssertions.isDebugInstrumentedBuild(); + System.out.println(SEAM_PATH_MARKER + debugBuild); + int hysteresisEpoch = 0; + int leakTid = 0; + // Snapshotted BEFORE seeding anything, while the BFS thread's search has not run a single + // pass yet (referenceChainPassesRunForTest0() reads 0 here) - see the wait loop below for why + // this exact value, not just "nonzero", is what the wait needs to advance past. + int initialPasses = debugBuild ? ReferenceChainTestHooks.referenceChainPassesRunForTest0() : -1; + + if (debugBuild) { + // Pins the frontier-state seam's -1 sentinel: an untagged object (tag 0) has no frontier + // entry. Probed before any seeding - no pass can run until shouldRunPass() sees a leak + // signal - so the BFS root walk cannot have tagged the probe yet. + int untaggedState = ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(new Object()); + if (untaggedState != FRONTIER_STATE_NONE) { + throw new IllegalStateException("frontier-state seam returned " + untaggedState + + " for an untagged probe object, expected " + FRONTIER_STATE_NONE); + } + // Wire a representative for the seeded klass BEFORE the seeds (set-representative-first + // is the load-bearing order: klassPopulationSetRepresentativeForTest() re-keys the + // synthetic entry to the representative's real klass id, and only a representative lets + // the entry mint stable_class_tag). Without a representative topKlassesByGenerationCount() + // skips the entry (no live instance was ever resolved), _watched_leak_klass_count stays + // 0, and runPass() declares the search COMPLETED after every pass - and the terminal + // pass itself then releases every frontier tag (releaseSearchTags()) while + // restartSearch() re-runs the identical walk. The frontier-state seam below blocks on + // _engine_lock mid-pass, so it can only read between passes - where the tags are already + // released - and the EXPANDED wait below can never observe its condition (observed as + // "holders never all reached EXPANDED within 45s" with states [-1, -1, -1]). + // The representative MUST be a byte[] chunk - the leaked klass itself - never the + // list holding it: the representative's real klass becomes the candidate klass, and only + // candidate-klass instances get leak-tagged by tagLeakInstances() and recorded by the + // leak-tag interception path whose chains the seam below reads. A non-byte[] + // representative (e.g. LEAK_BUFFER's own SynchronizedList klass, which has no tracked + // instances in this child JVM) mints no leak tags at all, and lateChunk is never + // discovered (observed as 0/6 runs, the seam returning 0 forever). Watching byte[] does + // flood _leak_parent_fanout with unrelated JVM-wide Object[] parents (~9.5k + // entries/search observed), starving the leak-accumulation rotation prong - but discovery + // does not depend on that prong: the static-anchor descend walk re-walks LEAK_BUFFER's + // whole subtree every search and intercepts every leak-tagged chunk in it (observed + // 10/10 searches), so lateChunk's chain is rebuilt once per search generation regardless. + // The seed chunk is held strongly by the static field, so the tracker's weak + // representative ref stays live. + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(LEAK_BUFFER_TEST_KLASS_ID, + LEAK_BUFFER.get(0)); + // Seed hysteresis-qualifying epochs so hasLeakSignal()/shouldRunPass() authorize the BFS + // thread's first pass. Must precede the passesRun() wait below: a pass only runs once + // shouldRunPass() sees a leak signal, so waiting first deadlocks. Per-(klass, tid) seeds + // (see LeakTagCorrelationScenario): the qualifying tid must be the allocating thread, and + // this thread allocates every LEAK_BUFFER chunk. + leakTid = ReferenceChainTestHooks.getTid(); + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(LEAK_BUFFER_TEST_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(LEAK_BUFFER_TEST_KLASS_ID, leakTid, epoch * 3, epoch); + } + hysteresisEpoch = SEED_EPOCHS_FOR_HYSTERESIS; + } + + // Wait until LEAK_BUFFER's holders are ReferenceChainTracker-EXPANDED - strictly before this + // scenario creates the one chunk whose chain it is actually going to watch for. This is the + // entire point of this scenario: proving that an element appended to a static field's + // collection AFTER that field's List is already EXPANDED gets discovered via a LATER + // re-expansion of that stale entry, not via the very first pass that admitted it. + // Release (non-DEBUG) builds have no frontier-state seam and fall back to a sleep: they never + // pin a specific representative and rely on much-slower real allocation sampling, so the + // margin changes nothing about what the eventual match proves. + if (debugBuild) { + // A changed pass counter alone is not proof that LEAK_BUFFER's holders were expanded: + // truncated passes also bump it, and admitStaticFieldRoots() only processes a bounded + // chunk of classes per call. So the wait synchronizes on the holders' ACTUAL frontier state + // instead: the SynchronizedList, its backing ArrayList and that ArrayList's elementData + // array must all be EXPANDED before lateChunk exists - only then can lateChunk be reached + // exclusively via a later re-expansion. The pass-counter check stays as a precondition + // (and gates the reflective holder reads until the first pass - with its root + // enumeration - has finished). + Field backingListField; + Field elementDataField; + try { + backingListField = + Class.forName("java.util.Collections$SynchronizedList").getDeclaredField("list"); + backingListField.setAccessible(true); + elementDataField = ArrayList.class.getDeclaredField("elementData"); + elementDataField.setAccessible(true); + } catch (ReflectiveOperationException | RuntimeException e) { + throw new IllegalStateException("cannot reach LEAK_BUFFER's backing ArrayList/elementData" + + " - run with --add-opens java.base/java.util=ALL-UNNAMED", e); + } + // admitStaticFieldRoots() sweeps every loaded class - a cold external JVM's full + // JUnit/JMC/Gradle-worker classpath (thousands of classes) can need several retries + // against pause-time-SLO pacing, and the expansion backlog ahead of LEAK_BUFFER's holders + // is FIFO - generous margin for the same reason ExternalProcessReferenceChainTest's own + // budget/pausetarget are raised this far above the in-process test's defaults. Wall-clock + // deadline: the seam reads can contend with a running pass's locks. Not scaled for + // asan/tsan: the parent's 150s launch timeout must still cover this wait + rounds + the 60s + // tail. + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(45); + boolean holdersExpanded = false; + while (!holdersExpanded && System.nanoTime() < deadline) { + holdersExpanded = ReferenceChainTestHooks.referenceChainPassesRunForTest0() != initialPasses + && leakBufferHoldersExpanded(backingListField, elementDataField); + if (!holdersExpanded) { + Thread.sleep(100); + } + } + if (!holdersExpanded) { + throw new IllegalStateException( + "LEAK_BUFFER's holders never all reached EXPANDED within 45s (passesRun=" + + ReferenceChainTestHooks.referenceChainPassesRunForTest0() + ", initial=" + initialPasses + + ", frontier states [list, backing ArrayList, elementData]=" + + describeHolderStates(backingListField, elementDataField) + + ") - cannot safely create the chunk this scenario is supposed to discover only " + + "via a later re-expansion of an already-EXPANDED static-field entry"); + } + } else { + Thread.sleep(3000); + } + // Snapshotted right after the wait above confirms the holders are EXPANDED - any + // later change (advance, or reset-to-lower via a restart) proves a walk that started strictly + // after lateChunk existed. + int passesRunBeforeLateChunk = debugBuild ? ReferenceChainTestHooks.referenceChainPassesRunForTest0() : -1; + // lateChunk is deliberately NOT held in a local: the main thread's stack is a root-walk root, + // so a live local lets the root enumeration admit (and, once the canary candidate aliases to + // byte[] and a GC runs tagLeakInstances(), leak-tag) lateChunk directly - the resulting + // stack-local-rooted chain never resolves into the static-field chain this scenario watches, + // and the pre-existing tag blocks the elementData re-walk from admitting lateChunk under the + // stale entry at all (observed as buildChainEvent "target_tag not in frontier" for the whole + // run). Re-fetch the chunk from LEAK_BUFFER on every use instead - the only durable path to + // it is the static field, so its discovery proves the stale-entry re-expansion. + final int LATE_CHUNK_INDEX = SEED_CHUNKS; + LEAK_BUFFER.add(new byte[CHUNK_BYTES]); + + // From here on, seeded incrementally, one epoch per round below, rather than as a single + // upfront burst - found the hard way: profiler.execute()'s start() resets + // Profiler::classMap()'s generation synchronously, but LivenessTracker::cleanup_table() only + // observes that new generation (and updates its own _last_class_map_generation) lazily, on the + // BFS thread's own first tick. A burst seeded entirely before that first tick lands in + // _klass_population, then gets wiped wholesale the moment that tick's class-map-generation + // mismatch fires (cleanup_table()'s own comment, livenessTracker.cpp). Seeding one fresh epoch + // per round instead is self-healing exactly the way the real leak this scenario mirrors is: + // LEAK_BUFFER keeps growing every round regardless of any one-time startup housekeeping, so + // however many rounds a wipe costs, the trend simply resumes accumulating from the next + // round's seed call. + ReferenceChainAssertions.ChainMatch match = null; + for (int round = 1; round <= TOTAL_ROUNDS && match == null; round++) { + // Mirrors the real leak's continuous growth; lateChunk stays the watched target. + LEAK_BUFFER.add(new byte[CHUNK_BYTES]); + System.gc(); + + if (match == null && debugBuild) { + // Seeding/polling every round unconditionally (not gated on passesRun() having advanced) + // is load-bearing, found the hard way: gating this on the tracker's OWN progress creates a + // deadlock - ReferenceChainTracker::hasLeakSignal() (which authorizes both a search's first + // pass and any later restart once the search reaches a terminal state) reads + // LivenessTracker::selectLeakCandidates(), which needs a continuously-refreshed population + // trend to keep reporting this candidate; stopping that refresh mid-search (to wait on the + // search's own progress) can stall the search itself with no path back, since nothing else + // keeps re-authorizing it. This scenario's target - a growing static-field collection - + // legitimately produces a continuous real signal in production; seeding continuously here + // is what actually mirrors that, not an artifact to gate away. + // + // The representative pins lateChunk, not whatever the real sampler picks. Seeding stays + // uncapped past SEED_EPOCHS_FOR_HYSTERESIS so a wipe costing early rounds still leaves + // enough rounds to requalify. + hysteresisEpoch++; + ReferenceChainTestHooks.seedKlassPopulationSample0( + LEAK_BUFFER_TEST_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0( + LEAK_BUFFER_TEST_KLASS_ID, leakTid, hysteresisEpoch * 3, hysteresisEpoch); + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(LEAK_BUFFER_TEST_KLASS_ID, + LEAK_BUFFER.get(LATE_CHUNK_INDEX)); + ReferenceChainTestHooks.pollReferenceChainTargets0(); + + // Only TRUST a match once passesRun() has genuinely changed since the snapshot taken right + // before lateChunk was created - either advanced (the same, never-restarted search ran a + // later pass) or reset to a lower value (restartSearch() ran, which only ever happens on a + // threadLoop tick strictly after that snapshot, since time only moves forward). Either way + // proves the match came from a walk that started after lateChunk existed, ruling out the + // one race this whole seam exists to rule out: the very first, still-in-flight pass finding + // lateChunk by accident because admitStaticFieldRoots() and expandFrontier() are two steps + // of that SAME call, and Java can observe the former's completion before the latter runs. + // + // The match is pinned to lateChunk's OWN chain: any byte[] chain (the seed chunks created + // before the first pass, discovered instances, other representatives) is not enough. The + // tag is read BEFORE dumping - the dump snapshots the same resolved-chain cache - and a + // round where the current search has not resolved lateChunk's chain yet is skipped. + if (ReferenceChainTestHooks.referenceChainPassesRunForTest0() != passesRunBeforeLateChunk) { + long lateChunkTargetTag = ReferenceChainTestHooks.getReferenceChainResolvedTargetTagForTest0( + LEAK_BUFFER.get(LATE_CHUNK_INDEX)); + if (lateChunkTargetTag != 0) { + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath, lateChunkTargetTag); + } + } + } + + // Release (non-DEBUG) builds have no identity seam: class-only matching, as before. + if (match == null && !debugBuild) { + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath, 0); + } + if (match == null && !debugBuild) { + Thread.sleep(300); + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath, 0); + } + } + + // A cold external JVM's one-shot root-seeded walk over its whole reachable graph (full + // JUnit/JMC/Gradle-worker classpath) takes far longer than the round loop - a 5 x 1s tail + // gave up moments before runPass() completed and built the chain. + int watchedIndex = -1; + int attemptsOnWatchedChunk = 0; + for (int attempt = 0; match == null && attempt < TAIL_ATTEMPTS; attempt++) { + Thread.sleep(1500); + // Same passesRun()-changed guard as the round loop. + long requiredTargetTag = 0; + if (debugBuild) { + if (ReferenceChainTestHooks.referenceChainPassesRunForTest0() == passesRunBeforeLateChunk) { + continue; + } + // The tail watches, one at a time, chunks appended to LEAK_BUFFER well after that + // collection was first EXPANDED - exactly the discovery this scenario exists to prove. + // Watching a chunk allocated once at scenario start (lateChunk above) is not durable: + // the whole leak-tag pipeline keys on the chunk's allocation-tracking record, records + // are only minted at allocation, and the tracking table's churn can evict an old + // chunk's record for the rest of the run - observed as the representative resolving + // that chunk correctly but reading tag=0 for 2000+ consecutive polls (no leak tag ever + // minted, so no interception and no chain ever built for it). A FRESHLY appended chunk + // has a guaranteed-live record: the explicit poll below tags it immediately, the next + // static-anchor descend intercepts it, and a later attempt's poll resolves its chain. + // But discovery takes several walk cycles (interception, then a later poll's chain + // build) - measured at more than one 1.5s attempt - so the SAME chunk must stay the + // watched target for several attempts before rotating to a new one; rotating every + // attempt reads a just-allocated chunk that has not matured yet and abandons each + // chunk right before it would resolve (observed as 0/8 runs). If a chunk still has + // not resolved after TAIL_CHUNK_ATTEMPTS attempts, its record most likely died the + // same death lateChunk's can - rotate. Same no-local discipline as lateChunk above: + // each watched chunk is reachable only through LEAK_BUFFER, so any walk that reaches + // it comes through elementData, never through a stack-local root. + if (watchedIndex < 0 || attemptsOnWatchedChunk >= TAIL_CHUNK_ATTEMPTS) { + LEAK_BUFFER.add(new byte[CHUNK_BYTES]); + watchedIndex = LEAK_BUFFER.size() - 1; + attemptsOnWatchedChunk = 0; + } + attemptsOnWatchedChunk++; + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(LEAK_BUFFER_TEST_KLASS_ID, + LEAK_BUFFER.get(watchedIndex)); + ReferenceChainTestHooks.pollReferenceChainTargets0(); + requiredTargetTag = ReferenceChainTestHooks.getReferenceChainResolvedTargetTagForTest0( + LEAK_BUFFER.get(watchedIndex)); + if (requiredTargetTag == 0) { + continue; + } + } + profiler.dump(scratchDumpPath); + match = findMatch(scratchDumpPath, requiredTargetTag); + } + + if (match == null) { + System.out.println(NOT_FOUND_MARKER); + return; + } + System.out.println(FOUND_MARKER + match.chain.get(0).getFullName()); + } + + /** + * {@code requiredTargetTag == 0}: the first byte[] chain (class-only, non-debug builds). + * Otherwise: only the byte[] chain whose targetTag identifies that one instance. + */ + private static ReferenceChainAssertions.ChainMatch findMatch(Path scratchDumpPath, + long requiredTargetTag) throws Exception { + if (!Files.exists(scratchDumpPath)) { + return null; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + IItemCollection chains = events.apply(ItemFilters.type("datadog.ReferenceChain")); + return requiredTargetTag == 0 + ? ReferenceChainAssertions.findMatchForClass(chains, byte[].class) + : ReferenceChainAssertions.findMatchForClassAndTargetTag( + chains, byte[].class, requiredTargetTag); + } + + /** + * Frontier states of LEAK_BUFFER, its backing ArrayList and that list's elementData array. + * The holders are fetched fresh on every call and never kept beyond it - a static field or a + * long-lived local would add a second root path to them. + */ + private static int[] holderStates(Field backingListField, Field elementDataField) + throws IllegalAccessException { + Object backing = backingListField.get(LEAK_BUFFER); + Object elementData = elementDataField.get(backing); + return new int[] { + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(LEAK_BUFFER), + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(backing), + ReferenceChainTestHooks.getReferenceChainFrontierStateForTest0(elementData) + }; + } + + private static boolean leakBufferHoldersExpanded(Field backingListField, Field elementDataField) + throws IllegalAccessException { + for (int state : holderStates(backingListField, elementDataField)) { + if (state != FRONTIER_STATE_EXPANDED) { + return false; + } + } + return true; + } + + private static String describeHolderStates(Field backingListField, Field elementDataField) + throws IllegalAccessException { + return Arrays.toString(holderStates(backingListField, elementDataField)); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakReferenceChainTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakReferenceChainTest.java new file mode 100644 index 0000000000..b0e631a740 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakReferenceChainTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.AbstractProcessProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Deque; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_BASE; +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_POOL_SIZE; +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.Assertions.fail; +import static org.junit.jupiter.api.Assumptions.assumeFalse; + +/** + * The thread-local taxonomy of the leak-tag correlation use case, end-to-end + * in a separate child JVM (same separate-process rationale as + * {@code LeakTagCorrelationReferenceChainTest}'s own class comment): a growing + * {@code byte[]} collection held ONLY through the leaking thread's + * {@code ThreadLocal} map must still produce a correlated + * {@code datadog.ReferenceChain} - {@code targetTag} in the leak-tag pool + * range, matched by a {@code datadog.HeapLiveObject} {@code leakTag}. The + * scenario is {@code ThreadLocalLeakScenario}. + */ +@Tag("slow") +public class ThreadLocalLeakReferenceChainTest extends AbstractProcessProfilerTest { + + @Test + void shouldCorrelateThreadLocalHeldLeakChain() throws Exception { + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + + Path scratchDumpPath = Files.createTempFile("referencechains-tl-correlation-", ".jfr"); + Files.deleteIfExists(scratchDumpPath); + Path continuousJfrPath = Files.createTempFile("referencechains-tl-correlation-continuous-", ".jfr"); + Deque testLogTail = new ArrayDeque<>(); + try { + // Same raised budget/ratio rationale as + // LeakTagCorrelationReferenceChainTest (cold external JVM, ~100k filler, + // memory=64:l:1.0 keeping every chunk allocation tracked). + String startCommand = "start,memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:" + + "pausetarget=60000" + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + List jvmArgs = Collections.singletonList( + "-Dddprof_test.config=" + ReferenceChainAssertions.requiredTestConfig()); + + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch("threadlocal-leak", jvmArgs, packedCommand, + Collections.emptyMap(), + childTimeoutSeconds(150), + line -> { + if (line.startsWith(ThreadLocalLeakScenario.FOUND_MARKER) + || line.equals(ThreadLocalLeakScenario.NOT_FOUND_MARKER) + || line.startsWith(ThreadLocalLeakScenario.TAG_OUT_OF_POOL_MARKER) + || line.startsWith(ThreadLocalLeakScenario.NO_LIVE_OBJECT_MARKER) + || line.equals(ThreadLocalLeakScenario.HANDOFF_TIMEOUT_MARKER)) { + resultLine.set(line); + } + if (line.startsWith("[TEST::INFO]")) { + // Synchronized: the reader thread can outlive a join timeout. + synchronized (testLogTail) { + testLogTail.addLast(line); + while (testLogTail.size() > 2000) { + testLogTail.removeFirst(); + } + } + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout" + + diagnostics(testLogTail)); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + assertNotNull(resultLine.get(), "Child process never printed a recognizable result " + + "marker on stdout" + diagnostics(testLogTail)); + String line = resultLine.get(); + if (line.startsWith(ThreadLocalLeakScenario.FOUND_MARKER)) { + long targetTag = Long.parseLong( + line.substring(ThreadLocalLeakScenario.FOUND_MARKER.length()).trim()); + assertTrue(targetTag >= LEAK_TAG_BASE && targetTag < LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE, + "Reported targetTag " + targetTag + " is outside the leak-tag pool range [" + + LEAK_TAG_BASE + ", " + (LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE) + ")"); + return; + } + fail("Thread-local leak-tag correlation did not succeed: " + line + + diagnostics(testLogTail)); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } + + /** + * Same contract as {@link #shouldCorrelateThreadLocalHeldLeakChain()}, but + * the leak thread is started only AFTER the recording began. Regression + * test for the {@code Profiler::onThreadStart} → + * {@code registerThreadObject()} wiring: a mid-recording thread is + * invisible to {@code registerExistingThreads()}'s one-time snapshot, so + * only the onThreadStart registration lets + * {@code walkCandidateThreadLocals()} reach its ThreadLocalMap-held leak. + * With the wiring broken this child reports + * {@code ThreadLocalLeakScenario.NOT_FOUND_MARKER} and this test fails. + */ + @Test + void shouldCorrelateLeakOnThreadCreatedAfterRecordingStart() throws Exception { + assumeFalse(Platform.isJavaVersion(8)); + assumeFalse(Platform.isJ9()); + assumeFalse(Platform.isZing()); + + Path scratchDumpPath = Files.createTempFile("referencechains-tl-late-", ".jfr"); + Files.deleteIfExists(scratchDumpPath); + Path continuousJfrPath = Files.createTempFile("referencechains-tl-late-continuous-", ".jfr"); + Deque testLogTail = new ArrayDeque<>(); + try { + String startCommand = "start,memory=64:l:1.0,generations=true," + + "referencechains=true:hops=64:budget=200000:ttl=120000:framecap=2000000:" + + "pausetarget=60000" + + ",jfr,file=" + continuousJfrPath.toAbsolutePath(); + String packedCommand = startCommand + "|||" + scratchDumpPath.toAbsolutePath(); + + List jvmArgs = Collections.singletonList( + "-Dddprof_test.config=" + ReferenceChainAssertions.requiredTestConfig()); + + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch("threadlocal-leak-late", jvmArgs, packedCommand, + Collections.emptyMap(), + childTimeoutSeconds(150), + line -> { + if (line.startsWith(ThreadLocalLeakScenario.FOUND_MARKER) + || line.equals(ThreadLocalLeakScenario.NOT_FOUND_MARKER) + || line.startsWith(ThreadLocalLeakScenario.TAG_OUT_OF_POOL_MARKER) + || line.startsWith(ThreadLocalLeakScenario.NO_LIVE_OBJECT_MARKER) + || line.equals(ThreadLocalLeakScenario.HANDOFF_TIMEOUT_MARKER)) { + resultLine.set(line); + } + if (line.startsWith("[TEST::INFO]")) { + // Synchronized: the reader thread can outlive a join timeout. + synchronized (testLogTail) { + testLogTail.addLast(line); + while (testLogTail.size() > 2000) { + testLogTail.removeFirst(); + } + } + } + return LineConsumerResult.CONTINUE; + }, + null); + + assertTrue(result.inTime, "Child process did not exit within the wait timeout" + + diagnostics(testLogTail)); + assertEquals(0, result.exitCode, "Child process exited with a non-zero code"); + assertNotNull(resultLine.get(), "Child process never printed a recognizable result " + + "marker on stdout" + diagnostics(testLogTail)); + String line = resultLine.get(); + assertTrue(line.startsWith(ThreadLocalLeakScenario.FOUND_MARKER), + "Thread-local leak held by a thread created after recording start was not " + + "correlated: " + line + diagnostics(testLogTail)); + } finally { + Files.deleteIfExists(scratchDumpPath); + Files.deleteIfExists(continuousJfrPath); + } + } + + /** Filtered summary of the child's TEST_LOG stream, embedded in failure messages. */ + private static String diagnostics(Deque testLogTailRef) { + List testLogTail; + synchronized (testLogTailRef) { + testLogTail = new ArrayList<>(testLogTailRef); + } + if (testLogTail.isEmpty()) { + return "\n(no TEST_LOG output captured - non-debug build or no reference-chain passes ran)"; + } + List threadWalk = new ArrayList<>(); + List runPassDone = new ArrayList<>(); + List gotw = new ArrayList<>(); + List leakTagLines = new ArrayList<>(); + for (String s : testLogTail) { + if (s.contains("walkCandidateThreadLocals")) { + threadWalk.add(s); + } else if (s.contains("runPass done:")) { + runPassDone.add(s); + } else if (s.contains("expandFrontier gotw")) { + gotw.add(s); + } else if (s.contains("leak-tag intercepted") + || s.contains("correlateAdmittedLeakTag") + || s.contains("tagLeakInstances summary")) { + leakTagLines.add(s); + } + } + StringBuilder sb = new StringBuilder("\n--- filtered TEST_LOG tail ---\n"); + appendTail(sb, "thread-walk:", threadWalk, 10); + appendTail(sb, "runPass:", runPassDone, 5); + appendTail(sb, "gotw:", gotw, 5); + appendTail(sb, "leak-tag:", leakTagLines, 10); + return sb.toString(); + } + + private static void appendTail(StringBuilder sb, String label, List lines, int cap) { + if (lines.isEmpty()) { + return; + } + sb.append(label).append('\n'); + int start = Math.max(0, lines.size() - cap); + for (int i = start; i < lines.size(); i++) { + sb.append(" ").append(lines.get(i)).append('\n'); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakScenario.java b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakScenario.java new file mode 100644 index 0000000000..5f7fb799bf --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/referencechains/ThreadLocalLeakScenario.java @@ -0,0 +1,488 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.referencechains; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.ReferenceChainTestHooks; +import org.openjdk.jmc.common.IMCType; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.item.IType; +import org.openjdk.jmc.common.item.ItemFilters; +import org.openjdk.jmc.flightrecorder.JfrLoaderToolkit; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.TimeUnit; + +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_BASE; +import static com.datadoghq.profiler.referencechains.ReferenceChainAssertions.LEAK_TAG_POOL_SIZE; + +/** + * The thread-local taxonomy of the leak-tag correlation use case, run inside a + * separate child JVM by {@code ExternalLauncher}'s {@code threadlocal-leak} mode: + * a growing {@code byte[]} collection held ONLY through the leaking thread's + * own {@code ThreadLocal} map - never by any static field - must end up + * reported as a {@code datadog.ReferenceChain} event whose {@code targetTag} + * is a leak-tag-pool tag, matching a {@code datadog.HeapLiveObject} event's + * {@code leakTag} for the same object. + * + *

    This is the taxonomy shape {@code ReferenceChainTracker}'s thread-scoped + * walk exists for (walkCandidateThreadLocals(), referenceChains.cpp): the + * retained chunks are reachable only through the Thread object's + * {@code threadLocals} -> ThreadLocalMap -> table -> Entry -> value path, so + * the scenario proves that holder shape keeps working end-to-end - the + * thread-object registry, the anchor admission and the descend gates do not + * break the plain chain + correlation contract. The transient-root gate and + * the noise-chain suppression are {@code LeakTagCorrelationScenario}'s own + * contracts and are deliberately not re-asserted here. + * + *

    Same separate-process, seed-before-start, per-round hysteresis + * maintenance and debug-seam patterns as {@code LeakTagCorrelationScenario}'s + * own class comment (the one-shot root-seeded walk is a per-process + * resource, and a one-shot seeded ramp ages out of LivenessTracker's + * hysteresis once real fold samples interleave). + */ +public final class ThreadLocalLeakScenario { + private ThreadLocalLeakScenario() {} + + /** + * The leak sink, touched ONLY by the leaking thread. Deliberately NOT + * static-retained: the only durable path to the chunks must run through + * the leaking thread's ThreadLocalMap. + */ + private static final ThreadLocal> SINK = + ThreadLocal.withInitial(() -> new ArrayList<>()); + + /** Scale ingredient - same rationale as LeakTagCorrelationScenario.FILLER. */ + static final Object[][] FILLER = new Object[1500][]; + + // Distinct from the other scenarios' ids (987301-987304) - each scenario + // runs in its own child JVM, but a distinct id keeps logs unambiguous. + private static final int LEAK_KLASS_ID = 987305; + + // See LeakTagCorrelationScenario.SEED_EPOCHS_FOR_HYSTERESIS' own comment. + private static final int SEED_EPOCHS_FOR_HYSTERESIS = 15; + private static final int SEED_CHUNKS = 10; + + private static int hysteresisEpoch = 0; + + /** + * Chunk size that clears ObjectSampler's 256KiB sampling floor on its own + * (see LeakTagCorrelationScenario.CHUNK_BYTES' own comment): every chunk + * allocation must be sampled so it becomes a tracked, taggable + * live-heap instance. + */ + private static final int CHUNK_BYTES = 1_000_000; + + /** + * Handoff for the leak thread's profiler tid and one seed chunk - the + * main thread seeds LivenessTracker's per-(klass, tid) qualification with + * the REAL allocating thread's tid, and that tid lives on the leak thread. + * The chunk reference is dropped by take() so the main thread never keeps a + * second path to the sink alive. + */ + private static final class LeakHandoff { + volatile int tid; + volatile byte[] seedChunk; + + void publish(int tid, byte[] chunk) { + this.tid = tid; + this.seedChunk = chunk; + } + + byte[] take() { + byte[] chunk = seedChunk; + seedChunk = null; + return chunk; + } + + /** + * Required in both thread-start modes: neither starting the thread + * before {@code profiler.execute()} nor after it guarantees it has + * published by the time run() consumes the handoff. Must be called + * before take() (it polls seedChunk, which take() clears). + */ + boolean awaitPublication(long timeoutMs) throws InterruptedException { + long deadline = System.nanoTime() + + TimeUnit.MILLISECONDS.toNanos(timeoutMs); + while (seedChunk == null && System.nanoTime() < deadline) { + Thread.sleep(10); + } + return seedChunk != null; + } + } + + /** Printed to stdout, followed by the correlated targetTag, on full success. */ + public static final String FOUND_MARKER = "[tl-correlation-found] "; + + /** Printed to stdout if no leaked byte[] chain was ever observed. */ + public static final String NOT_FOUND_MARKER = "[tl-correlation-not-found]"; + + /** Printed to stdout (with the tag) if a chain's targetTag is out of pool range. */ + public static final String TAG_OUT_OF_POOL_MARKER = "[tl-correlation-tag-out-of-pool] "; + + /** Printed to stdout (with the tag) if no HeapLiveObject matched the chain's tag. */ + public static final String NO_LIVE_OBJECT_MARKER = "[tl-correlation-no-live-object] "; + + /** + * Printed to stdout if the leak thread never published its tid and seed chunk - a fixture + * failure, reported apart from a correlation miss. + */ + public static final String HANDOFF_TIMEOUT_MARKER = "[tl-correlation-handoff-timeout]"; + + private static final class ScanResult { + ReferenceChainAssertions.ChainMatch payloadChain; + boolean payloadChainInPool; + // ReferenceChain.leakTagIncarnation of payloadChain; -1 when the field is absent. + long payloadChainIncarnation = -1; + int liveObjectMatchesForChainTag; + } + + public static void run(JavaProfiler profiler, String startCommand, + Path scratchDumpPath) throws Exception { + run(profiler, startCommand, scratchDumpPath, false); + } + + /** + * @param lateThread when true, the leak thread is started only AFTER the + * recording began. This is the regression shape for + * {@code Profiler::onThreadStart}'s {@code registerThreadObject()} + * wiring (referenceChains.h): a thread born mid-recording is invisible + * to {@code registerExistingThreads()}'s one-time snapshot, so with + * the wiring broken the (klass, tid) qualification is seeded but + * {@code walkCandidateThreadLocals()} finds no Thread object for the + * tid and the ThreadLocalMap-held chain is never reached (the child + * prints {@link #NOT_FOUND_MARKER}). + */ + public static void run(JavaProfiler profiler, String startCommand, + Path scratchDumpPath, boolean lateThread) throws Exception { + // Build the whole fixture before the profiler starts - see the class + // comment and LeakingCacheScenario's seed-before-start rationale. + for (int row = 0; row < FILLER.length; row++) { + Object[] column = new Object[64]; + for (int col = 0; col < column.length; col++) { + column[col] = new FillerNode(); + } + FILLER[row] = column; + } + + LeakHandoff handoff = new LeakHandoff(); + // One round = the main thread offers a token; the leak thread takes it and + // appends one chunk to ITS OWN ThreadLocal sink (same persistent-allocator + // shape as ReferenceChainTrackingTest's togcroot fixture, so the chunks are + // always allocated on the same qualifying tid). + BlockingQueue rounds = new ArrayBlockingQueue<>(32); + Thread leakThread = new Thread(() -> { + // No local ever holds the sink: re-fetch it via SINK on every use so + // the only durable path to the chunks is Thread.threadLocals -> + // ThreadLocalMap. A live stack local would let root enumeration reach + // ArrayList -> elementData -> byte[] directly (a depth-2 stack-local + // chain the transient-root gate does not suppress), bypassing the + // thread-scoped walk this scenario exists to prove. + for (int i = 0; i < SEED_CHUNKS; i++) { + SINK.get().add(new byte[CHUNK_BYTES]); + } + handoff.publish(ReferenceChainTestHooks.getTid(), SINK.get().get(0)); + while (true) { + try { + if (rounds.poll(50, TimeUnit.MILLISECONDS) == null) { + continue; + } + SINK.get().add(new byte[CHUNK_BYTES]); + } catch (InterruptedException e) { + return; + } + } + }, "threadlocal-leak"); + leakThread.setDaemon(true); + if (!lateThread) { + leakThread.start(); + } + + if (startCommand != null && !startCommand.isEmpty()) { + profiler.execute(startCommand); + } + + if (lateThread) { + leakThread.start(); + } + if (!handoff.awaitPublication(30000)) { + System.out.println(HANDOFF_TIMEOUT_MARKER); + return; + } + + boolean debugBuild = ReferenceChainAssertions.isDebugInstrumentedBuild(); + int leakTid = 0; + if (debugBuild) { + // Representative first, seeds second: the rep's real klass id must + // own the seeds or they authorize nothing. take() drops the handoff's + // reference, so the rep chunk stays reachable only via ThreadLocalMap. + ReferenceChainTestHooks.setKlassPopulationRepresentativeForTest0(LEAK_KLASS_ID, handoff.take()); + leakTid = handoff.tid; + for (int epoch = 1; epoch <= SEED_EPOCHS_FOR_HYSTERESIS; epoch++) { + ReferenceChainTestHooks.seedKlassPopulationSample0(LEAK_KLASS_ID, epoch * 10, epoch); + ReferenceChainTestHooks.seedTidTrendSample0(LEAK_KLASS_ID, leakTid, epoch * 3, epoch); + } + // Maintenance continues the seeded ramp (next epoch = + // SEED_EPOCHS_FOR_HYSTERESIS + 1) instead of restarting at 1 - same as + // StaticFieldGrowingCollectionScenario. + hysteresisEpoch = SEED_EPOCHS_FOR_HYSTERESIS; + // Wait for the BFS thread's first full pass, then poll - same + // load-bearing wait as LeakTagCorrelationScenario. + int initialPasses = ReferenceChainTestHooks.referenceChainPassesRunForTest0(); + for (int i = 0; i < 300; i++) { + if (ReferenceChainTestHooks.referenceChainPassesRunForTest0() != initialPasses) { + break; + } + Thread.sleep(100); + } + ReferenceChainTestHooks.pollReferenceChainTargets0(); + } else { + // Drop the handoff's chunk reference: the lambda (captured by the + // Thread's runnable) retains `handoff`, so a never-taken seedChunk + // would be a second, non-ThreadLocalMap path to a chunk. + handoff.take(); + Thread.sleep(5000); + } + + ScanResult best = null; + int totalRounds = 25; + for (int round = 1; round <= totalRounds; round++) { + if (!rounds.offer(round)) { + throw new IllegalStateException("rounds queue rejected round " + round); + } + if (debugBuild) { + hysteresisEpoch++; + ReferenceChainTestHooks.seedKlassPopulationSample0( + LEAK_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0(LEAK_KLASS_ID, leakTid, hysteresisEpoch * 3, hysteresisEpoch); + } + Thread.sleep(300); + System.gc(); + profiler.dump(scratchDumpPath); + // Scan every 3rd round - same scan-churn rationale as + // LeakTagCorrelationScenario's own comment. + if (round % 3 == 0) { + best = keepBest(best, scan(scratchDumpPath)); + if (best != null && best.payloadChainInPool + && best.liveObjectMatchesForChainTag > 0) { + break; + } + } + } + + for (int attempt = 0; attempt < 5; attempt++) { + if (best != null && best.payloadChainInPool && best.liveObjectMatchesForChainTag > 0) { + break; + } + if (debugBuild) { + hysteresisEpoch++; + ReferenceChainTestHooks.seedKlassPopulationSample0( + LEAK_KLASS_ID, hysteresisEpoch * 10, hysteresisEpoch); + ReferenceChainTestHooks.seedTidTrendSample0(LEAK_KLASS_ID, leakTid, hysteresisEpoch * 3, hysteresisEpoch); + } + if (!rounds.offer(attempt)) { + throw new IllegalStateException( + "rounds queue rejected attempt " + attempt); + } + Thread.sleep(1000); + System.gc(); + profiler.dump(scratchDumpPath); + best = keepBest(best, scan(scratchDumpPath)); + } + + if (best == null || best.payloadChain == null) { + System.out.println(NOT_FOUND_MARKER); + return; + } + if (!best.payloadChainInPool) { + System.out.println(TAG_OUT_OF_POOL_MARKER + best.payloadChain.targetTag); + return; + } + if (best.liveObjectMatchesForChainTag == 0) { + System.out.println(NO_LIVE_OBJECT_MARKER + best.payloadChain.targetTag); + return; + } + System.out.println(FOUND_MARKER + best.payloadChain.targetTag); + } + + private static boolean passesThroughThreadLocalMap(Object[] rawChain) { + for (Object element : rawChain) { + if (element instanceof IMCType) { + String name = ((IMCType) element).getFullName(); + if (name != null && name.contains("ThreadLocalMap")) { + return true; + } + } + } + return false; + } + + private static ScanResult keepBest(ScanResult best, ScanResult scan) { + if (scan == null) { + return best; + } + // Unlike LeakTagCorrelationScenario.keepBest, the losing scan has nothing + // to merge: this scenario's ScanResult carries no transient/noise evidence + // (that is LeakTagCorrelation's contract), and a scan without a payload + // chain has no other populated field worth keeping. If evidence fields are + // ever added here, merge them like LeakTagCorrelationScenario's mergeDistinct. + if (best == null || best.payloadChain == null) { + return scan; + } + if (scan.payloadChain == null) { + return best; + } + // Mirror scan()'s own within-scan preference: an in-pool (correlated) + // chain outranks an out-of-pool one no matter which scan observed it, so + // a later out-of-pool chain cannot displace an earlier correlated one. + if (best.payloadChainInPool != scan.payloadChainInPool) { + return best.payloadChainInPool ? best : scan; + } + return scan; + } + + /** + * Scans one dump for a leaked {@code byte[]} chain through a ThreadLocalMap + * hop, whether its targetTag is in the leak-tag pool, and how many + * {@code datadog.HeapLiveObject} {@code byte[]} events carry that tag in + * {@code leakTag}. No transient/noise gate scans - those are + * LeakTagCorrelationScenario's contracts. + */ + private static ScanResult scan(Path scratchDumpPath) throws Exception { + if (!Files.exists(scratchDumpPath)) { + return null; + } + IItemCollection events; + try (InputStream in = Files.newInputStream(scratchDumpPath)) { + events = JfrLoaderToolkit.loadEvents(in); + } + ScanResult result = new ScanResult(); + + IItemCollection chains = events.apply( + ItemFilters.type("datadog.ReferenceChain")); + for (IItemIterable iterable : chains) { + IType type = iterable.getType(); + IMemberAccessor chainAccessor = + ReferenceChainAssertions.findAccessor(type, "chain"); + IMemberAccessor targetTagAccessor = + ReferenceChainAssertions.findAccessor(type, "targetTag"); + IMemberAccessor incarnationAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTagIncarnation"); + for (IItem item : iterable) { + Object chainValue = chainAccessor.getMember(item); + if (!(chainValue instanceof Object[])) { + continue; + } + Object[] rawChain = (Object[]) chainValue; + String leaf = rawChain.length > 0 && rawChain[0] instanceof IMCType + ? ((IMCType) rawChain[0]).getFullName() : ""; + long targetTag = targetTagAccessor != null + ? ReferenceChainAssertions.numberValue(targetTagAccessor.getMember(item)) : -1; + if (!"byte[]".equals(leaf)) { + continue; + } + // Only a chain through the leaking thread's ThreadLocalMap proves the + // thread-scoped walk; any other byte[] chain must not satisfy this + // scenario (with thread registration broken it would be the only + // kind of chain left, and the scenario must report NOT_FOUND). + if (!passesThroughThreadLocalMap(rawChain)) { + continue; + } + boolean inPool = + targetTag >= LEAK_TAG_BASE && targetTag < LEAK_TAG_BASE + LEAK_TAG_POOL_SIZE; + // Prefer the first in-pool (correlated) chain over any out-of-pool + // one seen earlier - same rationale as + // LeakTagCorrelationScenario.scan(). + if ((inPool && !result.payloadChainInPool) || result.payloadChain == null) { + List chain = new ArrayList<>(rawChain.length); + for (Object element : rawChain) { + chain.add((IMCType) element); + } + result.payloadChain = new ReferenceChainAssertions.ChainMatch(chain, targetTag, 0); + result.payloadChainInPool = inPool; + // The (tag, incarnation) pair is the chain's identity; an unresolved + // -1 stamp must fail loud rather than degrade to tag-only matching, + // which a recycled tag's chunk could satisfy. + if (incarnationAccessor == null) { + throw new IllegalStateException( + "datadog.ReferenceChain events have no leakTagIncarnation accessor"); + } + long chainIncarnation = + ReferenceChainAssertions.numberValue(incarnationAccessor.getMember(item)); + if (chainIncarnation == -1) { + throw new IllegalStateException( + "chain leakTagIncarnation is not a numeric value; " + + "tag-only correlation is ambiguous across recycled tags"); + } + result.payloadChainIncarnation = chainIncarnation; + } + } + } + + if (result.payloadChainInPool) { + IItemCollection liveObjects = events.apply( + ItemFilters.type("datadog.HeapLiveObject")); + for (IItemIterable iterable : liveObjects) { + IType type = iterable.getType(); + IMemberAccessor leakTagAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTag"); + IMemberAccessor objectClassAccessor = + ReferenceChainAssertions.findAccessor(type, "objectClass"); + IMemberAccessor incarnationAccessor = + ReferenceChainAssertions.findAccessor(type, "leakTagIncarnation"); + // Fail loud if either identity field is missing: without the tag, every + // live object reads -1 and never matches the chain's targetTag; without + // the incarnation, the match degrades to tag-only, which a recycled + // tag's stale chunk could satisfy. Both must surface as errors, not as + // an empty correlation with no diagnostic. + if (incarnationAccessor == null) { + throw new IllegalStateException( + "datadog.HeapLiveObject events have no leakTagIncarnation accessor"); + } + if (leakTagAccessor == null) { + throw new IllegalStateException( + "datadog.HeapLiveObject events have no leakTag accessor"); + } + for (IItem item : iterable) { + long leakTag = + ReferenceChainAssertions.numberValue(leakTagAccessor.getMember(item)); + long incarnation = + ReferenceChainAssertions.numberValue(incarnationAccessor.getMember(item)); + // Leak tags are recycled: the tag alone is ambiguous across chunks. + // An unresolved -1 incarnation is a hard mismatch, never a tag-only + // match: a stale chunk that inherited the recycled tag must not + // count as this chain's live object. + if (incarnation == -1 + || leakTag != result.payloadChain.targetTag + || incarnation != result.payloadChainIncarnation) { + continue; + } + Object objectClass = objectClassAccessor != null + ? objectClassAccessor.getMember(item) : null; + if (objectClass instanceof IMCType + && "byte[]".equals(((IMCType) objectClass).getFullName())) { + result.liveObjectMatchesForChainTag++; + } + } + } + } + return result; + } + + /** Scale-only object - same rationale as LeakTagCorrelationScenario.FillerNode. */ + static final class FillerNode { + long p0, p1, p2, p3, p4, p5, p6, p7; + } +} diff --git a/docs/sphinx/specs/2026-10-13-sphinx-review-high-the-new-frontierstate.md b/docs/sphinx/specs/2026-10-13-sphinx-review-high-the-new-frontierstate.md new file mode 100644 index 0000000000..03aded3da0 --- /dev/null +++ b/docs/sphinx/specs/2026-10-13-sphinx-review-high-the-new-frontierstate.md @@ -0,0 +1,82 @@ +# Spec: Reference-chain test seams — locking, contract docs, scenario predicate and JDK-8 args + +This spec covers PR-800 change requests F-91f3cc84a25e, F-17cff193f8ed, F-054807ffb76e, F-3b9270c81830, F-b2e5089803c3 and F-32af0fcc0ab2. + +The design is in `.sphinx/address/design.md`. Its "Cluster Rules & Invariants" section is the normative source of truth for this spec. + +## Problem + +The PR added two native test seams. Java test scenarios use them to stay in step with the reference-chain BFS engine. + +- `ReferenceChainTracker::frontierStateForTest()` reads `_frontier` without `_engine_lock`. Every other engine-state seam takes that lock, so this seam can see a pass or a restart while it is only partly done (F-91f3cc84a25e). Its guard and sentinel paths have no tests (F-32af0fcc0ab2). +- `ReferenceChainTracker::resolvedChainTargetTagForTest()` samples the search generation (`_search_start_ns`) before it takes any lock. A concurrent `restartSearch()` or new `runPass()` can then change the generation between the sample and the comparison against the cached entry. The seam can return a stale chain's target tag (F-17cff193f8ed). + - The real writers of the generation hold `_engine_lock`, not `_resolved_chains_lock`. Reordering the load relative to `_resolved_chains_lock` alone does not close the window. +- The javadoc and header comment for the frontier-state seam skip state value 2 (EDGE) without explaining why (F-3b9270c81830). +- `StaticFieldGrowingCollectionScenario` enables its seam-based synchronization only when the config is `"debug"`. The seams are also compiled into asan and tsan builds, so the strongest race-prevention path never runs on sanitizer CI (F-b2e5089803c3). +- `ExternalProcessReferenceChainTest` always passes `--add-opens`. A JDK 8 child JVM rejects that option (F-054807ffb76e). + +## Correct behaviour + +1. **R-LOCK (seam locking).** + - `frontierStateForTest(tag)` and `resolvedChainTargetTagForTest(tag)` hold `_engine_lock` for their entire read of engine state. This matches `pendingExpandPositionForTest()` and `pendingExpandSizeForTest()`. + - If another thread holds `_engine_lock`, a seam call with a valid tag does not return until that thread releases it. + - The invalid-tag fast path returns the sentinel without locking. Invalid means `tag <= 0`, or a leak tag. +2. **R-ORDER (lock order).** + - The lock order is `_engine_lock` first, then `_resolved_chains_lock`. This is the same order as the BFS thread (`finishLoopIterationSerialized` → `pollWatchedTargets`, and `cacheResolvedChain`). + - No path holds `_resolved_chains_lock` while it waits on `_engine_lock`. + - A seam call that runs concurrently with a lock holder finishes once that holder releases. It never deadlocks. +3. **R-GEN (generation guard).** `resolvedChainTargetTagForTest(tag)` returns the cached `_target_tag` only when all of the following hold. Otherwise it returns 0. + - `tag > 0`. + - The tag is not a leak tag. + - `_search_start_ns != 0`. + - `cached.source_search_ns == _search_start_ns`, with `_search_start_ns` read while both locks are held. + + If a lock holder changes the generation while a seam call is waiting, the seam call sees the new generation. +4. **R-SENTINEL (frontier-state contract).** `frontierStateForTest(tag)` returns -1 in each of these cases: + - `tag <= 0`. + - `tag` is a leak tag. + - There is no frontier table. + - There is no entry for `tag`. + + Otherwise it returns the raw `FrontierEntryState`: FRONTIER = 0, EXPANDED = 1, ABANDONED = 3. EDGE = 2 is retired; the production walk never produces it. From Java, an object that has never been tagged reads as -1. +5. **R-BUILD (scenario seam-path predicate).** `StaticFieldGrowingCollectionScenario` takes the seam path (holder-EXPANDED wait, lateChunk identity pinning, untagged-probe check) exactly when `config != null && ("debug".equals(config) || config.endsWith("san"))`. Release builds take the class-only path. The 45 s holder deadline and the 150 s parent launch timeout stay as they are. +6. **R-JDK (child JVM args).** The `leak-static-field` child always receives `-Dddprof_test.config=`. It receives `--add-opens=java.base/java.util=ALL-UNNAMED` if and only if `Platform.isJavaVersionAtLeast(9)`. +7. **R-DOC (single source of truth).** + - `FrontierEntryState` in `referenceChainFrontier.h` is the canonical numbering. + - The JavaProfiler javadoc and the `referenceChains.h` seam comment list all four values. Both say that 2 = EDGE is retired. + - The `EDGE` and `markEdge` comments say they have no production caller. + - No numeric values change. + +## Constraints + +- No production behaviour change. Every native change touches test-only seams or comments. +- Keep the seam signatures, the JNI shims (`javaApi.cpp`), the release stubs, and the `FrontierEntryState` numeric values unchanged. Keep `markEdge`, because the fuzz harness uses it. +- `Mutex` is recursive (`PTHREAD_MUTEX_RECURSIVE`). Never take `_engine_lock` while holding `_resolved_chains_lock`. +- Java formatting goes through spotless. C++ style follows the existing two-space, ~100-column comments. +- Profiler hot paths are not touched. Both seams are called only from Java test threads via JNI. + +## Scope + +### Primary fixes + +- `ddprof-lib/src/main/cpp/referenceChains.cpp:1066` (F-91f3cc84a25e). Defect class: a test seam reads engine state without `_engine_lock`. Fix: take `_engine_lock` after the tag guard, and move the `_frontier == nullptr` check under the lock. +- `ddprof-lib/src/main/cpp/referenceChainEvents.cpp:502` (F-17cff193f8ed). Defect class: a generation stamp is sampled outside the lock that serializes its writers. Fix: take `_engine_lock`, then `_resolved_chains_lock`, then load `_search_start_ns`. +- `ddprof-test/.../ExternalProcessReferenceChainTest.java:174` (F-054807ffb76e). Defect class: a JDK 9+ only JVM option is passed to the child unconditionally. Fix: add `--add-opens` only when `Platform.isJavaVersionAtLeast(9)`. +- `ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java:677` (F-3b9270c81830). Defect class: an incomplete enum contract in the docs. Fix: document all four states in the javadoc, in `referenceChains.h`, and in the `referenceChainFrontier.h` EDGE and `markEdge` comments. +- `ddprof-test/.../StaticFieldGrowingCollectionScenario.java:100` (F-b2e5089803c3). Defect class: the seam-path predicate is narrower than the builds where the seams exist. Fix: change it to debug-or-`*san`. +- `ddprof-lib/src/main/cpp/referenceChains.cpp:1067` (F-32af0fcc0ab2). Defect class: the sentinel and lock paths have no tests. Fix: the lock is covered by the first fix; add the untagged-probe `-1` assertion in the scenario before seeding. Native gtests per the tester plan. + +### Auto-expanded sibling fixes + +None identified. The candidates below were considered and deferred; see `planner-report.json`'s `architecture_notes`. + +- **Debug-only predicate in other scenarios** (`ThreadLocalLeakScenario:223`, `LeakTagCorrelationScenario:244`, `LeakingCacheScenario:131`, and others). There the predicate only gates a seeding shortcut, not an identity or race guarantee. Widening it would change those tests' behaviour on sanitizer builds, and there is no runtime evidence that would hold up. The review did not ask for it. +- **`tagAsRootForTest`**. It is pre-existing, it mutates engine state rather than reading it (a different defect class), and this PR does not touch it. + +## Assumptions + +- I am at least 90% confident that taking `_engine_lock` in `resolvedChainTargetTagForTest()` is the minimal change that actually satisfies the reviewer's intent. The full writer list was grep-verified. +- A Java test thread blocked in native code on `_engine_lock` for up to one pass does not stall safepoints. The `pendingExpand*` seams already rely on the same pattern. +- `Platform.isJavaVersionAtLeast(9)` reflects the test JVM, which in the gradle matrix is the same JDK the child runs on. +- **Needs runtime evidence:** whether the strict path on asan/tsan completes within 45 s. +- **Residual, out of scope:** the JNI shims call `GetTag` outside `_engine_lock`, which is a time-of-check/time-of-use gap. diff --git a/docs/sphinx/specs/2026-10-15-leak-tag-reuse-cached-chain-identity.md b/docs/sphinx/specs/2026-10-15-leak-tag-reuse-cached-chain-identity.md new file mode 100644 index 0000000000..869dc0db14 --- /dev/null +++ b/docs/sphinx/specs/2026-10-15-leak-tag-reuse-cached-chain-identity.md @@ -0,0 +1,185 @@ +# Spec: Leak-tag ownership-period identity for cached chains, and launcher reader join + +This spec covers the PR-800 change requests `4282599797` (leak-tag reuse in the cached-chain +lookup) and `4282599807` (join the stdout/stderr readers before `launch()` returns). + +The design is in `.sphinx/address/design.md`. It converged after 2 adversary rounds. Its "Cluster +Rules & Invariants" section (A-R1..A-R10, B-R1..B-R4) is the normative source of truth for this +spec. + +## Problem + +- **Stale chain attributed to a new object (seam).** + `ReferenceChainTracker::resolvedChainTargetTagForTest()` (`referenceChainEvents.cpp:513-531`) + answers a leak-tag query with `object_tag` whenever any cached chain has + `_target_tag == object_tag`. + - Leak tags are recycled. `cleanup_table()` releases the tag when its object dies + (`livenessTracker.cpp:343-345`), and `acquireLeakTag()` hands it out again in LIFO order. + - Cached chains are not invalidated when their target dies. + - So the seam can match a dead object's chain against a new object that now holds the same tag. + - The code comment and the header doc both claim that tag equality is exact object identity. +- **The same mix-up in emitted events (sibling, production).** `drainPendingChainEvents()` + re-emits every cached chain in every dump, including chains whose leak-tagged target has died. + - A consumer joins `ReferenceChain.targetTag` to `HeapLiveObject.leakTag`. That join attributes + the dead object's chain to the tag's new owner. + - `StaticFieldGrowingCollectionScenario.findMatch()` can also match the stale chain. +- **Launcher returns before its readers finish.** `AbstractProcessProfilerTest.launch()` returns + after `p.waitFor()` without joining the stdout/stderr reader threads. Callback side effects, such + as the marker flags the reference-chain tests assert on, can be incomplete or not yet visible + when `launch()` returns. + +## Correct behaviour + +1. **R-STAMP (A-R2).** + - `LivenessTracker` keeps a `u32` ownership-period stamp for every leak-tag slot. + - The stamp changes, under `_leak_tag_pool_lock`, on every ownership transition of the slot: + - `acquireLeakTag()` takes the slot (free → in use); + - `releaseLeakTag()` actually releases it (in use → free); + - `start()`'s ownership rebuild or `leakTagPoolResetForTest()` flips the slot's in-use bit. + - Rejected and out-of-range releases, and rebuilds that leave a slot's ownership unchanged, do + not change the stamp. + - `u32 leakTagIncarnation(jlong tag) const` reads the stamp lock-free with acquire semantics. + It returns 0 for a tag outside the pool range. +2. **R-CAPTURE (A-R3).** + - Every production site that binds a leak tag to a frontier entry stores the current stamp with + it, in `FrontierEntry::leak_tag_incarnation`. The sites are the edge interception in + `heapReferenceCallback`, the root interception in `heapRootCallback`, and + `correlateAdmittedLeakTag`. + - `FrontierTable::setLeakTag` takes the stamp as a required argument with no default. + - `insert()` zeroes the stamp. +3. **R-PROPAGATE (A-R4).** `buildChainEvent()` copies the entry's stamp into a new in-memory field, + `ReferenceChainEvent::_leak_tag_incarnation`. That field defaults to 0. It is serialized as + `ReferenceChain.leakTagIncarnation`, and `flush_table()` writes the owner's stamp as + `HeapLiveObject.leakTagIncarnation`, so consumers join on `(tag, leakTagIncarnation)`: the tag + alone is only unambiguous within one chunk. +4. **R-OWNER (A-R5).** A cached chain describes the current owner of its leak tag if and only if + both of these hold: + - `isLeakTag(_target_tag)`; + - `_leak_tag_incarnation == LivenessTracker::instance()->leakTagIncarnation(_target_tag)`. + + One private helper implements this predicate, and both the seam and emission use it. +5. **R-SEAM (A-R6).** For a leak tag `L`, `resolvedChainTargetTagForTest(L)` returns `L` only if + some cached chain satisfies R-OWNER for `L`. Otherwise it returns 0. + - Search generation does not matter: the same owner's chain from an earlier generation still + matches. + - The frontier-tag path, the `<= 0` guard and the lock order are unchanged. +6. **R-EMIT (A-R7).** `drainPendingChainEvents()` does not emit a cached chain that has a leak-tag + target and fails R-OWNER. + - It erases that chain under `_resolved_chains_lock`: stamps only advance, so the mismatch is + permanent. `cacheResolvedChain()` also refuses to cache such a chain. + - Every other chain is emitted exactly as before. +7. **R-JOIN (B-R2/B-R3).** On both exit paths of `launch()` (in-time exit, and timeout followed by + forced destroy), the method joins `stdoutReader` and `stderrReader` before it returns. + - Each join is bounded by a named constant (10 s per reader). + - When the bound expires, `launch()` returns as before. + - `LaunchResult` and the `[ready]`/STOP handshake are unchanged. +8. **R-DOC.** Each of these documents its contract as "identity = (leak tag, ownership-period + stamp)" and no longer claims "exact object identity": + - the seam's in-body comment; + - the `referenceChains.h` seam doc; + - the `drainPendingChainEvents()` header doc; + - the `JavaProfiler.getReferenceChainResolvedTargetTagForTest0` Javadoc. + +## Constraints + +- The JFR format and metadata are unchanged. `flightRecorder.cpp` is not touched. +- `sizeof(FrontierEntry)` stays the same. Put the new `u32` directly after `u8 state` and add a + `static_assert` on the size. +- Heap callbacks add exactly one lock-free atomic load per leak-tag interception. They add no lock + and no allocation. +- The emission filter adds only lock-free loads under `_resolved_chains_lock`. +- No new lock-order edges. `_leak_tag_pool_lock` stays a leaf lock. Do not take + `_leak_tag_pool_lock` from reference-chain code. +- `improveChain()` and `reparentToDurableRoot()` must not touch the stamp. The stamp moves together + with `leak_tag`, and only `insert()`/`setLeakTag()` write it. +- Java: no signature changes to `launch(...)`/`LaunchResult`. Formatting goes through spotless. +- C++ style follows the existing two-space indentation and comments of roughly 100 columns. + +## Scope + +### Primary fixes + +- `ddprof-lib/src/main/cpp/referenceChainEvents.cpp:526` (`4282599797`). + - Defect class: identity by a recycled numeric key without an ownership-period check (ABA). + - Fix: + - add the stamp array and accessor in `livenessTracker.{h,cpp}`, bumping the stamp in acquire, + release, `start()`'s rebuild and the test reset; + - add `FrontierEntry::leak_tag_incarnation` and change `setLeakTag`'s signature in + `referenceChainFrontier.{h,cpp}`; + - capture the stamp at the three binding sites (`referenceChainWalk.cpp:299`, + `referenceChainTraversal.cpp:94`, `referenceChainEvents.cpp:782`); + - add the event field `u32 _leak_tag_incarnation` to `ReferenceChainEvent` + (`event.h:136`, initialized to 0 in the constructor) and fill it in `buildChainEvent`; + - add the owner predicate and apply it in the seam; + - update the docs (`referenceChains.h`, `JavaProfiler.java:685-691`). +- `ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProcessProfilerTest.java:144` + (`4282599807`). + - Defect class: an asynchronous output consumer is not joined before the result is returned. + - Fix: do a bounded join of both reader threads after the wait/destroy block, on both paths. + +### Auto-expanded sibling fixes + +- `ddprof-lib/src/main/cpp/referenceChainEvents.cpp:792-812`, `drainPendingChainEvents()`. + - Same ABA class, on the production emission path. + - Without this fix, the scenario-level identity assertion the reviewer named still matches the + stale chain. + - Apply the R-OWNER skip without erasing (R-EMIT). + +The following candidates were considered and rejected because they are class signals, not +identity: + +- `leak_tag != 0` checks in `buildDiscoveredInstanceChains` coverage accounting + (`referenceChainEvents.cpp:679-690`); +- anchor-tier checks (`referenceChainAnchors.cpp:161,211`); +- noise eviction (`referenceChainEvents.cpp:742`); +- `tagLeakInstances`' `existing >= LEAK_TAG_BASE` branch, which compares an object with itself. + +Frontier-tag ABA is already guarded by `source_search_ns`. + +No launcher siblings: `waitFor(` appears only in `AbstractProcessProfilerTest`, and every +child-process test goes through that one `launch()`. + +## Test plan (tester owns test edits) + +- `livenessTracker_ut.cpp` `LeakTagPoolTest`: + - the stamp changes on acquire and on release; + - the stamp is unchanged after a rejected double release and after out-of-range releases; + - after release and re-acquire of the same tag (LIFO), the stamp differs from the first + ownership's; + - `leakTagIncarnation()` returns 0 out of range. +- `referenceChainsTrackerTests.inc` `ResolvedChainCacheTest`. Use `makeEvent` with a leak-range + target, set `_leak_tag_incarnation`, and drive the pool through the `LivenessTracker` test + accessors: + - (a) a chain whose stamp equals the current stamp is returned by the seam for `L` and emitted + by `drain`; + - (b) after `acquire` + `release` (+ re-`acquire`) of `L`, the same cached chain is rejected by + the seam (0) and is absent from `drain`, and the drain evicts it (`resolvedChainCount()` drops); + - (c) the seam still matches across a search-generation change when the stamp is unchanged; + - (d) chains with a frontier-tag target are emitted regardless of pool state. + + Reset the pool in SetUp/TearDown so the stamp state stays isolated between tests. +- `referenceChainsEventTests.inc:361,367`: update the direct `setLeakTag` calls to pass the current + stamp. `CorrelateAdmittedLeakTagSetsEntryAndDiscovers`: assert that the entry's stamp equals + `leakTagIncarnation(kLeakTag)`. +- Java: the existing `ExternalProcessReferenceChainTest`/`JavaProfilerTest` runs are the regression + check for R-JOIN. No new Java test is needed beyond these runs: the race cannot be forced + deterministically. +- Verification: `./gradlew :ddprof-lib:gtestDebug` (or the per-binary targets for + `livenessTracker_ut` and `referenceChains_ut`), the debug Java test build for the reference-chain + tests, and `spotlessCheck`. + +## Assumptions + +- (≥90%) Lifetime-aware identity, rather than cache invalidation, is the minimal correct fix. An + invalidated chain would be rebuilt from the dead object's frontier entry on the next poll. +- (≥90%) Not emitting chains whose leak-tag owner is gone matches the documented "live sample" + contract of `_resolved_chains`. Such a target has no `HeapLiveObject` to correlate with. +- (≥90%) `releaseLeakTag()` runs only for dead rows (`livenessTracker.cpp:338-346`; there is no + live-row eviction release). So at binding sites (a) and (b), a live object carrying `L` implies + that its row owns `L` now. +- (≥90%) No reference-chain gtest acquires pool tags (only `leakTagPoolResetForTest()` at + `referenceChainsPodTests.inc:42`). So the existing drain and seam expectations hold with stamp 0. +- Residual: the u32 stamp wraps after 2^32 transitions of one slot. This is accepted and + documented at the field. +- Residual: if a pipe-holding descendant outlives the 10 s bound, the reader join gives up and the + original race window remains (best effort).