From e0a3aa860b81fb5fc261ec9b298342256cd44cd2 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 10:48:09 +0200 Subject: [PATCH 1/3] 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 2/3] 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 bb96c852d74334ae3b75ec90991df0be4b817aaa Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 15:33:48 +0200 Subject: [PATCH 3/3] fix: address sphinx feedback --- ddprof-lib/src/main/cpp/arguments.h | 1 - ddprof-lib/src/main/cpp/profiler.cpp | 23 +++- ddprof-lib/src/main/cpp/profiler.h | 7 ++ ddprof-lib/src/main/cpp/threadFilter.cpp | 18 +-- ddprof-lib/src/main/cpp/threadFilter.h | 1 - ddprof-lib/src/main/cpp/wallClock.cpp | 33 +++-- ddprof-lib/src/main/cpp/wallClock.h | 8 ++ .../com/datadoghq/profiler/JavaProfiler.java | 2 +- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 24 ++-- ddprof-lib/src/test/cpp/wallScope_ut.cpp | 113 ++++++++++++++++++ 10 files changed, 187 insertions(+), 43 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/wallScope_ut.cpp diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index b14c334339..235e1aa06f 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -88,7 +88,6 @@ enum WallclockSampler { }; enum WallScope { - WALL_SCOPE_LEGACY, WALL_SCOPE_CONTEXT, WALL_SCOPE_ALL }; diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index a2b2885d58..86753669ca 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1052,11 +1052,24 @@ void Profiler::updateJavaThreadNames() { jvmti->Deallocate((unsigned char *)thread_objects); } -void Profiler::registerExistingJavaThreads() { +void Profiler::registerThreadsForAllThreadScope(const std::vector& tids) { + // All-thread wall scope is the only mode that needs a pre-populated registry; + // context scope registers threads lazily via the ThreadStart callback. if (!_thread_filter.allThreads()) { return; } + for (int tid : tids) { + if (tid >= 0) { + _thread_filter.registerThread(tid); + } + } +} +void Profiler::registerExistingJavaThreads() { + // The all-thread scope guard lives in registerThreadsForAllThreadScope(); + // enumerating threads here in context scope is a one-time, harmless startup + // cost, and keeping the decision in one tested place avoids a second guard + // that no test can reach without a live JVMTI environment. jvmtiEnv *jvmti = VM::jvmti(); JNIEnv *jni = VM::jni(); jint thread_count; @@ -1065,17 +1078,17 @@ void Profiler::registerExistingJavaThreads() { return; } + std::vector tids; + tids.reserve(thread_count); 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); - } + tids.push_back(JVMThread::nativeThreadId(jni, thread)); jni->DeleteLocalRef(thread); } } jvmti->Deallocate(reinterpret_cast(thread_objects)); + registerThreadsForAllThreadScope(tids); } void Profiler::updateNativeThreadNames(bool defer_initializing) { diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 11fc1db7fd..7d788fd068 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -234,6 +234,13 @@ class alignas(alignof(SpinLock)) Profiler { // dump-time pass (which passes false), records the final name instead. void updateNativeThreadNames(bool defer_initializing = false); + // Registers the supplied native thread ids in the thread filter, but only + // when all-thread wall scope is active. In context scope the registry is + // populated lazily through the ThreadStart callback, so this bootstrap must + // do nothing. Split out from registerExistingJavaThreads() so the scope guard + // can be exercised without a live JVMTI environment. + void registerThreadsForAllThreadScope(const std::vector& tids); + inline void incFailure(int type) { if (type < ASGCT_FAILURE_TYPES) { diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index 42279ff227..14f13e5b2b 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -443,22 +443,6 @@ void ThreadFilter::collect(std::vector& entries) const { } } -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, slot.lifecycleGeneration()}); - } - } - } -} - void ThreadFilter::clearActive() { int num_chunks = _num_chunks.load(std::memory_order_acquire); for (int chunk_idx = 0; chunk_idx < num_chunks; ++chunk_idx) { @@ -516,7 +500,7 @@ bool ThreadFilter::exitBlockedRun(SlotID slot_id, u32 generation) { } void ThreadFilter::init(const char* filter, bool all_threads) { - // Legacy/context scope uses any non-empty filter value (including "0") as + // 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; diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index c20473df3e..0f03dc70d5 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -255,7 +255,6 @@ class ThreadFilter { 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(); diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 5336235eac..c68bfc4497 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -76,10 +76,25 @@ static inline void incrementSuppressedSampledRun() { WallClockCounters::incrementSuppressedSampledRun(); } -static inline bool blockContextEligible(ThreadFilter::Slot* slot) { - ThreadFilter* registry = Profiler::instance()->threadFilter(); - return !registry->allThreads() || slot->activeBlockRemainedOutsideContextWindow(); +// A blocked run is eligible for precheck-driven suppression only when it can be +// attributed to a specific context window. In context scope every registered +// thread qualifies; in all-thread scope suppression is limited to runs that +// stayed outside any context window for their whole lifetime. +static inline bool blockContextEligible(ThreadFilter* registry, + ThreadFilter::Slot* slot) { + return !registry->allThreads() || + slot->activeBlockRemainedOutsideContextWindow(); +} + +#ifdef UNIT_TEST +// Test-only seam exposing the file-local blockContextEligible() gate so unit +// tests can assert its behavior across wall scopes without driving the full +// signal handler. Compiled only into gtest binaries (see GtestTaskBuilder). +bool blockContextEligibleForTest(ThreadFilter* registry, + ThreadFilter::Slot* slot) { + return blockContextEligible(registry, slot); } +#endif static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { ThreadFilter::Slot* slot = entry.slot; @@ -88,7 +103,7 @@ static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { } if (slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation || - !blockContextEligible(slot)) { + !blockContextEligible(Profiler::instance()->threadFilter(), slot)) { return false; } OSThreadState block_state = slot->activeBlockState(); @@ -109,16 +124,16 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - ThreadFilter::Slot* slot = - Profiler::instance()->threadFilter()->slotForId(current->filterSlotId()); + ThreadFilter* registry = Profiler::instance()->threadFilter(); + ThreadFilter::Slot* slot = registry->slotForId(current->filterSlotId()); if (slot == nullptr) { - slot = Profiler::instance()->threadFilter()->lookupByTid(current->tid()); + slot = registry->lookupByTid(current->tid()); } if (slot == nullptr) { return result; } - if (Profiler::instance()->threadFilter()->allThreads() && slot->inContextWindow()) { + if (registry->allThreads() && slot->inContextWindow()) { return result; } @@ -127,7 +142,7 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, bool has_owned_block = active_block_owner != BlockRunOwner::NONE && isPrecheckSuppressionState(active_block_state) && - blockContextEligible(slot); + blockContextEligible(registry, slot); if (has_owned_block) { if (slot->sampledThisRun() && active_block_state == slot->lastSampledState()) { diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index eeeb49f393..97d85aad66 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -18,6 +18,14 @@ #include "tsc.h" #include "wallClockCounters.h" +#ifdef UNIT_TEST +// Test-only accessor for the file-local blockContextEligible() precheck gate in +// wallClock.cpp. Lets unit tests assert the all-thread vs context scope decision +// directly. Compiled only into gtest binaries (see GtestTaskBuilder). +bool blockContextEligibleForTest(ThreadFilter* registry, + ThreadFilter::Slot* slot); +#endif + class BaseWallClock : public Engine { private: static std::atomic _enabled; 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..049520944c 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -209,7 +209,7 @@ public boolean recordTraceRoot(long rootSpanId, String endpoint, int sizeLimit) * *

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. + * context wall scope, the marker retains its historical filtering behavior. */ public void addThread() { filterThreadAdd0(); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 701aef1044..7e03cd1c14 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -566,29 +566,35 @@ TEST(ThreadRegistryTest, AllThreadScopeSeparatesRegistrationFromContextWindow) { ASSERT_GE(slot_id, 0); ThreadFilter::Slot* slot = registry.slotForId(slot_id); ASSERT_NE(nullptr, slot); + // Registration publishes the native tid and tid-index entry, but leaves the + // thread outside the context window. 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); - + // Registration alone does not add the thread to the context-window snapshot. std::vector context; registry.collect(context); EXPECT_TRUE(context.empty()); + // Entering the context window flips only the context marker; the underlying + // registration is unchanged. registry.add(1234, slot_id); + EXPECT_TRUE(slot->inContextWindow()); + EXPECT_EQ(1234, slot->nativeTid()); + EXPECT_EQ(slot, registry.lookupByTid(1234)); registry.collect(context); ASSERT_EQ(1u, context.size()); + EXPECT_EQ(1234, context[0].tid); + + // Leaving the context window clears only the context marker. The thread stays + // registered: its slot, native tid, and tid-index lookup all survive. registry.remove(slot_id); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(1234, slot->nativeTid()); + EXPECT_EQ(slot, registry.lookupByTid(1234)); registry.collect(context); EXPECT_TRUE(context.empty()); - - registry.collectRegistered(registered); - ASSERT_EQ(1u, registered.size()); - EXPECT_EQ(1234, registered[0].tid); } TEST(ThreadRegistryTest, ContextWindowTransitionsAreIdempotent) { diff --git a/ddprof-lib/src/test/cpp/wallScope_ut.cpp b/ddprof-lib/src/test/cpp/wallScope_ut.cpp new file mode 100644 index 0000000000..1bf86fc86f --- /dev/null +++ b/ddprof-lib/src/test/cpp/wallScope_ut.cpp @@ -0,0 +1,113 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include + +#include "../../main/cpp/gtest_crash_handler.h" +#include "profiler.h" +#include "threadFilter.h" +#include "wallClock.h" + +static constexpr char WALL_SCOPE_TEST_NAME[] = "WallScopeTest"; + +class WallScopeGlobalSetup { +public: + WallScopeGlobalSetup() { installGtestCrashHandler(); } + ~WallScopeGlobalSetup() { restoreDefaultSignalHandlers(); } +}; +static WallScopeGlobalSetup wall_scope_global_setup; + +// --- x-4: registerExistingJavaThreads()'s all-thread scope guard -------------- +// +// registerExistingJavaThreads() bootstraps the registry with Java threads that +// already existed when the profiler started. Its scope guard is factored into +// Profiler::registerThreadsForAllThreadScope() so it can be exercised without a +// live JVMTI environment. These tests pin both directions of the guard so an +// inverted condition (registering in context scope / skipping in all-thread +// scope) is caught. + +TEST(WallScopeRegisterExistingTest, AllThreadScopeRegistersPreExistingThreads) { + ThreadFilter* filter = Profiler::instance()->threadFilter(); + filter->init("0", /*all_threads=*/true); + ASSERT_TRUE(filter->allThreads()); + + // Distinct, otherwise-unused tids so the shared singleton registry cannot be + // polluted by (or pollute) other tests in this binary. + ASSERT_EQ(nullptr, filter->lookupByTid(918001)); + ASSERT_EQ(nullptr, filter->lookupByTid(918002)); + + Profiler::instance()->registerThreadsForAllThreadScope({918001, -1, 918002}); + + EXPECT_NE(nullptr, filter->lookupByTid(918001)); + EXPECT_NE(nullptr, filter->lookupByTid(918002)); +} + +TEST(WallScopeRegisterExistingTest, ContextScopeDoesNotRegisterPreExistingThreads) { + ThreadFilter* filter = Profiler::instance()->threadFilter(); + filter->init("enabled", /*all_threads=*/false); + ASSERT_FALSE(filter->allThreads()); + + ASSERT_EQ(nullptr, filter->lookupByTid(918101)); + + Profiler::instance()->registerThreadsForAllThreadScope({918101}); + + // Context scope populates the registry lazily via the ThreadStart callback, + // so the bootstrap must be a no-op here. + EXPECT_EQ(nullptr, filter->lookupByTid(918101)); +} + +// --- x-5: blockContextEligible() precheck-suppression gate -------------------- +// +// blockContextEligible() decides whether a blocked run may be suppressed by the +// wall-clock precheck. In context scope every registered thread is eligible; in +// all-thread scope only runs that stayed outside the context window qualify. +// The test-only seam blockContextEligibleForTest() forwards to the file-local +// implementation so both scopes can be asserted directly. + +TEST(WallScopeBlockContextEligibleTest, ContextScopeAlwaysEligible) { + ThreadFilter registry; + registry.init("enabled"); // context scope (all_threads = false) + ASSERT_FALSE(registry.allThreads()); + + int slot_id = registry.registerThread(4242); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + // Outside any context window: eligible. + ASSERT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); + EXPECT_TRUE(blockContextEligibleForTest(®istry, slot)); + + // Inside the context window the slot no longer "remained outside", yet context + // scope keeps it eligible. This pins the !allThreads() term of the gate: if it + // were dropped, eligibility would collapse to the (now false) window predicate. + registry.add(4242, slot_id); + ASSERT_TRUE(slot->inContextWindow()); + ASSERT_FALSE(slot->activeBlockRemainedOutsideContextWindow()); + EXPECT_TRUE(blockContextEligibleForTest(®istry, slot)); +} + +TEST(WallScopeBlockContextEligibleTest, AllThreadScopeFollowsContextWindow) { + ThreadFilter registry; + registry.init("0", /*all_threads=*/true); + ASSERT_TRUE(registry.allThreads()); + + int slot_id = registry.registerThread(5252); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + // Outside the context window: eligible for suppression. + ASSERT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); + EXPECT_TRUE(blockContextEligibleForTest(®istry, slot)); + + // Inside the context window: not eligible. This pins the all-thread branch: if + // the scope term were inverted, eligibility would stay true here. + registry.add(5252, slot_id); + ASSERT_TRUE(slot->inContextWindow()); + ASSERT_FALSE(slot->activeBlockRemainedOutsideContextWindow()); + EXPECT_FALSE(blockContextEligibleForTest(®istry, slot)); +}