From 1118f62831a4384bdf0283ef01a626c5bdc48af5 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Sun, 19 Jul 2026 12:53:47 +0200 Subject: [PATCH 01/15] feat(wall): track all threads for unfiltered wall-clock precheck --- ddprof-lib/src/main/cpp/counters.h | 7 +- ddprof-lib/src/main/cpp/engine.h | 4 + ddprof-lib/src/main/cpp/javaApi.cpp | 65 +-- ddprof-lib/src/main/cpp/jvmThread.cpp | 4 + ddprof-lib/src/main/cpp/jvmThread.h | 5 +- ddprof-lib/src/main/cpp/profiler.cpp | 87 +++- ddprof-lib/src/main/cpp/profiler.h | 2 +- ddprof-lib/src/main/cpp/threadFilter.cpp | 350 +++++++++++++-- ddprof-lib/src/main/cpp/threadFilter.h | 137 +++++- ddprof-lib/src/main/cpp/wallClock.cpp | 114 +++-- ddprof-lib/src/main/cpp/wallClock.h | 58 ++- .../src/main/cpp/wallClockCandidateSelector.h | 64 +++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 402 +++++++++++++++++- .../cpp/wallClockCandidateSelector_ut.cpp | 173 ++++++++ .../src/test/cpp/wallprecheck_args_ut.cpp | 29 ++ .../WallClockPrecheckBenchmarkHooks.java | 21 + .../WallClockPrecheckOverheadBenchmark.java | 145 +++++++ .../profiler/AbstractProfilerTest.java | 19 +- .../J9WallClockPrecheckCapabilityTest.java | 36 ++ .../JvmtiBasedUnfilteredWallPrecheckTest.java | 44 ++ .../UnfilteredWallPrecheckRestartTest.java | 127 ++++++ .../wallclock/UnfilteredWallPrecheckTest.java | 239 +++++++++++ 22 files changed, 2019 insertions(+), 113 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/wallClockCandidateSelector.h create mode 100644 ddprof-lib/src/test/cpp/wallClockCandidateSelector_ut.cpp create mode 100644 ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/WallClockPrecheckBenchmarkHooks.java create mode 100644 ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/WallClockPrecheckOverheadBenchmark.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedUnfilteredWallPrecheckTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckRestartTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 34b2e908dd..5d3286768d 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -1,5 +1,5 @@ /* - * Copyright 2023 Datadog, Inc + * Copyright 2023, 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. @@ -57,6 +57,8 @@ X(THREAD_NAMES_COUNT, "thread_names_count") \ X(THREAD_FILTER_PAGES, "thread_filter_pages") \ X(THREAD_FILTER_BYTES, "thread_filter_bytes") \ + X(THREAD_REGISTRY_BOOTSTRAP_FAILURES, "thread_registry_bootstrap_failures") \ + X(THREAD_REGISTRY_STALE_SLOTS_RETIRED, "thread_registry_stale_slots_retired") \ X(JMETHODID_SKIPPED, "jmethodid_skipped_count") \ X(CODECACHE_NATIVE_SIZE_BYTES, "codecache_native_size_bytes") \ X(CODECACHE_NATIVE_COUNT, "native_codecache_count") \ @@ -67,6 +69,9 @@ 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_PRECHECK_REGISTRY_LOOKUPS, "wc_precheck_registry_lookups") \ + X(WC_PRECHECK_CANDIDATES_REJECTED, "wc_precheck_candidates_rejected") \ + X(WC_PRECHECK_LOOKUP_BUDGET_EXHAUSTED, "wc_precheck_lookup_budget_exhausted") \ 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") \ diff --git a/ddprof-lib/src/main/cpp/engine.h b/ddprof-lib/src/main/cpp/engine.h index c9e75e2986..9d0de68108 100644 --- a/ddprof-lib/src/main/cpp/engine.h +++ b/ddprof-lib/src/main/cpp/engine.h @@ -1,5 +1,6 @@ /* * Copyright 2017 Andrei Pangin + * 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. @@ -51,6 +52,9 @@ class Engine { virtual void stop(); virtual long interval() const { return 0L; } + // Whether empty-filter wall prechecks can consume ThreadFilter registry state. + virtual bool supportsUnfilteredWallPrecheck() const { return false; } + virtual int registerThread(int tid) { return -1; } virtual void unregisterThread(int tid) {} diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 83f6045c1a..a7be9f44b7 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -138,6 +138,30 @@ Java_com_datadoghq_profiler_JavaProfiler_getSamples(JNIEnv *env, // some duplication between add and remove, though we want to avoid having an extra branch in the hot path +static ThreadFilter::SlotID ensureCurrentThreadFilterSlot( + ThreadFilter *thread_filter, ProfiledThread *current) { + int tid = current->tid(); + if (unlikely(tid < 0)) { + return -1; + } + + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (likely(slot_id >= 0)) { + if (likely(thread_filter->activeSlotForId(slot_id, tid) != nullptr)) { + return slot_id; + } + current->setFilterSlotId(-1); + } + + // Startup can register this TID centrally, but it cannot update another + // pthread's TLS. registerThread(tid) reuses that existing slot. + slot_id = thread_filter->registerThread(tid); + if (slot_id >= 0) { + current->setFilterSlotId(slot_id); + } + return slot_id; +} + // JavaCritical is faster JNI, but more restrictive - parameters and return value have to be // primitives or arrays of primitive types. // We direct corresponding JNI calls to JavaCritical to make sure the parameters/return value @@ -155,24 +179,14 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } - int slot_id = current->filterSlotId(); - 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(); - current->setFilterSlotId(slot_id); - } - - if (unlikely(slot_id == -1)) { + int slot_id = ensureCurrentThreadFilterSlot(thread_filter, current); + if (unlikely(slot_id < 0)) { 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); } @@ -189,12 +203,13 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadRemove0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } int slot_id = current->filterSlotId(); - if (unlikely(slot_id == -1)) { + if (unlikely(slot_id == -1 || + thread_filter->activeSlotForId(slot_id, tid) == nullptr)) { // Thread doesn't have a slot ID yet - nothing to remove return; } @@ -351,8 +366,8 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) } bool first_park = current->parkEnter(); ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (first_park && tf->enabled()) { - ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (first_park && tf->registryActive()) { + ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); if (slot_id >= 0) { current->setParkBlockToken( tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); @@ -373,9 +388,10 @@ Java_com_datadoghq_profiler_JavaProfiler_parkExit0( return; } ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { + if (tf->registryActive()) { ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); - if (current->filterSlotId() == slot_id) { + if (tf->activeSlotForId(current->filterSlotId(), current->tid()) != nullptr && + current->filterSlotId() == slot_id) { tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(park_block_token)); } } @@ -402,10 +418,10 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( return 0; } ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (!tf->enabled()) { + if (!tf->registryActive()) { return 0; } - ThreadFilter::SlotID slot_id = current->filterSlotId(); + ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); if (slot_id < 0) { return 0; } @@ -423,12 +439,13 @@ 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) { + if (current->filterSlotId() != slot_id || + tf->activeSlotForId(slot_id, current->tid()) == nullptr) { return; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { + if (tf->registryActive()) { tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(block_token)); } } diff --git a/ddprof-lib/src/main/cpp/jvmThread.cpp b/ddprof-lib/src/main/cpp/jvmThread.cpp index d249d943f7..f4f24a3978 100644 --- a/ddprof-lib/src/main/cpp/jvmThread.cpp +++ b/ddprof-lib/src/main/cpp/jvmThread.cpp @@ -29,6 +29,10 @@ bool JVMThread::initialize() { return _jvm_thread.initialize(current_thread); } +bool JVMThread::supportsNativeThreadIdLookup() { + return VM::isOpenJ9() || VMThread::hasNativeThreadId(); +} + int JVMThread::nativeThreadId(JNIEnv* jni, jthread thread) { return VM::isOpenJ9() ? J9Support::GetOSThreadID(thread) : VMThread::nativeThreadId(jni, thread); } diff --git a/ddprof-lib/src/main/cpp/jvmThread.h b/ddprof-lib/src/main/cpp/jvmThread.h index 2f5bd69104..8c7988d503 100644 --- a/ddprof-lib/src/main/cpp/jvmThread.h +++ b/ddprof-lib/src/main/cpp/jvmThread.h @@ -23,9 +23,12 @@ class JVMThread { /* * The initialization happens in early startup, in single-threaded mode, * no synchronization is needed - */ + */ static bool initialize(); + // Whether nativeThreadId can resolve a Java thread other than the caller. + static bool supportsNativeThreadIdLookup(); + static inline bool isInitialized() { return _tid != nullptr && _jvm_thread.isKeyValid(); } diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index ca376873e6..fc25018abe 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -82,11 +82,9 @@ void Profiler::onThreadStart(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { 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); @@ -106,9 +104,11 @@ void Profiler::onThreadEnd(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { int slot_id = current->filterSlotId(); tid = current->tid(); - if (_thread_filter.enabled()) { - _thread_filter.unregisterThread(slot_id); + if (slot_id >= 0) { + _thread_filter.unregisterThread(slot_id, tid); current->setFilterSlotId(-1); + } else { + _thread_filter.unregisterThreadByTid(tid); } updateThreadName(jvmti, jni, thread, false); @@ -132,6 +132,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); } @@ -1056,6 +1057,38 @@ void Profiler::updateJavaThreadNames() { jvmti->Deallocate((unsigned char *)thread_objects); } +void Profiler::registerExistingJavaThreads() { + if (!_thread_filter.unfilteredWallTrackingActive() || + !JVMThread::supportsNativeThreadIdLookup()) { + 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) { + Counters::increment(THREAD_REGISTRY_BOOTSTRAP_FAILURES); + 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)); + int retired = _thread_filter.retireInactiveRegistrations(); + if (retired > 0) { + Counters::increment(THREAD_REGISTRY_STALE_SLOTS_RETIRED, retired); + } +} + void Profiler::updateNativeThreadNames(bool defer_initializing) { ThreadList *thread_list = OS::listThreads(); constexpr size_t buffer_size = 64; @@ -1383,24 +1416,33 @@ Error Profiler::start(Arguments &args, bool reset) { _safe_mode |= GC_TRACES | LAST_JAVA_PC; } - // TODO: Current way of setting filter is weird with the recent changes - _thread_filter.init(args._filter ? args._filter : "0"); - - // Minor optim: Register the current thread (start thread won't be called) - if (_thread_filter.enabled()) { + _cpu_engine = selectCpuEngine(args); + _wall_engine = selectWallEngine(args); + + const char *filter = args._filter != nullptr ? args._filter : "0"; + const bool track_unfiltered_wall = + (_event_mask & EM_WALL) != 0 && args._wall_precheck && + args._filter != nullptr && args._filter[0] == '\0' && + _wall_engine->supportsUnfilteredWallPrecheck(); + _thread_filter.init(filter, track_unfiltered_wall); + + // Reset per-recording state before a wall timer can inspect the registry. + if (_thread_filter.registryActive()) { _thread_filter.clearActive(); + } + + // Preserve the context-filter fast path. Unfiltered tracking bootstraps the + // current thread only after the wall engine has started successfully. + if (_thread_filter.enabled()) { ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); 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); - _wall_engine = selectWallEngine(args); _cstack = args._cstack; if (_cstack == CSTACK_DEFAULT) { if (VMStructs::hasStackStructs() && OS::isLinux()) { @@ -1451,6 +1493,7 @@ Error Profiler::start(Arguments &args, bool reset) { _num_context_attributes = args._context_attributes.size(); error = _jfr.start(args, reset); if (error) { + _thread_filter.deactivateRecording(); switchLibraryTrap(false); _libs->stopRefresher(); return error; @@ -1500,6 +1543,7 @@ Error Profiler::start(Arguments &args, bool reset) { Log::warn("%s", error.message()); if (_event_mask == EM_NATIVEMEM) { // nativemem is the only requested mode: propagate the real error + _thread_filter.deactivateRecording(); disableEngines(); switchLibraryTrap(false); _libs->stopRefresher(); @@ -1523,9 +1567,19 @@ Error Profiler::start(Arguments &args, bool reset) { } } + // A recoverable wall-engine failure must not leave registry work enabled for + // unrelated engines that did start successfully. + if (track_unfiltered_wall && (activated & EM_WALL) == 0) { + _thread_filter.init(filter, false); + } + 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(). @@ -1547,6 +1601,7 @@ Error Profiler::start(Arguments &args, bool reset) { return Error::OK; } // no engine was activated; perform cleanup + _thread_filter.deactivateRecording(); disableEngines(); switchLibraryTrap(false); _libs->stopRefresher(); @@ -1598,6 +1653,8 @@ Error Profiler::stop() { if (_event_mask & EM_CPU) _cpu_engine->stop(); + _thread_filter.deactivateRecording(); + switchLibraryTrap(false); switchThreadEvents(JVMTI_DISABLE); Libraries::instance()->refresh(); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index c4d7e6e24e..f532e297a8 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -152,6 +152,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); @@ -233,7 +234,6 @@ class alignas(alignof(SpinLock)) Profiler { // dump-time pass (which passes false), records the final name instead. void updateNativeThreadNames(bool defer_initializing = false); - inline void incFailure(int type) { if (type < ASGCT_FAILURE_TYPES) { atomicIncRelaxed(_failures[type]); diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index 531ce75a1a..24c25825a5 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -32,12 +32,16 @@ ThreadFilter::ShardHead ThreadFilter::_free_heads[ThreadFilter::kShardCount] {}; -ThreadFilter::ThreadFilter() : _enabled(false) { +ThreadFilter::ThreadFilter() + : _enabled(false), _registry_active(false), _track_unfiltered_wall(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,9 @@ 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); + _track_unfiltered_wall.store(false, std::memory_order_release); + _recording_epoch.store(0, 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 +66,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 +88,9 @@ 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.recording_epoch.store(0, 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 +104,42 @@ 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) { + RecordingEpoch epoch = recordingEpoch(); + if (epoch != 0) { + refreshSlotForRecording(slotForId(existing), epoch); + } + return existing; + } + } + + RecordingEpoch epoch = recordingEpoch(); // 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->recording_epoch.store(0, std::memory_order_relaxed); + 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); + return -1; + } + if (epoch != 0) { + slot->recording_epoch.store(epoch, std::memory_order_release); + } return reused_slot; } @@ -131,9 +170,124 @@ 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->recording_epoch.store(0, std::memory_order_relaxed); + 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); + return -1; + } + if (epoch != 0) { + slot->recording_epoch.store(epoch, std::memory_order_release); + } + return index; } +void ThreadFilter::refreshSlotForRecording(Slot* slot, RecordingEpoch epoch) { + if (slot == nullptr || epoch == 0 || slot->recordingEpoch() == epoch) { + return; + } + + // Make the retained identity ineligible before resetting its recording-local + // payload, then publish the new epoch only after the reset is complete. + slot->recording_epoch.store(0, std::memory_order_release); + slot->context_window_state.store(0, std::memory_order_relaxed); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->recording_epoch.store(epoch, std::memory_order_release); +} + +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); +} + +ThreadFilter::Slot* ThreadFilter::lookupByTid(int tid, + RecordingEpoch epoch) const { + if (epoch == 0 || recordingEpoch() != epoch) { + return nullptr; + } + Slot* slot = lookupByTid(tid); + return slot != nullptr && slot->recordingEpoch() == epoch ? slot : nullptr; +} + +ThreadFilter::Slot* ThreadFilter::activeSlotForId(SlotID slot_id, + int tid) const { + Slot* slot = slotForId(slot_id); + if (slot == nullptr || slot->nativeTid() != tid) { + return nullptr; + } + RecordingEpoch epoch = recordingEpoch(); + if (epoch != 0 && slot->recordingEpoch() != epoch) { + return nullptr; + } + return slot; +} + void ThreadFilter::initFreeList() { // Initialize the free list storage for (int i = 0; i < kFreeListSize; ++i) { @@ -160,7 +314,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 +330,18 @@ 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 (!indexSlot(slot_id, tid)) { + slot.tid.store(-1, std::memory_order_release); + return; + } + } + } + slot.enterContextWindow(); } } @@ -198,16 +363,59 @@ 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, int expected_tid) { + std::lock_guard lock(_registry_lock); + unregisterThreadLocked(slot_id, expected_tid); } -void ThreadFilter::unregisterThread(SlotID slot_id) { +void ThreadFilter::unregisterThreadLocked(SlotID slot_id, int expected_tid) { if (slot_id < 0) return; - remove(slot_id); - resetSlotRunState(slot_id); + Slot* slot = slotForId(slot_id); + if (slot == nullptr) return; + int tid = slot->nativeTid(); + if (expected_tid >= 0 && tid != expected_tid) return; + unindexSlot(slot_id, tid); + slot->recording_epoch.store(0, std::memory_order_release); + 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); + } +} + +int ThreadFilter::retireInactiveRegistrations() { + RecordingEpoch epoch = recordingEpoch(); + if (epoch == 0 || !unfilteredWallTrackingActive()) { + return 0; + } + + std::lock_guard lock(_registry_lock); + int retired = 0; + 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 (int slot_idx = 0; slot_idx < kChunkSize; ++slot_idx) { + Slot& slot = chunk->slots[slot_idx]; + if (slot.nativeTid() != -1 && slot.recordingEpoch() != epoch) { + unregisterThreadLocked((chunk_idx << kChunkShift) + slot_idx); + retired++; + } + } + } + return retired; +} + bool ThreadFilter::pushToFreeList(SlotID slot_id) { // Lock-free sharded Treiber stack push const int shard = shardOfSlot(slot_id); @@ -274,8 +482,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 +507,10 @@ void ThreadFilter::collect(std::vector& entries) const { } for (auto& slot : chunk->slots) { - int slot_tid = slot.value.load(std::memory_order_acquire); - if (slot_tid != -1) { - entries.push_back({slot_tid, &slot}); + int slot_tid = slot.nativeTid(); + if (slot_tid != -1 && slot.inContextWindow()) { + entries.push_back({slot_tid, &slot, slot.lifecycleGeneration(), + slot.recordingEpoch()}); } } } @@ -316,7 +525,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 +548,8 @@ 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, + unfilteredWallTrackingActive())) { return 0; } return encodeBlockRunToken(slot_id, generation); @@ -363,14 +573,106 @@ 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); +bool ThreadFilter::shouldSuppressOwnedBlock(const ThreadEntry& entry) const { + Slot* slot = entry.slot; + if (slot == nullptr || slot->nativeTid() != entry.tid || + slot->lifecycleGeneration() != entry.lifecycle_generation) { + return false; + } + + const bool unfiltered_tracking = unfilteredWallTrackingActive(); + RecordingEpoch epoch = 0; + if (unfiltered_tracking) { + epoch = recordingEpoch(); + if (epoch == 0 || entry.recording_epoch != epoch || + slot->recordingEpoch() != epoch) { + return false; + } + } + +#ifdef UNIT_TEST + if (_suppression_snapshot_hook != nullptr) { + _suppression_snapshot_hook(_suppression_snapshot_hook_arg); + } +#endif + + u32 block_generation = slot->blockGeneration(); + BlockRunOwner owner = slot->activeBlockOwner(); + OSThreadState state = slot->activeBlockState(); + bool context_eligible = + !unfiltered_tracking || slot->activeBlockRemainedOutsideContextWindow(); + bool sampled = slot->sampledThisRun(); + OSThreadState last_sampled_state = + sampled ? slot->lastSampledState() : OSThreadState::UNKNOWN; + bool suppressible_state = state == OSThreadState::SLEEPING || + state == OSThreadState::CONDVAR_WAIT || + state == OSThreadState::OBJECT_WAIT || + state == OSThreadState::MONITOR_WAIT; + if (owner == BlockRunOwner::NONE || !context_eligible || + !suppressible_state || !sampled || state != last_sampled_state) { + return false; + } + + // The payload is spread across independent atomics. Accept it only if the + // slot still represents the lifecycle and block run captured by the timer. + if (slot->activeBlockOwner() != owner || + slot->blockGeneration() != block_generation || + slot->nativeTid() != entry.tid || + slot->lifecycleGeneration() != entry.lifecycle_generation) { + return false; + } + if (unfiltered_tracking && + (recordingEpoch() != epoch || slot->recordingEpoch() != epoch || + !slot->activeBlockRemainedOutsideContextWindow())) { + return false; + } + return true; +} + +void ThreadFilter::init(const char* filter, bool track_unfiltered_wall) { + // Preserve the legacy filter contract: every non-empty value, including + // "0", enables context filtering. Empty filter disables filtering; the + // extra flag only retains metadata for unfiltered wall prechecks. + bool context_filter = filter != nullptr && strlen(filter) > 0; + bool unfiltered_tracking = track_unfiltered_wall && !context_filter; + RecordingEpoch epoch = 0; + if (unfiltered_tracking) { + epoch = _next_recording_epoch.fetch_add(1, std::memory_order_acq_rel) + 1; + if (epoch == 0) { + // Zero is reserved for inactive state. This can occur only after + // 2^64 recording starts; skip it rather than publishing ambiguity. + epoch = _next_recording_epoch.fetch_add(1, std::memory_order_acq_rel) + 1; + } + } + _recording_epoch.store(epoch, std::memory_order_release); + _track_unfiltered_wall.store(unfiltered_tracking, + std::memory_order_release); + _registry_active.store(unfiltered_tracking || 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::unfilteredWallTrackingActive() const { + return _track_unfiltered_wall.load(std::memory_order_acquire); +} + +ThreadFilter::RecordingEpoch ThreadFilter::recordingEpoch() const { + return _recording_epoch.load(std::memory_order_acquire); +} + +void ThreadFilter::deactivateRecording() { + // Close producer admission before invalidating the recording epoch. Existing + // slots remain allocated so surviving threads can refresh them on restart. + _registry_active.store(false, std::memory_order_release); + _enabled.store(false, std::memory_order_release); + _track_unfiltered_wall.store(false, std::memory_order_release); + _recording_epoch.store(0, std::memory_order_release); +} diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 541249e4c1..2a73f37666 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" @@ -37,6 +38,7 @@ enum class BlockRunOwner : int { class ThreadFilter { public: using SlotID = int; + using RecordingEpoch = u64; // Optimized limits for reasonable memory usage static constexpr int kChunkSize = 256; @@ -45,8 +47,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 +60,21 @@ 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}; + // Per-recording publication flag. A retained TID mapping is eligible for + // unfiltered suppression only when this value matches the registry's + // active recording epoch. The payload is reset before the epoch is + // release-published. + std::atomic recording_epoch{0}; + std::atomic active_block_context_epoch{0}; std::atomic unowned_blocked_state{OSThreadState::UNKNOWN}; - std::atomic value{-1}; + // Native identity and context-window membership are independent so an + // unfiltered wall recording can retain lifecycle metadata without + // changing ordinary thread selection. + 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 +88,10 @@ 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) - sizeof(std::atomic) @@ -82,6 +103,44 @@ 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 RecordingEpoch recordingEpoch() const { + return recording_epoch.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 +206,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,19 +238,30 @@ 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, "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"); + static_assert(std::atomic::is_always_lock_free, + "Slot::recording_epoch must be lock-free for signal-handler safety"); ThreadFilter(); ~ThreadFilter(); - void init(const char* filter); + void init(const char* filter, bool track_unfiltered_wall = false); void initFreeList(); bool enabled() const; + bool registryActive() const; + bool unfilteredWallTrackingActive() const; + RecordingEpoch recordingEpoch() 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); @@ -197,6 +279,18 @@ class ThreadFilter { // another owner. void exitBlockedRun(SlotID slot_id); bool exitBlockedRun(SlotID slot_id, u32 generation); + // Reads the complete timer-side suppression payload and rejects it if slot + // identity or block lifecycle changes before final validation. + bool shouldSuppressOwnedBlock(const ThreadEntry& entry) const; + +#ifdef UNIT_TEST + using SuppressionSnapshotHook = void (*)(void*); + void setSuppressionSnapshotHookForTest(SuppressionSnapshotHook hook, + void* arg) { + _suppression_snapshot_hook = hook; + _suppression_snapshot_hook_arg = arg; + } +#endif static inline u64 encodeBlockRunToken(SlotID slot_id, u32 generation) { return (static_cast(generation) << 32) | static_cast(slot_id + 1); @@ -218,8 +312,14 @@ class ThreadFilter { return chunk != nullptr ? &chunk->slots[slot_idx] : nullptr; } - SlotID registerThread(); - void unregisterThread(SlotID slot_id); + SlotID registerThread(int tid = -1); + void unregisterThread(SlotID slot_id, int expected_tid = -1); + void unregisterThreadByTid(int tid); + Slot* lookupByTid(int tid) const; + Slot* lookupByTid(int tid, RecordingEpoch epoch) const; + Slot* activeSlotForId(SlotID slot_id, int tid) const; + int retireInactiveRegistrations(); + void deactivateRecording(); private: @@ -235,6 +335,10 @@ class ThreadFilter { }; std::atomic _enabled{false}; + std::atomic _registry_active{false}; + std::atomic _track_unfiltered_wall{false}; + std::atomic _recording_epoch{0}; + std::atomic _next_recording_epoch{0}; // Lazily allocated storage for chunks std::atomic _chunks[kMaxChunks]; @@ -243,6 +347,17 @@ 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; + +#ifdef UNIT_TEST + SuppressionSnapshotHook _suppression_snapshot_hook = nullptr; + void* _suppression_snapshot_hook_arg = nullptr; +#endif // Cache line aligned to prevent false sharing between shards struct alignas(DEFAULT_CACHE_LINE_SIZE) ShardHead { std::atomic head{-1}; }; @@ -254,12 +369,22 @@ 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 refreshSlotForRecording(Slot* slot, RecordingEpoch epoch); + void unregisterThreadLocked(SlotID slot_id, int expected_tid = -1); + 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; + ThreadFilter::RecordingEpoch recording_epoch; }; #endif // _THREADFILTER_H diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index eac9f3fd37..562c5181da 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -76,19 +76,13 @@ static inline void incrementSuppressedSampledRun() { WallClockCounters::incrementSuppressedSampledRun(); } -static inline bool suppressAlreadySampledBlock(ThreadFilter::Slot* slot) { - if (slot == nullptr) { +static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { + ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); + if (!thread_filter->shouldSuppressOwnedBlock(entry)) { return false; } - OSThreadState block_state = slot->activeBlockState(); - if (slot->activeBlockOwner() != BlockRunOwner::NONE && - isPrecheckSuppressionState(block_state) && - slot->sampledThisRun() && - block_state == slot->lastSampledState()) { - incrementSuppressedSampledRun(); - return true; - } - return false; + incrementSuppressedSampledRun(); + return true; } static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, @@ -98,9 +92,22 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } + ThreadFilter* registry = Profiler::instance()->threadFilter(); ThreadFilter::Slot* slot = - Profiler::instance()->threadFilter()->slotForId(current->filterSlotId()); + registry->activeSlotForId(current->filterSlotId(), current->tid()); if (slot == nullptr) { + ThreadFilter::RecordingEpoch epoch = registry->recordingEpoch(); + slot = epoch != 0 ? registry->lookupByTid(current->tid(), epoch) + : registry->lookupByTid(current->tid()); + } + if (slot == nullptr) { + return result; + } + + // In an unfiltered recording, context threads keep their normal MethodSample + // stream. Only owned blocks that remain outside the context window may replace + // repeated signals. + if (registry->unfilteredWallTrackingActive() && slot->inContextWindow()) { return result; } @@ -108,7 +115,9 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, BlockRunOwner active_block_owner = slot->activeBlockOwner(); bool has_owned_block = active_block_owner != BlockRunOwner::NONE && - isPrecheckSuppressionState(active_block_state); + isPrecheckSuppressionState(active_block_state) && + (!registry->unfilteredWallTrackingActive() || + slot->activeBlockRemainedOutsideContextWindow()); if (has_owned_block) { if (slot->sampledThisRun() && active_block_state == slot->lastSampledState()) { @@ -311,7 +320,6 @@ Error BaseWallClock::start(Arguments &args) { _reservoir_size = args._wall_threads_per_tick ? args._wall_threads_per_tick : DEFAULT_WALL_THREADS_PER_TICK; - initialize(args); _running = true; @@ -349,11 +357,15 @@ void WallClockASGCT::initialize(Arguments& args) { } void WallClockASGCT::timerLoop() { - // todo: re-allocating the vector every time is not efficient + ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); + const bool lazy_backfill = + _precheck && thread_filter->unfilteredWallTrackingActive(); + const ThreadFilter::RecordingEpoch recording_epoch = + lazy_backfill ? thread_filter->recordingEpoch() : 0; 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 (thread_filter->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { const int refresher_tid = Libraries::instance()->refresherTid(); @@ -365,21 +377,37 @@ 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) + entries.push_back({tid, nullptr, 0, 0}); } } delete thread_list; } + if (_precheck && !lazy_backfill) { + 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) { + auto sampleThreads = [&](ThreadEntry entry, int& num_failures, + int& threads_already_exited, int& permission_denied, + int& registry_lookups, bool lookup_registry_slot) { + if (lookup_registry_slot && entry.slot == nullptr) { + registry_lookups++; + ThreadFilter::Slot* slot = + thread_filter->lookupByTid(entry.tid, recording_epoch); + if (slot != nullptr) { + entry.slot = slot; + entry.lifecycle_generation = slot->lifecycleGeneration(); + entry.recording_epoch = slot->recordingEpoch(); + } + } // 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.slot)) { - return false; + if (_precheck && suppressAlreadySampledBlock(entry)) { + return WallClockCandidateOutcome::PRECHECK_REJECTED; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { num_failures++; @@ -395,15 +423,16 @@ void WallClockASGCT::timerLoop() { Log::debug("unexpected error %s", strerror(errno)); } } - return false; + return WallClockCandidateOutcome::SIGNAL_FAILED; } - return true; + return WallClockCandidateOutcome::SIGNAL_SENT; }; auto doNothing = []() { }; - timerLoopCommon(collectThreads, sampleThreads, doNothing, _reservoir_size, _interval); + timerLoopCommon(collectThreads, sampleThreads, doNothing, + _reservoir_size, _interval, lazy_backfill); } // WallClockJvmti: mirrors WallClockASGCT's dispatch, but the signal handler @@ -497,9 +526,14 @@ void WallClockJvmti::initialize(Arguments &args) { } void WallClockJvmti::timerLoop() { + ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); + const bool lazy_backfill = + _precheck && thread_filter->unfilteredWallTrackingActive(); + const ThreadFilter::RecordingEpoch recording_epoch = + lazy_backfill ? thread_filter->recordingEpoch() : 0; auto collectThreads = [&](std::vector &entries) { const int refresher_tid = Libraries::instance()->refresherTid(); - if (Profiler::instance()->threadFilter()->enabled()) { + if (thread_filter->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { ThreadList *thread_list = OS::listThreads(); @@ -508,17 +542,33 @@ 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}); + entries.push_back({tid, nullptr, 0, 0}); } } delete thread_list; } + if (_precheck && !lazy_backfill) { + 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)) { - return false; + int &threads_already_exited, int &permission_denied, + int ®istry_lookups, bool lookup_registry_slot) { + if (lookup_registry_slot && entry.slot == nullptr) { + registry_lookups++; + ThreadFilter::Slot* slot = + thread_filter->lookupByTid(entry.tid, recording_epoch); + if (slot != nullptr) { + entry.slot = slot; + entry.lifecycle_generation = slot->lifecycleGeneration(); + entry.recording_epoch = slot->recordingEpoch(); + } + } + if (_precheck && suppressAlreadySampledBlock(entry)) { + return WallClockCandidateOutcome::PRECHECK_REJECTED; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { num_failures++; @@ -534,13 +584,13 @@ void WallClockJvmti::timerLoop() { Log::debug("unexpected error %s", strerror(errno)); } } - return false; + return WallClockCandidateOutcome::SIGNAL_FAILED; } - return true; + return WallClockCandidateOutcome::SIGNAL_SENT; }; auto doNothing = []() {}; timerLoopCommon(collectThreads, sampleThreads, doNothing, - _reservoir_size, _interval); + _reservoir_size, _interval, lazy_backfill); } diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index 14e3f88aa3..8bd9ef8c66 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -16,6 +16,7 @@ #include "threadFilter.h" #include "threadState.h" #include "tsc.h" +#include "wallClockCandidateSelector.h" #include "wallClockCounters.h" class BaseWallClock : public Engine { @@ -23,6 +24,9 @@ class BaseWallClock : public Engine { static std::atomic _enabled; std::atomic _running; protected: + // Backfill rejected candidates without letting a population of already- + // suppressed blockers restore O(N) registry lookups on every wall tick. + static constexpr size_t PRECHECK_VISIT_BUDGET_MULTIPLIER = 4; long _interval; // Maximum number of threads sampled in one iteration. This limit serves as a // throttle when generating profiling signals. Otherwise applications with too @@ -30,7 +34,6 @@ class BaseWallClock : public Engine { // limit low enough helps to avoid contention on a spin lock inside // Profiler::recordSample(). int _reservoir_size; - pthread_t _thread; virtual void timerLoop() = 0; virtual void initialize(Arguments& args) {}; @@ -44,7 +47,9 @@ class BaseWallClock : public Engine { static bool inSyscall(void* ucontext); template - void timerLoopCommon(CollectThreadsFunc collectThreads, SampleThreadsFunc sampleThreads, CleanThreadFunc cleanThreads, int reservoirSize, u64 interval) { + void timerLoopCommon(CollectThreadsFunc collectThreads, SampleThreadsFunc sampleThreads, + CleanThreadFunc cleanThreads, int reservoirSize, u64 interval, + bool lazyBackfill = false) { if (!_enabled.load(std::memory_order_acquire)) { return; } @@ -55,6 +60,13 @@ class BaseWallClock : public Engine { std::random_device rd; std::mt19937 generator(rd()); std::normal_distribution distribution(interval, stddev); + std::mt19937 candidate_generator; + if (lazyBackfill) { + std::random_device candidate_rd; + std::seed_seq candidate_seed{candidate_rd(), candidate_rd(), + candidate_rd(), candidate_rd()}; + candidate_generator.seed(candidate_seed); + } std::vector threads; threads.reserve(reservoirSize); @@ -83,12 +95,44 @@ class BaseWallClock : public Engine { int num_failures = 0; int threads_already_exited = 0; int permission_denied = 0; + int registry_lookups = 0; u32 num_successful_samples = 0; - std::vector sample = reservoir.sample(threads); - for (ThreadType thread : sample) { - if (sampleThreads(thread, num_failures, threads_already_exited, permission_denied)) { - num_successful_samples++; + if (lazyBackfill) { + WallClockCandidateStats stats = selectWallClockCandidates( + threads, + static_cast(reservoirSize), + static_cast(reservoirSize) * + PRECHECK_VISIT_BUDGET_MULTIPLIER, + candidate_generator, + [&](ThreadType thread) { + WallClockCandidateOutcome outcome = sampleThreads( + thread, num_failures, threads_already_exited, + permission_denied, registry_lookups, true); + if (outcome == WallClockCandidateOutcome::SIGNAL_SENT) { + num_successful_samples++; + } + return outcome; + }); + if (stats.precheck_rejected > 0) { + Counters::increment(WC_PRECHECK_CANDIDATES_REJECTED, + stats.precheck_rejected); + } + if (stats.visit_limit_reached) { + Counters::increment(WC_PRECHECK_LOOKUP_BUDGET_EXHAUSTED); } + } else { + std::vector sample = reservoir.sample(threads); + for (ThreadType thread : sample) { + WallClockCandidateOutcome outcome = sampleThreads( + thread, num_failures, threads_already_exited, + permission_denied, registry_lookups, false); + if (outcome == WallClockCandidateOutcome::SIGNAL_SENT) { + num_successful_samples++; + } + } + } + if (registry_lookups > 0) { + Counters::increment(WC_PRECHECK_REGISTRY_LOOKUPS, registry_lookups); } epoch.updateNumSamplableThreads(threads.size()); @@ -159,6 +203,7 @@ class WallClockASGCT : public BaseWallClock { const char* name() override { return "WallClock (ASGCT)"; } + bool supportsUnfilteredWallPrecheck() const override { return true; } }; // Wall-clock engine that uses BaseWallClock's pthread reservoir sampling loop @@ -180,6 +225,7 @@ class WallClockJvmti : public BaseWallClock { const char* name() override { return "WallClock (JVMTI)"; } + bool supportsUnfilteredWallPrecheck() const override { return true; } }; #endif // _WALLCLOCK_H diff --git a/ddprof-lib/src/main/cpp/wallClockCandidateSelector.h b/ddprof-lib/src/main/cpp/wallClockCandidateSelector.h new file mode 100644 index 0000000000..8a8358e79b --- /dev/null +++ b/ddprof-lib/src/main/cpp/wallClockCandidateSelector.h @@ -0,0 +1,64 @@ +/* + * Copyright 2026 Datadog, Inc + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef WALL_CLOCK_CANDIDATE_SELECTOR_H +#define WALL_CLOCK_CANDIDATE_SELECTOR_H + +#include +#include +#include +#include +#include + +enum class WallClockCandidateOutcome { + SIGNAL_SENT, + SIGNAL_FAILED, + PRECHECK_REJECTED, +}; + +struct WallClockCandidateStats { + size_t visited = 0; + size_t slots_consumed = 0; + size_t precheck_rejected = 0; + bool visit_limit_reached = false; +}; + +// Visits a uniformly randomized prefix without replacement. A precheck rejection +// is the only outcome that does not consume target capacity. This runs only on +// the wall-clock timer thread. +template +WallClockCandidateStats selectWallClockCandidates(std::vector& candidates, + size_t target_size, + size_t visit_limit, + URBG& generator, + Visitor&& visitor) { + WallClockCandidateStats stats; + if (target_size == 0 || candidates.empty() || visit_limit == 0) { + return stats; + } + + size_t max_visits = std::min(candidates.size(), visit_limit); + for (size_t i = 0; i < max_visits && stats.slots_consumed < target_size; ++i) { + std::uniform_int_distribution next(i, candidates.size() - 1); + size_t selected = next(generator); + if (selected != i) { + std::swap(candidates[i], candidates[selected]); + } + + stats.visited++; + WallClockCandidateOutcome outcome = visitor(candidates[i]); + if (outcome == WallClockCandidateOutcome::PRECHECK_REJECTED) { + stats.precheck_rejected++; + } else { + stats.slots_consumed++; + } + } + stats.visit_limit_reached = + stats.slots_consumed < target_size && + stats.visited == max_visits && max_visits < candidates.size(); + return stats; +} + +#endif // WALL_CLOCK_CANDIDATE_SELECTOR_H diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 4608e11697..cb3755ff72 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. @@ -185,7 +185,9 @@ TEST_F(ThreadFilterTest, RecoveryAfterMaxCapacity) { int slot_id = filter->registerThread(); EXPECT_GE(slot_id, 0) << "Failed to register slot " << i << " after freeing"; new_slot_ids.push_back(slot_id); - filter->add(i + 3000, slot_id); + // Keep replacement identities disjoint from the still-live 3024..4047 + // range. The registry intentionally rejects two slots for one native TID. + filter->add(i + 5000, slot_id); } // Verify we can still register up to capacity @@ -552,3 +554,399 @@ TEST_F(ThreadFilterTest, TokenRoundTripPreservesHighGenerationBit) { EXPECT_EQ(slot_id, ThreadFilter::tokenSlotId(static_cast(java_token))); EXPECT_EQ(generation, ThreadFilter::tokenGeneration(static_cast(java_token))); } + +class ThreadRegistryTest : public ::testing::Test { +protected: + void SetUp() override { + registry.init("", true); + } + + ThreadFilter registry; +}; + +TEST_F(ThreadRegistryTest, UnfilteredTrackingSeparatesRegistrationFromContextWindow) { + + EXPECT_TRUE(registry.registryActive()); + EXPECT_TRUE(registry.unfilteredWallTrackingActive()); + 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 context; + registry.collect(context); + EXPECT_TRUE(context.empty()); + + registry.add(1234, slot_id); + registry.collect(context); + ASSERT_EQ(1u, context.size()); + EXPECT_EQ(1234, context[0].tid); + + registry.remove(slot_id); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(slot, registry.lookupByTid(1234)); + registry.collect(context); + EXPECT_TRUE(context.empty()); +} + +TEST_F(ThreadRegistryTest, RegisteringKnownTidReturnsExistingSlotWithoutMutation) { + constexpr int tid = 4321; + int slot_id = registry.registerThread(tid); + 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(0ULL, token); + slot->markSampledThisRun(OSThreadState::SLEEPING); + + u64 lifecycle_generation = slot->lifecycleGeneration(); + EXPECT_EQ(slot_id, registry.registerThread(tid)); + EXPECT_EQ(slot, registry.lookupByTid(tid)); + EXPECT_EQ(lifecycle_generation, slot->lifecycleGeneration()); + EXPECT_EQ(OSThreadState::SLEEPING, slot->activeBlockState()); + EXPECT_TRUE(slot->sampledThisRun()); + EXPECT_TRUE(registry.exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); +} + +TEST_F(ThreadRegistryTest, ConcurrentSameTidRegistrationConvergesOnOneSlot) { + constexpr int thread_count = 32; + constexpr int tid = 8765; + std::atomic ready{0}; + std::atomic start{false}; + std::vector slots(thread_count, -1); + std::vector threads; + threads.reserve(thread_count); + + for (int i = 0; i < thread_count; ++i) { + threads.emplace_back([&, i] { + ready.fetch_add(1, std::memory_order_release); + while (!start.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + slots[i] = registry.registerThread(tid); + }); + } + + while (ready.load(std::memory_order_acquire) != thread_count) { + std::this_thread::yield(); + } + start.store(true, std::memory_order_release); + for (std::thread& thread : threads) { + thread.join(); + } + + ASSERT_GE(slots[0], 0); + for (int slot_id : slots) { + EXPECT_EQ(slots[0], slot_id); + } + ThreadFilter::Slot* slot = registry.slotForId(slots[0]); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(tid, slot->nativeTid()); + EXPECT_EQ(slot, registry.lookupByTid(tid)); +} + +TEST_F(ThreadRegistryTest, ContextWindowTransitionsAreIdempotent) { + 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_F(ThreadRegistryTest, SlotReuseChangesLifecycleGenerationAndTidMapping) { + 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_F(ThreadRegistryTest, ContextTransitionInvalidatesOwnedRunSuppression) { + 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); + slot->markSampledThisRun(OSThreadState::SLEEPING); + EXPECT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); + + registry.add(3333, slot_id); + registry.remove(slot_id); + EXPECT_FALSE(slot->activeBlockRemainedOutsideContextWindow()); + + ThreadEntry entry{3333, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + EXPECT_FALSE(registry.shouldSuppressOwnedBlock(entry)); +} + +TEST_F(ThreadRegistryTest, UnfilteredSuppressionValidatesIdentityAndLifecycle) { + int slot_id = registry.registerThread(4444); + 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); + slot->markSampledThisRun(OSThreadState::SLEEPING); + ThreadEntry entry{4444, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + EXPECT_TRUE(registry.shouldSuppressOwnedBlock(entry)); + + ThreadEntry wrong_tid{4445, slot, entry.lifecycle_generation, + entry.recording_epoch}; + EXPECT_FALSE(registry.shouldSuppressOwnedBlock(wrong_tid)); + ThreadEntry stale_generation{4444, slot, entry.lifecycle_generation + 1, + entry.recording_epoch}; + EXPECT_FALSE(registry.shouldSuppressOwnedBlock(stale_generation)); + + EXPECT_TRUE(registry.exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + EXPECT_FALSE(registry.shouldSuppressOwnedBlock(entry)); +} + +TEST_F(ThreadRegistryTest, ContextFilteredSuppressionPreservesHistoricalEligibility) { + registry.init("0"); + int slot_id = registry.registerThread(5555); + ASSERT_GE(slot_id, 0); + registry.add(5555, slot_id); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0u, token); + slot->markSampledThisRun(OSThreadState::SLEEPING); + ThreadEntry entry{5555, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + EXPECT_TRUE(registry.shouldSuppressOwnedBlock(entry)); +} + +TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { + constexpr int tid = 5601; + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_NE(0u, registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING)); + slot->markSampledThisRun(OSThreadState::SLEEPING); + ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + + struct SnapshotPause { + std::atomic reached{false}; + std::atomic resume{false}; + } pause; + registry.setSuppressionSnapshotHookForTest( + [](void* raw) { + SnapshotPause* pause = static_cast(raw); + pause->reached.store(true, std::memory_order_release); + while (!pause->resume.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + }, + &pause); + + std::atomic suppressed{true}; + std::thread reader([&] { + suppressed.store(registry.shouldSuppressOwnedBlock(stale), + std::memory_order_release); + }); + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (!pause.reached.load(std::memory_order_acquire) && + std::chrono::steady_clock::now() < deadline) { + std::this_thread::yield(); + } + if (!pause.reached.load(std::memory_order_acquire)) { + pause.resume.store(true, std::memory_order_release); + reader.join(); + registry.setSuppressionSnapshotHookForTest(nullptr, nullptr); + GTEST_FAIL() << "Suppression reader did not reach the snapshot barrier"; + } + + registry.unregisterThread(slot_id, tid); + int reused_id = registry.registerThread(tid); + ThreadFilter::Slot* reused = registry.slotForId(reused_id); + u64 new_token = registry.enterBlockedRun(reused_id, OSThreadState::SLEEPING); + if (reused != nullptr && new_token != 0) { + reused->markSampledThisRun(OSThreadState::SLEEPING); + } + + pause.resume.store(true, std::memory_order_release); + reader.join(); + registry.setSuppressionSnapshotHookForTest(nullptr, nullptr); + + ASSERT_EQ(slot_id, reused_id); + ASSERT_NE(nullptr, reused); + ASSERT_NE(0u, new_token); + EXPECT_FALSE(suppressed.load(std::memory_order_acquire)); +} + +TEST_F(ThreadRegistryTest, TidIndexRemainsReusableAcrossLongThreadChurn) { + 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_F(ThreadRegistryTest, ConfigurationSeparatesFilterAndUnfilteredTracking) { + registry.init("0", false); + EXPECT_TRUE(registry.enabled()); + EXPECT_TRUE(registry.registryActive()); + EXPECT_FALSE(registry.unfilteredWallTrackingActive()); + + registry.init("", false); + EXPECT_FALSE(registry.enabled()); + EXPECT_FALSE(registry.registryActive()); + EXPECT_FALSE(registry.unfilteredWallTrackingActive()); + + registry.init("", true); + EXPECT_FALSE(registry.enabled()); + EXPECT_TRUE(registry.registryActive()); + EXPECT_TRUE(registry.unfilteredWallTrackingActive()); +} + +TEST_F(ThreadRegistryTest, RecordingEpochMakesRetainedSlotInactiveUntilRefresh) { + constexpr int tid = 6101; + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ThreadFilter::RecordingEpoch first_epoch = registry.recordingEpoch(); + ASSERT_NE(0u, first_epoch); + EXPECT_EQ(slot, registry.lookupByTid(tid, first_epoch)); + + u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0u, token); + slot->markSampledThisRun(OSThreadState::SLEEPING); + ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + ASSERT_TRUE(registry.shouldSuppressOwnedBlock(stale)); + + registry.init("", true); + ThreadFilter::RecordingEpoch second_epoch = registry.recordingEpoch(); + ASSERT_NE(first_epoch, second_epoch); + EXPECT_EQ(nullptr, registry.lookupByTid(tid, first_epoch)); + EXPECT_EQ(nullptr, registry.lookupByTid(tid, second_epoch)); + EXPECT_FALSE(registry.shouldSuppressOwnedBlock(stale)); + + EXPECT_EQ(slot_id, registry.registerThread(tid)); + EXPECT_EQ(slot, registry.lookupByTid(tid, second_epoch)); + EXPECT_FALSE(slot->sampledThisRun()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); +} + +TEST_F(ThreadRegistryTest, RetiresOnlySlotsNotRefreshedIntoCurrentEpoch) { + int retained_id = registry.registerThread(6103); + int stale_id = registry.registerThread(6104); + ASSERT_GE(retained_id, 0); + ASSERT_GE(stale_id, 0); + + registry.init("", true); + ThreadFilter::RecordingEpoch current_epoch = registry.recordingEpoch(); + EXPECT_EQ(retained_id, registry.registerThread(6103)); + + EXPECT_EQ(1, registry.retireInactiveRegistrations()); + EXPECT_NE(nullptr, registry.lookupByTid(6103, current_epoch)); + EXPECT_EQ(nullptr, registry.lookupByTid(6104)); + EXPECT_EQ(-1, registry.slotForId(stale_id)->nativeTid()); +} + +TEST_F(ThreadRegistryTest, ConcurrentRefreshAndRetirementKeepCurrentIdentity) { + constexpr int tid = 6110; + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0); + + for (int iteration = 0; iteration < 100; ++iteration) { + registry.init("", true); + ThreadFilter::RecordingEpoch epoch = registry.recordingEpoch(); + std::atomic start{false}; + int refreshed_id = -1; + + std::thread refresh([&] { + while (!start.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + refreshed_id = registry.registerThread(tid); + }); + std::thread retire([&] { + while (!start.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + registry.retireInactiveRegistrations(); + }); + + start.store(true, std::memory_order_release); + refresh.join(); + retire.join(); + + ASSERT_GE(refreshed_id, 0); + ThreadFilter::Slot* slot = registry.lookupByTid(tid, epoch); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(tid, slot->nativeTid()); + slot_id = refreshed_id; + } + EXPECT_EQ(slot_id, registry.registerThread(tid)); +} + +TEST_F(ThreadRegistryTest, ExpectedTidProtectsReusedSlotDuringTeardown) { + int slot_id = registry.registerThread(6105); + ASSERT_GE(slot_id, 0); + registry.unregisterThread(slot_id, 9999); + EXPECT_NE(nullptr, registry.lookupByTid(6105)); + + registry.unregisterThread(slot_id, 6105); + EXPECT_EQ(nullptr, registry.lookupByTid(6105)); +} + +TEST_F(ThreadRegistryTest, DeactivationMakesSlotsIneligibleWithoutClearingStorage) { + constexpr int tid = 6106; + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ThreadFilter::RecordingEpoch epoch = registry.recordingEpoch(); + + registry.deactivateRecording(); + EXPECT_FALSE(registry.registryActive()); + EXPECT_FALSE(registry.unfilteredWallTrackingActive()); + EXPECT_EQ(0u, registry.recordingEpoch()); + EXPECT_EQ(nullptr, registry.lookupByTid(tid, epoch)); + EXPECT_EQ(slot, registry.lookupByTid(tid)); + EXPECT_EQ(-1, registry.registerThread(7777)); +} diff --git a/ddprof-lib/src/test/cpp/wallClockCandidateSelector_ut.cpp b/ddprof-lib/src/test/cpp/wallClockCandidateSelector_ut.cpp new file mode 100644 index 0000000000..77071af433 --- /dev/null +++ b/ddprof-lib/src/test/cpp/wallClockCandidateSelector_ut.cpp @@ -0,0 +1,173 @@ +/* + * Copyright 2026 Datadog, Inc + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#include "wallClockCandidateSelector.h" + +#include +#include +#include +#include + +static std::vector makeCandidates(size_t count) { + std::vector candidates(count); + std::iota(candidates.begin(), candidates.end(), 0); + return candidates; +} + +TEST(WallClockCandidateSelectorTest, VisitsOnlyTargetSizeWithoutRejections) { + std::vector candidates = makeCandidates(1000); + std::mt19937 generator(1234); + std::set selected; + + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, 10, 40, generator, [&](int tid) { + selected.insert(tid); + return WallClockCandidateOutcome::SIGNAL_SENT; + }); + + EXPECT_EQ(10u, stats.visited); + EXPECT_EQ(10u, stats.slots_consumed); + EXPECT_EQ(0u, stats.precheck_rejected); + EXPECT_EQ(10u, selected.size()); +} + +TEST(WallClockCandidateSelectorTest, PrecheckRejectedCandidatesAreBackfilled) { + std::vector candidates = makeCandidates(100); + std::mt19937 generator(42); + std::set selected; + + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, 8, 100, generator, [&](int tid) { + if ((tid & 1) == 0) { + return WallClockCandidateOutcome::PRECHECK_REJECTED; + } + selected.insert(tid); + return WallClockCandidateOutcome::SIGNAL_SENT; + }); + + EXPECT_EQ(8u, stats.slots_consumed); + EXPECT_EQ(stats.slots_consumed + stats.precheck_rejected, stats.visited); + EXPECT_EQ(8u, selected.size()); + for (int tid : selected) { + EXPECT_EQ(1, tid & 1); + } +} + +TEST(WallClockCandidateSelectorTest, AllPrecheckRejectedCandidatesRespectVisitLimit) { + std::vector candidates = makeCandidates(257); + std::mt19937 generator(7); + std::set visited; + + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, 10, 40, generator, [&](int tid) { + visited.insert(tid); + return WallClockCandidateOutcome::PRECHECK_REJECTED; + }); + + EXPECT_EQ(40u, stats.visited); + EXPECT_EQ(0u, stats.slots_consumed); + EXPECT_EQ(40u, stats.precheck_rejected); + EXPECT_EQ(40u, visited.size()); + EXPECT_TRUE(stats.visit_limit_reached); +} + +TEST(WallClockCandidateSelectorTest, SignalFailureConsumesCapacityWithoutBackfill) { + std::vector candidates{1, 2, 3, 4, 5}; + std::mt19937 generator(17); + int callbacks = 0; + + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, 3, 12, generator, [&](int) { + callbacks++; + return WallClockCandidateOutcome::SIGNAL_FAILED; + }); + + EXPECT_EQ(3, callbacks); + EXPECT_EQ(3u, stats.visited); + EXPECT_EQ(3u, stats.slots_consumed); + EXPECT_EQ(0u, stats.precheck_rejected); +} + +TEST(WallClockCandidateSelectorTest, EmptyBoundsDoNoWork) { + std::vector candidates{1, 2, 3}; + std::vector empty; + std::mt19937 generator(1); + int callbacks = 0; + auto visitor = [&](int) { + callbacks++; + return WallClockCandidateOutcome::SIGNAL_SENT; + }; + + WallClockCandidateStats zero_target = + selectWallClockCandidates(candidates, 0, 3, generator, visitor); + WallClockCandidateStats empty_input = + selectWallClockCandidates(empty, 3, 3, generator, visitor); + WallClockCandidateStats zero_visits = + selectWallClockCandidates(candidates, 3, 0, generator, visitor); + + EXPECT_EQ(0, callbacks); + EXPECT_EQ(0u, zero_target.visited); + EXPECT_EQ(0u, empty_input.visited); + EXPECT_EQ(0u, zero_visits.visited); +} + +TEST(WallClockCandidateSelectorTest, FixedSeedProducesDeterministicTraversal) { + std::vector first = makeCandidates(50); + std::vector second = first; + std::mt19937 first_generator(2026); + std::mt19937 second_generator(2026); + std::vector first_result; + std::vector second_result; + + selectWallClockCandidates(first, 12, 24, first_generator, [&](int tid) { + first_result.push_back(tid); + return WallClockCandidateOutcome::SIGNAL_SENT; + }); + selectWallClockCandidates(second, 12, 24, second_generator, [&](int tid) { + second_result.push_back(tid); + return WallClockCandidateOutcome::SIGNAL_SENT; + }); + + EXPECT_EQ(first_result, second_result); +} + +TEST(WallClockCandidateSelectorTest, RandomizedPrefixRemainsFairAcrossCandidates) { + constexpr int candidate_count = 20; + constexpr int sample_size = 4; + constexpr int rounds = 10000; + std::vector candidates(candidate_count); + std::vector selections(candidate_count, 0); + std::mt19937 generator(2026); + + for (int round = 0; round < rounds; ++round) { + std::iota(candidates.begin(), candidates.end(), 0); + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, sample_size, sample_size, generator, [&](int candidate) { + selections[candidate]++; + return WallClockCandidateOutcome::SIGNAL_SENT; + }); + ASSERT_EQ(sample_size, stats.slots_consumed); + } + + constexpr int expected = rounds * sample_size / candidate_count; + for (int count : selections) { + EXPECT_NEAR(expected, count, expected / 10); + } +} + +TEST(WallClockCandidateSelectorTest, ExhaustingInputDoesNotReportVisitLimit) { + std::vector candidates{1, 2, 3}; + std::mt19937 generator(8); + + WallClockCandidateStats stats = selectWallClockCandidates( + candidates, 5, 20, generator, + [](int) { return WallClockCandidateOutcome::PRECHECK_REJECTED; }); + + EXPECT_EQ(3u, stats.visited); + EXPECT_EQ(3u, stats.precheck_rejected); + EXPECT_FALSE(stats.visit_limit_reached); +} diff --git a/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp index 5579e354fe..c8b66591b3 100644 --- a/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp +++ b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp @@ -5,6 +5,21 @@ #include #include "arguments.h" +#include "engine.h" +#include "j9/j9WallClock.h" +#include "wallClock.h" + +TEST(WallPrecheckCapabilityTest, OnlySupportingWallEnginesAdvertiseUnfilteredTracking) { + Engine engine; + J9WallClock j9; + WallClockASGCT asgct; + WallClockJvmti jvmti; + + EXPECT_FALSE(engine.supportsUnfilteredWallPrecheck()); + EXPECT_FALSE(j9.supportsUnfilteredWallPrecheck()); + EXPECT_TRUE(asgct.supportsUnfilteredWallPrecheck()); + EXPECT_TRUE(jvmti.supportsUnfilteredWallPrecheck()); +} TEST(WallPrecheckArgsTest, DefaultsToDisabled) { Arguments args; @@ -56,3 +71,17 @@ TEST(WallPrecheckArgsTest, EnabledWithinLongerArgString) { EXPECT_TRUE(args._wall_precheck); } +TEST(WallPrecheckArgsTest, OmittedFilterRemainsNull) { + Arguments args; + + EXPECT_EQ(nullptr, args._filter); +} + +TEST(WallPrecheckArgsTest, ExplicitEmptyFilterIsPreserved) { + Arguments args; + Error error = args.parse("filter="); + + EXPECT_FALSE(error); + ASSERT_NE(nullptr, args._filter); + EXPECT_STREQ("", args._filter); +} diff --git a/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/WallClockPrecheckBenchmarkHooks.java b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/WallClockPrecheckBenchmarkHooks.java new file mode 100644 index 0000000000..ac96ec5b85 --- /dev/null +++ b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/WallClockPrecheckBenchmarkHooks.java @@ -0,0 +1,21 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler; + +/** Exposes package-scoped owned-block hooks to the wall-clock overhead benchmark. */ +public final class WallClockPrecheckBenchmarkHooks { + private WallClockPrecheckBenchmarkHooks() {} + + /** Marks the current benchmark worker as entering an owned sleeping interval. */ + public static long enterSleeping(JavaProfiler profiler) { + return profiler.blockEnter(7); + } + + /** Closes an interval returned by {@link #enterSleeping(JavaProfiler)}. */ + public static void exit(JavaProfiler profiler, long token) { + profiler.blockExit(token); + } +} diff --git a/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/WallClockPrecheckOverheadBenchmark.java b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/WallClockPrecheckOverheadBenchmark.java new file mode 100644 index 0000000000..1af41a979f --- /dev/null +++ b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/WallClockPrecheckOverheadBenchmark.java @@ -0,0 +1,145 @@ +/* + * 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. + */ +package com.datadoghq.profiler.stresstest.scenarios.throughput; + +import com.datadoghq.profiler.JavaProfiler; +import com.datadoghq.profiler.WallClockPrecheckBenchmarkHooks; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures steady-state wall-clock timer overhead as an owned-block thread population grows. + * + *

The implementation separately records registry lookup work in the {@code + * wc_precheck_registry_lookups} debug counter and bounds candidate visits to four times {@code + * walltpt} per tick. This benchmark does not report that counter; it compares {@code + * precheck=false} and {@code precheck=true} at each population to detect timer-loop throughput + * regressions independently of one-time startup registration. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Fork(value = 1, warmups = 0, jvmArgsAppend = "-Xss256k") +@Warmup(iterations = 3, time = 2) +@Measurement(iterations = 5, time = 3) +@State(Scope.Benchmark) +public class WallClockPrecheckOverheadBenchmark { + @Param({"false", "true"}) + public boolean precheck; + + @Param({"100", "500", "1000"}) + public int threadCount; + + private final List workers = new ArrayList<>(); + private volatile boolean running; + private JavaProfiler profiler; + private Path recording; + + /** Creates the requested thread population before starting the profiler. */ + @Setup(Level.Trial) + public void setup() throws Exception { + running = true; + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch profilerStarted = new CountDownLatch(1); + CountDownLatch armed = new CountDownLatch(threadCount); + for (int i = 0; i < threadCount; ++i) { + Thread worker = + new Thread( + () -> { + ready.countDown(); + long token = 0; + boolean armedReported = false; + try { + profilerStarted.await(); + token = WallClockPrecheckBenchmarkHooks.enterSleeping(profiler); + armed.countDown(); + armedReported = true; + while (running) { + LockSupport.parkNanos(TimeUnit.SECONDS.toNanos(1)); + } + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + } finally { + if (!armedReported) { + armed.countDown(); + } + WallClockPrecheckBenchmarkHooks.exit(profiler, token); + } + }, + "wall-precheck-benchmark-" + i); + worker.setDaemon(true); + worker.start(); + workers.add(worker); + } + if (!ready.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out creating benchmark workers"); + } + + profiler = JavaProfiler.getInstance(); + recording = Files.createTempFile("wall-precheck-overhead-", ".jfr"); + profiler.execute( + "start,wall=1ms,walltpt=16,filter=,wallprecheck=" + + precheck + + ",jfr,file=" + + recording.toAbsolutePath()); + profilerStarted.countDown(); + if (!armed.await(30, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out arming benchmark workers"); + } + } + + /** Stops profiling and releases every background worker. */ + @TearDown(Level.Trial) + public void tearDown() throws Exception { + running = false; + for (Thread worker : workers) { + LockSupport.unpark(worker); + } + for (Thread worker : workers) { + worker.join(TimeUnit.SECONDS.toMillis(5)); + } + workers.clear(); + if (profiler != null) { + profiler.stop(); + } + if (recording != null) { + Files.deleteIfExists(recording); + } + } + + /** Provides stable foreground work whose throughput captures timer-loop interference. */ + @Benchmark + public void foregroundWork() { + org.openjdk.jmh.infra.Blackhole.consumeCPU(1_000); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java index de75c2f068..ed84e9f497 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java @@ -1,3 +1,8 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + package com.datadoghq.profiler; import java.nio.file.Files; @@ -233,6 +238,7 @@ public void setupProfiler(TestInfo testInfo) throws Exception { jfrDump = Files.createTempFile(rootDir, testInfo.getTestMethod().map(m -> m.getDeclaringClass().getSimpleName() + "_" + m.getName()).orElse("unknown") + (testConfig.isEmpty() ? "" : "-" + testConfig.replace('/', '_')), ".jfr"); profiler = JavaProfiler.getInstance(); + beforeProfilerStart(); String command = "start," + getAmendedProfilerCommand() + ",jfr,file=" + jfrDump.toAbsolutePath(); cpuInterval = command.contains("cpu") ? parseInterval(command, "cpu") : (command.contains("interval") ? parseInterval(command, "interval") : Duration.ZERO); wallInterval = parseInterval(command, "wall"); @@ -268,6 +274,17 @@ public void cleanup() throws Exception { protected void before() throws Exception { } + /** + * Runs after the profiler instance is available but before the recording starts. + * + *

Tests may override this hook when their setup must predate profiler thread-event + * registration. + * + * @throws Exception if setup fails + */ + protected void beforeProfilerStart() throws Exception { + } + protected void after() throws Exception { } @@ -504,4 +521,4 @@ public long getRecordedCounterValue(String counterName) { } return -1; } -} \ No newline at end of file +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java new file mode 100644 index 0000000000..f91eeef578 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java @@ -0,0 +1,36 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.ProfilerOwnedBlockHooks; +import org.junit.jupiter.api.Test; + +/** Verifies that unsupported J9 wall sampling does not activate unfiltered precheck tracking. */ +public class J9WallClockPrecheckCapabilityTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + + /** Ensures owned-block hooks stay inactive when the selected wall engine cannot consume them. */ + @Test + public void unsupportedEngineDoesNotActivateRegistry() { + long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); + + assertEquals(0L, token, "J9WallClock must not activate unfiltered precheck tracking"); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isJ9(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallsampler=jvmti,filter=,wallprecheck=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedUnfilteredWallPrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedUnfilteredWallPrecheckTest.java new file mode 100644 index 0000000000..1c5491e3a9 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedUnfilteredWallPrecheckTest.java @@ -0,0 +1,44 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; +import org.junit.jupiter.api.Assumptions; + +/** Runs unfiltered owned-block precheck coverage through delegated JVMTI stack collection. */ +public class JvmtiBasedUnfilteredWallPrecheckTest extends UnfilteredWallPrecheckTest { + private boolean jvmtiDelegationAvailable; + private long requestedBefore; + + @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"); + jvmtiDelegationAvailable = true; + requestedBefore = counters.getOrDefault("jvmti_stacks_requested", 0L); + } + + @Override + protected void after() { + if (!jvmtiDelegationAvailable) { + return; + } + long requestedAfter = + profiler.getDebugCounters().getOrDefault("jvmti_stacks_requested", 0L); + assertTrue( + requestedAfter > requestedBefore, + "Expected wallclock jvmtistacks path to request delegated stack traces"); + } + + @Override + protected String getProfilerCommand() { + return super.getProfilerCommand() + ",jvmtistacks=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckRestartTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckRestartTest.java new file mode 100644 index 0000000000..afc78ad543 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckRestartTest.java @@ -0,0 +1,127 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.ProfilerOwnedBlockHooks; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +/** Verifies that unfiltered wall registry activation does not leak across recordings. */ +public class UnfilteredWallPrecheckRestartTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + + /** Exercises enabled, disabled, CPU-only, and re-enabled tracking in one process. */ + @Test + public void recordingRestartsReconfigureUnfilteredTracking() throws Exception { + assertOwnedBlockArmed(); + stopProfiler(); + + runRecording("wall=1ms,filter=,wallprecheck=false", false); + runRecording("cpu=1ms,filter=,wallprecheck=true", false); + runRecording("wall=1ms,filter=,wallprecheck=true", true); + } + + /** Verifies epoch refresh and lazy registration for workers that survive a stopped gap. */ + @Test + public void workerLifecyclesRemainSafeAcrossStoppedGap() throws Exception { + ExecutorService survivingWorker = Executors.newSingleThreadExecutor(); + ExecutorService stoppedGapWorker = null; + Path recording = null; + boolean restarted = false; + try { + long oldToken = enterBlock(survivingWorker); + assertNotEquals(0L, oldToken, "Expected the initial worker run to be armed"); + + stopProfiler(); + stoppedGapWorker = Executors.newSingleThreadExecutor(); + // Force creation while JVMTI lifecycle callbacks are disabled. + assertEquals(0L, enterBlock(stoppedGapWorker)); + + recording = Files.createTempFile("unfiltered-wall-worker-restart-", ".jfr"); + profiler.execute( + "start," + getProfilerCommand() + ",jfr,file=" + recording.toAbsolutePath()); + restarted = true; + + long newToken = enterBlock(survivingWorker); + assertNotEquals(0L, newToken, "Expected the surviving worker to refresh its slot"); + exitBlock(survivingWorker, oldToken); + assertEquals( + 0L, + enterBlock(survivingWorker), + "A token from the previous recording cleared the current worker run"); + exitBlock(survivingWorker, newToken); + + long stoppedGapToken = enterBlock(stoppedGapWorker); + assertNotEquals( + 0L, stoppedGapToken, "Expected the stopped-gap worker to register lazily after restart"); + exitBlock(stoppedGapWorker, stoppedGapToken); + } finally { + if (restarted) { + profiler.stop(); + } + survivingWorker.shutdownNow(); + if (stoppedGapWorker != null) { + stoppedGapWorker.shutdownNow(); + } + if (recording != null) { + Files.deleteIfExists(recording); + } + } + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,wallprecheck=true"; + } + + @Override + protected boolean isPlatformSupported() { + return !Platform.isJ9(); + } + + private void assertOwnedBlockArmed() { + long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); + assertNotEquals(0L, token, "Expected unfiltered wall precheck to arm the owned block"); + ProfilerOwnedBlockHooks.blockExit(profiler, token); + } + + private void runRecording(String command, boolean expectArmed) throws Exception { + Path recording = Files.createTempFile("unfiltered-wall-restart-", ".jfr"); + profiler.execute("start," + command + ",jfr,file=" + recording.toAbsolutePath()); + try { + long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); + if (expectArmed) { + assertNotEquals(0L, token, "Expected unfiltered wall tracking after restart"); + ProfilerOwnedBlockHooks.blockExit(profiler, token); + } else { + assertEquals(0L, token, "Registry tracking leaked into " + command); + } + } finally { + profiler.stop(); + Files.deleteIfExists(recording); + } + } + + private long enterBlock(ExecutorService worker) throws Exception { + Future result = + worker.submit( + () -> ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING)); + return result.get(); + } + + private void exitBlock(ExecutorService worker, long token) throws Exception { + worker.submit(() -> ProfilerOwnedBlockHooks.blockExit(profiler, token)).get(); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java new file mode 100644 index 0000000000..d98f619f10 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java @@ -0,0 +1,239 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import com.datadoghq.profiler.ProfilerOwnedBlockHooks; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.FutureTask; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assumptions; +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 owned-block prechecks when legacy {@code filter=} samples every thread. */ +public class UnfilteredWallPrecheckTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + private static final long SLEEP_MILLIS = 300; + private static final String PRE_EXISTING_THREAD_NAME = "unfiltered-precheck-existing"; + private static final String SUPPRESSED_RUN_COUNTER = "wc_signals_suppressed_sampled_run"; + + private ExecutorService preExistingWorker; + private Thread preExistingThread; + + /** + * Verifies that an untraced thread's owned sleeping run is sampled once and then suppressed. + * + * @throws Exception if the worker cannot complete + */ + @RetryingTest(3) + public void sleepingThreadOutsideContextWindowIsOwnedBlockSuppressed() throws Exception { + long suppressedBefore = suppressedSignals(); + assertTrue( + runPreExistingSleepingWorker(false) != 0, + "Expected native blockEnter to arm SLEEPING state"); + + stopProfiler(); + assertSuppressedSamples(PRE_EXISTING_THREAD_NAME); + assertOwnedBlockSuppressionObserved(suppressedBefore); + } + + /** + * Verifies that entering the context window prevents owned-block suppression in an + * unfiltered recording. + * + * @throws Exception if the worker cannot complete + */ + @RetryingTest(3) + public void sleepingThreadInsideContextWindowIsNotOverSuppressed() throws Exception { + assertTrue( + runPreExistingSleepingWorker(true) != 0, + "Expected native blockEnter to arm SLEEPING state"); + + stopProfiler(); + + long sampleCount = samplesForThread(PRE_EXISTING_THREAD_NAME); + assertTrue( + sampleCount >= 10, + "Expected normal MethodSample volume inside the context window, got: " + sampleCount); + } + + /** + * Verifies that a pre-existing thread can lazily bind its slot through the park hook. + * + * @throws Exception if the worker cannot complete + */ + @RetryingTest(3) + public void parkedPreExistingThreadOutsideContextWindowIsOwnedBlockSuppressed() + throws Exception { + long suppressedBefore = suppressedSignals(); + runPreExistingParkedWorker(); + + stopProfiler(); + assertSuppressedSamples(PRE_EXISTING_THREAD_NAME); + assertOwnedBlockSuppressionObserved(suppressedBefore); + } + + /** + * Retains coverage for threads whose filter slot is installed by a post-start ThreadStart event. + * + * @throws Exception if the worker cannot complete + */ + @RetryingTest(3) + public void postStartSleepingThreadStillUsesThreadStartSlot() throws Exception { + String threadName = "unfiltered-precheck-post-start"; + assertTrue( + runPostStartSleepingWorker(threadName) != 0, + "Expected ThreadStart registration to arm SLEEPING state"); + + stopProfiler(); + assertSuppressedSamples(threadName); + } + + @Override + protected void beforeProfilerStart() throws Exception { + preExistingWorker = + Executors.newSingleThreadExecutor( + task -> { + Thread worker = new Thread(task, PRE_EXISTING_THREAD_NAME); + worker.setDaemon(true); + return worker; + }); + preExistingThread = preExistingWorker.submit(Thread::currentThread).get(); + } + + /** Stops the worker that was deliberately created before profiler startup. */ + @AfterEach + public void stopPreExistingWorker() throws InterruptedException { + if (preExistingWorker == null) { + return; + } + preExistingWorker.shutdownNow(); + assertTrue( + preExistingWorker.awaitTermination(5, TimeUnit.SECONDS), + "Pre-existing wall-clock worker did not terminate"); + } + + @Override + protected boolean isPlatformSupported() { + return !Platform.isJ9(); + } + + @Override + protected void withTestAssumptions() { + Assumptions.assumeTrue( + Platform.isJavaVersionAtLeast(11), + "Sleeping-state precheck assertions are stable on JDK 11+"); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,wallprecheck=true"; + } + + private long runPreExistingSleepingWorker(boolean enterContextWindowDuringBlock) + throws Exception { + Future sleep = + preExistingWorker.submit( + () -> { + assertSame(preExistingThread, Thread.currentThread()); + return runSleepingBlock(enterContextWindowDuringBlock); + }); + return sleep.get(); + } + + private long runPostStartSleepingWorker(String threadName) throws Exception { + FutureTask sleep = new FutureTask<>(() -> runSleepingBlock(false)); + Thread worker = new Thread(sleep, threadName); + worker.start(); + return sleep.get(); + } + + private long runSleepingBlock(boolean enterContextWindowDuringBlock) throws Exception { + long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); + if (enterContextWindowDuringBlock) { + profiler.addThread(); + } + try { + Thread.sleep(SLEEP_MILLIS); + return token; + } finally { + ProfilerOwnedBlockHooks.blockExit(profiler, token); + if (enterContextWindowDuringBlock) { + profiler.removeThread(); + } + } + } + + private void runPreExistingParkedWorker() throws Exception { + preExistingWorker + .submit( + () -> { + assertSame(preExistingThread, Thread.currentThread()); + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(SLEEP_MILLIS); + while (System.nanoTime() < deadline) { + // Keep the OS thread runnable so suppression must come from the owned park marker. + } + } finally { + ProfilerOwnedBlockHooks.parkExit( + profiler, System.identityHashCode(preExistingThread), 0L); + } + return null; + }) + .get(); + } + + private void assertOwnedBlockSuppressionObserved(long suppressedBefore) { + if (suppressedBefore >= 0) { + assertTrue( + suppressedSignals() > suppressedBefore, + "Expected owned-block once-per-run suppression counter to increase"); + } + } + + private long suppressedSignals() { + return profiler.getDebugCounters().getOrDefault(SUPPRESSED_RUN_COUNTER, -1L); + } + + private void assertSuppressedSamples(String threadName) { + long sampleCount = samplesForThread(threadName); + assertTrue(sampleCount > 0, "Expected the owned block run to be sampled once"); + assertTrue( + sampleCount < 10, + "Expected nearly no samples from owned block thread, got: " + sampleCount); + } + + 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; + } +} From ec71bb00bdd49d8d2e88e1a2611a3c5989dd936d Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 11:16:23 +0200 Subject: [PATCH 02/15] 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 | 99 +++++++- ddprof-lib/src/main/cpp/jfrMetadata.cpp | 18 +- ddprof-lib/src/main/cpp/jfrMetadata.h | 1 + ddprof-lib/src/main/cpp/jvmSupport.cpp | 24 +- 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 | 86 ++++--- ddprof-lib/src/main/cpp/threadFilter.h | 114 +++++---- ddprof-lib/src/main/cpp/threadLocalData.h | 24 +- ddprof-lib/src/main/cpp/wallClock.cpp | 69 ++---- 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 | 147 ++++++++--- .../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, 1514 insertions(+), 283 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 6337afc922..9f97973875 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -68,13 +68,19 @@ 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_PRECHECK_REGISTRY_LOOKUPS, "wc_precheck_registry_lookups") \ X(WC_PRECHECK_CANDIDATES_REJECTED, "wc_precheck_candidates_rejected") \ X(WC_PRECHECK_LOOKUP_BUDGET_EXHAUSTED, "wc_precheck_lookup_budget_exhausted") \ + 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 a622baf638..6a7c0914d3 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 a7be9f44b7..3b87480530 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -32,6 +32,7 @@ #include "otel_process_ctx.h" #include "profiler.h" #include "threadLocalData.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "vmEntry.h" #include @@ -417,14 +418,16 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (current == nullptr) { return 0; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (!tf->registryActive()) { + if (ContextApi::snapshot().spanId != 0) { return 0; } - ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); - if (slot_id < 0) { + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + if (!profiler->taskBlockEnabled() && !tf->enabled()) { return 0; } + ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + if (slot_id < 0) return 0; return static_cast(tf->enterBlockedRun(slot_id, decoded)); } @@ -450,6 +453,94 @@ Java_com_datadoghq_profiler_JavaProfiler_blockExit0( } } +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(); + if (!tf->unfilteredWallTrackingActive()) return 0; + ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + if (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 Java_com_datadoghq_profiler_JavaProfiler_currentTicks0(JNIEnv *env, jclass unused) { 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 783c34e458..e464924ffd 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -16,16 +16,38 @@ #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; - // This method must be called after JVM has been properly initialized, e.g. after JVMTI::VMinit() // callback. // Currently, there are two paths lead to this call // - JVMTI::VMInit() callback (vmEntry.cpp) // - JavaProfiler.getInstance() via JNI down call - JVM must have been initialized +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 295e966954..09ce3f3fb0 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -33,6 +33,7 @@ #include "stackFrame.h" #include "stackWalker.h" #include "symbols.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "utils.h" #include "wallClock.h" @@ -45,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -144,6 +146,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); @@ -151,6 +155,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) { @@ -727,6 +736,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) { @@ -1317,6 +1419,7 @@ Error Profiler::init() { 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; @@ -1515,6 +1618,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) { @@ -1612,6 +1716,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 && track_unfiltered_wall, + std::memory_order_release); _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1636,6 +1743,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 @@ -1653,6 +1761,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) @@ -1721,6 +1834,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 @@ -1802,10 +1916,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 f532e297a8..5c0e05eaf5 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 24c25825a5..fdf7de7cb8 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -32,6 +32,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), _track_unfiltered_wall(false) { // Initialize chunk pointers to null (lazy allocation) @@ -524,7 +533,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); } @@ -537,21 +547,28 @@ 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, - unfilteredWallTrackingActive())) { + u64 generation = 0; + if (!s->tryPrepareActiveBlockRun( + owner, &generation, unfilteredWallTrackingActive())) { 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; @@ -564,60 +581,75 @@ 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; } -bool ThreadFilter::shouldSuppressOwnedBlock(const ThreadEntry& entry) const { +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) { return false; } - const bool unfiltered_tracking = unfilteredWallTrackingActive(); RecordingEpoch epoch = 0; if (unfiltered_tracking) { epoch = recordingEpoch(); if (epoch == 0 || entry.recording_epoch != epoch || - slot->recordingEpoch() != epoch) { + slot->recordingEpoch() != epoch || + !slot->activeBlockRemainedOutsideContextWindow()) { return false; } } -#ifdef UNIT_TEST - if (_suppression_snapshot_hook != nullptr) { - _suppression_snapshot_hook(_suppression_snapshot_hook_arg); - } -#endif - - u32 block_generation = slot->blockGeneration(); + u64 block_generation = slot->blockGeneration(); BlockRunOwner owner = slot->activeBlockOwner(); OSThreadState state = slot->activeBlockState(); - bool context_eligible = - !unfiltered_tracking || slot->activeBlockRemainedOutsideContextWindow(); - bool sampled = slot->sampledThisRun(); - OSThreadState last_sampled_state = - sampled ? slot->lastSampledState() : OSThreadState::UNKNOWN; bool suppressible_state = state == OSThreadState::SLEEPING || state == OSThreadState::CONDVAR_WAIT || state == OSThreadState::OBJECT_WAIT || state == OSThreadState::MONITOR_WAIT; - if (owner == BlockRunOwner::NONE || !context_eligible || - !suppressible_state || !sampled || state != last_sampled_state) { - return false; + if (owner == BlockRunOwner::NONE || !suppressible_state) return false; + +#ifdef UNIT_TEST + if (_suppression_snapshot_hook != nullptr) { + _suppression_snapshot_hook(_suppression_snapshot_hook_arg); } +#endif // The payload is spread across independent atomics. Accept it only if the // slot still represents the lifecycle and block run captured by the timer. if (slot->activeBlockOwner() != owner || slot->blockGeneration() != block_generation || - slot->nativeTid() != entry.tid || + slot->activeBlockState() != state || slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation) { return false; } diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 2a73f37666..97816ff347 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; @@ -46,6 +54,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 @@ -70,23 +83,16 @@ class ThreadFilter { // release-published. std::atomic recording_epoch{0}; std::atomic active_block_context_epoch{0}; + std::atomic block_generation{0}; std::atomic unowned_blocked_state{OSThreadState::UNKNOWN}; // Native identity and context-window membership are independent so an // unfiltered wall recording can retain lifecycle metadata without // changing ordinary thread selection. 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) @@ -98,10 +104,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); @@ -141,21 +146,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); } @@ -165,7 +155,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() { @@ -205,9 +195,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; @@ -224,18 +214,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 { @@ -244,12 +241,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"); static_assert(std::atomic::is_always_lock_free, "Slot::recording_epoch must be lock-free for signal-handler safety"); @@ -278,10 +283,11 @@ 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); - // Reads the complete timer-side suppression payload and rejects it if slot - // identity or block lifecycle changes before final validation. - bool shouldSuppressOwnedBlock(const ThreadEntry& entry) const; + 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; #ifdef UNIT_TEST using SuppressionSnapshotHook = void (*)(void*); @@ -292,16 +298,28 @@ class ThreadFilter { } #endif - 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; @@ -320,6 +338,7 @@ class ThreadFilter { Slot* activeSlotForId(SlotID slot_id, int tid) const; int retireInactiveRegistrations(); void deactivateRecording(); + SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } private: @@ -355,6 +374,7 @@ class ThreadFilter { std::mutex _registry_lock; #ifdef UNIT_TEST + static std::atomic _block_run_publish_observer; SuppressionSnapshotHook _suppression_snapshot_hook = nullptr; void* _suppression_snapshot_hook_arg = nullptr; #endif diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 96364b6610..c01c81911a 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -77,6 +77,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) @@ -102,7 +105,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) { @@ -344,6 +349,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 562c5181da..429cc59aa9 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,18 +69,17 @@ 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 suppressAlreadySampledBlock(const ThreadEntry& entry) { - ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); - if (!thread_filter->shouldSuppressOwnedBlock(entry)) { - return false; +static inline bool suppressOwnedBlock(const ThreadEntry& entry) { + if (Profiler::instance()->threadFilter()->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); + return true; } - incrementSuppressedSampledRun(); - return true; + return false; } static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, @@ -105,31 +102,17 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, } // In an unfiltered recording, context threads keep their normal MethodSample - // stream. Only owned blocks that remain outside the context window may replace - // repeated signals. + // stream. TaskBlock replaces signals only for owned blocks that remain + // outside the context window. if (registry->unfilteredWallTrackingActive() && 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) && - (!registry->unfilteredWallTrackingActive() || - slot->activeBlockRemainedOutsideContextWindow()); - 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(), + slot->recordingEpoch()}; + if (registry->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); + result.suppress = true; return result; } @@ -163,9 +146,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, @@ -384,7 +364,7 @@ void WallClockASGCT::timerLoop() { } if (_precheck && !lazy_backfill) { entries.erase(std::remove_if(entries.begin(), entries.end(), - suppressAlreadySampledBlock), + suppressOwnedBlock), entries.end()); } }; @@ -402,11 +382,10 @@ void WallClockASGCT::timerLoop() { entry.recording_epoch = slot->recordingEpoch(); } } - // 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)) { + // Timer-thread fast path (wallprecheck=true): skip the kernel IPI while + // an explicit lifecycle hook owns a suppressible blocked run. Raw OS + // thread state cannot prove run boundaries for the target thread. + if (_precheck && suppressOwnedBlock(entry)) { return WallClockCandidateOutcome::PRECHECK_REJECTED; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { @@ -508,8 +487,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); @@ -549,7 +528,7 @@ void WallClockJvmti::timerLoop() { } if (_precheck && !lazy_backfill) { entries.erase(std::remove_if(entries.begin(), entries.end(), - suppressAlreadySampledBlock), + suppressOwnedBlock), entries.end()); } }; @@ -567,7 +546,7 @@ void WallClockJvmti::timerLoop() { entry.recording_epoch = slot->recordingEpoch(); } } - if (_precheck && suppressAlreadySampledBlock(entry)) { + if (_precheck && suppressOwnedBlock(entry)) { return WallClockCandidateOutcome::PRECHECK_REJECTED; } 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 8bd9ef8c66..e0f819eefe 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -138,7 +138,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 eab2de7580..87cd0cbc63 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -530,8 +530,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(); @@ -539,7 +539,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); @@ -547,7 +547,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 @@ -564,6 +564,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 @@ -627,6 +655,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 6557567ebc..efba2e5a77 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 cb3755ff72..08db166897 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -486,7 +486,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(); @@ -495,8 +494,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); @@ -544,15 +541,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)); } class ThreadRegistryTest : public ::testing::Test { @@ -603,14 +706,13 @@ TEST_F(ThreadRegistryTest, RegisteringKnownTidReturnsExistingSlotWithoutMutation u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0ULL, token); - slot->markSampledThisRun(OSThreadState::SLEEPING); u64 lifecycle_generation = slot->lifecycleGeneration(); EXPECT_EQ(slot_id, registry.registerThread(tid)); EXPECT_EQ(slot, registry.lookupByTid(tid)); EXPECT_EQ(lifecycle_generation, slot->lifecycleGeneration()); EXPECT_EQ(OSThreadState::SLEEPING, slot->activeBlockState()); - EXPECT_TRUE(slot->sampledThisRun()); + EXPECT_EQ(BlockRunOwner::JAVA, slot->activeBlockOwner()); EXPECT_TRUE(registry.exitBlockedRun( slot_id, ThreadFilter::tokenGeneration(token))); } @@ -699,7 +801,6 @@ TEST_F(ThreadRegistryTest, ContextTransitionInvalidatesOwnedRunSuppression) { u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); - slot->markSampledThisRun(OSThreadState::SLEEPING); EXPECT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); registry.add(3333, slot_id); @@ -708,7 +809,7 @@ TEST_F(ThreadRegistryTest, ContextTransitionInvalidatesOwnedRunSuppression) { ThreadEntry entry{3333, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - EXPECT_FALSE(registry.shouldSuppressOwnedBlock(entry)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(entry)); } TEST_F(ThreadRegistryTest, UnfilteredSuppressionValidatesIdentityAndLifecycle) { @@ -719,21 +820,20 @@ TEST_F(ThreadRegistryTest, UnfilteredSuppressionValidatesIdentityAndLifecycle) { u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); - slot->markSampledThisRun(OSThreadState::SLEEPING); ThreadEntry entry{4444, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - EXPECT_TRUE(registry.shouldSuppressOwnedBlock(entry)); + EXPECT_TRUE(registry.isOwnedBlockSuppressionCandidate(entry)); ThreadEntry wrong_tid{4445, slot, entry.lifecycle_generation, entry.recording_epoch}; - EXPECT_FALSE(registry.shouldSuppressOwnedBlock(wrong_tid)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(wrong_tid)); ThreadEntry stale_generation{4444, slot, entry.lifecycle_generation + 1, entry.recording_epoch}; - EXPECT_FALSE(registry.shouldSuppressOwnedBlock(stale_generation)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(stale_generation)); EXPECT_TRUE(registry.exitBlockedRun( slot_id, ThreadFilter::tokenGeneration(token))); - EXPECT_FALSE(registry.shouldSuppressOwnedBlock(entry)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(entry)); } TEST_F(ThreadRegistryTest, ContextFilteredSuppressionPreservesHistoricalEligibility) { @@ -746,10 +846,9 @@ TEST_F(ThreadRegistryTest, ContextFilteredSuppressionPreservesHistoricalEligibil u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); - slot->markSampledThisRun(OSThreadState::SLEEPING); ThreadEntry entry{5555, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - EXPECT_TRUE(registry.shouldSuppressOwnedBlock(entry)); + EXPECT_TRUE(registry.isOwnedBlockSuppressionCandidate(entry)); } TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { @@ -759,7 +858,6 @@ TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { ThreadFilter::Slot* slot = registry.slotForId(slot_id); ASSERT_NE(nullptr, slot); ASSERT_NE(0u, registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING)); - slot->markSampledThisRun(OSThreadState::SLEEPING); ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; @@ -779,7 +877,7 @@ TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { std::atomic suppressed{true}; std::thread reader([&] { - suppressed.store(registry.shouldSuppressOwnedBlock(stale), + suppressed.store(registry.isOwnedBlockSuppressionCandidate(stale), std::memory_order_release); }); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); @@ -798,9 +896,6 @@ TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { int reused_id = registry.registerThread(tid); ThreadFilter::Slot* reused = registry.slotForId(reused_id); u64 new_token = registry.enterBlockedRun(reused_id, OSThreadState::SLEEPING); - if (reused != nullptr && new_token != 0) { - reused->markSampledThisRun(OSThreadState::SLEEPING); - } pause.resume.store(true, std::memory_order_release); reader.join(); @@ -852,21 +947,19 @@ TEST_F(ThreadRegistryTest, RecordingEpochMakesRetainedSlotInactiveUntilRefresh) u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); - slot->markSampledThisRun(OSThreadState::SLEEPING); ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - ASSERT_TRUE(registry.shouldSuppressOwnedBlock(stale)); + ASSERT_TRUE(registry.isOwnedBlockSuppressionCandidate(stale)); registry.init("", true); ThreadFilter::RecordingEpoch second_epoch = registry.recordingEpoch(); ASSERT_NE(first_epoch, second_epoch); EXPECT_EQ(nullptr, registry.lookupByTid(tid, first_epoch)); EXPECT_EQ(nullptr, registry.lookupByTid(tid, second_epoch)); - EXPECT_FALSE(registry.shouldSuppressOwnedBlock(stale)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(stale)); EXPECT_EQ(slot_id, registry.registerThread(tid)); EXPECT_EQ(slot, registry.lookupByTid(tid, second_epoch)); - EXPECT_FALSE(slot->sampledThisRun()); EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); } 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 091f722a65e1dec5049be1fa2a393b3783a5fa9e Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 17:46:49 +0200 Subject: [PATCH 03/15] 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 0faffe8df2541e882ae90fc6b68be7c6c54ad596 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 22:27:13 +0200 Subject: [PATCH 04/15] fix: restrict block suppression to all-thread scope --- ddprof-lib/src/main/cpp/threadFilter.cpp | 22 ++++++-------- ddprof-lib/src/main/cpp/wallClock.cpp | 8 ++--- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 30 +++++++++++++++---- .../JavaProfilerTaskBlockApiTest.java | 2 +- .../JavaProfilerTaskBlockDisabledTest.java | 2 +- .../wallclock/JvmtiBasedPrecheckTest.java | 4 +-- .../wallclock/PrecheckEfficiencyTest.java | 6 ++-- .../profiler/wallclock/PrecheckTest.java | 7 ++--- .../WallclockMitigationsCombinedTest.java | 25 +++++++--------- 9 files changed, 58 insertions(+), 48 deletions(-) diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index fdf7de7cb8..8bfdc15e43 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -615,19 +615,16 @@ 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 (!unfilteredWallTrackingActive() || slot == nullptr || + slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation) { return false; } - const bool unfiltered_tracking = unfilteredWallTrackingActive(); - RecordingEpoch epoch = 0; - if (unfiltered_tracking) { - epoch = recordingEpoch(); - if (epoch == 0 || entry.recording_epoch != epoch || - slot->recordingEpoch() != epoch || - !slot->activeBlockRemainedOutsideContextWindow()) { - return false; - } + RecordingEpoch epoch = recordingEpoch(); + if (epoch == 0 || entry.recording_epoch != epoch || + slot->recordingEpoch() != epoch || + !slot->activeBlockRemainedOutsideContextWindow()) { + return false; } u64 block_generation = slot->blockGeneration(); @@ -653,9 +650,8 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( slot->lifecycleGeneration() != entry.lifecycle_generation) { return false; } - if (unfiltered_tracking && - (recordingEpoch() != epoch || slot->recordingEpoch() != epoch || - !slot->activeBlockRemainedOutsideContextWindow())) { + if (recordingEpoch() != epoch || slot->recordingEpoch() != epoch || + !slot->activeBlockRemainedOutsideContextWindow()) { return false; } return true; diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 429cc59aa9..fb0b444955 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -101,10 +101,10 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - // In an unfiltered recording, context threads keep their normal MethodSample - // stream. TaskBlock replaces signals only for owned blocks that remain - // outside the context window. - if (registry->unfilteredWallTrackingActive() && slot->inContextWindow()) { + // TaskBlock replaces signals only for threads that unfiltered wall-clock + // profiling observes outside the tracing context window. Context-scoped + // profiling must continue sampling its selected threads normally. + if (!registry->unfilteredWallTrackingActive() || 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 08db166897..26a028e38a 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -629,18 +629,35 @@ TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0ULL, token); - ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( - {1235, slot, slot->lifecycleGeneration()})); + {1235, slot, slot->lifecycleGeneration(), slot->recordingEpoch()})); EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( - {1234, slot, slot->lifecycleGeneration() + 1})); + {1234, slot, slot->lifecycleGeneration() + 1, + slot->recordingEpoch()})); ASSERT_TRUE(filter->exitBlockedRun( slot_id, ThreadFilter::tokenGeneration(token))); 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(), + slot->recordingEpoch()}; + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { filter->init(nullptr, true); int slot_id = filter->registerThread(1234); @@ -649,7 +666,8 @@ TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { ASSERT_NE(nullptr, slot); ASSERT_NE(0ULL, filter->enterBlockedRun( slot_id, OSThreadState::CONDVAR_WAIT)); - ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; ASSERT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); filter->add(1234, slot_id); @@ -836,7 +854,7 @@ TEST_F(ThreadRegistryTest, UnfilteredSuppressionValidatesIdentityAndLifecycle) { EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(entry)); } -TEST_F(ThreadRegistryTest, ContextFilteredSuppressionPreservesHistoricalEligibility) { +TEST_F(ThreadRegistryTest, ContextFilteredSuppressionRemainsDisabled) { registry.init("0"); int slot_id = registry.registerThread(5555); ASSERT_GE(slot_id, 0); @@ -848,7 +866,7 @@ TEST_F(ThreadRegistryTest, ContextFilteredSuppressionPreservesHistoricalEligibil ASSERT_NE(0u, token); ThreadEntry entry{5555, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - EXPECT_TRUE(registry.isOwnedBlockSuppressionCandidate(entry)); + EXPECT_FALSE(registry.isOwnedBlockSuppressionCandidate(entry)); } TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { 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 index 905a52fcba..033de4dc25 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java @@ -182,7 +182,7 @@ public void liveDumpDoesNotRequireAnEntrySample() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms,wallscope=all,wallprecheck=true"; + return "wall=1ms,filter=,wallprecheck=true"; } private boolean runEligibleBlock(long blocker) 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 index 6a50edbce7..fd6560f510 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java @@ -21,6 +21,6 @@ public void pairedApiIsInactiveOutsideAllThreadScope() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallscope=context,wallprecheck=true"; + return "wall=1ms,filter=0,wallprecheck=true"; } } 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 22a2926d18..07ae6de492 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,wallscope=all,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,filter=,wallprecheck=true,jvmtistacks=true"; } @Override protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallscope=all,wallprecheck=false,jvmtistacks=true"; + return "wall=1ms,filter=,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 5dc3607b50..33d7e19467 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,8 +308,8 @@ public void realisticServiceWorkload() throws Exception { @Override protected String getProfilerCommand() { - // The workload deliberately has no tracing context, so its samples - // require the explicit all-thread wall-clock scope. - return "wall=1ms,wallscope=all"; + // The workload deliberately has no tracing context, so keep unfiltered + // wall-clock sampling enabled explicitly. + return "wall=1ms,filter="; } } 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..7aad303423 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 @@ -196,13 +196,12 @@ private void leaveClearedInitializedContext() { @Override protected String getProfilerCommand() { // 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"; + // tracing-context window. Keep that population in scope explicitly. + return "wall=1ms,filter=,wallprecheck=true"; } protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallscope=all,wallprecheck=false"; + return "wall=1ms,filter=,wallprecheck=false"; } private long samplesForThread(String threadName) { 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 1062a4b23abd168aa15cc5e598dd7fe25fc70488 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:15:31 +0200 Subject: [PATCH 05/15] 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 09ce3f3fb0..e9ea4ddb3a 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -528,14 +528,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 @@ -773,11 +766,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 62018f7f070798e22e7be098167032ad7eaf8610 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 11:32:21 +0200 Subject: [PATCH 06/15] 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 7aad303423..452e86dacf 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 ebab4c4a50fba6ef669b18c4aa0aebbdff629a4c Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 20 Jul 2026 01:30:56 +0200 Subject: [PATCH 07/15] fix: address sphinx review --- ddprof-lib/src/main/cpp/javaApi.cpp | 45 ++--------- ddprof-lib/src/main/cpp/jvmSupport.cpp | 4 +- ddprof-lib/src/main/cpp/jvmSupport.h | 2 +- ddprof-lib/src/main/cpp/profiler.cpp | 6 -- ddprof-lib/src/main/cpp/profiler.h | 1 - ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 43 +++++++++++ ddprof-lib/src/main/cpp/taskBlockRecorder.h | 5 ++ ddprof-lib/src/main/cpp/threadFilter.cpp | 23 +++--- ddprof-lib/src/main/cpp/threadFilter.h | 1 - ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 46 ++++++++++- .../src/test/cpp/taskBlockRecorder_ut.cpp | 55 +++++++++++++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 6 +- .../profiler/AbstractProfilerTest.java | 1 + .../context/AllNativeContextTest.java | 3 +- .../context/OtelContextStorageModeTest.java | 3 +- ...rofilerTaskBlockPreExistingThreadTest.java | 77 +++++++++++++++++++ .../wallclock/PrecheckEfficiencyTest.java | 5 ++ .../wallclock/UnfilteredWallPrecheckTest.java | 10 ++- 18 files changed, 266 insertions(+), 70 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 3b87480530..18e907212f 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -461,7 +461,7 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( !JVMSupport::isPlatformThread(env, thread)) { return 0; } - ProfiledThread *current = ProfiledThread::current(); + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); Profiler *profiler = Profiler::instance(); if (current == nullptr || !profiler->isRunning() || !profiler->taskBlockEnabled()) { @@ -498,46 +498,13 @@ Java_com_datadoghq_profiler_JavaProfiler_endTaskBlock0( !JVMSupport::isPlatformThread(env, thread)) { return JNI_FALSE; } - ProfiledThread *current = ProfiledThread::current(); + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); 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(); + bool recorded = recordTaskBlockAtExit( + current, Profiler::instance()->threadFilter(), thread, 1, block_token, + slot_id, generation, static_cast(blocker), + static_cast(unblockingSpanId)); return recorded ? JNI_TRUE : JNI_FALSE; } diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index e464924ffd..86dff230ce 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -19,7 +19,7 @@ using JniFunction = void (JNICALL*)(); using IsVirtualThreadFunction = jboolean (JNICALL*)(JNIEnv*, jobject); -static constexpr jint JNI_VERSION_21_VALUE = 0x00150000; +static constexpr jint JNI_VERSION_19_VALUE = 0x00130000; static constexpr int IS_VIRTUAL_THREAD_INDEX = 234; static_assert(sizeof(JniFunction) == sizeof(void*), @@ -37,7 +37,7 @@ 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; + if (jni_version < JNI_VERSION_19_VALUE) return true; const JniFunction* functions = reinterpret_cast(jni->functions); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index f21fb4e3f8..c292284cd0 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -45,7 +45,7 @@ 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. + // IsVirtualThread was added to the JNI function table in JDK 19. static bool isPlatformThread(JNIEnv* jni, jthread thread); // Initialize JVM support - check JVM related resources are available. diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index e9ea4ddb3a..61e6b68b93 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -801,12 +801,6 @@ 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) { diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 5c0e05eaf5..e1e890ef7f 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -443,7 +443,6 @@ class alignas(alignof(SpinLock)) Profiler { #endif bool tryEnterTaskBlockActivity(); void leaveTaskBlockActivity(); - void waitForTaskBlockRotation(); bool taskBlockEnabled() const { return _task_block_enabled.load(std::memory_order_acquire); } diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index ae46a02534..bc1a958c3a 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -23,3 +23,46 @@ bool exceedsMinTaskBlockDuration(u64 start_ticks, u64 end_ticks) { if (min_ticks == 0) min_ticks = computeMinTaskBlockTicks(); return end_ticks > start_ticks && end_ticks - start_ticks >= min_ticks; } + +bool recordTaskBlockAtExit(ProfiledThread* current, ThreadFilter* thread_filter, + jthread thread, int start_depth, u64 block_token, + ThreadFilter::SlotID slot_id, u64 generation, + u64 blocker, u64 unblocking_span_id) { + u64 start_ticks = 0; + Context context{}; + if (!current->taskBlockExit(block_token, start_ticks, context)) { + return false; + } + + Profiler* profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) { + current_slot = thread_filter->slotIdByTid(current->tid()); + } + BlockRunSnapshot snapshot; + bool exited = current_slot == slot_id && + thread_filter->snapshotAndExitBlockedRun(slot_id, generation, &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return false; + } + if (!recording_enabled || !exited) { + profiler->leaveTaskBlockActivity(); + return false; + } + if (!snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + profiler->leaveTaskBlockActivity(); + return false; + } + + bool recorded = recordTaskBlockIfEligible( + current->tid(), thread, start_depth, start_ticks, TSC::ticks(), context, + blocker, unblocking_span_id, snapshot.active_state, true); + profiler->leaveTaskBlockActivity(); + return recorded; +} diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.h b/ddprof-lib/src/main/cpp/taskBlockRecorder.h index 600e0b5e1a..9e4de189de 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.h +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.h @@ -15,6 +15,11 @@ void initializeTaskBlockDurationThreshold(); bool exceedsMinTaskBlockDuration(u64 start_ticks, u64 end_ticks); +bool recordTaskBlockAtExit(ProfiledThread* current, ThreadFilter* thread_filter, + jthread thread, int start_depth, u64 block_token, + ThreadFilter::SlotID slot_id, u64 generation, + u64 blocker, u64 unblocking_span_id); + class TaskBlockActivity { private: Profiler* _profiler; diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index 8bfdc15e43..b0b8581e47 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -607,11 +607,6 @@ bool ThreadFilter::snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, 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; @@ -620,6 +615,17 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( slot->lifecycleGeneration() != entry.lifecycle_generation) { return false; } + + // active_block_state publishes the rest of the block-run payload. Acquire + // it before reading the context epoch, owner, or generation so those reads + // observe the stores that preceded publishActiveBlockRun(). + OSThreadState state = slot->activeBlockState(); + bool suppressible_state = state == OSThreadState::SLEEPING || + state == OSThreadState::CONDVAR_WAIT || + state == OSThreadState::OBJECT_WAIT || + state == OSThreadState::MONITOR_WAIT; + if (!suppressible_state) return false; + RecordingEpoch epoch = recordingEpoch(); if (epoch == 0 || entry.recording_epoch != epoch || slot->recordingEpoch() != epoch || @@ -629,12 +635,7 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( u64 block_generation = slot->blockGeneration(); BlockRunOwner owner = slot->activeBlockOwner(); - OSThreadState state = slot->activeBlockState(); - bool suppressible_state = state == OSThreadState::SLEEPING || - state == OSThreadState::CONDVAR_WAIT || - state == OSThreadState::OBJECT_WAIT || - state == OSThreadState::MONITOR_WAIT; - if (owner == BlockRunOwner::NONE || !suppressible_state) return false; + if (owner == BlockRunOwner::NONE) return false; #ifdef UNIT_TEST if (_suppression_snapshot_hook != nullptr) { diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 97816ff347..19a735fb2b 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -286,7 +286,6 @@ class ThreadFilter { 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; #ifdef UNIT_TEST diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index efba2e5a77..42f5e4658c 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -88,13 +88,57 @@ TEST_F(JvmSupportThreadClassificationTest, InvalidJniVersionFailsClosed) { EXPECT_EQ(0, is_virtual_thread_calls); } -TEST_F(JvmSupportThreadClassificationTest, PreJni21ThreadIsPlatform) { +TEST_F(JvmSupportThreadClassificationTest, PreJni19ThreadIsPlatform) { 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, Jni19PlatformThreadIsAccepted) { + jni_version = 0x00130000; + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni19VirtualThreadIsRejected) { + jni_version = 0x00130000; + 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, MissingJni19FunctionFailsClosed) { + jni_version = 0x00130000; + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni20PlatformThreadIsAccepted) { + jni_version = 0x00140000; + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni20VirtualThreadIsRejected) { + jni_version = 0x00140000; + 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, MissingJni20FunctionFailsClosed) { + jni_version = 0x00140000; + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_FALSE(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); diff --git a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp index a307068eb2..9745609ad6 100644 --- a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -12,6 +12,8 @@ #include #include +#include +#include #include namespace { @@ -135,6 +137,59 @@ TEST_F(TaskBlockRecorderTest, RotationWaitsForInflightActivity) { profiler->leaveTaskBlockActivity(); } +TEST_F(TaskBlockRecorderTest, RotationRejectsEndWithoutStrandingLifecycle) { + constexpr int tid = 12345; + ThreadFilter filter; + filter.init("", true); + ThreadFilter::SlotID slot_id = filter.registerThread(tid); + ASSERT_GE(slot_id, 0); + + std::unique_ptr current( + ProfiledThread::forTid(tid), ProfiledThread::deleteForTest); + current->setFilterSlotId(slot_id); + u64 token = filter.enterBlockedRun( + slot_id, OSThreadState::SLEEPING, BlockRunOwner::JAVA); + ASSERT_NE(0ULL, token); + Context context{}; + ASSERT_TRUE(current->taskBlockEnter(token, TSC::ticks(), context)); + + Profiler* profiler = Profiler::instance(); + profiler->beginTaskBlockRotationForTest(); + std::future result = std::async(std::launch::async, [&]() { + return recordTaskBlockAtExit( + current.get(), &filter, nullptr, 1, token, + ThreadFilter::tokenSlotId(token), + ThreadFilter::tokenGeneration(token), 0, 0); + }); + + std::future_status status = result.wait_for(std::chrono::seconds(1)); + bool returned_during_rotation = status == std::future_status::ready; + EXPECT_TRUE(returned_during_rotation); + if (returned_during_rotation) { + ThreadFilter::Slot* slot = filter.slotForId(slot_id); + EXPECT_NE(nullptr, slot); + if (slot != nullptr) { + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + } + + u64 next_token = filter.enterBlockedRun( + slot_id, OSThreadState::SLEEPING, BlockRunOwner::JAVA); + EXPECT_NE(0ULL, next_token); + EXPECT_TRUE(current->taskBlockEnter(next_token, TSC::ticks(), context)); + u64 ignored_ticks = 0; + Context ignored_context{}; + EXPECT_TRUE(current->taskBlockExit( + next_token, ignored_ticks, ignored_context)); + EXPECT_TRUE(filter.exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(next_token))); + } + + profiler->endTaskBlockRotationForTest(); + EXPECT_FALSE(result.get()); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); +} + TEST_F(TaskBlockRecorderTest, StackCaptureFailureIsCountedAndActivityReleased) { g_record_result.store(Profiler::TaskBlockRecordResult::STACK_CAPTURE_FAILED, std::memory_order_release); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 26a028e38a..f5300bb7bc 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -606,10 +606,12 @@ TEST_F(ThreadFilterTest, SaturatedGenerationRefusesEntryWithoutClaimingSlot) { TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { int slot_id = filter->registerThread(); 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); - BlockRunSnapshot snapshot = filter->snapshotBlockedRun(slot_id); + BlockRunSnapshot snapshot = slot->snapshotBlockRun(); EXPECT_TRUE(snapshot.active); EXPECT_EQ(OSThreadState::SLEEPING, snapshot.active_state); EXPECT_EQ(BlockRunOwner::JAVA, snapshot.owner); @@ -617,7 +619,7 @@ TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { ASSERT_TRUE(filter->snapshotAndExitBlockedRun( slot_id, ThreadFilter::tokenGeneration(token), &snapshot)); - EXPECT_FALSE(filter->snapshotBlockedRun(slot_id).active); + EXPECT_FALSE(slot->snapshotBlockRun().active); } TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java index ed84e9f497..88d8c601f5 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/AbstractProfilerTest.java @@ -347,6 +347,7 @@ protected void runTests(Runnable... runnables) throws InterruptedException { public final void stopProfiler() { if (!stopped) { profiler.stop(); + profiler.clearTraceContext(); profiler.resetThreadContext(); stopped = true; checkConfig(); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/context/AllNativeContextTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/context/AllNativeContextTest.java index 60f7680e16..80caafb41a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/context/AllNativeContextTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/context/AllNativeContextTest.java @@ -64,9 +64,10 @@ public static void setup() throws IOException { public void cleanup() { if (profilerStarted) { profiler.stop(); - profiler.resetThreadContext(); profilerStarted = false; } + profiler.clearTraceContext(); + profiler.resetThreadContext(); } private void start() throws IOException { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/context/OtelContextStorageModeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/context/OtelContextStorageModeTest.java index 8e5f4f6ac8..ccf9dd92e9 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/context/OtelContextStorageModeTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/context/OtelContextStorageModeTest.java @@ -46,9 +46,10 @@ public static void setup() throws IOException { public void cleanup() { if (profilerStarted) { profiler.stop(); - profiler.resetThreadContext(); profilerStarted = false; } + profiler.clearTraceContext(); + profiler.resetThreadContext(); } /** diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java new file mode 100644 index 0000000000..81e02e5b25 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java @@ -0,0 +1,77 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies TaskBlock TLS initialization for threads created before profiler startup. */ +public class JavaProfilerTaskBlockPreExistingThreadTest extends AbstractProfilerTest { + private static final int OSTHREAD_STATE_SLEEPING = 7; + private static final long BLOCKER = 0x7401L; + private static final long UNBLOCKING_SPAN_ID = 0x7402L; + + private ExecutorService preExistingWorker; + private Thread preExistingThread; + + @Override + protected void beforeProfilerStart() throws Exception { + preExistingWorker = + Executors.newSingleThreadExecutor( + task -> { + Thread worker = new Thread(task, "taskblock-pre-existing"); + worker.setDaemon(true); + return worker; + }); + preExistingThread = preExistingWorker.submit(Thread::currentThread).get(); + } + + /** Stops the worker that was deliberately created before profiler startup. */ + @AfterEach + public void stopPreExistingWorker() throws InterruptedException { + if (preExistingWorker == null) return; + preExistingWorker.shutdownNow(); + assertTrue( + preExistingWorker.awaitTermination(5, TimeUnit.SECONDS), + "Pre-existing TaskBlock worker did not terminate"); + } + + /** Verifies that the first post-start TaskBlock call initializes carrier-local TLS. */ + @Test + public void preExistingThreadCanRecordTaskBlockAfterProfilerStart() throws Exception { + Future recorded = + preExistingWorker.submit( + () -> { + assertSame(preExistingThread, Thread.currentThread()); + long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + assertTrue(token != 0, "Pre-existing thread must initialize TaskBlock TLS"); + Thread.sleep(200L); + return profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID); + }); + + assertTrue(recorded.get(5, TimeUnit.SECONDS)); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContains( + events, 0L, 0L, BLOCKER, UNBLOCKING_SPAN_ID); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,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 33d7e19467..b33c9592fe 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 @@ -1,3 +1,8 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + package com.datadoghq.profiler.wallclock; import com.datadoghq.profiler.AbstractProfilerTest; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java index d98f619f10..6ac6b91498 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/UnfilteredWallPrecheckTest.java @@ -30,13 +30,14 @@ public class UnfilteredWallPrecheckTest extends AbstractProfilerTest { private static final int OSTHREAD_STATE_SLEEPING = 7; private static final long SLEEP_MILLIS = 300; private static final String PRE_EXISTING_THREAD_NAME = "unfiltered-precheck-existing"; - private static final String SUPPRESSED_RUN_COUNTER = "wc_signals_suppressed_sampled_run"; + private static final String SUPPRESSED_OWNED_BLOCK_COUNTER = + "wc_signals_suppressed_owned_block"; private ExecutorService preExistingWorker; private Thread preExistingThread; /** - * Verifies that an untraced thread's owned sleeping run is sampled once and then suppressed. + * Verifies that an untraced thread's owned sleeping run is suppressed. * * @throws Exception if the worker cannot complete */ @@ -96,12 +97,14 @@ public void parkedPreExistingThreadOutsideContextWindowIsOwnedBlockSuppressed() @RetryingTest(3) public void postStartSleepingThreadStillUsesThreadStartSlot() throws Exception { String threadName = "unfiltered-precheck-post-start"; + long suppressedBefore = suppressedSignals(); assertTrue( runPostStartSleepingWorker(threadName) != 0, "Expected ThreadStart registration to arm SLEEPING state"); stopProfiler(); assertSuppressedSamples(threadName); + assertOwnedBlockSuppressionObserved(suppressedBefore); } @Override @@ -208,12 +211,11 @@ private void assertOwnedBlockSuppressionObserved(long suppressedBefore) { } private long suppressedSignals() { - return profiler.getDebugCounters().getOrDefault(SUPPRESSED_RUN_COUNTER, -1L); + return profiler.getDebugCounters().getOrDefault(SUPPRESSED_OWNED_BLOCK_COUNTER, -1L); } private void assertSuppressedSamples(String threadName) { long sampleCount = samplesForThread(threadName); - assertTrue(sampleCount > 0, "Expected the owned block run to be sampled once"); assertTrue( sampleCount < 10, "Expected nearly no samples from owned block thread, got: " + sampleCount); From f391a8d73d4c1250183cf1bec09b06aabbac11a9 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 11:44:48 +0200 Subject: [PATCH 08/15] feat(taskblock): add JVM blocking producers --- ddprof-lib/src/main/cpp/javaApi.cpp | 87 +++++-- 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 | 51 +++- 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, 910 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 18e907212f..eede4d7c11 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -69,7 +69,8 @@ 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) { Error error = Profiler::instance()->init(); if (error) { throwNew(env, "java/lang/IllegalStateException", error.message()); @@ -77,7 +78,7 @@ Java_com_datadoghq_profiler_JavaProfiler_init0(JNIEnv *env, jclass unused) { } // 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 @@ -94,6 +95,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) { @@ -360,42 +367,77 @@ 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::initCurrentThreadSignalSafe(); if (current == nullptr) { return; } - bool first_park = current->parkEnter(); - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (first_park && tf->registryActive()) { + Context context = ContextApi::snapshot(); + if (!current->parkEnter(TSC::ticks(), context)) { + return; + } + + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + if (context.spanId == 0 && tf->registryActive() && + (profiler->taskBlockEnabled() || tf->enabled())) { ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); if (slot_id >= 0) { - current->setParkBlockToken( - tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); + 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::initCurrentThreadSignalSafe(); 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->registryActive()) { - ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); - if (tf->activeSlotForId(current->filterSlotId(), current->tid()) != nullptr && - 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) { @@ -409,9 +451,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::initCurrentThreadSignalSafe(); @@ -433,9 +476,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::initCurrentThreadSignalSafe(); diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 61e6b68b93..d8a6e2b3c5 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1702,6 +1702,9 @@ Error Profiler::start(Arguments &args, bool reset) { _task_block_enabled.store( (activated & EM_WALL) && args._wall_precheck && track_unfiltered_wall, 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); @@ -1727,6 +1730,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 e1e890ef7f..72f12a0573 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 c01c81911a..3fee2e5cc4 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -44,7 +44,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, @@ -76,10 +77,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) @@ -105,8 +113,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), @@ -340,11 +351,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; } @@ -367,16 +391,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 8eadc643d1..b24c28683a 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" @@ -19,6 +20,8 @@ #include "profiler.h" #include "safeAccess.h" #include "threadLocalData.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). @@ -48,6 +51,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; @@ -67,6 +72,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 @@ -438,7 +576,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; @@ -468,6 +607,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; @@ -484,11 +625,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(); } @@ -505,6 +653,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); @@ -557,6 +711,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 875e7c39b1..943dc46fe6 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 87cd0cbc63..2f71bd9035 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -131,6 +131,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; } @@ -140,12 +159,11 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s if (!result.succeeded) { throw new IOException("Failed to load Datadog Java profiler library", result.error); } - if (isVirtualThread(Thread.currentThread())) { throw new IOException("Cannot initialize profiler on a virtual thread"); } - init0(); + init0(delegateMonitorWaitEvents); instance = profiler; @@ -161,6 +179,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) * @@ -534,7 +562,7 @@ public void recordQueueTime(long startTicks, * production is intentionally separate from the public paired API. */ void parkEnter() { - parkEnter0(); + parkEnter0(Thread.currentThread()); } /** @@ -542,7 +570,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); } /** @@ -554,14 +582,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); } /** @@ -626,7 +654,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; @@ -634,6 +662,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); @@ -647,13 +676,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 60978ddb93aa1935786df4acc6560ccc85e328cd Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:20:43 +0200 Subject: [PATCH 09/15] test: verify JVM producers in all-thread scope --- .../JvmtiBasedMonitorTaskBlockTest.java | 2 +- .../JvmtiBasedParkTaskBlockTest.java | 2 +- .../wallclock/MonitorTaskBlockTest.java | 4 +- .../profiler/wallclock/ParkTaskBlockTest.java | 56 ++++++++++++++++++- .../WallclockMitigationsCombinedTest.java | 5 +- 5 files changed, 63 insertions(+), 6 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..ff6df5c970 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,filter=,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..63e56c3805 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,filter=,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..b05f95b6ed 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 @@ -142,7 +142,7 @@ public void staleWaitStateIsRecoveredAfterProfilerRestart() throws Exception { Path recording = Files.createTempFile("MonitorTaskBlockTest-restart-", ".jfr"); boolean restarted = false; try { - profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + profiler.execute("start,wall=1ms,filter=,wallprecheck=true,jfr,file=" + recording.toAbsolutePath()); restarted = true; synchronized (contentionMonitor) { @@ -218,7 +218,7 @@ public void virtualMonitorCallbacksDoNotEmitCarrierTaskBlocks() throws Exception @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,filter=,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..bf7d3528b4 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,filter=,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); + } } 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() { From 746edc2a235e9d064e56b38261b8e7b630f9c846 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 20 Jul 2026 10:01:17 +0200 Subject: [PATCH 10/15] fix: address sphinx review --- ddprof-lib/src/main/cpp/javaApi.cpp | 54 +++++----- ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 17 ++++ ddprof-lib/src/main/cpp/taskBlockRecorder.h | 11 +++ ddprof-lib/src/main/cpp/vmEntry.cpp | 74 +++++++------- ddprof-lib/src/main/cpp/vmEntry.h | 13 ++- .../com/datadoghq/profiler/JavaProfiler.java | 20 ++-- .../src/test/cpp/taskBlockRecorder_ut.cpp | 97 ++++++++++++++++++ .../datadoghq/profiler/ExternalLauncher.java | 84 ++++++++++++++++ .../profiler/JavaProfilerApiSurfaceTest.java | 2 +- .../datadoghq/profiler/JavaProfilerTest.java | 99 +++++++++++++++++++ .../JavaProfilerTaskBlockApiTest.java | 25 +++-- .../JavaProfilerTaskBlockDisabledTest.java | 4 +- ...rofilerTaskBlockPreExistingThreadTest.java | 3 +- 13 files changed, 404 insertions(+), 99 deletions(-) diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index eede4d7c11..9da748329a 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -78,7 +78,20 @@ Java_com_datadoghq_profiler_JavaProfiler_init0( } // JavaVM* has already been stored when the native library was loaded so we can pass nullptr here - return VM::initProfilerBridge(nullptr, true, delegateMonitorWaitEvents); + ProfilerBridgeInitResult result = + VM::initProfilerBridge(nullptr, true, delegateMonitorWaitEvents); + if (result == ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT) { + throwNew(env, "java/lang/IllegalStateException", + "Monitor-event ownership conflicts with the profiler's " + "process-wide initialization"); + return JNI_FALSE; + } + if (result != ProfilerBridgeInitResult::SUCCESS) { + throwNew(env, "java/lang/IllegalStateException", + "Failed to initialize the profiler bridge"); + return JNI_FALSE; + } + return JNI_TRUE; } extern "C" DLLEXPORT void JNICALL @@ -412,32 +425,10 @@ Java_com_datadoghq_profiler_JavaProfiler_parkExit0( 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(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(); + finishTaskBlockAtExit( + current, profiler->threadFilter(), thread, 1, park_block_token, + start_ticks, context, static_cast(blocker), + static_cast(unblockingSpanId)); } static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { @@ -498,10 +489,8 @@ Java_com_datadoghq_profiler_JavaProfiler_blockExit0( 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)) { + JNIEnv *env, jclass unused, jthread thread) { + if (!JVMSupport::isPlatformThread(env, thread)) { return 0; } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -520,7 +509,8 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); return 0; } - u64 token = tf->enterBlockedRun(slot_id, decoded, BlockRunOwner::JAVA); + u64 token = tf->enterBlockedRun( + slot_id, OSThreadState::SLEEPING, BlockRunOwner::JAVA); if (!current->taskBlockEnter(token, TSC::ticks(), context)) { if (token != 0) { tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index bc1a958c3a..34492f2054 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -34,10 +34,27 @@ bool recordTaskBlockAtExit(ProfiledThread* current, ThreadFilter* thread_filter, return false; } + if (slot_id != ThreadFilter::tokenSlotId(block_token) || + generation != ThreadFilter::tokenGeneration(block_token)) { + return false; + } + + return finishTaskBlockAtExit( + current, thread_filter, thread, start_depth, block_token, start_ticks, + context, blocker, unblocking_span_id); +} + +bool finishTaskBlockAtExit(ProfiledThread* current, + ThreadFilter* thread_filter, jthread thread, + int start_depth, u64 block_token, u64 start_ticks, + const Context& context, u64 blocker, + u64 unblocking_span_id) { Profiler* profiler = Profiler::instance(); bool recording_enabled = profiler->taskBlockEnabled(); bool activity = profiler->tryEnterTaskBlockActivity(); + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(block_token); + u64 generation = ThreadFilter::tokenGeneration(block_token); ThreadFilter::SlotID current_slot = current->filterSlotId(); if (current_slot < 0) { current_slot = thread_filter->slotIdByTid(current->tid()); diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.h b/ddprof-lib/src/main/cpp/taskBlockRecorder.h index 9e4de189de..fb11a6155b 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.h +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.h @@ -20,6 +20,17 @@ bool recordTaskBlockAtExit(ProfiledThread* current, ThreadFilter* thread_filter, ThreadFilter::SlotID slot_id, u64 generation, u64 blocker, u64 unblocking_span_id); +// Completes ThreadFilter lifecycle cleanup for an already-exited producer and +// records its event only when dump/stop rotation admits the recording work. +// Cleanup is deliberately performed even when admission is rejected so an +// application thread never waits for rotation and suppression cannot be left +// armed. +bool finishTaskBlockAtExit(ProfiledThread* current, + ThreadFilter* thread_filter, jthread thread, + int start_depth, u64 block_token, u64 start_ticks, + const Context& context, u64 blocker, + u64 unblocking_span_id); + class TaskBlockActivity { private: Profiler* _profiler; diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index b24c28683a..7338d2e2f5 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -16,6 +16,7 @@ #include "jvmThread.h" #include "libraries.h" #include "log.h" +#include "mutex.h" #include "os.h" #include "profiler.h" #include "safeAccess.h" @@ -55,6 +56,12 @@ bool VM::_monitor_events_delegated = false; bool VM::_native_monitor_events_available = false; bool VM::_is_adaptive_gc_boundary_flag_set = false; +// Serializes the one-time bridge installation and ownership negotiation. +// Callback readers need no synchronization because ownership is assigned +// before callbacks can be enabled and is never changed afterward. +static Mutex profiler_bridge_init_lock; +static bool profiler_bridge_initialized = false; + jvmtiExtensionFunction VM::_request_stack_trace = nullptr; jvmtiExtensionFunction VM::_init_request_stack_trace = nullptr; @@ -86,7 +93,7 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, !JVMSupport::isPlatformThread(jni, thread)) { return; } - ProfiledThread *current = ProfiledThread::current(); + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); if (current == nullptr) return; Context context = ContextApi::snapshot(); if (context.spanId != 0) { @@ -101,10 +108,15 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, 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); + ThreadFilter::Slot *slot = current->filterSlotId() == slot_id + ? tf->activeSlotForId(slot_id, current->tid()) + : nullptr; + if (slot != nullptr) { + BlockRunSnapshot snapshot = slot->snapshotBlockRun(); + current_owner = snapshot.active && + snapshot.owner == BlockRunOwner::JVMTI && + snapshot.generation == ThreadFilter::tokenGeneration(token); + } } if (current_owner) { return; @@ -122,7 +134,7 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, slot_id = tf->slotIdByTid(current->tid()); if (slot_id >= 0) current->setFilterSlotId(slot_id); } - if (!tf->allThreads() || slot_id < 0) { + if (!tf->unfilteredWallTrackingActive() || slot_id < 0) { current->clearMonitorBlock(); return; } @@ -154,31 +166,8 @@ static void monitorBlockExit(JNIEnv *jni, jthread thread, OSThreadState state) { } 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(); + finishTaskBlockAtExit(current, profiler->threadFilter(), thread, 0, token, + start_ticks, context, blocker, 0); } static void JNICALL MonitorContendedEnter(jvmtiEnv *jvmti, JNIEnv *jni, @@ -576,16 +565,25 @@ bool VM::initializeRequestStackTrace() { return false; } -bool VM::initProfilerBridge(JavaVM *vm, bool attach, - bool delegateMonitorEvents) { +ProfilerBridgeInitResult VM::initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorEvents) { + MutexLocker init_locker(profiler_bridge_init_lock); + if (profiler_bridge_initialized) { + bool requested_delegation = + delegateMonitorEvents && _native_monitor_events_available; + return requested_delegation == _monitor_events_delegated + ? ProfilerBridgeInitResult::SUCCESS + : ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT; + } + TEST_LOG("VM::initProfilerBridge"); if (!initShared(vm)) { - return false; + return ProfilerBridgeInitResult::FAILURE; } CodeCache *lib = openJvmLibrary(); if (lib == nullptr) { - return false; + return ProfilerBridgeInitResult::FAILURE; } if (!attach && hotspot_version() == 8 && OS::isLinux()) { @@ -708,7 +706,8 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach, OS::installSignalHandler(WAKEUP_SIGNAL, NULL, wakeupHandler); - return true; + profiler_bridge_initialized = true; + return ProfilerBridgeInitResult::SUCCESS; } bool VM::setNativeMonitorEventsEnabled(bool enabled) { @@ -859,7 +858,8 @@ Agent_OnLoad(JavaVM* vm, char* options, void* reserved) { return ARGUMENTS_ERROR; } - if (!VM::initProfilerBridge(vm, false)) { + if (VM::initProfilerBridge(vm, false) != + ProfilerBridgeInitResult::SUCCESS) { Log::error("JVM does not support Tool Interface"); return COMMAND_ERROR; } diff --git a/ddprof-lib/src/main/cpp/vmEntry.h b/ddprof-lib/src/main/cpp/vmEntry.h index 943dc46fe6..f5c23dabba 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.h +++ b/ddprof-lib/src/main/cpp/vmEntry.h @@ -132,6 +132,15 @@ class JavaVersionAccess { static int get_hotspot_version(char* prop_value); }; +// The profiler bridge is process-wide and initialized exactly once. Later Java +// API initialization may reuse it only with the same effective Object.wait +// ownership. +enum class ProfilerBridgeInitResult { + SUCCESS, + FAILURE, + MONITOR_EVENTS_DELEGATION_CONFLICT, +}; + class VM { friend class VMTestAccessor; @@ -179,8 +188,8 @@ class VM { static JVM_GetManagement _getManagement; static bool initLibrary(JavaVM *vm); - static bool initProfilerBridge(JavaVM *vm, bool attach, - bool delegateMonitorEvents = false); + static ProfilerBridgeInitResult initProfilerBridge( + JavaVM *vm, bool attach, bool delegateMonitorEvents = false); static jvmtiEnv *jvmti() { return _jvmti; } 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 2f71bd9035..f2bd989e35 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -137,8 +137,9 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s /** * 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 + *

The first successful native bridge initialization fixes this process-wide setting because + * the native profiler is a singleton. This may occur during {@code -agentpath} startup before + * this method is called. 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. * @@ -147,6 +148,8 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s * @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 + * @throws IllegalStateException if monitor ownership conflicts with an earlier native bridge + * initialization */ public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir, boolean delegateMonitorWaitEvents) throws IOException { @@ -575,7 +578,7 @@ void parkExit(long blocker, long unblockingSpanId) { /** * Internal hook marking the current platform thread as entering an explicitly instrumented - * blocked interval. The public paired API is {@link #beginTaskBlock(int)}. + * blocked interval. The public paired API is {@link #beginTaskBlock()}. * * @param state native {@code OSThreadState} value for the blocked interval; * currently only {@code SLEEPING} is armed @@ -597,20 +600,19 @@ void blockExit(long token) { * 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); + public long beginTaskBlock() { + return beginTaskBlock0(Thread.currentThread()); } /** - * Ends a blocking interval created by {@link #beginTaskBlock(int)} and records its + * Ends a blocking interval created by {@link #beginTaskBlock()} 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 + * @param token opaque token returned by {@link #beginTaskBlock()}; {@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} @@ -684,7 +686,7 @@ private static ThreadContext initializeThreadContext() { private static native void blockExit0(Thread thread, long token); - private static native long beginTaskBlock0(Thread thread, int state); + private static native long beginTaskBlock0(Thread thread); private static native boolean endTaskBlock0(Thread thread, long token, long blocker, long unblockingSpanId); diff --git a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp index 9745609ad6..3c90c672f0 100644 --- a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -190,6 +190,103 @@ TEST_F(TaskBlockRecorderTest, RotationRejectsEndWithoutStrandingLifecycle) { EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); } +TEST_F(TaskBlockRecorderTest, RotationRejectsParkExitWithoutBlockingOrStranding) { + constexpr int tid = 12346; + ThreadFilter filter; + filter.init("", true); + ThreadFilter::SlotID slot_id = filter.registerThread(tid); + ASSERT_GE(slot_id, 0); + + std::unique_ptr current( + ProfiledThread::forTid(tid), ProfiledThread::deleteForTest); + current->setFilterSlotId(slot_id); + Context context{}; + ASSERT_TRUE(current->parkEnter(TSC::ticks(), context)); + u64 token = filter.enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT, BlockRunOwner::JAVA); + ASSERT_NE(0ULL, token); + current->setParkBlockToken(token); + + Profiler* profiler = Profiler::instance(); + profiler->beginTaskBlockRotationForTest(); + std::future result = std::async(std::launch::async, [&]() { + u64 start_ticks = 0; + u64 exit_token = 0; + Context exit_context{}; + if (!current->parkExit(start_ticks, exit_context, exit_token)) return true; + return finishTaskBlockAtExit( + current.get(), &filter, nullptr, 1, exit_token, start_ticks, + exit_context, 0, 0); + }); + + EXPECT_EQ(std::future_status::ready, + result.wait_for(std::chrono::seconds(1))); + ThreadFilter::Slot* slot = filter.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_TRUE(current->parkEnter(TSC::ticks(), context)); + u64 ignored_ticks = 0; + u64 ignored_token = 0; + Context ignored_context{}; + EXPECT_TRUE(current->parkExit( + ignored_ticks, ignored_context, ignored_token)); + + profiler->endTaskBlockRotationForTest(); + EXPECT_FALSE(result.get()); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); +} + +TEST_F(TaskBlockRecorderTest, + RotationRejectsMonitorExitWithoutBlockingOrStranding) { + constexpr int tid = 12347; + ThreadFilter filter; + filter.init("", true); + ThreadFilter::SlotID slot_id = filter.registerThread(tid); + ASSERT_GE(slot_id, 0); + + std::unique_ptr current( + ProfiledThread::forTid(tid), ProfiledThread::deleteForTest); + current->setFilterSlotId(slot_id); + Context context{}; + ASSERT_TRUE(current->monitorEnter( + TSC::ticks(), context, 7, OSThreadState::OBJECT_WAIT)); + u64 token = filter.enterBlockedRun( + slot_id, OSThreadState::OBJECT_WAIT, BlockRunOwner::JVMTI); + ASSERT_NE(0ULL, token); + current->setMonitorBlockToken(token); + + Profiler* profiler = Profiler::instance(); + profiler->beginTaskBlockRotationForTest(); + std::future result = std::async(std::launch::async, [&]() { + u64 start_ticks = 0; + u64 blocker = 0; + u64 exit_token = 0; + Context exit_context{}; + if (!current->monitorExit(OSThreadState::OBJECT_WAIT, start_ticks, + exit_context, blocker, exit_token)) { + return true; + } + return finishTaskBlockAtExit( + current.get(), &filter, nullptr, 0, exit_token, start_ticks, + exit_context, blocker, 0); + }); + + EXPECT_EQ(std::future_status::ready, + result.wait_for(std::chrono::seconds(1))); + ThreadFilter::Slot* slot = filter.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_TRUE(current->monitorEnter( + TSC::ticks(), context, 8, OSThreadState::MONITOR_WAIT)); + current->clearMonitorBlock(); + + profiler->endTaskBlockRotationForTest(); + EXPECT_FALSE(result.get()); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); +} + TEST_F(TaskBlockRecorderTest, StackCaptureFailureIsCountedAndActivityReleased) { g_record_result.store(Profiler::TaskBlockRecordResult::STACK_CAPTURE_FAILED, std::memory_order_release); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 695412dcb0..8120e44609 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -9,7 +9,14 @@ import java.lang.management.ManagementFactory; import java.lang.management.ThreadMXBean; import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.Random; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.LongAdder; /** @@ -23,6 +30,10 @@ *

  • profiler [comma delimited profiler command list] - starts the profiler
  • *
  • profiler-work: [comma delimited profiler command list] - starts the profiler and runs a CPU-intensive task
  • *
  • profiler-virtual-thread - calls {@link JavaProfiler#getInstance()} for the first time from a virtual thread
  • + *
  • profiler-agent-compatible - reuses native monitor ownership after agent initialization
  • + *
  • profiler-delegation-conflict - requests delegated monitor ownership after agent initialization
  • + *
  • profiler-preexisting-monitor-wait - exercises Object.wait on a thread created before profiler initialization
  • + *
  • profiler-preexisting-monitor-contention - exercises monitor contention on a thread created before profiler initialization
  • * */ public class ExternalLauncher { @@ -38,6 +49,63 @@ private static Thread startVirtualThread(Runnable task) throws Exception { return (Thread) start.invoke(builder, task); } + /** Runs one native monitor callback lifecycle on a platform thread created before JNI load. */ + private static void runPreExistingMonitorCallback(boolean contention) throws Exception { + ExecutorService executor = Executors.newSingleThreadExecutor(task -> { + Thread thread = new Thread(task, "preexisting-monitor-callback"); + thread.setDaemon(true); + return thread; + }); + executor.submit(Thread::currentThread).get(5, TimeUnit.SECONDS); + + Path recording = Files.createTempFile("preexisting-monitor-callback", ".jfr"); + JavaProfiler profiler = null; + boolean started = false; + try { + profiler = JavaProfiler.getInstance(); + profiler.execute("start,wall=1ms,filter=,wallprecheck=true,jfr,file=" + + recording.toAbsolutePath()); + started = true; + long before = profiler.getDebugCounters().getOrDefault("task_block_emitted", 0L); + Object monitor = new Object(); + + if (contention) { + CountDownLatch attempting = new CountDownLatch(1); + Future blocked; + synchronized (monitor) { + blocked = executor.submit(() -> { + attempting.countDown(); + synchronized (monitor) { + // Acquiring the monitor completes the contended interval. + } + }); + if (!attempting.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Worker did not attempt monitor entry"); + } + Thread.sleep(100L); + } + blocked.get(5, TimeUnit.SECONDS); + } else { + executor.submit(() -> { + synchronized (monitor) { + monitor.wait(100L); + } + return null; + }).get(5, TimeUnit.SECONDS); + } + + long emitted = profiler.getDebugCounters().getOrDefault("task_block_emitted", 0L) - before; + System.out.println("[preexisting-monitor-events] " + emitted); + } finally { + if (started) { + profiler.stop(); + } + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + Files.deleteIfExists(recording); + } + } + public static void main(String[] args) throws Exception { Thread worker = null; try { @@ -58,6 +126,22 @@ public static void main(String[] args) throws Exception { } }); vt.join(); + } else if (args[0].equals("profiler-delegation-conflict")) { + String libraryPath = System.getProperty("ddprof.test.agent.path"); + try { + JavaProfiler.getInstance(libraryPath, null, true); + System.out.println("[delegation-conflict-missed]"); + } catch (IllegalStateException expected) { + System.out.println("[delegation-conflict] " + expected.getMessage()); + } + } else if (args[0].equals("profiler-agent-compatible")) { + String libraryPath = System.getProperty("ddprof.test.agent.path"); + JavaProfiler profiler = JavaProfiler.getInstance(libraryPath, null, false); + System.out.println("[agent-compatible] " + profiler.isMonitorEventsDelegated()); + } else if (args[0].equals("profiler-preexisting-monitor-wait")) { + runPreExistingMonitorCallback(false); + } else if (args[0].equals("profiler-preexisting-monitor-contention")) { + runPreExistingMonitorCallback(true); } else if (args[0].equals("profiler")) { JavaProfiler instance = JavaProfiler.getInstance(); if (args.length == 2) { 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 54daefb50a..33f90767ce 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -23,7 +23,7 @@ public void taskBlockApiIsPublicButInternalHooksRemainPackageScoped() throws Exc assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockEnter", int.class)); assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockExit", long.class)); assertTrue(Modifier.isPublic(JavaProfiler.class - .getDeclaredMethod("beginTaskBlock", int.class).getModifiers())); + .getDeclaredMethod("beginTaskBlock").getModifiers())); assertTrue(Modifier.isPublic(JavaProfiler.class .getDeclaredMethod("endTaskBlock", long.class, long.class, long.class) .getModifiers())); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java index 2023c4757c..154ffec237 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java @@ -7,8 +7,10 @@ import org.junit.jupiter.api.Test; +import java.io.InputStream; import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -18,12 +20,46 @@ import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.LockSupport; +import java.util.function.Function; import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assumptions.assumeFalse; import static org.junit.jupiter.api.Assumptions.assumeTrue; public class JavaProfilerTest extends AbstractProcessProfilerTest { + /** Extracts the packaged native library so a child JVM can load it through {@code -agentpath}. */ + private static Path extractProfilerLibrary() throws Exception { + OperatingSystem os = OperatingSystem.current(); + String extension = os == OperatingSystem.macos ? "dylib" : "so"; + String qualifier = os == OperatingSystem.linux && os.isMusl() ? "-musl" : ""; + String resource = "/META-INF/native-libs/" + os.name().toLowerCase() + "-" + + Arch.current().name().toLowerCase() + qualifier + "/libjavaProfiler." + extension; + Path library = Files.createTempFile("libjavaProfiler-agent-", "." + extension); + try (InputStream input = JavaProfiler.class.getResourceAsStream(resource)) { + assertNotNull(input, "Profiler library resource not found: " + resource); + Files.copy(input, library, StandardCopyOption.REPLACE_EXISTING); + } + return library; + } + + /** Launches a child JVM whose profiler bridge is initialized before Java application startup. */ + private LaunchResult launchWithProfilerAgent( + String target, Function onStdoutLine) throws Exception { + Path library = extractProfilerLibrary(); + Path recording = Files.createTempFile("agent-initialization-", ".jfr"); + try { + List jvmArgs = new ArrayList<>(); + jvmArgs.add("-agentpath:" + library.toAbsolutePath() + + "=start,wall=10ms,filter=,wallprecheck=true,jfr,file=" + + recording.toAbsolutePath()); + jvmArgs.add("-Dddprof.test.agent.path=" + library.toAbsolutePath()); + return launch(target, jvmArgs, "", onStdoutLine, null); + } finally { + Files.deleteIfExists(recording); + Files.deleteIfExists(library); + } + } + @Test void sanityInitailizationTest() throws Exception { String config = System.getProperty("ddprof_test.config"); @@ -134,6 +170,69 @@ void getInstanceFromVirtualThreadThrowsIOException() throws Exception { "Expected IOException from getInstance() on a virtual thread, got: " + result); } + @Test + void compatibleLateJavaInitializationReusesAgentBridge() throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launchWithProfilerAgent("profiler-agent-compatible", line -> { + if (line.startsWith("[agent-compatible]")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertEquals("[agent-compatible] false", resultLine.get()); + } + + @Test + void conflictingLateMonitorDelegationIsRejected() throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launchWithProfilerAgent("profiler-delegation-conflict", line -> { + if (line.startsWith("[delegation-conflict")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertNotNull(resultLine.get(), "Late delegation request did not report a result"); + assertTrue(resultLine.get().startsWith("[delegation-conflict]"), + "Expected ownership conflict, got: " + resultLine.get()); + } + + @Test + void preExistingThreadObjectWaitUsesNativeMonitorCallbacks() throws Exception { + assertPreExistingMonitorCallback("profiler-preexisting-monitor-wait"); + } + + @Test + void preExistingThreadContentionUsesNativeMonitorCallbacks() throws Exception { + assertPreExistingMonitorCallback("profiler-preexisting-monitor-contention"); + } + + /** Verifies that a pre-JNI-load worker emits a TaskBlock through its first monitor callback. */ + private void assertPreExistingMonitorCallback(String target) throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch(target, Collections.emptyList(), "", line -> { + if (line.startsWith("[preexisting-monitor-events]")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }, null); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertNotNull(resultLine.get(), "Pre-existing monitor callback did not report a result"); + long emitted = Long.parseLong(resultLine.get().substring( + "[preexisting-monitor-events] ".length())); + assertTrue(emitted > 0, "Pre-existing thread emitted no native monitor TaskBlock event"); + } + @Test void vmStackwalkerCrashRecoveryTest() throws Exception { assumeFalse(Platform.isJ9() || Platform.isZing()); // J9 and Zing do not support vmstructs 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 index 033de4dc25..51d476ecf9 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockApiTest.java @@ -24,7 +24,6 @@ /** 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; @@ -46,9 +45,9 @@ public void pairedApiEmitsTaskBlockWithStack() throws Exception { public void invalidAndNestedTokensDoNotLoseCurrentOwner() throws Exception { AtomicBoolean recorded = new AtomicBoolean(); runWorker(() -> { - long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long token = profiler.beginTaskBlock(); assertTrue(token != 0); - assertEquals(0L, profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + assertEquals(0L, profiler.beginTaskBlock()); assertFalse(profiler.endTaskBlock(token + 1, BLOCKER, UNBLOCKING_SPAN_ID)); Thread.sleep(200L); recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); @@ -61,9 +60,9 @@ public void tooShortIntervalStillClearsLifecycle() throws Exception { AtomicBoolean recorded = new AtomicBoolean(true); AtomicLong secondToken = new AtomicLong(); runWorker(() -> { - long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long token = profiler.beginTaskBlock(); recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); - secondToken.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + secondToken.set(profiler.beginTaskBlock()); profiler.endTaskBlock(secondToken.get(), BLOCKER, UNBLOCKING_SPAN_ID); }); @@ -79,12 +78,12 @@ public void contextWindowAdmissionAndCrossingAreEnforced() throws Exception { runWorker(() -> { profiler.addThread(); try { - assertEquals(0L, profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + assertEquals(0L, profiler.beginTaskBlock()); } finally { profiler.removeThread(); } - long crossedToken = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long crossedToken = profiler.beginTaskBlock(); assertTrue(crossedToken != 0); profiler.addThread(); profiler.removeThread(); @@ -92,7 +91,7 @@ public void contextWindowAdmissionAndCrossingAreEnforced() throws Exception { assertFalse(profiler.endTaskBlock( crossedToken, BLOCKER, UNBLOCKING_SPAN_ID)); - tokenAfterWindow.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + tokenAfterWindow.set(profiler.beginTaskBlock()); profiler.endTaskBlock(tokenAfterWindow.get(), BLOCKER, UNBLOCKING_SPAN_ID); }); assertTrue(tokenAfterWindow.get() != 0, @@ -105,7 +104,7 @@ public void traceContextRejectsAtEntry() throws Exception { runWorker(() -> { profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); try { - token.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + token.set(profiler.beginTaskBlock()); } finally { profiler.clearContext(); } @@ -127,14 +126,14 @@ public void virtualThreadCannotMutateCarrierTaskBlockState() throws Exception { AtomicLong token = new AtomicLong(-1L); Thread virtual = (Thread) startVirtualThread.invoke(null, (Runnable) () -> - token.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING))); + token.set(profiler.beginTaskBlock())); virtual.join(5_000L); assertFalse(virtual.isAlive()); assertEquals(0L, token.get()); AtomicLong platformToken = new AtomicLong(); runWorker(() -> { - platformToken.set(profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING)); + platformToken.set(profiler.beginTaskBlock()); profiler.endTaskBlock(platformToken.get(), BLOCKER, UNBLOCKING_SPAN_ID); }); assertTrue(platformToken.get() != 0, @@ -151,7 +150,7 @@ public void liveDumpDoesNotRequireAnEntrySample() throws Exception { .getOrDefault("wc_signals_suppressed_owned_block", 0L); Thread worker = new Thread(() -> { try { - long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long token = profiler.beginTaskBlock(); assertTrue(token != 0); armed.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); @@ -188,7 +187,7 @@ protected String getProfilerCommand() { private boolean runEligibleBlock(long blocker) throws Exception { AtomicBoolean result = new AtomicBoolean(); runWorker(() -> { - long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long token = profiler.beginTaskBlock(); if (token == 0) throw new AssertionError("interval was not armed"); Thread.sleep(200L); result.set(profiler.endTaskBlock(token, blocker, UNBLOCKING_SPAN_ID)); 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 index fd6560f510..2a04c8fdf4 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockDisabledTest.java @@ -12,11 +12,9 @@ /** 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)); + assertEquals(0L, profiler.beginTaskBlock()); } @Override diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java index 81e02e5b25..8777a6c785 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java @@ -19,7 +19,6 @@ /** Verifies TaskBlock TLS initialization for threads created before profiler startup. */ public class JavaProfilerTaskBlockPreExistingThreadTest extends AbstractProfilerTest { - private static final int OSTHREAD_STATE_SLEEPING = 7; private static final long BLOCKER = 0x7401L; private static final long UNBLOCKING_SPAN_ID = 0x7402L; @@ -55,7 +54,7 @@ public void preExistingThreadCanRecordTaskBlockAfterProfilerStart() throws Excep preExistingWorker.submit( () -> { assertSame(preExistingThread, Thread.currentThread()); - long token = profiler.beginTaskBlock(OSTHREAD_STATE_SLEEPING); + long token = profiler.beginTaskBlock(); assertTrue(token != 0, "Pre-existing thread must initialize TaskBlock TLS"); Thread.sleep(200L); return profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID); From 0e696d549519a46f0b60103e311fc365d632c9bb Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 12:33:22 +0200 Subject: [PATCH 11/15] feat: instrument blocking native I/O for TaskBlock events --- ddprof-lib/build.gradle.kts | 5 + ddprof-lib/src/main/cpp/codeCache.cpp | 163 +- ddprof-lib/src/main/cpp/codeCache.h | 45 +- ddprof-lib/src/main/cpp/flightRecorder.cpp | 4 +- ddprof-lib/src/main/cpp/libraryPatcher.h | 76 +- .../src/main/cpp/libraryPatcher_linux.cpp | 472 +++-- ddprof-lib/src/main/cpp/nativeBlock.cpp | 140 ++ ddprof-lib/src/main/cpp/nativeBlock.h | 66 + .../src/main/cpp/nativeFdClassifier.cpp | 197 ++ ddprof-lib/src/main/cpp/nativeFdClassifier.h | 64 + .../src/main/cpp/nativeSocketInterposer.cpp | 477 +++++ .../src/main/cpp/nativeSocketInterposer.h | 186 ++ .../src/main/cpp/nativeSocketSampler.cpp | 117 +- ddprof-lib/src/main/cpp/nativeSocketSampler.h | 102 +- ddprof-lib/src/main/cpp/profiler.cpp | 8 + ddprof-lib/src/main/cpp/profiler.h | 3 + ddprof-lib/src/main/cpp/symbols.h | 5 + ddprof-lib/src/main/cpp/symbols_linux.cpp | 47 +- ddprof-lib/src/main/cpp/symbols_macos.cpp | 26 + ddprof-lib/src/main/cpp/threadFilter.cpp | 3 +- ddprof-lib/src/main/cpp/threadLocalData.h | 20 + ddprof-lib/src/main/cpp/threadState.h | 5 +- ddprof-lib/src/main/cpp/wallClock.cpp | 3 +- ddprof-lib/src/test/cpp/elfparser_ut.cpp | 28 + ddprof-lib/src/test/cpp/nativeBlock_ut.cpp | 338 ++++ .../test/cpp/nativeSocketInterposer_ut.cpp | 1742 +++++++++++++++++ .../src/test/cpp/nativeSocketSampler_ut.cpp | 297 +++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 18 + .../native-libs/reladyn-lib/reladyn.c | 19 +- .../native-libs/unloadable-io-lib/Makefile | 6 + .../unloadable-io-lib/unloadable_io.c | 12 + .../throughput/NativeSocketIoBenchmark.java | 318 +++ .../src/main/cpp/nativeioblock.c | 403 ++++ .../JvmtiBasedNativeSocketTaskBlockTest.java | 32 + .../wallclock/NativeIoBlockHelper.java | 30 + .../NativeSocketTaskBlockLifecycleTest.java | 257 +++ .../wallclock/NativeSocketTaskBlockTest.java | 442 +++++ .../wallclock/TaskBlockAssertions.java | 57 + .../VirtualNativeIoTaskBlockTest.java | 63 + 39 files changed, 5989 insertions(+), 307 deletions(-) create mode 100644 ddprof-lib/src/main/cpp/nativeBlock.cpp create mode 100644 ddprof-lib/src/main/cpp/nativeBlock.h create mode 100644 ddprof-lib/src/main/cpp/nativeFdClassifier.cpp create mode 100644 ddprof-lib/src/main/cpp/nativeFdClassifier.h create mode 100644 ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp create mode 100644 ddprof-lib/src/main/cpp/nativeSocketInterposer.h create mode 100644 ddprof-lib/src/test/cpp/nativeBlock_ut.cpp create mode 100644 ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp create mode 100644 ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile create mode 100644 ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c create mode 100644 ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java create mode 100644 ddprof-test-native/src/main/cpp/nativeioblock.c create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index b39dbfc249..a86e1dd8a3 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -1,3 +1,6 @@ +// Copyright 2026, Datadog, Inc. +// SPDX-License-Identifier: Apache-2.0 + import com.datadoghq.native.model.Platform import com.datadoghq.native.util.PlatformUtils import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven @@ -36,6 +39,8 @@ nativeBuild { gtest { testSourceDir.set(layout.projectDirectory.dir("src/test/cpp")) mainSourceDir.set(layout.projectDirectory.dir("src/main/cpp")) + nativeLibsSourceDir.set(layout.projectDirectory.dir("src/test/resources/native-libs")) + nativeLibsOutputDir.set(rootProject.layout.buildDirectory.dir("test/resources/native-libs")) // Include paths for compilation val javaHome = PlatformUtils.javaHome() diff --git a/ddprof-lib/src/main/cpp/codeCache.cpp b/ddprof-lib/src/main/cpp/codeCache.cpp index 5203d5c5d2..30547693bc 100644 --- a/ddprof-lib/src/main/cpp/codeCache.cpp +++ b/ddprof-lib/src/main/cpp/codeCache.cpp @@ -9,6 +9,8 @@ #include "os.h" #include "safeAccess.h" +#include +#include #include #include #include @@ -59,7 +61,9 @@ CodeCache::CodeCache(const char *name, short lib_index, _build_id_len = 0; _load_bias = 0; - memset(_imports, 0, sizeof(_imports)); + memset(_import_offsets, 0, sizeof(_import_offsets)); + _incomplete_imports = 0; + _imports_finalized = true; _imports_patchable = imports_patchable; _dwarf_table = NULL; @@ -96,7 +100,10 @@ void CodeCache::copyFrom(const CodeCache& other) { } _load_bias = other._load_bias; - memset(_imports, 0, sizeof(_imports)); + _imports.clear(); + memset(_import_offsets, 0, sizeof(_import_offsets)); + _incomplete_imports = 0; + _imports_finalized = true; _imports_patchable = other._imports_patchable; _dwarf_table_length = other._dwarf_table_length; @@ -299,29 +306,44 @@ void CodeCache::findSymbolsByPrefix(std::vector &prefixes, } void CodeCache::saveImport(ImportId id, void** entry) { - for (int ty = 0; ty < NUM_IMPORT_TYPES; ty++) { - if (_imports[id][ty] == nullptr) { - _imports[id][ty] = entry; - return; - } + if (entry == nullptr || id < 0 || id >= NUM_IMPORTS) { + return; + } + try { + _imports.push_back({id, entry}); + _imports_finalized = false; + } catch (const std::bad_alloc&) { + _incomplete_imports |= 1ULL << id; } } void CodeCache::addImport(void **entry, const char *name) { switch (name[0]) { case 'a': - if (strcmp(name, "aligned_alloc") == 0) { + if (strcmp(name, "accept") == 0) { + saveImport(im_accept, entry); + } else if (strcmp(name, "accept4") == 0) { + saveImport(im_accept4, entry); + } else if (strcmp(name, "aligned_alloc") == 0) { saveImport(im_aligned_alloc, entry); } break; case 'c': if (strcmp(name, "calloc") == 0) { saveImport(im_calloc, entry); + } else if (strcmp(name, "close") == 0) { + saveImport(im_close, entry); + } else if (strcmp(name, "connect") == 0) { + saveImport(im_connect, entry); } break; case 'd': if (strcmp(name, "dlopen") == 0) { saveImport(im_dlopen, entry); + } else if (strcmp(name, "dup2") == 0) { + saveImport(im_dup2, entry); + } else if (strcmp(name, "dup3") == 0) { + saveImport(im_dup3, entry); } break; case 'f': @@ -329,6 +351,13 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_free, entry); } break; + case 'e': + if (strcmp(name, "epoll_wait") == 0) { + saveImport(im_epoll_wait, entry); + } else if (strcmp(name, "epoll_pwait") == 0) { + saveImport(im_epoll_pwait, entry); + } + break; case 'm': if (strcmp(name, "malloc") == 0) { saveImport(im_malloc, entry); @@ -343,6 +372,10 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_pthread_setspecific, entry); } else if (strcmp(name, "poll") == 0) { saveImport(im_poll, entry); + } else if (strcmp(name, "ppoll") == 0) { + saveImport(im_ppoll, entry); + } else if (strcmp(name, "pselect") == 0) { + saveImport(im_pselect, entry); } else if (strcmp(name, "posix_memalign") == 0) { saveImport(im_posix_memalign, entry); } @@ -352,6 +385,10 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_realloc, entry); } else if (strcmp(name, "recv") == 0) { saveImport(im_recv, entry); + } else if (strcmp(name, "recvfrom") == 0) { + saveImport(im_recvfrom, entry); + } else if (strcmp(name, "recvmsg") == 0) { + saveImport(im_recvmsg, entry); } else if (strcmp(name, "read") == 0) { saveImport(im_read, entry); } @@ -361,6 +398,8 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_send, entry); } else if (strcmp(name, "sigaction") == 0) { saveImport(im_sigaction, entry); + } else if (strcmp(name, "select") == 0) { + saveImport(im_select, entry); } break; case 'w': @@ -371,46 +410,97 @@ void CodeCache::addImport(void **entry, const char *name) { } } -void **CodeCache::findImport(ImportId id) { - if (!_imports_patchable) { - makeImportsPatchable(); - _imports_patchable = true; +void CodeCache::finalizeImports() { + if (_imports_finalized) { + return; } - return _imports[id][PRIMARY]; -} -void CodeCache::patchImport(ImportId id, void *hook_func) { - if (!_imports_patchable) { - makeImportsPatchable(); - _imports_patchable = true; + std::sort(_imports.begin(), _imports.end(), [](const ImportLocation& a, + const ImportLocation& b) { + if (a._id != b._id) { + return a._id < b._id; } + return reinterpret_cast(a._location) < + reinterpret_cast(b._location); + }); + _imports.erase(std::unique(_imports.begin(), _imports.end(), + [](const ImportLocation& a, const ImportLocation& b) { + return a._id == b._id && a._location == b._location; + }), _imports.end()); + + memset(_import_offsets, 0, sizeof(_import_offsets)); + for (const ImportLocation& entry : _imports) { + _import_offsets[entry._id + 1]++; + } + for (int id = 0; id < NUM_IMPORTS; id++) { + _import_offsets[id + 1] += _import_offsets[id]; + } + _imports_finalized = true; +} - for (int ty = 0; ty < NUM_IMPORT_TYPES; ty++) {void **entry = _imports[id][ty]; - if (entry != NULL) { - *entry = hook_func; - }} +size_t CodeCache::importCount(ImportId id) { + if (id < 0 || id >= NUM_IMPORTS) { + return 0; + } + finalizeImports(); + return _import_offsets[id + 1] - _import_offsets[id]; } -void CodeCache::makeImportsPatchable() { - void **min_import = (void **)-1; - void **max_import = NULL; - for (int i = 0; i < NUM_IMPORTS; i++) { - for (int j = 0; j < NUM_IMPORT_TYPES; j++) { - void** entry = _imports[i][j]; - if (entry == NULL) continue; - if (entry < min_import) +bool CodeCache::importsComplete(ImportId id) const { + return id >= 0 && id < NUM_IMPORTS && + (_incomplete_imports & (1ULL << id)) == 0; +} + +bool CodeCache::prepareImportsForPatch() { + if (_imports_patchable) { + return true; + } + return makeImportsPatchable(); +} + +void **CodeCache::findImport(ImportId id, size_t index) { + if (id < 0 || id >= NUM_IMPORTS || index >= importCount(id)) { + return nullptr; + } + if (!prepareImportsForPatch()) { + return nullptr; + } + return _imports[_import_offsets[id] + index]._location; +} + +bool CodeCache::patchImport(ImportId id, void *hook_func) { + if (!prepareImportsForPatch()) { + return false; + } + size_t count = importCount(id); + for (size_t index = 0; index < count; index++) { + *_imports[_import_offsets[id] + index]._location = hook_func; + } + return true; +} + +bool CodeCache::makeImportsPatchable() { + finalizeImports(); + uintptr_t min_import = UINTPTR_MAX; + uintptr_t max_import = 0; + for (const ImportLocation& import : _imports) { + uintptr_t entry = reinterpret_cast(import._location); + if (entry < min_import) min_import = entry; if (entry > max_import) max_import = entry; - } } - if (max_import != NULL) { - uintptr_t patch_start = (uintptr_t)min_import & ~OS::page_mask; - uintptr_t patch_end = (uintptr_t)max_import & ~OS::page_mask; - mprotect((void *)patch_start, patch_end - patch_start + OS::page_size, - PROT_READ | PROT_WRITE); + if (max_import != 0) { + uintptr_t patch_start = min_import & ~OS::page_mask; + uintptr_t patch_end = max_import & ~OS::page_mask; + if (mprotect((void *)patch_start, patch_end - patch_start + OS::page_size, + PROT_READ | PROT_WRITE) != 0) { + return false; + } } + _imports_patchable = true; + return true; } void CodeCache::setDwarfTable(FrameDesc *table, int length, const FrameDesc &default_frame) { @@ -467,4 +557,3 @@ void CodeCache::setBuildId(const char* build_id, size_t build_id_len) { } } } - diff --git a/ddprof-lib/src/main/cpp/codeCache.h b/ddprof-lib/src/main/cpp/codeCache.h index 92b45bf472..452ac60245 100644 --- a/ddprof-lib/src/main/cpp/codeCache.h +++ b/ddprof-lib/src/main/cpp/codeCache.h @@ -13,6 +13,7 @@ #include "utils.h" #include +#include #include #include #include @@ -27,6 +28,8 @@ const int MAX_NATIVE_LIBS = 2048; enum ImportId { im_dlopen, + im_dup2, + im_dup3, im_pthread_create, im_pthread_exit, im_pthread_setspecific, @@ -42,15 +45,20 @@ enum ImportId { im_recv, im_write, im_read, + im_close, + im_connect, + im_accept, + im_accept4, + im_recvfrom, + im_recvmsg, + im_epoll_wait, + im_epoll_pwait, + im_ppoll, + im_select, + im_pselect, NUM_IMPORTS }; -enum ImportType { - PRIMARY, - SECONDARY, - NUM_IMPORT_TYPES -}; - enum Mark { MARK_VM_RUNTIME = 1, MARK_INTERPRETER = 2, @@ -126,6 +134,13 @@ class CodeBlob { class CodeCache { private: + static_assert(NUM_IMPORTS <= 64, "import completeness mask must cover every import"); + + struct ImportLocation { + ImportId _id; + void** _location; + }; + char *_name; short _lib_index; const void *_min_address; @@ -141,7 +156,10 @@ class CodeCache { size_t _build_id_len; // Build-id length in bytes (raw, not hex string length) uintptr_t _load_bias; // Load bias (image_base - file_base address) - void **_imports[NUM_IMPORTS][NUM_IMPORT_TYPES]; + std::vector _imports; + size_t _import_offsets[NUM_IMPORTS + 1]; + u64 _incomplete_imports; + bool _imports_finalized; bool _imports_patchable; bool _debug_symbols; @@ -154,7 +172,8 @@ class CodeCache { CodeBlob *_blobs; void expand(); - void makeImportsPatchable(); + void finalizeImports(); + bool makeImportsPatchable(); void saveImport(ImportId id, void** entry); void copyFrom(const CodeCache& other); @@ -236,8 +255,11 @@ class CodeCache { } void addImport(void **entry, const char *name); - void **findImport(ImportId id); - void patchImport(ImportId, void *hook_func); + size_t importCount(ImportId id); + bool importsComplete(ImportId id) const; + bool prepareImportsForPatch(); + void **findImport(ImportId id, size_t index = 0); + bool patchImport(ImportId, void *hook_func); CodeBlob *findBlob(const char *name); CodeBlob *findBlobByAddress(const void *address); @@ -252,7 +274,8 @@ class CodeCache { FrameDesc findFrameDesc(const void *pc); long long memoryUsage() { - return _capacity * sizeof(CodeBlob *) + _count * sizeof(NativeFunc); + return _capacity * sizeof(CodeBlob *) + _count * sizeof(NativeFunc) + + _imports.capacity() * sizeof(ImportLocation); } int count() { return _count; } diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 6a7c0914d3..60f491d45b 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1501,7 +1501,7 @@ void Recording::writeFrameTypes(Buffer *buf) { void Recording::writeThreadStates(Buffer *buf) { buf->putVar64(T_THREAD_STATE); - buf->put8(10); + buf->put8(11); buf->put8(static_cast(OSThreadState::UNKNOWN)); buf->putUtf8("UNKNOWN"); buf->put8(static_cast(OSThreadState::NEW)); @@ -1522,6 +1522,8 @@ void Recording::writeThreadStates(Buffer *buf) { buf->putUtf8("TERMINATED"); buf->put8(static_cast(OSThreadState::SYSCALL)); buf->putUtf8("SYSCALL"); + buf->put8(static_cast(OSThreadState::IO_WAIT)); + buf->putUtf8("IO_WAIT"); flushIfNeeded(buf); } diff --git a/ddprof-lib/src/main/cpp/libraryPatcher.h b/ddprof-lib/src/main/cpp/libraryPatcher.h index 70be3659b7..7bccb65d73 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher.h +++ b/ddprof-lib/src/main/cpp/libraryPatcher.h @@ -1,21 +1,43 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + #ifndef _LIBRARYPATCHER_H #define _LIBRARYPATCHER_H #include "codeCache.h" #include "spinLock.h" + #include +#include #ifdef __linux__ -// Patch libraries' @plt entries +// Patch libraries' imported function relocation slots. typedef struct _patchEntry { CodeCache* _lib; - // library's @plt location + // Library import location. void** _location; // original function void* _func; } PatchEntry; +// Native I/O patching only needs the slot and its exact pre-patch value. +// Each retained import location is tracked independently for restoration. +typedef struct _socketPatchEntry { + void** _location; + void* _func; +} SocketPatchEntry; + +typedef struct _socketPatchedLibrary { + const void* _image_base; + void* _unload_protection; + size_t _first_patch; + size_t _patch_count; +} SocketPatchedLibrary; + +const int SOCKET_BASE_TABLE_SIZE = MAX_NATIVE_LIBS * 2; class LibraryPatcher { private: @@ -29,36 +51,48 @@ class LibraryPatcher { static PatchEntry _sigaction_entries[MAX_NATIVE_LIBS]; static int _sigaction_size; - // Separate tracking for socket (send/recv/write/read) patches. - // Each library can contribute up to 4 GOT slots (send/recv/write/read). - static PatchEntry _socket_entries[4 * MAX_NATIVE_LIBS]; - static int _socket_size; + // Separate tracking for native I/O patches. + // Each library can contribute any number of retained import slots per I/O hook. + static std::vector _socket_entries; + static std::vector _socket_libraries; + static const void* _socket_bases[SOCKET_BASE_TABLE_SIZE]; static void patch_library_unlocked(CodeCache* lib); static void patch_pthread_create(); static void patch_pthread_setspecific(); static void patch_sigaction_in_library(CodeCache* lib); + static bool socket_library_patched_unlocked(const void* image_base); + static void remember_socket_library_unlocked(const void* image_base); + static void unpatch_socket_functions_unlocked( + std::vector& libraries_to_release); + static void release_socket_libraries( + std::vector& libraries); public: - // True while socket hooks are installed; read by Profiler::dlopen_hook + // True while native I/O hooks are installed; read by library refresh paths // to decide whether to re-patch after a new library is loaded. // Set to true after the first batch of libraries is patched in patch_socket_functions(). - // Libraries loaded after profiler start are picked up on the next dlopen_hook call, + // Libraries loaded after profiler start are picked up on the next refresh, // which calls install_socket_hooks() to patch them if _socket_active is true. - // Low-probability race: stop() is called only on JVM exit; atomic is zero-cost insurance. + // start()/stop() and the library refresher can observe this state from different + // threads, so keep the flag atomic even though stop normally happens at JVM shutdown. static std::atomic _socket_active; static void initialize(); static void patch_libraries(); static void unpatch_libraries(); static void patch_sigaction(); - static bool patch_socket_functions(); + static bool patch_socket_functions(bool require_active = false); static void unpatch_socket_functions(); - // Called from Profiler::dlopen_hook after a new library is loaded. - // No-op when socket hooks are not active. - static inline void install_socket_hooks() { - if (_socket_active.load(std::memory_order_acquire)) { - patch_socket_functions(); - } - } + static bool unpatch_socket_functions_if_inactive(); +#ifdef UNIT_TEST + static int patch_socket_import_for_test(CodeCache* lib, ImportId import_id, + void* hook, const char* name, + bool retain_library = false); + static int socket_patch_count_for_test(); + static int socket_library_count_for_test(); +#endif + // Called after a new library is loaded and the library list is refreshed. + // No-op when native I/O hooks are not active. + static void install_socket_hooks(); }; #else @@ -69,8 +103,14 @@ class LibraryPatcher { static void patch_libraries() { } static void unpatch_libraries() { } static void patch_sigaction() { } - static bool patch_socket_functions() { return false; } + static bool patch_socket_functions(bool require_active = false) { + (void)require_active; + return false; + } static void unpatch_socket_functions() { } + static bool unpatch_socket_functions_if_inactive() { + return false; + } static void install_socket_hooks() { } }; diff --git a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp index 1c08286d22..029ffada6e 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp +++ b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp @@ -9,16 +9,21 @@ #include "counters.h" #include "guards.h" #include "jvmThread.h" +#include "nativeSocketInterposer.h" #include "nativeSocketSampler.h" #include "profiler.h" +#include "symbols.h" +#include #include #include +#include #include #include #include #include #include +#include typedef void* (*func_start_routine)(void*); @@ -28,10 +33,40 @@ PatchEntry LibraryPatcher::_patched_entries[MAX_NATIVE_LIBS]; int LibraryPatcher::_size = 0; PatchEntry LibraryPatcher::_sigaction_entries[MAX_NATIVE_LIBS]; int LibraryPatcher::_sigaction_size = 0; -PatchEntry LibraryPatcher::_socket_entries[4 * MAX_NATIVE_LIBS]; -int LibraryPatcher::_socket_size = 0; +std::vector LibraryPatcher::_socket_entries; +std::vector LibraryPatcher::_socket_libraries; +const void* LibraryPatcher::_socket_bases[SOCKET_BASE_TABLE_SIZE] = {}; std::atomic LibraryPatcher::_socket_active{false}; +static_assert(SOCKET_BASE_TABLE_SIZE > 0 && + (SOCKET_BASE_TABLE_SIZE & (SOCKET_BASE_TABLE_SIZE - 1)) == 0, + "socket DSO lookup table size must be a power of two"); + +bool LibraryPatcher::socket_library_patched_unlocked(const void* image_base) { + size_t slot = (reinterpret_cast(image_base) >> 12) & + (SOCKET_BASE_TABLE_SIZE - 1); + for (int probe = 0; probe < SOCKET_BASE_TABLE_SIZE; probe++) { + const void* value = _socket_bases[slot]; + if (value == nullptr) { + return false; + } + if (value == image_base) { + return true; + } + slot = (slot + 1) & (SOCKET_BASE_TABLE_SIZE - 1); + } + return false; +} + +void LibraryPatcher::remember_socket_library_unlocked(const void* image_base) { + size_t slot = (reinterpret_cast(image_base) >> 12) & + (SOCKET_BASE_TABLE_SIZE - 1); + while (_socket_bases[slot] != nullptr && _socket_bases[slot] != image_base) { + slot = (slot + 1) & (SOCKET_BASE_TABLE_SIZE - 1); + } + _socket_bases[slot] = image_base; +} + void LibraryPatcher::initialize() { if (_profiler_name == nullptr) { Dl_info info; @@ -538,7 +573,40 @@ void LibraryPatcher::patch_sigaction() { } } -bool LibraryPatcher::patch_socket_functions() { +class SocketPatchCandidate { +public: + CodeCache* _lib; + UnloadProtection _protection; + size_t _patch_count; + + SocketPatchCandidate(CodeCache* lib, UnloadProtection&& protection, + size_t patch_count) + : _lib(lib), _protection(std::move(protection)), + _patch_count(patch_count) {} + + SocketPatchCandidate(const SocketPatchCandidate&) = delete; + SocketPatchCandidate& operator=(const SocketPatchCandidate&) = delete; + SocketPatchCandidate(SocketPatchCandidate&&) noexcept = default; + SocketPatchCandidate& operator=(SocketPatchCandidate&&) noexcept = default; +}; + +static bool mappingMatches(const CodeCache* lib) { + if (lib->imageBase() == nullptr) { + return false; + } + Dl_info info; + return dladdr(lib->imageBase(), &info) != 0 && + info.dli_fbase == lib->imageBase(); +} + +bool LibraryPatcher::patch_socket_functions(bool require_active) { + auto disable_and_unpatch = []() { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + LibraryPatcher::unpatch_socket_functions(); + return false; + }; + // Resolve the real libc symbols ONCE at first call and cache them. On a // restart cycle (stop()→start()) we MUST NOT re-resolve via RTLD_NEXT: if // any GOT slot in another DSO was missed during unpatch (e.g. its CodeCache @@ -552,167 +620,329 @@ bool LibraryPatcher::patch_socket_functions() { // May resolve to an LD_PRELOAD interposer (e.g. libasan) — intentional. // On musl, RTLD_NEXT returns NULL when libc is loaded before this DSO in the // link map; fall back to RTLD_DEFAULT which finds symbols globally. - // The four statics and the `cached` flag are written once and then - // read-only. They live outside the ExclusiveLockGuard intentionally (dlsym - // must not be called while holding _lock because dlsym may acquire the + // The cached originals and the `has_cached_original` flag are written once + // and then read-only. They live outside the ExclusiveLockGuard intentionally + // (dlsym must not be called while holding _lock because dlsym may acquire the // linker lock, which is also acquired during dlopen — inverting the order // would deadlock). Guard the one-time init with a dedicated once_flag so // that concurrent callers serialise on the dlsym block rather than racing // to write the statics. - static NativeSocketSampler::send_fn cached_send = nullptr; - static NativeSocketSampler::recv_fn cached_recv = nullptr; - static NativeSocketSampler::write_fn cached_write = nullptr; - static NativeSocketSampler::read_fn cached_read = nullptr; + static void* cached_originals[NativeSocketInterposer::NUM_NATIVE_IO_HOOKS] = {}; + static bool has_cached_original = false; static std::once_flag dlsym_once; + + const NativeSocketInterposer::NativeIoHookSpec* hooks = + NativeSocketInterposer::hookSpecs(); + std::call_once(dlsym_once, [&]() { - cached_send = (NativeSocketSampler::send_fn) dlsym(RTLD_NEXT, "send"); - if (!cached_send) cached_send = (NativeSocketSampler::send_fn) dlsym(RTLD_DEFAULT, "send"); - cached_recv = (NativeSocketSampler::recv_fn) dlsym(RTLD_NEXT, "recv"); - if (!cached_recv) cached_recv = (NativeSocketSampler::recv_fn) dlsym(RTLD_DEFAULT, "recv"); - cached_write = (NativeSocketSampler::write_fn) dlsym(RTLD_NEXT, "write"); - if (!cached_write) cached_write = (NativeSocketSampler::write_fn) dlsym(RTLD_DEFAULT, "write"); - cached_read = (NativeSocketSampler::read_fn) dlsym(RTLD_NEXT, "read"); - if (!cached_read) cached_read = (NativeSocketSampler::read_fn) dlsym(RTLD_DEFAULT, "read"); - // If dlsym resolves to one of our own hooks the linker is already serving - // the patched copy. Null the pointers so the early-return below fires. - if (cached_send == &NativeSocketSampler::send_hook || - cached_recv == &NativeSocketSampler::recv_hook || - cached_write == &NativeSocketSampler::write_hook || - cached_read == &NativeSocketSampler::read_hook) { - TEST_LOG("patch_socket_functions dlsym returned hook address; refusing to self-reference"); - cached_send = nullptr; cached_recv = nullptr; - cached_write = nullptr; cached_read = nullptr; + for (int hook_index = 0; hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + void* original = dlsym(RTLD_NEXT, hooks[hook_index].name); + if (original == nullptr) { + original = dlsym(RTLD_DEFAULT, hooks[hook_index].name); + } + if (original == hooks[hook_index].hook) { + TEST_LOG("patch_socket_functions dlsym returned hook address for %s", + hooks[hook_index].name); + // If dlsym resolves to one of our own hooks the linker is already serving + // the patched copy. Null this pointer so the hook is not installed. + original = nullptr; + } + cached_originals[hook_index] = original; + has_cached_original |= original != nullptr; + if (original != nullptr) { + NativeSocketInterposer::setOriginalFunction(hook_index, original); + } } + NativeSocketSampler::setOriginalFunctions( + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_SEND]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_RECV]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_WRITE]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_READ])); }); - auto pre_send = cached_send; - auto pre_recv = cached_recv; - auto pre_write = cached_write; - auto pre_read = cached_read; - TEST_LOG("patch_socket_functions dlsym send=%p recv=%p write=%p read=%p", - (void*)pre_send, (void*)pre_recv, (void*)pre_write, (void*)pre_read); - if (!pre_send || !pre_recv || !pre_write || !pre_read) { - TEST_LOG("patch_socket_functions EARLY RETURN: at least one dlsym returned NULL"); - return false; + + if (!has_cached_original) { + Log::warn("native I/O hooks disabled: all original symbol lookups failed"); + return disable_and_unpatch(); } const CodeCacheArray& native_libs = Libraries::instance()->native_libs(); int num_of_libs = native_libs.count(); - // Pre-resolve all library paths before acquiring the lock: realpath() may - // block on I/O and must not be called while holding _lock. - // We only need the is-self flag per library, so avoid a huge stack allocation. - static_assert(MAX_NATIVE_LIBS > 0, "MAX_NATIVE_LIBS must be positive"); - bool is_self[MAX_NATIVE_LIBS]; - int capped = (num_of_libs <= MAX_NATIVE_LIBS) ? num_of_libs : MAX_NATIVE_LIBS; + int capped = num_of_libs <= MAX_NATIVE_LIBS ? num_of_libs : MAX_NATIVE_LIBS; + std::vector candidates; + try { + candidates.reserve(capped); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to allocate DSO candidate table"); + return disable_and_unpatch(); + } + for (int index = 0; index < capped; index++) { CodeCache* lib = native_libs.at(index); - is_self[index] = false; - if (lib == nullptr || lib->name() == nullptr) continue; + if (lib == nullptr || lib->name() == nullptr) { + continue; + } char path[PATH_MAX]; - char* rp = realpath(lib->name(), path); - is_self[index] = (rp != nullptr && strcmp(rp, _profiler_name) == 0); - } + char* resolved_path = realpath(lib->name(), path); + if (_profiler_name != nullptr && resolved_path != nullptr && + strcmp(resolved_path, _profiler_name) == 0) { + continue; + } - ExclusiveLockGuard locker(&_lock); - // Re-check under the lock only on re-entry (when hooks are already installed): - // a concurrent unpatch_socket_functions() may have cleared _socket_active - // between the acquire-load in install_socket_hooks() and this lock acquisition. - // The initial call from NativeSocketSampler::start() always has _socket_size == 0 - // and must proceed regardless of _socket_active. - if (_socket_size > 0 && !_socket_active.load(std::memory_order_relaxed)) { - return false; - } - // Only assign orig pointers on the first call (no hooks installed yet). - // On re-entry via dlopen, RTLD_NEXT would resolve to the hook itself. - if (_socket_size == 0) { - NativeSocketSampler::setOriginalFunctions(pre_send, pre_recv, pre_write, pre_read); - } - // TODO: hook table (name + hook fn) should be owned by NativeSocketSampler; - // LibraryPatcher should iterate an externally-provided table rather than - // hardcoding the four socket hooks here. - auto try_patch_slot = [&](void** location, void* hook_fn, const char* fn_name, CodeCache* lib) { - if (location == nullptr) return; - for (int i = 0; i < _socket_size; i++) { - if (_socket_entries[i]._location == location) return; + size_t patch_count = 0; + for (int hook_index = 0; hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + ImportId import_id = hooks[hook_index].import_id; + size_t count = lib->importCount(import_id); + if (count == 0) { + continue; + } + if (!lib->importsComplete(import_id)) { + Log::warn("native I/O hooks disabled: incomplete %s imports in %s", + hooks[hook_index].name, lib->name()); + return disable_and_unpatch(); + } + if (cached_originals[hook_index] == nullptr) { + Log::warn("native I/O hooks disabled: no original for imported %s in %s", + hooks[hook_index].name, lib->name()); + return disable_and_unpatch(); + } + patch_count += count; } - if (_socket_size < 4 * MAX_NATIVE_LIBS) { - void* orig = (void*)__atomic_load_n(location, __ATOMIC_ACQUIRE); - _socket_entries[_socket_size]._lib = lib; - _socket_entries[_socket_size]._location = location; - _socket_entries[_socket_size]._func = orig; - __atomic_store_n(location, hook_fn, __ATOMIC_RELEASE); - _socket_size++; - } else { - Log::warn("socket patch table full (%d slots), skipping %s in %s", 4 * MAX_NATIVE_LIBS, fn_name, lib ? lib->name() : "?"); + if (patch_count == 0 || !mappingMatches(lib)) { + continue; } - }; - for (int index = 0; index < capped; index++) { - CodeCache* lib = native_libs.at(index); - if (lib == nullptr) continue; - if (lib->name() == nullptr) continue; - if (is_self[index]) { - continue; + UnloadProtection protection(lib); + if (!protection.isValid()) { + if (!mappingMatches(lib)) { + continue; + } + Log::warn("native I/O hooks disabled: cannot retain mapped DSO %s", + lib->name()); + return disable_and_unpatch(); + } + try { + candidates.emplace_back(lib, std::move(protection), + patch_count); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to retain DSO candidate %s", + lib->name()); + return disable_and_unpatch(); } + } - void** send_location = (void**)lib->findImport(im_send); - void** recv_location = (void**)lib->findImport(im_recv); - void** write_location = (void**)lib->findImport(im_write); - void** read_location = (void**)lib->findImport(im_read); + std::vector libraries_to_release; + bool success = true; + { + ExclusiveLockGuard locker(&_lock); + if (require_active && + !_socket_active.load(std::memory_order_relaxed)) { + return false; + } - if (send_location == nullptr && recv_location == nullptr - && write_location == nullptr && read_location == nullptr) continue; + size_t additional_patches = 0; + size_t additional_libraries = 0; + for (SocketPatchCandidate& candidate : candidates) { + if (!socket_library_patched_unlocked(candidate._lib->imageBase())) { + additional_patches += candidate._patch_count; + additional_libraries++; + } + } + + try { + _socket_entries.reserve(_socket_entries.size() + additional_patches); + _socket_libraries.reserve(_socket_libraries.size() + additional_libraries); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to reserve patch transaction"); + success = false; + } - TEST_LOG("patch_socket_functions PATCH %s send=%p recv=%p write=%p read=%p", - lib->name(), (void*)send_location, (void*)recv_location, - (void*)write_location, (void*)read_location); + if (success) { + for (SocketPatchCandidate& candidate : candidates) { + if (!socket_library_patched_unlocked(candidate._lib->imageBase()) && + !candidate._lib->prepareImportsForPatch()) { + Log::warn("native I/O hooks disabled: cannot make imports writable in %s", + candidate._lib->name()); + success = false; + break; + } + } + } - // The _lock is held during patching to protect _socket_entries and _socket_size. - // Concurrent dlopen_hook calls serialize via the same lock in install_socket_hooks(), - // ensuring slot_patched checks and updates are atomic with respect to each other. - try_patch_slot(send_location, (void*)NativeSocketSampler::send_hook, "send", lib); - try_patch_slot(recv_location, (void*)NativeSocketSampler::recv_hook, "recv", lib); - try_patch_slot(write_location, (void*)NativeSocketSampler::write_hook, "write", lib); - try_patch_slot(read_location, (void*)NativeSocketSampler::read_hook, "read", lib); + if (success) { + for (SocketPatchCandidate& candidate : candidates) { + CodeCache* lib = candidate._lib; + if (socket_library_patched_unlocked(lib->imageBase())) { + continue; + } + size_t first_patch = _socket_entries.size(); + for (int hook_index = 0; + hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + ImportId import_id = hooks[hook_index].import_id; + size_t count = lib->importCount(import_id); + for (size_t import_index = 0; import_index < count; import_index++) { + void** location = lib->findImport(import_id, import_index); + void* original = + reinterpret_cast(__atomic_load_n(location, __ATOMIC_ACQUIRE)); + _socket_entries.push_back({location, original}); + __atomic_store_n(location, hooks[hook_index].hook, __ATOMIC_RELEASE); + } + } + _socket_libraries.push_back( + {lib->imageBase(), candidate._protection.release(), first_patch, + _socket_entries.size() - first_patch}); + remember_socket_library_unlocked(lib->imageBase()); + } + _socket_active.store(true, std::memory_order_release); + } else { + unpatch_socket_functions_unlocked(libraries_to_release); + } + } + + release_socket_libraries(libraries_to_release); + if (!success) { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + return false; } - TEST_LOG("patch_socket_functions DONE total_slots=%d num_libs_scanned=%d", - _socket_size, capped); - _socket_active.store(true, std::memory_order_release); + TEST_LOG("patch_socket_functions DONE total_slots=%zu num_libs_scanned=%d", + _socket_entries.size(), capped); return true; } -void LibraryPatcher::unpatch_socket_functions() { +#ifdef UNIT_TEST +int LibraryPatcher::patch_socket_import_for_test(CodeCache* lib, ImportId import_id, + void* hook, const char* name, + bool retain_library) { + (void)name; + UnloadProtection protection(lib); + if (retain_library && + (!mappingMatches(lib) || !protection.isValid())) { + return -1; + } + ExclusiveLockGuard locker(&_lock); + if (!lib->importsComplete(import_id) || !lib->prepareImportsForPatch()) { + return -1; + } + size_t count = lib->importCount(import_id); + size_t initial_size = _socket_entries.size(); + try { + _socket_entries.reserve(initial_size + count); + if (retain_library) { + _socket_libraries.reserve(_socket_libraries.size() + 1); + } + } catch (const std::bad_alloc&) { + return -1; + } + for (size_t index = 0; index < count; index++) { + void** location = lib->findImport(import_id, index); + bool already_patched = std::any_of( + _socket_entries.begin(), _socket_entries.end(), + [location](const SocketPatchEntry& entry) { + return entry._location == location; + }); + if (already_patched) { + continue; + } + void* original = + reinterpret_cast(__atomic_load_n(location, __ATOMIC_ACQUIRE)); + _socket_entries.push_back({location, original}); + __atomic_store_n(location, hook, __ATOMIC_RELEASE); + } + size_t patched = _socket_entries.size() - initial_size; + if (retain_library && patched != 0) { + _socket_libraries.push_back( + {lib->imageBase(), protection.release(), initial_size, patched}); + remember_socket_library_unlocked(lib->imageBase()); + } + return static_cast(patched); +} + +int LibraryPatcher::socket_patch_count_for_test() { + ExclusiveLockGuard locker(&_lock); + return static_cast(_socket_entries.size()); +} + +int LibraryPatcher::socket_library_count_for_test() { ExclusiveLockGuard locker(&_lock); + return static_cast(_socket_libraries.size()); +} +#endif + +void LibraryPatcher::unpatch_socket_functions_unlocked( + std::vector& libraries_to_release) { // Clear _socket_active FIRST so that any concurrent install_socket_hooks() // thread that already passed the acquire-load on _socket_active (before we // acquired the lock) will see false when it checks again after acquiring the // lock — preventing it from re-patching slots we are about to restore. // Hooks that already entered the hook body before this store are benign: they // hold no lock and will complete normally using the still-valid orig pointers. - // - // ASSUMPTION (dlclose UAF): we write through _socket_entries[i]._location - // without checking that the owning library is still mapped. If a patched - // DSO were actually unmapped between patch and unpatch, this store would - // corrupt freed memory or SEGV. In practice this is benign because (a) the - // host JVM does not dlclose libc-importing DSOs, (b) glibc's dlclose - // refcounts and only unmaps when the final reference is dropped, and - // (c) the same risk is already accepted by unpatch_libraries() and - // unpatch_socket_functions has the same trust model. If a host that - // routinely unmaps libc-importing libraries is ever supported, gate each - // store on a /proc/self/maps lookup or hold a dlopen handle on each lib - // for the patch lifetime. _socket_active.store(false, std::memory_order_release); - TEST_LOG("unpatch_socket_functions restoring %d slot(s)", _socket_size); - for (int index = 0; index < _socket_size; index++) { - __atomic_store_n(_socket_entries[index]._location, _socket_entries[index]._func, __ATOMIC_RELEASE); + TEST_LOG("unpatch_socket_functions restoring %zu slot(s)", _socket_entries.size()); + for (const SocketPatchEntry& entry : _socket_entries) { + __atomic_store_n(entry._location, entry._func, + __ATOMIC_RELEASE); } - _socket_size = 0; - // _orig_send/_orig_recv/_orig_write/_orig_read are intentionally NOT nulled. + _socket_entries.clear(); + memset(_socket_bases, 0, sizeof(_socket_bases)); + _socket_libraries.swap(libraries_to_release); + // Original function pointers are intentionally NOT nulled. // In-flight hook invocations that entered before PLT entries were restored // above may still be executing and will dereference these pointers. // They remain valid (pointing to the real libc functions) until the next // patch_socket_functions() call. } +void LibraryPatcher::release_socket_libraries( + std::vector& libraries) { + for (const SocketPatchedLibrary& library : libraries) { + if (library._unload_protection != nullptr) { + dlclose(library._unload_protection); + } + } + libraries.clear(); +} + +void LibraryPatcher::unpatch_socket_functions() { + std::vector libraries_to_release; + { + ExclusiveLockGuard locker(&_lock); + unpatch_socket_functions_unlocked(libraries_to_release); + } + release_socket_libraries(libraries_to_release); +} + +bool LibraryPatcher::unpatch_socket_functions_if_inactive() { + std::vector libraries_to_release; + { + ExclusiveLockGuard locker(&_lock); + if (NativeSocketInterposer::instance()->active() || NativeSocketSampler::active()) { + return false; + } + if (!_socket_active.load(std::memory_order_relaxed) && + _socket_entries.empty()) { + return false; + } + unpatch_socket_functions_unlocked(libraries_to_release); + } + release_socket_libraries(libraries_to_release); + return true; +} + +void LibraryPatcher::install_socket_hooks() { + if (_socket_active.load(std::memory_order_acquire) && + !patch_socket_functions(true)) { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + } +} + #endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeBlock.cpp b/ddprof-lib/src/main/cpp/nativeBlock.cpp new file mode 100644 index 0000000000..962d839bdf --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.cpp @@ -0,0 +1,140 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeBlock.h" + +#if defined(__linux__) + +#include "context_api.h" +#include "profiler.h" +#include "taskBlockRecorder.h" +#include "threadLocalData.h" +#include "tsc.h" + +#include +#include + +#ifdef UNIT_TEST +static std::atomic _native_block_observer{nullptr}; + +void NativeBlockScope::setHookObserverForTest(HookObserver observer) { + _native_block_observer.store(observer, std::memory_order_release); +} + +static void observeNativeBlockPhase(const char* phase, NativeBlockKind kind, int blocker_id) { + NativeBlockScope::HookObserver observer = + _native_block_observer.load(std::memory_order_acquire); + if (observer != nullptr) { + observer(phase, kind, blocker_id); + } +} +#endif + +NativeBlockScope::NativeBlockScope(NativeBlockKind kind, int blocker_id, + OSThreadState state) + : _blocker(blocker(kind, blocker_id)), _state(state) { + int saved_errno = errno; +#ifdef UNIT_TEST + observeNativeBlockPhase("enter", kind, blocker_id); +#endif + + Profiler* profiler = Profiler::instance(); + if (!profiler->taskBlockEnabled()) { + errno = saved_errno; + return; + } + + ThreadFilter* thread_filter = profiler->threadFilter(); + if (!thread_filter->registryActive()) { + errno = saved_errno; + return; + } + + ProfiledThread* current = ProfiledThread::currentSignalSafe(); + if (current == nullptr || current->threadType() != ProfiledThread::TYPE_JAVA_THREAD) { + errno = saved_errno; + return; + } + + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + errno = saved_errno; + return; + } + + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + errno = saved_errno; + return; + } + + u64 token = thread_filter->enterBlockedRun(slot_id, state, BlockRunOwner::NATIVE); + if (token == 0) { + errno = saved_errno; + return; + } + + _active = true; + _tid = current->tid(); + _slot_id = slot_id; + _generation = ThreadFilter::tokenGeneration(token); + _start_ticks = TSC::ticks(); + _context = context; + errno = saved_errno; +} + +NativeBlockScope::~NativeBlockScope() { +#ifdef UNIT_TEST + observeNativeBlockPhase("exit", static_cast(_blocker >> 32), + static_cast(_blocker & 0xffffffff)); +#endif + if (!_active) { + return; + } + int saved_errno = errno; + finish(TSC::ticks()); + errno = saved_errno; +} + +void NativeBlockScope::finish(u64 end_ticks) { + if (!_active) { + return; + } + _active = false; + + Profiler* profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) { + profiler->waitForTaskBlockRotation(); + } + ThreadFilter* thread_filter = profiler->threadFilter(); + BlockRunSnapshot snapshot{}; + snapshot.active_state = _state; + snapshot.owner = BlockRunOwner::NATIVE; + if (!thread_filter->registryActive() || + !thread_filter->snapshotAndExitBlockedRun(_slot_id, _generation, &snapshot)) { + if (activity) { + profiler->leaveTaskBlockActivity(); + } else { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + } + return; + } + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + + if (recording_enabled) { + recordTaskBlockIfEligible(_tid, nullptr, 0, _start_ticks, end_ticks, + _context, _blocker, 0, + snapshot.active_state, true); + } + profiler->leaveTaskBlockActivity(); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeBlock.h b/ddprof-lib/src/main/cpp/nativeBlock.h new file mode 100644 index 0000000000..565bbe0365 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.h @@ -0,0 +1,66 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_BLOCK_H +#define _NATIVE_BLOCK_H + +#include "arch.h" + +#if defined(__linux__) + +#include "context.h" +#include "threadFilter.h" +#include "threadState.h" + +enum class NativeBlockKind : u32 { + STREAM_SOCKET = 1, + CONNECT = 2, + ACCEPT = 3, + UDP_RECEIVE = 4, + POLL = 5, + SELECT = 6, + EPOLL_WAIT = 7, +}; + +// Describes physical blocking by the current OS thread. When called by a virtual +// thread in JNI, the recorded thread is the pinned carrier, not the logical thread. +class NativeBlockScope { +public: + NativeBlockScope(NativeBlockKind kind, int blocker_id, + OSThreadState state = OSThreadState::IO_WAIT); + ~NativeBlockScope(); + + NativeBlockScope(const NativeBlockScope&) = delete; + NativeBlockScope& operator=(const NativeBlockScope&) = delete; + + bool active() const { return _active; } + + static u64 blocker(NativeBlockKind kind, int blocker_id) { + return (static_cast(kind) << 32) | static_cast(blocker_id); + } + +#ifdef UNIT_TEST + using HookObserver = void (*)(const char* phase, NativeBlockKind kind, int blocker_id); + static void setHookObserverForTest(HookObserver observer); + u64 startTicksForTest() const { return _start_ticks; } + void finishForTest(u64 end_ticks) { finish(end_ticks); } +#endif + +private: + bool _active = false; + int _tid = -1; + ThreadFilter::SlotID _slot_id = -1; + u64 _generation = 0; + u64 _start_ticks = 0; + u64 _blocker = 0; + OSThreadState _state = OSThreadState::UNKNOWN; + Context _context = {}; + + void finish(u64 end_ticks); +}; + +#endif // __linux__ + +#endif // _NATIVE_BLOCK_H diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp new file mode 100644 index 0000000000..b6549629f1 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp @@ -0,0 +1,197 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeFdClassifier.h" + +#if defined(__linux__) + +#include +#include +#include + +#ifdef UNIT_TEST +std::atomic NativeFdClassifier::_probe_override{nullptr}; +std::atomic NativeFdClassifier::_probe_count{0}; +#endif + +NativeFdClassifier::NativeFdClassifier() { + for (int index = 0; index < FD_TYPE_CACHE_SIZE; index++) { + _fd_type_cache[index].store(0, std::memory_order_relaxed); + } + for (int index = 0; index < HIGH_FD_TYPE_CACHE_SIZE; index++) { + _high_fd_type_cache[index].store(0, std::memory_order_relaxed); + } +} + +#ifdef UNIT_TEST +void NativeFdClassifier::setProbeOverrideForTest(ProbeOverride probe) { + _probe_override.store(probe, std::memory_order_release); +} + +uint64_t NativeFdClassifier::probeCountForTest() { + return _probe_count.load(std::memory_order_acquire); +} + +void NativeFdClassifier::resetProbeCountForTest() { + _probe_count.store(0, std::memory_order_release); +} +#endif + +uint8_t NativeFdClassifier::probeFdType(int fd) { +#ifdef UNIT_TEST + _probe_count.fetch_add(1, std::memory_order_relaxed); +#endif + int so_type; + socklen_t solen = sizeof(so_type); + int rc; +#ifdef UNIT_TEST + ProbeOverride probe = _probe_override.load(std::memory_order_acquire); + int probe_errno = 0; + if (probe != nullptr) { + rc = probe(fd, &so_type, &probe_errno); + if (rc != 0) { + errno = probe_errno; + } + } else +#endif + { + rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); + } + if (rc == 0) { + if (so_type == SOCK_STREAM) { + return FD_TYPE_STREAM_SOCKET; + } + if (so_type == SOCK_DGRAM) { + return FD_TYPE_DATAGRAM_SOCKET; + } + return FD_TYPE_OTHER_SOCKET; + } + return errno == ENOTSOCK ? FD_TYPE_NON_SOCKET : 0; +} + +void NativeFdClassifier::cacheFdType(int fd, uint8_t type, uint32_t gen) { + if (fd < 0 || type == 0) { + return; + } + if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { + _fd_type_cache[fd].store((gen << FD_TYPE_GEN_SHIFT) | type, + std::memory_order_release); + } else { + _high_fd_type_cache[highFdCacheIndex(fd)].store(highFdEntry(fd, gen, type), + std::memory_order_release); + } +} + +uint64_t NativeFdClassifier::highFdEntry(int fd, uint32_t gen, uint8_t type) { + return (static_cast(static_cast(fd)) << 32) + | (static_cast(gen & FD_TYPE_GEN_MASK) << FD_TYPE_GEN_SHIFT) + | static_cast(type); +} + +bool NativeFdClassifier::highFdEntryMatches(uint64_t entry, int fd, uint32_t gen) { + return highFdEntryMatchesFd(entry, fd) + && (((entry >> FD_TYPE_GEN_SHIFT) & FD_TYPE_GEN_MASK) + == (gen & FD_TYPE_GEN_MASK)); +} + +bool NativeFdClassifier::highFdEntryMatchesFd(uint64_t entry, int fd) { + return static_cast(entry >> 32) == static_cast(fd); +} + +int NativeFdClassifier::highFdCacheIndex(int fd) { + return static_cast(static_cast(fd) % + static_cast(HIGH_FD_TYPE_CACHE_SIZE)); +} + +uint8_t NativeFdClassifier::highFdType(int fd, uint32_t gen) { + int index = highFdCacheIndex(fd); + uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); + if (highFdEntryMatches(cached, fd, gen)) { + uint8_t type = static_cast(cached & FD_TYPE_MASK); + if (type != 0) { + return type; + } + } + + uint8_t type = probeFdType(fd); + // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: + // the same fd number may later be reused for a socket. + if (type != 0) { + _high_fd_type_cache[index].store(highFdEntry(fd, gen, type), + std::memory_order_release); + } + return type; +} + +uint8_t NativeFdClassifier::fdType(int fd) { + if (fd < 0) { + return 0; + } + + uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); + if (static_cast(fd) >= static_cast(FD_TYPE_CACHE_SIZE)) { + return highFdType(fd, gen); + } + + uint32_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); + if ((cached >> FD_TYPE_GEN_SHIFT) == gen) { + uint8_t type = static_cast(cached & FD_TYPE_MASK); + if (type != 0) { + return type; + } + } + + uint8_t type = probeFdType(fd); + // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: + // the same fd number may later be reused for a socket. + if (type != 0) { + cacheFdType(fd, type, gen); + } + return type; +} + +bool NativeFdClassifier::isStreamSocket(int fd) { + return fdType(fd) == FD_TYPE_STREAM_SOCKET; +} + +bool NativeFdClassifier::isDatagramSocket(int fd) { + return fdType(fd) == FD_TYPE_DATAGRAM_SOCKET; +} + +void NativeFdClassifier::cacheNonSocket(int fd) { + if (fd < 0) { + return; + } + uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); + cacheFdType(fd, FD_TYPE_NON_SOCKET, gen); +} + +void NativeFdClassifier::clearHighFdType(int fd) { + int index = highFdCacheIndex(fd); + uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); + while (highFdEntryMatchesFd(cached, fd)) { + if (_high_fd_type_cache[index].compare_exchange_weak( + cached, 0, std::memory_order_acq_rel, std::memory_order_acquire)) { + return; + } + } +} + +void NativeFdClassifier::clearFdType(int fd) { + if (fd < 0) { + return; + } + if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { + _fd_type_cache[fd].store(0, std::memory_order_release); + } else { + clearHighFdType(fd); + } +} + +void NativeFdClassifier::clearFdTypeCache() { + _fd_cache_gen.fetch_add(1, std::memory_order_release); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.h b/ddprof-lib/src/main/cpp/nativeFdClassifier.h new file mode 100644 index 0000000000..7523da76ea --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.h @@ -0,0 +1,64 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_FD_CLASSIFIER_H +#define _NATIVE_FD_CLASSIFIER_H + +#include +#include + +#if defined(__linux__) + +class NativeFdClassifier { +public: + NativeFdClassifier(); + + bool isStreamSocket(int fd); + bool isDatagramSocket(int fd); + void cacheNonSocket(int fd); + void clearFdType(int fd); + void clearFdTypeCache(); + +#ifdef UNIT_TEST + using ProbeOverride = int (*)(int fd, int *so_type, int *probe_errno); + static void setProbeOverrideForTest(ProbeOverride probe); + static uint64_t probeCountForTest(); + static void resetProbeCountForTest(); +#endif + +private: + static const int FD_TYPE_CACHE_SIZE = 65536; + static const int HIGH_FD_TYPE_CACHE_SIZE = 4096; + static const uint32_t FD_TYPE_MASK = 0xf; + static const uint32_t FD_TYPE_GEN_SHIFT = 4; + static const uint32_t FD_TYPE_GEN_MASK = 0x0fffffff; + static const uint8_t FD_TYPE_STREAM_SOCKET = 1; + static const uint8_t FD_TYPE_DATAGRAM_SOCKET = 2; + static const uint8_t FD_TYPE_OTHER_SOCKET = 3; + static const uint8_t FD_TYPE_NON_SOCKET = 4; + + std::atomic _fd_cache_gen{1}; + std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; + std::atomic _high_fd_type_cache[HIGH_FD_TYPE_CACHE_SIZE]; + + static uint8_t probeFdType(int fd); + static uint64_t highFdEntry(int fd, uint32_t gen, uint8_t type); + static bool highFdEntryMatches(uint64_t entry, int fd, uint32_t gen); + static bool highFdEntryMatchesFd(uint64_t entry, int fd); + static int highFdCacheIndex(int fd); + void cacheFdType(int fd, uint8_t type, uint32_t gen); + uint8_t highFdType(int fd, uint32_t gen); + void clearHighFdType(int fd); + uint8_t fdType(int fd); + +#ifdef UNIT_TEST + static std::atomic _probe_override; + static std::atomic _probe_count; +#endif +}; + +#endif // __linux__ + +#endif // _NATIVE_FD_CLASSIFIER_H diff --git a/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp b/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp new file mode 100644 index 0000000000..341dcdc31d --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp @@ -0,0 +1,477 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeSocketInterposer.h" + +#if defined(__linux__) + +#include "libraryPatcher.h" +#include "nativeSocketSampler.h" +#include "tsc.h" + +#include +#include +#include + +static inline bool nonZeroTimeval(const struct timeval* timeout) { + return timeout == nullptr || timeout->tv_sec != 0 || timeout->tv_usec != 0; +} + +static inline bool nonZeroTimespec(const struct timespec* timeout) { + return timeout == nullptr || timeout->tv_sec != 0 || timeout->tv_nsec != 0; +} + +class ErrnoGuard { +public: + ErrnoGuard() : _saved(errno) {} + ~ErrnoGuard() { errno = _saved; } + +private: + int _saved; +}; + +template +static inline Ret runNativeIoHook(bool eligible, NativeBlockKind kind, int fd, + Fn fn, Call call) { + if (fn == nullptr) { + errno = ENOSYS; + return static_cast(-1); + } + if (!NativeSocketInterposer::instance()->active() || !eligible) { + return call(fn); + } + + NativeBlockScope block(kind, fd); + Ret ret = call(fn); + return ret; +} + +template +static inline ssize_t runStreamSocketHook(int fd, Fn fn, u8 op, Call call) { + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + + // read/write are intentionally routed through the socket classifier because + // socket I/O often uses generic fd APIs. Non-socket fds are cached after the + // first stable ENOTSOCK probe; high or transiently failing fds may be probed + // again on later calls. + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + if (!eligible) { + return call(fn); + } + + u64 t0 = TSC::ticks(); + ssize_t ret; + { + NativeBlockScope block(NativeBlockKind::STREAM_SOCKET, fd); + ret = call(fn); + } + u64 t1 = TSC::ticks(); + if (NativeSocketSampler::active()) { + ErrnoGuard errno_guard; + return NativeSocketSampler::recordHookResult(fd, ret, t0, t1, op); + } + return ret; +} + +template +static inline Ret runDatagramSocketHook(int fd, Fn fn, Call call) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isDatagramSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::UDP_RECEIVE, fd, fn, + call); +} + +NativeSocketInterposer* const NativeSocketInterposer::_instance = new NativeSocketInterposer(); +std::atomic NativeSocketInterposer::_orig_send{nullptr}; +std::atomic NativeSocketInterposer::_orig_recv{nullptr}; +std::atomic NativeSocketInterposer::_orig_write{nullptr}; +std::atomic NativeSocketInterposer::_orig_read{nullptr}; +std::atomic NativeSocketInterposer::_orig_close{nullptr}; +std::atomic NativeSocketInterposer::_orig_dup2{nullptr}; +std::atomic NativeSocketInterposer::_orig_dup3{nullptr}; +std::atomic NativeSocketInterposer::_orig_connect{nullptr}; +std::atomic NativeSocketInterposer::_orig_accept{nullptr}; +std::atomic NativeSocketInterposer::_orig_accept4{nullptr}; +std::atomic NativeSocketInterposer::_orig_recvfrom{nullptr}; +std::atomic NativeSocketInterposer::_orig_recvmsg{nullptr}; +std::atomic NativeSocketInterposer::_orig_epoll_wait{nullptr}; +std::atomic NativeSocketInterposer::_orig_epoll_pwait{nullptr}; +std::atomic NativeSocketInterposer::_orig_poll{nullptr}; +std::atomic NativeSocketInterposer::_orig_ppoll{nullptr}; +std::atomic NativeSocketInterposer::_orig_select{nullptr}; +std::atomic NativeSocketInterposer::_orig_pselect{nullptr}; + +static_assert(std::atomic::is_always_lock_free, + "native I/O hook function pointers must be lock-free"); + +const NativeSocketInterposer::NativeIoHookSpec* NativeSocketInterposer::hookSpecs() { + static const NativeIoHookSpec specs[NUM_NATIVE_IO_HOOKS] = { + {im_send, "send", reinterpret_cast(send_hook)}, + {im_recv, "recv", reinterpret_cast(recv_hook)}, + {im_write, "write", reinterpret_cast(write_hook)}, + {im_read, "read", reinterpret_cast(read_hook)}, + {im_close, "close", reinterpret_cast(close_hook)}, + {im_dup2, "dup2", reinterpret_cast(dup2_hook)}, + {im_dup3, "dup3", reinterpret_cast(dup3_hook)}, + {im_connect, "connect", reinterpret_cast(connect_hook)}, + {im_accept, "accept", reinterpret_cast(accept_hook)}, + {im_accept4, "accept4", reinterpret_cast(accept4_hook)}, + {im_recvfrom, "recvfrom", reinterpret_cast(recvfrom_hook)}, + {im_recvmsg, "recvmsg", reinterpret_cast(recvmsg_hook)}, + {im_epoll_wait, "epoll_wait", reinterpret_cast(epoll_wait_hook)}, + {im_epoll_pwait, "epoll_pwait", reinterpret_cast(epoll_pwait_hook)}, + {im_poll, "poll", reinterpret_cast(poll_hook)}, + {im_ppoll, "ppoll", reinterpret_cast(ppoll_hook)}, + {im_select, "select", reinterpret_cast(select_hook)}, + {im_pselect, "pselect", reinterpret_cast(pselect_hook)}, + }; + return specs; +} + +bool NativeSocketInterposer::setOriginalFunction(int hook_index, void* original) { + switch (hook_index) { + case HOOK_SEND: + _orig_send.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECV: + _orig_recv.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_WRITE: + _orig_write.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_READ: + _orig_read.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_CLOSE: + _orig_close.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_DUP2: + _orig_dup2.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_DUP3: + _orig_dup3.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_CONNECT: + _orig_connect.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_ACCEPT: + _orig_accept.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_ACCEPT4: + _orig_accept4.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECVFROM: + _orig_recvfrom.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECVMSG: + _orig_recvmsg.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_EPOLL_WAIT: + _orig_epoll_wait.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_EPOLL_PWAIT: + _orig_epoll_pwait.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_POLL: + _orig_poll.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_PPOLL: + _orig_ppoll.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_SELECT: + _orig_select.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_PSELECT: + _orig_pselect.store(reinterpret_cast(original), + std::memory_order_release); + return true; + default: + return false; + } +} + +bool NativeSocketInterposer::isStreamSocket(int fd) { + return _fd_classifier.isStreamSocket(fd); +} + +bool NativeSocketInterposer::isDatagramSocket(int fd) { + return _fd_classifier.isDatagramSocket(fd); +} + +void NativeSocketInterposer::clearFdType(int fd) { + _fd_classifier.clearFdType(fd); +} + +void NativeSocketInterposer::clearFdTypeCache() { + _fd_classifier.clearFdTypeCache(); +} + +Error NativeSocketInterposer::start() { + clearFdTypeCache(); + _active.store(true, std::memory_order_release); + if (!LibraryPatcher::patch_socket_functions()) { + _active.store(false, std::memory_order_release); + return Error("failed to install native I/O hooks"); + } + return Error::OK; +} + +void NativeSocketInterposer::stop() { + _active.store(false, std::memory_order_release); + LibraryPatcher::unpatch_socket_functions_if_inactive(); + clearFdTypeCache(); +} + +void NativeSocketInterposer::disableAfterPatchFailure() { + _active.store(false, std::memory_order_release); + clearFdTypeCache(); +} + +ssize_t NativeSocketInterposer::send_hook(int fd, const void* buf, size_t len, + int flags) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::send_hook(fd, buf, len, flags); + } + return runStreamSocketHook(fd, _orig_send.load(std::memory_order_acquire), 0, + [&](send_fn fn) { return fn(fd, buf, len, flags); }); +} + +ssize_t NativeSocketInterposer::recv_hook(int fd, void* buf, size_t len, + int flags) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::recv_hook(fd, buf, len, flags); + } + return runStreamSocketHook(fd, _orig_recv.load(std::memory_order_acquire), 1, + [&](recv_fn fn) { return fn(fd, buf, len, flags); }); +} + +ssize_t NativeSocketInterposer::write_hook(int fd, const void* buf, size_t len) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::write_hook(fd, buf, len); + } + return runStreamSocketHook(fd, _orig_write.load(std::memory_order_acquire), 2, + [&](write_fn fn) { return fn(fd, buf, len); }); +} + +ssize_t NativeSocketInterposer::read_hook(int fd, void* buf, size_t len) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::read_hook(fd, buf, len); + } + return runStreamSocketHook(fd, _orig_read.load(std::memory_order_acquire), 3, + [&](read_fn fn) { return fn(fd, buf, len); }); +} + +int NativeSocketInterposer::close_hook(int fd) { + int ret; + close_fn original = _orig_close.load(std::memory_order_acquire); + if (original == nullptr) { + ret = static_cast(syscall(SYS_close, fd)); + } else { + ret = original(fd); + } + { + ErrnoGuard errno_guard; + NativeSocketInterposer::instance()->clearFdType(fd); + NativeSocketSampler::instance()->clearFdCacheEntry(fd); + } + return ret; +} + +int NativeSocketInterposer::dup2_hook(int oldfd, int newfd) { + int ret; + dup2_fn original = _orig_dup2.load(std::memory_order_acquire); + if (original == nullptr) { +#ifdef SYS_dup2 + ret = static_cast(syscall(SYS_dup2, oldfd, newfd)); +#else + errno = ENOSYS; + ret = -1; +#endif + } else { + ret = original(oldfd, newfd); + } + { + ErrnoGuard errno_guard; + if (ret >= 0) { + // dup2() implicitly closes newfd before reusing it, so clear stale fd + // classification and address state for the target descriptor. + NativeSocketInterposer::instance()->clearFdType(newfd); + NativeSocketSampler::instance()->clearFdCacheEntry(newfd); + } + } + return ret; +} + +int NativeSocketInterposer::dup3_hook(int oldfd, int newfd, int flags) { + int ret; + dup3_fn original = _orig_dup3.load(std::memory_order_acquire); + if (original == nullptr) { +#ifdef SYS_dup3 + ret = static_cast(syscall(SYS_dup3, oldfd, newfd, flags)); +#else + errno = ENOSYS; + ret = -1; +#endif + } else { + ret = original(oldfd, newfd, flags); + } + { + ErrnoGuard errno_guard; + if (ret >= 0) { + // dup3() implicitly closes newfd before reusing it, so clear stale fd + // classification and address state for the target descriptor. + NativeSocketInterposer::instance()->clearFdType(newfd); + NativeSocketSampler::instance()->clearFdCacheEntry(newfd); + } + } + return ret; +} + +int NativeSocketInterposer::connect_hook(int fd, const struct sockaddr* addr, + socklen_t addrlen) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::CONNECT, fd, + _orig_connect.load(std::memory_order_acquire), + [&](connect_fn fn) { return fn(fd, addr, addrlen); }); +} + +int NativeSocketInterposer::accept_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, + _orig_accept.load(std::memory_order_acquire), + [&](accept_fn fn) { return fn(fd, addr, addrlen); }); +} + +int NativeSocketInterposer::accept4_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen, int flags) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, + _orig_accept4.load(std::memory_order_acquire), + [&](accept4_fn fn) { return fn(fd, addr, addrlen, flags); }); +} + +ssize_t NativeSocketInterposer::recvfrom_hook(int fd, void* buf, size_t len, + int flags, struct sockaddr* src_addr, + socklen_t* addrlen) { + return runDatagramSocketHook( + fd, _orig_recvfrom.load(std::memory_order_acquire), [&](recvfrom_fn fn) { + return fn(fd, buf, len, flags, src_addr, addrlen); + }); +} + +ssize_t NativeSocketInterposer::recvmsg_hook(int fd, struct msghdr* msg, int flags) { + return runDatagramSocketHook( + fd, _orig_recvmsg.load(std::memory_order_acquire), [&](recvmsg_fn fn) { + return fn(fd, msg, flags); + }); +} + +int NativeSocketInterposer::epoll_wait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout) { + bool eligible = maxevents > 0 && timeout != 0; + return runNativeIoHook( + eligible, NativeBlockKind::EPOLL_WAIT, epfd, + _orig_epoll_wait.load(std::memory_order_acquire), + [&](epoll_wait_fn fn) { + return fn(epfd, events, maxevents, timeout); + }); +} + +int NativeSocketInterposer::epoll_pwait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout, + const sigset_t* sigmask) { + bool eligible = maxevents > 0 && timeout != 0; + return runNativeIoHook( + eligible, NativeBlockKind::EPOLL_WAIT, epfd, + _orig_epoll_pwait.load(std::memory_order_acquire), + [&](epoll_pwait_fn fn) { + return fn(epfd, events, maxevents, timeout, sigmask); + }); +} + +int NativeSocketInterposer::poll_hook(struct pollfd* fds, nfds_t nfds, int timeout) { + bool eligible = fds != nullptr && nfds > 0 && timeout != 0; + return runNativeIoHook(eligible, NativeBlockKind::POLL, 0, + _orig_poll.load(std::memory_order_acquire), + [&](poll_fn fn) { return fn(fds, nfds, timeout); }); +} + +int NativeSocketInterposer::ppoll_hook(struct pollfd* fds, nfds_t nfds, + const struct timespec* timeout_ts, + const sigset_t* sigmask) { + bool eligible = fds != nullptr && nfds > 0 && nonZeroTimespec(timeout_ts); + return runNativeIoHook(eligible, NativeBlockKind::POLL, 0, + _orig_ppoll.load(std::memory_order_acquire), + [&](ppoll_fn fn) { return fn(fds, nfds, timeout_ts, sigmask); }); +} + +int NativeSocketInterposer::select_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, struct timeval* timeout) { + bool eligible = nfds > 0 && nonZeroTimeval(timeout); + return runNativeIoHook(eligible, NativeBlockKind::SELECT, 0, + _orig_select.load(std::memory_order_acquire), + [&](select_fn fn) { + return fn(nfds, readfds, writefds, exceptfds, timeout); + }); +} + +int NativeSocketInterposer::pselect_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, + const struct timespec* timeout_ts, + const sigset_t* sigmask) { + bool eligible = nfds > 0 && nonZeroTimespec(timeout_ts); + return runNativeIoHook(eligible, NativeBlockKind::SELECT, 0, + _orig_pselect.load(std::memory_order_acquire), + [&](pselect_fn fn) { + return fn(nfds, readfds, writefds, exceptfds, + timeout_ts, sigmask); + }); +} + +#else + +NativeSocketInterposer* const NativeSocketInterposer::_instance = new NativeSocketInterposer(); + +#endif diff --git a/ddprof-lib/src/main/cpp/nativeSocketInterposer.h b/ddprof-lib/src/main/cpp/nativeSocketInterposer.h new file mode 100644 index 0000000000..23f03830d7 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.h @@ -0,0 +1,186 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_SOCKET_INTERPOSER_H +#define _NATIVE_SOCKET_INTERPOSER_H + +#include "arguments.h" + +#include +#include + +#if defined(__linux__) + +#include "codeCache.h" +#include "nativeBlock.h" +#include "nativeFdClassifier.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class NativeSocketInterposer { +public: + typedef ssize_t (*send_fn)(int, const void*, size_t, int); + typedef ssize_t (*recv_fn)(int, void*, size_t, int); + typedef ssize_t (*write_fn)(int, const void*, size_t); + typedef ssize_t (*read_fn)(int, void*, size_t); + typedef int (*close_fn)(int); + typedef int (*dup2_fn)(int, int); + typedef int (*dup3_fn)(int, int, int); + typedef int (*connect_fn)(int, const struct sockaddr*, socklen_t); + typedef int (*accept_fn)(int, struct sockaddr*, socklen_t*); + typedef int (*accept4_fn)(int, struct sockaddr*, socklen_t*, int); + typedef ssize_t (*recvfrom_fn)(int, void*, size_t, int, struct sockaddr*, socklen_t*); + typedef ssize_t (*recvmsg_fn)(int, struct msghdr*, int); + typedef int (*epoll_wait_fn)(int, struct epoll_event*, int, int); + typedef int (*epoll_pwait_fn)(int, struct epoll_event*, int, int, const sigset_t*); + typedef int (*poll_fn)(struct pollfd*, nfds_t, int); + typedef int (*ppoll_fn)(struct pollfd*, nfds_t, const struct timespec*, const sigset_t*); + typedef int (*select_fn)(int, fd_set*, fd_set*, fd_set*, struct timeval*); + typedef int (*pselect_fn)(int, fd_set*, fd_set*, fd_set*, const struct timespec*, + const sigset_t*); + + enum NativeIoHookIndex : int { + HOOK_SEND = 0, + HOOK_RECV, + HOOK_WRITE, + HOOK_READ, + HOOK_CLOSE, + HOOK_DUP2, + HOOK_DUP3, + HOOK_CONNECT, + HOOK_ACCEPT, + HOOK_ACCEPT4, + HOOK_RECVFROM, + HOOK_RECVMSG, + HOOK_EPOLL_WAIT, + HOOK_EPOLL_PWAIT, + HOOK_POLL, + HOOK_PPOLL, + HOOK_SELECT, + HOOK_PSELECT, + NUM_NATIVE_IO_HOOKS + }; + + struct NativeIoHookSpec { + ImportId import_id; + const char* name; + void* hook; + }; + + static NativeSocketInterposer* instance() { return _instance; } + + Error start(); + void stop(); + void disableAfterPatchFailure(); + bool active() const { return _active.load(std::memory_order_acquire); } + +#ifdef UNIT_TEST + bool setActiveForTest(bool active) { + return _active.exchange(active, std::memory_order_acq_rel); + } +#endif + + bool isStreamSocket(int fd); + bool isDatagramSocket(int fd); + void clearFdType(int fd); + void clearFdTypeCache(); + + static const NativeIoHookSpec* hookSpecs(); + static bool setOriginalFunction(int hook_index, void* original); + + static ssize_t send_hook(int fd, const void* buf, size_t len, int flags); + static ssize_t recv_hook(int fd, void* buf, size_t len, int flags); + static ssize_t write_hook(int fd, const void* buf, size_t len); + static ssize_t read_hook(int fd, void* buf, size_t len); + static int close_hook(int fd); + static int dup2_hook(int oldfd, int newfd); + static int dup3_hook(int oldfd, int newfd, int flags); + static int connect_hook(int fd, const struct sockaddr* addr, socklen_t addrlen); + static int accept_hook(int fd, struct sockaddr* addr, socklen_t* addrlen); + static int accept4_hook(int fd, struct sockaddr* addr, socklen_t* addrlen, int flags); + static ssize_t recvfrom_hook(int fd, void* buf, size_t len, int flags, + struct sockaddr* src_addr, socklen_t* addrlen); + static ssize_t recvmsg_hook(int fd, struct msghdr* msg, int flags); + static int epoll_wait_hook(int epfd, struct epoll_event* events, int maxevents, + int timeout); + static int epoll_pwait_hook(int epfd, struct epoll_event* events, int maxevents, + int timeout, const sigset_t* sigmask); + static int poll_hook(struct pollfd* fds, nfds_t nfds, int timeout); + static int ppoll_hook(struct pollfd* fds, nfds_t nfds, + const struct timespec* timeout_ts, const sigset_t* sigmask); + static int select_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, struct timeval* timeout); + static int pselect_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, const struct timespec* timeout_ts, + const sigset_t* sigmask); + + static void setOriginalFunctions(send_fn s, recv_fn r, write_fn w, read_fn rd) { + _orig_send.store(s, std::memory_order_release); + _orig_recv.store(r, std::memory_order_release); + _orig_write.store(w, std::memory_order_release); + _orig_read.store(rd, std::memory_order_release); + } + + static void getOriginalFunctions(send_fn& s, recv_fn& r, write_fn& w, read_fn& rd) { + s = _orig_send.load(std::memory_order_acquire); + r = _orig_recv.load(std::memory_order_acquire); + w = _orig_write.load(std::memory_order_acquire); + rd = _orig_read.load(std::memory_order_acquire); + } + +private: + static NativeSocketInterposer* const _instance; + // Production publishes these once before installing any import hook. Atomic + // access also keeps test overrides and hook reads data-race-free. + static std::atomic _orig_send; + static std::atomic _orig_recv; + static std::atomic _orig_write; + static std::atomic _orig_read; + static std::atomic _orig_close; + static std::atomic _orig_dup2; + static std::atomic _orig_dup3; + static std::atomic _orig_connect; + static std::atomic _orig_accept; + static std::atomic _orig_accept4; + static std::atomic _orig_recvfrom; + static std::atomic _orig_recvmsg; + static std::atomic _orig_epoll_wait; + static std::atomic _orig_epoll_pwait; + static std::atomic _orig_poll; + static std::atomic _orig_ppoll; + static std::atomic _orig_select; + static std::atomic _orig_pselect; + + NativeFdClassifier _fd_classifier; + std::atomic _active{false}; + + NativeSocketInterposer() = default; +}; + +#else + +class NativeSocketInterposer { +public: + static NativeSocketInterposer* instance() { return _instance; } + Error start() { return Error::OK; } + void stop() {} + void disableAfterPatchFailure() {} + void clearFdTypeCache() {} + +private: + static NativeSocketInterposer* const _instance; + NativeSocketInterposer() = default; +}; + +#endif + +#endif // _NATIVE_SOCKET_INTERPOSER_H diff --git a/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp b/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp index 79591e7d5a..69e1923fff 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp @@ -11,6 +11,7 @@ #include "flightRecorder.h" #include "libraryPatcher.h" #include "log.h" +#include "nativeSocketInterposer.h" #include "os.h" #include "profiler.h" #include "tsc.h" @@ -44,6 +45,34 @@ std::atomic NativeSocketSampler::_orig_send{nullp std::atomic NativeSocketSampler::_orig_recv{nullptr}; std::atomic NativeSocketSampler::_orig_write{nullptr}; std::atomic NativeSocketSampler::_orig_read{nullptr}; +std::atomic NativeSocketSampler::_active{false}; + +#ifdef UNIT_TEST +static std::atomic _native_socket_sampler_observer{nullptr}; + +void NativeSocketSampler::setHookObserverForTest(HookObserver observer) { + _native_socket_sampler_observer.store(observer, std::memory_order_release); +} + +uint64_t NativeSocketSampler::socketProbeCountForTest() { + return NativeFdClassifier::probeCountForTest(); +} + +void NativeSocketSampler::resetSocketProbeCountForTest() { + NativeFdClassifier::resetProbeCountForTest(); +} + +void NativeSocketSampler::setProbeOverrideForTest(ProbeOverride probe) { + NativeFdClassifier::setProbeOverrideForTest(probe); +} + +void NativeSocketSampler::observeHookPhaseForTest(const char* phase, int fd, u8 op, ssize_t ret) { + HookObserver observer = _native_socket_sampler_observer.load(std::memory_order_acquire); + if (observer != nullptr) { + observer(phase, fd, op, ret); + } +} +#endif std::string NativeSocketSampler::resolveAddr(int fd) { struct sockaddr_storage ss; @@ -84,51 +113,7 @@ bool NativeSocketSampler::isSocket(int fd) { // Accepts any SOCK_STREAM socket (including AF_UNIX); AF_INET/AF_INET6 filtering // is deferred to resolveAddr() which is only called for sampled events. AF_UNIX // will produce an empty remoteAddress field in the JFR event. - if (fd < 0) return false; - if ((size_t)fd >= (size_t)FD_TYPE_CACHE_SIZE) { - int so_type; - socklen_t solen = sizeof(so_type); - return getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen) == 0 - && so_type == SOCK_STREAM; - } - // Acquire on the gen load pairs with the release on the gen-bump in start() - // and on the cache cell store below; without it, on a weakly-ordered arch - // (aarch64) a thread could observe a freshly written cell without the matching - // gen bump (or vice versa), defeating the generation-tag invalidation contract. - uint8_t gen = _fd_cache_gen.load(std::memory_order_acquire); - uint8_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); - // High nibble encodes generation; entry is valid only when it matches current gen mod 16. - if ((cached >> 4) == (gen & 0xF)) { - uint8_t type = cached & 0xF; - // A cached NON_SOCKET verdict is safe to trust: the worst case is that a - // newly-socketed fd reuse under-samples until the next gen reset, which is - // the documented accepted staleness tradeoff. - if (type == FD_TYPE_NON_SOCKET) return false; - // Cached SOCKET: trust the verdict on the hot path; revalidation is deferred - // to recordEvent() on sampled write/read events (see revalidateSocket()). - if (type == FD_TYPE_SOCKET) return true; - } - - int so_type; - socklen_t solen = sizeof(so_type); - int rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); - if (rc == 0) { - bool tcp = (so_type == SOCK_STREAM); - uint8_t type = tcp ? FD_TYPE_SOCKET : FD_TYPE_NON_SOCKET; - _fd_type_cache[fd].store((uint8_t)(((gen & 0xF) << 4) | type), - std::memory_order_release); - return tcp; - } - // Only cache the non-socket verdict when getsockopt definitively says - // "not a socket" (ENOTSOCK). Transient errors (EBADF on a racing close, - // EINTR, etc.) must NOT poison the cache: a sticky misclassification - // would survive fd reuse via dup2() and silently suppress sampling for - // the rest of the session. - if (errno == ENOTSOCK) { - _fd_type_cache[fd].store((uint8_t)(((gen & 0xF) << 4) | FD_TYPE_NON_SOCKET), - std::memory_order_release); - } - return false; + return _fd_classifier.isStreamSocket(fd); } void NativeSocketSampler::insertFdAddrLocked(int fd, std::string addr) { @@ -146,18 +131,24 @@ void NativeSocketSampler::insertFdAddrLocked(int fd, std::string addr) { } } +void NativeSocketSampler::clearFdCacheEntry(int fd) { + _fd_classifier.clearFdType(fd); + + std::lock_guard lock(_fd_cache_mutex); + auto it = _fd_cache.find(fd); + if (it != _fd_cache.end()) { + _fd_lru_list.erase(it->second); + _fd_cache.erase(it); + } +} + bool NativeSocketSampler::revalidateSocket(int fd) { int so_type; socklen_t solen = sizeof(so_type); int rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); if (rc == 0 && so_type == SOCK_STREAM) return true; // fd was reused for a non-socket or is already closed; update the type cache. - if (fd >= 0 && (size_t)fd < (size_t)FD_TYPE_CACHE_SIZE) { - uint8_t gen = _fd_cache_gen.load(std::memory_order_acquire); - _fd_type_cache[fd].store( - (uint8_t)(((gen & 0xF) << 4) | FD_TYPE_NON_SOCKET), - std::memory_order_release); - } + _fd_classifier.cacheNonSocket(fd); return false; } @@ -369,12 +360,9 @@ Error NativeSocketSampler::start(Arguments &args) { // (which carry no latency signal) are suppressed when the interval is large. _rate_limiter.start(init_interval, TARGET_EVENTS_PER_SECOND, PID_WINDOW_SECS, PID_P_GAIN, PID_I_GAIN, PID_D_GAIN, PID_CUTOFF_S); - // Clear the fd->addr cache and reset the fd-type cache generation for the new - // session so stale entries from a prior run cannot produce misattributed events - // even if stop() was not called. clearFdCache() bumps _fd_cache_gen under the - // mutex so the clear and the gen bump are atomic with respect to concurrent - // isSocket() calls. A single call per start() keeps the mod-16 generation-wrap - // budget at the full 16 cycles documented in nativeSocketSampler.h. + // Clear the fd->addr cache and reset the fd-type classifier generation for + // the new session so stale entries from a prior run cannot produce + // misattributed events even if stop() was not called. clearFdCache(); #ifdef DEBUG _send_hook_calls.store(0, std::memory_order_relaxed); @@ -386,7 +374,9 @@ Error NativeSocketSampler::start(Arguments &args) { TEST_LOG("NativeSocketSampler::start interval_ticks=%ld tsc_freq=%llu", init_interval, (unsigned long long)TSC::frequency()); #endif + _active.store(true, std::memory_order_release); if (!LibraryPatcher::patch_socket_functions()) { + _active.store(false, std::memory_order_release); return Error("failed to install native socket hooks (dlsym returned NULL)"); } return Error::OK; @@ -402,18 +392,21 @@ void NativeSocketSampler::stop() { (unsigned long long)_record_accept_calls.load(std::memory_order_relaxed), (unsigned long long)_record_reject_calls.load(std::memory_order_relaxed)); #endif - LibraryPatcher::unpatch_socket_functions(); + _active.store(false, std::memory_order_release); + LibraryPatcher::unpatch_socket_functions_if_inactive(); clearFdCache(); } +void NativeSocketSampler::disableAfterPatchFailure() { + _active.store(false, std::memory_order_release); + _instance->clearFdCache(); +} + void NativeSocketSampler::clearFdCache() { std::lock_guard lock(_fd_cache_mutex); _fd_cache.clear(); _fd_lru_list.clear(); - // Bump the generation under the lock so the clear and the bump are atomic - // with respect to concurrent isSocket() calls: no thread can insert an - // entry tagged with the old generation after the map is cleared. - _fd_cache_gen.fetch_add(1, std::memory_order_release); + _fd_classifier.clearFdTypeCache(); } #else // !__linux__ diff --git a/ddprof-lib/src/main/cpp/nativeSocketSampler.h b/ddprof-lib/src/main/cpp/nativeSocketSampler.h index e45bf60113..d18bebc88f 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.h +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.h @@ -13,6 +13,7 @@ #if defined(__linux__) +#include "nativeFdClassifier.h" #include "poissonSampler.h" #include "rateLimiter.h" #include @@ -34,17 +35,12 @@ class LibraryPatcher; // released for resolveAddr(); a concurrent thread may // emplace the same fd before re-acquisition. emplace() // is idempotent in that case (first writer wins). -// Address staleness on fd reuse is accepted: worst case -// is one misattributed event per reuse. -// _fd_type_cache : std::atomic array, lock-free. Entry encoding: -// bits [7:4] = generation mod 16, bits [3:0] = type -// (0=unknown, 1=TCP socket, 2=non-TCP). Valid only when -// high nibble matches _fd_cache_gen mod 16. A cached SOCKET -// verdict is trusted on the hot path; revalidation via -// getsockopt() is deferred to recordEvent() for sampled -// write/read events (revalidateSocket()). A cached NON_SOCKET -// verdict is trusted (worst case: a reused fd under-samples -// until the next gen reset). +// Address staleness is possible only after fd reuse +// through unobserved lifecycle paths. +// _fd_classifier : lock-free fd-type classifier shared as code with the +// native I/O interposer. A cached stream-socket verdict +// is trusted on the hot path; sampled write/read events +// revalidate before recording (revalidateSocket()). // _rate_limiter : RateLimiter — owns std::atomic interval, epoch, and // event count. PID update races are resolved by CAS // inside RateLimiter::maybeUpdateInterval(). @@ -71,17 +67,23 @@ class NativeSocketSampler : public Engine { Error check(Arguments &args) override; Error start(Arguments &args) override; void stop() override; + static void disableAfterPatchFailure(); + static bool active() { return _active.load(std::memory_order_acquire); } // Clears the fd-to-address cache and resets the fd-type cache. // Called from both start() (to reset state on restart) and stop(). // Intentionally NOT called on JFR chunk boundaries. void clearFdCache(); + void clearFdCacheEntry(int fd); // PLT hooks installed by LibraryPatcher::patch_socket_functions(). static ssize_t send_hook(int fd, const void* buf, size_t len, int flags); static ssize_t recv_hook(int fd, void* buf, size_t len, int flags); static ssize_t write_hook(int fd, const void* buf, size_t len); static ssize_t read_hook(int fd, void* buf, size_t len); + static ssize_t recordHookResult(int fd, ssize_t ret, u64 t0, u64 t1, u8 op) { + return recordResultForHook(fd, ret, t0, t1, op); + } // Called once by LibraryPatcher::patch_socket_functions() to install the // real libc function pointers before any PLT entries are patched. @@ -100,19 +102,30 @@ class NativeSocketSampler : public Engine { rd = _orig_read.load(std::memory_order_acquire); } +#ifdef UNIT_TEST + static bool setActiveForTest(bool active) { + return _active.exchange(active, std::memory_order_acq_rel); + } + using HookObserver = void (*)(const char* phase, int fd, u8 op, ssize_t ret); + static void setHookObserverForTest(HookObserver observer); + // Compatibility wrappers for sampler tests; probe override/counting is owned + // by NativeFdClassifier now that sampler delegates fd classification to it. + static uint64_t socketProbeCountForTest(); + static void resetSocketProbeCountForTest(); + using ProbeOverride = int (*)(int fd, int *so_type, int *probe_errno); + static void setProbeOverrideForTest(ProbeOverride probe); +#endif + private: static NativeSocketSampler* const _instance; - // Set by setOriginalFunctions() (called under _lock, before PLT patching) and - // read by the hooks on arbitrary application threads. Declared std::atomic with - // release/acquire pairing so a stop()→start() restart cycle, which rewrites these - // pointers while a stale-epoch hook may still be in flight, has no data race and no - // value tearing on any memory model. The acquire load in each hook also pairs with - // the release store here to publish the pointer before the hook observes it. + // Production publishes these once before PLT patching. Atomic access pairs + // that publication with hook reads and also keeps test overrides data-race-free. static std::atomic _orig_send; static std::atomic _orig_recv; static std::atomic _orig_write; static std::atomic _orig_read; + static std::atomic _active; // Target aggregate event rate: ~83 events/s (~5000/min) across all four hooks // (send/write and recv/read) combined. @@ -147,30 +160,7 @@ class NativeSocketSampler : public Engine { std::unordered_map _fd_cache; std::mutex _fd_cache_mutex; - // fd-type cache for write/read hooks. Lock-free: one atomic byte per fd number. - // Encoding: bits [7:4] = generation mod 16, bits [3:0] = type (0=unknown/invalid - // — implicit zero in fresh array, never written explicitly; 1=TCP socket; - // 2=non-TCP). An entry is valid only when its high nibble equals _fd_cache_gen - // mod 16. Incrementing _fd_cache_gen invalidates all entries in O(1) without - // touching the 65536-entry array. - // - // KNOWN LIMITATION (mod-16 generation wrap): _fd_cache_gen is only consulted via - // its low 4 bits. After 16 start() cycles the generation wraps and stale entries - // from a previous incarnation become indistinguishable from current ones until each - // fd is naturally re-probed. Profiler restarts are not exercised in production - // (only in tests), so the wrap is benign in practice. If restart-in-prod ever - // becomes a supported mode, widen _fd_cache_gen to uint32_t and store the full - // generation in a wider per-fd cell. - // Fds outside [0, FD_TYPE_CACHE_SIZE) are probed on every call. - static const int FD_TYPE_CACHE_SIZE = 65536; - // FD_TYPE_UNKNOWN is the implicit value-zero sentinel for never-written entries - // and gen-mismatch entries; it is decoded by the (cached >> 4) != gen path in - // isSocket(), not by an explicit comparison against this constant. - static const uint8_t FD_TYPE_UNKNOWN = 0; - static const uint8_t FD_TYPE_SOCKET = 1; - static const uint8_t FD_TYPE_NON_SOCKET = 2; - std::atomic _fd_cache_gen{0}; // incremented on each cache reset - std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; + NativeFdClassifier _fd_classifier; NativeSocketSampler() = default; @@ -193,17 +183,28 @@ class NativeSocketSampler : public Engine { std::lock_guard lock(_fd_cache_mutex); return (int)_fd_cache.size(); } + bool fdAddrCacheContainsForTest(int fd) { + std::lock_guard lock(_fd_cache_mutex); + return _fd_cache.find(fd) != _fd_cache.end(); + } void fdAddrCacheInsertForTest(int fd, const std::string& addr) { std::lock_guard lock(_fd_cache_mutex); insertFdAddrLocked(fd, addr); } + bool isSocketForTest(int fd) { + return isSocket(fd); + } +#ifdef UNIT_TEST + bool revalidateSocketForTest(int fd) { + return revalidateSocket(fd); + } +#endif private: // Returns true if fd is a SOCK_STREAM socket (including AF_UNIX). - // Uses the fd-type cache; calls getsockopt on first encounter per fd and on - // every cached-SOCKET hit to revalidate against fd reuse (a closed socket fd - // reassigned to a regular file/pipe must not keep emitting socket events). + // Uses the fd classifier; calls getsockopt on first encounter per fd. + // Cached SOCKET verdicts are revalidated only on sampled write/read events. bool isSocket(int fd); // Decide whether to sample and compute weight. @@ -223,6 +224,17 @@ class NativeSocketSampler : public Engine { if (ret > 0) _instance->recordEvent(fd, t0, t1, ret, op); return ret; } + + static inline ssize_t recordResultForHook(int fd, ssize_t ret, u64 t0, u64 t1, u8 op) { +#ifdef UNIT_TEST + observeHookPhaseForTest("record", fd, op, ret); +#endif + return record_if_positive(fd, ret, t0, t1, op); + } + +#ifdef UNIT_TEST + static void observeHookPhaseForTest(const char* phase, int fd, u8 op, ssize_t ret); +#endif }; #else // !__linux__ @@ -233,7 +245,9 @@ class NativeSocketSampler : public Engine { Error check(Arguments &args) override { return Error::OK; } Error start(Arguments &args) override { return Error::OK; } void stop() override {} + static void disableAfterPatchFailure() {} void clearFdCache() {} + void clearFdCacheEntry(int fd) { (void)fd; } private: static NativeSocketSampler* const _instance; NativeSocketSampler() {} diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index d8a6e2b3c5..39a1e6dc66 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -8,6 +8,7 @@ #include "profiler.h" #include "asyncSampleMutex.h" #include "mallocTracer.h" +#include "nativeSocketInterposer.h" #include "nativeSocketSampler.h" #include "context.h" #include "guards.h" @@ -1705,6 +1706,12 @@ Error Profiler::start(Arguments &args, bool reset) { _task_block_monitor_events_enabled = taskBlockEnabled() && VM::nativeMonitorEventsAvailable() && VM::setNativeMonitorEventsEnabled(true); + if (taskBlockEnabled()) { + Error native_io_error = NativeSocketInterposer::instance()->start(); + if (native_io_error) { + Log::warn("%s", native_io_error.message()); + } + } _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1766,6 +1773,7 @@ Error Profiler::stop() { // it can see _socket_active=true, wait for the lock, then re-patch PLT slots // that unpatch just restored. Stopping the refresher here closes that window. _libs->stopRefresher(); + NativeSocketInterposer::instance()->stop(); if (_event_mask & EM_NATIVESOCKET) NativeSocketSampler::instance()->stop(); if (_event_mask & EM_WALL) diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 72f12a0573..eab90d23d7 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -441,6 +441,9 @@ class alignas(alignof(SpinLock)) Profiler { int tid, jthread thread, int start_depth, TaskBlockEvent *event); static void setTaskBlockRecordOverrideForTest( TaskBlockRecordOverride override); + bool setTaskBlockEnabledForTest(bool enabled) { + return _task_block_enabled.exchange(enabled, std::memory_order_acq_rel); + } #endif bool tryEnterTaskBlockActivity(); void leaveTaskBlockActivity(); diff --git a/ddprof-lib/src/main/cpp/symbols.h b/ddprof-lib/src/main/cpp/symbols.h index b315d51ef5..81a4fb82e1 100644 --- a/ddprof-lib/src/main/cpp/symbols.h +++ b/ddprof-lib/src/main/cpp/symbols.h @@ -1,5 +1,6 @@ /* * Copyright The async-profiler authors + * Copyright 2026, Datadog, Inc. * SPDX-License-Identifier: Apache-2.0 */ @@ -41,9 +42,13 @@ class UnloadProtection { UnloadProtection(const CodeCache *cc); ~UnloadProtection(); + UnloadProtection(const UnloadProtection& other) = delete; UnloadProtection& operator=(const UnloadProtection& other) = delete; + UnloadProtection(UnloadProtection&& other) noexcept; + UnloadProtection& operator=(UnloadProtection&& other) noexcept; bool isValid() const { return _valid; } + void* release(); }; #endif // _SYMBOLS_H diff --git a/ddprof-lib/src/main/cpp/symbols_linux.cpp b/ddprof-lib/src/main/cpp/symbols_linux.cpp index b328fcfd56..9df054c06b 100644 --- a/ddprof-lib/src/main/cpp/symbols_linux.cpp +++ b/ddprof-lib/src/main/cpp/symbols_linux.cpp @@ -1,5 +1,6 @@ /* * Copyright The async-profiler authors + * Copyright 2026, Datadog, Inc. * SPDX-License-Identifier: Apache-2.0 */ @@ -948,22 +949,26 @@ void ElfParser::loadSymbolTable(const char* symbols, size_t total_size, size_t e // to a small pointer that still compares <= end, walking off the image. The // `ent_size <= total_size - off` form keeps off <= total_size with no overflow. for (size_t off = 0; ent_size <= total_size - off; off += ent_size) { - ElfSymbol* sym = (ElfSymbol*)(symbols + off); - if (sym->st_name != 0 && sym->st_value != 0) { + // Section contents are byte-addressed and a malformed sh_offset need not + // satisfy ElfSymbol alignment. Copying also keeps every field read within + // the sizeof(ElfSymbol) range validated by the loop condition. + ElfSymbol sym; + memcpy(&sym, symbols + off, sizeof(sym)); + if (sym.st_name != 0 && sym.st_value != 0) { // Resolve the name through the bounded string table; a bad st_name // offset (or unterminated string) drops the symbol instead of reading // out of bounds. - const char* sym_name = strAt(strings, strings_size, sym->st_name); + const char* sym_name = strAt(strings, strings_size, sym.st_name); if (sym_name == NULL) { continue; } // Skip special AArch64 mapping symbols: $x and $d - if (sym->st_size != 0 || sym->st_info != 0 || sym_name[0] != '$') { + if (sym.st_size != 0 || sym.st_info != 0 || sym_name[0] != '$') { const char* addr; if (base != NULL) { - // Check for overflow when adding sym->st_value to base + // Check for overflow when adding sym.st_value to base uintptr_t base_addr = (uintptr_t)base; - uint64_t symbol_value = sym->st_value; + uint64_t symbol_value = sym.st_value; // Skip this symbol if addition would overflow // First check if symbol_value exceeds the address space @@ -978,9 +983,9 @@ void ElfParser::loadSymbolTable(const char* symbols, size_t total_size, size_t e // Perform addition using integer arithmetic to avoid pointer overflow addr = (const char*)(base_addr + (uintptr_t)symbol_value); } else { - addr = (const char*)sym->st_value; + addr = (const char*)sym.st_value; } - _cc->add(addr, (int)sym->st_size, sym_name); + _cc->add(addr, (int)sym.st_size, sym_name); } } } @@ -1258,6 +1263,32 @@ UnloadProtection::~UnloadProtection() { } } +UnloadProtection::UnloadProtection(UnloadProtection&& other) noexcept + : _lib_handle(other._lib_handle), _valid(other._valid) { + other._lib_handle = NULL; + other._valid = false; +} + +UnloadProtection& UnloadProtection::operator=(UnloadProtection&& other) noexcept { + if (this != &other) { + if (_lib_handle != NULL) { + dlclose(_lib_handle); + } + _lib_handle = other._lib_handle; + _valid = other._valid; + other._lib_handle = NULL; + other._valid = false; + } + return *this; +} + +void* UnloadProtection::release() { + void* handle = _lib_handle; + _lib_handle = NULL; + _valid = false; + return handle; +} + void Symbols::initLibraryRanges() { init_lib_ranges_once(); } diff --git a/ddprof-lib/src/main/cpp/symbols_macos.cpp b/ddprof-lib/src/main/cpp/symbols_macos.cpp index e09c0f1490..55353ccc20 100644 --- a/ddprof-lib/src/main/cpp/symbols_macos.cpp +++ b/ddprof-lib/src/main/cpp/symbols_macos.cpp @@ -29,6 +29,32 @@ UnloadProtection::~UnloadProtection() { } } +UnloadProtection::UnloadProtection(UnloadProtection&& other) noexcept + : _lib_handle(other._lib_handle), _valid(other._valid) { + other._lib_handle = NULL; + other._valid = false; +} + +UnloadProtection& UnloadProtection::operator=(UnloadProtection&& other) noexcept { + if (this != &other) { + if (_lib_handle != NULL) { + dlclose(_lib_handle); + } + _lib_handle = other._lib_handle; + _valid = other._valid; + other._lib_handle = NULL; + other._valid = false; + } + return *this; +} + +void* UnloadProtection::release() { + void* handle = _lib_handle; + _lib_handle = NULL; + _valid = false; + return handle; +} + class MachOParser { private: CodeCache* _cc; diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index b0b8581e47..232628067a 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -623,7 +623,8 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( bool suppressible_state = state == OSThreadState::SLEEPING || state == OSThreadState::CONDVAR_WAIT || state == OSThreadState::OBJECT_WAIT || - state == OSThreadState::MONITOR_WAIT; + state == OSThreadState::MONITOR_WAIT || + state == OSThreadState::IO_WAIT; if (!suppressible_state) return false; RecordingEpoch epoch = recordingEpoch(); diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 3fee2e5cc4..726d478ee6 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -7,6 +7,7 @@ #define THREAD_LOCAL_DATA_H #include "context.h" +#include "context_api.h" #include "otel_context.h" #include "os.h" #include "threadLocal.h" @@ -351,6 +352,25 @@ class ProfiledThread : public ThreadLocalData { _otel_local_root_span_id = 0; } +#ifdef UNIT_TEST + void setContextForTest(u64 span_id, u64 root_span_id) { + ContextApi::initializeContextTLS(this); + for (int i = 7; i >= 0; i--) { + _otel_ctx_record.span_id[i] = static_cast(span_id & 0xff); + span_id >>= 8; + } + _otel_local_root_span_id = root_span_id; + __atomic_store_n(&_otel_ctx_record.valid, 1, __ATOMIC_RELEASE); + } + + void clearContextForTest() { + if (_otel_ctx_initialized) { + __atomic_store_n(&_otel_ctx_record.valid, 0, __ATOMIC_RELEASE); + } + clearOtelSidecar(); + } +#endif + inline bool parkEnter(u64 start_ticks, const Context& context) { u32 flags = __atomic_load_n(&_misc_flags, __ATOMIC_ACQUIRE); while ((flags & FLAG_PARKED) == 0) { diff --git a/ddprof-lib/src/main/cpp/threadState.h b/ddprof-lib/src/main/cpp/threadState.h index 786c96fb6f..0fc0141dc0 100644 --- a/ddprof-lib/src/main/cpp/threadState.h +++ b/ddprof-lib/src/main/cpp/threadState.h @@ -16,8 +16,9 @@ enum class OSThreadState : int { BREAKPOINTED = 6, // Suspended at breakpoint SLEEPING = 7, // Thread.sleep() TERMINATED = 8, // All done, but not reclaimed yet - SYSCALL = 9 // does not originate in the JVM, used when the current frame is - // known to be a syscall + SYSCALL = 9, // does not originate in the JVM, used when the current frame is + // known to be a syscall + IO_WAIT = 10 // Physical platform/carrier thread blocked in native I/O }; enum class ExecutionMode : int { diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index fb0b444955..3a8737aec4 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -33,7 +33,8 @@ static inline bool isPrecheckSuppressionState(OSThreadState state) { return state == OSThreadState::SLEEPING || state == OSThreadState::CONDVAR_WAIT || state == OSThreadState::OBJECT_WAIT || - state == OSThreadState::MONITOR_WAIT; + state == OSThreadState::MONITOR_WAIT || + state == OSThreadState::IO_WAIT; } static inline u64 loadSpanId(OtelThreadContextRecord* record) { diff --git a/ddprof-lib/src/test/cpp/elfparser_ut.cpp b/ddprof-lib/src/test/cpp/elfparser_ut.cpp index c0e7cf5842..4753ffe1e3 100644 --- a/ddprof-lib/src/test/cpp/elfparser_ut.cpp +++ b/ddprof-lib/src/test/cpp/elfparser_ut.cpp @@ -1,9 +1,15 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + #ifdef __linux__ #include #include #include "codeCache.h" +#include "common.h" #include "libraries.h" #include "symbols.h" #include "symbols_linux.h" @@ -118,6 +124,22 @@ TEST_F(ElfReladyn, resolveFromRela_dyn_R_ABS64) { ASSERT_THAT(sym, ::testing::NotNull()); } +TEST_F(ElfReladyn, resolvesEveryLocationForTheSameImport) { + ASSERT_EQ(3u, libreladyn()->importCount(im_read)); + + void** first = libreladyn()->findImport(im_read, 0); + void** second = libreladyn()->findImport(im_read, 1); + void** third = libreladyn()->findImport(im_read, 2); + + ASSERT_THAT(first, ::testing::NotNull()); + ASSERT_THAT(second, ::testing::NotNull()); + ASSERT_THAT(third, ::testing::NotNull()); + EXPECT_NE(first, second); + EXPECT_NE(first, third); + EXPECT_NE(second, third); + EXPECT_THAT(libreladyn()->findImport(im_read, 3), ::testing::IsNull()); +} + class ElfTest : public ::testing::Test { protected: void SetUp() override { @@ -319,6 +341,12 @@ INSTANTIATE_TEST_SUITE_P( #else TEST_P(ElfTestParam, invalidElfSmallMappingAfterUnmap) { +#if defined(TSAN_ENABLED) + // This stress test deliberately overlaps dlclose's writes to the DSO mapping + // with ELF parser reads. That intentional test mechanism is a data race, so + // TSan must report it even when the mapping remains valid for the read. + GTEST_SKIP() << "concurrent dlclose stress is incompatible with TSan"; +#endif char cwd[PATH_MAX - 64]; if (getcwd(cwd, sizeof(cwd)) == nullptr) { exit(1); diff --git a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp new file mode 100644 index 0000000000..d175a4b212 --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp @@ -0,0 +1,338 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#if defined(__linux__) + +#include "context_api.h" +#include "counters.h" +#include "nativeBlock.h" +#include "profiler.h" +#include "threadLocalData.h" +#include "tsc.h" + +#include +#include +#include + +namespace { + +std::atomic g_record_calls{0}; +int g_record_tid = -1; +jthread g_record_thread = reinterpret_cast(1); +int g_record_start_depth = -1; +TaskBlockEvent g_record_event{}; + +Profiler::TaskBlockRecordResult recordTaskBlockSuccessForTest( + int tid, jthread thread, int start_depth, TaskBlockEvent* event) { + g_record_tid = tid; + g_record_thread = thread; + g_record_start_depth = start_depth; + g_record_event = *event; + g_record_calls.fetch_add(1, std::memory_order_relaxed); + return Profiler::TaskBlockRecordResult::RECORDED; +} + +class ScopedTaskBlockEnabled { +public: + explicit ScopedTaskBlockEnabled(bool enabled) + : _saved(Profiler::instance()->setTaskBlockEnabledForTest(enabled)) {} + ~ScopedTaskBlockEnabled() { + Profiler::instance()->setTaskBlockEnabledForTest(_saved); + } + +private: + bool _saved; +}; + +class CurrentThreadScope { +public: + CurrentThreadScope() { + ProfiledThread::initCurrentThread(); + _thread = ProfiledThread::current(); + _thread->clearContextForTest(); + _thread->setFilterSlotId(-1); + _thread->setJavaThread(false); + } + ~CurrentThreadScope() { + if (_thread != nullptr) { + _thread->clearContextForTest(); + } + ProfiledThread::release(); + } + + ProfiledThread* thread() const { return _thread; } + + void releaseOwnership() { _thread = nullptr; } + +private: + ProfiledThread* _thread; +}; + +class DetachedCurrentThread { +public: + explicit DetachedCurrentThread(CurrentThreadScope& current) + : _thread(ProfiledThread::clearCurrentThreadTLS()) { + current.releaseOwnership(); + } + ~DetachedCurrentThread() { + if (_thread != nullptr) { + ProfiledThread::deleteForTest(_thread); + } + } + +private: + ProfiledThread* _thread; +}; + +class NativeBlockScopeTest : public ::testing::Test { +protected: + void SetUp() override { + Counters::reset(); + Profiler::setTaskBlockRecordOverrideForTest(recordTaskBlockSuccessForTest); + g_record_calls = 0; + g_record_tid = -1; + g_record_thread = reinterpret_cast(1); + g_record_start_depth = -1; + g_record_event = {}; + Profiler::instance()->threadFilter()->init("enabled"); + Profiler::instance()->threadFilter()->clearActive(); + } + + void TearDown() override { + if (ProfiledThread::currentSignalSafe() != nullptr) { + ProfiledThread::release(); + } + Profiler::setTaskBlockRecordOverrideForTest(nullptr); + Profiler::instance()->setTaskBlockEnabledForTest(false); + Profiler::instance()->threadFilter()->clearActive(); + Counters::reset(); + } + + int registerCurrentJavaThread(ProfiledThread* thread) { + ThreadFilter* filter = Profiler::instance()->threadFilter(); + int slot_id = filter->registerThread(); + EXPECT_GE(slot_id, 0); + thread->setJavaThread(true); + thread->setFilterSlotId(slot_id); + filter->add(thread->tid(), slot_id); + return slot_id; + } +}; + +u64 eligibleEndTicks(u64 start_ticks) { + return start_ticks + (TSC::frequency() / 1000) + 1; +} + +} // namespace + +TEST_F(NativeBlockScopeTest, DisabledTaskBlockGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(false); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, NullCurrentThreadGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + DetachedCurrentThread detached(current); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, NonJavaThreadGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + current.thread()->setJavaThread(false); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, MissingSlotGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + current.thread()->setJavaThread(true); + current.thread()->setFilterSlotId(-1); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, AllThreadRegistryWorksWithoutLegacyContextFilter) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + filter->init(nullptr, true); + int slot_id = filter->registerThread(current.thread()->tid()); + ASSERT_GE(slot_id, 0); + current.thread()->setJavaThread(true); + current.thread()->setFilterSlotId(slot_id); + ASSERT_FALSE(filter->enabled()); + ASSERT_TRUE(filter->registryActive()); + + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_TRUE(scope.active()); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); +} + +TEST_F(NativeBlockScopeTest, TraceContextGateLeavesScopeInactiveAndSlotUnowned) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + current.thread()->setContextForTest(0x1234, 0x5678); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); + ThreadFilter::Slot* slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, EnterBlockedRunFailureLeavesExistingOwnerIntact) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT, + BlockRunOwner::JVMTI); + ASSERT_NE(0ULL, token); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::JVMTI, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot->activeBlockState()); + EXPECT_TRUE(filter->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token))); +} + +TEST_F(NativeBlockScopeTest, ActiveScopeExitsSlotAndRecordsSynchronousIoWaitEvent) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + ThreadFilter::Slot* active_slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, active_slot); + EXPECT_EQ(E2BIG, errno); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + + ThreadFilter::Slot* slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(1, g_record_calls.load(std::memory_order_relaxed)); + EXPECT_EQ(current.thread()->tid(), g_record_tid); + EXPECT_EQ(nullptr, g_record_thread); + EXPECT_EQ(0, g_record_start_depth); + EXPECT_EQ(OSThreadState::IO_WAIT, g_record_event._observedBlockingState); + EXPECT_EQ(NativeBlockScope::blocker(NativeBlockKind::STREAM_SOCKET, 17), + g_record_event._blocker); +} + +TEST_F(NativeBlockScopeTest, FinishAfterTaskBlockDisableExitsWithoutRecording) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + + NativeBlockScope scope(NativeBlockKind::CONNECT, 19, OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + Profiler::instance()->setTaskBlockEnabledForTest(false); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + + EXPECT_EQ(0, g_record_calls.load(std::memory_order_relaxed)); + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, FinishSkipsAndLeavesSlotOwnedWhenFilterDisabledBeforeExit) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + + { + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + filter->init(nullptr); + } + + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NATIVE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::IO_WAIT, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, ConcurrentScopeLifecyclePreservesSlotOwnership) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + std::atomic stop{false}; + std::atomic failures{0}; + + std::thread observer([&]() { + while (!stop.load(std::memory_order_acquire)) { + BlockRunSnapshot snapshot = filter->snapshotBlockedRun(slot_id); + if (snapshot.active && snapshot.owner != BlockRunOwner::NATIVE) { + failures.fetch_add(1, std::memory_order_relaxed); + } + std::this_thread::yield(); + } + }); + + for (int i = 0; i < 1000; i++) { + { + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + } + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + ASSERT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + } + + stop.store(true, std::memory_order_release); + observer.join(); + EXPECT_EQ(0, failures.load(std::memory_order_relaxed)); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp new file mode 100644 index 0000000000..35a17745e6 --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp @@ -0,0 +1,1742 @@ +/* + * 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. + */ + +#include + +#if defined(__linux__) + +#include "libraries.h" +#include "libraryPatcher.h" +#include "nativeBlock.h" +#include "nativeFdClassifier.h" +#include "nativeSocketInterposer.h" +#include "nativeSocketSampler.h" +#include "os.h" +#include "profiler.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +static const int kFdTypeCacheSizeForTest = 65536; +static const int kHighFdCacheSizeForTest = 4096; + +std::atomic g_send_calls{0}; +std::atomic g_sampler_send_calls{0}; +std::atomic g_recv_calls{0}; +std::atomic g_write_calls{0}; +std::atomic g_sampler_write_calls{0}; +std::atomic g_read_calls{0}; +std::atomic g_close_calls{0}; +std::atomic g_connect_calls{0}; +std::atomic g_accept_calls{0}; +std::atomic g_accept4_calls{0}; +std::atomic g_recvfrom_calls{0}; +std::atomic g_recvmsg_calls{0}; +std::atomic g_epoll_wait_calls{0}; +std::atomic g_epoll_pwait_calls{0}; +std::atomic g_poll_calls{0}; +std::atomic g_ppoll_calls{0}; +std::atomic g_select_calls{0}; +std::atomic g_pselect_calls{0}; +std::atomic g_fd_probe_calls{0}; +std::atomic g_fd_probe_rc{0}; +std::atomic g_fd_probe_errno{0}; +std::atomic g_fd_probe_so_type{0}; +std::atomic g_fd_probe_last_fd{0}; +std::atomic g_sequence{0}; +std::atomic g_raw_syscall_sequence{0}; +std::atomic g_taskblock_enter_sequence{0}; +std::atomic g_taskblock_exit_sequence{0}; +std::atomic g_sampler_record_sequence{0}; +std::atomic g_send_ret{0}; +std::atomic g_sampler_send_ret{0}; +std::atomic g_recv_ret{0}; +std::atomic g_write_ret{0}; +std::atomic g_sampler_write_ret{0}; +std::atomic g_read_ret{0}; +std::atomic g_close_ret{0}; +std::atomic g_close_errno{0}; +std::atomic g_connect_ret{0}; +std::atomic g_accept_ret{0}; +std::atomic g_accept4_ret{0}; +std::atomic g_recvfrom_ret{0}; +std::atomic g_recvmsg_ret{0}; +std::atomic g_epoll_wait_ret{0}; +std::atomic g_epoll_pwait_ret{0}; +std::atomic g_poll_ret{0}; +std::atomic g_ppoll_ret{0}; +std::atomic g_select_ret{0}; +std::atomic g_pselect_ret{0}; + +ssize_t stub_send(int, const void*, size_t, int) { + g_send_calls++; + return g_send_ret.load(); +} + +ssize_t sampler_stub_send(int, const void*, size_t, int) { + g_sampler_send_calls++; + return g_sampler_send_ret.load(); +} + +int stub_fd_probe(int, int *so_type, int *probe_errno) { + g_fd_probe_calls++; + *so_type = g_fd_probe_so_type.load(std::memory_order_acquire); + *probe_errno = g_fd_probe_errno.load(std::memory_order_acquire); + return g_fd_probe_rc.load(std::memory_order_acquire); +} + +int recording_fd_probe(int fd, int *so_type, int *probe_errno) { + g_fd_probe_last_fd.store(fd, std::memory_order_release); + return stub_fd_probe(fd, so_type, probe_errno); +} + +class ScopedFdProbeOverride { +public: + explicit ScopedFdProbeOverride(NativeFdClassifier::ProbeOverride probe) { + NativeFdClassifier::setProbeOverrideForTest(probe); + } + ~ScopedFdProbeOverride() { + NativeFdClassifier::setProbeOverrideForTest(nullptr); + } +}; + +class ScopedTaskBlockEnabled { +public: + explicit ScopedTaskBlockEnabled(bool enabled) + : _saved(Profiler::instance()->setTaskBlockEnabledForTest(enabled)) {} + ~ScopedTaskBlockEnabled() { + Profiler::instance()->setTaskBlockEnabledForTest(_saved); + } + +private: + bool _saved; +}; + +class ScopedNativeSocketInterposerActive { +public: + explicit ScopedNativeSocketInterposerActive(bool active) + : _saved(NativeSocketInterposer::instance()->setActiveForTest(active)) {} + ~ScopedNativeSocketInterposerActive() { + NativeSocketInterposer::instance()->setActiveForTest(_saved); + } + +private: + bool _saved; +}; + +class ScopedNativeSocketSamplerActive { +public: + explicit ScopedNativeSocketSamplerActive(bool active) + : _saved(NativeSocketSampler::setActiveForTest(active)) {} + ~ScopedNativeSocketSamplerActive() { + NativeSocketSampler::setActiveForTest(_saved); + } + +private: + bool _saved; +}; + +ssize_t stub_recv(int, void*, size_t, int) { + g_recv_calls++; + return g_recv_ret.load(); +} + +ssize_t stub_write(int, const void*, size_t) { + g_write_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_write_ret.load(); +} + +ssize_t sampler_stub_write(int, const void*, size_t) { + g_sampler_write_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_sampler_write_ret.load(); +} + +ssize_t stub_read(int, void*, size_t) { + g_read_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_read_ret.load(); +} + +void native_block_observer(const char* phase, NativeBlockKind, int) { + int sequence = g_sequence.fetch_add(1) + 1; + if (strcmp(phase, "enter") == 0) { + g_taskblock_enter_sequence.store(sequence, std::memory_order_release); + } else if (strcmp(phase, "exit") == 0) { + g_taskblock_exit_sequence.store(sequence, std::memory_order_release); + } +} + +void native_socket_sampler_observer(const char* phase, int, u8, ssize_t) { + if (strcmp(phase, "record") == 0) { + g_sampler_record_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + } +} + +int stub_close(int) { + g_close_calls++; + errno = g_close_errno.load(); + return g_close_ret.load(); +} + +int stub_connect(int, const struct sockaddr*, socklen_t) { + g_connect_calls++; + return g_connect_ret.load(); +} + +int stub_accept(int, struct sockaddr*, socklen_t*) { + g_accept_calls++; + return g_accept_ret.load(); +} + +int stub_accept4(int, struct sockaddr*, socklen_t*, int) { + g_accept4_calls++; + return g_accept4_ret.load(); +} + +ssize_t stub_recvfrom(int, void*, size_t, int, struct sockaddr*, socklen_t*) { + g_recvfrom_calls++; + return g_recvfrom_ret.load(); +} + +ssize_t stub_recvmsg(int, struct msghdr*, int) { + g_recvmsg_calls++; + return g_recvmsg_ret.load(); +} + +int stub_epoll_wait(int, struct epoll_event*, int, int) { + g_epoll_wait_calls++; + return g_epoll_wait_ret.load(); +} + +int stub_epoll_pwait(int, struct epoll_event*, int, int, const sigset_t*) { + g_epoll_pwait_calls++; + return g_epoll_pwait_ret.load(); +} + +int stub_poll(struct pollfd*, nfds_t, int) { + g_poll_calls++; + return g_poll_ret.load(); +} + +int stub_ppoll(struct pollfd*, nfds_t, const struct timespec*, const sigset_t*) { + g_ppoll_calls++; + return g_ppoll_ret.load(); +} + +int stub_select(int, fd_set*, fd_set*, fd_set*, struct timeval*) { + g_select_calls++; + return g_select_ret.load(); +} + +int stub_pselect(int, fd_set*, fd_set*, fd_set*, const struct timespec*, + const sigset_t*) { + g_pselect_calls++; + return g_pselect_ret.load(); +} + +void setOriginalFunction(NativeSocketInterposer::NativeIoHookIndex hook, void* fn) { + ASSERT_TRUE(NativeSocketInterposer::setOriginalFunction(hook, fn)); +} + +class NativeSocketInterposerHookTest : public ::testing::Test { +protected: + NativeSocketInterposer::send_fn saved_send = nullptr; + NativeSocketInterposer::recv_fn saved_recv = nullptr; + NativeSocketInterposer::write_fn saved_write = nullptr; + NativeSocketInterposer::read_fn saved_read = nullptr; + NativeSocketSampler::send_fn saved_sampler_send = nullptr; + NativeSocketSampler::recv_fn saved_sampler_recv = nullptr; + NativeSocketSampler::write_fn saved_sampler_write = nullptr; + NativeSocketSampler::read_fn saved_sampler_read = nullptr; + bool saved_active = false; + + void SetUp() override { + NativeSocketInterposer::getOriginalFunctions(saved_send, saved_recv, saved_write, + saved_read); + NativeSocketSampler::getOriginalFunctions(saved_sampler_send, saved_sampler_recv, + saved_sampler_write, saved_sampler_read); + NativeSocketInterposer::setOriginalFunctions(stub_send, stub_recv, stub_write, + stub_read); + NativeSocketSampler::setOriginalFunctions(sampler_stub_send, stub_recv, + sampler_stub_write, stub_read); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(stub_close)); + setOriginalFunction(NativeSocketInterposer::HOOK_CONNECT, + reinterpret_cast(stub_connect)); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT, + reinterpret_cast(stub_accept)); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT4, + reinterpret_cast(stub_accept4)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVFROM, + reinterpret_cast(stub_recvfrom)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVMSG, + reinterpret_cast(stub_recvmsg)); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_WAIT, + reinterpret_cast(stub_epoll_wait)); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_PWAIT, + reinterpret_cast(stub_epoll_pwait)); + setOriginalFunction(NativeSocketInterposer::HOOK_POLL, + reinterpret_cast(stub_poll)); + setOriginalFunction(NativeSocketInterposer::HOOK_PPOLL, + reinterpret_cast(stub_ppoll)); + setOriginalFunction(NativeSocketInterposer::HOOK_SELECT, + reinterpret_cast(stub_select)); + setOriginalFunction(NativeSocketInterposer::HOOK_PSELECT, + reinterpret_cast(stub_pselect)); + saved_active = LibraryPatcher::_socket_active.load(std::memory_order_acquire); + LibraryPatcher::_socket_active.store(false, std::memory_order_release); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + NativeSocketSampler::resetSocketProbeCountForTest(); + NativeBlockScope::setHookObserverForTest(nullptr); + NativeSocketSampler::setHookObserverForTest(nullptr); + g_send_calls = 0; + g_sampler_send_calls = 0; + g_recv_calls = 0; + g_write_calls = 0; + g_sampler_write_calls = 0; + g_read_calls = 0; + g_close_calls = 0; + g_connect_calls = 0; + g_accept_calls = 0; + g_accept4_calls = 0; + g_recvfrom_calls = 0; + g_recvmsg_calls = 0; + g_epoll_wait_calls = 0; + g_epoll_pwait_calls = 0; + g_poll_calls = 0; + g_ppoll_calls = 0; + g_select_calls = 0; + g_pselect_calls = 0; + g_fd_probe_calls = 0; + g_sequence = 0; + g_raw_syscall_sequence = 0; + g_taskblock_enter_sequence = 0; + g_taskblock_exit_sequence = 0; + g_sampler_record_sequence = 0; + g_send_ret = 0; + g_sampler_send_ret = 0; + g_recv_ret = 0; + g_write_ret = 0; + g_sampler_write_ret = 0; + g_read_ret = 0; + g_close_ret = 0; + g_close_errno = 0; + g_connect_ret = 0; + g_accept_ret = 0; + g_accept4_ret = 0; + g_recvfrom_ret = 0; + g_recvmsg_ret = 0; + g_epoll_wait_ret = 0; + g_epoll_pwait_ret = 0; + g_poll_ret = 0; + g_ppoll_ret = 0; + g_select_ret = 0; + g_pselect_ret = 0; + } + + void TearDown() override { + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); + NativeSocketInterposer::setOriginalFunctions(saved_send, saved_recv, saved_write, + saved_read); + NativeSocketSampler::setOriginalFunctions(saved_sampler_send, saved_sampler_recv, + saved_sampler_write, saved_sampler_read); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_CONNECT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT4, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVFROM, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVMSG, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_WAIT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_PWAIT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_POLL, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_PPOLL, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_SELECT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_PSELECT, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + NativeSocketSampler::resetSocketProbeCountForTest(); + NativeBlockScope::setHookObserverForTest(nullptr); + NativeSocketSampler::setHookObserverForTest(nullptr); + } +}; + +class NativeSocketInterposerFdTest : public ::testing::Test { +protected: + void SetUp() override { + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(::close)); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP2, + reinterpret_cast(::dup2)); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP3, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + } + + void TearDown() override { + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP2, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP3, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + } + + int closeThroughHook(int fd) { + return NativeSocketInterposer::close_hook(fd); + } + + int dup2ThroughHook(int oldfd, int newfd) { + return NativeSocketInterposer::dup2_hook(oldfd, newfd); + } + + int dup3ThroughHook(int oldfd, int newfd, int flags) { + return NativeSocketInterposer::dup3_hook(oldfd, newfd, flags); + } + + int datagramSocketAtFd(int target_fd) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0 || fd == target_fd) { + return fd; + } + + int ret = ::dup2(fd, target_fd); + int saved_errno = errno; + ::close(fd); + errno = saved_errno; + return ret; + } +}; + +class LibraryPatcherImportTest : public ::testing::Test { +protected: + void SetUp() override { + LibraryPatcher::unpatch_socket_functions(); + } + + void TearDown() override { + LibraryPatcher::unpatch_socket_functions(); + cache.reset(); + } + + void initializeImports(size_t count) { + imports[0] = reinterpret_cast(stub_read); + imports[1] = reinterpret_cast(stub_write); + imports[2] = reinterpret_cast(stub_recv); + cache = std::make_unique("import-test", -1, + NO_MIN_ADDRESS, NO_MAX_ADDRESS, + nullptr, true); + for (size_t index = 0; index < count; index++) { + cache->addImport(&imports[index], "read"); + } + } + + std::unique_ptr cache; + void* imports[3] = {}; +}; + +} // namespace + +TEST_F(NativeSocketInterposerHookTest, InactiveHookForwardsWithoutChangingErrno) { + g_send_ret = 13; + char buf[8] = {}; + + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::send_hook(0, buf, sizeof(buf), 0); + + EXPECT_EQ(13, ret); + EXPECT_EQ(1, g_send_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, + OriginalFunctionCanBePublishedWhileHooksReadIt) { + constexpr int iterations = 100000; + std::atomic start{false}; + char buffer[1] = {}; + + std::thread reader([&]() { + while (!start.load(std::memory_order_acquire)) { + } + for (int index = 0; index < iterations; index++) { + NativeSocketInterposer::send_hook(0, buffer, sizeof(buffer), 0); + } + }); + + start.store(true, std::memory_order_release); + for (int index = 0; index < iterations; index++) { + setOriginalFunction( + NativeSocketInterposer::HOOK_SEND, + reinterpret_cast(index % 2 == 0 ? stub_send : sampler_stub_send)); + } + reader.join(); + + EXPECT_EQ(iterations, g_send_calls.load() + g_sampler_send_calls.load()); + setOriginalFunction(NativeSocketInterposer::HOOK_SEND, + reinterpret_cast(stub_send)); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveNonSocketReadPreservesEntryErrno) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + g_read_ret = 7; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::read_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(7, ret); + EXPECT_EQ(1, g_read_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketWriteForwards) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_write_ret = 5; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(5, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamSendUsesSharedRawSyscall) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + g_send_ret = 11; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::send_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(11, ret); + EXPECT_EQ(1, g_send_calls.load()); + EXPECT_EQ(0, g_sampler_send_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamWriteUsesSharedRawSyscall) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + g_write_ret = 11; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(11, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(0, g_sampler_write_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + SamplerOnlyStreamWriteClassifiesThroughSamplerClassifier) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(false); + ScopedNativeSocketSamplerActive sampler_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_sampler_write_ret = 23; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(23, ret); + EXPECT_EQ(0, g_write_calls.load()); + EXPECT_EQ(1, g_sampler_write_calls.load()); + EXPECT_EQ(1, g_fd_probe_calls.load()) + << "sampler-only path must classify through its NativeFdClassifier instance"; + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamWriteClassifiesOnceAndRecordsAfterTaskBlockScope) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + NativeBlockScope::setHookObserverForTest(native_block_observer); + NativeSocketSampler::setHookObserverForTest(native_socket_sampler_observer); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_write_ret = 31; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(31, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(0, g_sampler_write_calls.load()); + EXPECT_EQ(1, g_fd_probe_calls.load()); + EXPECT_LT(0, g_taskblock_enter_sequence.load()); + EXPECT_LT(g_taskblock_enter_sequence.load(), g_raw_syscall_sequence.load()); + EXPECT_LT(g_raw_syscall_sequence.load(), g_taskblock_exit_sequence.load()); + EXPECT_LT(g_taskblock_exit_sequence.load(), g_sampler_record_sequence.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, CloseForwardsAndPreservesErrno) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + g_close_ret = 0; + g_close_errno = E2BIG; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = ERANGE; + int ret = NativeSocketInterposer::close_hook(fds[0]); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_close_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamSendOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_SEND, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::send_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_send_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamRecvOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECV, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::recv_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_recv_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamWriteOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_WRITE, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_write_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamReadOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_READ, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::read_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_read_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerFdTest, CloseFallsBackToSyscallWhenOriginalIsMissing) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + + errno = E2BIG; + int ret = closeThroughHook(fds[0]); + + EXPECT_EQ(0, ret); + EXPECT_EQ(E2BIG, errno); + errno = 0; + EXPECT_EQ(-1, close(fds[0])); + EXPECT_EQ(EBADF, errno); + close(fds[1]); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchClearsSocketActiveWhenOwnersInactive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + + EXPECT_TRUE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_FALSE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchKeepsSocketActiveWhenSamplerActive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + ScopedNativeSocketSamplerActive sampler_active(true); + + EXPECT_FALSE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_TRUE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchKeepsSocketActiveWhenInterposerActive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + ScopedNativeSocketInterposerActive interposer_active(true); + + EXPECT_FALSE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_TRUE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, + RefreshDoesNotPatchAfterSocketHooksBecomeInactive) { + LibraryPatcher::unpatch_socket_functions(); + + EXPECT_FALSE(LibraryPatcher::patch_socket_functions(true)); + EXPECT_FALSE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); + EXPECT_EQ(0, LibraryPatcher::socket_library_count_for_test()); +} + +TEST_F(LibraryPatcherImportTest, PatchesAndRestoresEveryImportLocation) { + initializeImports(3); + void* originals[3] = {imports[0], imports[1], imports[2]}; + void* hook = reinterpret_cast(NativeSocketInterposer::read_hook); + + EXPECT_EQ(3, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(3, LibraryPatcher::socket_patch_count_for_test()); + EXPECT_EQ(hook, imports[0]); + EXPECT_EQ(hook, imports[1]); + EXPECT_EQ(hook, imports[2]); + + EXPECT_EQ(0, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(3, LibraryPatcher::socket_patch_count_for_test()); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(originals[0], imports[0]); + EXPECT_EQ(originals[1], imports[1]); + EXPECT_EQ(originals[2], imports[2]); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); +} + +TEST_F(LibraryPatcherImportTest, PatchesSingleImportLocation) { + initializeImports(1); + void* original = imports[0]; + void* hook = reinterpret_cast(NativeSocketInterposer::read_hook); + + EXPECT_EQ(1, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(hook, imports[0]); + EXPECT_EQ(1, LibraryPatcher::socket_patch_count_for_test()); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(original, imports[0]); +} + +TEST_F(LibraryPatcherImportTest, MissingImportDoesNotConsumePatchSlot) { + initializeImports(1); + void* hook = reinterpret_cast(NativeSocketInterposer::send_hook); + + EXPECT_EQ(0, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_send, hook, "send")); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); +} + +TEST(LibraryPatcherDsoLifetimeTest, RetainsPatchedLibraryUntilImportsAreRestored) { + char cwd[PATH_MAX - 64]; + ASSERT_NE(nullptr, getcwd(cwd, sizeof(cwd))); + char path[PATH_MAX]; + snprintf(path, sizeof(path), + "%s/../build/test/resources/native-libs/unloadable-io-lib/" + "libunloadable-io.so", + cwd); + + void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL); + ASSERT_NE(nullptr, handle) << dlerror(); + void* symbol = dlsym(handle, "unloadable_read"); + ASSERT_NE(nullptr, symbol) << dlerror(); + + Libraries::instance()->updateSymbols(false); + CodeCache* lib = + Libraries::instance()->findLibraryByName("libunloadable-io"); + ASSERT_NE(nullptr, lib); + ASSERT_EQ(1u, lib->importCount(im_read)); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(1, LibraryPatcher::patch_socket_import_for_test( + lib, im_read, + reinterpret_cast(NativeSocketInterposer::read_hook), + "read", true)); + EXPECT_EQ(1, LibraryPatcher::socket_library_count_for_test()); + + ASSERT_EQ(0, dlclose(handle)); + Dl_info info; + EXPECT_NE(0, dladdr(symbol, &info)); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); + EXPECT_EQ(0, LibraryPatcher::socket_library_count_for_test()); + if (OS::isMusl()) { + EXPECT_NE(0, dladdr(symbol, &info)); + } else { + EXPECT_EQ(0, dladdr(symbol, &info)); + } +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketConnectForwards) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_connect_ret = 0; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::connect_hook(fds[0], nullptr, 0); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_connect_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketAcceptForwards) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_accept_ret = 17; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::accept_hook(fds[0], nullptr, nullptr); + + EXPECT_EQ(17, ret); + EXPECT_EQ(1, g_accept_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketAccept4Forwards) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_accept4_ret = 19; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::accept4_hook(fds[0], nullptr, nullptr, 0); + + EXPECT_EQ(19, ret); + EXPECT_EQ(1, g_accept4_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveDatagramRecvfromForwards) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(fd, 0); + g_recvfrom_ret = 3; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::recvfrom_hook(fd, buf, sizeof(buf), 0, + nullptr, nullptr); + + EXPECT_EQ(3, ret); + EXPECT_EQ(1, g_recvfrom_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fd); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveDatagramRecvmsgForwards) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(fd, 0); + g_recvmsg_ret = 4; + struct msghdr msg = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::recvmsg_hook(fd, &msg, 0); + + EXPECT_EQ(4, ret); + EXPECT_EQ(1, g_recvmsg_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fd); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveEpollZeroTimeoutForwards) { + g_epoll_wait_ret = 0; + struct epoll_event events[1] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::epoll_wait_hook(31, events, 1, 0); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_epoll_wait_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveEpollPwaitZeroTimeoutForwards) { + g_epoll_pwait_ret = 0; + struct epoll_event events[1] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::epoll_pwait_hook(31, events, 1, 0, nullptr); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_epoll_pwait_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActivePollZeroTimeoutForwards) { + g_poll_ret = 0; + struct pollfd fds[1] = {{0, POLLIN, 0}}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::poll_hook(fds, 1, 0); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_poll_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActivePpollZeroTimeoutForwards) { + g_ppoll_ret = 0; + struct pollfd fds[1] = {{0, POLLIN, 0}}; + struct timespec timeout = {0, 0}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::ppoll_hook(fds, 1, &timeout, nullptr); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_ppoll_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveSelectZeroTimeoutForwards) { + g_select_ret = 0; + struct timeval timeout = {0, 0}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::select_hook(1, nullptr, nullptr, nullptr, + &timeout); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_select_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActivePselectZeroTimeoutForwards) { + g_pselect_ret = 0; + struct timespec timeout = {0, 0}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + int ret = NativeSocketInterposer::pselect_hook(1, nullptr, nullptr, nullptr, + &timeout, nullptr); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_pselect_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveEpollPositiveTimeoutEligibleForwards) { + g_epoll_wait_ret = 1; + g_epoll_pwait_ret = 2; + struct epoll_event events[1] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + EXPECT_EQ(1, NativeSocketInterposer::epoll_wait_hook(31, events, 1, 1)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(2, NativeSocketInterposer::epoll_pwait_hook(31, events, 1, -1, nullptr)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(1, g_epoll_wait_calls.load()); + EXPECT_EQ(1, g_epoll_pwait_calls.load()); +} + +TEST_F(NativeSocketInterposerHookTest, ActivePollPositiveAndNullTimeoutEligibleForwards) { + g_poll_ret = 1; + g_ppoll_ret = 2; + struct pollfd fds[1] = {{0, POLLIN, 0}}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + EXPECT_EQ(1, NativeSocketInterposer::poll_hook(fds, 1, -1)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(2, NativeSocketInterposer::ppoll_hook(fds, 1, nullptr, nullptr)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(1, g_poll_calls.load()); + EXPECT_EQ(1, g_ppoll_calls.load()); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveSelectPositiveAndNullTimeoutEligibleForwards) { + g_select_ret = 1; + g_pselect_ret = 2; + struct timeval select_timeout = {1, 0}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + EXPECT_EQ(1, NativeSocketInterposer::select_hook(1, nullptr, nullptr, nullptr, + &select_timeout)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(2, NativeSocketInterposer::pselect_hook(1, nullptr, nullptr, nullptr, + nullptr, nullptr)); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(1, g_select_calls.load()); + EXPECT_EQ(1, g_pselect_calls.load()); +} + +TEST(NativeBlockScopeTest, EncodesKindAndBlockerId) { + EXPECT_EQ((static_cast(NativeBlockKind::CONNECT) << 32) | 17, + NativeBlockScope::blocker(NativeBlockKind::CONNECT, 17)); +} + +TEST(NativeBlockScopeTest, DisabledTaskBlockGateLeavesScopeInactiveAndPreservesErrno) { + ScopedTaskBlockEnabled task_block_enabled(false); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerFdTest, ClassifiesStreamSocketsOnly) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(stream_fds[0])); + ASSERT_EQ(0, closeThroughHook(stream_fds[0])); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + + int datagram_fd = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(datagram_fd, 0); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(datagram_fd)); +} + +TEST_F(NativeSocketInterposerFdTest, ClassifiesDatagramSocketsOnly) { + int datagram_fd = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_GE(datagram_fd, 0); + EXPECT_TRUE(NativeSocketInterposer::instance()->isDatagramSocket(datagram_fd)); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(datagram_fd)); + + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + EXPECT_FALSE(NativeSocketInterposer::instance()->isDatagramSocket(stream_fds[0])); + ASSERT_EQ(0, closeThroughHook(stream_fds[0])); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, TransientFdProbeFailureIsNotCached) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = -1; + g_fd_probe_errno = EIO; + g_fd_probe_so_type = 0; + int fd = 42; + + EXPECT_FALSE(classifier.isStreamSocket(fd)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + + EXPECT_TRUE(classifier.isStreamSocket(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + EXPECT_TRUE(classifier.isStreamSocket(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, NegativeFdDoesNotProbe) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(recording_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_so_type = SOCK_STREAM; + + EXPECT_FALSE(classifier.isStreamSocket(-1)); + EXPECT_FALSE(classifier.isDatagramSocket(-1)); + EXPECT_EQ(0, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, EnotsockFailureIsCachedAsNonSocket) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = -1; + g_fd_probe_errno = ENOTSOCK; + g_fd_probe_so_type = 0; + + EXPECT_FALSE(classifier.isStreamSocket(43)); + EXPECT_FALSE(classifier.isDatagramSocket(43)); + EXPECT_EQ(1, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, CacheNonSocketOverridesCachedStreamType) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + + ASSERT_TRUE(classifier.isStreamSocket(43)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + classifier.cacheNonSocket(43); + + EXPECT_FALSE(classifier.isStreamSocket(43)); + EXPECT_FALSE(classifier.isDatagramSocket(43)); + EXPECT_EQ(1, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, OtherSocketTypeIsCachedAsNeitherStreamNorDatagram) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_RAW; + + EXPECT_FALSE(classifier.isStreamSocket(44)); + EXPECT_FALSE(classifier.isDatagramSocket(44)); + EXPECT_EQ(1, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, HighFdUsesClassifierCache) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(recording_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + + EXPECT_TRUE(classifier.isStreamSocket(kFdTypeCacheSizeForTest)); + EXPECT_TRUE(classifier.isStreamSocket(kFdTypeCacheSizeForTest)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + EXPECT_EQ(kFdTypeCacheSizeForTest, g_fd_probe_last_fd.load()); +} + +TEST_F(NativeSocketInterposerFdTest, HighFdTransientProbeFailureIsNotCached) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + int fd = kFdTypeCacheSizeForTest + 1; + g_fd_probe_calls = 0; + g_fd_probe_rc = -1; + g_fd_probe_errno = EIO; + g_fd_probe_so_type = 0; + + EXPECT_FALSE(classifier.isStreamSocket(fd)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + + EXPECT_TRUE(classifier.isStreamSocket(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + EXPECT_TRUE(classifier.isStreamSocket(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, ClearFdTypeInvalidatesHighFdOnly) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + int fd = kFdTypeCacheSizeForTest + 2; + int other_fd = fd + 1; + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(classifier.isStreamSocket(fd)); + ASSERT_TRUE(classifier.isStreamSocket(other_fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + classifier.clearFdType(fd); + + EXPECT_FALSE(classifier.isStreamSocket(fd)); + EXPECT_TRUE(classifier.isDatagramSocket(fd)); + EXPECT_TRUE(classifier.isStreamSocket(other_fd)); + EXPECT_EQ(3, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, ClearFdTypeCacheInvalidatesHighFds) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + int fd = kFdTypeCacheSizeForTest + 3; + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(classifier.isStreamSocket(fd)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + classifier.clearFdTypeCache(); + + EXPECT_FALSE(classifier.isStreamSocket(fd)); + EXPECT_TRUE(classifier.isDatagramSocket(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, HighFdCacheCollisionReprobesExactFd) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(recording_fd_probe); + int stream_fd = kFdTypeCacheSizeForTest + 4; + int datagram_fd = stream_fd + kHighFdCacheSizeForTest; + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + + g_fd_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(classifier.isStreamSocket(stream_fd)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + EXPECT_FALSE(classifier.isStreamSocket(datagram_fd)); + EXPECT_TRUE(classifier.isDatagramSocket(datagram_fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + EXPECT_EQ(datagram_fd, g_fd_probe_last_fd.load()); + + g_fd_probe_so_type = SOCK_STREAM; + EXPECT_TRUE(classifier.isStreamSocket(stream_fd)); + EXPECT_EQ(3, g_fd_probe_calls.load()); + EXPECT_EQ(stream_fd, g_fd_probe_last_fd.load()); +} + +TEST_F(NativeSocketInterposerFdTest, ClearFdTypeInvalidatesOnlyThatFd) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(classifier.isStreamSocket(45)); + ASSERT_TRUE(classifier.isStreamSocket(46)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + classifier.clearFdType(45); + + EXPECT_FALSE(classifier.isStreamSocket(45)); + EXPECT_TRUE(classifier.isDatagramSocket(45)); + EXPECT_TRUE(classifier.isStreamSocket(46)); + EXPECT_EQ(3, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, ClearFdTypeCacheInvalidatesCachedFds) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(classifier.isStreamSocket(47)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + classifier.clearFdTypeCache(); + + EXPECT_FALSE(classifier.isStreamSocket(47)); + EXPECT_TRUE(classifier.isDatagramSocket(47)); + EXPECT_EQ(2, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, ClassifierInstancesHaveIndependentCacheState) { + NativeFdClassifier first; + NativeFdClassifier second; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + + ASSERT_TRUE(first.isStreamSocket(48)); + ASSERT_TRUE(second.isStreamSocket(48)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + first.cacheNonSocket(48); + + EXPECT_FALSE(first.isStreamSocket(48)); + EXPECT_TRUE(second.isStreamSocket(48)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + first.clearFdTypeCache(); + g_fd_probe_so_type = SOCK_DGRAM; + + EXPECT_FALSE(first.isStreamSocket(48)); + EXPECT_TRUE(second.isStreamSocket(48)); + EXPECT_EQ(3, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerFdTest, + SamplerAndInterposerClassifiersHaveIndependentCacheState) { + NativeSocketInterposer* interposer = NativeSocketInterposer::instance(); + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + interposer->clearFdTypeCache(); + sampler->clearFdCache(); + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + const int fd = 49; + + ASSERT_TRUE(interposer->isStreamSocket(fd)); + EXPECT_EQ(1, g_fd_probe_calls.load()); + ASSERT_TRUE(sampler->isSocketForTest(fd)); + EXPECT_EQ(2, g_fd_probe_calls.load()); + + g_fd_probe_so_type = SOCK_DGRAM; + interposer->clearFdType(fd); + + EXPECT_FALSE(interposer->isStreamSocket(fd)); + EXPECT_TRUE(interposer->isDatagramSocket(fd)); + EXPECT_EQ(3, g_fd_probe_calls.load()); + EXPECT_TRUE(sampler->isSocketForTest(fd)); + EXPECT_EQ(3, g_fd_probe_calls.load()); + + sampler->clearFdCacheEntry(fd); + + EXPECT_FALSE(sampler->isSocketForTest(fd)); + EXPECT_EQ(4, g_fd_probe_calls.load()); + interposer->clearFdTypeCache(); + sampler->clearFdCache(); +} + +TEST_F(NativeSocketInterposerFdTest, ConcurrentClassifierReadsAndClearsAreSafe) { + NativeFdClassifier classifier; + ScopedFdProbeOverride override(stub_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + static constexpr int kReaders = 4; + std::atomic start{false}; + std::atomic stop{false}; + std::atomic ready_readers{0}; + std::atomic reads{0}; + int fd = 48; + + std::thread clearer([&]() { + while (ready_readers.load(std::memory_order_acquire) < kReaders) { + std::this_thread::yield(); + } + start.store(true, std::memory_order_release); + for (int i = 0; i < 1000; i++) { + g_fd_probe_so_type = (i % 2 == 0) ? SOCK_STREAM : SOCK_DGRAM; + classifier.clearFdType(fd); + if ((i % 16) == 0) { + classifier.clearFdTypeCache(); + } + std::this_thread::yield(); + } + stop.store(true, std::memory_order_release); + }); + + std::vector readers; + for (int i = 0; i < kReaders; i++) { + readers.emplace_back([&]() { + ready_readers.fetch_add(1, std::memory_order_release); + while (!start.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + while (!stop.load(std::memory_order_acquire)) { + (void)classifier.isStreamSocket(fd); + (void)classifier.isDatagramSocket(fd); + reads.fetch_add(1, std::memory_order_relaxed); + } + }); + } + + clearer.join(); + for (auto& reader : readers) { + reader.join(); + } + + EXPECT_GT(reads.load(std::memory_order_relaxed), 0); + EXPECT_GT(g_fd_probe_calls.load(), 0); +} + +TEST_F(NativeSocketInterposerFdTest, CloseHookInvalidatesFdBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + int reused_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(reused_fd)); + ASSERT_EQ(0, closeThroughHook(reused_fd)); + + int datagram_fd = datagramSocketAtFd(reused_fd); + ASSERT_EQ(reused_fd, datagram_fd); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(datagram_fd)); + + ASSERT_EQ(0, closeThroughHook(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, + CloseHookInvalidatesNativeSocketSamplerFdStateBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + int reused_fd = stream_fds[0]; + EXPECT_TRUE(sampler->isSocketForTest(reused_fd)); + sampler->fdAddrCacheInsertForTest(reused_fd, "127.0.0.1:12345"); + ASSERT_TRUE(sampler->fdAddrCacheContainsForTest(reused_fd)); + + errno = E2BIG; + ASSERT_EQ(0, closeThroughHook(reused_fd)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(sampler->fdAddrCacheContainsForTest(reused_fd)); + + int datagram_fd = datagramSocketAtFd(reused_fd); + ASSERT_EQ(reused_fd, datagram_fd); + EXPECT_FALSE(sampler->isSocketForTest(datagram_fd)); + + ASSERT_EQ(0, closeThroughHook(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, FailedCloseInvalidatesCachesAndPreservesErrno) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + int fd = stream_fds[0]; + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(fd)); + EXPECT_TRUE(sampler->isSocketForTest(fd)); + sampler->fdAddrCacheInsertForTest(fd, "127.0.0.1:12345"); + ASSERT_TRUE(sampler->fdAddrCacheContainsForTest(fd)); + + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(stub_close)); + g_close_ret = -1; + g_close_errno = EINTR; + uint64_t probes_before = NativeFdClassifier::probeCountForTest(); + + errno = E2BIG; + EXPECT_EQ(-1, closeThroughHook(fd)); + EXPECT_EQ(EINTR, errno); + EXPECT_FALSE(sampler->fdAddrCacheContainsForTest(fd)); + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(fd)); + EXPECT_GT(NativeFdClassifier::probeCountForTest(), probes_before); + + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(::close)); + ASSERT_EQ(0, closeThroughHook(fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, RepeatedCacheClearsDoNotResurrectOldFdType) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + int reused_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(reused_fd)); + ASSERT_EQ(0, close(reused_fd)); + + for (int i = 0; i < 1024; i++) { + NativeSocketInterposer::instance()->clearFdTypeCache(); + } + + int datagram_fd = datagramSocketAtFd(reused_fd); + ASSERT_EQ(reused_fd, datagram_fd); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(datagram_fd)); + + ASSERT_EQ(0, closeThroughHook(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, Dup2InvalidatesTargetFdBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + + int target_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + errno = E2BIG; + ASSERT_EQ(target_fd, dup2ThroughHook(pipe_fds[0], target_fd)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, + Dup2InvalidatesNativeSocketSamplerTargetFdStateBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + int target_fd = stream_fds[0]; + EXPECT_TRUE(sampler->isSocketForTest(target_fd)); + sampler->fdAddrCacheInsertForTest(target_fd, "127.0.0.1:12345"); + ASSERT_TRUE(sampler->fdAddrCacheContainsForTest(target_fd)); + + errno = E2BIG; + ASSERT_EQ(target_fd, dup2ThroughHook(pipe_fds[0], target_fd)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(sampler->fdAddrCacheContainsForTest(target_fd)); + EXPECT_FALSE(sampler->isSocketForTest(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, FailedDup2DoesNotInvalidateTargetFd) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + int target_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + errno = 0; + EXPECT_EQ(-1, dup2ThroughHook(-1, target_fd)); + EXPECT_EQ(EBADF, errno); + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, + FailedDup2DoesNotInvalidateNativeSocketSamplerTargetFdState) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + int target_fd = stream_fds[0]; + EXPECT_TRUE(sampler->isSocketForTest(target_fd)); + sampler->fdAddrCacheInsertForTest(target_fd, "127.0.0.1:12345"); + ASSERT_TRUE(sampler->fdAddrCacheContainsForTest(target_fd)); + + errno = 0; + EXPECT_EQ(-1, dup2ThroughHook(-1, target_fd)); + EXPECT_EQ(EBADF, errno); + EXPECT_TRUE(sampler->isSocketForTest(target_fd)); + EXPECT_TRUE(sampler->fdAddrCacheContainsForTest(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); +} + +#ifdef SYS_dup3 +TEST_F(NativeSocketInterposerFdTest, Dup3InvalidatesTargetFdBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + + int target_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + errno = E2BIG; + ASSERT_EQ(target_fd, dup3ThroughHook(pipe_fds[0], target_fd, 0)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, Dup3PreservesErrnoOnSuccessfulInvalidation) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + + int target_fd = stream_fds[0]; + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + errno = E2BIG; + ASSERT_EQ(target_fd, dup3ThroughHook(pipe_fds[0], target_fd, O_CLOEXEC)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} + +TEST_F(NativeSocketInterposerFdTest, + Dup3InvalidatesNativeSocketSamplerTargetFdStateBeforeReuse) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + + NativeSocketSampler* sampler = NativeSocketSampler::instance(); + int target_fd = stream_fds[0]; + EXPECT_TRUE(sampler->isSocketForTest(target_fd)); + sampler->fdAddrCacheInsertForTest(target_fd, "127.0.0.1:12345"); + ASSERT_TRUE(sampler->fdAddrCacheContainsForTest(target_fd)); + + errno = E2BIG; + ASSERT_EQ(target_fd, dup3ThroughHook(pipe_fds[0], target_fd, 0)); + EXPECT_EQ(E2BIG, errno); + EXPECT_FALSE(sampler->fdAddrCacheContainsForTest(target_fd)); + EXPECT_FALSE(sampler->isSocketForTest(target_fd)); + + ASSERT_EQ(0, closeThroughHook(target_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} +#endif + +TEST_F(NativeSocketInterposerFdTest, ConcurrentFdReuseInvalidationDoesNotPreserveStaleStreamType) { + for (int i = 0; i < 64; i++) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int reused_fd = stream_fds[0]; + ASSERT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(reused_fd)); + + std::atomic done{false}; + std::thread reader([&]() { + while (!done.load(std::memory_order_acquire)) { + (void)NativeSocketInterposer::instance()->isStreamSocket(reused_fd); + std::this_thread::yield(); + } + }); + + ASSERT_EQ(0, closeThroughHook(reused_fd)); + done.store(true, std::memory_order_release); + reader.join(); + + int datagram_fd = datagramSocketAtFd(reused_fd); + ASSERT_EQ(reused_fd, datagram_fd); + + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(datagram_fd)); + EXPECT_TRUE(NativeSocketInterposer::instance()->isDatagramSocket(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(datagram_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + } +} + +TEST_F(NativeSocketInterposerFdTest, RejectsNonSockets) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(fds[0])); + ASSERT_EQ(0, closeThroughHook(fds[0])); + ASSERT_EQ(0, closeThroughHook(fds[1])); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/test/cpp/nativeSocketSampler_ut.cpp b/ddprof-lib/src/test/cpp/nativeSocketSampler_ut.cpp index ee81af7e51..a7a3b0327e 100644 --- a/ddprof-lib/src/test/cpp/nativeSocketSampler_ut.cpp +++ b/ddprof-lib/src/test/cpp/nativeSocketSampler_ut.cpp @@ -22,7 +22,9 @@ #include "libraryPatcher.h" #include +#include #include +#include // --------------------------------------------------------------------------- // Stub tracking @@ -57,6 +59,76 @@ static ssize_t stub_read(int /*fd*/, void* /*buf*/, size_t /*len*/) { return g_read_ret.load(); } +static const int kSamplerFdTypeCacheSizeForTest = 65536; +static const int kSamplerHighFdCacheSizeForTest = 4096; +static std::atomic g_probe_calls{0}; +static std::atomic g_probe_last_fd{-1}; +static std::atomic g_probe_rc{0}; +static std::atomic g_probe_errno{0}; +static std::atomic g_probe_so_type{SOCK_STREAM}; + +static int stub_probe(int fd, int *so_type, int *probe_errno) { + g_probe_calls++; + g_probe_last_fd = fd; + *so_type = g_probe_so_type.load(); + *probe_errno = g_probe_errno.load(); + return g_probe_rc.load(); +} + +static int datagramSocketAtFdForTest(int target_fd) { + int datagram_fd = socket(AF_INET, SOCK_DGRAM, 0); + if (datagram_fd < 0) { + return -1; + } + if (datagram_fd == target_fd) { + return datagram_fd; + } + int dup_fd = dup2(datagram_fd, target_fd); + int saved_errno = errno; + close(datagram_fd); + errno = saved_errno; + return dup_fd; +} + +class ScopedFdForTest { +public: + explicit ScopedFdForTest(int fd = -1) : _fd(fd) {} + ~ScopedFdForTest() { + if (_fd >= 0) { + close(_fd); + } + } + ScopedFdForTest(const ScopedFdForTest&) = delete; + ScopedFdForTest& operator=(const ScopedFdForTest&) = delete; + + void reset(int fd) { + if (_fd >= 0) { + close(_fd); + } + _fd = fd; + } + + int release() { + int fd = _fd; + _fd = -1; + return fd; + } + +private: + int _fd; +}; + +class ScopedSamplerProbeOverride { +public: + explicit ScopedSamplerProbeOverride(NativeSocketSampler::ProbeOverride probe) { + NativeSocketSampler::setProbeOverrideForTest(probe); + } + + ~ScopedSamplerProbeOverride() { + NativeSocketSampler::setProbeOverrideForTest(nullptr); + } +}; + // --------------------------------------------------------------------------- // Test fixture — installs stubs as the "original" function pointers so the // hooks invoke them without needing GOT patching or a running JVM. @@ -345,6 +417,231 @@ TEST(NativeSocketSamplerLruTest, ClearResetsCache) { << "clearFdCache() must empty both the map and the LRU list"; } +TEST(NativeSocketSamplerLruTest, ClearFdCacheEntryRemovesOnlyRequestedEntry) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + + inst->fdAddrCacheInsertForTest(1, "1.2.3.4:100"); + inst->fdAddrCacheInsertForTest(2, "1.2.3.4:200"); + ASSERT_EQ(inst->fdAddrCacheSizeForTest(), 2); + + inst->clearFdCacheEntry(1); + + EXPECT_FALSE(inst->fdAddrCacheContainsForTest(1)); + EXPECT_TRUE(inst->fdAddrCacheContainsForTest(2)); + EXPECT_EQ(inst->fdAddrCacheSizeForTest(), 1); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerLruTest, ClearFdCacheEntryHandlesInvalidFds) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + + inst->fdAddrCacheInsertForTest(7, "1.2.3.4:700"); + + inst->clearFdCacheEntry(-1); + inst->clearFdCacheEntry(NativeSocketSampler::MAX_FD_CACHE + 1); + + EXPECT_TRUE(inst->fdAddrCacheContainsForTest(7)); + EXPECT_EQ(inst->fdAddrCacheSizeForTest(), 1); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerLruTest, ClearFdCacheEntryInvalidatesFdTypeBeforeReuse) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + + int stream_fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds), 0); + + int reused_fd = stream_fds[0]; + EXPECT_TRUE(inst->isSocketForTest(reused_fd)); + close(reused_fd); + + inst->clearFdCacheEntry(reused_fd); + + int datagram_fd = socket(AF_INET, SOCK_DGRAM, 0); + ASSERT_EQ(datagram_fd, reused_fd); + EXPECT_FALSE(inst->isSocketForTest(datagram_fd)); + + close(datagram_fd); + close(stream_fds[1]); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, RevalidateSocketDowngradesReusedFdToNonSocket) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + + int stream_fds[2]; + ASSERT_EQ(socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds), 0); + + int reused_fd = stream_fds[0]; + ScopedFdForTest stream_peer(stream_fds[1]); + ASSERT_TRUE(inst->isSocketForTest(reused_fd)); + close(reused_fd); + + int datagram_fd = datagramSocketAtFdForTest(reused_fd); + ScopedFdForTest datagram(datagram_fd); + ASSERT_EQ(datagram_fd, reused_fd); + + EXPECT_FALSE(inst->revalidateSocketForTest(datagram_fd)); + EXPECT_FALSE(inst->isSocketForTest(datagram_fd)); + + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, HighFdStreamVerdictIsCached) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = 0; + g_probe_errno = 0; + g_probe_so_type = SOCK_STREAM; + int fd = kSamplerFdTypeCacheSizeForTest; + + EXPECT_TRUE(inst->isSocketForTest(fd)); + EXPECT_TRUE(inst->isSocketForTest(fd)); + + EXPECT_EQ(g_probe_calls.load(), 1); + EXPECT_EQ(g_probe_last_fd.load(), fd); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, HighFdEnotsockVerdictIsCached) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = -1; + g_probe_errno = ENOTSOCK; + g_probe_so_type = 0; + int fd = kSamplerFdTypeCacheSizeForTest + 1; + + EXPECT_FALSE(inst->isSocketForTest(fd)); + EXPECT_FALSE(inst->isSocketForTest(fd)); + + EXPECT_EQ(g_probe_calls.load(), 1); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, HighFdTransientProbeFailureIsNotCached) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = -1; + g_probe_errno = EBADF; + g_probe_so_type = 0; + int fd = kSamplerFdTypeCacheSizeForTest + 2; + + EXPECT_FALSE(inst->isSocketForTest(fd)); + EXPECT_EQ(g_probe_calls.load(), 1); + + g_probe_rc = 0; + g_probe_errno = 0; + g_probe_so_type = SOCK_STREAM; + + EXPECT_TRUE(inst->isSocketForTest(fd)); + EXPECT_TRUE(inst->isSocketForTest(fd)); + EXPECT_EQ(g_probe_calls.load(), 2); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, ClearFdCacheEntryInvalidatesHighFdOnly) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = 0; + g_probe_errno = 0; + g_probe_so_type = SOCK_STREAM; + int fd = kSamplerFdTypeCacheSizeForTest + 3; + int other_fd = fd + 1; + + ASSERT_TRUE(inst->isSocketForTest(fd)); + ASSERT_TRUE(inst->isSocketForTest(other_fd)); + EXPECT_EQ(g_probe_calls.load(), 2); + + g_probe_so_type = SOCK_DGRAM; + inst->clearFdCacheEntry(fd); + + EXPECT_FALSE(inst->isSocketForTest(fd)); + EXPECT_TRUE(inst->isSocketForTest(other_fd)); + EXPECT_EQ(g_probe_calls.load(), 3); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, ClearFdCacheInvalidatesHighFdsByGeneration) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = 0; + g_probe_errno = 0; + g_probe_so_type = SOCK_STREAM; + int fd = kSamplerFdTypeCacheSizeForTest + 4; + + ASSERT_TRUE(inst->isSocketForTest(fd)); + EXPECT_EQ(g_probe_calls.load(), 1); + + g_probe_so_type = SOCK_DGRAM; + inst->clearFdCache(); + + EXPECT_FALSE(inst->isSocketForTest(fd)); + EXPECT_EQ(g_probe_calls.load(), 2); + inst->clearFdCache(); +} + +TEST(NativeSocketSamplerFdTypeTest, HighFdCacheCollisionReprobesExactFd) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = 0; + g_probe_errno = 0; + int stream_fd = kSamplerFdTypeCacheSizeForTest + 5; + int datagram_fd = stream_fd + kSamplerHighFdCacheSizeForTest; + + g_probe_so_type = SOCK_STREAM; + ASSERT_TRUE(inst->isSocketForTest(stream_fd)); + EXPECT_EQ(g_probe_calls.load(), 1); + + g_probe_so_type = SOCK_DGRAM; + EXPECT_FALSE(inst->isSocketForTest(datagram_fd)); + EXPECT_EQ(g_probe_calls.load(), 2); + EXPECT_EQ(g_probe_last_fd.load(), datagram_fd); + + g_probe_so_type = SOCK_STREAM; + EXPECT_TRUE(inst->isSocketForTest(stream_fd)); + EXPECT_EQ(g_probe_calls.load(), 3); + EXPECT_EQ(g_probe_last_fd.load(), stream_fd); + inst->clearFdCache(); +} + +TEST_F(NativeSocketSamplerHookTest, HighFdWriteHookReusesCachedSocketVerdict) { + NativeSocketSampler* inst = NativeSocketSampler::instance(); + inst->clearFdCache(); + ScopedSamplerProbeOverride override(stub_probe); + g_probe_calls = 0; + g_probe_rc = 0; + g_probe_errno = 0; + g_probe_so_type = SOCK_STREAM; + g_write_ret = -1; + int fd = kSamplerFdTypeCacheSizeForTest + 6; + char buf[16] = {}; + + bool prev = LibraryPatcher::_socket_active.exchange(true, std::memory_order_release); + EXPECT_EQ(-1, NativeSocketSampler::write_hook(fd, buf, sizeof(buf))); + EXPECT_EQ(-1, NativeSocketSampler::write_hook(fd, buf, sizeof(buf))); + LibraryPatcher::_socket_active.store(prev, std::memory_order_release); + + EXPECT_EQ(g_write_calls.load(), 2); + EXPECT_EQ(g_probe_calls.load(), 1); + inst->clearFdCache(); +} + TEST(NativeSocketSamplerLruTest, InsertAndLookupPreservesEntries) { NativeSocketSampler* inst = NativeSocketSampler::instance(); inst->clearFdCache(); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index f5300bb7bc..26ba3e7c70 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -660,6 +660,24 @@ TEST_F(ThreadFilterTest, ContextScopeNeverSuppressesOwnedBlock) { EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } +TEST_F(ThreadFilterTest, OwnedNativeIoSuppressesBeforeAnyWallSample) { + 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::IO_WAIT, BlockRunOwner::NATIVE); + ASSERT_NE(0ULL, token); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + 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); diff --git a/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c b/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c index 4d87f57edf..01e7691d70 100644 --- a/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c +++ b/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c @@ -1,13 +1,18 @@ /* * Copyright The async-profiler authors + * Copyright 2026, Datadog, Inc. * SPDX-License-Identifier: Apache-2.0 */ #include #include +#include // Force pthread_setspecific into .rela.dyn with R_X86_64_GLOB_DAT. int (*indirect_pthread_setspecific)(pthread_key_t, const void*); // Force pthread_exit into .rela.dyn with R_X86_64_64. void (*static_pthread_exit)(void*) = pthread_exit; +// Force read into .rela.plt (direct call) and two distinct .rela.dyn slots. +ssize_t (*static_read)(int, void*, size_t) = read; +ssize_t (*static_read_second)(int, void*, size_t) = read; void* thread_function(void* arg) { printf("Thread running\n"); return NULL; @@ -25,4 +30,16 @@ int reladyn() { // Use pthread_exit via the static pointer, forces into .rela.dyn as R_X86_64_64. static_pthread_exit(NULL); return 0; -} \ No newline at end of file +} + +ssize_t reladyn_direct_read(int fd, void* buffer, size_t size) { + return read(fd, buffer, size); +} + +ssize_t reladyn_indirect_read(int fd, void* buffer, size_t size) { + return static_read(fd, buffer, size); +} + +ssize_t reladyn_second_indirect_read(int fd, void* buffer, size_t size) { + return static_read_second(fd, buffer, size); +} diff --git a/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile new file mode 100644 index 0000000000..fca9ddac41 --- /dev/null +++ b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile @@ -0,0 +1,6 @@ +# Copyright 2026, Datadog, Inc. +# SPDX-License-Identifier: Apache-2.0 + +TARGET_DIR = ../build/test/resources/native-libs/unloadable-io-lib +all: + gcc -fPIC -shared -o $(TARGET_DIR)/libunloadable-io.so unloadable_io.c diff --git a/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c new file mode 100644 index 0000000000..f73e5d00c8 --- /dev/null +++ b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c @@ -0,0 +1,12 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include + +ssize_t unloadable_read(int fd, void* buffer, size_t size) { + return read(fd, buffer, size); +} diff --git a/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java new file mode 100644 index 0000000000..5e592bcf14 --- /dev/null +++ b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java @@ -0,0 +1,318 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.stresstest.scenarios.throughput; + +import com.datadoghq.profiler.JavaProfiler; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.channels.Pipe; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +@State(Scope.Benchmark) +/** Compares representative I/O costs with native TaskBlock interposition disabled and enabled. */ +public class NativeSocketIoBenchmark { + @Param({"none", "wall=1s,wallscope=all", "wall=1s,wallscope=all,wallprecheck=true"}) + public String command; + + private JavaProfiler profiler; + private Path jfr; + private InetAddress loopback; + private ServerSocket serverSocket; + private Socket clientSocket; + private Socket serverSideSocket; + private InputStream clientInput; + private OutputStream clientOutput; + private InputStream serverInput; + private OutputStream serverOutput; + private Path file; + private FileInputStream fileInput; + private ServerSocket connectServerSocket; + private volatile boolean connectAcceptorRunning; + private Thread connectAcceptorThread; + private ServerSocket acceptServerSocket; + private volatile boolean acceptConnectorRunning; + private Thread acceptConnectorThread; + private DatagramSocket udpReceiverSocket; + private DatagramSocket udpSenderSocket; + private byte[] udpReceiveBuffer; + private byte[] udpSendBuffer; + private DatagramPacket udpReceivePacket; + private DatagramPacket udpSendPacket; + private Selector selector; + private Pipe selectorPipe; + private ByteBuffer selectorWriteBuffer; + private ByteBuffer selectorReadBuffer; + private final AtomicReference backgroundError = new AtomicReference<>(); + + @Setup(Level.Trial) + public void setup() throws IOException { + backgroundError.set(null); + if (!"none".equals(command)) { + profiler = JavaProfiler.getInstance(); + jfr = Files.createTempFile("native-socket-io-benchmark", ".jfr"); + profiler.execute("start," + command + ",jfr,file=" + jfr.toAbsolutePath()); + } + + loopback = InetAddress.getLoopbackAddress(); + + serverSocket = new ServerSocket(0, 1, loopback); + clientSocket = new Socket(loopback, serverSocket.getLocalPort()); + serverSideSocket = serverSocket.accept(); + clientSocket.setTcpNoDelay(true); + serverSideSocket.setTcpNoDelay(true); + clientInput = clientSocket.getInputStream(); + clientOutput = clientSocket.getOutputStream(); + serverInput = serverSideSocket.getInputStream(); + serverOutput = serverSideSocket.getOutputStream(); + + file = Files.createTempFile("native-socket-io-benchmark", ".bin"); + byte[] data = new byte[1024 * 1024]; + Files.write(file, data); + fileInput = new FileInputStream(file.toFile()); + + connectServerSocket = new ServerSocket(0, 50, loopback); + connectAcceptorRunning = true; + connectAcceptorThread = new Thread(this::acceptConnectBenchmarkSockets, + "native-io-connect-acceptor"); + connectAcceptorThread.setDaemon(true); + connectAcceptorThread.start(); + + acceptServerSocket = new ServerSocket(0, 50, loopback); + acceptConnectorRunning = true; + acceptConnectorThread = new Thread(this::connectAcceptBenchmarkSockets, + "native-io-accept-connector"); + acceptConnectorThread.setDaemon(true); + acceptConnectorThread.start(); + + udpReceiverSocket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + udpSenderSocket = new DatagramSocket(); + udpReceiveBuffer = new byte[64]; + udpSendBuffer = new byte[]{1}; + udpReceivePacket = new DatagramPacket(udpReceiveBuffer, udpReceiveBuffer.length); + udpSendPacket = new DatagramPacket( + udpSendBuffer, udpSendBuffer.length, loopback, udpReceiverSocket.getLocalPort()); + + selector = Selector.open(); + selectorPipe = Pipe.open(); + selectorPipe.source().configureBlocking(false); + selectorPipe.source().register(selector, SelectionKey.OP_READ); + selectorWriteBuffer = ByteBuffer.allocate(1); + selectorReadBuffer = ByteBuffer.allocate(64); + } + + @TearDown(Level.Trial) + public void tearDown() throws IOException { + connectAcceptorRunning = false; + acceptConnectorRunning = false; + closeQuietly(connectServerSocket); + closeQuietly(acceptServerSocket); + joinQuietly(connectAcceptorThread); + joinQuietly(acceptConnectorThread); + closeQuietly(udpReceiverSocket); + closeQuietly(udpSenderSocket); + closeQuietly(selector); + if (selectorPipe != null) { + closeQuietly(selectorPipe.source()); + closeQuietly(selectorPipe.sink()); + } + closeQuietly(fileInput); + closeQuietly(clientSocket); + closeQuietly(serverSideSocket); + closeQuietly(serverSocket); + if (file != null) { + Files.deleteIfExists(file); + } + if (profiler != null) { + profiler.execute("stop"); + } + if (jfr != null) { + Files.deleteIfExists(jfr); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int socketRoundTrip() throws IOException { + clientOutput.write(1); + clientOutput.flush(); + int value = serverInput.read(); + serverOutput.write(value); + serverOutput.flush(); + return clientInput.read(); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int connectClose() throws IOException { + assertBackgroundHealthy(); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(loopback, connectServerSocket.getLocalPort())); + return socket.getLocalPort(); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int acceptClose() throws IOException { + assertBackgroundHealthy(); + try (Socket accepted = acceptServerSocket.accept()) { + return accepted.getPort(); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int datagramReceive() throws IOException { + udpSendBuffer[0]++; + udpSenderSocket.send(udpSendPacket); + udpReceivePacket.setLength(udpReceiveBuffer.length); + udpReceiverSocket.receive(udpReceivePacket); + return udpReceivePacket.getLength(); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int selectorSelect() throws IOException { + selectorWriteBuffer.clear(); + selectorWriteBuffer.put((byte) 1); + selectorWriteBuffer.flip(); + while (selectorWriteBuffer.hasRemaining()) { + selectorPipe.sink().write(selectorWriteBuffer); + } + int selected = selector.select(1_000L); + selector.selectedKeys().clear(); + selectorReadBuffer.clear(); + while (selectorPipe.source().read(selectorReadBuffer) > 0) { + selectorReadBuffer.clear(); + } + return selected; + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int regularFileRead() throws IOException { + int value = fileInput.read(); + if (value >= 0) { + return value; + } + fileInput.close(); + fileInput = new FileInputStream(file.toFile()); + return fileInput.read(); + } + + private void acceptConnectBenchmarkSockets() { + while (connectAcceptorRunning) { + try (Socket ignored = connectServerSocket.accept()) { + } catch (IOException e) { + if (connectAcceptorRunning) { + backgroundError.compareAndSet(null, e); + } + } + } + } + + private void connectAcceptBenchmarkSockets() { + while (acceptConnectorRunning) { + try (Socket ignored = new Socket(loopback, acceptServerSocket.getLocalPort())) { + } catch (IOException e) { + if (acceptConnectorRunning) { + backgroundError.compareAndSet(null, e); + } + } + } + } + + private void assertBackgroundHealthy() throws IOException { + Throwable failure = backgroundError.get(); + if (failure == null) { + return; + } + if (failure instanceof IOException) { + throw (IOException) failure; + } + throw new IOException(failure); + } + + private static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Exception ignored) { + } + } + + private static void joinQuietly(Thread thread) { + if (thread == null) { + return; + } + try { + thread.join(1_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ddprof-test-native/src/main/cpp/nativeioblock.c b/ddprof-test-native/src/main/cpp/nativeioblock.c new file mode 100644 index 0000000000..67e2b50a17 --- /dev/null +++ b/ddprof-test-native/src/main/cpp/nativeioblock.c @@ -0,0 +1,403 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#define _GNU_SOURCE + +#include + +#if defined(__linux__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct delayed_write_args { + int fd; + int delay_ms; + int close_after_write; +}; + +struct delayed_connect_args { + int port; + int delay_ms; +}; + +enum native_block_kind { + NATIVE_BLOCK_ACCEPT = 3, + NATIVE_BLOCK_POLL = 5, + NATIVE_BLOCK_SELECT = 6, + NATIVE_BLOCK_EPOLL_WAIT = 7 +}; + +static jlong native_blocker(enum native_block_kind kind, int blocker_id) { + return (jlong)(((uint64_t)kind << 32) | (uint32_t)blocker_id); +} + +static void sleep_ms(int delay_ms) { + if (delay_ms <= 0) { + return; + } + struct timespec ts; + ts.tv_sec = delay_ms / 1000; + ts.tv_nsec = (long)(delay_ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) != 0 && errno == EINTR) { + } +} + +static void throw_io_exception(JNIEnv* env, const char* operation) { + jclass exception_class = (*env)->FindClass(env, "java/io/IOException"); + if (exception_class == NULL) { + return; + } + char message[256]; + snprintf(message, sizeof(message), "%s failed: %s", operation, strerror(errno)); + (*env)->ThrowNew(env, exception_class, message); +} + +static void throw_out_of_memory(JNIEnv* env) { + jclass exception_class = (*env)->FindClass(env, "java/lang/OutOfMemoryError"); + if (exception_class != NULL) { + (*env)->ThrowNew(env, exception_class, "native allocation failed"); + } +} + +static void throw_timeout(JNIEnv* env, const char* operation) { + jclass exception_class = (*env)->FindClass(env, "java/io/IOException"); + if (exception_class != NULL) { + char message[256]; + snprintf(message, sizeof(message), "%s timed out", operation); + (*env)->ThrowNew(env, exception_class, message); + } +} + +static void* delayed_write(void* arg) { + struct delayed_write_args* args = (struct delayed_write_args*)arg; + sleep_ms(args->delay_ms); + uint64_t value = 1; + while (write(args->fd, &value, sizeof(value)) < 0 && errno == EINTR) { + } + if (args->close_after_write) { + close(args->fd); + } + free(args); + return NULL; +} + +static void* delayed_connect(void* arg) { + struct delayed_connect_args* args = (struct delayed_connect_args*)arg; + sleep_ms(args->delay_ms); + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) { + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons((uint16_t)args->port); + connect(fd, (struct sockaddr*)&addr, sizeof(addr)); + close(fd); + } + free(args); + return NULL; +} + +static int start_delayed_write(JNIEnv* env, int fd, int delay_ms, int close_after_write) { + struct delayed_write_args* args = + (struct delayed_write_args*)malloc(sizeof(struct delayed_write_args)); + if (args == NULL) { + throw_out_of_memory(env); + return -1; + } + args->fd = fd; + args->delay_ms = delay_ms; + args->close_after_write = close_after_write; + pthread_t thread; + int rc = pthread_create(&thread, NULL, delayed_write, args); + if (rc != 0) { + free(args); + errno = rc; + throw_io_exception(env, "pthread_create"); + return -1; + } + pthread_detach(thread); + return 0; +} + +static int start_delayed_connect(JNIEnv* env, int port, int delay_ms) { + struct delayed_connect_args* args = + (struct delayed_connect_args*)malloc(sizeof(struct delayed_connect_args)); + if (args == NULL) { + throw_out_of_memory(env); + return -1; + } + args->port = port; + args->delay_ms = delay_ms; + pthread_t thread; + int rc = pthread_create(&thread, NULL, delayed_connect, args); + if (rc != 0) { + free(args); + errno = rc; + throw_io_exception(env, "pthread_create"); + return -1; + } + pthread_detach(thread); + return 0; +} + +static jlong block_with_poll_like(JNIEnv* env, jint delay_ms, int use_ppoll) { + int fds[2]; + if (pipe(fds) != 0) { + throw_io_exception(env, "pipe"); + return 0; + } + if (start_delayed_write(env, fds[1], delay_ms, 1) != 0) { + close(fds[0]); + close(fds[1]); + return 0; + } + + struct pollfd pfd; + pfd.fd = fds[0]; + pfd.events = POLLIN; + pfd.revents = 0; + int rc; + do { + if (use_ppoll) { + struct timespec timeout_ts = {5, 0}; + rc = ppoll(&pfd, 1, &timeout_ts, NULL); + } else { + rc = poll(&pfd, 1, 5000); + } + } while (rc < 0 && errno == EINTR); + close(fds[0]); + if (rc < 0) { + throw_io_exception(env, use_ppoll ? "ppoll" : "poll"); + return 0; + } else if (rc == 0) { + throw_timeout(env, use_ppoll ? "ppoll" : "poll"); + return 0; + } + return native_blocker(NATIVE_BLOCK_POLL, 0); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPpoll( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_poll_like(env, delay_ms, 1); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPselect( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + int fds[2]; + if (pipe(fds) != 0) { + throw_io_exception(env, "pipe"); + return 0; + } + if (start_delayed_write(env, fds[1], delay_ms, 1) != 0) { + close(fds[0]); + close(fds[1]); + return 0; + } + + int rc; + do { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(fds[0], &readfds); + struct timespec timeout = {5, 0}; + rc = pselect(fds[0] + 1, &readfds, NULL, NULL, &timeout, NULL); + } while (rc < 0 && errno == EINTR); + close(fds[0]); + if (rc < 0) { + throw_io_exception(env, "pselect"); + return 0; + } else if (rc == 0) { + throw_timeout(env, "pselect"); + return 0; + } + return native_blocker(NATIVE_BLOCK_SELECT, 0); +} + +static jlong block_with_epoll(JNIEnv* env, jint delay_ms, int use_pwait) { + int event_fd = eventfd(0, EFD_CLOEXEC); + if (event_fd < 0) { + throw_io_exception(env, "eventfd"); + return 0; + } + int epfd = epoll_create1(EPOLL_CLOEXEC); + if (epfd < 0) { + close(event_fd); + throw_io_exception(env, "epoll_create1"); + return 0; + } + struct epoll_event event; + memset(&event, 0, sizeof(event)); + event.events = EPOLLIN; + event.data.fd = event_fd; + if (epoll_ctl(epfd, EPOLL_CTL_ADD, event_fd, &event) != 0) { + close(epfd); + close(event_fd); + throw_io_exception(env, "epoll_ctl"); + return 0; + } + if (start_delayed_write(env, event_fd, delay_ms, 0) != 0) { + close(epfd); + close(event_fd); + return 0; + } + + struct epoll_event out_event; + int rc; + do { + if (use_pwait) { + rc = epoll_pwait(epfd, &out_event, 1, 5000, NULL); + } else { + rc = epoll_wait(epfd, &out_event, 1, 5000); + } + } while (rc < 0 && errno == EINTR); + jlong blocker = native_blocker(NATIVE_BLOCK_EPOLL_WAIT, epfd); + close(epfd); + close(event_fd); + if (rc < 0) { + throw_io_exception(env, use_pwait ? "epoll_pwait" : "epoll_wait"); + return 0; + } else if (rc == 0) { + throw_timeout(env, use_pwait ? "epoll_pwait" : "epoll_wait"); + return 0; + } + return blocker; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollWait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_epoll(env, delay_ms, 0); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollPwait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_epoll(env, delay_ms, 1); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingAccept4( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + int server_fd = socket(AF_INET, SOCK_STREAM, 0); + if (server_fd < 0) { + throw_io_exception(env, "socket"); + return 0; + } + int one = 1; + setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0 || + listen(server_fd, 1) != 0) { + close(server_fd); + throw_io_exception(env, "bind/listen"); + return 0; + } + + socklen_t addr_len = sizeof(addr); + if (getsockname(server_fd, (struct sockaddr*)&addr, &addr_len) != 0) { + close(server_fd); + throw_io_exception(env, "getsockname"); + return 0; + } + if (start_delayed_connect(env, ntohs(addr.sin_port), delay_ms) != 0) { + close(server_fd); + return 0; + } + + jlong blocker = native_blocker(NATIVE_BLOCK_ACCEPT, server_fd); + int accepted_fd = accept4(server_fd, NULL, NULL, SOCK_CLOEXEC); + if (accepted_fd >= 0) { + close(accepted_fd); + } + close(server_fd); + if (accepted_fd < 0) { + throw_io_exception(env, "accept4"); + return 0; + } + return blocker; +} + +#else + +static void unsupported(JNIEnv* env) { + jclass exception_class = (*env)->FindClass(env, "java/lang/UnsupportedOperationException"); + if (exception_class != NULL) { + (*env)->ThrowNew(env, exception_class, "Native I/O block helper is Linux-only"); + } +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingAccept4( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPpoll( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPselect( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollWait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollPwait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +#endif diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java new file mode 100644 index 0000000000..831e2ce4de --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; + +import java.util.Map; + +/** Verifies synchronous native-I/O production when delegated wall-clock stacks are enabled. */ +public class JvmtiBasedNativeSocketTaskBlockTest extends NativeSocketTaskBlockTest { + @Override + protected void before() throws Exception { + 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/NativeIoBlockHelper.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java new file mode 100644 index 0000000000..f40853df8d --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +/** JNI workloads that block in Linux APIs not exposed directly by the Java standard library. */ +final class NativeIoBlockHelper { + static { + System.loadLibrary("ddproftest"); + } + + /** Blocks in {@code accept4} and returns the expected native blocker encoding. */ + static native long blockingAccept4(int delayMillis); + + /** Blocks in {@code ppoll} and returns the expected native blocker encoding. */ + static native long blockingPpoll(int delayMillis); + + /** Blocks in {@code pselect} and returns the expected native blocker encoding. */ + static native long blockingPselect(int delayMillis); + + /** Blocks in {@code epoll_wait} and returns the expected native blocker encoding. */ + static native long blockingEpollWait(int delayMillis); + + /** Blocks in {@code epoll_pwait} and returns the expected native blocker encoding. */ + static native long blockingEpollPwait(int delayMillis); + + private NativeIoBlockHelper() {} +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java new file mode 100644 index 0000000000..0d9ba39920 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies native I/O hooks are fully restored and can be reinstalled across profiler restarts. */ +public class NativeSocketTaskBlockLifecycleTest extends AbstractProfilerTest { + private static final int BLOCK_HOLD_MILLIS = 250; + private static final int NATIVE_BLOCK_ATTEMPTS = 5; + + @BeforeAll + static void preloadNativeHelpers() throws Exception { + if (Platform.isLinux()) { + NativeIoBlockHelper.blockingPpoll(0); + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept()) { + // Exercise Java networking before profiler start so the native libraries + // needed by socket reads are loaded before the initial patch pass. + } + } + } + + @Test + public void restartWithWallPrecheckDisabledStopsNativeSocketTaskBlocks() throws Exception { + long enabledBlocker = runNativeIoBlock(); + stopProfiler(); + assertIoWaitTaskBlockPresent(verifyEvents("datadog.TaskBlock", false), enabledBlocker); + + Path disabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_disabled_", ".jfr"); + try { + profiler.execute("start,wall=1ms,wallprecheck=false,jfr,file=" + + disabledRecording.toAbsolutePath()); + runNativeIoBlock(); + profiler.stop(); + + IItemCollection disabledTaskBlocks = + verifyEvents(disabledRecording, "datadog.TaskBlock", false); + assertFalse(disabledTaskBlocks.hasItems(), + "wallprecheck=false restart must disable native socket TaskBlock emission"); + } finally { + Files.deleteIfExists(disabledRecording); + } + + Path reenabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_reenabled_", ".jfr"); + try { + profiler.execute("start,wall=1ms,wallprecheck=true,jfr,file=" + + reenabledRecording.toAbsolutePath()); + long reenabledBlocker = runNativeIoBlock(); + profiler.stop(); + + assertIoWaitTaskBlockPresent(verifyEvents( + reenabledRecording, "datadog.TaskBlock", false), reenabledBlocker); + } finally { + Files.deleteIfExists(reenabledRecording); + } + } + + @Test + public void stopWhileSocketReadIsBlockedCleansUpAndAllowsReinstall() throws Exception { + String workerName = "taskblock-native-stop-inflight"; + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + ServerSocket server = new ServerSocket(0); + Socket accepted = null; + boolean stopped = false; + + Thread reader = new Thread(() -> { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + input.read(); + } catch (Throwable t) { + error.set(t); + } + }, workerName); + + try { + reader.start(); + accepted = server.accept(); + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), + "reader did not attempt the blocking socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + assertTrue(reader.isAlive(), + "socket read returned before the profiler was stopped"); + + stopProfiler(); + stopped = true; + assertTrue(reader.isAlive(), + "socket read returned before profiler shutdown completed"); + } finally { + if (!stopped) { + stopProfiler(); + } + if (accepted != null) { + accepted.close(); + } + server.close(); + reader.join(5_000L); + } + + assertFalse(reader.isAlive(), "blocked socket reader did not terminate"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + assertFalse(TaskBlockAssertions.containsObservedStateForEventThread( + verifyEvents("datadog.TaskBlock", false), "IO_WAIT", workerName), + "a native I/O operation returning after stop must not emit TaskBlock"); + + Path restartedRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_inflight_restart_", ".jfr"); + boolean restarted = false; + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + + restartedRecording.toAbsolutePath()); + restarted = true; + String restartedWorkerName = "taskblock-native-stop-reinstalled"; + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runCompletedSocketRead(restartedWorkerName); + } + profiler.stop(); + restarted = false; + + IItemCollection restartedTaskBlocks = + verifyEvents(restartedRecording, "datadog.TaskBlock", false); + TaskBlockAssertions.assertNoAnchorFields(restartedTaskBlocks); + TaskBlockAssertions.assertContainsStackTrace(restartedTaskBlocks); + TaskBlockAssertions.assertContainsJavaType( + restartedTaskBlocks, "NativeSocketTaskBlockLifecycleTest"); + assertTrue(TaskBlockAssertions.containsObservedStateForEventThread( + restartedTaskBlocks, "IO_WAIT", restartedWorkerName), + "socket TaskBlock hooks were not reinstalled after profiler shutdown"); + } finally { + if (restarted) { + profiler.stop(); + } + Files.deleteIfExists(restartedRecording); + } + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true"; + } + + private void assertIoWaitTaskBlockPresent(IItemCollection taskBlockEvents, long expectedBlocker) { + assertTrue(expectedBlocker != 0L, "native lifecycle helper must report the expected blocker"); + if (!taskBlockEvents.hasItems()) { + assertTrue(false, missingTaskBlockDiagnostic()); + } + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType( + taskBlockEvents, "NativeSocketTaskBlockLifecycleTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + private long runNativeIoBlock() throws InterruptedException { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + blocker.set(NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-lifecycle-helper"); + worker.start(); + worker.join(5_000L); + assertFalse(worker.isAlive(), "native lifecycle helper did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + return blocker.get(); + } + + private void runCompletedSocketRead(String workerName) throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } catch (Throwable t) { + error.set(t); + } + }, workerName); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), + "reader did not attempt the blocking socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + reader.join(5_000L); + assertFalse(reader.isAlive(), "socket reader did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + } + } + + private String missingTaskBlockDiagnostic() { + return "Expected lifecycle native TaskBlock after " + NATIVE_BLOCK_ATTEMPTS + + " blocked interval(s); emitted=" + getRecordedCounterValue("task_block_emitted") + + ", stack_capture_failed=" + + getRecordedCounterValue("task_block_stack_capture_failed") + + ", skipped_too_short=" + getRecordedCounterValue("task_block_skipped_too_short") + + ", skipped_trace_context=" + + getRecordedCounterValue("task_block_skipped_trace_context") + + ", record_failed=" + getRecordedCounterValue("task_block_record_failed"); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java new file mode 100644 index 0000000000..c934a116d0 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java @@ -0,0 +1,442 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.channels.Pipe; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies Linux native socket and readiness waits produce self-contained TaskBlock events. */ +public class NativeSocketTaskBlockTest extends AbstractProfilerTest { + private static final int BLOCK_HOLD_MILLIS = 250; + private static final int NATIVE_BLOCK_ATTEMPTS = 5; + + @BeforeAll + static void preloadNativeHelper() { + if (Platform.isLinux()) { + NativeIoBlockHelper.blockingPpoll(0); + } + } + + @Test + public void blockingSocketReadEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingSocketReadOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingSocketReadOnce() throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-socket-read"); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), "reader did not enter socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + assertCompleted(reader, error); + } + } + + @Test + public void blockingServerSocketAcceptEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingServerSocketAcceptOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingServerSocketAcceptOnce() throws Exception { + CountDownLatch acceptAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + InetAddress loopback = InetAddress.getLoopbackAddress(); + + try (ServerSocket server = new ServerSocket(0, 1, loopback)) { + Thread accepter = new Thread(() -> { + try { + acceptAttempted.countDown(); + try (Socket accepted = server.accept()) { + assertTrue(accepted.isConnected()); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-socket-accept"); + + accepter.start(); + assertTrue(acceptAttempted.await(5, TimeUnit.SECONDS), "accept did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + try (Socket ignored = new Socket(loopback, server.getLocalPort())) { + assertTrue(ignored.isConnected()); + } + assertCompleted(accepter, error); + } + } + + @Test + public void blockingDatagramReceiveEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingDatagramReceiveOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingDatagramReceiveOnce() throws Exception { + CountDownLatch receiveAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + InetAddress loopback = InetAddress.getLoopbackAddress(); + + try (DatagramSocket receiver = new DatagramSocket(new InetSocketAddress(loopback, 0))) { + Thread receiverThread = new Thread(() -> { + try { + byte[] data = new byte[1]; + DatagramPacket packet = new DatagramPacket(data, data.length); + receiveAttempted.countDown(); + receiver.receive(packet); + assertEquals(1, packet.getLength()); + assertEquals(7, data[0]); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-datagram-receive"); + + receiverThread.start(); + assertTrue(receiveAttempted.await(5, TimeUnit.SECONDS), "receive did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + try (DatagramSocket sender = new DatagramSocket()) { + byte[] data = new byte[]{7}; + DatagramPacket packet = new DatagramPacket( + data, data.length, loopback, receiver.getLocalPort()); + sender.send(packet); + } + assertCompleted(receiverThread, error); + } + } + + @Test + public void blockingSelectorSelectEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingSelectorSelectOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingSelectorSelectOnce() throws Exception { + CountDownLatch selectAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + Pipe pipe = Pipe.open(); + try (Selector selector = Selector.open(); + Pipe.SourceChannel source = pipe.source(); + Pipe.SinkChannel sink = pipe.sink()) { + source.configureBlocking(false); + source.register(selector, SelectionKey.OP_READ); + + Thread selectorThread = new Thread(() -> { + try { + selectAttempted.countDown(); + int selected = selectUntilReady(selector, 5_000L); + assertTrue(selected > 0, "selector did not observe pipe readiness"); + selector.selectedKeys().clear(); + ByteBuffer data = ByteBuffer.allocate(1); + while (data.hasRemaining() && source.read(data) > 0) { + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-selector-select"); + + selectorThread.start(); + assertTrue(selectAttempted.await(5, TimeUnit.SECONDS), "select did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + sink.write(ByteBuffer.wrap(new byte[]{1})); + assertCompleted(selectorThread, error); + } + } + + @Test + public void blockingAccept4EmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingAccept4(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingPpollEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void ownedNativeIoSuppressesWallSignalsBeforeCompletion() throws Exception { + long before = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + blocker.set(NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-suppression"); + + worker.start(); + waitForCounterAbove("wc_signals_suppressed_owned_block", before, 5_000L); + assertCompleted(worker, error); + + stopProfiler(); + assertNativeIoHelperCompleted(blocker.get()); + } + + @Test + public void blockingPselectEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingPselect(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingEpollWaitEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingEpollWait(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingEpollPwaitEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingEpollPwait(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void tracedBlockingSocketReadDoesNotEmitTaskBlock() throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try { + profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } finally { + profiler.clearContext(); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-traced-native-socket-read"); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), "reader did not enter socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + assertCompleted(reader, error); + } + + stopProfiler(); + assertFalse( + TaskBlockAssertions.containsSpan(verifyEvents("datadog.TaskBlock", false), 0x5101L), + "Traced socket I/O must keep MethodSample wall-clock data instead of emitting TaskBlock"); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true"; + } + + private static int selectUntilReady(Selector selector, long timeoutMillis) throws Exception { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + int selected; + do { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0L) { + return 0; + } + selected = selector.select(Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + } while (selected == 0); + return selected; + } + + protected void assertIoWaitTaskBlockSelfContained() { + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + } + + protected void assertIoWaitTaskBlockSelfContained(long expectedBlocker) { + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + private void assertNativeIoHelperCompleted(long expectedBlocker) { + assertTrue(expectedBlocker != 0L, "native I/O helper must report the expected blocker"); + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + protected void assertTaskBlockStackReference(IItemCollection taskBlockEvents) { + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType(taskBlockEvents, "NativeSocketTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + } + + private long runNativeIoBlockRepeated(NativeIoBlockWorkload workload) { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + blocker.set(workload.run()); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-helper"); + worker.start(); + try { + worker.join(5_000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for native helper", interrupted); + } + assertFalse(worker.isAlive(), "native helper did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + return blocker.get(); + } + + private void assertNativeTaskBlockPresent(IItemCollection taskBlockEvents) { + if (!taskBlockEvents.hasItems()) { + String diagnostic = missingTaskBlockDiagnostic(); + System.out.println(diagnostic); + assertTrue(false, diagnostic); + } + } + + private String missingTaskBlockDiagnostic() { + return "Expected native socket TaskBlock after " + NATIVE_BLOCK_ATTEMPTS + + " blocked interval(s); emitted=" + getRecordedCounterValue("task_block_emitted") + + ", stack_capture_failed=" + + getRecordedCounterValue("task_block_stack_capture_failed") + + ", skipped_too_short=" + getRecordedCounterValue("task_block_skipped_too_short") + + ", skipped_trace_context=" + + getRecordedCounterValue("task_block_skipped_trace_context") + + ", record_failed=" + getRecordedCounterValue("task_block_record_failed"); + } + + 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 NativeIoBlockWorkload { + long run(); + } + + private static void assertCompleted(Thread thread, AtomicReference error) + throws InterruptedException { + thread.join(5_000L); + assertFalse(thread.isAlive(), thread.getName() + " did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + } +} 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 64756e83d0..e893d62a14 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 @@ -10,13 +10,17 @@ import java.util.Set; import org.openjdk.jmc.common.IMCFrame; import org.openjdk.jmc.common.IMCStackTrace; +import org.openjdk.jmc.common.IMCThread; 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 org.openjdk.jmc.flightrecorder.JfrAttributes; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; 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; @@ -128,6 +132,59 @@ static boolean containsBlocker(IItemCollection events, long blocker) { return false; } + static boolean containsObservedStateForEventThread( + IItemCollection events, String observedState, String threadName) { + for (IItemIterable iterable : events) { + IMemberAccessor stateAccessor = + OBSERVED_BLOCKING_STATE.getAccessor(iterable.getType()); + IMemberAccessor threadAccessor = + JfrAttributes.EVENT_THREAD.getAccessor(iterable.getType()); + if (stateAccessor == null || threadAccessor == null) continue; + for (IItem item : iterable) { + IMCThread thread = threadAccessor.getMember(item); + if (observedState.equals(stateAccessor.getMember(item)) + && thread != null + && threadName.equals(thread.getThreadName())) { + return true; + } + } + } + return false; + } + + static boolean containsSpan(IItemCollection events, long spanId) { + for (IItemIterable iterable : events) { + IMemberAccessor spanAccessor = + AbstractProfilerTest.SPAN_ID.getAccessor(iterable.getType()); + if (spanAccessor == null) continue; + for (IItem item : iterable) { + if (spanAccessor.getMember(item).longValue() == spanId) return true; + } + } + return false; + } + + static void assertBlockerEventThreadDiffers( + IItemCollection events, long blocker, long logicalThreadId) { + int checked = 0; + for (IItemIterable iterable : events) { + IMemberAccessor blockerAccessor = + BLOCKER.getAccessor(iterable.getType()); + IMemberAccessor threadAccessor = + JfrAttributes.EVENT_THREAD.getAccessor(iterable.getType()); + if (blockerAccessor == null || threadAccessor == null) continue; + for (IItem item : iterable) { + if (blockerAccessor.getMember(item).longValue() != blocker) continue; + IMCThread eventThread = threadAccessor.getMember(item); + assertNotNull(eventThread, "TaskBlock eventThread must not be null"); + assertNotEquals(Long.valueOf(logicalThreadId), eventThread.getThreadId(), + "Native TaskBlock must identify the physical carrier, not the virtual thread"); + checked++; + } + } + assertTrue(checked > 0, "Expected TaskBlock eventThread for blocker=" + blocker); + } + 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/VirtualNativeIoTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java new file mode 100644 index 0000000000..7a44c3f71c --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** Verifies pinned virtual-thread native I/O is attributed to its physical carrier. */ +public class VirtualNativeIoTaskBlockTest extends AbstractProfilerTest { + @Test + public void nativeBlockOnVirtualThreadRemainsPhysicalCarrierTaskBlock() throws Exception { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread thread = startVirtualThread(() -> { + try { + for (int attempt = 0; attempt < 3; attempt++) { + blocker.set(NativeIoBlockHelper.blockingPpoll(200)); + } + } catch (Throwable t) { + error.set(t); + } + }); + + long logicalThreadId = thread.getId(); + thread.join(5_000L); + assertFalse(thread.isAlive(), "virtual thread did not complete"); + if (error.get() != null) throw new AssertionError(error.get()); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertContainsObservedState(events, "IO_WAIT"); + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "VirtualNativeIoTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + TaskBlockAssertions.assertBlockerEventThreadDiffers( + events, blocker.get(), logicalThreadId); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux() && Platform.isJavaVersionAtLeast(21); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallprecheck=true"; + } + + private static Thread startVirtualThread(Runnable task) throws Exception { + Method startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + return (Thread) startVirtualThread.invoke(null, task); + } +} From 550f384e19ebf2a2851de56ff4923794d56d2efe Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:26:49 +0200 Subject: [PATCH 12/15] test: run native I/O producers in all-thread scope --- .../wallclock/JvmtiBasedNativeSocketTaskBlockTest.java | 2 +- .../wallclock/NativeSocketTaskBlockLifecycleTest.java | 6 +++--- .../profiler/wallclock/NativeSocketTaskBlockTest.java | 2 +- .../profiler/wallclock/VirtualNativeIoTaskBlockTest.java | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java index 831e2ce4de..34876a72ee 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java @@ -27,6 +27,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/NativeSocketTaskBlockLifecycleTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java index 0d9ba39920..8a0a2d70cc 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java @@ -53,7 +53,7 @@ public void restartWithWallPrecheckDisabledStopsNativeSocketTaskBlocks() throws Path disabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), "NativeSocketTaskBlockLifecycleTest_disabled_", ".jfr"); try { - profiler.execute("start,wall=1ms,wallprecheck=false,jfr,file=" + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=false,jfr,file=" + disabledRecording.toAbsolutePath()); runNativeIoBlock(); profiler.stop(); @@ -69,7 +69,7 @@ public void restartWithWallPrecheckDisabledStopsNativeSocketTaskBlocks() throws Path reenabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), "NativeSocketTaskBlockLifecycleTest_reenabled_", ".jfr"); try { - profiler.execute("start,wall=1ms,wallprecheck=true,jfr,file=" + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + reenabledRecording.toAbsolutePath()); long reenabledBlocker = runNativeIoBlock(); profiler.stop(); @@ -170,7 +170,7 @@ protected boolean isPlatformSupported() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,wallscope=all,wallprecheck=true"; } private void assertIoWaitTaskBlockPresent(IItemCollection taskBlockEvents, long expectedBlocker) { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java index c934a116d0..aa9a64b630 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java @@ -318,7 +318,7 @@ protected boolean isPlatformSupported() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,wallscope=all,wallprecheck=true"; } private static int selectUntilReady(Selector selector, long timeoutMillis) throws Exception { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java index 7a44c3f71c..89181b863b 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java @@ -53,7 +53,7 @@ protected boolean isPlatformSupported() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + return "wall=1ms,wallscope=all,wallprecheck=true"; } private static Thread startVirtualThread(Runnable task) throws Exception { From 2ab0d8874b128d6396bd6360f74779edb2efd08e Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 09:40:49 +0200 Subject: [PATCH 13/15] fix: skip VDSO parsing under sanitizers --- ddprof-lib/src/main/cpp/symbols_linux.cpp | 5 ++++ .../test/cpp/nativeSocketInterposer_ut.cpp | 26 ++++++++++++------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/ddprof-lib/src/main/cpp/symbols_linux.cpp b/ddprof-lib/src/main/cpp/symbols_linux.cpp index 9df054c06b..3e451cb357 100644 --- a/ddprof-lib/src/main/cpp/symbols_linux.cpp +++ b/ddprof-lib/src/main/cpp/symbols_linux.cpp @@ -1188,7 +1188,12 @@ void Symbols::parseLibraries(CodeCacheArray* array, bool kernel_symbols) { if (strchr(lib.file, ':') != NULL) { // Do not try to parse pseudofiles like anon_inode:name, /memfd:name } else if (strcmp(lib.file, "[vdso]") == 0) { + // A sanitizer build can place the VDSO outside its instrumented + // application range. Reading that mapping then faults in the + // compiler-generated shadow-memory check before the ELF parser runs. +#if !defined(ASAN_ENABLED) && !defined(TSAN_ENABLED) ElfParser::parseProgramHeaders(cc, lib.map_start, lib.map_end, true); +#endif } else if (lib.image_base == NULL) { // Unlikely case when image base has not been found: not safe to access program headers. // Be careful: executable file is not always ELF, e.g. classes.jsa diff --git a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp index 35a17745e6..0466a229cc 100644 --- a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp +++ b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp @@ -32,7 +32,6 @@ #include #include #include -#include #include #include #include @@ -838,15 +837,22 @@ TEST_F(LibraryPatcherImportTest, MissingImportDoesNotConsumePatchSlot) { } TEST(LibraryPatcherDsoLifetimeTest, RetainsPatchedLibraryUntilImportsAreRestored) { - char cwd[PATH_MAX - 64]; - ASSERT_NE(nullptr, getcwd(cwd, sizeof(cwd))); - char path[PATH_MAX]; - snprintf(path, sizeof(path), - "%s/../build/test/resources/native-libs/unloadable-io-lib/" - "libunloadable-io.so", - cwd); - - void* handle = dlopen(path, RTLD_NOW | RTLD_LOCAL); + // Gradle starts gtests from ddprof-lib, while GitLab runs the binaries from + // the repository root. The support library is under the root build directory + // in both cases. + const char* paths[] = { + "build/test/resources/native-libs/unloadable-io-lib/" + "libunloadable-io.so", + "../build/test/resources/native-libs/unloadable-io-lib/" + "libunloadable-io.so", + }; + void* handle = nullptr; + for (const char* path : paths) { + handle = dlopen(path, RTLD_NOW | RTLD_LOCAL); + if (handle != nullptr) { + break; + } + } ASSERT_NE(nullptr, handle) << dlerror(); void* symbol = dlsym(handle, "unloadable_read"); ASSERT_NE(nullptr, symbol) << dlerror(); From 339557840ba9f631696db46d1564b3b942f57025 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 11:32:40 +0200 Subject: [PATCH 14/15] fix: build native fixtures for GTest binaries --- .../main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt index d2196cf8db..2b11eda2d8 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestPlugin.kt @@ -1,3 +1,5 @@ +// Copyright 2026, Datadog, Inc. +// SPDX-License-Identifier: Apache-2.0 package com.datadoghq.native.gtest @@ -200,6 +202,11 @@ class GtestPlugin : Plugin { val buildGtestConfigTask = project.tasks.register("buildGtest${config.capitalizedName()}") { group = "build" description = "Compile and link all Google Tests for the ${config.name} build (no run)" + if (extension.buildNativeLibs.get()) { + // CI executes these binaries directly, so the build-only task must also produce + // the native fixtures that the binaries load at runtime. + dependsOn("buildNativeLibs") + } } // Compile all library sources ONCE for this config. Each test From 0aadd79a1828447a6307a46b46a2c529aa8a0539 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 17 Jul 2026 12:14:59 +0200 Subject: [PATCH 15/15] fix: compute ELF load bias in integer space --- ddprof-lib/src/main/cpp/libraryPatcher.h | 2 + .../src/main/cpp/libraryPatcher_linux.cpp | 81 +++++++++- ddprof-lib/src/main/cpp/nativeBlock.cpp | 34 ++-- .../src/main/cpp/nativeFdClassifier.cpp | 146 ++++++++++++++---- ddprof-lib/src/main/cpp/nativeFdClassifier.h | 28 +++- ddprof-lib/src/main/cpp/nativeSocketSampler.h | 6 +- ddprof-lib/src/main/cpp/symbols_linux.cpp | 79 +++++----- ddprof-lib/src/test/cpp/nativeBlock_ut.cpp | 41 ++++- .../test/cpp/nativeSocketInterposer_ut.cpp | 77 +++++++++ .../JvmtiBasedNativeSocketTaskBlockTest.java | 2 + .../NativeSocketTaskBlockLifecycleTest.java | 2 + .../wallclock/NativeSocketTaskBlockTest.java | 2 + .../VirtualNativeIoTaskBlockTest.java | 2 + 13 files changed, 399 insertions(+), 103 deletions(-) diff --git a/ddprof-lib/src/main/cpp/libraryPatcher.h b/ddprof-lib/src/main/cpp/libraryPatcher.h index 7bccb65d73..2c1962a85c 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher.h +++ b/ddprof-lib/src/main/cpp/libraryPatcher.h @@ -89,6 +89,8 @@ class LibraryPatcher { bool retain_library = false); static int socket_patch_count_for_test(); static int socket_library_count_for_test(); + static bool socket_patch_target_for_test(const char* path, + const char* jdk_library_directory); #endif // Called after a new library is loaded and the library list is refreshed. // No-op when native I/O hooks are not active. diff --git a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp index 029ffada6e..ce8f500a1f 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp +++ b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp @@ -42,6 +42,71 @@ static_assert(SOCKET_BASE_TABLE_SIZE > 0 && (SOCKET_BASE_TABLE_SIZE & (SOCKET_BASE_TABLE_SIZE - 1)) == 0, "socket DSO lookup table size must be a power of two"); +static const char* library_basename(const char* path) { + if (path == nullptr) { + return nullptr; + } + const char* name = strrchr(path, '/'); + return name == nullptr ? path : name + 1; +} + +static bool is_socket_patch_target(const char* path, + const char* jdk_library_directory) { + if (path == nullptr || jdk_library_directory == nullptr) { + return false; + } + const char* name = library_basename(path); + // libjava contains OpenJDK's post-fork child code, while arbitrary JNI DSOs + // may call async-signal-safe I/O from signal handlers. Limit hooks to the + // JDK libraries that implement Java networking until those contexts can be + // bypassed without locks, TLS access, allocation, or stack capture. + if (strcmp(name, "libnet.so") != 0 && strcmp(name, "libnio.so") != 0) { + return false; + } + + const char* path_separator = strrchr(path, '/'); + if (path_separator == nullptr) { + return false; + } + size_t directory_length = static_cast(path_separator - path); + return strlen(jdk_library_directory) == directory_length + && strncmp(path, jdk_library_directory, directory_length) == 0; +} + +static bool resolve_jdk_library_directory(char* directory) { + CodeCache* java_library = Libraries::instance()->findLibraryByName("libjava.so"); + if (java_library != nullptr && java_library->name() != nullptr && + strcmp(library_basename(java_library->name()), "libjava.so") == 0 && + realpath(java_library->name(), directory) != nullptr) { + char* separator = strrchr(directory, '/'); + if (separator != nullptr) { + *separator = '\0'; + return true; + } + } + + // libjava may be loaded lazily. Supported JDK layouts place libjvm in a + // subdirectory immediately below the directory containing the other native + // JDK libraries. + CodeCache* jvm_library = Libraries::instance()->findLibraryByName("libjvm.so"); + if (jvm_library == nullptr || jvm_library->name() == nullptr || + strcmp(library_basename(jvm_library->name()), "libjvm.so") != 0 || + realpath(jvm_library->name(), directory) == nullptr) { + return false; + } + char* separator = strrchr(directory, '/'); + if (separator == nullptr) { + return false; + } + *separator = '\0'; + separator = strrchr(directory, '/'); + if (separator == nullptr) { + return false; + } + *separator = '\0'; + return true; +} + bool LibraryPatcher::socket_library_patched_unlocked(const void* image_base) { size_t slot = (reinterpret_cast(image_base) >> 12) & (SOCKET_BASE_TABLE_SIZE - 1); @@ -674,6 +739,11 @@ bool LibraryPatcher::patch_socket_functions(bool require_active) { int num_of_libs = native_libs.count(); int capped = num_of_libs <= MAX_NATIVE_LIBS ? num_of_libs : MAX_NATIVE_LIBS; + char jdk_library_directory[PATH_MAX]; + if (!resolve_jdk_library_directory(jdk_library_directory)) { + Log::warn("native I/O hooks disabled: cannot locate the JDK native library directory"); + return disable_and_unpatch(); + } std::vector candidates; try { candidates.reserve(capped); @@ -689,7 +759,11 @@ bool LibraryPatcher::patch_socket_functions(bool require_active) { } char path[PATH_MAX]; char* resolved_path = realpath(lib->name(), path); - if (_profiler_name != nullptr && resolved_path != nullptr && + if (resolved_path == nullptr || + !is_socket_patch_target(resolved_path, jdk_library_directory)) { + continue; + } + if (_profiler_name != nullptr && strcmp(resolved_path, _profiler_name) == 0) { continue; } @@ -875,6 +949,11 @@ int LibraryPatcher::socket_library_count_for_test() { ExclusiveLockGuard locker(&_lock); return static_cast(_socket_libraries.size()); } + +bool LibraryPatcher::socket_patch_target_for_test( + const char* path, const char* jdk_library_directory) { + return is_socket_patch_target(path, jdk_library_directory); +} #endif void LibraryPatcher::unpatch_socket_functions_unlocked( diff --git a/ddprof-lib/src/main/cpp/nativeBlock.cpp b/ddprof-lib/src/main/cpp/nativeBlock.cpp index 962d839bdf..56798bb006 100644 --- a/ddprof-lib/src/main/cpp/nativeBlock.cpp +++ b/ddprof-lib/src/main/cpp/nativeBlock.cpp @@ -52,7 +52,7 @@ NativeBlockScope::NativeBlockScope(NativeBlockKind kind, int blocker_id, return; } - ProfiledThread* current = ProfiledThread::currentSignalSafe(); + ProfiledThread* current = ProfiledThread::current(); if (current == nullptr || current->threadType() != ProfiledThread::TYPE_JAVA_THREAD) { errno = saved_errno; return; @@ -105,35 +105,27 @@ void NativeBlockScope::finish(u64 end_ticks) { _active = false; Profiler* profiler = Profiler::instance(); - bool recording_enabled = profiler->taskBlockEnabled(); - bool activity = profiler->tryEnterTaskBlockActivity(); - if (!activity) { - profiler->waitForTaskBlockRotation(); - } ThreadFilter* thread_filter = profiler->threadFilter(); + bool recording_enabled = + profiler->taskBlockEnabled() && thread_filter->registryActive(); + bool activity = profiler->tryEnterTaskBlockActivity(); BlockRunSnapshot snapshot{}; - snapshot.active_state = _state; - snapshot.owner = BlockRunOwner::NATIVE; - if (!thread_filter->registryActive() || - !thread_filter->snapshotAndExitBlockedRun(_slot_id, _generation, &snapshot)) { - if (activity) { - profiler->leaveTaskBlockActivity(); - } else { - Counters::increment(TASK_BLOCK_DROPPED_ROTATION); - } - return; - } + bool exited = thread_filter->snapshotAndExitBlockedRun( + _slot_id, _generation, &snapshot); if (!activity) { Counters::increment(TASK_BLOCK_DROPPED_ROTATION); return; } - if (recording_enabled) { - recordTaskBlockIfEligible(_tid, nullptr, 0, _start_ticks, end_ticks, - _context, _blocker, 0, - snapshot.active_state, true); + if (!recording_enabled || !exited) { + profiler->leaveTaskBlockActivity(); + return; } + + recordTaskBlockIfEligible(_tid, nullptr, 0, _start_ticks, end_ticks, + _context, _blocker, 0, + snapshot.active_state, true); profiler->leaveTaskBlockActivity(); } diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp index b6549629f1..43b17bdd41 100644 --- a/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp @@ -71,33 +71,77 @@ uint8_t NativeFdClassifier::probeFdType(int fd) { return errno == ENOTSOCK ? FD_TYPE_NON_SOCKET : 0; } -void NativeFdClassifier::cacheFdType(int fd, uint8_t type, uint32_t gen) { +uint64_t NativeFdClassifier::fdEntry(uint32_t epoch, uint64_t incarnation, + uint8_t type) { + return (static_cast(epoch) << FD_TYPE_EPOCH_SHIFT) + | ((incarnation & FD_TYPE_INCARNATION_MASK) + << FD_TYPE_INCARNATION_SHIFT) + | static_cast(type); +} + +uint32_t NativeFdClassifier::fdEntryEpoch(uint64_t entry) { + return static_cast(entry >> FD_TYPE_EPOCH_SHIFT); +} + +uint64_t NativeFdClassifier::fdEntryIncarnation(uint64_t entry) { + return (entry >> FD_TYPE_INCARNATION_SHIFT) & FD_TYPE_INCARNATION_MASK; +} + +void NativeFdClassifier::cacheFdType(int fd, uint8_t type) { if (fd < 0 || type == 0) { return; } if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { - _fd_type_cache[fd].store((gen << FD_TYPE_GEN_SHIFT) | type, - std::memory_order_release); + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); + uint64_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); + uint64_t desired = fdEntry(epoch, fdEntryIncarnation(cached), type); + _fd_type_cache[fd].compare_exchange_strong( + cached, desired, std::memory_order_acq_rel, std::memory_order_acquire); } else { - _high_fd_type_cache[highFdCacheIndex(fd)].store(highFdEntry(fd, gen, type), - std::memory_order_release); + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); + int index = highFdCacheIndex(fd); + uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); + uint64_t desired = highFdEntry(fd, epoch, highFdEntryIncarnation(cached), + type); + _high_fd_type_cache[index].compare_exchange_strong( + cached, desired, std::memory_order_acq_rel, std::memory_order_acquire); } } -uint64_t NativeFdClassifier::highFdEntry(int fd, uint32_t gen, uint8_t type) { - return (static_cast(static_cast(fd)) << 32) - | (static_cast(gen & FD_TYPE_GEN_MASK) << FD_TYPE_GEN_SHIFT) +uint64_t NativeFdClassifier::highFdEntry(int fd, uint32_t epoch, + uint64_t incarnation, uint8_t type) { + // The direct-mapped index carries the low 12 fd bits. The stored quotient + // carries the remaining 19 bits of a non-negative int fd, leaving room for + // a 17-bit cache epoch and a 24-bit slot incarnation in one atomic word. + uint64_t fd_tag = static_cast(fd) / + static_cast(HIGH_FD_TYPE_CACHE_SIZE); + return (fd_tag << HIGH_FD_TAG_SHIFT) + | (static_cast(epoch & HIGH_FD_EPOCH_MASK) + << HIGH_FD_EPOCH_SHIFT) + | ((incarnation & HIGH_FD_INCARNATION_MASK) + << FD_TYPE_INCARNATION_SHIFT) | static_cast(type); } -bool NativeFdClassifier::highFdEntryMatches(uint64_t entry, int fd, uint32_t gen) { +uint32_t NativeFdClassifier::highFdEntryEpoch(uint64_t entry) { + return static_cast((entry >> HIGH_FD_EPOCH_SHIFT) + & HIGH_FD_EPOCH_MASK); +} + +uint64_t NativeFdClassifier::highFdEntryIncarnation(uint64_t entry) { + return (entry >> FD_TYPE_INCARNATION_SHIFT) & HIGH_FD_INCARNATION_MASK; +} + +bool NativeFdClassifier::highFdEntryMatches(uint64_t entry, int fd, + uint32_t epoch) { return highFdEntryMatchesFd(entry, fd) - && (((entry >> FD_TYPE_GEN_SHIFT) & FD_TYPE_GEN_MASK) - == (gen & FD_TYPE_GEN_MASK)); + && highFdEntryEpoch(entry) == (epoch & HIGH_FD_EPOCH_MASK); } bool NativeFdClassifier::highFdEntryMatchesFd(uint64_t entry, int fd) { - return static_cast(entry >> 32) == static_cast(fd); + uint64_t fd_tag = static_cast(fd) / + static_cast(HIGH_FD_TYPE_CACHE_SIZE); + return (entry >> HIGH_FD_TAG_SHIFT) == fd_tag; } int NativeFdClassifier::highFdCacheIndex(int fd) { @@ -105,10 +149,11 @@ int NativeFdClassifier::highFdCacheIndex(int fd) { static_cast(HIGH_FD_TYPE_CACHE_SIZE)); } -uint8_t NativeFdClassifier::highFdType(int fd, uint32_t gen) { +uint8_t NativeFdClassifier::highFdType(int fd) { + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); int index = highFdCacheIndex(fd); uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); - if (highFdEntryMatches(cached, fd, gen)) { + if (highFdEntryMatches(cached, fd, epoch)) { uint8_t type = static_cast(cached & FD_TYPE_MASK); if (type != 0) { return type; @@ -119,8 +164,20 @@ uint8_t NativeFdClassifier::highFdType(int fd, uint32_t gen) { // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: // the same fd number may later be reused for a socket. if (type != 0) { - _high_fd_type_cache[index].store(highFdEntry(fd, gen, type), - std::memory_order_release); + uint64_t desired = highFdEntry(fd, epoch, + highFdEntryIncarnation(cached), type); + if (_high_fd_type_cache[index].compare_exchange_strong( + cached, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return (_fd_cache_gen.load(std::memory_order_acquire) + & HIGH_FD_EPOCH_MASK) == (epoch & HIGH_FD_EPOCH_MASK) + ? type : 0; + } + uint32_t current_epoch = _fd_cache_gen.load(std::memory_order_acquire); + if (highFdEntryMatches(cached, fd, current_epoch)) { + return static_cast(cached & FD_TYPE_MASK); + } + return 0; } return type; } @@ -130,13 +187,13 @@ uint8_t NativeFdClassifier::fdType(int fd) { return 0; } - uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); if (static_cast(fd) >= static_cast(FD_TYPE_CACHE_SIZE)) { - return highFdType(fd, gen); + return highFdType(fd); } - uint32_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); - if ((cached >> FD_TYPE_GEN_SHIFT) == gen) { + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); + uint64_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); + if (fdEntryEpoch(cached) == epoch) { uint8_t type = static_cast(cached & FD_TYPE_MASK); if (type != 0) { return type; @@ -147,7 +204,17 @@ uint8_t NativeFdClassifier::fdType(int fd) { // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: // the same fd number may later be reused for a socket. if (type != 0) { - cacheFdType(fd, type, gen); + uint64_t desired = fdEntry(epoch, fdEntryIncarnation(cached), type); + if (_fd_type_cache[fd].compare_exchange_strong( + cached, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return _fd_cache_gen.load(std::memory_order_acquire) == epoch ? type : 0; + } + uint32_t current_epoch = _fd_cache_gen.load(std::memory_order_acquire); + if (fdEntryEpoch(cached) == current_epoch) { + return static_cast(cached & FD_TYPE_MASK); + } + return 0; } return type; } @@ -164,18 +231,33 @@ void NativeFdClassifier::cacheNonSocket(int fd) { if (fd < 0) { return; } - uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); - cacheFdType(fd, FD_TYPE_NON_SOCKET, gen); + cacheFdType(fd, FD_TYPE_NON_SOCKET); } void NativeFdClassifier::clearHighFdType(int fd) { int index = highFdCacheIndex(fd); + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); - while (highFdEntryMatchesFd(cached, fd)) { + for (;;) { + // Advance the direct-mapped slot even when it currently holds a colliding + // fd. This makes every probe that started before this lifecycle event lose + // its publication CAS without unnecessarily evicting the colliding type. + uint64_t incarnation = highFdEntryIncarnation(cached) + 1; + bool current_entry = highFdEntryEpoch(cached) == + (epoch & HIGH_FD_EPOCH_MASK); + int cached_fd = current_entry + ? static_cast((cached >> HIGH_FD_TAG_SHIFT) * + HIGH_FD_TYPE_CACHE_SIZE + index) + : fd; + uint8_t type = current_entry && cached_fd != fd + ? static_cast(cached & FD_TYPE_MASK) : 0; + uint64_t desired = highFdEntry(cached_fd, epoch, incarnation, type); if (_high_fd_type_cache[index].compare_exchange_weak( - cached, 0, std::memory_order_acq_rel, std::memory_order_acquire)) { + cached, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) { return; } + epoch = _fd_cache_gen.load(std::memory_order_acquire); } } @@ -184,14 +266,24 @@ void NativeFdClassifier::clearFdType(int fd) { return; } if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { - _fd_type_cache[fd].store(0, std::memory_order_release); + uint32_t epoch = _fd_cache_gen.load(std::memory_order_acquire); + uint64_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); + for (;;) { + uint64_t desired = fdEntry(epoch, fdEntryIncarnation(cached) + 1, 0); + if (_fd_type_cache[fd].compare_exchange_weak( + cached, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return; + } + epoch = _fd_cache_gen.load(std::memory_order_acquire); + } } else { clearHighFdType(fd); } } void NativeFdClassifier::clearFdTypeCache() { - _fd_cache_gen.fetch_add(1, std::memory_order_release); + _fd_cache_gen.fetch_add(1, std::memory_order_acq_rel); } #endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.h b/ddprof-lib/src/main/cpp/nativeFdClassifier.h index 7523da76ea..cdcafd2986 100644 --- a/ddprof-lib/src/main/cpp/nativeFdClassifier.h +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.h @@ -31,25 +31,39 @@ class NativeFdClassifier { private: static const int FD_TYPE_CACHE_SIZE = 65536; static const int HIGH_FD_TYPE_CACHE_SIZE = 4096; - static const uint32_t FD_TYPE_MASK = 0xf; - static const uint32_t FD_TYPE_GEN_SHIFT = 4; - static const uint32_t FD_TYPE_GEN_MASK = 0x0fffffff; + static const uint64_t FD_TYPE_MASK = 0xf; + static const int FD_TYPE_INCARNATION_SHIFT = 4; + static const uint64_t FD_TYPE_INCARNATION_MASK = 0x0fffffff; + static const int FD_TYPE_EPOCH_SHIFT = 32; + static const uint64_t HIGH_FD_INCARNATION_MASK = 0x00ffffff; + static const int HIGH_FD_EPOCH_SHIFT = 28; + static const uint64_t HIGH_FD_EPOCH_MASK = 0x1ffff; + static const int HIGH_FD_TAG_SHIFT = 45; static const uint8_t FD_TYPE_STREAM_SOCKET = 1; static const uint8_t FD_TYPE_DATAGRAM_SOCKET = 2; static const uint8_t FD_TYPE_OTHER_SOCKET = 3; static const uint8_t FD_TYPE_NON_SOCKET = 4; + // A low-fd entry atomically couples its cached type to both the profiler + // cache epoch and that fd's lifecycle incarnation. A probe may publish only + // if close/dup has not changed the entry it observed before getsockopt(). std::atomic _fd_cache_gen{1}; - std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; + std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; std::atomic _high_fd_type_cache[HIGH_FD_TYPE_CACHE_SIZE]; static uint8_t probeFdType(int fd); - static uint64_t highFdEntry(int fd, uint32_t gen, uint8_t type); + static uint64_t fdEntry(uint32_t epoch, uint64_t incarnation, uint8_t type); + static uint32_t fdEntryEpoch(uint64_t entry); + static uint64_t fdEntryIncarnation(uint64_t entry); + static uint64_t highFdEntry(int fd, uint32_t epoch, uint64_t incarnation, + uint8_t type); + static uint32_t highFdEntryEpoch(uint64_t entry); + static uint64_t highFdEntryIncarnation(uint64_t entry); static bool highFdEntryMatches(uint64_t entry, int fd, uint32_t gen); static bool highFdEntryMatchesFd(uint64_t entry, int fd); static int highFdCacheIndex(int fd); - void cacheFdType(int fd, uint8_t type, uint32_t gen); - uint8_t highFdType(int fd, uint32_t gen); + void cacheFdType(int fd, uint8_t type); + uint8_t highFdType(int fd); void clearHighFdType(int fd); uint8_t fdType(int fd); diff --git a/ddprof-lib/src/main/cpp/nativeSocketSampler.h b/ddprof-lib/src/main/cpp/nativeSocketSampler.h index d18bebc88f..85a3eac6a6 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.h +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.h @@ -26,9 +26,9 @@ class LibraryPatcher; // Synchronisation strategy // ------------------------- -// Hook functions (send_hook / recv_hook / write_hook / read_hook) run on the -// calling Java thread, NOT in a signal handler. Therefore malloc and locking -// are safe inside hooks. +// Hook functions (send_hook / recv_hook / write_hook / read_hook) are installed +// only in the JDK's libnet and libnio DSOs. This excludes the libjava fork/exec +// child path and arbitrary JNI signal handlers from code that locks or allocates. // // fd-to-addr cache : guarded by _fd_cache_mutex (std::mutex). // TOCTOU note: the cache is checked under lock, then diff --git a/ddprof-lib/src/main/cpp/symbols_linux.cpp b/ddprof-lib/src/main/cpp/symbols_linux.cpp index 3e451cb357..9da8470c33 100644 --- a/ddprof-lib/src/main/cpp/symbols_linux.cpp +++ b/ddprof-lib/src/main/cpp/symbols_linux.cpp @@ -349,7 +349,7 @@ class ElfParser { bool _relocate_dyn; ElfHeader* _header; const char* _sections; - const char* _vaddr_diff; + uintptr_t _load_bias; const char* _image_end; // one-past-the-end of the mapped ELF image; bounds file-relative reads ElfParser(CodeCache* cc, const char* base, const void* addr, size_t image_size, const char* file_name, bool relocate_dyn) { @@ -358,6 +358,7 @@ class ElfParser { _file_name = file_name; _relocate_dyn = relocate_dyn; _header = (ElfHeader*)addr; + _load_bias = 0; _image_end = (const char*)addr + image_size; // e_shoff sits at a fixed offset inside the header; only compute the pointer // when the image is at least header-sized AND e_shoff is within the image, @@ -451,25 +452,34 @@ class ElfParser { return inImage(ph, sizeof(ElfProgramHeader)) ? ph : NULL; } - const char* at(ElfProgramHeader* pheader) { - if (_header->e_type == ET_EXEC) { - return (const char*)pheader->p_vaddr; + const char* addressAt(uint64_t virtual_address, size_t extra_offset = 0) const { + if (virtual_address > UINTPTR_MAX) { + return NULL; + } + uintptr_t offset = (uintptr_t)virtual_address; + if (extra_offset > UINTPTR_MAX - offset) { + return NULL; } - return _vaddr_diff == NULL ? (const char*)pheader->p_vaddr : _vaddr_diff + pheader->p_vaddr; + offset += extra_offset; + + uintptr_t load_bias = _header->e_type == ET_EXEC ? 0 : _load_bias; + if (load_bias > UINTPTR_MAX - offset) { + return NULL; + } + return (const char*)(load_bias + offset); } - const char* base() { - return _header->e_type == ET_EXEC ? NULL : _vaddr_diff; + const char* at(ElfProgramHeader* pheader) { + return addressAt(pheader->p_vaddr); } char* dyn_ptr(ElfDyn* dyn) { // GNU dynamic linker relocates pointers in the dynamic section, while musl doesn't. // Also, [vdso] is not relocated, and its vaddr may differ from the load address. - if (_relocate_dyn || (_base != NULL && (char*)dyn->d_un.d_ptr < _base)) { - return _vaddr_diff == NULL ? (char*)dyn->d_un.d_ptr : (char*)_vaddr_diff + dyn->d_un.d_ptr; - } else { - return (char*)dyn->d_un.d_ptr; + if (_relocate_dyn || (_base != NULL && dyn->d_un.d_ptr < (uintptr_t)_base)) { + return (char*)addressAt(dyn->d_un.d_ptr); } + return dyn->d_un.d_ptr <= UINTPTR_MAX ? (char*)(uintptr_t)dyn->d_un.d_ptr : NULL; } ElfSection* findSection(uint32_t type, const char* name); @@ -570,17 +580,18 @@ void ElfParser::parseProgramHeaders(CodeCache* cc, const char* base, const char* void ElfParser::calcVirtualLoadAddress() { // Find a difference between the virtual load address (often zero) and the actual DSO base if (_base == NULL) { - _vaddr_diff = NULL; + _load_bias = 0; return; } for (int i = 0; i < _header->e_phnum; i++) { ElfProgramHeader* pheader = phdrAt(i); if (pheader != NULL && pheader->p_type == PT_LOAD) { - _vaddr_diff = _base - pheader->p_vaddr; + // p_vaddr is an ELF integer address, not an offset into the C++ object at _base. + _load_bias = (uintptr_t)_base - (uintptr_t)pheader->p_vaddr; return; } } - _vaddr_diff = _base; + _load_bias = (uintptr_t)_base; } void ElfParser::parseDynamicSection() { @@ -666,7 +677,6 @@ void ElfParser::parseDynamicSection() { loadSymbolTable(symtab, syment * nsyms, syment, strtab, strsz); } - const char* base = this->base(); if (jmprel != NULL && pltrelsz != 0) { // Parse .rela.plt table for (size_t offs = 0; offs < pltrelsz; offs += relent) { @@ -675,7 +685,10 @@ void ElfParser::parseDynamicSection() { if (sym->st_name != 0) { const char* sym_name = strAt(strtab, strsz, sym->st_name); if (sym_name != NULL) { - _cc->addImport((void**)(base + r->r_offset), sym_name); + const char* location = addressAt(r->r_offset); + if (location != NULL) { + _cc->addImport((void**)location, sym_name); + } } } } @@ -692,7 +705,10 @@ void ElfParser::parseDynamicSection() { if (sym->st_name != 0) { const char* sym_name = strAt(strtab, strsz, sym->st_name); if (sym_name != NULL) { - _cc->addImport((void**)(base + r->r_offset), sym_name); + const char* location = addressAt(r->r_offset); + if (location != NULL) { + _cc->addImport((void**)location, sym_name); + } } } } @@ -793,7 +809,10 @@ void ElfParser::loadSymbols(bool use_debug) { _cc->setPlt(plt->sh_addr, plt->sh_size); ElfSection* reltab = findSection(SHT_RELA, ".rela.plt"); if (reltab != NULL || (reltab = findSection(SHT_REL, ".rel.plt")) != NULL) { - addRelocationSymbols(reltab, base() + plt->sh_addr + PLT_HEADER_SIZE); + const char* plt_address = addressAt(plt->sh_addr, PLT_HEADER_SIZE); + if (plt_address != NULL) { + addRelocationSymbols(reltab, plt_address); + } } } } @@ -943,7 +962,6 @@ void ElfParser::loadSymbolTable(const char* symbols, size_t total_size, size_t e if (ent_size < sizeof(ElfSymbol)) { return; } - const char* base = this->base(); // Iterate by a size_t offset rather than incrementing the pointer: a huge // attacker-controlled ent_size would otherwise overflow `symbols + ent_size` // to a small pointer that still compares <= end, walking off the image. The @@ -964,26 +982,9 @@ void ElfParser::loadSymbolTable(const char* symbols, size_t total_size, size_t e } // Skip special AArch64 mapping symbols: $x and $d if (sym.st_size != 0 || sym.st_info != 0 || sym_name[0] != '$') { - const char* addr; - if (base != NULL) { - // Check for overflow when adding sym.st_value to base - uintptr_t base_addr = (uintptr_t)base; - uint64_t symbol_value = sym.st_value; - - // Skip this symbol if addition would overflow - // First check if symbol_value exceeds the address space - if (symbol_value > UINTPTR_MAX) { - continue; - } - // Then check if addition would overflow - if (base_addr > UINTPTR_MAX - (uintptr_t)symbol_value) { - continue; - } - - // Perform addition using integer arithmetic to avoid pointer overflow - addr = (const char*)(base_addr + (uintptr_t)symbol_value); - } else { - addr = (const char*)sym.st_value; + const char* addr = addressAt(sym.st_value); + if (addr == NULL) { + continue; } _cc->add(addr, (int)sym.st_size, sym_name); } diff --git a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp index d175a4b212..789ad1b462 100644 --- a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp +++ b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp @@ -15,7 +15,9 @@ #include "tsc.h" #include +#include #include +#include #include namespace { @@ -103,7 +105,7 @@ class NativeBlockScopeTest : public ::testing::Test { } void TearDown() override { - if (ProfiledThread::currentSignalSafe() != nullptr) { + if (ProfiledThread::current() != nullptr) { ProfiledThread::release(); } Profiler::setTaskBlockRecordOverrideForTest(nullptr); @@ -281,7 +283,7 @@ TEST_F(NativeBlockScopeTest, FinishAfterTaskBlockDisableExitsWithoutRecording) { EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); } -TEST_F(NativeBlockScopeTest, FinishSkipsAndLeavesSlotOwnedWhenFilterDisabledBeforeExit) { +TEST_F(NativeBlockScopeTest, FinishAfterFilterDisableExitsWithoutRecording) { CurrentThreadScope current; ScopedTaskBlockEnabled task_block_enabled(true); int slot_id = registerCurrentJavaThread(current.thread()); @@ -296,8 +298,37 @@ TEST_F(NativeBlockScopeTest, FinishSkipsAndLeavesSlotOwnedWhenFilterDisabledBefo ThreadFilter::Slot* slot = filter->slotForId(slot_id); ASSERT_NE(nullptr, slot); - EXPECT_EQ(BlockRunOwner::NATIVE, slot->activeBlockOwner()); - EXPECT_EQ(OSThreadState::IO_WAIT, slot->activeBlockState()); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(0, g_record_calls.load(std::memory_order_relaxed)); +} + +TEST_F(NativeBlockScopeTest, RotationRejectsFinishWithoutBlockingOrStranding) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + + Profiler* profiler = Profiler::instance(); + profiler->beginTaskBlockRotationForTest(); + std::future result = std::async(std::launch::async, [&]() { + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + }); + + std::future_status status = result.wait_for(std::chrono::seconds(1)); + profiler->endTaskBlockRotationForTest(); + ASSERT_EQ(std::future_status::ready, status); + result.get(); + + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(0, g_record_calls.load(std::memory_order_relaxed)); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_DROPPED_ROTATION)); } TEST_F(NativeBlockScopeTest, ConcurrentScopeLifecyclePreservesSlotOwnership) { @@ -310,7 +341,7 @@ TEST_F(NativeBlockScopeTest, ConcurrentScopeLifecyclePreservesSlotOwnership) { std::thread observer([&]() { while (!stop.load(std::memory_order_acquire)) { - BlockRunSnapshot snapshot = filter->snapshotBlockedRun(slot_id); + BlockRunSnapshot snapshot = filter->slotForId(slot_id)->snapshotBlockRun(); if (snapshot.active && snapshot.owner != BlockRunOwner::NATIVE) { failures.fetch_add(1, std::memory_order_relaxed); } diff --git a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp index 0466a229cc..94f48ba9a8 100644 --- a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp +++ b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp @@ -74,6 +74,8 @@ std::atomic g_fd_probe_rc{0}; std::atomic g_fd_probe_errno{0}; std::atomic g_fd_probe_so_type{0}; std::atomic g_fd_probe_last_fd{0}; +std::atomic g_blocking_probe_started{false}; +std::atomic g_release_blocking_probe{false}; std::atomic g_sequence{0}; std::atomic g_raw_syscall_sequence{0}; std::atomic g_taskblock_enter_sequence{0}; @@ -121,6 +123,16 @@ int recording_fd_probe(int fd, int *so_type, int *probe_errno) { return stub_fd_probe(fd, so_type, probe_errno); } +int blocking_stream_fd_probe(int, int *so_type, int *probe_errno) { + g_blocking_probe_started.store(true, std::memory_order_release); + while (!g_release_blocking_probe.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + *so_type = SOCK_STREAM; + *probe_errno = 0; + return 0; +} + class ScopedFdProbeOverride { public: explicit ScopedFdProbeOverride(NativeFdClassifier::ProbeOverride probe) { @@ -790,6 +802,32 @@ TEST(LibraryPatcherSocketStateTest, EXPECT_EQ(0, LibraryPatcher::socket_library_count_for_test()); } +TEST(LibraryPatcherSocketStateTest, + RestrictsSocketHooksToJdkNetworkingLibraries) { + EXPECT_TRUE(LibraryPatcher::socket_patch_target_for_test( + "/usr/lib/jvm/java-21/lib/libnet.so", + "/usr/lib/jvm/java-21/lib")); + EXPECT_TRUE(LibraryPatcher::socket_patch_target_for_test( + "/usr/lib/jvm/java-21/lib/libnio.so", + "/usr/lib/jvm/java-21/lib")); + + // libjava owns OpenJDK's fork/exec child path. Arbitrary JNI libraries may + // issue I/O from signal handlers, so neither category may receive hooks. + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + "/usr/lib/jvm/java-21/lib/libjava.so", + "/usr/lib/jvm/java-21/lib")); + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + "/opt/app/libnative-plugin.so", "/usr/lib/jvm/java-21/lib")); + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + "/opt/app/libnet.so.backup", "/usr/lib/jvm/java-21/lib")); + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + "/opt/app/libnet.so", "/usr/lib/jvm/java-21/lib")); + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + nullptr, "/usr/lib/jvm/java-21/lib")); + EXPECT_FALSE(LibraryPatcher::socket_patch_target_for_test( + "/usr/lib/jvm/java-21/lib/libnet.so", nullptr)); +} + TEST_F(LibraryPatcherImportTest, PatchesAndRestoresEveryImportLocation) { initializeImports(3); void* originals[3] = {imports[0], imports[1], imports[2]}; @@ -1737,6 +1775,45 @@ TEST_F(NativeSocketInterposerFdTest, ConcurrentFdReuseInvalidationDoesNotPreserv } } +TEST_F(NativeSocketInterposerFdTest, + ProbeStraddlingCloseAndReuseCannotPublishStaleStreamType) { + int stream_fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, stream_fds)); + int pipe_fds[2]; + ASSERT_EQ(0, pipe(pipe_fds)); + int reused_fd = stream_fds[0]; + + g_blocking_probe_started.store(false, std::memory_order_release); + g_release_blocking_probe.store(false, std::memory_order_release); + std::atomic stale_stream{true}; + { + ScopedFdProbeOverride override(blocking_stream_fd_probe); + std::thread probe([&]() { + stale_stream.store( + NativeSocketInterposer::instance()->isStreamSocket(reused_fd), + std::memory_order_release); + }); + + while (!g_blocking_probe_started.load(std::memory_order_acquire)) { + std::this_thread::yield(); + } + int close_result = closeThroughHook(reused_fd); + int dup_result = dup2ThroughHook(pipe_fds[0], reused_fd); + g_release_blocking_probe.store(true, std::memory_order_release); + probe.join(); + ASSERT_EQ(0, close_result); + ASSERT_EQ(reused_fd, dup_result); + } + + EXPECT_FALSE(stale_stream.load(std::memory_order_acquire)); + EXPECT_FALSE(NativeSocketInterposer::instance()->isStreamSocket(reused_fd)); + + ASSERT_EQ(0, closeThroughHook(reused_fd)); + ASSERT_EQ(0, closeThroughHook(stream_fds[1])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[0])); + ASSERT_EQ(0, closeThroughHook(pipe_fds[1])); +} + TEST_F(NativeSocketInterposerFdTest, RejectsNonSockets) { int fds[2]; ASSERT_EQ(0, pipe(fds)); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java index 34876a72ee..209bde2431 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.java @@ -7,10 +7,12 @@ import com.datadoghq.profiler.Platform; import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Disabled; import java.util.Map; /** Verifies synchronous native-I/O production when delegated wall-clock stacks are enabled. */ +@Disabled("Native TaskBlock coverage requires broader DSO interposition than is currently safe") public class JvmtiBasedNativeSocketTaskBlockTest extends NativeSocketTaskBlockTest { @Override protected void before() throws Exception { diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java index 8a0a2d70cc..35667a4524 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java @@ -8,6 +8,7 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.Platform; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openjdk.jmc.common.item.IItemCollection; @@ -27,6 +28,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** Verifies native I/O hooks are fully restored and can be reinstalled across profiler restarts. */ +@Disabled("Arbitrary JNI DSO interposition is disabled until hooks are safe after fork and in signal handlers") public class NativeSocketTaskBlockLifecycleTest extends AbstractProfilerTest { private static final int BLOCK_HOLD_MILLIS = 250; private static final int NATIVE_BLOCK_ATTEMPTS = 5; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java index aa9a64b630..99b2703e61 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java @@ -8,6 +8,7 @@ import com.datadoghq.profiler.AbstractProfilerTest; import com.datadoghq.profiler.Platform; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openjdk.jmc.common.item.IItemCollection; @@ -33,6 +34,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** Verifies Linux native socket and readiness waits produce self-contained TaskBlock events. */ +@Disabled("Native TaskBlock coverage requires broader DSO interposition than is currently safe") public class NativeSocketTaskBlockTest extends AbstractProfilerTest { private static final int BLOCK_HOLD_MILLIS = 250; private static final int NATIVE_BLOCK_ATTEMPTS = 5; diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java index 89181b863b..3cb72dd521 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java @@ -10,12 +10,14 @@ import java.lang.reflect.Method; import java.util.concurrent.atomic.AtomicLong; import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import org.openjdk.jmc.common.item.IItemCollection; import static org.junit.jupiter.api.Assertions.assertFalse; /** Verifies pinned virtual-thread native I/O is attributed to its physical carrier. */ +@Disabled("Arbitrary JNI DSO interposition is disabled until hooks are safe after fork and in signal handlers") public class VirtualNativeIoTaskBlockTest extends AbstractProfilerTest { @Test public void nativeBlockOnVirtualThreadRemainsPhysicalCarrierTaskBlock() throws Exception {