Skip to content

Commit cfec595

Browse files
jbachorikclaude
andcommitted
Add debug-only test seams to decouple leak-candidate slope detection from reference-chain BFS reconstruction
Both mechanisms are probabilistic and hard to reliably co-occur in one JVM run; new JNI test seams (guarded by #ifdef DEBUG) let JUnit tests seed population history and tag a known live object directly, verifying each end-to-end in isolation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 76a40d8 commit cfec595

7 files changed

Lines changed: 437 additions & 11 deletions

File tree

ddprof-lib/src/main/cpp/javaApi.cpp

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -749,3 +749,112 @@ Java_com_datadoghq_profiler_JavaProfiler_dumpContext(JNIEnv* env, jclass unused)
749749
ContextApi::get(spanId, rootSpanId);
750750
TEST_LOG("===> Context: tid:%lu, spanId=%lu, rootSpanId=%lu", OS::threadId(), spanId, rootSpanId);
751751
}
752+
753+
// PROF-15341: LivenessTracker/ReferenceChainTracker test seams. Unlike
754+
// testlog()/dumpContext() above (harmless no-ops in release, via TEST_LOG's
755+
// own release-mode expansion to nothing), these mutate real tracker state
756+
// (tagging objects, seeding population history) - shipping them into a
757+
// release build would let a caller corrupt the actual leak-detection state,
758+
// not just add a silent no-op. Guarded out entirely instead, so they only
759+
// exist in the debug build ddprof-test's `testdebug` Gradle task loads
760+
// (`-DDEBUG`, see ConfigurationPresets.kt's configureDebug()) - never in the
761+
// `-DNDEBUG` release build.
762+
#ifdef DEBUG
763+
#include "livenessTracker.h"
764+
#include "referenceChains.h"
765+
766+
extern "C" DLLEXPORT jboolean JNICALL
767+
Java_com_datadoghq_profiler_JavaProfiler_setGcGenerationsEnabled0(
768+
JNIEnv *env, jclass unused, jboolean enabled) {
769+
LivenessTracker::instance()->setGcGenerationsForTest(enabled);
770+
return JNI_TRUE;
771+
}
772+
773+
extern "C" DLLEXPORT void JNICALL
774+
Java_com_datadoghq_profiler_JavaProfiler_seedKlassPopulationSample0(
775+
JNIEnv *env, jclass unused, jint klassId, jint count, jlong epoch) {
776+
int slot;
777+
bool created;
778+
LivenessTracker::instance()->klassPopulationRecordForTest(
779+
(u32)klassId, (u16)count, (u64)epoch, &slot, &created);
780+
}
781+
782+
// Wires a real, caller-chosen live object in as klassId's leak-candidate
783+
// representative, so a test-seeded slope signal (seedKlassPopulationSample0
784+
// above) and a directly-tagged frontier root (tagAsReferenceChainRoot0
785+
// below) can be joined into one deterministic end-to-end run of
786+
// pollWatchedTargets()'s bridging step - without either LivenessTracker's
787+
// real allocation sampler or ReferenceChainTracker's root-seeded walk ever
788+
// running. Takes its own weak global ref (klassPopulationSetRepresentativeForTest()'s
789+
// own contract, livenessTracker.h) rather than aliasing any handle the
790+
// caller manages.
791+
extern "C" DLLEXPORT void JNICALL
792+
Java_com_datadoghq_profiler_JavaProfiler_setKlassPopulationRepresentativeForTest0(
793+
JNIEnv *env, jclass unused, jint klassId, jobject representative) {
794+
jweak rep = env->NewWeakGlobalRef(representative);
795+
LivenessTracker::instance()->klassPopulationSetRepresentativeForTest(
796+
(u32)klassId, rep);
797+
}
798+
799+
extern "C" DLLEXPORT void JNICALL
800+
Java_com_datadoghq_profiler_JavaProfiler_resetKlassPopulationForTest0(
801+
JNIEnv *env, jclass unused) {
802+
LivenessTracker::instance()->klassPopulationResetForTest();
803+
}
804+
805+
extern "C" DLLEXPORT jintArray JNICALL
806+
Java_com_datadoghq_profiler_JavaProfiler_selectLeakCandidateKlassIds0(
807+
JNIEnv *env, jclass unused) {
808+
KlassCandidate candidates[5];
809+
int n = LivenessTracker::instance()->selectLeakCandidates(candidates, 5);
810+
jintArray result = env->NewIntArray(n);
811+
if (result == nullptr || n == 0) {
812+
return result;
813+
}
814+
jint ids[5];
815+
for (int i = 0; i < n; i++) {
816+
ids[i] = (jint)candidates[i].klass_id;
817+
}
818+
env->SetIntArrayRegion(result, 0, n, ids);
819+
return result;
820+
}
821+
822+
extern "C" DLLEXPORT jlong JNICALL
823+
Java_com_datadoghq_profiler_JavaProfiler_tagAsReferenceChainRoot0(
824+
JNIEnv *env, jclass unused, jobject target) {
825+
jvmtiEnv *jvmti = VM::jvmti();
826+
if (jvmti == nullptr) {
827+
return 0;
828+
}
829+
return ReferenceChainTracker::instance()->tagAsRootForTest(jvmti, env,
830+
target);
831+
}
832+
833+
extern "C" DLLEXPORT jboolean JNICALL
834+
Java_com_datadoghq_profiler_JavaProfiler_runReferenceChainPass0(
835+
JNIEnv *env, jclass unused) {
836+
jvmtiEnv *jvmti = VM::jvmti();
837+
if (jvmti == nullptr) {
838+
return JNI_FALSE;
839+
}
840+
return ReferenceChainTracker::instance()->runPass(jvmti, env);
841+
}
842+
843+
extern "C" DLLEXPORT void JNICALL
844+
Java_com_datadoghq_profiler_JavaProfiler_pollReferenceChainTargets0(
845+
JNIEnv *env, jclass unused) {
846+
jvmtiEnv *jvmti = VM::jvmti();
847+
if (jvmti == nullptr) {
848+
return;
849+
}
850+
ReferenceChainTracker::instance()->pollWatchedTargets(jvmti, env);
851+
}
852+
853+
extern "C" DLLEXPORT jint JNICALL
854+
Java_com_datadoghq_profiler_JavaProfiler_drainReferenceChainEventCount0(
855+
JNIEnv *env, jclass unused) {
856+
std::vector<ReferenceChainEvent> events;
857+
ReferenceChainTracker::instance()->drainPendingChainEvents(&events);
858+
return (jint)events.size();
859+
}
860+
#endif // DEBUG

