From e0a3aa860b81fb5fc261ec9b298342256cd44cd2 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 10:48:09 +0200 Subject: [PATCH 01/10] feat(wall): add configurable wall thread scope --- ddprof-lib/src/main/cpp/arguments.cpp | 11 + ddprof-lib/src/main/cpp/arguments.h | 12 + ddprof-lib/src/main/cpp/counters.h | 1 + ddprof-lib/src/main/cpp/javaApi.cpp | 9 +- ddprof-lib/src/main/cpp/profiler.cpp | 52 ++++- ddprof-lib/src/main/cpp/profiler.h | 1 + ddprof-lib/src/main/cpp/threadFilter.cpp | 206 ++++++++++++++++-- ddprof-lib/src/main/cpp/threadFilter.h | 98 ++++++++- ddprof-lib/src/main/cpp/wallClock.cpp | 52 ++++- ddprof-lib/src/main/cpp/wallClock.h | 2 + .../com/datadoghq/profiler/JavaProfiler.java | 13 +- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 128 ++++++++++- .../src/test/cpp/wallprecheck_args_ut.cpp | 49 +++++ .../wallclock/AllThreadWallScopeTest.java | 88 ++++++++ .../wallclock/ContextWallScopeTest.java | 71 ++++++ .../JvmtiBasedAllThreadWallScopeTest.java | 14 ++ 16 files changed, 752 insertions(+), 55 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index b43f99fccb..5d60a647f2 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -410,6 +410,17 @@ Error Arguments::parse(const char *args) { } } + CASE("wallscope") + if (value == NULL || value[0] == 0) { + msg = "wallscope must be 'context' or 'all'"; + } else if (strcmp(value, "context") == 0) { + _wall_scope = WALL_SCOPE_CONTEXT; + } else if (strcmp(value, "all") == 0) { + _wall_scope = WALL_SCOPE_ALL; + } else { + msg = "wallscope must be 'context' or 'all'"; + } + CASE("nativemem") _nativemem = value == NULL ? 0 : parseUnits(value, BYTES); if (_nativemem < 0) { diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index 16efe9c8ba..b14c334339 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -87,6 +87,12 @@ enum WallclockSampler { JVMTI }; +enum WallScope { + WALL_SCOPE_LEGACY, + WALL_SCOPE_CONTEXT, + WALL_SCOPE_ALL +}; + enum Clock { CLK_DEFAULT, CLK_TSC, @@ -161,6 +167,10 @@ class Arguments { } public: + bool wallScopeAllThreads() const { + return _wall_scope == WALL_SCOPE_ALL; + } + Action _action; Ring _ring; const char *_event; @@ -171,6 +181,7 @@ class Arguments { bool _wall_precheck; int _wall_threads_per_tick; WallclockSampler _wallclock_sampler; + WallScope _wall_scope; long _memory; bool _record_allocations; bool _record_liveness; @@ -212,6 +223,7 @@ class Arguments { _wall_precheck(false), _wall_threads_per_tick(DEFAULT_WALL_THREADS_PER_TICK), _wallclock_sampler(ASGCT), + _wall_scope(WALL_SCOPE_CONTEXT), _memory(-1), _record_allocations(false), _record_liveness(false), diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 6550136646..843714aad8 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -56,6 +56,7 @@ X(THREAD_NAMES_COUNT, "thread_names_count") \ X(THREAD_FILTER_PAGES, "thread_filter_pages") \ X(THREAD_FILTER_BYTES, "thread_filter_bytes") \ + X(THREAD_FILTER_CAPACITY_EXHAUSTED, "thread_filter_capacity_exhausted") \ X(JMETHODID_SKIPPED, "jmethodid_skipped_count") \ X(CODECACHE_NATIVE_SIZE_BYTES, "codecache_native_size_bytes") \ X(CODECACHE_NATIVE_COUNT, "native_codecache_count") \ diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 4711d06de7..5926ed8866 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -144,7 +144,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } @@ -152,16 +152,13 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { if (unlikely(slot_id == -1)) { // Thread doesn't have a slot ID yet (e.g., main thread), so register it // Happens when we are not enabled before thread start - slot_id = thread_filter->registerThread(); + slot_id = thread_filter->registerThread(tid); current->setFilterSlotId(slot_id); } if (unlikely(slot_id == -1)) { return; // Failed to register thread } - // Reset suppression state so a new thread occupying this slot does not inherit - // stale state from its predecessor. Must happen before add(). - thread_filter->resetSlotRunState(slot_id); thread_filter->add(tid, slot_id); } @@ -174,7 +171,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadRemove0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 561009e221..674be68353 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -78,11 +78,9 @@ void Profiler::onThreadStart(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { ProfiledThread *current = ProfiledThread::current(); current->setJavaThread(true); int tid = current->tid(); - if (_thread_filter.enabled()) { - int slot_id = _thread_filter.registerThread(); + if (_thread_filter.registryActive()) { + int slot_id = _thread_filter.registerThread(tid); current->setFilterSlotId(slot_id); - _thread_filter.resetSlotRunState(slot_id); - _thread_filter.remove(slot_id); // Remove from filtering initially } if (thread != NULL) { updateThreadName(jvmti, jni, thread, true); @@ -101,9 +99,11 @@ void Profiler::onThreadEnd(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { int slot_id = current->filterSlotId(); tid = current->tid(); - if (_thread_filter.enabled()) { + if (slot_id >= 0) { _thread_filter.unregisterThread(slot_id); current->setFilterSlotId(-1); + } else { + _thread_filter.unregisterThreadByTid(tid); } updateThreadName(jvmti, jni, thread, false); @@ -127,6 +127,7 @@ void Profiler::onThreadEnd(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { } updateThreadName(jvmti, jni, thread, false); + _thread_filter.unregisterThreadByTid(tid); _cpu_engine->unregisterThread(tid); _wall_engine->unregisterThread(tid); } @@ -1051,6 +1052,32 @@ void Profiler::updateJavaThreadNames() { jvmti->Deallocate((unsigned char *)thread_objects); } +void Profiler::registerExistingJavaThreads() { + if (!_thread_filter.allThreads()) { + return; + } + + jvmtiEnv *jvmti = VM::jvmti(); + JNIEnv *jni = VM::jni(); + jint thread_count; + jthread *thread_objects; + if (jvmti->GetAllThreads(&thread_count, &thread_objects) != JVMTI_ERROR_NONE) { + return; + } + + for (int i = 0; i < thread_count; ++i) { + jthread thread = thread_objects[i]; + if (thread != nullptr) { + int tid = JVMThread::nativeThreadId(jni, thread); + if (tid >= 0) { + _thread_filter.registerThread(tid); + } + jni->DeleteLocalRef(thread); + } + } + jvmti->Deallocate(reinterpret_cast(thread_objects)); +} + void Profiler::updateNativeThreadNames(bool defer_initializing) { ThreadList *thread_list = OS::listThreads(); constexpr size_t buffer_size = 64; @@ -1354,19 +1381,22 @@ Error Profiler::start(Arguments &args, bool reset) { } // TODO: Current way of setting filter is weird with the recent changes - _thread_filter.init(args._filter ? args._filter : "0"); + const bool all_threads = args.wallScopeAllThreads(); + const char *filter = args._wall_scope == WALL_SCOPE_CONTEXT + ? "0" + : (args._filter ? args._filter : "0"); + _thread_filter.init(filter, all_threads); // Minor optim: Register the current thread (start thread won't be called) - if (_thread_filter.enabled()) { + if (_thread_filter.registryActive()) { _thread_filter.clearActive(); ProfiledThread *current = ProfiledThread::current(); assert(current != nullptr); int slot_id = current->filterSlotId(); if (slot_id < 0) { - slot_id = _thread_filter.registerThread(); + slot_id = _thread_filter.registerThread(current->tid()); current->setFilterSlotId(slot_id); } - _thread_filter.remove(slot_id); // Remove from filtering initially (matches onThreadStart behavior) } _cpu_engine = selectCpuEngine(args); @@ -1496,6 +1526,10 @@ Error Profiler::start(Arguments &args, bool reset) { if (activated) { switchThreadEvents(JVMTI_ENABLE); + // ThreadStart events cover only threads created after the callbacks are + // enabled. Bootstrap registry identity for Java threads that already exist. + registerExistingJavaThreads(); + // Initialize this thread // Note: passing all nullptrs results in not able to resolve the thread name here. // However, the thread name will be updated later in updateJavaThreadNames(). diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 2f13d2a3ab..5227abe6df 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -143,6 +143,7 @@ class alignas(alignof(SpinLock)) Profiler { void updateThreadName(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, bool self = false); void updateJavaThreadNames(); + void registerExistingJavaThreads(); void mangle(const char *name, char *buf, size_t size); Engine *selectCpuEngine(Arguments &args); diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index 531ce75a1a..42279ff227 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -21,6 +21,7 @@ #include "threadFilter.h" #include "arch.h" +#include "counters.h" #include "os.h" #include "threadLocalData.h" #include @@ -32,12 +33,15 @@ ThreadFilter::ShardHead ThreadFilter::_free_heads[ThreadFilter::kShardCount] {}; -ThreadFilter::ThreadFilter() : _enabled(false) { +ThreadFilter::ThreadFilter() : _enabled(false), _registry_active(false), _all_threads(false) { // Initialize chunk pointers to null (lazy allocation) for (int i = 0; i < kMaxChunks; ++i) { _chunks[i].store(nullptr, std::memory_order_relaxed); } _free_list = std::make_unique(kFreeListSize); + for (auto& entry : _tid_index) { + entry.store(0, std::memory_order_relaxed); + } // Initialize the first chunk initializeChunk(0); @@ -51,6 +55,8 @@ ThreadFilter::ThreadFilter() : _enabled(false) { ThreadFilter::~ThreadFilter() { // Make the filter inert for any concurrent readers _enabled.store(false, std::memory_order_release); + _registry_active.store(false, std::memory_order_release); + _all_threads.store(false, std::memory_order_release); // Reset free-list heads and nodes first for (int s = 0; s < kShardCount; ++s) { _free_heads[s].head.store(-1, std::memory_order_relaxed); @@ -59,6 +65,9 @@ ThreadFilter::~ThreadFilter() { _free_list[i].value.store(-1, std::memory_order_relaxed); _free_list[i].next.store(-1, std::memory_order_relaxed); } + for (auto& entry : _tid_index) { + entry.store(0, std::memory_order_relaxed); + } // Publish 0 chunks to stop range scans (collect) _num_chunks.store(0, std::memory_order_release); // Detach and delete chunks @@ -78,7 +87,8 @@ void ThreadFilter::initializeChunk(int chunk_idx) { // Allocate and initialize new chunk completely before swapping ChunkStorage* new_chunk = new ChunkStorage(); for (auto& slot : new_chunk->slots) { - slot.value.store(-1, std::memory_order_relaxed); + slot.tid.store(-1, std::memory_order_relaxed); + slot.context_window_state.store(0, std::memory_order_relaxed); slot.active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_relaxed); } @@ -92,15 +102,33 @@ void ThreadFilter::initializeChunk(int chunk_idx) { } } -ThreadFilter::SlotID ThreadFilter::registerThread() { - // If disabled, block new registrations - if (!_enabled.load(std::memory_order_acquire)) { +ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { + if (!_registry_active.load(std::memory_order_acquire)) { return -1; } + std::lock_guard lock(_registry_lock); + + if (tid >= 0) { + SlotID existing = lookupSlotIdByTid(tid); + if (existing >= 0) { + return existing; + } + } // First, try to get a slot from the free list (lock-free stack) SlotID reused_slot = popFromFreeList(); if (reused_slot >= 0) { + Slot* slot = slotForId(reused_slot); + slot->lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); + slot->context_window_state.store(0, std::memory_order_relaxed); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->tid.store(tid, std::memory_order_release); + if (tid >= 0 && !indexSlot(reused_slot, tid)) { + slot->tid.store(-1, std::memory_order_release); + pushToFreeList(reused_slot); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); + return -1; + } return reused_slot; } @@ -109,6 +137,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread() { if (index >= kMaxThreads) { // Revert the increment and return failure _next_index.fetch_sub(1, std::memory_order_relaxed); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); return -1; } @@ -131,9 +160,86 @@ ThreadFilter::SlotID ThreadFilter::registerThread() { // Initialize the chunk if needed initializeChunk(chunk_idx); + Slot* slot = slotForId(index); + slot->lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); + slot->context_window_state.store(0, std::memory_order_relaxed); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->tid.store(tid, std::memory_order_release); + if (tid >= 0 && !indexSlot(index, tid)) { + slot->tid.store(-1, std::memory_order_release); + pushToFreeList(index); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); + return -1; + } + return index; } +bool ThreadFilter::indexSlot(SlotID slot_id, int tid) { + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value <= 0) { + _tid_index[index].store(slot_id + 1, std::memory_order_release); + return true; + } + if (value > 0) { + Slot* slot = slotForId(value - 1); + if (slot != nullptr && slot->nativeTid() == tid) { + return value - 1 == slot_id; + } + } + } + return false; +} + +void ThreadFilter::unindexSlot(SlotID slot_id, int tid) { + if (tid < 0) return; + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value == 0) return; + if (value == slot_id + 1) { + int next = (index + 1) & kTidIndexMask; + int replacement = + _tid_index[next].load(std::memory_order_acquire) == 0 ? 0 : -1; + _tid_index[index].store(replacement, std::memory_order_release); + if (replacement == 0) { + int previous = (index - 1) & kTidIndexMask; + while (_tid_index[previous].load(std::memory_order_acquire) == -1) { + _tid_index[previous].store(0, std::memory_order_release); + previous = (previous - 1) & kTidIndexMask; + } + } + return; + } + } +} + +ThreadFilter::SlotID ThreadFilter::lookupSlotIdByTid(int tid) const { + if (tid < 0) return -1; + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value == 0) return -1; + if (value > 0) { + Slot* slot = slotForId(value - 1); + if (slot != nullptr && slot->nativeTid() == tid) { + return value - 1; + } + } + } + return -1; +} + +ThreadFilter::Slot* ThreadFilter::lookupByTid(int tid) const { + SlotID slot_id = lookupSlotIdByTid(tid); + return slot_id < 0 ? nullptr : slotForId(slot_id); +} + void ThreadFilter::initFreeList() { // Initialize the free list storage for (int i = 0; i < kFreeListSize; ++i) { @@ -160,7 +266,7 @@ bool ThreadFilter::accept(SlotID slot_id) const { // This is not a fast path like the add operation. ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (likely(chunk != nullptr)) { - return chunk->slots[slot_idx].value.load(std::memory_order_relaxed) != -1; + return chunk->slots[slot_idx].inContextWindow(); } return false; } @@ -176,7 +282,19 @@ void ThreadFilter::add(int tid, SlotID slot_id) { // Fast path: assume valid slot_id from registerThread() ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (likely(chunk != nullptr)) { - chunk->slots[slot_idx].value.store(tid, std::memory_order_release); + Slot& slot = chunk->slots[slot_idx]; + if (slot.nativeTid() == -1) { + std::lock_guard lock(_registry_lock); + if (slot.nativeTid() == -1) { + slot.tid.store(tid, std::memory_order_release); + if (_all_threads.load(std::memory_order_acquire) && + !indexSlot(slot_id, tid)) { + slot.tid.store(-1, std::memory_order_release); + return; + } + } + } + slot.enterContextWindow(); } } @@ -198,16 +316,34 @@ void ThreadFilter::remove(SlotID slot_id) { return; } - chunk->slots[slot_idx].value.store(-1, std::memory_order_release); + chunk->slots[slot_idx].exitContextWindow(); } void ThreadFilter::unregisterThread(SlotID slot_id) { + std::lock_guard lock(_registry_lock); + unregisterThreadLocked(slot_id); +} + +void ThreadFilter::unregisterThreadLocked(SlotID slot_id) { if (slot_id < 0) return; - remove(slot_id); - resetSlotRunState(slot_id); + Slot* slot = slotForId(slot_id); + if (slot == nullptr) return; + int tid = slot->nativeTid(); + unindexSlot(slot_id, tid); + slot->tid.store(-1, std::memory_order_release); + slot->context_window_state.store(0, std::memory_order_release); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); pushToFreeList(slot_id); } +void ThreadFilter::unregisterThreadByTid(int tid) { + std::lock_guard lock(_registry_lock); + SlotID slot_id = lookupSlotIdByTid(tid); + if (slot_id >= 0) { + unregisterThreadLocked(slot_id); + } +} + bool ThreadFilter::pushToFreeList(SlotID slot_id) { // Lock-free sharded Treiber stack push const int shard = shardOfSlot(slot_id); @@ -274,8 +410,8 @@ void ThreadFilter::collect(std::vector& tids) const { } for (const auto& slot : chunk->slots) { - int slot_tid = slot.value.load(std::memory_order_relaxed); - if (slot_tid != -1) { + int slot_tid = slot.nativeTid(); + if (slot_tid != -1 && slot.inContextWindow()) { tids.push_back(slot_tid); } } @@ -299,9 +435,25 @@ void ThreadFilter::collect(std::vector& entries) const { } for (auto& slot : chunk->slots) { - int slot_tid = slot.value.load(std::memory_order_acquire); + int slot_tid = slot.nativeTid(); + if (slot_tid != -1 && slot.inContextWindow()) { + entries.push_back({slot_tid, &slot, slot.lifecycleGeneration()}); + } + } + } +} + +void ThreadFilter::collectRegistered(std::vector& entries) const { + entries.clear(); + entries.reserve(512); + int num_chunks = _num_chunks.load(std::memory_order_acquire); + for (int chunk_idx = 0; chunk_idx < num_chunks; ++chunk_idx) { + ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); + if (chunk == nullptr) continue; + for (auto& slot : chunk->slots) { + int slot_tid = slot.nativeTid(); if (slot_tid != -1) { - entries.push_back({slot_tid, &slot}); + entries.push_back({slot_tid, &slot, slot.lifecycleGeneration()}); } } } @@ -316,7 +468,7 @@ void ThreadFilter::clearActive() { } for (auto& slot : chunk->slots) { - slot.value.store(-1, std::memory_order_release); + slot.exitContextWindow(); slot.clearActiveBlockRun(OSThreadState::UNKNOWN); } } @@ -339,7 +491,7 @@ u64 ThreadFilter::enterBlockedRun(SlotID slot_id, OSThreadState state, Slot* s = slotForId(slot_id); if (s != nullptr) { u32 generation = 0; - if (!s->trySetActiveBlockRun(state, owner, &generation)) { + if (!s->trySetActiveBlockRun(state, owner, &generation, allThreads())) { return 0; } return encodeBlockRunToken(slot_id, generation); @@ -363,14 +515,24 @@ bool ThreadFilter::exitBlockedRun(SlotID slot_id, u32 generation) { return true; } -void ThreadFilter::init(const char* filter) { - // Simple logic: any filter value (including "0") enables filtering - // Only explicitly registered threads via addThread() will be sampled - // Previously we had a syntax where we could manually force some thread IDs. - // This is no longer supported. - _enabled.store(filter != nullptr && strlen(filter) > 0, std::memory_order_release); +void ThreadFilter::init(const char* filter, bool all_threads) { + // Legacy/context scope uses any non-empty filter value (including "0") as + // an explicit context allow-list. All-thread scope keeps registry identity + // active but makes the allow-list irrelevant to ordinary wall sampling. + bool context_filter = !all_threads && filter != nullptr && strlen(filter) > 0; + _all_threads.store(all_threads, std::memory_order_release); + _registry_active.store(all_threads || context_filter, std::memory_order_release); + _enabled.store(context_filter, std::memory_order_release); } bool ThreadFilter::enabled() const { return _enabled.load(std::memory_order_acquire); } + +bool ThreadFilter::registryActive() const { + return _registry_active.load(std::memory_order_acquire); +} + +bool ThreadFilter::allThreads() const { + return _all_threads.load(std::memory_order_acquire); +} diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 541249e4c1..c20473df3e 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -21,6 +21,7 @@ #include #include #include +#include #include "arch.h" #include "threadState.h" @@ -45,8 +46,10 @@ class ThreadFilter { static constexpr int kMaxThreads = 2048; static constexpr int kMaxChunks = (kMaxThreads + kChunkSize - 1) / kChunkSize; // = 8 chunks // High-performance free list using Treiber stack, 64 shards - static constexpr int kFreeListSize = 1024; // power-of-two for fast modulo + static constexpr int kFreeListSize = kMaxThreads; static constexpr int kShardCount = 64; // power-of-two for fast modulo + static constexpr int kTidIndexSize = 8192; // 4x maximum live slots + static constexpr int kTidIndexMask = kTidIndexSize - 1; // One cache line per slot to avoid false sharing. Slot instances are never freed // (ChunkStorage is process-lifetime), so a captured Slot* is always dereferenceable. @@ -56,8 +59,16 @@ class ThreadFilter { std::atomic unowned_blocked_pending_weight{0}; std::atomic unowned_blocked_decision_count{0}; std::atomic unowned_blocked_call_trace_id{0}; + // Packed as (epoch << 1) | in_context_window so a transition and its + // epoch change are observed atomically by block admission and exit. + std::atomic context_window_state{0}; + std::atomic lifecycle_generation{0}; + std::atomic active_block_context_epoch{0}; std::atomic unowned_blocked_state{OSThreadState::UNKNOWN}; - std::atomic value{-1}; + // The native TID and context-window membership are deliberately + // independent. A live thread remains ordinarily sampleable in all-thread + // scope while addThread()/removeThread() changes only the context marker. + std::atomic tid{-1}; std::atomic active_block_owner{static_cast(BlockRunOwner::NONE)}; std::atomic block_generation{0}; // Wall-clock once-per-run suppression state. The signal handler records the @@ -71,6 +82,9 @@ class ThreadFilter { std::atomic active_block_state{OSThreadState::UNKNOWN}; std::atomic sampled_this_run{false}; char padding[2 * DEFAULT_CACHE_LINE_SIZE + - sizeof(std::atomic) + - sizeof(std::atomic) + - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) @@ -82,6 +96,41 @@ class ThreadFilter { - sizeof(std::atomic) - sizeof(std::atomic)]; + inline int nativeTid() const { + return tid.load(std::memory_order_acquire); + } + inline u64 lifecycleGeneration() const { + return lifecycle_generation.load(std::memory_order_acquire); + } + inline bool inContextWindow() const { + return (context_window_state.load(std::memory_order_acquire) & 1) != 0; + } + inline u64 contextWindowEpoch() const { + return context_window_state.load(std::memory_order_acquire) >> 1; + } + inline bool enterContextWindow() { + u64 current = context_window_state.load(std::memory_order_acquire); + while ((current & 1) == 0) { + if (context_window_state.compare_exchange_weak( + current, current + 3, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return true; + } + } + return false; + } + inline bool exitContextWindow() { + u64 current = context_window_state.load(std::memory_order_acquire); + while ((current & 1) != 0) { + if (context_window_state.compare_exchange_weak( + current, current + 1, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return true; + } + } + return false; + } + inline bool sampledThisRun() const { return sampled_this_run.load(std::memory_order_acquire); } @@ -147,14 +196,26 @@ class ThreadFilter { return true; } inline bool trySetActiveBlockRun(OSThreadState state, BlockRunOwner owner, - u32* generation_out) { + u32* generation_out, + bool outside_context_required) { + u64 context_state = context_window_state.load(std::memory_order_acquire); + if (outside_context_required && (context_state & 1) != 0) { + return false; + } int expected_owner = static_cast(BlockRunOwner::NONE); if (!active_block_owner.compare_exchange_strong( expected_owner, static_cast(owner), std::memory_order_acq_rel, std::memory_order_acquire)) { return false; } + if (outside_context_required && + context_window_state.load(std::memory_order_acquire) != context_state) { + active_block_owner.store(static_cast(BlockRunOwner::NONE), + std::memory_order_release); + return false; + } u32 generation = block_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + active_block_context_epoch.store(context_state >> 1, std::memory_order_relaxed); resetUnownedBlockedSampling(); last_sampled_state.store(OSThreadState::UNKNOWN, std::memory_order_relaxed); sampled_this_run.store(false, std::memory_order_relaxed); @@ -167,6 +228,12 @@ class ThreadFilter { resetSampledRun(state); active_block_owner.store(static_cast(BlockRunOwner::NONE), std::memory_order_release); } + inline bool activeBlockRemainedOutsideContextWindow() const { + u64 context_state = context_window_state.load(std::memory_order_acquire); + return (context_state & 1) == 0 && + active_block_context_epoch.load(std::memory_order_acquire) == + (context_state >> 1); + } }; static_assert(sizeof(Slot) == 2 * DEFAULT_CACHE_LINE_SIZE, "Slot must be exactly two cache lines"); static_assert(std::atomic::is_always_lock_free, @@ -177,15 +244,18 @@ class ThreadFilter { ThreadFilter(); ~ThreadFilter(); - void init(const char* filter); + void init(const char* filter, bool all_threads = false); void initFreeList(); bool enabled() const; + bool registryActive() const; + bool allThreads() const; // Hot path methods - slot_id MUST be from registerThread(), undefined behavior otherwise bool accept(SlotID slot_id) const; void add(int tid, SlotID slot_id); void remove(SlotID slot_id); void collect(std::vector& tids) const; void collect(std::vector& entries) const; + void collectRegistered(std::vector& entries) const; // Clears per-recording membership and suppression state while keeping // process-lifetime slot ownership intact. Threads must opt in again with add(). void clearActive(); @@ -218,8 +288,10 @@ class ThreadFilter { return chunk != nullptr ? &chunk->slots[slot_idx] : nullptr; } - SlotID registerThread(); + SlotID registerThread(int tid = -1); void unregisterThread(SlotID slot_id); + void unregisterThreadByTid(int tid); + Slot* lookupByTid(int tid) const; private: @@ -235,6 +307,8 @@ class ThreadFilter { }; std::atomic _enabled{false}; + std::atomic _registry_active{false}; + std::atomic _all_threads{false}; // Lazily allocated storage for chunks std::atomic _chunks[kMaxChunks]; @@ -243,6 +317,12 @@ class ThreadFilter { // Lock-free slot allocation std::atomic _next_index{0}; std::unique_ptr _free_list; + // Entries contain slot_id + 1. Zero terminates a lookup probe; -1 is a + // tombstone left by unregister. The slot's published TID is the key. + std::array, kTidIndexSize> _tid_index; + // Registration and teardown never run in a signal handler. Serializing + // writers prevents duplicate TID mappings while lookups remain lock-free. + std::mutex _registry_lock; // Cache line aligned to prevent false sharing between shards struct alignas(DEFAULT_CACHE_LINE_SIZE) ShardHead { std::atomic head{-1}; }; @@ -254,12 +334,20 @@ class ThreadFilter { void initializeChunk(int chunk_idx); bool pushToFreeList(SlotID slot_id); SlotID popFromFreeList(); + bool indexSlot(SlotID slot_id, int tid); + void unindexSlot(SlotID slot_id, int tid); + void unregisterThreadLocked(SlotID slot_id); + SlotID lookupSlotIdByTid(int tid) const; + static inline unsigned hashTid(int tid) { + return static_cast(tid) * 2654435761u; + } }; // Snapshot entry produced by ThreadFilter::collect for the wall-clock timer. struct ThreadEntry { int tid; ThreadFilter::Slot* slot; + u64 lifecycle_generation; }; #endif // _THREADFILTER_H diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index c29365dfcf..5336235eac 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -76,10 +76,21 @@ static inline void incrementSuppressedSampledRun() { WallClockCounters::incrementSuppressedSampledRun(); } -static inline bool suppressAlreadySampledBlock(ThreadFilter::Slot* slot) { +static inline bool blockContextEligible(ThreadFilter::Slot* slot) { + ThreadFilter* registry = Profiler::instance()->threadFilter(); + return !registry->allThreads() || slot->activeBlockRemainedOutsideContextWindow(); +} + +static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { + ThreadFilter::Slot* slot = entry.slot; if (slot == nullptr) { return false; } + if (slot->nativeTid() != entry.tid || + slot->lifecycleGeneration() != entry.lifecycle_generation || + !blockContextEligible(slot)) { + return false; + } OSThreadState block_state = slot->activeBlockState(); if (slot->activeBlockOwner() != BlockRunOwner::NONE && isPrecheckSuppressionState(block_state) && @@ -100,15 +111,23 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, ThreadFilter::Slot* slot = Profiler::instance()->threadFilter()->slotForId(current->filterSlotId()); + if (slot == nullptr) { + slot = Profiler::instance()->threadFilter()->lookupByTid(current->tid()); + } if (slot == nullptr) { return result; } + if (Profiler::instance()->threadFilter()->allThreads() && slot->inContextWindow()) { + return result; + } + OSThreadState active_block_state = slot->activeBlockState(); BlockRunOwner active_block_owner = slot->activeBlockOwner(); bool has_owned_block = active_block_owner != BlockRunOwner::NONE && - isPrecheckSuppressionState(active_block_state); + isPrecheckSuppressionState(active_block_state) && + blockContextEligible(slot); if (has_owned_block) { if (slot->sampledThisRun() && active_block_state == slot->lastSampledState()) { @@ -311,6 +330,7 @@ Error BaseWallClock::start(Arguments &args) { _reservoir_size = args._wall_threads_per_tick ? args._wall_threads_per_tick : DEFAULT_WALL_THREADS_PER_TICK; + _all_threads = args.wallScopeAllThreads(); initialize(args); @@ -353,7 +373,7 @@ void WallClockASGCT::timerLoop() { auto collectThreads = [&](std::vector& entries) { // Get thread IDs from the filter if it's enabled // Otherwise list all threads in the system - if (Profiler::instance()->threadFilter()->enabled()) { + if (!_all_threads && Profiler::instance()->threadFilter()->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { const int refresher_tid = Libraries::instance()->refresherTid(); @@ -365,11 +385,19 @@ void WallClockASGCT::timerLoop() { // enough; we also want to avoid the kill() round-trip and any // pending-signal accumulation). if (tid != OS::threadId() && tid != refresher_tid) { - entries.push_back({tid, nullptr}); // no-filter: precheck fast path is skipped (null guards) + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->lookupByTid(tid); + entries.push_back({tid, slot, + slot == nullptr ? 0 : slot->lifecycleGeneration()}); } } delete thread_list; } + if (_precheck) { + entries.erase(std::remove_if(entries.begin(), entries.end(), + suppressAlreadySampledBlock), + entries.end()); + } }; auto sampleThreads = [&](ThreadEntry entry, int& num_failures, int& threads_already_exited, @@ -378,7 +406,7 @@ void WallClockASGCT::timerLoop() { // only when an explicit lifecycle hook still owns an already-sampled blocked // run. Raw OS thread state is intentionally not used here because the timer // thread cannot prove run boundaries for the target thread. - if (_precheck && suppressAlreadySampledBlock(entry.slot)) { + if (_precheck && suppressAlreadySampledBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { @@ -499,7 +527,7 @@ void WallClockJvmti::initialize(Arguments &args) { void WallClockJvmti::timerLoop() { auto collectThreads = [&](std::vector &entries) { const int refresher_tid = Libraries::instance()->refresherTid(); - if (Profiler::instance()->threadFilter()->enabled()) { + if (!_all_threads && Profiler::instance()->threadFilter()->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { ThreadList *thread_list = OS::listThreads(); @@ -508,16 +536,24 @@ void WallClockJvmti::timerLoop() { // Exclude the wallclock timer thread itself and the Libraries // refresher (profiler-internal). if (tid != OS::threadId() && tid != refresher_tid) { - entries.push_back({tid, nullptr}); + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->lookupByTid(tid); + entries.push_back({tid, slot, + slot == nullptr ? 0 : slot->lifecycleGeneration()}); } } delete thread_list; } + if (_precheck) { + entries.erase(std::remove_if(entries.begin(), entries.end(), + suppressAlreadySampledBlock), + entries.end()); + } }; auto sampleThreads = [&](ThreadEntry entry, int &num_failures, int &threads_already_exited, int &permission_denied) { - if (_precheck && suppressAlreadySampledBlock(entry.slot)) { + if (_precheck && suppressAlreadySampledBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index 14e3f88aa3..eeeb49f393 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -30,6 +30,7 @@ class BaseWallClock : public Engine { // limit low enough helps to avoid contention on a spin lock inside // Profiler::recordSample(). int _reservoir_size; + bool _all_threads; pthread_t _thread; virtual void timerLoop() = 0; @@ -123,6 +124,7 @@ class BaseWallClock : public Engine { _running(false), _interval(LONG_MAX), _reservoir_size(0), + _all_threads(false), _thread(0) {} virtual ~BaseWallClock() = default; 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 8f647ba418..4cbdf53e16 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -205,16 +205,21 @@ public boolean recordTraceRoot(long rootSpanId, String endpoint, int sizeLimit) } /** - * Add the given thread to the set of profiled threads. - * 'filter' option must be enabled to use this method. + * Marks the current thread as inside the profiling context window. + * + *

With {@code wallscope=all}, this marker does not control ordinary wall-clock + * sampling: the thread remains sampleable both inside and outside the window. With + * legacy/context wall scope, the marker retains its historical filtering behavior. */ public void addThread() { filterThreadAdd0(); } /** - * Remove the given thread to the set of profiled threads. - * 'filter' option must be enabled to use this method. + * Marks the current thread as outside the profiling context window. + * + *

Repeated calls are idempotent. With {@code wallscope=all}, leaving the window + * does not remove the thread from ordinary wall-clock profiling. */ public void removeThread() { filterThreadRemove0(); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 4608e11697..701aef1044 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 Datadog, Inc + * Copyright 2025, 2026 Datadog, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ #include +#include "counters.h" #include "threadFilter.h" #include "../../main/cpp/gtest_crash_handler.h" #include @@ -552,3 +553,128 @@ TEST_F(ThreadFilterTest, TokenRoundTripPreservesHighGenerationBit) { EXPECT_EQ(slot_id, ThreadFilter::tokenSlotId(static_cast(java_token))); EXPECT_EQ(generation, ThreadFilter::tokenGeneration(static_cast(java_token))); } + +TEST(ThreadRegistryTest, AllThreadScopeSeparatesRegistrationFromContextWindow) { + ThreadFilter registry; + registry.init("0", true); + + EXPECT_TRUE(registry.registryActive()); + EXPECT_TRUE(registry.allThreads()); + EXPECT_FALSE(registry.enabled()); + + int slot_id = registry.registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(1234, slot->nativeTid()); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(slot, registry.lookupByTid(1234)); + + std::vector registered; + registry.collectRegistered(registered); + ASSERT_EQ(1u, registered.size()); + EXPECT_EQ(1234, registered[0].tid); + + std::vector context; + registry.collect(context); + EXPECT_TRUE(context.empty()); + + registry.add(1234, slot_id); + registry.collect(context); + ASSERT_EQ(1u, context.size()); + registry.remove(slot_id); + registry.collect(context); + EXPECT_TRUE(context.empty()); + + registry.collectRegistered(registered); + ASSERT_EQ(1u, registered.size()); + EXPECT_EQ(1234, registered[0].tid); +} + +TEST(ThreadRegistryTest, ContextWindowTransitionsAreIdempotent) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(5678); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + u64 initial_epoch = slot->contextWindowEpoch(); + registry.add(5678, slot_id); + EXPECT_TRUE(slot->inContextWindow()); + EXPECT_EQ(initial_epoch + 1, slot->contextWindowEpoch()); + + registry.add(5678, slot_id); + EXPECT_EQ(initial_epoch + 1, slot->contextWindowEpoch()); + + registry.remove(slot_id); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(initial_epoch + 2, slot->contextWindowEpoch()); + + registry.remove(slot_id); + EXPECT_EQ(initial_epoch + 2, slot->contextWindowEpoch()); +} + +TEST(ThreadRegistryTest, SlotReuseChangesLifecycleGenerationAndTidMapping) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(1111); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + u64 first_generation = slot->lifecycleGeneration(); + + registry.unregisterThread(slot_id); + EXPECT_EQ(nullptr, registry.lookupByTid(1111)); + + int reused_id = registry.registerThread(2222); + ASSERT_EQ(slot_id, reused_id); + EXPECT_GT(slot->lifecycleGeneration(), first_generation); + EXPECT_EQ(nullptr, registry.lookupByTid(1111)); + EXPECT_EQ(slot, registry.lookupByTid(2222)); +} + +TEST(ThreadRegistryTest, ContextTransitionInvalidatesOwnedRunSuppression) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(3333); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0u, token); + EXPECT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); + + registry.add(3333, slot_id); + registry.remove(slot_id); + EXPECT_FALSE(slot->activeBlockRemainedOutsideContextWindow()); +} + +TEST(ThreadRegistryTest, TidIndexRemainsReusableAcrossLongThreadChurn) { + ThreadFilter registry; + registry.init("0", true); + + for (int tid = 1; tid <= ThreadFilter::kTidIndexSize * 3; ++tid) { + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0) << "tid=" << tid; + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_EQ(slot, registry.lookupByTid(tid)); + registry.unregisterThread(slot_id); + ASSERT_EQ(nullptr, registry.lookupByTid(tid)); + } +} + +TEST(ThreadRegistryTest, CapacityExhaustionIsCountedAndDoesNotDisableSampling) { + Counters::reset(); + ThreadFilter registry; + registry.init("0", true); + + for (int tid = 1; tid <= ThreadFilter::kMaxThreads; ++tid) { + ASSERT_GE(registry.registerThread(tid), 0); + } + EXPECT_EQ(-1, registry.registerThread(ThreadFilter::kMaxThreads + 1)); + EXPECT_EQ(1, Counters::getCounter(THREAD_FILTER_CAPACITY_EXHAUSTED)); + EXPECT_TRUE(registry.allThreads()); + EXPECT_FALSE(registry.enabled()); +} diff --git a/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp index 5579e354fe..cd3c94fb6a 100644 --- a/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp +++ b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp @@ -56,3 +56,52 @@ TEST(WallPrecheckArgsTest, EnabledWithinLongerArgString) { EXPECT_TRUE(args._wall_precheck); } +TEST(WallPrecheckArgsTest, WallScopeDefaultsToContext) { + Arguments args; + + EXPECT_EQ(WALL_SCOPE_CONTEXT, args._wall_scope); + EXPECT_FALSE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, WallScopeAcceptsContextAndAll) { + Arguments context_args; + Error error = context_args.parse("wallscope=context"); + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_CONTEXT, context_args._wall_scope); + EXPECT_FALSE(context_args.wallScopeAllThreads()); + + Arguments all_args; + error = all_args.parse("wallscope=all"); + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_ALL, all_args._wall_scope); + EXPECT_TRUE(all_args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, ExplicitFilterPreservesContextScope) { + Arguments args; + Error error = args.parse("filter=0"); + + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_CONTEXT, args._wall_scope); + EXPECT_FALSE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, ExplicitAllScopeOverridesLegacyFilter) { + Arguments args; + Error error = args.parse("filter=0,wallscope=all"); + + EXPECT_FALSE(error); + EXPECT_TRUE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, WallScopeRejectsMissingAndUnknownValues) { + Arguments missing_args; + Error error = missing_args.parse("wallscope"); + ASSERT_TRUE(static_cast(error)); + EXPECT_STREQ("wallscope must be 'context' or 'all'", error.message()); + + Arguments unknown_args; + error = unknown_args.parse("wallscope=everything"); + ASSERT_TRUE(static_cast(error)); + EXPECT_STREQ("wallscope must be 'context' or 'all'", error.message()); +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java new file mode 100644 index 0000000000..14ba13a7ec --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.HashSet; +import java.util.Set; +import org.junitpioneer.jupiter.RetryingTest; +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.flightrecorder.jdk.JdkAttributes; + +/** Verifies that all-thread wall scope is independent of context-window membership. */ +public class AllThreadWallScopeTest extends AbstractProfilerTest { + private static final long WORK_MILLIS = 250; + + /** + * Samples threads that never enter, remain inside, and have already left the context window. + * + * @throws Exception if a workload thread cannot complete + */ + @RetryingTest(3) + public void samplesEveryContextWindowState() throws Exception { + runWorker("wall-scope-never-added", WindowAction.NONE); + runWorker("wall-scope-inside-window", WindowAction.ENTER); + runWorker("wall-scope-after-window", WindowAction.ENTER_AND_EXIT); + + stopProfiler(); + Set sampledThreads = sampledThreadNames(verifyEvents("datadog.MethodSample")); + assertTrue(sampledThreads.contains("wall-scope-never-added"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-scope-inside-window"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-scope-after-window"), sampledThreads::toString); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms"; + } + + private void runWorker(String name, WindowAction action) throws Exception { + Thread worker = + new Thread( + () -> { + if (action != WindowAction.NONE) { + profiler.addThread(); + } + if (action == WindowAction.ENTER_AND_EXIT) { + profiler.removeThread(); + } + long deadline = System.nanoTime() + WORK_MILLIS * 1_000_000L; + while (System.nanoTime() < deadline) { + // Deliberately remain runnable so the assertion does not depend on + // idle-thread state classification. + } + if (action == WindowAction.ENTER) { + profiler.removeThread(); + } + }, + name); + worker.start(); + worker.join(); + } + + private static Set sampledThreadNames(IItemCollection events) { + Set names = new HashSet<>(); + for (IItemIterable samples : events) { + IMemberAccessor accessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(samples.getType()); + for (IItem sample : samples) { + names.add(accessor.getMember(sample)); + } + } + return names; + } + + private enum WindowAction { + NONE, + ENTER, + ENTER_AND_EXIT + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java new file mode 100644 index 0000000000..e91dc5009a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.HashSet; +import java.util.Set; +import org.junitpioneer.jupiter.RetryingTest; +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.flightrecorder.jdk.JdkAttributes; + +/** Verifies the explicit context-filtered wall-clock compatibility mode. */ +public class ContextWallScopeTest extends AbstractProfilerTest { + private static final long WORK_MILLIS = 250; + + /** Verifies that only a worker inside the context window is sampled. */ + @RetryingTest(3) + public void samplesOnlyContextWindowThread() throws Exception { + runWorker("wall-context-outside", false); + runWorker("wall-context-inside", true); + + stopProfiler(); + Set sampledThreads = sampledThreadNames(verifyEvents("datadog.MethodSample")); + assertFalse(sampledThreads.contains("wall-context-outside"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-context-inside"), sampledThreads::toString); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=context"; + } + + private void runWorker(String name, boolean inContextWindow) throws Exception { + Thread worker = + new Thread( + () -> { + if (inContextWindow) { + profiler.addThread(); + } + long deadline = System.nanoTime() + WORK_MILLIS * 1_000_000L; + while (System.nanoTime() < deadline) {} + if (inContextWindow) { + profiler.removeThread(); + } + }, + name); + worker.start(); + worker.join(); + } + + private static Set sampledThreadNames(IItemCollection events) { + Set names = new HashSet<>(); + for (IItemIterable samples : events) { + IMemberAccessor accessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(samples.getType()); + for (IItem sample : samples) { + names.add(accessor.getMember(sample)); + } + } + return names; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java new file mode 100644 index 0000000000..502c67a0e9 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +/** Runs all-thread wall-scope coverage through delegated JVMTI stack collection. */ +public class JvmtiBasedAllThreadWallScopeTest extends AllThreadWallScopeTest { + @Override + protected String getProfilerCommand() { + return super.getProfilerCommand() + ",jvmtistacks=true"; + } +} From c5c80a1b2b06112aa47ae079928002ed5f4487e0 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 16:34:45 +0200 Subject: [PATCH 02/10] fix: fix test all threads --- .../datadoghq/profiler/wallclock/AllThreadWallScopeTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java index 14ba13a7ec..1dc3b510de 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java @@ -41,7 +41,7 @@ public void samplesEveryContextWindowState() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms"; + return "wall=1ms,wallscope=all"; } private void runWorker(String name, WindowAction action) throws Exception { From 67109c4067bb752c28208b85283d73ece0cf2c89 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 11:16:23 +0200 Subject: [PATCH 03/10] feat(taskblock): capture stacks synchronously at block exit --- ddprof-lib/src/main/cpp/counters.h | 8 +- ddprof-lib/src/main/cpp/event.h | 22 +- ddprof-lib/src/main/cpp/flightRecorder.cpp | 32 ++- ddprof-lib/src/main/cpp/flightRecorder.h | 2 + ddprof-lib/src/main/cpp/javaApi.cpp | 118 ++++++++- ddprof-lib/src/main/cpp/jfrMetadata.cpp | 18 +- ddprof-lib/src/main/cpp/jfrMetadata.h | 1 + ddprof-lib/src/main/cpp/jvmSupport.cpp | 23 ++ ddprof-lib/src/main/cpp/jvmSupport.h | 4 + ddprof-lib/src/main/cpp/profiler.cpp | 116 +++++++++ ddprof-lib/src/main/cpp/profiler.h | 34 +++ ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 25 ++ ddprof-lib/src/main/cpp/taskBlockRecorder.h | 81 +++++++ ddprof-lib/src/main/cpp/threadFilter.cpp | 69 +++++- ddprof-lib/src/main/cpp/threadFilter.h | 114 +++++---- ddprof-lib/src/main/cpp/threadLocalData.h | 24 +- ddprof-lib/src/main/cpp/wallClock.cpp | 83 ++----- ddprof-lib/src/main/cpp/wallClock.h | 2 +- ddprof-lib/src/main/cpp/wallClockCounters.h | 12 +- .../com/datadoghq/profiler/JavaProfiler.java | 41 +++- ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 77 ++++++ ddprof-lib/src/test/cpp/park_state_ut.cpp | 51 +--- .../src/test/cpp/taskBlockRecorder_ut.cpp | 182 ++++++++++++++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 117 ++++++++- .../src/test/cpp/wallClockCounters_ut.cpp | 18 +- .../profiler/JavaProfilerApiSurfaceTest.java | 10 +- .../JavaProfilerTaskBlockApiTest.java | 228 ++++++++++++++++++ .../JavaProfilerTaskBlockDisabledTest.java | 26 ++ .../wallclock/PrecheckEfficiencyTest.java | 31 ++- .../profiler/wallclock/PrecheckTest.java | 79 +++--- .../wallclock/TaskBlockAssertions.java | 126 ++++++++++ .../WallclockMitigationsCombinedTest.java | 8 +- 32 files changed, 1524 insertions(+), 258 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/taskBlockRecorder.cpp create mode 100644 ddprof-lib/src/main/cpp/taskBlockRecorder.h create mode 100644 ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index e90652f7fc..4866cbc7a7 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -67,10 +67,16 @@ X(AGCT_NATIVE_NO_JAVA_CONTEXT, "agct_native_no_java_context") \ X(AGCT_BLOCKED_IN_VM, "agct_blocked_in_vm") \ X(SKIPPED_WALLCLOCK_UNWINDS, "skipped_wallclock_unwinds") \ - X(WC_SIGNAL_SUPPRESSED_SAMPLED_RUN, "wc_signals_suppressed_sampled_run") \ + X(WC_SIGNAL_SUPPRESSED_OWNED_BLOCK, "wc_signals_suppressed_owned_block") \ X(WC_UNOWNED_BLOCKED_SUPPRESSED, "wc_unowned_blocked_suppressed") \ X(WC_UNOWNED_BLOCKED_RECORDED, "wc_unowned_blocked_recorded") \ X(WC_SIGNAL_QUEUE_FULL, "wc_signals_queue_full") \ + X(TASK_BLOCK_EMITTED, "task_block_emitted") \ + X(TASK_BLOCK_SKIPPED_TRACE_CONTEXT, "task_block_skipped_trace_context") \ + X(TASK_BLOCK_SKIPPED_TOO_SHORT, "task_block_skipped_too_short") \ + X(TASK_BLOCK_STACK_CAPTURE_FAILED, "task_block_stack_capture_failed") \ + X(TASK_BLOCK_RECORD_FAILED, "task_block_record_failed") \ + X(TASK_BLOCK_DROPPED_ROTATION, "task_block_dropped_rotation") \ X(UNWINDING_TIME_ASYNC, "unwinding_ticks_async") \ X(UNWINDING_TIME_JVMTI, "unwinding_ticks_jvmti") \ X(CALLTRACE_STORAGE_DROPPED, "calltrace_storage_dropped_traces") \ diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index 0747f2a4fb..d63b5489cb 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -57,7 +57,7 @@ class ExecutionEvent : public Event { OSThreadState _thread_state; ExecutionMode _execution_mode; u64 _weight; - u32 _call_trace_id; + u64 _call_trace_id; ExecutionEvent() : Event(), _thread_state(OSThreadState::RUNNABLE), _execution_mode(ExecutionMode::UNKNOWN), @@ -122,13 +122,13 @@ class WallClockEpochEvent { u32 _num_failed_samples; u32 _num_exited_threads; u32 _num_permission_denied; - u64 _num_suppressed_sampled_run; + u64 _num_suppressed_owned_block; WallClockEpochEvent(u64 start_time) : _dirty(false), _start_time(start_time), _duration_millis(0), _num_samplable_threads(0), _num_successful_samples(0), _num_failed_samples(0), _num_exited_threads(0), - _num_permission_denied(0), _num_suppressed_sampled_run(0) {} + _num_permission_denied(0), _num_suppressed_owned_block(0) {} bool hasChanged() { return _dirty; } @@ -167,10 +167,10 @@ class WallClockEpochEvent { } } - void addNumSuppressedSampledRun(u64 n) { + void addNumSuppressedOwnedBlock(u64 n) { if (n > 0) { _dirty = true; - _num_suppressed_sampled_run += n; + _num_suppressed_owned_block += n; } } @@ -181,7 +181,7 @@ class WallClockEpochEvent { void newEpoch(u64 start_time) { _dirty = false; _start_time = start_time; - _num_suppressed_sampled_run = 0; + _num_suppressed_owned_block = 0; } }; @@ -206,4 +206,14 @@ typedef struct QueueTimeEvent { u32 _queueLength; } QueueTimeEvent; +typedef struct TaskBlockEvent { + u64 _start; + u64 _end; + u64 _blocker; + u64 _unblockingSpanId; + Context _ctx; + u64 _callTraceId; + OSThreadState _observedBlockingState; +} TaskBlockEvent; + #endif // _EVENT_H diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 86134652cc..5d5fb522fe 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1873,6 +1873,21 @@ void Recording::recordMethodSample(Buffer *buf, int tid, u64 call_trace_id, flushIfNeeded(buf); } +void Recording::recordTaskBlock(Buffer *buf, int tid, TaskBlockEvent *event) { + int start = buf->skip(1); + buf->putVar64(T_TASK_BLOCK); + buf->putVar64(event->_start); + buf->putVar64(event->_end - event->_start); + buf->putVar64(tid); + buf->putVar64(event->_blocker); + buf->putVar64(event->_unblockingSpanId); + buf->putVar64(event->_callTraceId); + buf->put8(static_cast(event->_observedBlockingState)); + writeContextSnapshot(buf, event->_ctx); + writeEventSizePrefix(buf, start); + flushIfNeeded(buf); +} + void Recording::recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event) { int start = buf->skip(1); buf->putVar64(T_WALLCLOCK_SAMPLE_EPOCH); @@ -1883,7 +1898,7 @@ void Recording::recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event) { buf->putVar64(event->_num_failed_samples); buf->putVar64(event->_num_exited_threads); buf->putVar64(event->_num_permission_denied); - buf->putVar64(event->_num_suppressed_sampled_run); + buf->putVar64(event->_num_suppressed_owned_block); writeEventSizePrefix(buf, start); flushIfNeeded(buf); } @@ -2138,6 +2153,21 @@ void FlightRecorder::recordQueueTime(int lock_index, int tid, } } +bool FlightRecorder::recordTaskBlock(int lock_index, int tid, + TaskBlockEvent *event) { + OptionalSharedLockGuard locker(&_rec_lock); + if (locker.ownsLock()) { + Recording* rec = _rec; + if (rec != nullptr) { + Buffer *buf = rec->buffer(lock_index); + rec->addThread(lock_index, tid); + rec->recordTaskBlock(buf, tid, event); + return true; + } + } + return false; +} + void FlightRecorder::recordDatadogSetting(int lock_index, int length, const char *name, const char *value, const char *unit) { diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index fd5bffda59..070d59ccff 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -316,6 +316,7 @@ class Recording { void recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event); void recordTraceRoot(Buffer *buf, int tid, TraceRootEvent *event); void recordQueueTime(Buffer *buf, int tid, QueueTimeEvent *event); + void recordTaskBlock(Buffer *buf, int tid, TaskBlockEvent *event); void recordAllocation(RecordingBuffer *buf, int tid, u64 call_trace_id, AllocEvent *event); void recordMallocSample(Buffer *buf, int tid, u64 call_trace_id, @@ -424,6 +425,7 @@ class FlightRecorder { void wallClockEpoch(int lock_index, WallClockEpochEvent *event); void recordTraceRoot(int lock_index, int tid, TraceRootEvent *event); void recordQueueTime(int lock_index, int tid, QueueTimeEvent *event); + bool recordTaskBlock(int lock_index, int tid, TaskBlockEvent *event); bool active() const { return _rec != NULL; } diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 5926ed8866..14850c7f04 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -26,11 +26,13 @@ #include "engine.h" #include "hotspot/vmStructs.inline.h" #include "incbin.h" +#include "jvmSupport.h" #include "jvmThread.h" #include "os.h" #include "otel_process_ctx.h" #include "profiler.h" #include "threadLocalData.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "vmEntry.h" #include @@ -371,14 +373,20 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (current == nullptr) { return 0; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (!tf->enabled()) { + if (ContextApi::snapshot().spanId != 0) { return 0; } - ThreadFilter::SlotID slot_id = current->filterSlotId(); - if (slot_id < 0) { + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + if (!profiler->taskBlockEnabled() && !tf->enabled()) { return 0; } + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0 && tf->allThreads()) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (slot_id < 0) return 0; return static_cast(tf->enterBlockedRun(slot_id, decoded)); } @@ -393,14 +401,108 @@ Java_com_datadoghq_profiler_JavaProfiler_blockExit0( if (current == nullptr) { return; } + ThreadFilter *tf = Profiler::instance()->threadFilter(); ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(block_token); - if (current->filterSlotId() != slot_id) { + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0 && tf->allThreads()) { + current_slot = tf->slotIdByTid(current->tid()); + if (current_slot >= 0) current->setFilterSlotId(current_slot); + } + if (current_slot != slot_id) { return; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { - tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(block_token)); + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(block_token)); +} + +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( + JNIEnv *env, jclass unused, jthread thread, jint state) { + OSThreadState decoded; + if (!decodeJavaBlockState(state, decoded) || + !JVMSupport::isPlatformThread(env, thread)) { + return 0; + } + ProfiledThread *current = ProfiledThread::current(); + Profiler *profiler = Profiler::instance(); + if (current == nullptr || !profiler->isRunning() || + !profiler->taskBlockEnabled()) { + return 0; + } + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (!tf->allThreads() || slot_id < 0) return 0; + + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + return 0; } + u64 token = tf->enterBlockedRun(slot_id, decoded, BlockRunOwner::JAVA); + if (!current->taskBlockEnter(token, TSC::ticks(), context)) { + if (token != 0) { + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); + } + return 0; + } + return static_cast(token); +} + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_endTaskBlock0( + JNIEnv *env, jclass unused, jthread thread, jlong token, jlong blocker, + jlong unblockingSpanId) { + u64 block_token = static_cast(token); + ThreadFilter::SlotID slot_id = -1; + u64 generation = 0; + if (!ThreadFilter::decodeBlockRunToken(block_token, slot_id, generation) || + !JVMSupport::isPlatformThread(env, thread)) { + return JNI_FALSE; + } + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return JNI_FALSE; + + u64 start_ticks = 0; + Context context{}; + if (!current->taskBlockExit(block_token, start_ticks, context)) { + return JNI_FALSE; + } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun(slot_id, generation, &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return JNI_FALSE; + } + if (!recording_enabled || !exited) { + profiler->leaveTaskBlockActivity(); + return JNI_FALSE; + } + if (!snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + profiler->leaveTaskBlockActivity(); + return JNI_FALSE; + } + + bool recorded = recordTaskBlockIfEligible( + current->tid(), thread, 1, start_ticks, TSC::ticks(), context, + static_cast(blocker), static_cast(unblockingSpanId), + snapshot.active_state, true); + profiler->leaveTaskBlockActivity(); + return recorded ? JNI_TRUE : JNI_FALSE; } extern "C" DLLEXPORT jlong JNICALL diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index f0f425ef77..4e48d7eacb 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -156,8 +156,8 @@ void JfrMetadata::initialize( "Number of Exited Threads Before Handling Signal") << field("numPermissionDenied", T_INT, "Number of Permission Denied Errors") - << field("numSuppressedSampledRun", T_LONG, - "Signals suppressed by the wall-clock once-per-run filter")) + << field("numSuppressedOwnedBlock", T_LONG, + "Signals suppressed for lifecycle-owned blocked intervals")) << (type("datadog.ObjectSample", T_ALLOC, "Allocation sample") << category("Datadog", "Profiling") @@ -209,6 +209,20 @@ void JfrMetadata::initialize( << field("localRootSpanId", T_LONG, "Local Root Span ID") || contextAttributes) + << (type("datadog.TaskBlock", T_TASK_BLOCK, "Task Block") + << category("Datadog") + << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) + << field("duration", T_LONG, "Duration", F_DURATION_TICKS) + << field("eventThread", T_THREAD, "Event Thread", F_CPOOL) + << field("blocker", T_LONG, "Blocker Identity Hash") + << field("unblockingSpanId", T_LONG, "Unblocking Span ID") + << field("stackTrace", T_STACK_TRACE, "Stack Trace", F_CPOOL) + << field("observedBlockingState", T_THREAD_STATE, + "Observed Blocking State", F_CPOOL) + << field("spanId", T_LONG, "Span ID") + << field("localRootSpanId", T_LONG, "Local Root Span ID") || + contextAttributes) + << (type("datadog.HeapUsage", T_HEAP_USAGE, "JVM Heap Usage") << category("Datadog") << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.h b/ddprof-lib/src/main/cpp/jfrMetadata.h index ac241a7a84..f5102ef705 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.h +++ b/ddprof-lib/src/main/cpp/jfrMetadata.h @@ -81,6 +81,7 @@ enum JfrType { T_UNWIND_FAILURE = 126, T_MALLOC = 127, T_NATIVE_SOCKET = 128, + T_TASK_BLOCK = 129, T_ANNOTATION = 200, T_LABEL = 201, T_CATEGORY = 202, diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index cff98c33b5..7ef95debde 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -16,10 +16,33 @@ #include +using JniFunction = void (JNICALL*)(); +using IsVirtualThreadFunction = jboolean (JNICALL*)(JNIEnv*, jobject); + +static constexpr jint JNI_VERSION_21_VALUE = 0x00150000; +static constexpr int IS_VIRTUAL_THREAD_INDEX = 234; + +static_assert(sizeof(JniFunction) == sizeof(void*), + "JNI function table entries must be pointer-sized"); volatile JVMSupport::JMethodIDLoadStats JVMSupport::jmethodID_load_state = JVMSupport::No_loaded; Mutex JVMSupport::_initialization_lock; +bool JVMSupport::isPlatformThread(JNIEnv* jni, jthread thread) { + if (jni == nullptr || thread == nullptr) return false; + jint jni_version = jni->GetVersion(); + if (jni_version <= 0) return false; + if (jni_version < JNI_VERSION_21_VALUE) return true; + + const JniFunction* functions = + reinterpret_cast(jni->functions); + IsVirtualThreadFunction is_virtual_thread = + reinterpret_cast( + functions[IS_VIRTUAL_THREAD_INDEX]); + return is_virtual_thread != nullptr && + is_virtual_thread(jni, thread) == JNI_FALSE; +} + bool JVMSupport::initialize() { MutexLocker locker(_initialization_lock); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 5ba33ae5ae..f21fb4e3f8 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -44,6 +44,10 @@ class JVMSupport { static bool isInitialized(); public: + // Java-owned profiler state is carrier-local and may only be used by platform threads. + // IsVirtualThread was added to the JNI function table in JDK 21. + static bool isPlatformThread(JNIEnv* jni, jthread thread); + // Initialize JVM support - check JVM related resources are available. // Return false if any critical resource is not available, which should // result in disabling profiling. diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index a2b2885d58..fef0d9dfb1 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -32,6 +32,7 @@ #include "stackFrame.h" #include "stackWalker.h" #include "symbols.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "utils.h" #include "wallClock.h" @@ -44,6 +45,7 @@ #include #include #include +#include #include #include #include @@ -138,6 +140,8 @@ int Profiler::registerThread(int tid) { } #ifdef UNIT_TEST static std::atomic g_test_last_unregistered_tid{-1}; +static std::atomic + g_test_task_block_record_override{nullptr}; int Profiler::lastUnregisteredTidForTest() { return g_test_last_unregistered_tid.load(std::memory_order_relaxed); @@ -145,6 +149,11 @@ int Profiler::lastUnregisteredTidForTest() { void Profiler::resetUnregisterObservableForTest() { g_test_last_unregistered_tid.store(-1, std::memory_order_relaxed); } + +void Profiler::setTaskBlockRecordOverrideForTest( + TaskBlockRecordOverride override) { + g_test_task_block_record_override.store(override, std::memory_order_release); +} #endif void Profiler::unregisterThread(int tid) { @@ -721,6 +730,99 @@ void Profiler::recordQueueTime(int tid, QueueTimeEvent *event) { _locks[lock_index].unlock(); } +Profiler::TaskBlockRecordResult Profiler::recordTaskBlock( + int tid, jthread thread, int start_depth, TaskBlockEvent *event) { +#ifdef UNIT_TEST + TaskBlockRecordOverride override = + g_test_task_block_record_override.load(std::memory_order_acquire); + if (override != nullptr) { + return override(tid, thread, start_depth, event); + } +#endif + CriticalSection cs; + u32 lock_index = getLockIndex(tid); + if (!_locks[lock_index].tryLock() && + !_locks[lock_index = (lock_index + 1) % CONCURRENCY_LEVEL].tryLock() && + !_locks[lock_index = (lock_index + 2) % CONCURRENCY_LEVEL].tryLock()) { + return TaskBlockRecordResult::RECORD_FAILED; + } + + if (_omit_stacktraces || _max_stack_depth <= 0 || + _calltrace_buffer[lock_index] == nullptr) { + _locks[lock_index].unlock(); + return TaskBlockRecordResult::STACK_CAPTURE_FAILED; + } + + CallTraceBuffer *buffer = _calltrace_buffer[lock_index]; + ASGCT_CallFrame *frames = buffer->_asgct_frames; + jvmtiFrameInfo *jvmti_frames = buffer->_jvmti_frames; + jint num_frames = 0; +#ifdef COUNTERS + u64 stack_start = TSC::ticks(); +#endif + jvmtiError error = VM::jvmti()->GetStackTrace( + thread, start_depth, _max_stack_depth, jvmti_frames, &num_frames); + if (error != JVMTI_ERROR_NONE || num_frames <= 0) { + _locks[lock_index].unlock(); + return TaskBlockRecordResult::STACK_CAPTURE_FAILED; + } + + for (int i = 0; i < num_frames; ++i) { + frames[i].method_id = jvmti_frames[i].method; + frames[i].bci = jvmti_frames[i].location; + LP64_ONLY(frames[i].padding = 0;) + } + u64 call_trace_id = + _call_trace_storage.put(num_frames, frames, false, 1); +#ifdef COUNTERS + u64 stack_duration = TSC::ticks() - stack_start; + if (stack_duration > 0) { + Counters::increment(UNWINDING_TIME_JVMTI, stack_duration); + } +#endif + if (call_trace_id == 0) { + _locks[lock_index].unlock(); + return TaskBlockRecordResult::STACK_CAPTURE_FAILED; + } + + event->_callTraceId = call_trace_id; + bool recorded = _jfr.recordTaskBlock(lock_index, tid, event); + _locks[lock_index].unlock(); + return recorded ? TaskBlockRecordResult::RECORDED + : TaskBlockRecordResult::RECORD_FAILED; +} + +bool Profiler::tryEnterTaskBlockActivity() { + if (_task_block_rotation.load(std::memory_order_acquire)) return false; + _task_block_inflight.fetch_add(1, std::memory_order_acq_rel); + if (_task_block_rotation.load(std::memory_order_acquire)) { + _task_block_inflight.fetch_sub(1, std::memory_order_acq_rel); + return false; + } + return true; +} + +void Profiler::leaveTaskBlockActivity() { + _task_block_inflight.fetch_sub(1, std::memory_order_release); +} + +void Profiler::waitForTaskBlockRotation() { + while (_task_block_rotation.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } +} + +void Profiler::beginTaskBlockRotation() { + _task_block_rotation.store(true, std::memory_order_release); + while (_task_block_inflight.load(std::memory_order_acquire) != 0) { + std::this_thread::yield(); + } +} + +void Profiler::endTaskBlockRotation() { + _task_block_rotation.store(false, std::memory_order_release); +} + void Profiler::recordExternalSample(u64 weight, int tid, int num_frames, ASGCT_CallFrame *frames, bool truncated, jint event_type, Event *event) { @@ -1264,6 +1366,7 @@ Error Profiler::checkState() { Error Profiler::start(Arguments &args, bool reset) { MutexLocker ml(_state_lock); + _task_block_enabled.store(false, std::memory_order_release); Error error = checkState(); if (error) { return error; @@ -1455,6 +1558,7 @@ Error Profiler::start(Arguments &args, bool reset) { _libs->stopRefresher(); return error; } + initializeTaskBlockDurationThreshold(); int activated = 0; if ((_event_mask & EM_CPU) && _cpu_engine != &noop_engine) { @@ -1545,6 +1649,9 @@ Error Profiler::start(Arguments &args, bool reset) { // Paired with drainInflight() on the stop side. _cpu_engine->enableEvents(true); + _task_block_enabled.store( + (activated & EM_WALL) && args._wall_precheck && all_threads, + std::memory_order_release); _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1568,6 +1675,7 @@ Error Profiler::stop() { if (state() != RUNNING) { return Error("Profiler is not active"); } + _task_block_enabled.store(false, std::memory_order_release); // Order matters: disable engines first so the _enabled check inside signal // handlers will fail for any new signal delivered from now on. drain() then @@ -1585,6 +1693,11 @@ Error Profiler::stop() { return Error("signal handlers did not drain; teardown skipped, retry stop()"); } + // Prevent existing paired intervals from recording during teardown. New + // intervals were disabled above; this also drains endTaskBlock calls that + // already entered their snapshot-and-record activity. + beginTaskBlockRotation(); + if (_event_mask & EM_ALLOC) _alloc_engine->stop(); if (_event_mask & EM_NATIVEMEM) @@ -1651,6 +1764,7 @@ Error Profiler::stop() { _thread_info.reportCounters(); rotateDictsAndRun([&]{ _jfr.stop(); }); + endTaskBlockRotation(); // Unpatch libraries AFTER JFR serialization completes // Remote symbolication RemoteFrameInfo structs contain pointers to build-ID strings @@ -1732,10 +1846,12 @@ Error Profiler::dump(const char *path, const int length) { // its own writer/reader coordination; #527's classMapSharedGuard readers // (deferred vtable receiver resolution) are coordinated through // _class_map_lock. + beginTaskBlockRotation(); rotateDictsAndRun([&]{ err = _jfr.dump(path, length); __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); }); + endTaskBlockRotation(); _thread_info.clearAll(thread_ids); _thread_info.reportCounters(); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 11fc1db7fd..7d5bb30b51 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -119,6 +119,9 @@ class alignas(alignof(SpinLock)) Profiler { alignas(DEFAULT_CACHE_LINE_SIZE) volatile u64 _sample_seq; alignas(DEFAULT_CACHE_LINE_SIZE) u64 _failures[ASGCT_FAILURE_TYPES]; bool _wall_precheck = false; + std::atomic _task_block_enabled{false}; + std::atomic _task_block_rotation{false}; + std::atomic _task_block_inflight{0}; SpinLock _class_map_lock; SpinLock _locks[CONCURRENCY_LEVEL]; @@ -162,6 +165,8 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); + void beginTaskBlockRotation(); + void endTaskBlockRotation(); // Rotate all three dictionaries, then run jfr_op under lockAll(). // @@ -422,6 +427,26 @@ class alignas(alignof(SpinLock)) Profiler { void recordWallClockEpoch(int tid, WallClockEpochEvent *event); void recordTraceRoot(int tid, TraceRootEvent *event); void recordQueueTime(int tid, QueueTimeEvent *event); + enum class TaskBlockRecordResult { + RECORDED, + STACK_CAPTURE_FAILED, + RECORD_FAILED, + }; + TaskBlockRecordResult recordTaskBlock(int tid, jthread thread, + int start_depth, + TaskBlockEvent *event); +#ifdef UNIT_TEST + using TaskBlockRecordOverride = TaskBlockRecordResult (*)( + int tid, jthread thread, int start_depth, TaskBlockEvent *event); + static void setTaskBlockRecordOverrideForTest( + TaskBlockRecordOverride override); +#endif + bool tryEnterTaskBlockActivity(); + void leaveTaskBlockActivity(); + void waitForTaskBlockRotation(); + bool taskBlockEnabled() const { + return _task_block_enabled.load(std::memory_order_acquire); + } void writeLog(LogLevel level, const char *message); void writeLog(LogLevel level, const char *message, size_t len); void writeDatadogProfilerSetting(int tid, int length, const char *name, @@ -442,6 +467,15 @@ class alignas(alignof(SpinLock)) Profiler { static void unregisterThread(int tid); #ifdef UNIT_TEST + void beginTaskBlockRotationForTest() { beginTaskBlockRotation(); } + void endTaskBlockRotationForTest() { endTaskBlockRotation(); } + bool taskBlockRotationActiveForTest() const { + return _task_block_rotation.load(std::memory_order_acquire); + } + int taskBlockInflightForTest() const { + return _task_block_inflight.load(std::memory_order_acquire); + } + // Returns the tid most recently passed to unregisterThread(), or -1 if it // has never been called (or since the last resetUnregisterObservableForTest). // Used by integration tests to assert that cleanup_unregister wired diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp new file mode 100644 index 0000000000..ae46a02534 --- /dev/null +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -0,0 +1,25 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "taskBlockRecorder.h" + +#include + +static const u64 kMinTaskBlockNanos = 1000000; +static std::atomic g_min_task_block_ticks{0}; + +static u64 computeMinTaskBlockTicks() { + return (TSC::frequency() * kMinTaskBlockNanos) / NANOTIME_FREQ; +} + +void initializeTaskBlockDurationThreshold() { + g_min_task_block_ticks.store(computeMinTaskBlockTicks(), std::memory_order_release); +} + +bool exceedsMinTaskBlockDuration(u64 start_ticks, u64 end_ticks) { + u64 min_ticks = g_min_task_block_ticks.load(std::memory_order_acquire); + if (min_ticks == 0) min_ticks = computeMinTaskBlockTicks(); + return end_ticks > start_ticks && end_ticks - start_ticks >= min_ticks; +} diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.h b/ddprof-lib/src/main/cpp/taskBlockRecorder.h new file mode 100644 index 0000000000..600e0b5e1a --- /dev/null +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.h @@ -0,0 +1,81 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _TASK_BLOCK_RECORDER_H +#define _TASK_BLOCK_RECORDER_H + +#include "context.h" +#include "counters.h" +#include "event.h" +#include "profiler.h" +#include "tsc.h" + +void initializeTaskBlockDurationThreshold(); +bool exceedsMinTaskBlockDuration(u64 start_ticks, u64 end_ticks); + +class TaskBlockActivity { + private: + Profiler* _profiler; + bool _active; + bool _owns_activity; + + public: + explicit TaskBlockActivity(bool already_active = false) + : _profiler(Profiler::instance()), + _active(already_active || _profiler->tryEnterTaskBlockActivity()), + _owns_activity(!already_active && _active) { + if (!_active) Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + } + + ~TaskBlockActivity() { + if (_owns_activity) _profiler->leaveTaskBlockActivity(); + } + + bool active() const { return _active; } +}; + +static inline bool taskBlockPassesBasicEligibility(u64 start_ticks, u64 end_ticks, + const Context& ctx) { + if (ctx.spanId != 0) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + return false; + } + if (!exceedsMinTaskBlockDuration(start_ticks, end_ticks)) { + Counters::increment(TASK_BLOCK_SKIPPED_TOO_SHORT); + return false; + } + return true; +} + +static inline bool recordTaskBlockIfEligible( + int tid, jthread thread, int start_depth, u64 start_ticks, u64 end_ticks, + const Context& ctx, u64 blocker, u64 unblocking_span_id, + OSThreadState observed_state, bool activity_already_held = false) { + TaskBlockActivity activity(activity_already_held); + if (!activity.active() || + !taskBlockPassesBasicEligibility(start_ticks, end_ticks, ctx)) { + return false; + } + TaskBlockEvent event{}; + event._start = start_ticks; + event._end = end_ticks; + event._blocker = blocker; + event._unblockingSpanId = unblocking_span_id; + event._ctx = ctx; + event._observedBlockingState = observed_state; + Profiler::TaskBlockRecordResult result = + Profiler::instance()->recordTaskBlock(tid, thread, start_depth, &event); + if (result == Profiler::TaskBlockRecordResult::RECORDED) { + Counters::increment(TASK_BLOCK_EMITTED); + return true; + } + Counters::increment( + result == Profiler::TaskBlockRecordResult::STACK_CAPTURE_FAILED + ? TASK_BLOCK_STACK_CAPTURE_FAILED + : TASK_BLOCK_RECORD_FAILED); + return false; +} + +#endif // _TASK_BLOCK_RECORDER_H diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index 42279ff227..bd36d33fea 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -33,6 +33,15 @@ ThreadFilter::ShardHead ThreadFilter::_free_heads[ThreadFilter::kShardCount] {}; +#ifdef UNIT_TEST +std::atomic + ThreadFilter::_block_run_publish_observer{nullptr}; + +void ThreadFilter::setBlockRunPublishObserverForTest(BlockRunPublishObserver observer) { + _block_run_publish_observer.store(observer, std::memory_order_release); +} +#endif + ThreadFilter::ThreadFilter() : _enabled(false), _registry_active(false), _all_threads(false) { // Initialize chunk pointers to null (lazy allocation) for (int i = 0; i < kMaxChunks; ++i) { @@ -467,7 +476,8 @@ void ThreadFilter::clearActive() { continue; } - for (auto& slot : chunk->slots) { + for (int slot_idx = 0; slot_idx < kChunkSize; ++slot_idx) { + Slot& slot = chunk->slots[slot_idx]; slot.exitContextWindow(); slot.clearActiveBlockRun(OSThreadState::UNKNOWN); } @@ -480,20 +490,27 @@ void ThreadFilter::resetSlotRunState(SlotID slot_id) { int slot_idx = slot_id & kChunkMask; ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (chunk != nullptr) { - // Clear stale suppression state so a new thread in this slot cannot inherit - // its predecessor's active block or once-per-run sampled marker. + // Clear stale suppression state so a new thread in this slot cannot + // inherit its predecessor's active block. chunk->slots[slot_idx].clearActiveBlockRun(OSThreadState::UNKNOWN); } } u64 ThreadFilter::enterBlockedRun(SlotID slot_id, OSThreadState state, BlockRunOwner owner) { + if (state == OSThreadState::UNKNOWN) return 0; Slot* s = slotForId(slot_id); if (s != nullptr) { - u32 generation = 0; - if (!s->trySetActiveBlockRun(state, owner, &generation, allThreads())) { + u64 generation = 0; + if (!s->tryPrepareActiveBlockRun(owner, &generation, allThreads())) { return 0; } + s->publishActiveBlockRun(state); +#ifdef UNIT_TEST + BlockRunPublishObserver observer = + _block_run_publish_observer.load(std::memory_order_acquire); + if (observer != nullptr) observer(this, slot_id); +#endif return encodeBlockRunToken(slot_id, generation); } return 0; @@ -506,15 +523,53 @@ void ThreadFilter::exitBlockedRun(SlotID slot_id) { } } -bool ThreadFilter::exitBlockedRun(SlotID slot_id, u32 generation) { +bool ThreadFilter::exitBlockedRun(SlotID slot_id, u64 generation) { + Slot* s = slotForId(slot_id); + if (s == nullptr || generation == 0 || + s->activeBlockState() == OSThreadState::UNKNOWN || + s->activeBlockOwner() == BlockRunOwner::NONE || + s->blockGeneration() != generation) { + return false; + } + s->clearActiveBlockRun(OSThreadState::RUNNABLE); + return true; +} + +bool ThreadFilter::snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, + BlockRunSnapshot* snapshot) { Slot* s = slotForId(slot_id); - if (s == nullptr || generation == 0 || s->blockGeneration() != generation) { + if (s == nullptr || generation == 0 || + s->activeBlockState() == OSThreadState::UNKNOWN || + s->activeBlockOwner() == BlockRunOwner::NONE || + s->blockGeneration() != generation) { return false; } + if (snapshot != nullptr) *snapshot = s->snapshotBlockRun(); s->clearActiveBlockRun(OSThreadState::RUNNABLE); return true; } +BlockRunSnapshot ThreadFilter::snapshotBlockedRun(SlotID slot_id) const { + Slot* s = slotForId(slot_id); + return s == nullptr ? BlockRunSnapshot{} : s->snapshotBlockRun(); +} + +bool ThreadFilter::isOwnedBlockSuppressionCandidate( + const ThreadEntry& entry) const { + Slot* slot = entry.slot; + if (slot == nullptr || slot->nativeTid() != entry.tid || + slot->lifecycleGeneration() != entry.lifecycle_generation || + (allThreads() && !slot->activeBlockRemainedOutsideContextWindow()) || + slot->activeBlockOwner() == BlockRunOwner::NONE) { + return false; + } + OSThreadState state = slot->activeBlockState(); + return state == OSThreadState::SLEEPING || + state == OSThreadState::CONDVAR_WAIT || + state == OSThreadState::OBJECT_WAIT || + state == OSThreadState::MONITOR_WAIT; +} + void ThreadFilter::init(const char* filter, bool all_threads) { // Legacy/context scope uses any non-empty filter value (including "0") as // an explicit context allow-list. All-thread scope keeps registry identity diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index c20473df3e..451b8a83bd 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -35,6 +35,14 @@ enum class BlockRunOwner : int { NATIVE = 3, }; +struct BlockRunSnapshot { + OSThreadState active_state{OSThreadState::UNKNOWN}; + BlockRunOwner owner{BlockRunOwner::NONE}; + u64 generation{0}; + bool active{false}; + bool context_eligible{false}; +}; + class ThreadFilter { public: using SlotID = int; @@ -45,6 +53,11 @@ class ThreadFilter { static constexpr int kChunkMask = kChunkSize - 1; static constexpr int kMaxThreads = 2048; static constexpr int kMaxChunks = (kMaxThreads + kChunkSize - 1) / kChunkSize; // = 8 chunks + static constexpr int kBlockRunSlotBits = 11; + static constexpr u64 kBlockRunSlotMask = (1ULL << kBlockRunSlotBits) - 1; + static constexpr u64 kMaxBlockRunGeneration = UINT64_MAX >> kBlockRunSlotBits; + static_assert(kMaxThreads == (1 << kBlockRunSlotBits), + "block-run token slot bits must cover every ThreadFilter slot"); // High-performance free list using Treiber stack, 64 shards static constexpr int kFreeListSize = kMaxThreads; static constexpr int kShardCount = 64; // power-of-two for fast modulo @@ -64,23 +77,16 @@ class ThreadFilter { std::atomic context_window_state{0}; std::atomic lifecycle_generation{0}; std::atomic active_block_context_epoch{0}; + std::atomic block_generation{0}; std::atomic unowned_blocked_state{OSThreadState::UNKNOWN}; // The native TID and context-window membership are deliberately // independent. A live thread remains ordinarily sampleable in all-thread // scope while addThread()/removeThread() changes only the context marker. std::atomic tid{-1}; std::atomic active_block_owner{static_cast(BlockRunOwner::NONE)}; - std::atomic block_generation{0}; - // Wall-clock once-per-run suppression state. The signal handler records the - // last sampled blocked state; the signal handler and timer thread read it to - // suppress duplicate samples, while lifecycle/block-exit paths reset it. - // Release/acquire on sampled_this_run pairs with relaxed last_sampled_state, - // following the standard flag+payload pattern. - std::atomic last_sampled_state{OSThreadState::UNKNOWN}; // 4 bytes // Set by explicit block enter/exit hooks. It lets the timer skip sending a signal // only while instrumentation still owns a suppressible blocking interval. std::atomic active_block_state{OSThreadState::UNKNOWN}; - std::atomic sampled_this_run{false}; char padding[2 * DEFAULT_CACHE_LINE_SIZE - sizeof(std::atomic) - sizeof(std::atomic) @@ -91,10 +97,9 @@ class ThreadFilter { - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) - - sizeof(std::atomic) - - sizeof(std::atomic) + - sizeof(std::atomic) - sizeof(std::atomic) - - sizeof(std::atomic)]; + - sizeof(std::atomic)]; inline int nativeTid() const { return tid.load(std::memory_order_acquire); @@ -131,21 +136,6 @@ class ThreadFilter { return false; } - inline bool sampledThisRun() const { - return sampled_this_run.load(std::memory_order_acquire); - } - inline OSThreadState lastSampledState() const { - return last_sampled_state.load(std::memory_order_relaxed); - } - inline void markSampledThisRun(OSThreadState state) { - last_sampled_state.store(state, std::memory_order_relaxed); - sampled_this_run.store(true, std::memory_order_release); - } - inline void resetSampledRun(OSThreadState state) { - resetUnownedBlockedSampling(); - last_sampled_state.store(state, std::memory_order_relaxed); - sampled_this_run.store(false, std::memory_order_release); - } inline OSThreadState activeBlockState() const { return active_block_state.load(std::memory_order_acquire); } @@ -155,7 +145,7 @@ class ThreadFilter { inline BlockRunOwner activeBlockOwner() const { return static_cast(active_block_owner.load(std::memory_order_acquire)); } - inline u32 blockGeneration() const { + inline u64 blockGeneration() const { return block_generation.load(std::memory_order_acquire); } inline void resetUnownedBlockedSampling() { @@ -195,9 +185,9 @@ class ThreadFilter { } return true; } - inline bool trySetActiveBlockRun(OSThreadState state, BlockRunOwner owner, - u32* generation_out, - bool outside_context_required) { + inline bool tryPrepareActiveBlockRun(BlockRunOwner owner, + u64* generation_out, + bool outside_context_required) { u64 context_state = context_window_state.load(std::memory_order_acquire); if (outside_context_required && (context_state & 1) != 0) { return false; @@ -214,18 +204,25 @@ class ThreadFilter { std::memory_order_release); return false; } - u32 generation = block_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + u64 generation = block_generation.load(std::memory_order_relaxed); + if (generation == kMaxBlockRunGeneration) { + active_block_owner.store(static_cast(BlockRunOwner::NONE), + std::memory_order_release); + return false; + } + generation++; + block_generation.store(generation, std::memory_order_relaxed); active_block_context_epoch.store(context_state >> 1, std::memory_order_relaxed); resetUnownedBlockedSampling(); - last_sampled_state.store(OSThreadState::UNKNOWN, std::memory_order_relaxed); - sampled_this_run.store(false, std::memory_order_relaxed); - active_block_state.store(state, std::memory_order_release); *generation_out = generation; return true; } - inline void clearActiveBlockRun(OSThreadState state) { + inline void publishActiveBlockRun(OSThreadState state) { + active_block_state.store(state, std::memory_order_release); + } + inline void clearActiveBlockRun(OSThreadState) { active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_release); - resetSampledRun(state); + resetUnownedBlockedSampling(); active_block_owner.store(static_cast(BlockRunOwner::NONE), std::memory_order_release); } inline bool activeBlockRemainedOutsideContextWindow() const { @@ -234,12 +231,20 @@ class ThreadFilter { active_block_context_epoch.load(std::memory_order_acquire) == (context_state >> 1); } + inline BlockRunSnapshot snapshotBlockRun() const { + BlockRunSnapshot snapshot; + snapshot.active_state = activeBlockState(); + snapshot.owner = activeBlockOwner(); + snapshot.generation = blockGeneration(); + snapshot.active = snapshot.owner != BlockRunOwner::NONE && + snapshot.active_state != OSThreadState::UNKNOWN; + snapshot.context_eligible = activeBlockRemainedOutsideContextWindow(); + return snapshot; + } }; static_assert(sizeof(Slot) == 2 * DEFAULT_CACHE_LINE_SIZE, "Slot must be exactly two cache lines"); static_assert(std::atomic::is_always_lock_free, "Slot OSThreadState fields must be lock-free for signal-handler safety"); - static_assert(std::atomic::is_always_lock_free, - "Slot::sampled_this_run must be lock-free for signal-handler safety"); ThreadFilter(); ~ThreadFilter(); @@ -266,18 +271,34 @@ class ThreadFilter { // lifecycles must use the generation-checked overload so they cannot clear // another owner. void exitBlockedRun(SlotID slot_id); - bool exitBlockedRun(SlotID slot_id, u32 generation); + bool exitBlockedRun(SlotID slot_id, u64 generation); + bool snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, + BlockRunSnapshot* snapshot); + BlockRunSnapshot snapshotBlockedRun(SlotID slot_id) const; + bool isOwnedBlockSuppressionCandidate(const ThreadEntry& entry) const; - static inline u64 encodeBlockRunToken(SlotID slot_id, u32 generation) { - return (static_cast(generation) << 32) | static_cast(slot_id + 1); + static inline u64 encodeBlockRunToken(SlotID slot_id, u64 generation) { + return (generation << kBlockRunSlotBits) | static_cast(slot_id); } static inline SlotID tokenSlotId(u64 token) { - return static_cast(static_cast(token) - 1); + return static_cast(token & kBlockRunSlotMask); + } + static inline u64 tokenGeneration(u64 token) { + return token >> kBlockRunSlotBits; } - static inline u32 tokenGeneration(u64 token) { - return static_cast(token >> 32); + static inline bool decodeBlockRunToken(u64 token, SlotID& slot_id, + u64& generation) { + if (token == 0) return false; + slot_id = tokenSlotId(token); + generation = tokenGeneration(token); + return generation != 0; } +#ifdef UNIT_TEST + using BlockRunPublishObserver = void (*)(ThreadFilter*, SlotID); + static void setBlockRunPublishObserverForTest(BlockRunPublishObserver observer); +#endif + // Returns nullptr if slot_id is invalid or its chunk has not been allocated. inline Slot* slotForId(SlotID slot_id) const { if (slot_id < 0) return nullptr; @@ -292,6 +313,7 @@ class ThreadFilter { void unregisterThread(SlotID slot_id); void unregisterThreadByTid(int tid); Slot* lookupByTid(int tid) const; + SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } private: @@ -324,6 +346,10 @@ class ThreadFilter { // writers prevents duplicate TID mappings while lookups remain lock-free. std::mutex _registry_lock; +#ifdef UNIT_TEST + static std::atomic _block_run_publish_observer; +#endif + // Cache line aligned to prevent false sharing between shards struct alignas(DEFAULT_CACHE_LINE_SIZE) ShardHead { std::atomic head{-1}; }; static ShardHead _free_heads[kShardCount]; // one cache-line each diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index e8b9a30c46..9482c1c24d 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -80,6 +80,9 @@ class ProfiledThread : public ThreadLocalData { u32 _recording_epoch; u32 _misc_flags; u64 _park_block_token; + u64 _task_block_start_ticks; + u64 _task_block_token; + Context _task_block_context; int _filter_slot_id; // Slot ID for thread filtering uint8_t _init_window; // Countdown for JVM thread init race window (PROF-13072) uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) @@ -100,7 +103,9 @@ class ProfiledThread : public ThreadLocalData { ProfiledThread(int tid) : ThreadLocalData(), _jmp_buf(nullptr), _pc(0), _sp(0), _span_id(0), _crash_depth(0), _tid(tid), _cpu_epoch(0), _wall_epoch(0), _call_trace_id(0), _recording_epoch(0), _misc_flags(0), - _park_block_token(0), _filter_slot_id(-1), _init_window(0), + _park_block_token(0), _task_block_start_ticks(0), + _task_block_token(0), _task_block_context{}, _filter_slot_id(-1), + _init_window(0), _signal_depth(0), _otel_ctx_initialized(false), _otel_ctx_record{}, _otel_tag_encodings{}, _otel_local_root_span_id(0) {}; @@ -307,6 +312,23 @@ class ProfiledThread : public ThreadLocalData { _park_block_token = token; } + inline bool taskBlockEnter(u64 token, u64 start_ticks, + const Context& context) { + if (token == 0 || _task_block_token != 0) return false; + _task_block_start_ticks = start_ticks; + _task_block_context = context; + _task_block_token = token; + return true; + } + + inline bool taskBlockExit(u64 token, u64& start_ticks, Context& context) { + if (token == 0 || _task_block_token != token) return false; + start_ticks = _task_block_start_ticks; + context = _task_block_context; + _task_block_token = 0; + return true; + } + // Returns false if the thread was not parked (idempotent). inline bool parkExit(u64 &park_block_token) { u32 prev = __atomic_fetch_and(&_misc_flags, ~FLAG_PARKED, __ATOMIC_ACQ_REL); diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 5336235eac..7d165f0cd6 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -59,8 +59,6 @@ static inline bool hasKnownActiveTraceContext(ProfiledThread* thread) { struct WallPrecheckResult { bool suppress = false; - ThreadFilter::Slot* slot_to_arm = nullptr; - OSThreadState state_to_arm = OSThreadState::UNKNOWN; OSThreadState observed_state = OSThreadState::UNKNOWN; bool observed_state_valid = false; ThreadFilter::Slot* unowned_weight_slot = nullptr; @@ -71,32 +69,14 @@ struct WallPrecheckResult { OSThreadState flush_state = OSThreadState::UNKNOWN; }; -static inline void incrementSuppressedSampledRun() { - Counters::increment(WC_SIGNAL_SUPPRESSED_SAMPLED_RUN); - WallClockCounters::incrementSuppressedSampledRun(); +static inline void incrementSuppressedOwnedBlock() { + Counters::increment(WC_SIGNAL_SUPPRESSED_OWNED_BLOCK); + WallClockCounters::incrementSuppressedOwnedBlock(); } -static inline bool blockContextEligible(ThreadFilter::Slot* slot) { - ThreadFilter* registry = Profiler::instance()->threadFilter(); - return !registry->allThreads() || slot->activeBlockRemainedOutsideContextWindow(); -} - -static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { - ThreadFilter::Slot* slot = entry.slot; - if (slot == nullptr) { - return false; - } - if (slot->nativeTid() != entry.tid || - slot->lifecycleGeneration() != entry.lifecycle_generation || - !blockContextEligible(slot)) { - return false; - } - OSThreadState block_state = slot->activeBlockState(); - if (slot->activeBlockOwner() != BlockRunOwner::NONE && - isPrecheckSuppressionState(block_state) && - slot->sampledThisRun() && - block_state == slot->lastSampledState()) { - incrementSuppressedSampledRun(); +static inline bool suppressOwnedBlock(const ThreadEntry& entry) { + if (Profiler::instance()->threadFilter()->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); return true; } return false; @@ -109,10 +89,12 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - ThreadFilter::Slot* slot = - Profiler::instance()->threadFilter()->slotForId(current->filterSlotId()); + ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + ThreadFilter::Slot* slot = thread_filter->slotForId(slot_id); if (slot == nullptr) { - slot = Profiler::instance()->threadFilter()->lookupByTid(current->tid()); + slot_id = thread_filter->slotIdByTid(current->tid()); + slot = thread_filter->slotForId(slot_id); } if (slot == nullptr) { return result; @@ -122,24 +104,10 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - OSThreadState active_block_state = slot->activeBlockState(); - BlockRunOwner active_block_owner = slot->activeBlockOwner(); - bool has_owned_block = - active_block_owner != BlockRunOwner::NONE && - isPrecheckSuppressionState(active_block_state) && - blockContextEligible(slot); - if (has_owned_block) { - if (slot->sampledThisRun() && - active_block_state == slot->lastSampledState()) { - incrementSuppressedSampledRun(); - result.suppress = true; - return result; - } - // Arm only after the MethodSample has been successfully recorded. If the - // JFR write is skipped due to lock contention, the next signal must retry - // instead of losing the only stack for this blocked run. - result.slot_to_arm = slot; - result.state_to_arm = active_block_state; + ThreadEntry entry{current->tid(), slot, slot->lifecycleGeneration()}; + if (thread_filter->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); + result.suppress = true; return result; } @@ -173,9 +141,6 @@ static inline void finishWallPrecheck(const WallPrecheckResult& precheck, recorded_call_trace_id, precheck.observed_state); } } - if (recorded && precheck.slot_to_arm != nullptr) { - precheck.slot_to_arm->markSampledThisRun(precheck.state_to_arm); - } } static inline void recordDeferredWallSample(int tid, u64 call_trace_id, @@ -395,18 +360,16 @@ void WallClockASGCT::timerLoop() { } if (_precheck) { entries.erase(std::remove_if(entries.begin(), entries.end(), - suppressAlreadySampledBlock), + suppressOwnedBlock), entries.end()); } }; auto sampleThreads = [&](ThreadEntry entry, int& num_failures, int& threads_already_exited, int& permission_denied) { - // Timer-thread fast path (wallprecheck=true): skip the kernel IPI entirely - // only when an explicit lifecycle hook still owns an already-sampled blocked - // run. Raw OS thread state is intentionally not used here because the timer - // thread cannot prove run boundaries for the target thread. - if (_precheck && suppressAlreadySampledBlock(entry)) { + // Skip the signal while an explicit lifecycle hook owns a suppressible + // blocked interval. Raw OS thread state cannot prove interval boundaries. + if (_precheck && suppressOwnedBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { @@ -507,8 +470,8 @@ void WallClockJvmti::signalHandler(int signo, siginfo_t *siginfo, // Pass nullptr ucontext so the JVM uses safepoint-based stack walking. // Passing the signal-frame PC causes the extension to reject samples where // the thread is currently inside JVM-internal (non-Java) code. - // JVMTI-delegated samples carry a correlation_id, not a call_trace_id, so - // unowned tail flushing remains limited to the ASGCT wall engine. + // JVMTI-delegated samples carry no call_trace_id, so unowned tail flushing + // remains limited to the ASGCT wall engine. bool recorded = Profiler::instance()->recordSampleDelegated( nullptr, last_sample, tid, BCI_WALL, &event); finishWallPrecheck(precheck, recorded); @@ -546,14 +509,14 @@ void WallClockJvmti::timerLoop() { } if (_precheck) { entries.erase(std::remove_if(entries.begin(), entries.end(), - suppressAlreadySampledBlock), + suppressOwnedBlock), entries.end()); } }; auto sampleThreads = [&](ThreadEntry entry, int &num_failures, int &threads_already_exited, int &permission_denied) { - if (_precheck && suppressAlreadySampledBlock(entry)) { + if (_precheck && suppressOwnedBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index eeeb49f393..57adcad531 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -95,7 +95,7 @@ class BaseWallClock : public Engine { epoch.updateNumSamplableThreads(threads.size()); epoch.updateNumFailedSamples(num_failures); epoch.updateNumSuccessfulSamples(num_successful_samples); - epoch.addNumSuppressedSampledRun(WallClockCounters::drainSuppressedSampledRun()); + epoch.addNumSuppressedOwnedBlock(WallClockCounters::drainSuppressedOwnedBlock()); epoch.updateNumExitedThreads(threads_already_exited); epoch.updateNumPermissionDenied(permission_denied); u64 endTime = TSC::ticks(); diff --git a/ddprof-lib/src/main/cpp/wallClockCounters.h b/ddprof-lib/src/main/cpp/wallClockCounters.h index f295ce87a8..72435b1046 100644 --- a/ddprof-lib/src/main/cpp/wallClockCounters.h +++ b/ddprof-lib/src/main/cpp/wallClockCounters.h @@ -17,19 +17,19 @@ static_assert(std::atomic::is_always_lock_free, // increment is counted in either the current drain or a later one. class WallClockCounters { private: - inline static std::atomic _suppressed_sampled_run{0}; + inline static std::atomic _suppressed_owned_block{0}; public: - static void incrementSuppressedSampledRun() { - _suppressed_sampled_run.fetch_add(1, std::memory_order_relaxed); + static void incrementSuppressedOwnedBlock() { + _suppressed_owned_block.fetch_add(1, std::memory_order_relaxed); } - static u64 drainSuppressedSampledRun() { - return (u64)_suppressed_sampled_run.exchange(0, std::memory_order_acq_rel); + static u64 drainSuppressedOwnedBlock() { + return (u64)_suppressed_owned_block.exchange(0, std::memory_order_acq_rel); } static void reset() { - _suppressed_sampled_run.store(0, std::memory_order_relaxed); + _suppressed_owned_block.store(0, std::memory_order_relaxed); } }; 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 4cbdf53e16..267f2b02eb 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -505,8 +505,8 @@ public void recordQueueTime(long startTicks, } /** - * Internal hook called before {@code LockSupport.park}. This remains package-scoped - * until PR2 wires production TaskBlock instrumentation. + * Internal hook called before {@code LockSupport.park}. Park-specific TaskBlock + * production is intentionally separate from the public paired API. */ void parkEnter() { parkEnter0(); @@ -514,7 +514,7 @@ void parkEnter() { /** * Internal hook called after {@code LockSupport.park}. Clears the parked flag. - * {@code blocker} and {@code unblockingSpanId} are reserved for PR2 TaskBlock use. + * {@code blocker} and {@code unblockingSpanId} are reserved for park instrumentation. */ void parkExit(long blocker, long unblockingSpanId) { parkExit0(blocker, unblockingSpanId); @@ -522,7 +522,7 @@ void parkExit(long blocker, long unblockingSpanId) { /** * Internal hook marking the current platform thread as entering an explicitly instrumented - * blocked interval. This is not public API in this PR; production TaskBlock wiring lands in PR2. + * blocked interval. The public paired API is {@link #beginTaskBlock(int)}. * * @param state native {@code OSThreadState} value for the blocked interval; * currently only {@code SLEEPING} is armed @@ -539,6 +539,34 @@ void blockExit(long token) { blockExit0(token); } + /** + * Begins an explicitly instrumented blocking interval on the current platform thread. + * The returned token is bound to the current thread and must be passed to + * {@link #endTaskBlock(long, long, long)}. + * + * @param state native {@code OSThreadState} value; currently only {@code SLEEPING} is accepted + * @return an opaque token, or {@code 0} when the interval could not be armed or the current + * thread is virtual; any non-zero value, including a negative value, is valid + */ + public long beginTaskBlock(int state) { + return beginTaskBlock0(Thread.currentThread(), state); + } + + /** + * Ends a blocking interval created by {@link #beginTaskBlock(int)} and records its + * {@code TaskBlock} event when it satisfies the profiler's eligibility rules. + * Lifecycle state is cleared even when no event is recorded. + * + * @param token opaque token returned by {@link #beginTaskBlock(int)}; {@code 0} is the only + * invalid sentinel + * @param blocker stable identifier describing the blocking resource + * @param unblockingSpanId span responsible for unblocking the interval, or {@code 0} + * @return {@code true} when an event was recorded; virtual threads always return {@code false} + */ + public boolean endTaskBlock(long token, long blocker, long unblockingSpanId) { + return endTaskBlock0(Thread.currentThread(), token, blocker, unblockingSpanId); + } + /** * Get the ticks for the current thread. * @return ticks @@ -602,6 +630,11 @@ private static ThreadContext initializeThreadContext() { private static native void blockExit0(long token); + private static native long beginTaskBlock0(Thread thread, int state); + + private static native boolean endTaskBlock0(Thread thread, long token, long blocker, + long unblockingSpanId); + private static native long currentTicks0(); private static native long tscFrequency0(); diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 3a1af6dade..20b2fe00e0 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -37,6 +37,83 @@ class JvmSupportGlobalSetup { }; static JvmSupportGlobalSetup jvm_support_global_setup; +class JvmSupportThreadClassificationTest : public ::testing::Test { +protected: + using JniFunction = void (JNICALL*)(); + + static constexpr int GET_VERSION_INDEX = 4; + static constexpr int IS_VIRTUAL_THREAD_INDEX = 234; + static constexpr int FUNCTION_TABLE_SIZE = IS_VIRTUAL_THREAD_INDEX + 1; + + inline static jint jni_version; + inline static jboolean virtual_thread; + inline static int is_virtual_thread_calls; + inline static jobject last_thread; + + JniFunction function_table[FUNCTION_TABLE_SIZE]{}; + JNIEnv jni{}; + _jobject thread_object; + jthread thread = &thread_object; + + static jint JNICALL getVersion(JNIEnv*) { return jni_version; } + + static jboolean JNICALL isVirtualThread(JNIEnv*, jobject candidate) { + is_virtual_thread_calls++; + last_thread = candidate; + return virtual_thread; + } + + void SetUp() override { + jni_version = 0x00150000; + virtual_thread = JNI_FALSE; + is_virtual_thread_calls = 0; + last_thread = nullptr; + function_table[GET_VERSION_INDEX] = + reinterpret_cast(&getVersion); + function_table[IS_VIRTUAL_THREAD_INDEX] = + reinterpret_cast(&isVirtualThread); + jni.functions = + reinterpret_cast(function_table); + } +}; + +TEST_F(JvmSupportThreadClassificationTest, NullInputsFailClosed) { + EXPECT_FALSE(JVMSupport::isPlatformThread(nullptr, thread)); + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, nullptr)); +} + +TEST_F(JvmSupportThreadClassificationTest, InvalidJniVersionFailsClosed) { + jni_version = 0; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportThreadClassificationTest, PreJni21ThreadIsPlatform) { + jni_version = 0x000a0000; + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni21PlatformThreadIsAccepted) { + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni21VirtualThreadIsRejected) { + virtual_thread = JNI_TRUE; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, MissingJni21FunctionFailsClosed) { + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + // --------------------------------------------------------------------------- // VMTestAccessor — friend of VM, lets tests swap VM::_jvmti for a mock so // JVMThread::currentThreadSlow() can be exercised without a live JVM. diff --git a/ddprof-lib/src/test/cpp/park_state_ut.cpp b/ddprof-lib/src/test/cpp/park_state_ut.cpp index 69f3792424..5119c1d9c3 100644 --- a/ddprof-lib/src/test/cpp/park_state_ut.cpp +++ b/ddprof-lib/src/test/cpp/park_state_ut.cpp @@ -39,7 +39,7 @@ TestProfiledThread testThread(int tid) { } // namespace -// Tests cover FLAG_PARKED lifecycle and the once-per-run slot filter state transitions. +// Tests cover FLAG_PARKED lifecycle and owned-block slot state transitions. // The slot state lives in ThreadFilter process-lifetime storage so the wall-clock // timer can read it without dereferencing per-thread objects from another thread. @@ -137,48 +137,18 @@ TEST(ProfiledThreadParkStateTest, ParkExitReturnsZeroTokenWhenBlockRunWasNotArme EXPECT_EQ(0ULL, park_block_token); } -TEST(WallClockOncePerRunFilterTest, SlotStateTransitions) { +TEST(WallClockOwnedBlockFilterTest, SlotStateTransitions) { ThreadFilter::Slot slot; - EXPECT_FALSE(slot.sampledThisRun()); - EXPECT_EQ(OSThreadState::UNKNOWN, slot.lastSampledState()); EXPECT_EQ(OSThreadState::UNKNOWN, slot.activeBlockState()); - // First signal: arm. slot.setActiveBlockState(OSThreadState::SLEEPING); - slot.markSampledThisRun(OSThreadState::SLEEPING); - EXPECT_TRUE(slot.sampledThisRun()); - EXPECT_EQ(OSThreadState::SLEEPING, slot.lastSampledState()); EXPECT_EQ(OSThreadState::SLEEPING, slot.activeBlockState()); - // Same state again: suppress (flag + state both match). - EXPECT_TRUE(slot.sampledThisRun() && - OSThreadState::SLEEPING == slot.lastSampledState()); - EXPECT_TRUE(slot.sampledThisRun() && - slot.activeBlockState() == slot.lastSampledState()); - - // Transition within skip set (SLEEPING -> CONDVAR_WAIT): state mismatch -> re-arm. slot.setActiveBlockState(OSThreadState::CONDVAR_WAIT); - EXPECT_FALSE(slot.sampledThisRun() && - OSThreadState::CONDVAR_WAIT == slot.lastSampledState()); - slot.markSampledThisRun(OSThreadState::CONDVAR_WAIT); - EXPECT_TRUE(slot.sampledThisRun()); - EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot.lastSampledState()); - EXPECT_TRUE(slot.sampledThisRun() && - slot.activeBlockState() == slot.lastSampledState()); - - // Leave skip set: reset -> next blocked entry re-arms. + EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot.activeBlockState()); slot.setActiveBlockState(OSThreadState::UNKNOWN); - slot.resetSampledRun(OSThreadState::RUNNABLE); - EXPECT_FALSE(slot.sampledThisRun()); - EXPECT_EQ(OSThreadState::RUNNABLE, slot.lastSampledState()); EXPECT_EQ(OSThreadState::UNKNOWN, slot.activeBlockState()); - - slot.setActiveBlockState(OSThreadState::SLEEPING); - slot.markSampledThisRun(OSThreadState::SLEEPING); - EXPECT_TRUE(slot.sampledThisRun()); - EXPECT_EQ(OSThreadState::SLEEPING, slot.lastSampledState()); - EXPECT_EQ(OSThreadState::SLEEPING, slot.activeBlockState()); } TEST(WallClockOncePerRunFilterTest, UnownedBlockedFallbackCarriesWeight) { @@ -332,33 +302,20 @@ TEST(WallClockOncePerRunFilterTest, FilterHelpersManageActiveBlockState) { ASSERT_NE(nullptr, slot); EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot->activeBlockState()); - slot->markSampledThisRun(OSThreadState::CONDVAR_WAIT); - EXPECT_TRUE(slot->sampledThisRun()); - EXPECT_TRUE(slot->sampledThisRun() && - slot->activeBlockState() == slot->lastSampledState()); - filter.exitBlockedRun(slot_id); EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); - EXPECT_FALSE(slot->sampledThisRun()); - EXPECT_EQ(OSThreadState::RUNNABLE, slot->lastSampledState()); } -// Slot reuse: stale armed state from the previous owner must be cleared before -// the new thread takes the slot (ThreadFilter::resetSlotRunState does this). -TEST(WallClockOncePerRunFilterTest, ResetClearsArmedFlagOnSlotReuse) { +TEST(WallClockOncePerRunFilterTest, ResetClearsOwnedBlockOnSlotReuse) { ThreadFilter filter; filter.init("1"); ThreadFilter::SlotID slot_id = filter.registerThread(); filter.enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT); ThreadFilter::Slot *slot = filter.slotForId(slot_id); ASSERT_NE(nullptr, slot); - slot->markSampledThisRun(OSThreadState::CONDVAR_WAIT); - EXPECT_TRUE(slot->sampledThisRun()); EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot->activeBlockState()); filter.resetSlotRunState(slot_id); - EXPECT_FALSE(slot->sampledThisRun()); - EXPECT_EQ(OSThreadState::UNKNOWN, slot->lastSampledState()); EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); } diff --git a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp new file mode 100644 index 0000000000..a307068eb2 --- /dev/null +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -0,0 +1,182 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "counters.h" +#include "profiler.h" +#include "taskBlockRecorder.h" +#include "tsc.h" + +#include +#include +#include + +namespace { + +std::atomic g_record_result{ + Profiler::TaskBlockRecordResult::RECORDED}; +std::atomic g_record_calls{0}; + +Profiler::TaskBlockRecordResult recordTaskBlockForTest( + int tid, jthread thread, int start_depth, TaskBlockEvent* event) { + g_record_calls.fetch_add(1, std::memory_order_relaxed); + return g_record_result.load(std::memory_order_acquire); +} + +u64 minEligibleEndTicks(u64 start_ticks) { + u64 low = start_ticks + 1; + u64 high = low; + while (!exceedsMinTaskBlockDuration(start_ticks, high)) { + high = start_ticks + ((high - start_ticks) * 2); + } + while (low < high) { + u64 mid = low + ((high - low) / 2); + if (exceedsMinTaskBlockDuration(start_ticks, mid)) { + high = mid; + } else { + low = mid + 1; + } + } + return low; +} + +class TaskBlockRecorderTest : public ::testing::Test { +protected: + void SetUp() override { + Counters::reset(); + initializeTaskBlockDurationThreshold(); + g_record_result.store(Profiler::TaskBlockRecordResult::RECORDED, + std::memory_order_release); + g_record_calls.store(0, std::memory_order_relaxed); + Profiler::setTaskBlockRecordOverrideForTest(recordTaskBlockForTest); + } + + void TearDown() override { + Profiler::setTaskBlockRecordOverrideForTest(nullptr); + Counters::reset(); + } +}; + +} // namespace + +TEST_F(TaskBlockRecorderTest, TraceContextIsRejectedBeforeDuration) { + Context context{}; + context.spanId = 123; + + EXPECT_FALSE(taskBlockPassesBasicEligibility(100, 100, context)); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_SKIPPED_TRACE_CONTEXT)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_SKIPPED_TOO_SHORT)); +} + +TEST_F(TaskBlockRecorderTest, DurationThresholdIncludesExactBoundary) { + Context context{}; + u64 start_ticks = TSC::ticks(); + u64 passing_end = minEligibleEndTicks(start_ticks); + + EXPECT_TRUE(taskBlockPassesBasicEligibility( + start_ticks, passing_end, context)); + EXPECT_FALSE(taskBlockPassesBasicEligibility( + start_ticks, passing_end - 1, context)); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_SKIPPED_TOO_SHORT)); +} + +TEST_F(TaskBlockRecorderTest, RotationRejectsNewActivity) { + Profiler* profiler = Profiler::instance(); + profiler->beginTaskBlockRotationForTest(); + + EXPECT_FALSE(profiler->tryEnterTaskBlockActivity()); + TaskBlockActivity activity; + EXPECT_FALSE(activity.active()); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); + + profiler->endTaskBlockRotationForTest(); + ASSERT_TRUE(profiler->tryEnterTaskBlockActivity()); + profiler->leaveTaskBlockActivity(); +} + +TEST_F(TaskBlockRecorderTest, RotationWaitsForInflightActivity) { + Profiler* profiler = Profiler::instance(); + ASSERT_TRUE(profiler->tryEnterTaskBlockActivity()); + ASSERT_EQ(1, profiler->taskBlockInflightForTest()); + + std::atomic rotation_returned{false}; + std::thread rotation([&]() { + profiler->beginTaskBlockRotationForTest(); + rotation_returned.store(true, std::memory_order_release); + profiler->endTaskBlockRotationForTest(); + }); + + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!profiler->taskBlockRotationActiveForTest() && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::yield(); + } + + bool rotation_active = profiler->taskBlockRotationActiveForTest(); + EXPECT_TRUE(rotation_active); + EXPECT_EQ(1, profiler->taskBlockInflightForTest()); + EXPECT_FALSE(rotation_returned.load(std::memory_order_acquire)); + if (rotation_active) { + bool entered = profiler->tryEnterTaskBlockActivity(); + EXPECT_FALSE(entered); + if (entered) profiler->leaveTaskBlockActivity(); + } + + profiler->leaveTaskBlockActivity(); + rotation.join(); + + EXPECT_TRUE(rotation_returned.load(std::memory_order_acquire)); + EXPECT_FALSE(profiler->taskBlockRotationActiveForTest()); + EXPECT_EQ(0, profiler->taskBlockInflightForTest()); + ASSERT_TRUE(profiler->tryEnterTaskBlockActivity()); + profiler->leaveTaskBlockActivity(); +} + +TEST_F(TaskBlockRecorderTest, StackCaptureFailureIsCountedAndActivityReleased) { + g_record_result.store(Profiler::TaskBlockRecordResult::STACK_CAPTURE_FAILED, + std::memory_order_release); + Context context{}; + u64 start_ticks = TSC::ticks(); + u64 end_ticks = minEligibleEndTicks(start_ticks); + + EXPECT_FALSE(recordTaskBlockIfEligible( + 123, nullptr, 0, start_ticks, end_ticks, context, 0, 0, + OSThreadState::SLEEPING)); + + EXPECT_EQ(1, g_record_calls.load(std::memory_order_relaxed)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_EMITTED)); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_STACK_CAPTURE_FAILED)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_RECORD_FAILED)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_SKIPPED_TRACE_CONTEXT)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_SKIPPED_TOO_SHORT)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); + EXPECT_EQ(0, Profiler::instance()->taskBlockInflightForTest()); + ASSERT_TRUE(Profiler::instance()->tryEnterTaskBlockActivity()); + Profiler::instance()->leaveTaskBlockActivity(); +} + +TEST_F(TaskBlockRecorderTest, RecordFailureIsCountedAndActivityReleased) { + g_record_result.store(Profiler::TaskBlockRecordResult::RECORD_FAILED, + std::memory_order_release); + Context context{}; + u64 start_ticks = TSC::ticks(); + u64 end_ticks = minEligibleEndTicks(start_ticks); + + EXPECT_FALSE(recordTaskBlockIfEligible( + 123, nullptr, 0, start_ticks, end_ticks, context, 0, 0, + OSThreadState::SLEEPING)); + + EXPECT_EQ(1, g_record_calls.load(std::memory_order_relaxed)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_EMITTED)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_STACK_CAPTURE_FAILED)); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_RECORD_FAILED)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_SKIPPED_TRACE_CONTEXT)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_SKIPPED_TOO_SHORT)); + EXPECT_EQ(0, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); + EXPECT_EQ(0, Profiler::instance()->taskBlockInflightForTest()); + ASSERT_TRUE(Profiler::instance()->tryEnterTaskBlockActivity()); + Profiler::instance()->leaveTaskBlockActivity(); +} diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 701aef1044..be05b057ee 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -485,7 +485,6 @@ TEST_F(ThreadFilterTest, ClearActiveDropsPreviousRecordingMembership) { filter->enterBlockedRun(stale_slot, OSThreadState::SLEEPING); ThreadFilter::Slot *stale = filter->slotForId(stale_slot); ASSERT_NE(nullptr, stale); - stale->markSampledThisRun(OSThreadState::SLEEPING); filter->clearActive(); @@ -494,8 +493,6 @@ TEST_F(ThreadFilterTest, ClearActiveDropsPreviousRecordingMembership) { EXPECT_TRUE(collected_tids.empty()); EXPECT_FALSE(filter->accept(stale_slot)); EXPECT_FALSE(filter->accept(current_slot)); - EXPECT_FALSE(stale->sampledThisRun()); - EXPECT_EQ(OSThreadState::UNKNOWN, stale->lastSampledState()); EXPECT_EQ(OSThreadState::UNKNOWN, stale->activeBlockState()); filter->add(2222, current_slot); @@ -543,15 +540,121 @@ TEST_F(ThreadFilterTest, NewGenerationRejectsStaleToken) { EXPECT_TRUE(filter->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(current_token))); } -TEST_F(ThreadFilterTest, TokenRoundTripPreservesHighGenerationBit) { +TEST_F(ThreadFilterTest, TokenRoundTripPreservesNegativeJavaLongBitPattern) { ThreadFilter::SlotID slot_id = 7; - u32 generation = 0x80000001u; + u64 generation = 1ULL << 52; u64 token = ThreadFilter::encodeBlockRunToken(slot_id, generation); int64_t java_token = static_cast(token); EXPECT_LT(java_token, 0); - EXPECT_EQ(slot_id, ThreadFilter::tokenSlotId(static_cast(java_token))); - EXPECT_EQ(generation, ThreadFilter::tokenGeneration(static_cast(java_token))); + ThreadFilter::SlotID decoded_slot = -1; + u64 decoded_generation = 0; + EXPECT_TRUE(ThreadFilter::decodeBlockRunToken( + static_cast(java_token), decoded_slot, decoded_generation)); + EXPECT_EQ(slot_id, decoded_slot); + EXPECT_EQ(generation, decoded_generation); +} + +TEST_F(ThreadFilterTest, TokenRoundTripCoversSlotAndGenerationBoundaries) { + ThreadFilter::SlotID decoded_slot = -1; + u64 decoded_generation = 0; + + u64 first = ThreadFilter::encodeBlockRunToken(0, 1); + ASSERT_TRUE(ThreadFilter::decodeBlockRunToken( + first, decoded_slot, decoded_generation)); + EXPECT_EQ(0, decoded_slot); + EXPECT_EQ(1ULL, decoded_generation); + + u64 last = ThreadFilter::encodeBlockRunToken( + ThreadFilter::kMaxThreads - 1, ThreadFilter::kMaxBlockRunGeneration); + EXPECT_EQ(UINT64_MAX, last); + ASSERT_TRUE(ThreadFilter::decodeBlockRunToken( + last, decoded_slot, decoded_generation)); + EXPECT_EQ(ThreadFilter::kMaxThreads - 1, decoded_slot); + EXPECT_EQ(ThreadFilter::kMaxBlockRunGeneration, decoded_generation); + + EXPECT_FALSE(ThreadFilter::decodeBlockRunToken( + 0, decoded_slot, decoded_generation)); + EXPECT_FALSE(ThreadFilter::decodeBlockRunToken( + static_cast(ThreadFilter::kMaxThreads - 1), + decoded_slot, decoded_generation)); +} + +TEST_F(ThreadFilterTest, SaturatedGenerationRefusesEntryWithoutClaimingSlot) { + int slot_id = filter->registerThread(); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + slot->block_generation.store(ThreadFilter::kMaxBlockRunGeneration - 1, + std::memory_order_release); + + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, token); + EXPECT_EQ(ThreadFilter::kMaxBlockRunGeneration, + ThreadFilter::tokenGeneration(token)); + ASSERT_TRUE(filter->exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + + EXPECT_EQ(0ULL, filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING)); + EXPECT_EQ(0ULL, filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING)); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(ThreadFilter::kMaxBlockRunGeneration, slot->blockGeneration()); +} + +TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { + int slot_id = filter->registerThread(); + ASSERT_GE(slot_id, 0); + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, token); + + BlockRunSnapshot snapshot = filter->snapshotBlockedRun(slot_id); + EXPECT_TRUE(snapshot.active); + EXPECT_EQ(OSThreadState::SLEEPING, snapshot.active_state); + EXPECT_EQ(BlockRunOwner::JAVA, snapshot.owner); + EXPECT_EQ(ThreadFilter::tokenGeneration(token), snapshot.generation); + + ASSERT_TRUE(filter->snapshotAndExitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token), &snapshot)); + EXPECT_FALSE(filter->snapshotBlockedRun(slot_id).active); +} + +TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { + filter->init(nullptr, true); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, token); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( + {1235, slot, slot->lifecycleGeneration()})); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( + {1234, slot, slot->lifecycleGeneration() + 1})); + + ASSERT_TRUE(filter->exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + +TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { + filter->init(nullptr, true); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_NE(0ULL, filter->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT)); + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + ASSERT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + filter->add(1234, slot_id); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); + filter->remove(slot_id); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } TEST(ThreadRegistryTest, AllThreadScopeSeparatesRegistrationFromContextWindow) { diff --git a/ddprof-lib/src/test/cpp/wallClockCounters_ut.cpp b/ddprof-lib/src/test/cpp/wallClockCounters_ut.cpp index c908b84fc7..7a6482d45a 100644 --- a/ddprof-lib/src/test/cpp/wallClockCounters_ut.cpp +++ b/ddprof-lib/src/test/cpp/wallClockCounters_ut.cpp @@ -18,25 +18,25 @@ class WallClockCountersTest : public ::testing::Test { } }; -TEST_F(WallClockCountersTest, DrainReturnsAndClearsSuppressedSampledRun) { - WallClockCounters::incrementSuppressedSampledRun(); - WallClockCounters::incrementSuppressedSampledRun(); +TEST_F(WallClockCountersTest, DrainReturnsAndClearsSuppressedOwnedBlock) { + WallClockCounters::incrementSuppressedOwnedBlock(); + WallClockCounters::incrementSuppressedOwnedBlock(); - EXPECT_EQ(2ULL, WallClockCounters::drainSuppressedSampledRun()); - EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedSampledRun()); + EXPECT_EQ(2ULL, WallClockCounters::drainSuppressedOwnedBlock()); + EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedOwnedBlock()); } -TEST_F(WallClockCountersTest, ResetClearsPendingSuppressedSampledRun) { - WallClockCounters::incrementSuppressedSampledRun(); +TEST_F(WallClockCountersTest, ResetClearsPendingSuppressedOwnedBlock) { + WallClockCounters::incrementSuppressedOwnedBlock(); WallClockCounters::reset(); - EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedSampledRun()); + EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedOwnedBlock()); } TEST_F(WallClockCountersTest, ResetIsIdempotent) { WallClockCounters::reset(); WallClockCounters::reset(); - EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedSampledRun()); + EXPECT_EQ(0ULL, WallClockCounters::drainSuppressedOwnedBlock()); } 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..37d80c0e5f 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -11,19 +11,25 @@ import java.lang.reflect.Modifier; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; public class JavaProfilerApiSurfaceTest { @Test - public void ownedBlockHooksAreNotPublicApiBeforeTaskBlockInstrumentation() throws Exception { + public void taskBlockApiIsPublicButInternalHooksRemainPackageScoped() throws Exception { assertNotPublic(JavaProfiler.class.getDeclaredMethod("parkEnter")); assertNotPublic(JavaProfiler.class.getDeclaredMethod( "parkExit", long.class, long.class)); assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockEnter", int.class)); assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockExit", long.class)); + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("beginTaskBlock", int.class).getModifiers())); + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("endTaskBlock", long.class, long.class, long.class) + .getModifiers())); } private static void assertNotPublic(Method method) { assertFalse(Modifier.isPublic(method.getModifiers()), - method.getName() + " must remain non-public until PR2 wires TaskBlock instrumentation"); + method.getName() + " is an internal instrumentation hook"); } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java new file mode 100644 index 0000000000..905a52fcba --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java @@ -0,0 +1,228 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.nio.file.Files; +import java.nio.file.Path; +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** End-to-end coverage for the paired synchronous TaskBlock API. */ +public class JavaProfilerTaskBlockApiTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + private static final long BLOCKER = 0x7301L; + private static final long UNBLOCKING_SPAN_ID = 0x7302L; + + @Test + public void pairedApiEmitsTaskBlockWithStack() throws Exception { + assertTrue(runEligibleBlock(BLOCKER)); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertNoAnchorFields(events); + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "JavaProfilerTaskBlockApiTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + TaskBlockAssertions.assertContains(events, 0L, 0L, BLOCKER, UNBLOCKING_SPAN_ID); + TaskBlockAssertions.assertContainsObservedState(events, "SLEEPING"); + } + + @Test + public void invalidAndNestedTokensDoNotLoseCurrentOwner() throws Exception { + AtomicBoolean recorded = new AtomicBoolean(); + runWorker(() -> { + long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + assertTrue(token != 0); + assertEquals(0L, profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + assertFalse(profiler.endTaskBlock(token + 1, BLOCKER, UNBLOCKING_SPAN_ID)); + Thread.sleep(200L); + recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); + }); + assertTrue(recorded.get()); + } + + @Test + public void tooShortIntervalStillClearsLifecycle() throws Exception { + AtomicBoolean recorded = new AtomicBoolean(true); + AtomicLong secondToken = new AtomicLong(); + runWorker(() -> { + long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); + secondToken.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + profiler.endTaskBlock(secondToken.get(), BLOCKER, UNBLOCKING_SPAN_ID); + }); + + assertFalse(recorded.get()); + assertTrue(secondToken.get() != 0); + stopProfiler(); + assertTrue(getRecordedCounterValue("task_block_skipped_too_short") > 0); + } + + @Test + public void contextWindowAdmissionAndCrossingAreEnforced() throws Exception { + AtomicLong tokenAfterWindow = new AtomicLong(); + runWorker(() -> { + profiler.addThread(); + try { + assertEquals(0L, profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + } finally { + profiler.removeThread(); + } + + long crossedToken = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + assertTrue(crossedToken != 0); + profiler.addThread(); + profiler.removeThread(); + Thread.sleep(20L); + assertFalse(profiler.endTaskBlock( + crossedToken, BLOCKER, UNBLOCKING_SPAN_ID)); + + tokenAfterWindow.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + profiler.endTaskBlock(tokenAfterWindow.get(), BLOCKER, UNBLOCKING_SPAN_ID); + }); + assertTrue(tokenAfterWindow.get() != 0, + "context rejection must still clear the prior lifecycle"); + } + + @Test + public void traceContextRejectsAtEntry() throws Exception { + AtomicLong token = new AtomicLong(-1L); + runWorker(() -> { + profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); + try { + token.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + } finally { + profiler.clearContext(); + } + }); + assertEquals(0L, token.get(), + "a traced interval must not arm timer-side suppression"); + } + + @Test + public void virtualThreadCannotMutateCarrierTaskBlockState() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = + Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + AtomicLong token = new AtomicLong(-1L); + Thread virtual = (Thread) startVirtualThread.invoke(null, (Runnable) () -> + token.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING))); + virtual.join(5_000L); + assertFalse(virtual.isAlive()); + assertEquals(0L, token.get()); + + AtomicLong platformToken = new AtomicLong(); + runWorker(() -> { + platformToken.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + profiler.endTaskBlock(platformToken.get(), BLOCKER, UNBLOCKING_SPAN_ID); + }); + assertTrue(platformToken.get() != 0, + "virtual-thread rejection must not strand carrier ownership"); + } + + @Test + public void liveDumpDoesNotRequireAnEntrySample() throws Exception { + CountDownLatch armed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean recorded = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + long before = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + Thread worker = new Thread(() -> { + try { + long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + assertTrue(token != 0); + armed.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-live-dump"); + + worker.start(); + assertTrue(armed.await(5, TimeUnit.SECONDS)); + waitForCounterAbove("wc_signals_suppressed_owned_block", before, 5_000L); + Path snapshot = Files.createTempFile("taskblock-live-dump-", ".jfr"); + try { + dump(snapshot); + } finally { + Files.deleteIfExists(snapshot); + } + release.countDown(); + worker.join(5_000L); + assertFalse(worker.isAlive()); + if (error.get() != null) throw new AssertionError(error.get()); + assertTrue(recorded.get()); + + stopProfiler(); + TaskBlockAssertions.assertContainsStackTrace(verifyEvents("datadog.TaskBlock")); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all,wallprecheck=true"; + } + + private boolean runEligibleBlock(long blocker) throws Exception { + AtomicBoolean result = new AtomicBoolean(); + runWorker(() -> { + long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + if (token == 0) throw new AssertionError("interval was not armed"); + Thread.sleep(200L); + result.set(profiler.endTaskBlock(token, blocker, UNBLOCKING_SPAN_ID)); + }); + return result.get(); + } + + private void runWorker(ThrowingRunnable action) throws Exception { + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + action.run(); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-paired-api"); + worker.start(); + worker.join(5_000L); + assertFalse(worker.isAlive()); + if (error.get() != null) throw new AssertionError(error.get()); + } + + private void waitForCounterAbove(String name, long baseline, long timeoutMillis) + throws Exception { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (System.nanoTime() < deadline) { + if (profiler.getDebugCounters().getOrDefault(name, 0L) > baseline) return; + Thread.sleep(10L); + } + throw new AssertionError("Counter did not increase: " + name); + } + + @FunctionalInterface + private interface ThrowingRunnable { + void run() throws Exception; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java new file mode 100644 index 0000000000..6a50edbce7 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java @@ -0,0 +1,26 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** Verifies that TaskBlock does not change legacy/context wall-clock scope. */ +public class JavaProfilerTaskBlockDisabledTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + + @Test + public void pairedApiIsInactiveOutsideAllThreadScope() { + assertEquals(0L, profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=context,wallprecheck=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java index 3582cf74d1..f057f3db14 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java @@ -22,9 +22,9 @@ /** * Measures the theoretical upper bound on {@code SIGVTALRM} suppression by running with - * {@code wallprecheck=false} and classifying sample states. The once-per-run filter + * {@code wallprecheck=false} and classifying sample states. Lifecycle ownership * ({@code wallprecheck=true}) suppresses {@code SLEEPING}, {@code CONDVAR_WAIT}, and - * {@code OBJECT_WAIT} after the entry sample; {@code RUNNABLE} is not skipped. Monitor + * suppresses {@code OBJECT_WAIT}; {@code RUNNABLE} is not skipped. Monitor * contention ({@code MONITOR_WAIT}) is also suppressible when monitor hooks identify the blocked * interval. */ @@ -43,23 +43,20 @@ public void compareSuppressionRates() throws Exception { AtomicBoolean stop = new AtomicBoolean(false); Object monitor = new Object(); - // SLEEPING / CONDVAR_WAIT — suppressed by once-per-run filter + // SLEEPING / CONDVAR_WAIT — suppressible with lifecycle ownership Thread sleeping = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} }, EFFICIENCY_SLEEPING); - // CONDVAR_WAIT — suppressed by once-per-run filter + // CONDVAR_WAIT — suppressible with lifecycle ownership Thread parked = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); LockSupport.parkNanos(10_000_000_000L); }, EFFICIENCY_PARKED); - // OBJECT_WAIT — suppressed by the once-per-run filter. + // OBJECT_WAIT — suppressible with lifecycle ownership. Thread waiting = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); synchronized (monitor) { try { monitor.wait(10_000); } catch (InterruptedException ignored) {} @@ -68,7 +65,6 @@ public void compareSuppressionRates() throws Exception { // RUNNABLE — not skipped Thread working = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); long x = 0; while (!stop.get()) { x++; } @@ -205,10 +201,7 @@ public void realisticServiceWorkload() throws Exception { AtomicInteger threadIndex = new AtomicInteger(0); ExecutorService pool = Executors.newFixedThreadPool(POOL_SIZE, r -> { - Thread t = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); - r.run(); - }); + Thread t = new Thread(r); t.setName("realistic-pool-" + threadIndex.incrementAndGet()); t.setDaemon(true); return t; @@ -222,7 +215,6 @@ public void realisticServiceWorkload() throws Exception { Thread.sleep(50); Thread scheduler = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); while (!stop.get()) { try { Thread.sleep(SCHEDULE_INTERVAL_MS); @@ -241,7 +233,6 @@ public void realisticServiceWorkload() throws Exception { scheduler.start(); Thread hotThread = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); long x = 0; while (!stop.get()) { x++; } }, "realistic-hot"); @@ -264,8 +255,16 @@ public void realisticServiceWorkload() throws Exception { for (IItemIterable batch : events) { IMemberAccessor stackAccessor = JdkAttributes.STACK_TRACE_STRING.getAccessor(batch.getType()); - if (stackAccessor == null) continue; + IMemberAccessor threadNameAccessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(batch.getType()); + if (stackAccessor == null || threadNameAccessor == null) continue; for (IItem item : batch) { + String threadName = threadNameAccessor.getMember(item); + if (threadName == null || (!threadName.startsWith("realistic-pool-") + && !"realistic-scheduler".equals(threadName) + && !"realistic-hot".equals(threadName))) { + continue; + } String stack = stackAccessor.getMember(item); if (stack == null) { otherSamples++; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java index 43d840b4a4..778b14acaf 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java @@ -16,7 +16,6 @@ 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.Aggregators; import org.openjdk.jmc.common.unit.IQuantity; import org.openjdk.jmc.common.unit.UnitLookup; import org.openjdk.jmc.flightrecorder.jdk.JdkAttributes; @@ -30,8 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Verifies once-per-run signal suppression ({@code wallprecheck=true}): a sleeping thread - * should produce a handful of {@code MethodSample} events (entry + boundary jitter), not ~300. + * Verifies lifecycle-owned signal suppression ({@code wallprecheck=true}): a sleeping thread + * should produce at most boundary-race {@code MethodSample} events, not ~300. * Requires JDK 11+ — JDK 8 HotSpot reports inconsistent OSThread states for sleep. */ public class PrecheckTest extends AbstractProfilerTest { @@ -49,7 +48,6 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); leaveClearedInitializedContext(); - registerCurrentThreadForWallClockProfiling(); long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); assertTrue(token != 0, "Expected native blockEnter to arm SLEEPING state"); @@ -61,17 +59,16 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); - // Explicitly owned once-per-run filter: entry signal emits, subsequent signals are - // suppressed until blockExit clears the owned run. + long sampleCount = samplesForThread(Thread.currentThread().getName()); + // Lifecycle ownership suppresses deliberate signals from blockEnter until blockExit. + // A few boundary-race samples remain possible. assertTrue(sampleCount < 10, "Expected nearly no MethodSample events for a sleeping thread with wallprecheck=true, got: " + sampleCount); Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { - assertTrue(counters.get("wc_signals_suppressed_sampled_run") > 0, - "wc_signals_suppressed_sampled_run should be > 0 for a 300 ms Thread.sleep()"); + if (counters.containsKey("wc_signals_suppressed_owned_block")) { + assertTrue(counters.get("wc_signals_suppressed_owned_block") > 0, + "wc_signals_suppressed_owned_block should be > 0 for a 300 ms Thread.sleep()"); } } @@ -80,16 +77,19 @@ public void unownedSleepingThreadIsNotExactOncePerRunSuppressed() throws Excepti Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); leaveClearedInitializedContext(); - registerCurrentThreadForWallClockProfiling(); Thread.sleep(300); stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); - assertTrue(sampleCount >= 10, - "Unowned Thread.sleep must not be exact once-per-run suppressed; got: " + sampleCount); + long sampleCount = samplesForThread(Thread.currentThread().getName()); + assertTrue(sampleCount > 0, + "Unowned Thread.sleep must remain sampled; got: " + sampleCount); + Map counters = profiler.getDebugCounters(); + assertTrue(counters.getOrDefault("wc_unowned_blocked_recorded", 0L) > 0, + "Expected the weighted unowned-block fallback to record samples"); + assertTrue(counters.getOrDefault("wc_unowned_blocked_suppressed", 0L) > 0, + "Expected the weighted unowned-block fallback to suppress intermediate samples"); } @Test @@ -98,7 +98,6 @@ public void unownedSleepingTailWeightIsPreserved() throws Exception { Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); Thread sleeper = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); try { for (int i = 0; i < TAIL_WEIGHT_ITERATIONS; i++) { Thread.sleep(TAIL_WEIGHT_SLEEP_MILLIS); @@ -120,19 +119,19 @@ public void unownedSleepingTailWeightIsPreserved() throws Exception { WeightedSamples weightedSamples = weightedSamplesForThread(TAIL_WEIGHT_THREAD); assertTrue(weightedSamples.count > 0, "Expected MethodSample events for " + TAIL_WEIGHT_THREAD); - long expectedTailContribution = TAIL_WEIGHT_ITERATIONS; - assertTrue(weightedSamples.weight >= weightedSamples.count + expectedTailContribution, + assertTrue(weightedSamples.weight > weightedSamples.count, "Expected preserved suppressed tail weight for " + TAIL_WEIGHT_THREAD + ", count=" + weightedSamples.count - + ", weight=" + weightedSamples.weight - + ", expectedTailContribution=" + expectedTailContribution); + + ", weight=" + weightedSamples.weight); + assertTrue(profiler.getDebugCounters() + .getOrDefault("wc_unowned_blocked_suppressed", 0L) > 0, + "Expected unowned blocked samples to be suppressed and represented by weight"); } @Test public void tracedSleepingThreadIsSampled() throws InterruptedException { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); - registerCurrentThreadForWallClockProfiling(); profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); try { @@ -143,15 +142,14 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); + long sampleCount = samplesForThread(Thread.currentThread().getName()); assertTrue(sampleCount >= 10, "Expected normal MethodSample volume for traced sleep, got: " + sampleCount); Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { - assertEquals(0L, counters.get("wc_signals_suppressed_sampled_run"), - "wc_signals_suppressed_sampled_run must not increment for traced sleep"); + if (counters.containsKey("wc_signals_suppressed_owned_block")) { + assertEquals(0L, counters.get("wc_signals_suppressed_owned_block"), + "wc_signals_suppressed_owned_block must not increment for traced sleep"); } } @@ -159,16 +157,15 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { public void suppressionCounterIsZeroWhenPrecheckDisabled() throws Exception { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); - registerCurrentThreadForWallClockProfiling(); // Stop the wallprecheck=true recording started by @BeforeEach before starting a new one. stopProfiler(); Map before = profiler.getDebugCounters(); - if (!before.containsKey("wc_signals_suppressed_sampled_run")) { + if (!before.containsKey("wc_signals_suppressed_owned_block")) { return; // counter not available in this build } - long suppressedBefore = before.get("wc_signals_suppressed_sampled_run"); + long suppressedBefore = before.get("wc_signals_suppressed_owned_block"); Path recordingB = Files.createTempFile(Paths.get("/tmp/recordings"), "PrecheckTest_disabled_", ".jfr"); @@ -178,11 +175,11 @@ public void suppressionCounterIsZeroWhenPrecheckDisabled() throws Exception { profiler.stop(); long suppressedAfter = profiler.getDebugCounters() - .getOrDefault("wc_signals_suppressed_sampled_run", 0L); + .getOrDefault("wc_signals_suppressed_owned_block", 0L); Files.deleteIfExists(recordingB); assertEquals(suppressedBefore, suppressedAfter, - "wc_signals_suppressed_sampled_run must not increment when wallprecheck=false"); + "wc_signals_suppressed_owned_block must not increment when wallprecheck=false"); } /** @@ -205,6 +202,24 @@ protected String getPrecheckDisabledProfilerCommand() { return "wall=1ms,wallprecheck=false,filter=0"; } + private long samplesForThread(String threadName) { + long count = 0; + IItemCollection events = verifyEvents("datadog.MethodSample", false); + for (IItemIterable batch : events) { + IMemberAccessor threadNameAccessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(batch.getType()); + if (threadNameAccessor == null) { + continue; + } + for (IItem item : batch) { + if (threadName.equals(threadNameAccessor.getMember(item))) { + count++; + } + } + } + return count; + } + private WeightedSamples weightedSamplesForThread(String threadName) { long count = 0; long weight = 0; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java new file mode 100644 index 0000000000..b0673d1219 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java @@ -0,0 +1,126 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.HashSet; +import java.util.Set; +import org.openjdk.jmc.common.IMCFrame; +import org.openjdk.jmc.common.IMCStackTrace; +import org.openjdk.jmc.common.item.IAttribute; +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.unit.IQuantity; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openjdk.jmc.common.item.Attribute.attr; +import static org.openjdk.jmc.common.unit.UnitLookup.NUMBER; +import static org.openjdk.jmc.common.unit.UnitLookup.PLAIN_TEXT; + +/** Assertions for the synchronous {@code datadog.TaskBlock} event contract. */ +final class TaskBlockAssertions { + private static final IAttribute BLOCKER = + attr("blocker", "blocker", "Blocker Identity Hash", NUMBER); + private static final IAttribute UNBLOCKING_SPAN_ID = + attr("unblockingSpanId", "unblockingSpanId", "Unblocking Span ID", NUMBER); + private static final IAttribute ANCHOR_SAMPLE_ID = + attr("anchorSampleId", "anchorSampleId", "Anchor MethodSample ID", NUMBER); + private static final IAttribute SUPPRESSED_SAMPLE_COUNT = + attr("suppressedSampleCount", "suppressedSampleCount", "Suppressed Sample Count", NUMBER); + private static final IAttribute OBSERVED_BLOCKING_STATE = + attr("observedBlockingState", "observedBlockingState", "Observed Blocking State", PLAIN_TEXT); + private static final IAttribute CORRELATION_ID = + attr("correlationId", "correlationId", "Async Stack Trace Correlation ID", NUMBER); + + private TaskBlockAssertions() {} + + static void assertContains(IItemCollection events, long rootSpanId, long spanId, + long blocker, long unblockingSpanId) { + for (IItemIterable iterable : events) { + IMemberAccessor root = + AbstractProfilerTest.LOCAL_ROOT_SPAN_ID.getAccessor(iterable.getType()); + IMemberAccessor span = + AbstractProfilerTest.SPAN_ID.getAccessor(iterable.getType()); + IMemberAccessor blockerAccessor = + BLOCKER.getAccessor(iterable.getType()); + IMemberAccessor unblocking = + UNBLOCKING_SPAN_ID.getAccessor(iterable.getType()); + if (root == null || span == null || blockerAccessor == null || unblocking == null) continue; + for (IItem item : iterable) { + if (root.getMember(item).longValue() == rootSpanId + && span.getMember(item).longValue() == spanId + && blockerAccessor.getMember(item).longValue() == blocker + && unblocking.getMember(item).longValue() == unblockingSpanId) { + return; + } + } + } + throw new AssertionError("Expected TaskBlock blocker=" + blocker + + ", unblockingSpanId=" + unblockingSpanId); + } + + static void assertContainsObservedState(IItemCollection events, String expected) { + Set states = new HashSet<>(); + for (IItemIterable iterable : events) { + IMemberAccessor accessor = + OBSERVED_BLOCKING_STATE.getAccessor(iterable.getType()); + if (accessor == null) continue; + for (IItem item : iterable) states.add(accessor.getMember(item)); + } + assertTrue(states.contains(expected), () -> "Observed states: " + states); + } + + static void assertContainsStackTrace(IItemCollection events) { + int count = 0; + for (IItemIterable iterable : events) { + IMemberAccessor accessor = + AbstractProfilerTest.STACK_TRACE.getAccessor(iterable.getType()); + assertTrue(accessor != null, "TaskBlock must expose stackTrace"); + for (IItem item : iterable) { + IMCStackTrace stack = accessor.getMember(item); + assertTrue(stack != null && !stack.getFrames().isEmpty()); + count++; + } + } + assertTrue(count > 0, "Expected a TaskBlock with a non-empty stack"); + } + + static void assertContainsJavaType(IItemCollection events, String expected) { + for (IItemIterable iterable : events) { + IMemberAccessor accessor = + AbstractProfilerTest.STACK_TRACE.getAccessor(iterable.getType()); + if (accessor == null) continue; + for (IItem item : iterable) { + IMCStackTrace stack = accessor.getMember(item); + if (stack == null) continue; + for (IMCFrame frame : stack.getFrames()) { + if (frame.getMethod() != null + && frame.getMethod().getType() != null + && frame.getMethod().getType().getFullName().contains(expected)) { + return; + } + } + } + } + throw new AssertionError("Expected TaskBlock stack type containing " + expected); + } + + static void assertNoCorrelationId(IItemCollection events) { + for (IItemIterable iterable : events) { + assertNull(CORRELATION_ID.getAccessor(iterable.getType())); + } + } + + static void assertNoAnchorFields(IItemCollection events) { + for (IItemIterable iterable : events) { + assertNull(ANCHOR_SAMPLE_ID.getAccessor(iterable.getType())); + assertNull(SUPPRESSED_SAMPLE_COUNT.getAccessor(iterable.getType())); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java index a2caa1b8a5..79f9e29c38 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java @@ -24,7 +24,7 @@ import java.util.concurrent.atomic.AtomicBoolean; /** - * Verifies once-per-run suppression ({@code wallprecheck=true}) with a mix of sleeping, + * Verifies lifecycle-owned suppression ({@code wallprecheck=true}) with a mix of sleeping, * parked, and runnable threads. */ public class WallclockMitigationsCombinedTest extends AbstractProfilerTest { @@ -118,10 +118,10 @@ public void precheckAndParkSuppressionWorkTogether() throws Exception { // Sleeping thread's suppression counter must have incremented. Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { + if (counters.containsKey("wc_signals_suppressed_owned_block")) { assertTrue( - counters.get("wc_signals_suppressed_sampled_run") > 0, - "Expected once-per-run suppression counter to increase"); + counters.get("wc_signals_suppressed_owned_block") > 0, + "Expected owned-block suppression counter to increase"); } } From 7b73ddb0b2f15d26a07ce0f4185c3e464bb8dd97 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 17:46:49 +0200 Subject: [PATCH 04/10] test: scope precheck workloads to all threads --- .../profiler/wallclock/JvmtiBasedPrecheckTest.java | 4 ++-- .../profiler/wallclock/PrecheckEfficiencyTest.java | 4 +++- .../com/datadoghq/profiler/wallclock/PrecheckTest.java | 7 +++++-- 3 files changed, 10 insertions(+), 5 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java index 5190e7da61..22a2926d18 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java @@ -54,11 +54,11 @@ protected void withTestAssumptions() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=true,jvmtistacks=true"; } @Override protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallprecheck=false,filter=0,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=false,jvmtistacks=true"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java index f057f3db14..5dc3607b50 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java @@ -308,6 +308,8 @@ public void realisticServiceWorkload() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms"; + // The workload deliberately has no tracing context, so its samples + // require the explicit all-thread wall-clock scope. + return "wall=1ms,wallscope=all"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java index 778b14acaf..025ca31913 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java @@ -195,11 +195,14 @@ private void leaveClearedInitializedContext() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + // This suite verifies sampling and suppression for threads outside a + // tracing-context window. Keep that population in scope explicitly; + // the production default remains wallscope=context. + return "wall=1ms,wallscope=all,wallprecheck=true"; } protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallprecheck=false,filter=0"; + return "wall=1ms,wallscope=all,wallprecheck=false"; } private long samplesForThread(String threadName) { From a5102743926b20ce2a8437b9bff3dbbed875f9ed Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 22:27:13 +0200 Subject: [PATCH 05/10] fix: restrict block suppression to all-thread scope --- ddprof-lib/src/main/cpp/threadFilter.cpp | 4 +-- ddprof-lib/src/main/cpp/wallClock.cpp | 5 +++- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 14 +++++++++++ .../WallclockMitigationsCombinedTest.java | 25 ++++++++----------- 4 files changed, 31 insertions(+), 17 deletions(-) diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index bd36d33fea..660941de40 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -557,9 +557,9 @@ BlockRunSnapshot ThreadFilter::snapshotBlockedRun(SlotID slot_id) const { bool ThreadFilter::isOwnedBlockSuppressionCandidate( const ThreadEntry& entry) const { Slot* slot = entry.slot; - if (slot == nullptr || slot->nativeTid() != entry.tid || + if (!allThreads() || slot == nullptr || slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation || - (allThreads() && !slot->activeBlockRemainedOutsideContextWindow()) || + !slot->activeBlockRemainedOutsideContextWindow() || slot->activeBlockOwner() == BlockRunOwner::NONE) { return false; } diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 7d165f0cd6..777a43a08f 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -100,7 +100,10 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - if (Profiler::instance()->threadFilter()->allThreads() && slot->inContextWindow()) { + // TaskBlock replaces signals only for threads that all-thread wall-clock + // profiling observes outside the tracing context window. Context-scoped + // profiling must continue sampling its selected threads normally. + if (!thread_filter->allThreads() || slot->inContextWindow()) { return result; } diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index be05b057ee..888465fdc7 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -640,6 +640,20 @@ TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } +TEST_F(ThreadFilterTest, ContextScopeNeverSuppressesOwnedBlock) { + filter->init("0", false); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + filter->add(1234, slot_id); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_NE(0ULL, filter->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT)); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { filter->init(nullptr, true); int slot_id = filter->registerThread(1234); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java index 79f9e29c38..bbaf518b5a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java @@ -5,6 +5,7 @@ package com.datadoghq.profiler.wallclock; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadoghq.profiler.AbstractProfilerTest; @@ -23,15 +24,12 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; -/** - * Verifies lifecycle-owned suppression ({@code wallprecheck=true}) with a mix of sleeping, - * parked, and runnable threads. - */ +/** Verifies that {@code wallprecheck=true} does not suppress context-scoped threads. */ public class WallclockMitigationsCombinedTest extends AbstractProfilerTest { private static final int OSTHREAD_STATE_SLEEPING = 7; @Test - public void precheckAndParkSuppressionWorkTogether() throws Exception { + public void contextScopedThreadsRemainSampled() throws Exception { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue( Platform.isJavaVersionAtLeast(11), @@ -39,6 +37,8 @@ public void precheckAndParkSuppressionWorkTogether() throws Exception { CountDownLatch ready = new CountDownLatch(3); AtomicBoolean stop = new AtomicBoolean(false); + long suppressedBefore = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); Thread sleeping = new Thread( @@ -109,20 +109,17 @@ public void precheckAndParkSuppressionWorkTogether() throws Exception { long parkedSamples = samplesByThread.getOrDefault("combined-parked", 0L); long runnableSamples = samplesByThread.getOrDefault("combined-runnable", 0L); - assertTrue(sleepingSamples < 10, - "Expected nearly no samples from owned sleeping thread, got: " + sleepingSamples); + assertTrue(sleepingSamples > 0, + "Expected samples from context-scoped sleeping thread, got: " + sleepingSamples); assertTrue(parkedSamples > 0, "Expected samples from traced parked thread, got: " + parkedSamples); assertTrue(runnableSamples > 0, "Expected samples from runnable thread, got: " + runnableSamples); - // Sleeping thread's suppression counter must have incremented. - Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_owned_block")) { - assertTrue( - counters.get("wc_signals_suppressed_owned_block") > 0, - "Expected owned-block suppression counter to increase"); - } + long suppressedAfter = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + assertEquals(suppressedBefore, suppressedAfter, + "Context-scoped blocked threads must not be signal-suppression candidates"); } @Override From 65ca5963fb5df4b3181940b8a5fe66589a30bb69 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:15:31 +0200 Subject: [PATCH 06/10] fix: preserve JVMTI frames in overlapping buffers --- ddprof-lib/src/main/cpp/frames.h | 30 ++++++++++++++++++++++++++++ ddprof-lib/src/main/cpp/profiler.cpp | 15 ++------------ ddprof-lib/src/test/cpp/frame_ut.cpp | 22 ++++++++++++++++++++ 3 files changed, 54 insertions(+), 13 deletions(-) diff --git a/ddprof-lib/src/main/cpp/frames.h b/ddprof-lib/src/main/cpp/frames.h index 15549e6e82..3ab52320ca 100644 --- a/ddprof-lib/src/main/cpp/frames.h +++ b/ddprof-lib/src/main/cpp/frames.h @@ -1,9 +1,39 @@ +/* + * Copyright 2026 Datadog, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ #ifndef _FRAMES_H #define _FRAMES_H #include +#include #include "vmEntry.h" +inline void copyJvmtiFrames(ASGCT_CallFrame *frames, + const jvmtiFrameInfo *jvmti_frames, + jint num_frames) { + // The source and destination commonly refer to the two views of the same + // CallTraceBuffer union. Read both source fields before either write. + for (jint i = 0; i < num_frames; ++i) { + jmethodID method = jvmti_frames[i].method; + jlocation location = jvmti_frames[i].location; + frames[i].method_id = method; + frames[i].bci = static_cast(location); + LP64_ONLY(frames[i].padding = 0;) + } +} + inline int makeFrame(ASGCT_CallFrame *frames, jint type, jmethodID id) { frames[0].bci = type; frames[0].method_id = id; diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index fef0d9dfb1..99ad2a8018 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -522,14 +522,7 @@ u64 Profiler::recordJVMTISample(u64 counter, int tid, jthread thread, jint event if (VM::jvmti()->GetStackTrace(thread, 0, _max_stack_depth, jvmti_frames, &num_frames) == JVMTI_ERROR_NONE && num_frames > 0) { // Convert to AsyncGetCallTrace format. // Note: jvmti_frames and frames may overlap. - for (int i = 0; i < num_frames; i++) { - jint bci = jvmti_frames[i].location; - jmethodID mid = jvmti_frames[i].method; - frames[i].method_id = mid; - frames[i].bci = bci; - // see https://github.com/async-profiler/async-profiler/pull/1090 - LP64_ONLY(frames[i].padding = 0;) - } + copyJvmtiFrames(frames, jvmti_frames, num_frames); // On JDK 21+, GetStackTrace on a virtual thread returns only the VT's // logical stack; it stops at the continuation boundary and never includes // carrier-thread frames. Without a synthetic root the trace appears @@ -767,11 +760,7 @@ Profiler::TaskBlockRecordResult Profiler::recordTaskBlock( return TaskBlockRecordResult::STACK_CAPTURE_FAILED; } - for (int i = 0; i < num_frames; ++i) { - frames[i].method_id = jvmti_frames[i].method; - frames[i].bci = jvmti_frames[i].location; - LP64_ONLY(frames[i].padding = 0;) - } + copyJvmtiFrames(frames, jvmti_frames, num_frames); u64 call_trace_id = _call_trace_storage.put(num_frames, frames, false, 1); #ifdef COUNTERS diff --git a/ddprof-lib/src/test/cpp/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index 951db75fb8..83779a8021 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -4,7 +4,9 @@ #include #include +#include #include "../../main/cpp/frame.h" +#include "../../main/cpp/frames.h" #include "../../main/cpp/gtest_crash_handler.h" // Test-only friend accessor for VM internals. It exists solely so these unit @@ -44,6 +46,26 @@ class GlobalSetup { static GlobalSetup global_setup; +TEST(CopyJvmtiFramesTest, PreservesOverlappingSourceFields) { + union { + ASGCT_CallFrame asgct[2]; + jvmtiFrameInfo jvmti[2]; + } buffer; + jmethodID first_method = + reinterpret_cast(static_cast(0x12340)); + jmethodID second_method = + reinterpret_cast(static_cast(0x56780)); + buffer.jvmti[0] = {first_method, 17}; + buffer.jvmti[1] = {second_method, 29}; + + copyJvmtiFrames(buffer.asgct, buffer.jvmti, 2); + + EXPECT_EQ(buffer.asgct[0].method_id, first_method); + EXPECT_EQ(buffer.asgct[0].bci, 17); + EXPECT_EQ(buffer.asgct[1].method_id, second_method); + EXPECT_EQ(buffer.asgct[1].bci, 29); +} + // ---- encode ---------------------------------------------------------------- TEST(FrameTypeEncodeTest, EncodedMarkerBitIsSet) { From 80d990917afb3e8ecaa67cd2a7d98a6ac7742d1e Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 11:32:21 +0200 Subject: [PATCH 07/10] test: compare precheck counter delta --- .../com/datadoghq/profiler/wallclock/PrecheckTest.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java index 025ca31913..188e135785 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java @@ -133,6 +133,7 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); + Map countersBefore = profiler.getDebugCounters(); profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); try { Thread.sleep(300); @@ -146,9 +147,11 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { assertTrue(sampleCount >= 10, "Expected normal MethodSample volume for traced sleep, got: " + sampleCount); - Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_owned_block")) { - assertEquals(0L, counters.get("wc_signals_suppressed_owned_block"), + if (countersBefore.containsKey("wc_signals_suppressed_owned_block")) { + long suppressedBefore = countersBefore.get("wc_signals_suppressed_owned_block"); + long suppressedAfter = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + assertEquals(suppressedBefore, suppressedAfter, "wc_signals_suppressed_owned_block must not increment for traced sleep"); } } From d3f95951eb4a281cff710511e698314cae20c4d6 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 11:44:48 +0200 Subject: [PATCH 08/10] feat(taskblock): add JVM blocking producers --- ddprof-lib/src/main/cpp/javaApi.cpp | 94 +++++-- ddprof-lib/src/main/cpp/profiler.cpp | 7 + ddprof-lib/src/main/cpp/profiler.h | 1 + ddprof-lib/src/main/cpp/threadLocalData.h | 96 ++++++- ddprof-lib/src/main/cpp/vmEntry.cpp | 189 +++++++++++++- ddprof-lib/src/main/cpp/vmEntry.h | 12 +- .../com/datadoghq/profiler/JavaProfiler.java | 50 +++- ddprof-lib/src/test/cpp/park_state_ut.cpp | 73 ++++++ .../profiler/JavaProfilerApiSurfaceTest.java | 10 + .../JvmtiBasedMonitorTaskBlockTest.java | 30 +++ .../JvmtiBasedParkTaskBlockTest.java | 30 +++ .../wallclock/MonitorTaskBlockTest.java | 240 ++++++++++++++++++ .../profiler/wallclock/ParkTaskBlockTest.java | 115 +++++++++ .../wallclock/TaskBlockAssertions.java | 11 + .../WallclockMitigationsCombinedTest.java | 5 +- 15 files changed, 916 insertions(+), 47 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 14850c7f04..8066aacc1b 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -69,9 +69,10 @@ class JniString { }; extern "C" DLLEXPORT jboolean JNICALL -Java_com_datadoghq_profiler_JavaProfiler_init0(JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_init0( + JNIEnv *env, jclass unused, jboolean delegateMonitorWaitEvents) { // JavaVM* has already been stored when the native library was loaded so we can pass nullptr here - return VM::initProfilerBridge(nullptr, true); + return VM::initProfilerBridge(nullptr, true, delegateMonitorWaitEvents); } extern "C" DLLEXPORT void JNICALL @@ -88,6 +89,12 @@ Java_com_datadoghq_profiler_JavaProfiler_getTid0(JNIEnv *env, jclass unused) { return OS::threadId(); } +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_monitorEventsDelegated0( + JNIEnv *env, jclass unused) { + return VM::monitorEventsDelegated(); +} + extern "C" DLLEXPORT jstring JNICALL Java_com_datadoghq_profiler_JavaProfiler_execute0(JNIEnv *env, jobject unused, jstring command) { @@ -317,40 +324,80 @@ Java_com_datadoghq_profiler_JavaProfiler_recordQueueEnd0( } extern "C" DLLEXPORT void JNICALL -Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( + JNIEnv *env, jclass unused, jthread thread) { + if (!JVMSupport::isPlatformThread(env, thread)) { + return; + } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return; } - bool first_park = current->parkEnter(); - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (first_park && tf->enabled()) { - ThreadFilter::SlotID slot_id = current->filterSlotId(); - if (slot_id >= 0) { - current->setParkBlockToken( - tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); - } + Context context = ContextApi::snapshot(); + if (!current->parkEnter(TSC::ticks(), context)) { + return; + } + + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0 && tf->allThreads()) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (context.spanId == 0 && slot_id >= 0 && + (profiler->taskBlockEnabled() || tf->enabled())) { + current->setParkBlockToken(tf->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT, BlockRunOwner::JAVA)); } } extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_parkExit0( - JNIEnv *env, jclass unused, jlong blocker, jlong unblockingSpanId) { + JNIEnv *env, jclass unused, jthread thread, jlong blocker, + jlong unblockingSpanId) { + if (!JVMSupport::isPlatformThread(env, thread)) { + return; + } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return; } + u64 start_ticks = 0; u64 park_block_token = 0; - if (!current->parkExit(park_block_token) || park_block_token == 0) { + Context context{}; + if (!current->parkExit(start_ticks, context, park_block_token) || + park_block_token == 0) { return; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { - ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); - if (current->filterSlotId() == slot_id) { - tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(park_block_token)); - } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot{}; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(park_block_token), &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; } + if (recording_enabled && exited && snapshot.context_eligible) { + recordTaskBlockIfEligible( + current->tid(), thread, 1, start_ticks, TSC::ticks(), context, + static_cast(blocker), static_cast(unblockingSpanId), + snapshot.active_state, true); + } else if (recording_enabled && exited && !snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + } + profiler->leaveTaskBlockActivity(); } static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { @@ -364,9 +411,10 @@ static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( - JNIEnv *env, jclass unused, jint state) { + JNIEnv *env, jclass unused, jthread thread, jint state) { OSThreadState decoded; - if (!decodeJavaBlockState(state, decoded)) { + if (!decodeJavaBlockState(state, decoded) || + !JVMSupport::isPlatformThread(env, thread)) { return 0; } ProfiledThread *current = ProfiledThread::current(); @@ -392,9 +440,9 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockExit0( - JNIEnv *env, jclass unused, jlong token) { + JNIEnv *env, jclass unused, jthread thread, jlong token) { u64 block_token = static_cast(token); - if (block_token == 0) { + if (block_token == 0 || !JVMSupport::isPlatformThread(env, thread)) { return; } ProfiledThread *current = ProfiledThread::current(); diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 99ad2a8018..38a1af9a88 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1641,6 +1641,9 @@ Error Profiler::start(Arguments &args, bool reset) { _task_block_enabled.store( (activated & EM_WALL) && args._wall_precheck && all_threads, std::memory_order_release); + _task_block_monitor_events_enabled = + taskBlockEnabled() && VM::nativeMonitorEventsAvailable() && + VM::setNativeMonitorEventsEnabled(true); _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1665,6 +1668,10 @@ Error Profiler::stop() { return Error("Profiler is not active"); } _task_block_enabled.store(false, std::memory_order_release); + if (_task_block_monitor_events_enabled) { + VM::setNativeMonitorEventsEnabled(false); + _task_block_monitor_events_enabled = false; + } // Order matters: disable engines first so the _enabled check inside signal // handlers will fail for any new signal delivered from now on. drain() then diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 7d5bb30b51..2de51719e4 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -120,6 +120,7 @@ class alignas(alignof(SpinLock)) Profiler { alignas(DEFAULT_CACHE_LINE_SIZE) u64 _failures[ASGCT_FAILURE_TYPES]; bool _wall_precheck = false; std::atomic _task_block_enabled{false}; + bool _task_block_monitor_events_enabled = false; std::atomic _task_block_rotation{false}; std::atomic _task_block_inflight{0}; diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 9482c1c24d..0f4ebecccc 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -43,7 +43,8 @@ class ProfiledThread : public ThreadLocalData { TYPE_MASK = TYPE_JAVA_THREAD | TYPE_NOT_JAVA_THREAD }; - static constexpr u32 FLAG_PARKED = 0x4u; // next free bit after TYPE_MASK (0x1|0x2) + static constexpr u32 FLAG_PARKED = 0x4u; + static constexpr u32 FLAG_MONITOR_BLOCKED = 0x8u; // We are allowing several levels of nesting because we can be // eg. in a crash handler when wallclock signal kicks in, @@ -79,10 +80,17 @@ class ProfiledThread : public ThreadLocalData { u64 _call_trace_id; u32 _recording_epoch; u32 _misc_flags; + u64 _park_start_ticks; u64 _park_block_token; + Context _park_context; u64 _task_block_start_ticks; u64 _task_block_token; Context _task_block_context; + u64 _monitor_start_ticks; + Context _monitor_context; + u64 _monitor_blocker; + u64 _monitor_block_token; + OSThreadState _monitor_block_state; int _filter_slot_id; // Slot ID for thread filtering uint8_t _init_window; // Countdown for JVM thread init race window (PROF-13072) uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) @@ -103,8 +111,11 @@ class ProfiledThread : public ThreadLocalData { ProfiledThread(int tid) : ThreadLocalData(), _jmp_buf(nullptr), _pc(0), _sp(0), _span_id(0), _crash_depth(0), _tid(tid), _cpu_epoch(0), _wall_epoch(0), _call_trace_id(0), _recording_epoch(0), _misc_flags(0), - _park_block_token(0), _task_block_start_ticks(0), - _task_block_token(0), _task_block_context{}, _filter_slot_id(-1), + _park_start_ticks(0), _park_block_token(0), _park_context{}, + _task_block_start_ticks(0), _task_block_token(0), _task_block_context{}, + _monitor_start_ticks(0), _monitor_context{}, _monitor_blocker(0), + _monitor_block_token(0), _monitor_block_state(OSThreadState::UNKNOWN), + _filter_slot_id(-1), _init_window(0), _signal_depth(0), _otel_ctx_initialized(false), @@ -303,11 +314,24 @@ class ProfiledThread : public ThreadLocalData { _otel_local_root_span_id = 0; } - inline bool parkEnter() { - u32 prev = __atomic_fetch_or(&_misc_flags, FLAG_PARKED, __ATOMIC_RELEASE); - return (prev & FLAG_PARKED) == 0; + inline bool parkEnter(u64 start_ticks, const Context& context) { + u32 flags = __atomic_load_n(&_misc_flags, __ATOMIC_ACQUIRE); + while ((flags & FLAG_PARKED) == 0) { + _park_start_ticks = start_ticks; + _park_context = context; + if (__atomic_compare_exchange_n(&_misc_flags, &flags, + flags | FLAG_PARKED, true, + __ATOMIC_RELEASE, __ATOMIC_ACQUIRE)) { + return true; + } + } + return false; } +#ifdef UNIT_TEST + inline bool parkEnter() { return parkEnter(0, Context{}); } +#endif + inline void setParkBlockToken(u64 token) { _park_block_token = token; } @@ -330,16 +354,74 @@ class ProfiledThread : public ThreadLocalData { } // Returns false if the thread was not parked (idempotent). - inline bool parkExit(u64 &park_block_token) { + inline bool parkExit(u64& start_ticks, Context& context, + u64& park_block_token) { u32 prev = __atomic_fetch_and(&_misc_flags, ~FLAG_PARKED, __ATOMIC_ACQ_REL); if ((prev & FLAG_PARKED) == 0) { return false; } + start_ticks = _park_start_ticks; + context = _park_context; park_block_token = _park_block_token; _park_block_token = 0; return true; } +#ifdef UNIT_TEST + inline bool parkExit(u64& park_block_token) { + u64 start_ticks = 0; + Context context{}; + return parkExit(start_ticks, context, park_block_token); + } +#endif + + // Object.wait owns its interval until MonitorWaited, including monitor + // reacquisition. A nested contention callback must not overwrite that state. + inline bool monitorEnter(u64 start_ticks, const Context& context, u64 blocker, + OSThreadState state) { + u32 flags = __atomic_load_n(&_misc_flags, __ATOMIC_ACQUIRE); + if ((flags & FLAG_MONITOR_BLOCKED) != 0) return false; + _monitor_start_ticks = start_ticks; + _monitor_context = context; + _monitor_blocker = blocker; + _monitor_block_token = 0; + _monitor_block_state = state; + __atomic_fetch_or(&_misc_flags, FLAG_MONITOR_BLOCKED, __ATOMIC_RELEASE); + return true; + } + + inline void setMonitorBlockToken(u64 token) { + _monitor_block_token = token; + } + + inline u64 monitorBlockToken() const { return _monitor_block_token; } + + inline void clearMonitorBlock() { + __atomic_fetch_and(&_misc_flags, ~FLAG_MONITOR_BLOCKED, __ATOMIC_ACQ_REL); + _monitor_block_token = 0; + _monitor_block_state = OSThreadState::UNKNOWN; + } + + inline bool monitorExit(OSThreadState expected_state, u64& start_ticks, + Context& context, u64& blocker, + u64& monitor_block_token) { + u32 flags = __atomic_load_n(&_misc_flags, __ATOMIC_ACQUIRE); + if ((flags & FLAG_MONITOR_BLOCKED) == 0 || + _monitor_block_state != expected_state) { + return false; + } + u32 prev = __atomic_fetch_and(&_misc_flags, ~FLAG_MONITOR_BLOCKED, + __ATOMIC_ACQ_REL); + if ((prev & FLAG_MONITOR_BLOCKED) == 0) return false; + start_ticks = _monitor_start_ticks; + context = _monitor_context; + blocker = _monitor_blocker; + monitor_block_token = _monitor_block_token; + _monitor_block_token = 0; + _monitor_block_state = OSThreadState::UNKNOWN; + return true; + } + Context snapshotContext(size_t numAttrs); private: diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index bcea72d07f..3cc04af51c 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -8,6 +8,7 @@ #include "vmEntry.h" #include "arguments.h" #include "context.h" +#include "context_api.h" #include "counters.h" #include "j9/j9Support.h" #include "jniHelper.h" @@ -18,6 +19,8 @@ #include "os.h" #include "profiler.h" #include "safeAccess.h" +#include "taskBlockRecorder.h" +#include "tsc.h" // Pulls in vmStructs.h plus the definitions of crashProtectionActive()/cast_to() that its inline // accessors odr-use here; the light vmStructs.h alone leaves those unresolved in assertion-enabled // builds (see the note in hotspotStackFrame_aarch64.cpp). @@ -47,6 +50,8 @@ bool VM::_hotspot = false; bool VM::_zing = false; bool VM::_can_sample_objects = false; bool VM::_can_intercept_binding = false; +bool VM::_monitor_events_delegated = false; +bool VM::_native_monitor_events_available = false; bool VM::_is_adaptive_gc_boundary_flag_set = false; jvmtiExtensionFunction VM::_request_stack_trace = nullptr; @@ -66,6 +71,139 @@ static void wakeupHandler(int signo) { // Dummy handler for interrupting syscalls } +static u64 monitorBlockerHash(jvmtiEnv *jvmti, jobject object) { + if (object == NULL) return 0; + jint hash = 0; + if (jvmti->GetObjectHashCode(object, &hash) != JVMTI_ERROR_NONE) return 0; + return static_cast(static_cast(hash)); +} + +static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, OSThreadState state) { + Profiler *profiler = Profiler::instance(); + if (!profiler->taskBlockEnabled() || + !JVMSupport::isPlatformThread(jni, thread)) { + return; + } + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return; + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + return; + } + + if (!current->monitorEnter(TSC::ticks(), context, + monitorBlockerHash(jvmti, object), state)) { + u64 token = current->monitorBlockToken(); + ThreadFilter *tf = profiler->threadFilter(); + bool current_owner = false; + if (token != 0) { + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(token); + BlockRunSnapshot snapshot = tf->snapshotBlockedRun(slot_id); + current_owner = current->filterSlotId() == slot_id && snapshot.active && + snapshot.owner == BlockRunOwner::JVMTI && + snapshot.generation == ThreadFilter::tokenGeneration(token); + } + if (current_owner) { + return; + } + current->clearMonitorBlock(); + if (!current->monitorEnter(TSC::ticks(), context, + monitorBlockerHash(jvmti, object), state)) { + return; + } + } + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (!tf->allThreads() || slot_id < 0) { + current->clearMonitorBlock(); + return; + } + u64 token = + tf->enterBlockedRun(slot_id, state, BlockRunOwner::JVMTI); + if (token == 0) { + ThreadFilter::Slot *slot = tf->slotForId(slot_id); + if (slot != nullptr && slot->inContextWindow()) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + } + current->clearMonitorBlock(); + return; + } + current->setMonitorBlockToken(token); +} + +static void monitorBlockExit(JNIEnv *jni, jthread thread, OSThreadState state) { + if (!JVMSupport::isPlatformThread(jni, thread)) return; + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return; + + u64 start_ticks = 0; + Context context{}; + u64 blocker = 0; + u64 token = 0; + if (!current->monitorExit(state, start_ticks, context, blocker, token) || + token == 0) { + return; + } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(token); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot{}; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token), &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + if (recording_enabled && exited && snapshot.context_eligible) { + recordTaskBlockIfEligible(current->tid(), thread, 0, start_ticks, + TSC::ticks(), context, blocker, 0, + snapshot.active_state, true); + } else if (recording_enabled && exited && !snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + } + profiler->leaveTaskBlockActivity(); +} + +static void JNICALL MonitorContendedEnter(jvmtiEnv *jvmti, JNIEnv *jni, + jthread thread, jobject object) { + monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::MONITOR_WAIT); +} + +static void JNICALL MonitorContendedEntered(jvmtiEnv *jvmti, JNIEnv *jni, + jthread thread, jobject object) { + monitorBlockExit(jni, thread, OSThreadState::MONITOR_WAIT); +} + +static void JNICALL MonitorWait(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, jlong timeout) { + if (!VM::monitorEventsDelegated()) { + monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::OBJECT_WAIT); + } +} + +static void JNICALL MonitorWaited(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, jboolean timed_out) { + if (!VM::monitorEventsDelegated()) { + monitorBlockExit(jni, thread, OSThreadState::OBJECT_WAIT); + } +} + static bool isVmRuntimeEntry(const char* blob_name) { return strcmp(blob_name, "_ZNK12MemAllocator8allocateEv") == 0 || strncmp(blob_name, "_Z22post_allocation_notify", 26) == 0 @@ -437,7 +575,8 @@ bool VM::initializeRequestStackTrace() { return false; } -bool VM::initProfilerBridge(JavaVM *vm, bool attach) { +bool VM::initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorEvents) { TEST_LOG("VM::initProfilerBridge"); if (!initShared(vm)) { return false; @@ -467,6 +606,8 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { _can_intercept_binding = potential_capabilities.can_generate_native_method_bind_events && HeapUsage::needsNativeBindingInterception(); + bool can_add_monitor_events = + potential_capabilities.can_generate_monitor_events; jvmtiCapabilities capabilities = {0}; capabilities.can_generate_all_class_hook_events = 1; @@ -483,11 +624,18 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { capabilities.can_get_source_file_name = 1; capabilities.can_get_line_numbers = 1; capabilities.can_generate_compiled_method_load_events = 1; - capabilities.can_generate_monitor_events = 1; + capabilities.can_generate_monitor_events = can_add_monitor_events ? 1 : 0; capabilities.can_tag_objects = 1; _jvmti->AddCapabilities(&capabilities); + jvmtiCapabilities actual_capabilities = {0}; + _jvmti->GetCapabilities(&actual_capabilities); + _native_monitor_events_available = + actual_capabilities.can_generate_monitor_events; + _monitor_events_delegated = + delegateMonitorEvents && _native_monitor_events_available; + if (_hotspot) { probeJFRRequestStackTrace(); } @@ -504,6 +652,12 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { callbacks.SampledObjectAlloc = ObjectSampler::SampledObjectAlloc; callbacks.GarbageCollectionFinish = LivenessTracker::GarbageCollectionFinish; callbacks.NativeMethodBind = VMStructs::NativeMethodBind; + if (_native_monitor_events_available) { + callbacks.MonitorContendedEnter = MonitorContendedEnter; + callbacks.MonitorContendedEntered = MonitorContendedEntered; + callbacks.MonitorWait = MonitorWait; + callbacks.MonitorWaited = MonitorWaited; + } _jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); _jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_VM_DEATH, NULL); @@ -556,6 +710,37 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { return true; } +bool VM::setNativeMonitorEventsEnabled(bool enabled) { + if (!_native_monitor_events_available) return false; + + jvmtiEventMode mode = enabled ? JVMTI_ENABLE : JVMTI_DISABLE; + jvmtiError enter = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_CONTENDED_ENTER, NULL); + jvmtiError entered = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, NULL); + jvmtiError wait = JVMTI_ERROR_NONE; + jvmtiError waited = JVMTI_ERROR_NONE; + // When Java instrumentation owns Object.wait, do not enable the native wait + // notifications at all. Disable still addresses all four events so teardown + // is complete even if ownership was configured before this initialization. + if (!enabled || !_monitor_events_delegated) { + wait = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_WAIT, NULL); + waited = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_WAITED, NULL); + } + + if (enter == JVMTI_ERROR_NONE && entered == JVMTI_ERROR_NONE && + wait == JVMTI_ERROR_NONE && waited == JVMTI_ERROR_NONE) { + return true; + } + + Log::warn("Unable to %s JVMTI monitor events: %d/%d/%d/%d", + enabled ? "enable" : "disable", enter, entered, wait, waited); + if (enabled) setNativeMonitorEventsEnabled(false); + return false; +} + // Run late initialization when JVM is ready void VM::ready(jvmtiEnv *jvmti, JNIEnv *jni) { Profiler::check_JDK_8313796_workaround(); diff --git a/ddprof-lib/src/main/cpp/vmEntry.h b/ddprof-lib/src/main/cpp/vmEntry.h index d97fa6af3a..593981ab3a 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.h +++ b/ddprof-lib/src/main/cpp/vmEntry.h @@ -147,6 +147,8 @@ class VM { static bool _zing; static bool _can_sample_objects; static bool _can_intercept_binding; + static bool _monitor_events_delegated; + static bool _native_monitor_events_available; static bool _is_adaptive_gc_boundary_flag_set; // HotSpot JFR async stack-trace extension (optional, JDK 27+). @@ -177,7 +179,8 @@ class VM { static JVM_GetManagement _getManagement; static bool initLibrary(JavaVM *vm); - static bool initProfilerBridge(JavaVM *vm, bool attach); + static bool initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorEvents = false); static jvmtiEnv *jvmti() { return _jvmti; } @@ -212,6 +215,13 @@ class VM { static bool canSampleObjects() { return _can_sample_objects; } + static bool monitorEventsDelegated() { return _monitor_events_delegated; } + + static bool nativeMonitorEventsAvailable() { + return _native_monitor_events_available; + } + static bool setNativeMonitorEventsEnabled(bool enabled); + static bool isZing() { return _zing; } static bool isUseAdaptiveGCBoundarySet() { 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 267f2b02eb..ba35e806b4 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -106,6 +106,25 @@ public static JavaProfiler getInstance(String scratchDir) throws IOException { * @param scratchDir directory where the bundled library will be exploded before linking; ignored when 'libLocation' is {@literal null} */ public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir) throws IOException { + return getInstance(libLocation, scratchDir, false); + } + + /** + * Get a {@linkplain JavaProfiler} instance with explicit monitor-event ownership. + * + *

The first successful initialization fixes this process-wide setting because the native + * profiler is a singleton. When delegation is enabled, Java instrumentation owns + * {@code Object.wait} TaskBlock intervals and native JVMTI wait callbacks are suppressed; + * native JVMTI callbacks continue to own synchronized monitor contention. + * + * @param libLocation the path to the native library to use, or {@literal null} for the bundled library + * @param scratchDir directory where the bundled library will be exploded before linking + * @param delegateMonitorWaitEvents whether Java instrumentation owns {@code Object.wait} intervals + * @return the process-wide profiler instance + * @throws IOException if the native library cannot be loaded + */ + public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir, + boolean delegateMonitorWaitEvents) throws IOException { if (instance != null) { return instance; } @@ -115,7 +134,7 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s if (!result.succeeded) { throw new IOException("Failed to load Datadog Java profiler library", result.error); } - init0(); + init0(delegateMonitorWaitEvents); instance = profiler; @@ -131,6 +150,16 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s return profiler; } + /** + * Reports whether Java instrumentation, rather than JVMTI callbacks, owns + * {@code Object.wait} TaskBlock intervals. + * + * @return {@code true} when native wait callbacks are delegated + */ + public boolean isMonitorEventsDelegated() { + return monitorEventsDelegated0(); + } + /** * Stop profiling (without dumping results) * @@ -509,7 +538,7 @@ public void recordQueueTime(long startTicks, * production is intentionally separate from the public paired API. */ void parkEnter() { - parkEnter0(); + parkEnter0(Thread.currentThread()); } /** @@ -517,7 +546,7 @@ void parkEnter() { * {@code blocker} and {@code unblockingSpanId} are reserved for park instrumentation. */ void parkExit(long blocker, long unblockingSpanId) { - parkExit0(blocker, unblockingSpanId); + parkExit0(Thread.currentThread(), blocker, unblockingSpanId); } /** @@ -529,14 +558,14 @@ void parkExit(long blocker, long unblockingSpanId) { * @return an opaque token to pass to {@link #blockExit(long)}, or 0 if no state was armed */ long blockEnter(int state) { - return blockEnter0(state); + return blockEnter0(Thread.currentThread(), state); } /** * Clears a blocked interval previously armed by {@link #blockEnter(int)}. */ void blockExit(long token) { - blockExit0(token); + blockExit0(Thread.currentThread(), token); } /** @@ -601,7 +630,7 @@ private static ThreadContext initializeThreadContext() { return new ThreadContext(buffer, metadata); } - private static native boolean init0(); + private static native boolean init0(boolean delegateMonitorWaitEvents); private native void stop0() throws IllegalStateException; private native String execute0(String command) throws IllegalArgumentException, IllegalStateException, IOException; @@ -609,6 +638,7 @@ private static ThreadContext initializeThreadContext() { private static native void filterThreadRemove0(); private static native int getTid0(); + private static native boolean monitorEventsDelegated0(); private static native boolean recordTrace0(long rootSpanId, String endpoint, String operation, int sizeLimit); @@ -622,13 +652,13 @@ private static ThreadContext initializeThreadContext() { private static native void recordQueueEnd0(long startTicks, long endTicks, String task, String scheduler, Thread origin, String queueType, int queueLength); - private static native void parkEnter0(); + private static native void parkEnter0(Thread thread); - private static native void parkExit0(long blocker, long unblockingSpanId); + private static native void parkExit0(Thread thread, long blocker, long unblockingSpanId); - private static native long blockEnter0(int state); + private static native long blockEnter0(Thread thread, int state); - private static native void blockExit0(long token); + private static native void blockExit0(Thread thread, long token); private static native long beginTaskBlock0(Thread thread, int state); diff --git a/ddprof-lib/src/test/cpp/park_state_ut.cpp b/ddprof-lib/src/test/cpp/park_state_ut.cpp index 5119c1d9c3..c5219c96a3 100644 --- a/ddprof-lib/src/test/cpp/park_state_ut.cpp +++ b/ddprof-lib/src/test/cpp/park_state_ut.cpp @@ -137,6 +137,79 @@ TEST(ProfiledThreadParkStateTest, ParkExitReturnsZeroTokenWhenBlockRunWasNotArme EXPECT_EQ(0ULL, park_block_token); } +TEST(ProfiledThreadParkStateTest, ParkExitReturnsEntrySnapshot) { + TestProfiledThread thread = testThread(12351); + Context entered{}; + entered.spanId = 17; + entered.rootSpanId = 18; + ASSERT_TRUE(thread->parkEnter(123, entered)); + thread->setParkBlockToken(456); + + u64 start_ticks = 0; + u64 token = 0; + Context exited{}; + ASSERT_TRUE(thread->parkExit(start_ticks, exited, token)); + EXPECT_EQ(123ULL, start_ticks); + EXPECT_EQ(456ULL, token); + EXPECT_EQ(17ULL, exited.spanId); + EXPECT_EQ(18ULL, exited.rootSpanId); +} + +TEST(ProfiledThreadMonitorStateTest, MatchingExitReturnsEntrySnapshot) { + TestProfiledThread thread = testThread(12352); + Context entered{}; + entered.spanId = 21; + ASSERT_TRUE(thread->monitorEnter( + 100, entered, 200, OSThreadState::MONITOR_WAIT)); + thread->setMonitorBlockToken(300); + + u64 start_ticks = 0; + u64 blocker = 0; + u64 token = 0; + Context exited{}; + ASSERT_TRUE(thread->monitorExit(OSThreadState::MONITOR_WAIT, start_ticks, + exited, blocker, token)); + EXPECT_EQ(100ULL, start_ticks); + EXPECT_EQ(200ULL, blocker); + EXPECT_EQ(300ULL, token); + EXPECT_EQ(21ULL, exited.spanId); +} + +TEST(ProfiledThreadMonitorStateTest, NestedContentionDoesNotReplaceObjectWait) { + TestProfiledThread thread = testThread(12353); + Context context{}; + ASSERT_TRUE(thread->monitorEnter( + 100, context, 200, OSThreadState::OBJECT_WAIT)); + thread->setMonitorBlockToken(300); + EXPECT_FALSE(thread->monitorEnter( + 400, context, 500, OSThreadState::MONITOR_WAIT)); + + u64 start_ticks = 0; + u64 blocker = 0; + u64 token = 0; + Context exited{}; + EXPECT_FALSE(thread->monitorExit(OSThreadState::MONITOR_WAIT, start_ticks, + exited, blocker, token)); + ASSERT_TRUE(thread->monitorExit(OSThreadState::OBJECT_WAIT, start_ticks, + exited, blocker, token)); + EXPECT_EQ(100ULL, start_ticks); + EXPECT_EQ(200ULL, blocker); + EXPECT_EQ(300ULL, token); +} + +TEST(ProfiledThreadMonitorStateTest, ClearAllowsRecoveryFromStaleState) { + TestProfiledThread thread = testThread(12354); + Context context{}; + ASSERT_TRUE(thread->monitorEnter( + 100, context, 200, OSThreadState::OBJECT_WAIT)); + thread->setMonitorBlockToken(300); + thread->clearMonitorBlock(); + + ASSERT_TRUE(thread->monitorEnter( + 400, context, 500, OSThreadState::MONITOR_WAIT)); + EXPECT_EQ(0ULL, thread->monitorBlockToken()); +} + TEST(WallClockOwnedBlockFilterTest, SlotStateTransitions) { ThreadFilter::Slot slot; 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 37d80c0e5f..54daefb50a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -13,6 +13,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +/** Locks the supported public boundary and package-scoped producer hooks. */ public class JavaProfilerApiSurfaceTest { @Test public void taskBlockApiIsPublicButInternalHooksRemainPackageScoped() throws Exception { @@ -28,6 +29,15 @@ public void taskBlockApiIsPublicButInternalHooksRemainPackageScoped() throws Exc .getModifiers())); } + @Test + public void monitorWaitOwnershipIsExplicitPublicApi() throws Exception { + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("getInstance", String.class, String.class, boolean.class) + .getModifiers())); + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("isMonitorEventsDelegated").getModifiers())); + } + private static void assertNotPublic(Method method) { assertFalse(Modifier.isPublic(method.getModifiers()), method.getName() + " is an internal instrumentation hook"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java new file mode 100644 index 0000000000..550d9d22b4 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.Platform; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; + +/** Verifies synchronous monitor production when delegated wall-clock stacks are enabled. */ +public class JvmtiBasedMonitorTaskBlockTest extends MonitorTaskBlockTest { + @Override + protected void before() { + Map counters = profiler.getDebugCounters(); + Assumptions.assumeTrue(counters.getOrDefault("jvmti_stacks_init_ok", 0L) > 0, + "HotSpot RequestStackTrace JVMTI extension is not available"); + } + + @Override + protected void withTestAssumptions() { + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java new file mode 100644 index 0000000000..d17eae2f7a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.Platform; +import java.util.Map; +import org.junit.jupiter.api.Assumptions; + +/** Verifies synchronous park production when delegated wall-clock stacks are enabled. */ +public class JvmtiBasedParkTaskBlockTest extends ParkTaskBlockTest { + @Override + protected void before() { + Map counters = profiler.getDebugCounters(); + Assumptions.assumeTrue(counters.getOrDefault("jvmti_stacks_init_ok", 0L) > 0, + "HotSpot RequestStackTrace JVMTI extension is not available"); + } + + @Override + protected void withTestAssumptions() { + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java new file mode 100644 index 0000000000..b2c8bf6086 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies TaskBlock production from native JVMTI monitor callbacks. */ +public class MonitorTaskBlockTest extends AbstractProfilerTest { + @Test + public void objectWaitEmitsTaskBlockOutsideContextWindow() throws Exception { + Object monitor = new Object(); + CountDownLatch entered = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + synchronized (monitor) { + entered.countDown(); + monitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-object-wait"); + + worker.start(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertCompleted(worker, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, identityHash(monitor), 0); + TaskBlockAssertions.assertContainsObservedState(events, "WAITING"); + } + + @Test + public void monitorContentionEmitsTaskBlockOutsideContextWindow() throws Exception { + Object monitor = new Object(); + CountDownLatch attempting = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker; + synchronized (monitor) { + worker = new Thread(() -> { + try { + attempting.countDown(); + synchronized (monitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-monitor-contention"); + worker.start(); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + + assertCompleted(worker, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, identityHash(monitor), 0); + TaskBlockAssertions.assertContainsObservedState(events, "CONTENDED"); + } + + @Test + public void contextWindowObjectWaitDoesNotEmitTaskBlock() throws Exception { + Object monitor = new Object(); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + registerCurrentThreadForWallClockProfiling(); + profiler.setContext(0x4400L, 0x4401L, 0L, 0x4401L); + synchronized (monitor) { + monitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } finally { + profiler.clearContext(); + profiler.removeThread(); + } + }, "taskblock-traced-object-wait"); + + worker.start(); + assertCompleted(worker, failure); + stopProfiler(); + + assertFalse(TaskBlockAssertions.containsBlocker( + verifyEvents("datadog.TaskBlock", false), identityHash(monitor))); + } + + @Test + public void staleWaitStateIsRecoveredAfterProfilerRestart() throws Exception { + Object waitMonitor = new Object(); + Object contentionMonitor = new Object(); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch waitCompleted = new CountDownLatch(1); + CountDownLatch restartReady = new CountDownLatch(1); + CountDownLatch attemptingContention = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + synchronized (waitMonitor) { + waiting.countDown(); + waitMonitor.wait(); + } + waitCompleted.countDown(); + assertTrue(restartReady.await(5, TimeUnit.SECONDS)); + attemptingContention.countDown(); + synchronized (contentionMonitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-monitor-restart"); + + worker.start(); + assertTrue(waiting.await(5, TimeUnit.SECONDS)); + Thread.sleep(50); + stopProfiler(); + synchronized (waitMonitor) { + waitMonitor.notifyAll(); + } + assertTrue(waitCompleted.await(5, TimeUnit.SECONDS)); + + Path recording = Files.createTempFile("MonitorTaskBlockTest-restart-", ".jfr"); + boolean restarted = false; + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + + recording.toAbsolutePath()); + restarted = true; + synchronized (contentionMonitor) { + restartReady.countDown(); + assertTrue(attemptingContention.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(worker, failure); + profiler.stop(); + restarted = false; + + IItemCollection events = verifyEvents(recording, "datadog.TaskBlock", false); + assertTaskBlockStackReference(events); + assertTrue(TaskBlockAssertions.containsBlocker( + events, identityHash(contentionMonitor))); + } finally { + restartReady.countDown(); + synchronized (waitMonitor) { + waitMonitor.notifyAll(); + } + if (restarted) profiler.stop(); + worker.join(5_000); + Files.deleteIfExists(recording); + } + } + + @Test + public void virtualMonitorCallbacksDoNotEmitCarrierTaskBlocks() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + Object waitMonitor = new Object(); + AtomicReference failure = new AtomicReference<>(); + Thread waiter = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + try { + synchronized (waitMonitor) { + waitMonitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } + }); + assertCompleted(waiter, failure); + + Object contentionMonitor = new Object(); + CountDownLatch attempting = new CountDownLatch(1); + Thread contender; + synchronized (contentionMonitor) { + contender = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + try { + attempting.countDown(); + synchronized (contentionMonitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(contender, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock", false); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(waitMonitor))); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(contentionMonitor))); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(IItemCollection events) { + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "MonitorTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + } + + private static void assertCompleted(Thread thread, AtomicReference failure) + throws InterruptedException { + thread.join(5_000); + assertFalse(thread.isAlive(), "worker did not complete"); + if (failure.get() != null) throw new AssertionError(failure.get()); + } + + private static long identityHash(Object object) { + return Integer.toUnsignedLong(System.identityHashCode(object)); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java new file mode 100644 index 0000000000..36dd6790d3 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java @@ -0,0 +1,115 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.ProfilerOwnedBlockHooks; +import java.lang.reflect.Method; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** Verifies TaskBlock production from Java-owned platform-thread park hooks. */ +public class ParkTaskBlockTest extends AbstractProfilerTest { + private static final long BLOCKER = 0x3102L; + private static final long UNBLOCKING_SPAN_ID = 0x3103L; + + @Test + public void platformParkEmitsTaskBlockOutsideContextWindow() { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertNoAnchorFields(events); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); + TaskBlockAssertions.assertContainsObservedState(events, "PARKED"); + } + + @Test + public void contextWindowParkDoesNotEmitTaskBlock() { + registerCurrentThreadForWallClockProfiling(); + profiler.setContext(0x3100L, 0x3101L, 0L, 0x3101L); + try { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + } finally { + profiler.clearContext(); + profiler.removeThread(); + } + stopProfiler(); + + assertFalse(verifyEvents("datadog.TaskBlock", false).hasItems(), + "A park inside the context window must remain ordinary wall-clock data"); + } + + @Test + public void virtualParkDoesNotMutateCarrierProducerState() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + long virtualBlocker = 0x3201L; + Thread virtual = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(20); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, virtualBlocker, 0); + } + }); + virtual.join(5_000); + assertFalse(virtual.isAlive()); + + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertFalse(TaskBlockAssertions.containsBlocker(events, virtualBlocker)); + TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(IItemCollection events) { + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "ParkTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + } + + private static void parkForMillis(long millis) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + long remaining; + while ((remaining = deadline - System.nanoTime()) > 0) { + LockSupport.parkNanos(remaining); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java index b0673d1219..64756e83d0 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java @@ -117,6 +117,17 @@ static void assertNoCorrelationId(IItemCollection events) { } } + static boolean containsBlocker(IItemCollection events, long blocker) { + for (IItemIterable iterable : events) { + IMemberAccessor accessor = BLOCKER.getAccessor(iterable.getType()); + if (accessor == null) continue; + for (IItem item : iterable) { + if (accessor.getMember(item).longValue() == blocker) return true; + } + } + return false; + } + static void assertNoAnchorFields(IItemCollection events) { for (IItemIterable iterable : events) { assertNull(ANCHOR_SAMPLE_ID.getAccessor(iterable.getType())); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java index bbaf518b5a..999ade9a35 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java @@ -43,7 +43,6 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread sleeping = new Thread( () -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); long token = ProfilerOwnedBlockHooks.blockEnter( profiler, OSTHREAD_STATE_SLEEPING); @@ -59,7 +58,6 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread parkedBusy = new Thread( () -> { - registerCurrentThreadForWallClockProfiling(); long spanId = 0x1111L; long rootSpanId = 0x2222L; profiler.setContext(rootSpanId, spanId, 0, 0); @@ -78,7 +76,6 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread runnable = new Thread( () -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); while (!stop.get()) { // keep runnable @@ -124,7 +121,7 @@ public void contextScopedThreadsRemainSampled() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms,filter=0,wallprecheck=true"; + return "wall=1ms,wallprecheck=true"; } private Map samplesByThreadName() { From 47adc9cdc219fd4ec57b9c95c86bd3de6e34ee20 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:20:43 +0200 Subject: [PATCH 09/10] test: verify JVM producers in all-thread scope --- .../JvmtiBasedMonitorTaskBlockTest.java | 2 +- .../JvmtiBasedParkTaskBlockTest.java | 2 +- .../wallclock/MonitorTaskBlockTest.java | 2 +- .../profiler/wallclock/ParkTaskBlockTest.java | 56 ++++++++++++++++++- 4 files changed, 58 insertions(+), 4 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java index 550d9d22b4..bc334861fc 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedMonitorTaskBlockTest.java @@ -25,6 +25,6 @@ protected void withTestAssumptions() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=true,jvmtistacks=true"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java index d17eae2f7a..dbcd4d80f6 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java @@ -25,6 +25,6 @@ protected void withTestAssumptions() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=true,jvmtistacks=true"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java index b2c8bf6086..f781d0a5c4 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java @@ -218,7 +218,7 @@ public void virtualMonitorCallbacksDoNotEmitCarrierTaskBlocks() throws Exception @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,wallscope=all,wallprecheck=true"; } protected void assertTaskBlockStackReference(IItemCollection events) { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java index 36dd6790d3..78e8dc4366 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java @@ -8,13 +8,17 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.ProfilerOwnedBlockHooks; import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.LockSupport; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Assumptions; import org.openjdk.jmc.common.item.IItemCollection; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; /** Verifies TaskBlock production from Java-owned platform-thread park hooks. */ public class ParkTaskBlockTest extends AbstractProfilerTest { @@ -94,9 +98,17 @@ public void virtualParkDoesNotMutateCarrierProducerState() throws Exception { TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); } + @Test + public void platformParkSuppressesSignalsAndClearsOwnership() throws Exception { + long baseline = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + long afterFirstPark = runSuppressedPark(baseline); + runSuppressedPark(afterFirstPark); + } + @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,wallscope=all,wallprecheck=true"; } protected void assertTaskBlockStackReference(IItemCollection events) { @@ -112,4 +124,46 @@ private static void parkForMillis(long millis) { LockSupport.parkNanos(remaining); } } + + private long runSuppressedPark(long baseline) throws Exception { + CountDownLatch armed = new CountDownLatch(1); + AtomicBoolean release = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + ProfilerOwnedBlockHooks.parkEnter(profiler); + armed.countDown(); + while (!release.get()) { + Thread.yield(); + } + } catch (Throwable t) { + error.set(t); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + }, "taskblock-park-suppression"); + + worker.start(); + assertTrue(armed.await(5, TimeUnit.SECONDS)); + try { + waitForCounterAbove("wc_signals_suppressed_owned_block", baseline, 5_000L); + } finally { + release.set(true); + } + worker.join(5_000L); + assertFalse(worker.isAlive()); + if (error.get() != null) throw new AssertionError(error.get()); + return profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + } + + private void waitForCounterAbove(String name, long baseline, long timeoutMillis) + throws Exception { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (System.nanoTime() < deadline) { + if (profiler.getDebugCounters().getOrDefault(name, 0L) > baseline) return; + Thread.sleep(10L); + } + throw new AssertionError("Counter did not increase: " + name); + } } From c11181224884586f33d98ba19aa41e6162ecf5f2 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 09:39:07 +0200 Subject: [PATCH 10/10] test: register context-scope mitigation threads --- .../profiler/wallclock/WallclockMitigationsCombinedTest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java index 999ade9a35..bbaf518b5a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java @@ -43,6 +43,7 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread sleeping = new Thread( () -> { + registerCurrentThreadForWallClockProfiling(); ready.countDown(); long token = ProfilerOwnedBlockHooks.blockEnter( profiler, OSTHREAD_STATE_SLEEPING); @@ -58,6 +59,7 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread parkedBusy = new Thread( () -> { + registerCurrentThreadForWallClockProfiling(); long spanId = 0x1111L; long rootSpanId = 0x2222L; profiler.setContext(rootSpanId, spanId, 0, 0); @@ -76,6 +78,7 @@ public void contextScopedThreadsRemainSampled() throws Exception { Thread runnable = new Thread( () -> { + registerCurrentThreadForWallClockProfiling(); ready.countDown(); while (!stop.get()) { // keep runnable @@ -121,7 +124,7 @@ public void contextScopedThreadsRemainSampled() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,filter=0,wallprecheck=true"; } private Map samplesByThreadName() {