ddprof-lib/src/main/cpp/livenessTracker.cpp

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -249,16 +249,36 @@ void LivenessTracker::foldKlassCountsLocked(JNIEnv *env, u64 epoch) {
249249
if (evicted != nullptr) {
250250
env->DeleteWeakGlobalRef(evicted);
251251
}
252-
// Also retry minting when an existing entry's representative is still
253-
// nullptr (not just when the entry was just created): a klass whose
254-
// first-epoch sample_source died in the brief window between
255-
// cleanup_table()'s survival check and the mint attempt below would
256-
// otherwise be left representative == nullptr forever - last_updated_epoch
257-
// keeps advancing every epoch this klass has survivors (right below), so
258-
// it is never the LRU eviction victim that would otherwise let a fresh
259-
// entry (and a fresh mint attempt) replace it. Retrying here every epoch
260-
// bounds that gap to "one epoch with no representative", not permanent.
261-
if (created || _klass_population[slot].representative == nullptr) {
252+
// Also retry minting when an existing entry's representative is stale:
253+
// either the field itself is still nullptr (a klass whose first-epoch
254+
// sample_source died in the brief window between cleanup_table()'s
255+
// survival check and the mint attempt below), or the field holds a jweak
256+
// handle whose referent has since died - a jweak's own pointer value
257+
// never becomes nullptr just because its referent was collected, so a
258+
// representative pinned to one specific instance that later dies (while
259+
// other instances of the same still-growing klass keep surviving, so
260+
// this entry keeps being re-selected as a leak candidate) would
261+
// otherwise be left permanently unresolvable: the `created ||
262+
// representative == nullptr` check alone can only ever be true once per
263+
// slot. last_updated_epoch keeps advancing every epoch this klass has
264+
// survivors (right below), so it is never the LRU eviction victim that
265+
// would otherwise let a fresh entry (and a fresh mint attempt) replace
266+
// it. Resolving here every epoch bounds any given gap to "one epoch with
267+
// no representative", not permanent.
268+
jweak current_rep = _klass_population[slot].representative;
269+
bool stale = false;
270+
if (current_rep != nullptr) {
271+
jobject probe = env->NewLocalRef(current_rep);
272+
stale = (probe == nullptr);
273+
if (probe != nullptr) {
274+
env->DeleteLocalRef(probe);
275+
}
276+
}
277+
if (created || current_rep == nullptr || stale) {
278+
if (stale) {
279+
env->DeleteWeakGlobalRef(current_rep);
280+
_klass_population[slot].representative = nullptr;
281+
}
262282
// Mint a fresh, independent representative jweak rather than reusing
263283
// s.sample_source directly - s.sample_source is the corresponding
264284
// TrackingEntry's own weak ref, and that table slot's jweak gets

ddprof-lib/src/main/cpp/referenceChains.cpp

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,59 @@ void ReferenceChainTracker::clearTag(jvmtiEnv *jvmti, jobject obj) {
588588
jvmti->SetTag(obj, 0);
589589
}
590590

591+
jlong ReferenceChainTracker::tagAsRootForTest(jvmtiEnv *jvmti, JNIEnv *jni,
592+
jobject obj) {
593+
if (_frontier == nullptr || jvmti == nullptr || jni == nullptr ||
594+
obj == nullptr) {
595+
return 0;
596+
}
597+
// Resolves the klass_id the same way LivenessTracker::resolveKlassId()
598+
// does (GetObjectClass + Class.getName() + Profiler::lookupClass()) -
599+
// this is a test-only, off-hot-path call so caching _Class/_Class_getName
600+
// like LivenessTracker does is not worth the extra state.
601+
u32 klass_id = 0;
602+
jclass klass = jni->GetObjectClass(obj);
603+
jclass class_class = jni->FindClass("java/lang/Class");
604+
if (class_class != nullptr) {
605+
jmethodID get_name =
606+
jni->GetMethodID(class_class, "getName", "()Ljava/lang/String;");
607+
if (get_name != nullptr) {
608+
jstring name_str = (jstring)jni->CallObjectMethod(klass, get_name);
609+
if (name_str != nullptr) {
610+
const char *name = jni->GetStringUTFChars(name_str, nullptr);
611+
if (name != nullptr) {
612+
int id = Profiler::instance()->lookupClass(name, strlen(name));
613+
if (id > 0) {
614+
klass_id = (u32)id;
615+
}
616+
jni->ReleaseStringUTFChars(name_str, name);
617+
}
618+
jni->DeleteLocalRef(name_str);
619+
}
620+
}
621+
jni->DeleteLocalRef(class_class);
622+
}
623+
jni->DeleteLocalRef(klass);
624+
625+
// Tags `obj` and inserts it as a frontier root (parent_tag=0, depth=0),
626+
// exactly the convention runPass()'s heap-root callback path already uses
627+
// (referenceChains.cpp's heapReferenceCallback(), referrer_tag_ptr ==
628+
// nullptr branch) - this lets a test drive the real BFS/chain-
629+
// reconstruction logic (runPass()/pollWatchedTargets()/buildChainEvent())
630+
// against a known, caller-chosen live object, decoupled from whether the
631+
// real root-seeded walk or LivenessTracker's probabilistic sampler happens
632+
// to reach/select it on its own.
633+
jlong tag = tagObject(jvmti, obj);
634+
if (tag == 0) {
635+
return 0;
636+
}
637+
if (!_frontier->insert(tag, 0, klass_id, 0)) {
638+
clearTag(jvmti, obj);
639+
return 0;
640+
}
641+
return tag;
642+
}
643+
591644
// ---------------------------------------------------------------------------
592645
// Heap-walk engine
593646
// ---------------------------------------------------------------------------

ddprof-lib/src/main/cpp/referenceChains.h

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1045,6 +1045,19 @@ class ReferenceChainTracker {
10451045

10461046
static void JNICALL GarbageCollectionStart(jvmtiEnv *jvmti_env);
10471047
static void JNICALL GarbageCollectionFinish(jvmtiEnv *jvmti_env);
1048+
1049+
// Test seam - not part of the production API. Mirrors LivenessTracker's
1050+
// own "Test seams" block (livenessTracker.h). Production code only ever
1051+
// discovers frontier roots via runPass()'s root-seeded FollowReferences
1052+
// walk; this lets a test tag and insert one specific, caller-chosen live
1053+
// object as a frontier root directly, so runPass()/pollWatchedTargets()/
1054+
// buildChainEvent() can be exercised end-to-end against a known target
1055+
// without depending on LivenessTracker's probabilistic allocation sampler
1056+
// to organically select and surface the same object. Returns the assigned
1057+
// tag (matching the value buildChainEvent()'s target_tag expects), or 0 on
1058+
// failure (obj/jvmti/jni null, SetTag failed, or the frontier table is at
1059+
// capacity).
1060+
jlong tagAsRootForTest(jvmtiEnv *jvmti, JNIEnv *jni, jobject obj);
10481061
};
10491062

10501063
#endif // _REFERENCECHAINS_H

ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,96 @@ public ContextStorageMode contextStorageMode() {
502502

503503
public static native void dumpContext();
504504

505+
/**
506+
* Test seam (debug native builds only - a no-op returning {@code false}/{@code 0}/an
507+
* empty array in release builds): decouples LivenessTracker's leak-candidate
508+
* detection from ReferenceChainTracker's chain reconstruction, each independently
509+
* verifiable end-to-end without depending on both the probabilistic JVMTI heap
510+
* sampler and the reference-chain BFS search organically producing the right
511+
* conditions in the same test run.
512+
* <p>
513+
* Enables/disables LivenessTracker's per-klass population tracking directly,
514+
* bypassing {@code initialize()}'s live-JVM requirement. Returns {@code true} on
515+
* debug builds.
516+
*/
517+
public static native boolean setGcGenerationsEnabled0(boolean enabled);
518+
519+
/**
520+
* Test seam (debug native builds only): seeds one epoch's worth of population
521+
* history for {@code klassId} directly into LivenessTracker's ring buffer,
522+
* bypassing real allocation sampling. Repeated calls (with distinct
523+
* {@code epoch} values) build up a trend {@link #selectLeakCandidateKlassIds0()}
524+
* can then rank, letting a test assert a slope signal would be generated for a
525+
* chosen klass id without waiting on real GC epochs.
526+
*/
527+
public static native void seedKlassPopulationSample0(int klassId, int count, long epoch);
528+
529+
/**
530+
* Test seam (debug native builds only): wires {@code representative} in as {@code klassId}'s
531+
* leak-candidate representative directly (a fresh weak global ref owned by LivenessTracker),
532+
* bypassing the real allocation-sampling path that would otherwise populate this. Combined
533+
* with {@link #seedKlassPopulationSample0} and {@link #tagAsReferenceChainRoot0}, lets a test
534+
* join a synthetic slope signal to a real, directly-tagged object so
535+
* {@link #pollReferenceChainTargets0()}'s bridging step can be exercised end-to-end with
536+
* neither the real sampler nor the real root-seeded walk involved.
537+
*/
538+
public static native void setKlassPopulationRepresentativeForTest0(int klassId, Object representative);
539+
540+
/**
541+
* Test seam (debug native builds only): clears LivenessTracker's per-klass population table,
542+
* so a later test in the same JVM does not observe leak candidates seeded by an earlier one.
543+
*/
544+
public static native void resetKlassPopulationForTest0();
545+
546+
/**
547+
* Test seam (debug native builds only): returns the klass ids LivenessTracker's
548+
* real leak-candidate ranking (positive population slope, top 5) currently
549+
* selects - the same call ReferenceChainTracker's restart gate and target-polling
550+
* bridge use in production, exposed here so a test can assert a slope signal was
551+
* generated (real or seeded via {@link #seedKlassPopulationSample0}) without
552+
* needing a reference-chain search to also be running.
553+
*/
554+
public static native int[] selectLeakCandidateKlassIds0();
555+
556+
/**
557+
* Test seam (debug native builds only): tags {@code target} and inserts it
558+
* directly as a reference-chain frontier root, bypassing ReferenceChainTracker's
559+
* normal discovery path (a root-seeded FollowReferences walk) and
560+
* LivenessTracker's leak-candidate selection entirely. Lets a test drive
561+
* {@link #runReferenceChainPass0()}/{@link #pollReferenceChainTargets0()} against
562+
* a known, caller-chosen live object. Returns the assigned frontier tag (matching
563+
* the {@code target_tag} a resulting {@code datadog.ReferenceChain} event
564+
* reports), or {@code 0} on failure (reference chains disabled, or the frontier
565+
* table is at capacity).
566+
*/
567+
public static native long tagAsReferenceChainRoot0(Object target);
568+
569+
/**
570+
* Test seam (debug native builds only): runs exactly one bounded BFS pass of the
571+
* reference-chain search synchronously, rather than waiting on the tracker's own
572+
* background thread/cadence. Returns {@code false} if reference chains are
573+
* disabled or the tracker was never started.
574+
*/
575+
public static native boolean runReferenceChainPass0();
576+
577+
/**
578+
* Test seam (debug native builds only): runs one poll of
579+
* ReferenceChainTracker's LivenessTracker-to-chain-reconstruction bridging step
580+
* synchronously - for each current leak candidate already discovered by a prior
581+
* {@link #runReferenceChainPass0()} walk, reconstructs and queues its chain
582+
* event, rather than waiting on the background thread's own scheduling cycle.
583+
*/
584+
public static native void pollReferenceChainTargets0();
585+
586+
/**
587+
* Test seam (debug native builds only): drains and returns the number of
588+
* reference-chain events queued by {@link #pollReferenceChainTargets0()} so far
589+
* (the same queue {@code Profiler.dump()} drains in production to write
590+
* {@code datadog.ReferenceChain} JFR events) - lets a test assert a chain was
591+
* actually reconstructed without needing a real JFR dump.
592+
*/
593+
public static native int drainReferenceChainEventCount0();
594+
505595
/**
506596
* Resets the cached ThreadContext for the current storage slot — the calling thread in
507597
* {@link ContextStorageMode#THREAD}, or its current carrier in

0 commit comments

Comments
 (0)