From bd9563f4b159b4f040e625586bfaff7bd8f996ca Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 15 Jul 2026 11:16:23 +0200 Subject: [PATCH 01/19] 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 | 100 +++++++- 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 | 84 +++++-- ddprof-lib/src/main/cpp/threadFilter.h | 114 +++++---- ddprof-lib/src/main/cpp/threadLocalData.h | 24 +- ddprof-lib/src/main/cpp/wallClock.cpp | 52 ++-- 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 | 27 +-- .../profiler/wallclock/PrecheckTest.java | 73 +++--- .../wallclock/TaskBlockAssertions.java | 126 ++++++++++ .../WallclockMitigationsCombinedTest.java | 8 +- 32 files changed, 1496 insertions(+), 273 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 b101722b58..aae2bc0e17 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -73,14 +73,20 @@ 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_SLOT_ID_RECOVERED, "wc_precheck_slot_id_recovered") \ 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 67ff97b381..ece568fd14 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -58,7 +58,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), @@ -123,13 +123,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; } @@ -168,10 +168,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; } } @@ -182,7 +182,7 @@ class WallClockEpochEvent { void newEpoch(u64 start_time) { _dirty = false; _start_time = start_time; - _num_suppressed_sampled_run = 0; + _num_suppressed_owned_block = 0; } }; @@ -207,4 +207,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 3a2f633d61..a8c1606eb7 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1971,6 +1971,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); @@ -1981,7 +1996,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); } @@ -2244,6 +2259,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 2922f368b2..bf72ca13f1 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -338,6 +338,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, @@ -439,6 +440,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 3206bb2248..d08fe94fc1 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -31,6 +31,7 @@ #include "os.h" #include "otel_process_ctx.h" #include "profiler.h" +#include "taskBlockRecorder.h" #include "threadLocalData.inline.h" #include "tsc.h" #include "vmEntry.h" @@ -477,19 +478,20 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (current == nullptr) { return 0; } - OSThreadState decoded; if (!decodeJavaBlockState(state, decoded)) { 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->registryActive()) { return 0; } + ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + if (slot_id < 0) return 0; return static_cast(tf->enterBlockedRun(slot_id, decoded)); } @@ -516,6 +518,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 4064ba241d..ddbcf22a66 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -176,8 +176,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") @@ -229,6 +229,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 4d3f2a86e9..7df1be6901 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 cde4962e56..6bb48e42fa 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -18,16 +18,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 8d652fed80..99cec357db 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -47,6 +47,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 42ea86100c..30cd0a1631 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -35,6 +35,7 @@ #include "stackFrame.h" #include "stackWalker.h" #include "symbols.h" +#include "taskBlockRecorder.h" #include "threadLocalData.inline.h" #include "tsc.h" #include "utils.h" @@ -49,6 +50,7 @@ #include #include #include +#include #include #include #include @@ -153,6 +155,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); @@ -160,6 +164,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) { @@ -779,6 +788,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) { @@ -1414,6 +1516,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; @@ -1652,6 +1755,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) { @@ -1745,6 +1849,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); @@ -1769,6 +1876,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 @@ -1786,6 +1894,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) @@ -1857,6 +1970,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 @@ -1948,10 +2062,12 @@ Error Profiler::dump(const char *path, const int length) { // dump (fences ASGCT/JNI writers to CallTraceStorage), then clearStandby()s // the rotated buffers. StringDictionary's RefCountGuard protocol handles // its own writer/reader coordination. + 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 a6f8b13abf..fe42323c84 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -132,6 +132,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]; @@ -182,6 +185,8 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); + void beginTaskBlockRotation(); + void endTaskBlockRotation(); // Rotate all three dictionaries, then run jfr_op under lockAll(). // @@ -451,6 +456,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, @@ -474,6 +499,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 f83ecfa8af..acc855563e 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -34,6 +34,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) @@ -606,7 +615,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); } @@ -619,21 +629,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; @@ -646,57 +663,72 @@ 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->blockGeneration() != generation) { + 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::shouldSuppressOwnedBlock(const ThreadEntry& entry) const { +bool ThreadFilter::snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, + BlockRunSnapshot* snapshot) { + Slot* s = slotForId(slot_id); + if (s == nullptr || generation == 0 || + s->activeBlockState() == OSThreadState::UNKNOWN || + s->activeBlockOwner() == BlockRunOwner::NONE || + s->blockGeneration() != generation) { + return false; + } + if (snapshot != nullptr) *snapshot = s->snapshotBlockRun(); + s->clearActiveBlockRun(OSThreadState::RUNNABLE); + return true; +} + +BlockRunSnapshot ThreadFilter::snapshotBlockedRun(SlotID slot_id) const { + Slot* s = slotForId(slot_id); + return s == nullptr ? BlockRunSnapshot{} : s->snapshotBlockRun(); +} + +bool ThreadFilter::isOwnedBlockSuppressionCandidate( + const ThreadEntry& entry) const { Slot* slot = entry.slot; if (slot == nullptr || slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation) { 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; } } + u64 block_generation = slot->blockGeneration(); + BlockRunOwner owner = slot->activeBlockOwner(); + OSThreadState state = slot->activeBlockState(); + bool suppressible_state = isPrecheckSuppressionState(state); + if (owner == BlockRunOwner::NONE || !suppressible_state) 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 = isPrecheckSuppressionState(state); - 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->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 b641a20261..7b6f80cc46 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); @@ -143,21 +148,6 @@ class ThreadFilter { return true; } - 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); } @@ -167,7 +157,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() { @@ -207,9 +197,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; @@ -226,18 +216,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 { @@ -246,12 +243,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"); @@ -287,10 +292,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*); @@ -310,16 +316,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; @@ -337,6 +355,7 @@ class ThreadFilter { Slot* lookupByTid(int tid, RecordingEpoch epoch, SlotID* out_slot_id = nullptr) const; Slot* activeSlotForId(SlotID slot_id, int tid) const; void deactivateRecording(); + SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } private: @@ -372,6 +391,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; PostActiveCheckHook _post_active_check_hook = nullptr; diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 9960d46f41..96ba77eaa5 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -87,6 +87,9 @@ class ProfiledThread : public ThreadLocalData { u32 _recording_epoch; volatile 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) volatile uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) @@ -109,7 +112,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) { @@ -416,6 +421,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 19643ab565..c96ca1f8bb 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -56,8 +56,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; @@ -68,18 +66,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; + if (Profiler::instance()->threadFilter()->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); + return true; } - incrementSuppressedSampledRun(); - return true; + return false; } static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, @@ -112,31 +109,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; } @@ -177,9 +160,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, @@ -552,8 +532,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); diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index fe54d8eccc..b643688f26 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -149,7 +149,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 ecbcbb9a3e..86d0a9c74a 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -394,8 +394,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(); @@ -403,7 +403,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); @@ -411,7 +411,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 @@ -428,6 +428,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 @@ -502,6 +530,11 @@ public boolean isThreadRegistryActiveForTest() { 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 c203d296e3..415609289a 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -38,6 +38,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 22cd50f2bd..2aa4d6a65f 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -524,7 +524,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(); @@ -533,8 +532,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); @@ -582,15 +579,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 { @@ -641,14 +744,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))); } @@ -809,7 +911,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); @@ -818,7 +919,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) { @@ -829,21 +930,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) { @@ -856,10 +956,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) { @@ -869,7 +968,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()}; @@ -889,7 +987,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); @@ -908,9 +1006,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(); @@ -963,10 +1058,9 @@ TEST_F(ThreadRegistryTest, NewUnfilteredRecordingReclaimsRetainedSlot) { 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(); @@ -976,11 +1070,10 @@ TEST_F(ThreadRegistryTest, NewUnfilteredRecordingReclaimsRetainedSlot) { EXPECT_EQ(nullptr, registry.lookupByTid(tid)); EXPECT_EQ(-1, slot->nativeTid()); EXPECT_GT(slot->lifecycleGeneration(), first_lifecycle_generation); - 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 0f150fb773..f697b0e3a8 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 @@ -24,9 +24,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. */ @@ -45,23 +45,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) {} @@ -70,7 +67,6 @@ public void compareSuppressionRates() throws Exception { // RUNNABLE — not skipped Thread working = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); long x = 0; while (!stop.get()) { x++; } @@ -196,10 +192,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; @@ -213,7 +206,6 @@ public void realisticServiceWorkload() throws Exception { Thread.sleep(50); Thread scheduler = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); while (!stop.get()) { try { Thread.sleep(SCHEDULE_INTERVAL_MS); @@ -232,7 +224,6 @@ public void realisticServiceWorkload() throws Exception { scheduler.start(); Thread hotThread = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); long x = 0; while (!stop.get()) { x++; } }, "realistic-hot"); @@ -254,6 +245,12 @@ public void realisticServiceWorkload() throws Exception { long sleepSamples = 0, parkSamples = 0, otherSamples = 0; for (JfrEvent item : events) { + String threadName = item.getThreadName("eventThread"); + if (threadName == null || (!threadName.startsWith("realistic-pool-") + && !"realistic-scheduler".equals(threadName) + && !"realistic-hot".equals(threadName))) { + continue; + } String stack = item.getStackTraceString(); 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 9e27a088cf..cd8f0ba935 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 @@ -22,8 +22,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 { @@ -39,7 +39,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"); @@ -51,17 +50,16 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .count(); - // 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()"); } } @@ -70,16 +68,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) - .count(); - 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 @@ -88,7 +89,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); @@ -110,19 +110,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(); Map countersBefore = profiler.getDebugCounters(); profiler.setTraceContext(0x5100L, 0x5101L, 0L, 0x5101L, -1, null, -1, null); @@ -134,17 +134,16 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .count(); + long sampleCount = samplesForThread(Thread.currentThread().getName()); assertTrue(sampleCount >= 10, "Expected normal MethodSample volume for traced sleep, got: " + sampleCount); - if (countersBefore.containsKey("wc_signals_suppressed_sampled_run")) { - long suppressedBefore = countersBefore.get("wc_signals_suppressed_sampled_run"); + 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_sampled_run", 0L); + .getOrDefault("wc_signals_suppressed_owned_block", 0L); assertEquals(suppressedBefore, suppressedAfter, - "wc_signals_suppressed_sampled_run must not increment for traced sleep"); + "wc_signals_suppressed_owned_block must not increment for traced sleep"); } } @@ -152,16 +151,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"); @@ -171,11 +169,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"); } /** @@ -201,6 +199,17 @@ protected String getPrecheckDisabledProfilerCommand() { return "wall=1ms,wallprecheck=false"; } + private long samplesForThread(String threadName) { + long count = 0; + JfrEvents events = verifyEvents("datadog.MethodSample", false); + for (JfrEvent item : events) { + if (threadName.equals(item.getThreadName("eventThread"))) { + 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 00b51d9ba2..e987248486 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 @@ -21,7 +21,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 { @@ -115,10 +115,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 6089b939d25ca2aaa6b0913afe6c1920e35c4439 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 22:27:13 +0200 Subject: [PATCH 02/19] 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 | 10 ++++--- .../profiler/wallclock/PrecheckTest.java | 12 ++++---- .../WallclockMitigationsCombinedTest.java | 25 +++++++--------- 9 files changed, 65 insertions(+), 50 deletions(-) diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index acc855563e..761adf36b0 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -697,19 +697,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(); @@ -732,9 +729,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 c96ca1f8bb..3ac99a104f 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -108,10 +108,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 2aa4d6a65f..5f655f10e9 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -667,18 +667,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); @@ -687,7 +704,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); @@ -946,7 +964,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); @@ -958,7 +976,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 c8f6a501e7..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,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,filter=,wallprecheck=true,jvmtistacks=true"; } @Override protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,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 f697b0e3a8..52274cf155 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 @@ -293,9 +293,11 @@ public void realisticServiceWorkload() throws Exception { @Override protected String getProfilerCommand() { - // The workload deliberately has no tracing context; it relies on the - // default context-filter scope (filter="0") plus each worker thread - // explicitly registering itself via registerCurrentThreadForWallClockProfiling(). - return "wall=1ms"; + // The workload deliberately has no tracing context, and owned-block + // suppression only applies to the unfiltered wall-clock scope, so + // keep unfiltered wall-clock sampling enabled explicitly plus each + // worker thread explicitly registering itself via + // registerCurrentThreadForWallClockProfiling(). + 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 cd8f0ba935..ca1770d7cf 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 @@ -189,14 +189,16 @@ private void leaveClearedInitializedContext() { @Override protected String getProfilerCommand() { // This suite verifies sampling and suppression for threads outside a - // tracing-context window. It relies on the default context-filter - // scope (filter="0") plus each worker thread explicitly registering - // itself via registerCurrentThreadForWallClockProfiling()/addThread(). - return "wall=1ms,wallprecheck=true"; + // tracing-context window. Owned-block suppression only applies to the + // unfiltered wall-clock scope, so keep that population in scope + // explicitly via filter=, plus each worker thread explicitly + // registering itself via + // registerCurrentThreadForWallClockProfiling()/addThread(). + return "wall=1ms,filter=,wallprecheck=true"; } protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,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 e987248486..77319c13d2 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; @@ -20,15 +21,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), @@ -36,6 +34,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( @@ -106,20 +106,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 a5ae1b5ff156125058dfe9667b045f1872b2de1a Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 16 Jul 2026 23:15:31 +0200 Subject: [PATCH 03/19] fix: preserve JVMTI frames in overlapping buffers --- ddprof-lib/src/main/cpp/profiler.cpp | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 30cd0a1631..952469230a 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -825,11 +825,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 From ee6d17c2dfa7e32d02a8970cd336073533d15df6 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 20 Jul 2026 01:30:56 +0200 Subject: [PATCH 04/19] fix: address sphinx review --- ddprof-lib/src/main/cpp/javaApi.cpp | 54 +++--------- ddprof-lib/src/main/cpp/jvmSupport.cpp | 4 +- ddprof-lib/src/main/cpp/jvmSupport.h | 2 +- ddprof-lib/src/main/cpp/profiler.cpp | 65 +++++++------- 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 | 20 +++-- ddprof-lib/src/main/cpp/threadFilter.h | 2 - .../com/datadoghq/profiler/JavaProfiler.java | 20 ++--- 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/JavaProfilerApiSurfaceTest.java | 2 +- .../JavaProfilerTaskBlockApiTest.java | 25 +++--- .../JavaProfilerTaskBlockDisabledTest.java | 4 +- .../JavaProfilerTaskBlockLightweightTest.java | 86 +++++++++++++++++++ ...rofilerTaskBlockPreExistingThreadTest.java | 76 ++++++++++++++++ .../wallclock/TaskBlockAssertions.java | 14 +++ .../wallclock/UnfilteredWallPrecheckTest.java | 10 ++- 20 files changed, 414 insertions(+), 126 deletions(-) create mode 100644 ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java 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 d08fe94fc1..fecbbd2577 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -520,13 +520,11 @@ 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::current(); + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); Profiler *profiler = Profiler::instance(); if (current == nullptr || !profiler->isRunning() || !profiler->taskBlockEnabled()) { @@ -542,7 +540,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)); @@ -563,46 +562,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 6bb48e42fa..32761db69f 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -21,7 +21,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*), @@ -39,7 +39,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 99cec357db..e8d7a8a46e 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -48,7 +48,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 952469230a..e9763da9e0 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -805,41 +805,44 @@ Profiler::TaskBlockRecordResult Profiler::recordTaskBlock( 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; - } + // Lightweight recordings intentionally encode an absent stack trace as + // constant-pool ID 0, as the regular CPU and wall-clock sample paths do. + if (!_omit_stacktraces) { + if (_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; + 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(); + 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; - } + 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; + } - copyJvmtiFrames(frames, jvmti_frames, num_frames); - u64 call_trace_id = - _call_trace_storage.put(num_frames, frames, false, 1); + copyJvmtiFrames(frames, jvmti_frames, num_frames); + 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); - } + 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; - } + if (call_trace_id == 0) { + _locks[lock_index].unlock(); + return TaskBlockRecordResult::STACK_CAPTURE_FAILED; + } - event->_callTraceId = call_trace_id; + event->_callTraceId = call_trace_id; + } bool recorded = _jfr.recordTaskBlock(lock_index, tid, event); _locks[lock_index].unlock(); return recorded ? TaskBlockRecordResult::RECORDED @@ -860,12 +863,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 fe42323c84..53fae45897 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -472,7 +472,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 761adf36b0..ed6bf1ee67 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -689,11 +689,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; @@ -702,6 +697,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 || @@ -711,9 +717,7 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( u64 block_generation = slot->blockGeneration(); BlockRunOwner owner = slot->activeBlockOwner(); - OSThreadState state = slot->activeBlockState(); - bool suppressible_state = isPrecheckSuppressionState(state); - 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 7b6f80cc46..060652ae5e 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -105,7 +105,6 @@ class ThreadFilter { - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) - - sizeof(std::atomic) - sizeof(std::atomic)]; inline int nativeTid() const { @@ -295,7 +294,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/main/java/com/datadoghq/profiler/JavaProfiler.java b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java index 86d0a9c74a..c521fca2de 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -411,7 +411,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 @@ -429,24 +429,24 @@ void blockExit(long 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)}. + * Begins an explicitly instrumented {@link Thread#sleep(long) sleeping} interval on the current + * platform thread. The resulting {@code TaskBlock} event is classified as {@code SLEEPING}. + * 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} @@ -530,7 +530,7 @@ public boolean isThreadRegistryActiveForTest() { private static native void blockExit0(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/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 415609289a..a4a94809df 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -89,13 +89,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 5f655f10e9..31f47b015e 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -644,10 +644,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); @@ -655,7 +657,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/JavaProfilerApiSurfaceTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java index 37d80c0e5f..4efc30cb1b 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -22,7 +22,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/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/JavaProfilerTaskBlockLightweightTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java new file mode 100644 index 0000000000..5c0ab26af1 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java @@ -0,0 +1,86 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; + +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 stackless TaskBlock events in lightweight mode. */ +public class JavaProfilerTaskBlockLightweightTest extends AbstractProfilerTest { + private static final long BLOCKER = 0x7401L; + private static final long UNBLOCKING_SPAN_ID = 0x7402L; + + @Test + public void suppressedWallSamplesAreReplacedByAStacklessTaskBlock() throws Exception { + CountDownLatch armed = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + AtomicBoolean recorded = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + long suppressedBefore = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + Thread worker = new Thread(() -> { + try { + long token = profiler.beginTaskBlock(); + assertTrue(token != 0, "Expected TaskBlock interval to be armed"); + armed.countDown(); + assertTrue(release.await(5, TimeUnit.SECONDS)); + recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-lightweight"); + + worker.start(); + assertTrue(armed.await(5, TimeUnit.SECONDS)); + waitForCounterAbove( + "wc_signals_suppressed_owned_block", suppressedBefore, 5_000L); + // The periodic signal may arrive less than 1 ms after beginTaskBlock(). + Thread.sleep(10L); + release.countDown(); + worker.join(5_000L); + assertFalse(worker.isAlive()); + if (error.get() != null) throw new AssertionError(error.get()); + assertTrue(recorded.get(), "Expected stackless TaskBlock event to be recorded"); + + stopProfiler(); + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertEquals(1L, events.stream().flatMap(IItemIterable::stream).count()); + TaskBlockAssertions.assertContainsNoStackTrace(events); + TaskBlockAssertions.assertNoAnchorFields(events); + TaskBlockAssertions.assertNoCorrelationId(events); + TaskBlockAssertions.assertContains(events, 0L, 0L, BLOCKER, UNBLOCKING_SPAN_ID); + TaskBlockAssertions.assertContainsObservedState(events, "SLEEPING"); + assertTrue(getRecordedCounterValue("wc_signals_suppressed_owned_block") + > suppressedBefore); + assertEquals(1L, getRecordedCounterValue("task_block_emitted")); + assertEquals(0L, getRecordedCounterValue("task_block_stack_capture_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); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,wallprecheck=true,lightweight=yes"; + } +} 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..8777a6c785 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockPreExistingThreadTest.java @@ -0,0 +1,76 @@ +/* + * 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 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(); + 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/TaskBlockAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java index b0673d1219..2752966cd0 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 @@ -91,6 +91,20 @@ static void assertContainsStackTrace(IItemCollection events) { assertTrue(count > 0, "Expected a TaskBlock with a non-empty stack"); } + static void assertContainsNoStackTrace(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) { + assertNull(accessor.getMember(item)); + count++; + } + } + assertTrue(count > 0, "Expected a TaskBlock without a stack"); + } + static void assertContainsJavaType(IItemCollection events, String expected) { for (IItemIterable iterable : events) { IMemberAccessor accessor = 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 2b7c98fb3a..6402743862 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 @@ -28,14 +28,15 @@ 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 UNOWNED_SUPPRESSED_COUNTER = "wc_unowned_blocked_suppressed"; + 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 */ @@ -123,12 +124,14 @@ public void unownedSleepAfterOwnedBlockUsesNormalSampling() throws Exception { @RetryingTest(3) public void postStartSleepingThreadLazilyRegistersOwnedBlock() throws Exception { String threadName = "unfiltered-precheck-post-start"; + long suppressedBefore = suppressedSignals(); assertTrue( runPostStartSleepingWorker(threadName) != 0, "Expected the owned-block hook to register and arm SLEEPING state"); stopProfiler(); assertSuppressedSamples(threadName); + assertOwnedBlockSuppressionObserved(suppressedBefore); } @Override @@ -249,7 +252,7 @@ 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 long unownedSuppressedSignals() { @@ -258,7 +261,6 @@ private long unownedSuppressedSignals() { 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 2441b5f5a6ea25b9b0106ef29b86b6d1d8b084ed Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 24 Jul 2026 17:57:22 +0200 Subject: [PATCH 05/19] fix(taskblock): preserve unowned wall fallback outside owned blocks --- ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 17 +++++++ ddprof-lib/src/main/cpp/taskBlockRecorder.h | 11 +++++ ddprof-lib/src/main/cpp/threadFilter.cpp | 47 +++++++++++++++++++ ddprof-lib/src/main/cpp/threadFilter.h | 20 +++++++- ddprof-lib/src/main/cpp/wallClock.cpp | 14 +----- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 19 ++++++++ .../wallclock/UnfilteredWallPrecheckTest.java | 21 ++------- 7 files changed, 119 insertions(+), 30 deletions(-) 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/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index ed6bf1ee67..b96825eaf2 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -115,6 +115,7 @@ void ThreadFilter::initializeChunk(int chunk_idx) { 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); + slot.unowned_blocked_fallback_enabled.store(1, std::memory_order_relaxed); } // Try to install it atomically @@ -165,6 +166,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { 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->enableUnownedBlockedFallback(); slot->clearActiveBlockRun(OSThreadState::UNKNOWN); if (!indexOrRollback(*slot, reused_slot, tid)) { pushToFreeList(reused_slot); @@ -208,6 +210,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { 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->enableUnownedBlockedFallback(); slot->clearActiveBlockRun(OSThreadState::UNKNOWN); if (!indexOrRollback(*slot, index, tid)) { pushToFreeList(index); @@ -242,6 +245,7 @@ void ThreadFilter::refreshSlotForRecording(Slot* slot, RecordingEpoch epoch) { current, 0, std::memory_order_acq_rel, std::memory_order_acquire)) { Counters::increment(THREAD_REGISTRY_CONTEXT_RESET_RACE_DETECTED); } + slot->enableUnownedBlockedFallback(); slot->clearActiveBlockRun(OSThreadState::UNKNOWN); slot->recording_epoch.store(epoch, std::memory_order_release); @@ -361,6 +365,45 @@ ThreadFilter::Slot* ThreadFilter::activeSlotForId(SlotID slot_id, return slot; } +bool ThreadFilter::lookupThreadEntry(ThreadEntry& entry, + RecordingEpoch epoch) const { + Slot* slot = epoch != 0 ? lookupByTid(entry.tid, epoch) + : lookupByTid(entry.tid); + if (slot == nullptr) { + return false; + } + entry.slot = slot; + entry.lifecycle_generation = slot->lifecycleGeneration(); + entry.recording_epoch = slot->recordingEpoch(); + return true; +} + +ThreadFilter::SlotID ThreadFilter::ensureCurrentThreadSlot(ProfiledThread* current) { + if (current == nullptr) { + return -1; + } + int tid = current->tid(); + if (unlikely(tid < 0)) { + return -1; + } + + SlotID slot_id = current->filterSlotId(); + if (likely(slot_id >= 0)) { + if (likely(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 = registerThread(tid); + if (slot_id >= 0) { + current->setFilterSlotId(slot_id); + } + return slot_id; +} + void ThreadFilter::initFreeList() { // Initialize the free list storage for (int i = 0; i < kFreeListSize; ++i) { @@ -465,6 +508,7 @@ void ThreadFilter::unregisterThreadLocked(SlotID slot_id, int expected_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->enableUnownedBlockedFallback(); slot->clearActiveBlockRun(OSThreadState::UNKNOWN); pushToFreeList(slot_id); } @@ -497,6 +541,7 @@ void ThreadFilter::resetRegistrationsLocked() { 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.enableUnownedBlockedFallback(); slot.clearActiveBlockRun(OSThreadState::UNKNOWN); } } @@ -618,6 +663,7 @@ void ThreadFilter::clearActive() { for (int slot_idx = 0; slot_idx < kChunkSize; ++slot_idx) { Slot& slot = chunk->slots[slot_idx]; slot.exitContextWindow(); + slot.enableUnownedBlockedFallback(); slot.clearActiveBlockRun(OSThreadState::UNKNOWN); } } @@ -631,6 +677,7 @@ void ThreadFilter::resetSlotRunState(SlotID slot_id) { if (chunk != nullptr) { // Clear stale suppression state so a new thread in this slot cannot // inherit its predecessor's active block. + chunk->slots[slot_idx].enableUnownedBlockedFallback(); chunk->slots[slot_idx].clearActiveBlockRun(OSThreadState::UNKNOWN); } } diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 060652ae5e..9923bc6737 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -26,6 +26,7 @@ #include "arch.h" #include "threadState.h" +class ProfiledThread; struct ThreadEntry; // defined after ThreadFilter; carries a pointer to a ThreadFilter::Slot enum class BlockRunOwner : int { @@ -90,6 +91,7 @@ class ThreadFilter { // changing ordinary thread selection. std::atomic tid{-1}; std::atomic active_block_owner{static_cast(BlockRunOwner::NONE)}; + std::atomic unowned_blocked_fallback_enabled{1}; // 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}; @@ -104,6 +106,7 @@ class ThreadFilter { - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) + - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic)]; @@ -159,6 +162,17 @@ class ThreadFilter { inline u64 blockGeneration() const { return block_generation.load(std::memory_order_acquire); } + inline bool unownedBlockedFallbackEnabled() const { + return unowned_blocked_fallback_enabled.load(std::memory_order_acquire) != 0; + } + inline void enableUnownedBlockedFallback() { + resetUnownedBlockedSampling(); + unowned_blocked_fallback_enabled.store(1, std::memory_order_release); + } + inline void disableUnownedBlockedFallback() { + unowned_blocked_fallback_enabled.store(0, std::memory_order_release); + resetUnownedBlockedSampling(); + } inline void resetUnownedBlockedSampling() { unowned_blocked_pending_weight.store(0, std::memory_order_relaxed); unowned_blocked_decision_count.store(0, std::memory_order_relaxed); @@ -224,7 +238,7 @@ class ThreadFilter { generation++; block_generation.store(generation, std::memory_order_relaxed); active_block_context_epoch.store(context_state >> 1, std::memory_order_relaxed); - resetUnownedBlockedSampling(); + disableUnownedBlockedFallback(); *generation_out = generation; return true; } @@ -352,6 +366,10 @@ class ThreadFilter { Slot* lookupByTid(int tid, SlotID* out_slot_id = nullptr) const; Slot* lookupByTid(int tid, RecordingEpoch epoch, SlotID* out_slot_id = nullptr) const; Slot* activeSlotForId(SlotID slot_id, int tid) const; + // Populates lifecycle metadata from an existing mapping without registering + // the TID. Timer-side observation must remain read-only. + bool lookupThreadEntry(ThreadEntry& entry, RecordingEpoch epoch) const; + SlotID ensureCurrentThreadSlot(ProfiledThread* current); void deactivateRecording(); SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 3ac99a104f..e930fc0e14 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -122,11 +122,7 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, result.suppress = true; return result; } - - // Unfiltered tracking exists only to support explicit context and owned-block - // hooks. Keep unowned observations on ordinary per-signal sampling: the JVMTI - // path has no call_trace_id with which to replay a suppressed tail. - if (registry->unfilteredWallTrackingActive()) { + if (!slot->unownedBlockedFallbackEnabled()) { return result; } @@ -364,13 +360,7 @@ WallClockCandidateOutcome BaseWallClock::sampleThreadCommon( ThreadFilter::RecordingEpoch recording_epoch) { 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(); - } + thread_filter->lookupThreadEntry(entry, recording_epoch); } // Timer-thread fast path (wallprecheck=true): skip the kernel IPI entirely // only when an explicit lifecycle hook still owns an already-sampled blocked diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 31f47b015e..c4098d0a01 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -755,6 +755,25 @@ TEST_F(ThreadRegistryTest, UnfilteredTrackingSeparatesRegistrationFromContextWin EXPECT_TRUE(context.empty()); } +TEST_F(ThreadRegistryTest, LookupThreadEntryDoesNotRegisterUnknownTid) { + constexpr int tid = 3210; + ThreadEntry entry{tid, nullptr, 0, 0}; + + EXPECT_FALSE(registry.lookupThreadEntry(entry, registry.recordingEpoch())); + EXPECT_EQ(nullptr, entry.slot); + EXPECT_EQ(nullptr, registry.lookupByTid(tid)); + + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + EXPECT_TRUE(registry.lookupThreadEntry(entry, registry.recordingEpoch())); + EXPECT_EQ(slot, entry.slot); + EXPECT_EQ(slot->lifecycleGeneration(), entry.lifecycle_generation); + EXPECT_EQ(slot->recordingEpoch(), entry.recording_epoch); +} + TEST_F(ThreadRegistryTest, RegisteringKnownTidReturnsExistingSlotWithoutMutation) { constexpr int tid = 4321; int slot_id = registry.registerThread(tid); 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 6402743862..fb3f017d9b 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 @@ -5,7 +5,6 @@ package com.datadoghq.profiler.wallclock; -import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -28,7 +27,6 @@ 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 UNOWNED_SUPPRESSED_COUNTER = "wc_unowned_blocked_suppressed"; private static final String SUPPRESSED_OWNED_BLOCK_COUNTER = "wc_signals_suppressed_owned_block"; @@ -96,11 +94,9 @@ public void parkedPreExistingThreadOutsideContextWindowIsOwnedBlockSuppressed() */ @RetryingTest(3) public void unownedSleepAfterOwnedBlockUsesNormalSampling() throws Exception { - long unownedSuppressedBefore = unownedSuppressedSignals(); assertTrue( runPreExistingUnownedSleepingWorker() != 0, "Expected the setup block to register the worker"); - long unownedSuppressedAfter = unownedSuppressedSignals(); stopProfiler(); @@ -108,26 +104,21 @@ public void unownedSleepAfterOwnedBlockUsesNormalSampling() throws Exception { assertTrue( sampleCount >= 10, "Expected normal MethodSample volume for an unowned sleep, got: " + sampleCount); - if (unownedSuppressedBefore >= 0) { - assertEquals( - unownedSuppressedBefore, - unownedSuppressedAfter, - "Unfiltered mode must not use observation-only unowned suppression"); - } } /** - * Retains coverage for post-start threads, whose owned-block hook must register a slot lazily. + * Retains coverage for threads whose registry slot is installed by a post-start ThreadStart + * event. * * @throws Exception if the worker cannot complete */ @RetryingTest(3) - public void postStartSleepingThreadLazilyRegistersOwnedBlock() throws Exception { + public void postStartSleepingThreadUsesThreadStartSlot() throws Exception { String threadName = "unfiltered-precheck-post-start"; long suppressedBefore = suppressedSignals(); assertTrue( runPostStartSleepingWorker(threadName) != 0, - "Expected the owned-block hook to register and arm SLEEPING state"); + "Expected ThreadStart registration to arm SLEEPING state"); stopProfiler(); assertSuppressedSamples(threadName); @@ -255,10 +246,6 @@ private long suppressedSignals() { return profiler.getDebugCounters().getOrDefault(SUPPRESSED_OWNED_BLOCK_COUNTER, -1L); } - private long unownedSuppressedSignals() { - return profiler.getDebugCounters().getOrDefault(UNOWNED_SUPPRESSED_COUNTER, -1L); - } - private void assertSuppressedSamples(String threadName) { long sampleCount = samplesForThread(threadName); assertTrue( From c6c7a7bad7f3ad4a9e079b2c4b691ab179891dd0 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 27 Jul 2026 15:51:06 +0200 Subject: [PATCH 06/19] fix: fix emission problems --- ddprof-lib/src/main/cpp/profiler.cpp | 2 +- ddprof-lib/src/main/cpp/threadFilter.cpp | 23 ++++++++- ddprof-lib/src/main/cpp/threadFilter.h | 25 ++++++++++ ddprof-lib/src/main/cpp/wallClock.cpp | 36 +++++++++----- ddprof-lib/src/test/cpp/park_state_ut.cpp | 4 ++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 48 ++++++++++++++++++- .../JavaProfilerTaskBlockApiTest.java | 21 +++++++- .../wallclock/UnfilteredWallPrecheckTest.java | 6 ++- 8 files changed, 145 insertions(+), 20 deletions(-) diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index e9763da9e0..6a84831cbc 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1509,11 +1509,11 @@ 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; } + _task_block_enabled.store(false, std::memory_order_release); // Sanity checks run at most once per process, across start and stop cycles. // Profiler::start() sets sanity_checked to true before it checks diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index b96825eaf2..a25f505ef6 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -736,8 +736,20 @@ bool ThreadFilter::snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, return true; } +bool ThreadFilter::activeOwnedBlockGeneration(const ThreadEntry& entry, + u64& generation) const { + return ownedBlockGeneration(entry, generation, false); +} + bool ThreadFilter::isOwnedBlockSuppressionCandidate( const ThreadEntry& entry) const { + u64 generation = 0; + return ownedBlockGeneration(entry, generation, true); +} + +bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, + u64& generation, + bool require_sampled) const { Slot* slot = entry.slot; if (!unfilteredWallTrackingActive() || slot == nullptr || slot->nativeTid() != entry.tid || @@ -764,7 +776,11 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( u64 block_generation = slot->blockGeneration(); BlockRunOwner owner = slot->activeBlockOwner(); - if (owner == BlockRunOwner::NONE) return false; + if (owner == BlockRunOwner::NONE || + (require_sampled && + slot->sampledBlockGeneration() != block_generation)) { + return false; + } #ifdef UNIT_TEST if (_suppression_snapshot_hook != nullptr) { @@ -777,13 +793,16 @@ bool ThreadFilter::isOwnedBlockSuppressionCandidate( if (slot->activeBlockOwner() != owner || slot->blockGeneration() != block_generation || slot->activeBlockState() != state || slot->nativeTid() != entry.tid || - slot->lifecycleGeneration() != entry.lifecycle_generation) { + slot->lifecycleGeneration() != entry.lifecycle_generation || + (require_sampled && + slot->sampledBlockGeneration() != block_generation)) { return false; } if (recordingEpoch() != epoch || slot->recordingEpoch() != epoch || !slot->activeBlockRemainedOutsideContextWindow()) { return false; } + generation = block_generation; return true; } diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 9923bc6737..393eb94df7 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -85,6 +85,10 @@ class ThreadFilter { std::atomic recording_epoch{0}; std::atomic active_block_context_epoch{0}; std::atomic block_generation{0}; + // The most recent owned-block generation for which a MethodSample was + // recorded successfully. Generations are monotonic, so a delayed + // completion from an older run cannot mark a newer run as sampled. + std::atomic sampled_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 @@ -103,6 +107,7 @@ class ThreadFilter { - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) + - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) - sizeof(std::atomic) @@ -162,6 +167,20 @@ class ThreadFilter { inline u64 blockGeneration() const { return block_generation.load(std::memory_order_acquire); } + inline u64 sampledBlockGeneration() const { + return sampled_block_generation.load(std::memory_order_acquire); + } + inline void markBlockGenerationSampled(u64 generation) { + u64 sampled = sampled_block_generation.load(std::memory_order_relaxed); + while (sampled < generation && + !sampled_block_generation.compare_exchange_weak( + sampled, generation, std::memory_order_release, + std::memory_order_relaxed)) { + } + } + inline void resetSampledBlockGeneration() { + sampled_block_generation.store(0, std::memory_order_relaxed); + } inline bool unownedBlockedFallbackEnabled() const { return unowned_blocked_fallback_enabled.load(std::memory_order_acquire) != 0; } @@ -238,6 +257,7 @@ class ThreadFilter { generation++; block_generation.store(generation, std::memory_order_relaxed); active_block_context_epoch.store(context_state >> 1, std::memory_order_relaxed); + resetSampledBlockGeneration(); disableUnownedBlockedFallback(); *generation_out = generation; return true; @@ -247,6 +267,7 @@ class ThreadFilter { } inline void clearActiveBlockRun(OSThreadState) { active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_release); + resetSampledBlockGeneration(); resetUnownedBlockedSampling(); active_block_owner.store(static_cast(BlockRunOwner::NONE), std::memory_order_release); } @@ -308,6 +329,8 @@ class ThreadFilter { bool exitBlockedRun(SlotID slot_id, u64 generation); bool snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, BlockRunSnapshot* snapshot); + bool activeOwnedBlockGeneration(const ThreadEntry& entry, + u64& generation) const; bool isOwnedBlockSuppressionCandidate(const ThreadEntry& entry) const; #ifdef UNIT_TEST @@ -374,6 +397,8 @@ class ThreadFilter { SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } private: + bool ownedBlockGeneration(const ThreadEntry& entry, u64& generation, + bool require_sampled) const; // Lock-free free list using a stack-like structure struct FreeListNode { diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index e930fc0e14..da3bec4a09 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -56,6 +56,8 @@ static inline bool hasKnownActiveTraceContext(ProfiledThread* thread) { struct WallPrecheckResult { bool suppress = false; + ThreadFilter::Slot* owned_block_slot = nullptr; + u64 owned_block_generation = 0; OSThreadState observed_state = OSThreadState::UNKNOWN; bool observed_state_valid = false; ThreadFilter::Slot* unowned_weight_slot = nullptr; @@ -108,9 +110,9 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - // 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. + // Owned blocks replace repeated signals only after their current generation + // has produced one MethodSample. Context-scoped profiling must continue + // sampling its selected threads normally. if (!registry->unfilteredWallTrackingActive() || slot->inContextWindow()) { return result; } @@ -122,6 +124,14 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, result.suppress = true; return result; } + u64 block_generation = 0; + if (registry->activeOwnedBlockGeneration(entry, block_generation)) { + // Arm only after recordSample succeeds. A skipped JFR write must leave the + // run eligible so the next signal retries instead of losing its only stack. + result.owned_block_slot = slot; + result.owned_block_generation = block_generation; + return result; + } if (!slot->unownedBlockedFallbackEnabled()) { return result; } @@ -146,6 +156,10 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, static inline void finishWallPrecheck(const WallPrecheckResult& precheck, bool recorded, u64 recorded_call_trace_id = 0) { + if (recorded && precheck.owned_block_slot != nullptr) { + precheck.owned_block_slot->markBlockGenerationSampled( + precheck.owned_block_generation); + } if (!recorded && precheck.unowned_weight_slot != nullptr) { precheck.unowned_weight_slot->restoreUnownedBlockedWeight( precheck.unowned_weight); @@ -244,12 +258,10 @@ void WallClockASGCT::signalHandler(int signo, siginfo_t *siginfo, void *ucontext current->tickInitWindow(); return; } - // Once-per-run filter (wallprecheck=true): for untraced threads, exact - // suppression is only valid while an explicit lifecycle hook owns the blocked - // interval. Raw OS thread state is only an observation; it cannot distinguish - // one long sleep from several short sleeps separated by runnable gaps between - // signals. Unowned blocked observations therefore use weighted fallback - // sampling instead of arming sampled_this_run. + // Once-per-run filter (wallprecheck=true): an explicitly owned block keeps + // its first successful MethodSample and suppresses subsequent signals. + // Unowned blocked observations use weighted fallback sampling because raw OS + // state cannot distinguish one long sleep from several shorter runs. WallPrecheckResult precheck = prepareWallPrecheck(current, _precheck); if (precheck.suppress) { return; @@ -362,10 +374,8 @@ WallClockCandidateOutcome BaseWallClock::sampleThreadCommon( registry_lookups++; thread_filter->lookupThreadEntry(entry, recording_epoch); } - // 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. + // Timer-thread fast path (wallprecheck=true): skip the kernel IPI only + // after an explicitly owned run has recorded its first MethodSample. if (precheck && suppressAlreadySampledBlock(entry)) { return WallClockCandidateOutcome::PRECHECK_REJECTED; } diff --git a/ddprof-lib/src/test/cpp/park_state_ut.cpp b/ddprof-lib/src/test/cpp/park_state_ut.cpp index 5119c1d9c3..5a236994e0 100644 --- a/ddprof-lib/src/test/cpp/park_state_ut.cpp +++ b/ddprof-lib/src/test/cpp/park_state_ut.cpp @@ -304,6 +304,7 @@ TEST(WallClockOncePerRunFilterTest, FilterHelpersManageActiveBlockState) { filter.exitBlockedRun(slot_id); EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(0ULL, slot->sampledBlockGeneration()); } TEST(WallClockOncePerRunFilterTest, ResetClearsOwnedBlockOnSlotReuse) { @@ -314,8 +315,11 @@ TEST(WallClockOncePerRunFilterTest, ResetClearsOwnedBlockOnSlotReuse) { ThreadFilter::Slot *slot = filter.slotForId(slot_id); ASSERT_NE(nullptr, slot); EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot->activeBlockState()); + slot->markBlockGenerationSampled(slot->blockGeneration()); + ASSERT_EQ(slot->blockGeneration(), slot->sampledBlockGeneration()); filter.resetSlotRunState(slot_id); EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(0ULL, slot->sampledBlockGeneration()); } diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index c4098d0a01..173902b232 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -660,7 +660,7 @@ TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { EXPECT_FALSE(slot->snapshotBlockRun().active); } -TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { +TEST_F(ThreadFilterTest, OwnedBlockSuppressesOnlyAfterSuccessfulWallSample) { filter->init(nullptr, true); int slot_id = filter->registerThread(1234); ASSERT_GE(slot_id, 0); @@ -671,6 +671,12 @@ TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; + u64 generation = 0; + EXPECT_TRUE(filter->activeOwnedBlockGeneration(entry, generation)); + EXPECT_EQ(ThreadFilter::tokenGeneration(token), generation); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); + + slot->markBlockGenerationSampled(generation); EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( {1235, slot, slot->lifecycleGeneration(), slot->recordingEpoch()})); @@ -683,6 +689,38 @@ TEST_F(ThreadFilterTest, OwnedBlockSuppressesBeforeAnyWallSample) { EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } +TEST_F(ThreadFilterTest, StaleSampleCompletionCannotSuppressNewBlockGeneration) { + 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 first_token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, first_token); + u64 first_generation = ThreadFilter::tokenGeneration(first_token); + ASSERT_TRUE(filter->exitBlockedRun(slot_id, first_generation)); + + u64 second_token = + filter->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT); + ASSERT_NE(0ULL, second_token); + u64 second_generation = ThreadFilter::tokenGeneration(second_token); + ASSERT_GT(second_generation, first_generation); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + slot->markBlockGenerationSampled(first_generation); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); + + slot->markBlockGenerationSampled(second_generation); + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + // A delayed completion from the first run must not overwrite the newer mark. + slot->markBlockGenerationSampled(first_generation); + EXPECT_EQ(second_generation, slot->sampledBlockGeneration()); + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + TEST_F(ThreadFilterTest, ContextScopeNeverSuppressesOwnedBlock) { filter->init("0", false); int slot_id = filter->registerThread(1234); @@ -695,6 +733,7 @@ TEST_F(ThreadFilterTest, ContextScopeNeverSuppressesOwnedBlock) { ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; + slot->markBlockGenerationSampled(slot->blockGeneration()); EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } @@ -708,6 +747,7 @@ TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { slot_id, OSThreadState::CONDVAR_WAIT)); ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; + slot->markBlockGenerationSampled(slot->blockGeneration()); ASSERT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); filter->add(1234, slot_id); @@ -969,6 +1009,7 @@ TEST_F(ThreadRegistryTest, UnfilteredSuppressionValidatesIdentityAndLifecycle) { u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); + slot->markBlockGenerationSampled(ThreadFilter::tokenGeneration(token)); ThreadEntry entry{4444, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; EXPECT_TRUE(registry.isOwnedBlockSuppressionCandidate(entry)); @@ -1006,7 +1047,9 @@ TEST_F(ThreadRegistryTest, ConcurrentTidReuseInvalidatesSuppressionSnapshot) { 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)); + u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0u, token); + slot->markBlockGenerationSampled(ThreadFilter::tokenGeneration(token)); ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; @@ -1097,6 +1140,7 @@ TEST_F(ThreadRegistryTest, NewUnfilteredRecordingReclaimsRetainedSlot) { u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); ASSERT_NE(0u, token); + slot->markBlockGenerationSampled(ThreadFilter::tokenGeneration(token)); ThreadEntry stale{tid, slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; ASSERT_TRUE(registry.isOwnedBlockSuppressionCandidate(stale)); 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 51d476ecf9..1dc1ff692d 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 @@ -16,10 +16,13 @@ import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; 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.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; /** End-to-end coverage for the paired synchronous TaskBlock API. */ @@ -41,6 +44,22 @@ public void pairedApiEmitsTaskBlockWithStack() throws Exception { TaskBlockAssertions.assertContainsObservedState(events, "SLEEPING"); } + @ParameterizedTest + @ValueSource(strings = {"start", "resume"}) + public void rejectedDuplicateStartOrResumePreservesActiveTaskBlockRecording(String action) + throws Exception { + IllegalStateException rejected = assertThrows( + IllegalStateException.class, + () -> profiler.execute(action + "," + getProfilerCommand())); + assertEquals("Profiler already started", rejected.getMessage()); + + assertTrue(runEligibleBlock(BLOCKER)); + stopProfiler(); + + TaskBlockAssertions.assertContains( + verifyEvents("datadog.TaskBlock"), 0L, 0L, BLOCKER, UNBLOCKING_SPAN_ID); + } + @Test public void invalidAndNestedTokensDoNotLoseCurrentOwner() throws Exception { AtomicBoolean recorded = new AtomicBoolean(); @@ -141,7 +160,7 @@ public void virtualThreadCannotMutateCarrierTaskBlockState() throws Exception { } @Test - public void liveDumpDoesNotRequireAnEntrySample() throws Exception { + public void liveDumpPreservesTaskBlockAfterEntrySample() throws Exception { CountDownLatch armed = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); AtomicBoolean recorded = new AtomicBoolean(); 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 fb3f017d9b..9a42c38e36 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 @@ -248,9 +248,13 @@ private long suppressedSignals() { private void assertSuppressedSamples(String threadName) { long sampleCount = samplesForThread(threadName); + assertTrue( + sampleCount >= 1, + "Expected the owned block's first MethodSample to be retained"); assertTrue( sampleCount < 10, - "Expected nearly no samples from owned block thread, got: " + sampleCount); + "Expected samples after the first owned-block sample to be suppressed, got: " + + sampleCount); } private long samplesForThread(String threadName) { From 39514006cd920ae51509cd773361707d1f4d02fb Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 3 Aug 2026 13:16:48 +0200 Subject: [PATCH 07/19] test(taskblock): migrate core context setup --- .../profiler/wallclock/JavaProfilerTaskBlockApiTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 1dc1ff692d..d387a6ecd8 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 @@ -121,11 +121,11 @@ public void contextWindowAdmissionAndCrossingAreEnforced() throws Exception { public void traceContextRejectsAtEntry() throws Exception { AtomicLong token = new AtomicLong(-1L); runWorker(() -> { - profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); + profiler.setTraceContext(0x5100L, 0x5101L, 0L, 0x5101L, -1, null, -1, null); try { token.set(profiler.beginTaskBlock()); } finally { - profiler.clearContext(); + profiler.clearTraceContext(); } }); assertEquals(0L, token.get(), From 1fd632988b7e845cd34e43ebaed83c0e78ada0ae Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Mon, 3 Aug 2026 15:33:37 +0200 Subject: [PATCH 08/19] fix(taskblock): report park entry ownership --- ddprof-lib/src/main/cpp/javaApi.cpp | 5 +++-- .../main/java/com/datadoghq/profiler/JavaProfiler.java | 8 +++++--- .../datadoghq/profiler/JavaProfilerApiSurfaceTest.java | 5 ++++- .../com/datadoghq/profiler/ProfilerOwnedBlockHooks.java | 4 ++-- 4 files changed, 14 insertions(+), 8 deletions(-) diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index fecbbd2577..decfc2c044 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -422,11 +422,11 @@ Java_com_datadoghq_profiler_JavaProfiler_recordQueueEnd0( Profiler::instance()->recordQueueTime(tid, &event); } -extern "C" DLLEXPORT void JNICALL +extern "C" DLLEXPORT jboolean JNICALL Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) { ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); if (current == nullptr) { - return; + return JNI_FALSE; } bool first_park = current->parkEnter(); @@ -438,6 +438,7 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); } } + return first_park ? JNI_TRUE : JNI_FALSE; } extern "C" DLLEXPORT void JNICALL 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 c521fca2de..c84d61cb42 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -396,9 +396,11 @@ public void recordQueueTime(long startTicks, /** * Internal hook called before {@code LockSupport.park}. Park-specific TaskBlock * production is intentionally separate from the public paired API. + * + * @return {@code true} when this call owns a park interval that must be closed */ - void parkEnter() { - parkEnter0(); + boolean parkEnter() { + return parkEnter0(); } /** @@ -522,7 +524,7 @@ public boolean isThreadRegistryActiveForTest() { 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 boolean parkEnter0(); private static native void parkExit0(long blocker, long unblockingSpanId); 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 4efc30cb1b..058bd52944 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -10,13 +10,16 @@ import java.lang.reflect.Method; import java.lang.reflect.Modifier; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; public class JavaProfilerApiSurfaceTest { @Test public void taskBlockApiIsPublicButInternalHooksRemainPackageScoped() throws Exception { - assertNotPublic(JavaProfiler.class.getDeclaredMethod("parkEnter")); + Method parkEnter = JavaProfiler.class.getDeclaredMethod("parkEnter"); + assertNotPublic(parkEnter); + assertEquals(boolean.class, parkEnter.getReturnType()); assertNotPublic(JavaProfiler.class.getDeclaredMethod( "parkExit", long.class, long.class)); assertNotPublic(JavaProfiler.class.getDeclaredMethod("blockEnter", int.class)); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java index f58837f5de..54b9106196 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java @@ -9,8 +9,8 @@ public final class ProfilerOwnedBlockHooks { private ProfilerOwnedBlockHooks() {} - public static void parkEnter(JavaProfiler profiler) { - profiler.parkEnter(); + public static boolean parkEnter(JavaProfiler profiler) { + return profiler.parkEnter(); } public static void parkExit(JavaProfiler profiler, long blocker, long unblockingSpanId) { From 79eadad4882fc31d53748e0a2e706ba37e36410c Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Sun, 16 Aug 2026 14:05:08 +0200 Subject: [PATCH 09/19] fix(taskblock): migrate TaskBlock tests to the jafar-backed JFR API These tests predate main's JMC-to-jafar JFR loader migration and only surfaced as broken once taskblock-core was rebased onto the merged main. --- .../JavaProfilerTaskBlockApiTest.java | 4 +- .../JavaProfilerTaskBlockLightweightTest.java | 7 +- ...rofilerTaskBlockPreExistingThreadTest.java | 4 +- .../wallclock/TaskBlockAssertions.java | 128 ++++++------------ 4 files changed, 48 insertions(+), 95 deletions(-) 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 d387a6ecd8..e3ef5b51af 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 @@ -18,7 +18,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; -import org.openjdk.jmc.common.item.IItemCollection; +import com.datadoghq.profiler.JfrEvents; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -35,7 +35,7 @@ public void pairedApiEmitsTaskBlockWithStack() throws Exception { assertTrue(runEligibleBlock(BLOCKER)); stopProfiler(); - IItemCollection events = verifyEvents("datadog.TaskBlock"); + JfrEvents events = verifyEvents("datadog.TaskBlock"); TaskBlockAssertions.assertNoAnchorFields(events); TaskBlockAssertions.assertContainsStackTrace(events); TaskBlockAssertions.assertContainsJavaType(events, "JavaProfilerTaskBlockApiTest"); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java index 5c0ab26af1..3654a90a34 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JavaProfilerTaskBlockLightweightTest.java @@ -11,8 +11,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.Test; -import org.openjdk.jmc.common.item.IItemCollection; -import org.openjdk.jmc.common.item.IItemIterable; +import com.datadoghq.profiler.JfrEvents; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -56,8 +55,8 @@ public void suppressedWallSamplesAreReplacedByAStacklessTaskBlock() throws Excep assertTrue(recorded.get(), "Expected stackless TaskBlock event to be recorded"); stopProfiler(); - IItemCollection events = verifyEvents("datadog.TaskBlock"); - assertEquals(1L, events.stream().flatMap(IItemIterable::stream).count()); + JfrEvents events = verifyEvents("datadog.TaskBlock"); + assertEquals(1L, events.count()); TaskBlockAssertions.assertContainsNoStackTrace(events); TaskBlockAssertions.assertNoAnchorFields(events); TaskBlockAssertions.assertNoCorrelationId(events); 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 8777a6c785..bb21a3bee8 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 @@ -12,7 +12,7 @@ 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 com.datadoghq.profiler.JfrEvents; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; @@ -63,7 +63,7 @@ public void preExistingThreadCanRecordTaskBlockAfterProfilerStart() throws Excep assertTrue(recorded.get(5, TimeUnit.SECONDS)); stopProfiler(); - IItemCollection events = verifyEvents("datadog.TaskBlock"); + JfrEvents events = verifyEvents("datadog.TaskBlock"); TaskBlockAssertions.assertContainsStackTrace(events); TaskBlockAssertions.assertContains( events, 0L, 0L, BLOCKER, UNBLOCKING_SPAN_ID); 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 2752966cd0..435a18dc8d 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 @@ -6,135 +6,89 @@ package com.datadoghq.profiler.wallclock; import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvent; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.JfrFrame; 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 static final String BLOCKER = "blocker"; + private static final String UNBLOCKING_SPAN_ID = "unblockingSpanId"; + private static final String ANCHOR_SAMPLE_ID = "anchorSampleId"; + private static final String SUPPRESSED_SAMPLE_COUNT = "suppressedSampleCount"; + private static final String OBSERVED_BLOCKING_STATE = "observedBlockingState"; + private static final String CORRELATION_ID = "correlationId"; private TaskBlockAssertions() {} - static void assertContains(IItemCollection events, long rootSpanId, long spanId, + static void assertContains(JfrEvents 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; - } + for (JfrEvent item : events) { + if (item.getLong(AbstractProfilerTest.LOCAL_ROOT_SPAN_ID, Long.MIN_VALUE) == rootSpanId + && item.getLong(AbstractProfilerTest.SPAN_ID, Long.MIN_VALUE) == spanId + && item.getLong(BLOCKER, Long.MIN_VALUE) == blocker + && item.getLong(UNBLOCKING_SPAN_ID, Long.MIN_VALUE) == unblockingSpanId) { + return; } } throw new AssertionError("Expected TaskBlock blocker=" + blocker + ", unblockingSpanId=" + unblockingSpanId); } - static void assertContainsObservedState(IItemCollection events, String expected) { + static void assertContainsObservedState(JfrEvents 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)); + for (JfrEvent item : events) { + states.add(item.getString(OBSERVED_BLOCKING_STATE)); } assertTrue(states.contains(expected), () -> "Observed states: " + states); } - static void assertContainsStackTrace(IItemCollection events) { + static void assertContainsStackTrace(JfrEvents 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++; - } + for (JfrEvent item : events) { + assertTrue(!item.getStackTrace().isEmpty()); + count++; } assertTrue(count > 0, "Expected a TaskBlock with a non-empty stack"); } - static void assertContainsNoStackTrace(IItemCollection events) { + static void assertContainsNoStackTrace(JfrEvents 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) { - assertNull(accessor.getMember(item)); - count++; - } + for (JfrEvent item : events) { + assertTrue(item.getStackTrace().isEmpty()); + count++; } assertTrue(count > 0, "Expected a TaskBlock without a 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; - } + static void assertContainsJavaType(JfrEvents events, String expected) { + for (JfrEvent item : events) { + if (!item.has(AbstractProfilerTest.STACK_TRACE)) continue; + for (JfrFrame frame : item.getStackTrace().frames()) { + String className = frame.className(); + if (className != null && className.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 assertNoCorrelationId(JfrEvents events) { + for (JfrEvent item : events) { + assertNull(item.get(CORRELATION_ID)); } } - static void assertNoAnchorFields(IItemCollection events) { - for (IItemIterable iterable : events) { - assertNull(ANCHOR_SAMPLE_ID.getAccessor(iterable.getType())); - assertNull(SUPPRESSED_SAMPLE_COUNT.getAccessor(iterable.getType())); + static void assertNoAnchorFields(JfrEvents events) { + for (JfrEvent item : events) { + assertNull(item.get(ANCHOR_SAMPLE_ID)); + assertNull(item.get(SUPPRESSED_SAMPLE_COUNT)); } } } From 5046d8a52ff3b861a37ec737acdbf30d95b26cdd Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Tue, 18 Aug 2026 10:50:06 +0200 Subject: [PATCH 10/19] fix: address review comments --- ddprof-lib/src/main/cpp/profiler.h | 9 +++++++++ ddprof-lib/src/main/cpp/threadFilter.cpp | 18 ++++++++++++++++++ ddprof-lib/src/main/cpp/threadFilter.h | 8 ++++++++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 18 ++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 53fae45897..a5af1f8af6 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -240,6 +240,15 @@ class alignas(alignof(SpinLock)) Profiler { for (int i = 0; i < CONCURRENCY_LEVEL; i++) { _calltrace_buffer[i] = NULL; } + + // Protects wall-clock unowned-block fallback tail traces (cached in + // ThreadFilter::Slot) from being dropped by a chunk rotation before + // flushUnownedBlockedTail() emits them. ThreadFilter is process-lifetime, + // so this is registered once rather than per-recording. + registerLivenessChecker([this](CallTraceIdSet& buffer) { + _thread_filter.collectUnownedBlockedTraceIds( + [&buffer](u64 call_trace_id) { buffer.insert(call_trace_id); }); + }); } static inline Profiler *instance() { diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index a25f505ef6..c125cfd70a 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -652,6 +652,24 @@ void ThreadFilter::collect(std::vector& entries) const { } } +void ThreadFilter::collectUnownedBlockedTraceIds(const std::function& visit) const { + int num_chunks = _num_chunks.load(std::memory_order_relaxed); + 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 (const auto& slot : chunk->slots) { + u64 call_trace_id = + slot.unowned_blocked_call_trace_id.load(std::memory_order_acquire); + if (call_trace_id != 0) { + visit(call_trace_id); + } + } + } +} + void ThreadFilter::clearActive() { int num_chunks = _num_chunks.load(std::memory_order_acquire); for (int chunk_idx = 0; chunk_idx < num_chunks; ++chunk_idx) { diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 393eb94df7..0e6fe9bfad 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -22,6 +22,7 @@ #include #include #include +#include #include "arch.h" #include "threadState.h" @@ -316,6 +317,13 @@ class ThreadFilter { void remove(SlotID slot_id); void collect(std::vector& tids) const; void collect(std::vector& entries) const; + // Liveness hook for CallTraceStorage::processTraces: a slot may cache a + // call_trace_id for a still-blocked thread's weighted fallback tail + // (recordUnownedBlockedSample) well before flushUnownedBlockedTail() emits + // it. Without this, a chunk rotation racing that window drops the trace, + // and the eventual deferred sample references an id with no constant-pool + // entry in the chunk it lands in. + void collectUnownedBlockedTraceIds(const std::function& visit) const; // Clears per-recording membership and suppression state while keeping // process-lifetime slot ownership intact. Threads must opt in again with add(). void clearActive(); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 173902b232..2f0cf6d2b8 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -513,6 +513,24 @@ TEST_F(ThreadFilterTest, CollectMixedStates) { } } +TEST_F(ThreadFilterTest, CollectUnownedBlockedTraceIdsFindsOnlyCachedSlots) { + int with_trace_id = filter->registerThread(); + int without_trace_id = filter->registerThread(); + ASSERT_GE(with_trace_id, 0); + ASSERT_GE(without_trace_id, 0); + + ThreadFilter::Slot* slot = filter->slotForId(with_trace_id); + ASSERT_NE(slot, nullptr); + slot->recordUnownedBlockedSample(0xABCDULL, OSThreadState::SLEEPING); + + std::set collected; + filter->collectUnownedBlockedTraceIds( + [&](u64 call_trace_id) { collected.insert(call_trace_id); }); + + EXPECT_EQ(collected.size(), 1u); + EXPECT_TRUE(collected.count(0xABCDULL)); +} + TEST_F(ThreadFilterTest, ClearActiveDropsPreviousRecordingMembership) { int stale_slot = filter->registerThread(); int current_slot = filter->registerThread(); From dde0bc1d23aadd62d5b7abef244251dfd564f95e Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Tue, 18 Aug 2026 15:58:55 +0200 Subject: [PATCH 11/19] fix: address sphinx comments --- ddprof-lib/src/main/cpp/counters.h | 1 + ddprof-lib/src/main/cpp/event.h | 3 +- ddprof-lib/src/main/cpp/javaApi.cpp | 44 +++---------------- ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 2 +- ddprof-lib/src/main/cpp/threadFilter.h | 2 +- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 17 +++++++ .../JavaProfilerTaskBlockApiTest.java | 6 +++ 7 files changed, 32 insertions(+), 43 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index aae2bc0e17..9277b16006 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -83,6 +83,7 @@ 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_CONTEXT_WINDOW, "task_block_skipped_context_window") \ 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") \ diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index ece568fd14..01ca053af8 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -58,11 +58,10 @@ class ExecutionEvent : public Event { OSThreadState _thread_state; ExecutionMode _execution_mode; u64 _weight; - u64 _call_trace_id; ExecutionEvent() : Event(), _thread_state(OSThreadState::RUNNABLE), _execution_mode(ExecutionMode::UNKNOWN), - _weight(1), _call_trace_id(0) {} + _weight(1) {} }; class AllocEvent : public Event { diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index decfc2c044..0843944d0c 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -165,40 +165,6 @@ 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. - // - // This is the only place a JavaCritical fast path (filterThreadAdd0, - // parkEnter0, blockEnter0) can block on _registry_lock. It's bounded to at - // most once per thread lifetime (cold TLS) plus once per recording-epoch - // transition this thread observes (stale cached slot) - not a per-call cost. - // THREAD_REGISTRY_JAVACRITICAL_REREGISTRATION makes that bound observable; - // if it starts firing per-sample rather than per-thread/per-recording, the - // "provably rare" assumption has broken and the JavaCritical dispatch should - // be revisited. - Counters::increment(THREAD_REGISTRY_JAVACRITICAL_REREGISTRATION); - 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 @@ -220,7 +186,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { return; } - int slot_id = ensureCurrentThreadFilterSlot(thread_filter, current); + int slot_id = thread_filter->ensureCurrentThreadSlot(current); if (unlikely(slot_id < 0)) { return; // Failed to register thread } @@ -228,7 +194,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { // The cached slot_id was rejected (lazy tid-index fallback failed under // this thread's own registry reset race, or the tid index is exhausted). // Clear the cache so the next filterThreadAdd0()/parkEnter0()/blockEnter0() - // call re-runs ensureCurrentThreadFilterSlot()'s registerThread() path + // call re-runs ensureCurrentThreadSlot()'s registerThread() path // instead of leaving this thread permanently outside the context window. current->setFilterSlotId(-1); } @@ -432,7 +398,7 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) bool first_park = current->parkEnter(); ThreadFilter *tf = Profiler::instance()->threadFilter(); if (first_park && tf->registryActive()) { - ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id >= 0) { current->setParkBlockToken( tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); @@ -491,7 +457,7 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (!profiler->taskBlockEnabled() && !tf->registryActive()) { return 0; } - ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id < 0) return 0; return static_cast(tf->enterBlockedRun(slot_id, decoded)); } @@ -533,7 +499,7 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( } ThreadFilter *tf = profiler->threadFilter(); if (!tf->unfilteredWallTrackingActive()) return 0; - ThreadFilter::SlotID slot_id = ensureCurrentThreadFilterSlot(tf, current); + ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id < 0) return 0; Context context = ContextApi::snapshot(); diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index 34492f2054..c9e3dcf84e 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -72,7 +72,7 @@ bool finishTaskBlockAtExit(ProfiledThread* current, return false; } if (!snapshot.context_eligible) { - Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + Counters::increment(TASK_BLOCK_SKIPPED_CONTEXT_WINDOW); profiler->leaveTaskBlockActivity(); return false; } diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 0e6fe9bfad..53a2a6a8af 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -269,7 +269,7 @@ class ThreadFilter { inline void clearActiveBlockRun(OSThreadState) { active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_release); resetSampledBlockGeneration(); - resetUnownedBlockedSampling(); + enableUnownedBlockedFallback(); active_block_owner.store(static_cast(BlockRunOwner::NONE), std::memory_order_release); } inline bool activeBlockRemainedOutsideContextWindow() const { diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 2f0cf6d2b8..c0dbf71a35 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -707,6 +707,23 @@ TEST_F(ThreadFilterTest, OwnedBlockSuppressesOnlyAfterSuccessfulWallSample) { EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); } +TEST_F(ThreadFilterTest, ExitingOwnedBlockReenablesUnownedBlockedFallback) { + 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_TRUE(slot->unownedBlockedFallbackEnabled()); + + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, token); + EXPECT_FALSE(slot->unownedBlockedFallbackEnabled()); + + ASSERT_TRUE(filter->exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + EXPECT_TRUE(slot->unownedBlockedFallbackEnabled()); +} + TEST_F(ThreadFilterTest, StaleSampleCompletionCannotSuppressNewBlockGeneration) { filter->init(nullptr, true); int slot_id = filter->registerThread(1234); 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 e3ef5b51af..9551ee946f 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 @@ -115,6 +115,12 @@ public void contextWindowAdmissionAndCrossingAreEnforced() throws Exception { }); assertTrue(tokenAfterWindow.get() != 0, "context rejection must still clear the prior lifecycle"); + + stopProfiler(); + assertTrue(getRecordedCounterValue("task_block_skipped_context_window") > 0, + "context-window crossing must be counted separately from trace-context rejection"); + assertEquals(0L, getRecordedCounterValue("task_block_skipped_trace_context"), + "context-window crossing must not be attributed to the trace-context counter"); } @Test From 5bcc2b7e60ce79a618899b29866345135e6826bb Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Tue, 18 Aug 2026 20:59:12 +0200 Subject: [PATCH 12/19] fix: address review comments Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/main/cpp/counters.h | 2 + ddprof-lib/src/main/cpp/javaApi.cpp | 41 ++++++++-- ddprof-lib/src/main/cpp/jvmSupport.cpp | 1 + ddprof-lib/src/main/cpp/profiler.cpp | 61 ++++++++++++-- ddprof-lib/src/main/cpp/profiler.h | 4 +- ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 18 +---- ddprof-lib/src/main/cpp/taskBlockRecorder.h | 1 - ddprof-lib/src/main/cpp/threadFilter.cpp | 40 +++++----- ddprof-lib/src/main/cpp/threadFilter.h | 12 ++- ddprof-lib/src/main/cpp/wallClock.cpp | 13 +-- .../com/datadoghq/profiler/JavaProfiler.java | 16 ++++ ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 80 +++++++++++++++++-- .../src/test/cpp/taskBlockRecorder_ut.cpp | 25 +++++- .../profiler/ProfilerOwnedBlockHooks.java | 9 +++ .../JavaProfilerTaskBlockApiTest.java | 27 +++++++ .../profiler/wallclock/PrecheckTest.java | 15 ++++ 16 files changed, 294 insertions(+), 71 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 9277b16006..8d4d0e827d 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -88,6 +88,8 @@ 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(TASK_BLOCK_SKIPPED_THREAD_MISMATCH, "task_block_skipped_thread_mismatch") \ + X(TASK_BLOCK_ROTATION_TIMEOUT, "task_block_rotation_timeout") \ 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/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 0843944d0c..fccfd229b6 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -438,6 +438,20 @@ static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { return false; } +// beginTaskBlock0/endTaskBlock0 accept an explicit jthread for stack-walking and +// classification; verify it actually identifies the calling thread, since the two +// could otherwise diverge (e.g. a stale or mismatched jthread handle). +static bool isCurrentJniThread(JNIEnv* env, jthread thread) { + if (thread == nullptr) return false; + jthread current_thread = nullptr; + if (VM::jvmti()->GetCurrentThread(¤t_thread) != JVMTI_ERROR_NONE) { + return false; + } + bool same = env->IsSameObject(thread, current_thread); + env->DeleteLocalRef(current_thread); + return same; +} + extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( JNIEnv *env, jclass unused, jint state) { @@ -449,7 +463,9 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (!decodeJavaBlockState(state, decoded)) { return 0; } - if (ContextApi::snapshot().spanId != 0) { + u64 span_id = 0, root_span_id = 0; + ContextApi::get(span_id, root_span_id); + if (span_id != 0) { return 0; } Profiler *profiler = Profiler::instance(); @@ -488,9 +504,6 @@ Java_com_datadoghq_profiler_JavaProfiler_blockExit0( extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( JNIEnv *env, jclass unused, jthread thread) { - if (!JVMSupport::isPlatformThread(env, thread)) { - return 0; - } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); Profiler *profiler = Profiler::instance(); if (current == nullptr || !profiler->isRunning() || @@ -499,6 +512,13 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( } ThreadFilter *tf = profiler->threadFilter(); if (!tf->unfilteredWallTrackingActive()) return 0; + if (!isCurrentJniThread(env, thread)) { + Counters::increment(TASK_BLOCK_SKIPPED_THREAD_MISMATCH); + return 0; + } + if (!JVMSupport::isPlatformThread(env, thread)) { + return 0; + } ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id < 0) return 0; @@ -525,8 +545,14 @@ Java_com_datadoghq_profiler_JavaProfiler_endTaskBlock0( 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)) { + if (!ThreadFilter::decodeBlockRunToken(block_token, slot_id, generation)) { + return JNI_FALSE; + } + if (!isCurrentJniThread(env, thread)) { + Counters::increment(TASK_BLOCK_SKIPPED_THREAD_MISMATCH); + return JNI_FALSE; + } + if (!JVMSupport::isPlatformThread(env, thread)) { return JNI_FALSE; } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -534,8 +560,7 @@ Java_com_datadoghq_profiler_JavaProfiler_endTaskBlock0( bool recorded = recordTaskBlockAtExit( current, Profiler::instance()->threadFilter(), thread, 1, block_token, - slot_id, generation, static_cast(blocker), - static_cast(unblockingSpanId)); + 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 32761db69f..72fb1bc8b0 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -40,6 +40,7 @@ bool JVMSupport::isPlatformThread(JNIEnv* jni, jthread thread) { jint jni_version = jni->GetVersion(); if (jni_version <= 0) return false; if (jni_version < JNI_VERSION_19_VALUE) return true; + if (!VM::isHotspot()) return true; const JniFunction* functions = reinterpret_cast(jni->functions); diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 6a84831cbc..427b570ea0 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -52,12 +52,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include // The instance is not deleted on purpose, since profiler structures @@ -863,11 +865,49 @@ void Profiler::leaveTaskBlockActivity() { _task_block_inflight.fetch_sub(1, std::memory_order_release); } -void Profiler::beginTaskBlockRotation() { +bool Profiler::beginTaskBlockRotation() { + static const long TASK_BLOCK_ROTATION_TIMEOUT_NS = 200000000L; // 200ms, matches SignalInflight::drain() _task_block_rotation.store(true, std::memory_order_release); + if (_task_block_inflight.load(std::memory_order_acquire) == 0) { + return true; // fast path: nothing in flight + } + + struct timespec deadline; + if (clock_gettime(CLOCK_MONOTONIC, &deadline) != 0) { + Log::error("Profiler::beginTaskBlockRotation: clock_gettime(CLOCK_MONOTONIC) failed " + "(errno=%d). Skipping task-block rotation to avoid a stuck wait.", errno); + _task_block_rotation.store(false, std::memory_order_release); + return false; + } + deadline.tv_nsec += TASK_BLOCK_ROTATION_TIMEOUT_NS; + if (deadline.tv_nsec >= 1000000000L) { + deadline.tv_sec += 1; + deadline.tv_nsec -= 1000000000L; + } + while (_task_block_inflight.load(std::memory_order_acquire) != 0) { + struct timespec now; + if (clock_gettime(CLOCK_MONOTONIC, &now) != 0) { + Log::error("Profiler::beginTaskBlockRotation: clock_gettime(CLOCK_MONOTONIC) failed " + "(errno=%d). Skipping task-block rotation to avoid a stuck wait.", errno); + _task_block_rotation.store(false, std::memory_order_release); + return false; + } + if (now.tv_sec > deadline.tv_sec || + (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec)) { + u64 remaining = _task_block_inflight.load(std::memory_order_acquire); + Log::error("Profiler::beginTaskBlockRotation: timed out after %ldms waiting for " + "%llu in-flight TaskBlock recording(s). Skipping task-block rotation; " + "this indicates a stuck TaskBlock record path.", + TASK_BLOCK_ROTATION_TIMEOUT_NS / 1000000L, + (unsigned long long)remaining); + Counters::increment(TASK_BLOCK_ROTATION_TIMEOUT); + _task_block_rotation.store(false, std::memory_order_release); + return false; + } std::this_thread::yield(); } + return true; } void Profiler::endTaskBlockRotation() { @@ -1890,7 +1930,9 @@ Error Profiler::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 (!beginTaskBlockRotation()) { + return Error("task-block rotation did not drain; teardown skipped, retry stop()"); + } if (_event_mask & EM_ALLOC) _alloc_engine->stop(); @@ -2055,12 +2097,15 @@ Error Profiler::dump(const char *path, const int length) { // dump (fences ASGCT/JNI writers to CallTraceStorage), then clearStandby()s // the rotated buffers. StringDictionary's RefCountGuard protocol handles // its own writer/reader coordination. - beginTaskBlockRotation(); - rotateDictsAndRun([&]{ - err = _jfr.dump(path, length); - __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); - }); - endTaskBlockRotation(); + if (beginTaskBlockRotation()) { + rotateDictsAndRun([&]{ + err = _jfr.dump(path, length); + __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); + }); + endTaskBlockRotation(); + } else { + err = Error("task-block rotation did not drain; dump skipped"); + } _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 a5af1f8af6..3d1228c957 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -185,7 +185,7 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); - void beginTaskBlockRotation(); + bool beginTaskBlockRotation(); void endTaskBlockRotation(); // Rotate all three dictionaries, then run jfr_op under lockAll(). @@ -507,7 +507,7 @@ class alignas(alignof(SpinLock)) Profiler { static void unregisterThread(int tid); #ifdef UNIT_TEST - void beginTaskBlockRotationForTest() { beginTaskBlockRotation(); } + bool beginTaskBlockRotationForTest() { return beginTaskBlockRotation(); } void endTaskBlockRotationForTest() { endTaskBlockRotation(); } bool taskBlockRotationActiveForTest() const { return _task_block_rotation.load(std::memory_order_acquire); diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index c9e3dcf84e..2a82c67dce 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -26,7 +26,6 @@ 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) { u64 start_ticks = 0; Context context{}; @@ -34,11 +33,6 @@ 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); @@ -51,7 +45,7 @@ bool finishTaskBlockAtExit(ProfiledThread* current, u64 unblocking_span_id) { Profiler* profiler = Profiler::instance(); bool recording_enabled = profiler->taskBlockEnabled(); - bool activity = profiler->tryEnterTaskBlockActivity(); + TaskBlockActivity activity; ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(block_token); u64 generation = ThreadFilter::tokenGeneration(block_token); @@ -63,23 +57,19 @@ bool finishTaskBlockAtExit(ProfiledThread* current, bool exited = current_slot == slot_id && thread_filter->snapshotAndExitBlockedRun(slot_id, generation, &snapshot); - if (!activity) { - Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + if (!activity.active()) { + // TaskBlockActivity's constructor already incremented TASK_BLOCK_DROPPED_ROTATION. return false; } if (!recording_enabled || !exited) { - profiler->leaveTaskBlockActivity(); return false; } if (!snapshot.context_eligible) { Counters::increment(TASK_BLOCK_SKIPPED_CONTEXT_WINDOW); - profiler->leaveTaskBlockActivity(); return false; } - bool recorded = recordTaskBlockIfEligible( + return 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 fb11a6155b..172b0f43c1 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.h +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.h @@ -17,7 +17,6 @@ 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); // Completes ThreadFilter lifecycle cleanup for an already-exited producer and diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index c125cfd70a..b3187a157a 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -167,7 +167,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { slot->recording_epoch.store(0, std::memory_order_relaxed); slot->context_window_state.store(0, std::memory_order_relaxed); slot->enableUnownedBlockedFallback(); - slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->clearActiveBlockRun(); if (!indexOrRollback(*slot, reused_slot, tid)) { pushToFreeList(reused_slot); return -1; @@ -211,7 +211,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { slot->recording_epoch.store(0, std::memory_order_relaxed); slot->context_window_state.store(0, std::memory_order_relaxed); slot->enableUnownedBlockedFallback(); - slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->clearActiveBlockRun(); if (!indexOrRollback(*slot, index, tid)) { pushToFreeList(index); return -1; @@ -246,8 +246,7 @@ void ThreadFilter::refreshSlotForRecording(Slot* slot, RecordingEpoch epoch) { Counters::increment(THREAD_REGISTRY_CONTEXT_RESET_RACE_DETECTED); } slot->enableUnownedBlockedFallback(); - - slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->clearActiveBlockRun(); slot->recording_epoch.store(epoch, std::memory_order_release); } @@ -509,7 +508,7 @@ void ThreadFilter::unregisterThreadLocked(SlotID slot_id, int expected_tid) { slot->tid.store(-1, std::memory_order_release); slot->context_window_state.store(0, std::memory_order_release); slot->enableUnownedBlockedFallback(); - slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->clearActiveBlockRun(); pushToFreeList(slot_id); } @@ -542,7 +541,7 @@ void ThreadFilter::resetRegistrationsLocked() { slot.tid.store(-1, std::memory_order_release); slot.context_window_state.store(0, std::memory_order_release); slot.enableUnownedBlockedFallback(); - slot.clearActiveBlockRun(OSThreadState::UNKNOWN); + slot.clearActiveBlockRun(); } } for (auto& entry : _tid_index) { @@ -682,7 +681,7 @@ void ThreadFilter::clearActive() { Slot& slot = chunk->slots[slot_idx]; slot.exitContextWindow(); slot.enableUnownedBlockedFallback(); - slot.clearActiveBlockRun(OSThreadState::UNKNOWN); + slot.clearActiveBlockRun(); } } } @@ -696,7 +695,7 @@ void ThreadFilter::resetSlotRunState(SlotID slot_id) { // Clear stale suppression state so a new thread in this slot cannot // inherit its predecessor's active block. chunk->slots[slot_idx].enableUnownedBlockedFallback(); - chunk->slots[slot_idx].clearActiveBlockRun(OSThreadState::UNKNOWN); + chunk->slots[slot_idx].clearActiveBlockRun(); } } @@ -724,7 +723,7 @@ u64 ThreadFilter::enterBlockedRun(SlotID slot_id, OSThreadState state, void ThreadFilter::exitBlockedRun(SlotID slot_id) { Slot* s = slotForId(slot_id); if (s != nullptr) { - s->clearActiveBlockRun(OSThreadState::RUNNABLE); + s->clearActiveBlockRun(); } } @@ -736,7 +735,7 @@ bool ThreadFilter::exitBlockedRun(SlotID slot_id, u64 generation) { s->blockGeneration() != generation) { return false; } - s->clearActiveBlockRun(OSThreadState::RUNNABLE); + s->clearActiveBlockRun(); return true; } @@ -750,24 +749,26 @@ bool ThreadFilter::snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, return false; } if (snapshot != nullptr) *snapshot = s->snapshotBlockRun(); - s->clearActiveBlockRun(OSThreadState::RUNNABLE); + s->clearActiveBlockRun(); return true; } bool ThreadFilter::activeOwnedBlockGeneration(const ThreadEntry& entry, u64& generation) const { - return ownedBlockGeneration(entry, generation, false); + bool already_sampled = false; + return ownedBlockGeneration(entry, generation, already_sampled); } bool ThreadFilter::isOwnedBlockSuppressionCandidate( const ThreadEntry& entry) const { u64 generation = 0; - return ownedBlockGeneration(entry, generation, true); + bool already_sampled = false; + return ownedBlockGeneration(entry, generation, already_sampled) && already_sampled; } bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, u64& generation, - bool require_sampled) const { + bool& already_sampled) const { Slot* slot = entry.slot; if (!unfilteredWallTrackingActive() || slot == nullptr || slot->nativeTid() != entry.tid || @@ -794,11 +795,8 @@ bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, u64 block_generation = slot->blockGeneration(); BlockRunOwner owner = slot->activeBlockOwner(); - if (owner == BlockRunOwner::NONE || - (require_sampled && - slot->sampledBlockGeneration() != block_generation)) { - return false; - } + u64 sampled_generation = slot->sampledBlockGeneration(); + if (owner == BlockRunOwner::NONE) return false; #ifdef UNIT_TEST if (_suppression_snapshot_hook != nullptr) { @@ -812,8 +810,7 @@ bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, slot->blockGeneration() != block_generation || slot->activeBlockState() != state || slot->nativeTid() != entry.tid || slot->lifecycleGeneration() != entry.lifecycle_generation || - (require_sampled && - slot->sampledBlockGeneration() != block_generation)) { + slot->sampledBlockGeneration() != sampled_generation) { return false; } if (recordingEpoch() != epoch || slot->recordingEpoch() != epoch || @@ -821,6 +818,7 @@ bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, return false; } generation = block_generation; + already_sampled = sampled_generation == block_generation; return true; } diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 53a2a6a8af..4d734c5b4f 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -266,7 +266,7 @@ class ThreadFilter { inline void publishActiveBlockRun(OSThreadState state) { active_block_state.store(state, std::memory_order_release); } - inline void clearActiveBlockRun(OSThreadState) { + inline void clearActiveBlockRun() { active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_release); resetSampledBlockGeneration(); enableUnownedBlockedFallback(); @@ -340,6 +340,14 @@ class ThreadFilter { bool activeOwnedBlockGeneration(const ThreadEntry& entry, u64& generation) const; bool isOwnedBlockSuppressionCandidate(const ThreadEntry& entry) const; + // Single-pass merge of activeOwnedBlockGeneration()/isOwnedBlockSuppressionCandidate(): + // callers that need both the generation and the already-sampled state should use this + // instead of calling the two wrappers above in sequence, which would re-validate the + // same slot atomics twice. + bool ownedBlockDecision(const ThreadEntry& entry, bool& already_sampled, + u64& generation) const { + return ownedBlockGeneration(entry, generation, already_sampled); + } #ifdef UNIT_TEST using SuppressionSnapshotHook = void (*)(void*); @@ -406,7 +414,7 @@ class ThreadFilter { private: bool ownedBlockGeneration(const ThreadEntry& entry, u64& generation, - bool require_sampled) const; + bool& already_sampled) const; // Lock-free free list using a stack-like structure struct FreeListNode { diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index da3bec4a09..110124b7a7 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -119,13 +119,14 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, ThreadEntry entry{current->tid(), slot, slot->lifecycleGeneration(), slot->recordingEpoch()}; - if (registry->isOwnedBlockSuppressionCandidate(entry)) { - incrementSuppressedOwnedBlock(); - result.suppress = true; - return result; - } u64 block_generation = 0; - if (registry->activeOwnedBlockGeneration(entry, block_generation)) { + bool already_sampled = false; + if (registry->ownedBlockDecision(entry, already_sampled, block_generation)) { + if (already_sampled) { + incrementSuppressedOwnedBlock(); + result.suppress = true; + return result; + } // Arm only after recordSample succeeds. A skipped JFR write must leave the // run eligible so the next signal retries instead of losing its only stack. result.owned_block_slot = slot; 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 c84d61cb42..c5b127ee08 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -458,6 +458,22 @@ public boolean endTaskBlock(long token, long blocker, long unblockingSpanId) { return endTaskBlock0(Thread.currentThread(), token, blocker, unblockingSpanId); } + /** + * Test-only hook exercising {@link #beginTaskBlock0} with an explicit {@code thread}, to cover + * the rejection path when it does not identify the calling thread. + */ + long beginTaskBlockForThread(Thread thread) { + return beginTaskBlock0(thread); + } + + /** + * Test-only hook exercising {@link #endTaskBlock0} with an explicit {@code thread}, to cover + * the rejection path when it does not identify the calling thread. + */ + boolean endTaskBlockForThread(Thread thread, long token, long blocker, long unblockingSpanId) { + return endTaskBlock0(thread, token, blocker, unblockingSpanId); + } + /** * Get the ticks for the current thread. * @return ticks diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index a4a94809df..b1863f72ad 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -38,6 +38,19 @@ class JvmSupportGlobalSetup { }; static JvmSupportGlobalSetup jvm_support_global_setup; +// --------------------------------------------------------------------------- +// VMTestAccessor — friend of VM, lets tests swap VM::_jvmti/_hotspot for a +// mock/forced value so JVM-vendor-dependent code paths can be exercised +// deterministically without a live JVM. +// --------------------------------------------------------------------------- +class VMTestAccessor { +public: + static jvmtiEnv* getJvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } + static bool getHotspot() { return VM::_hotspot; } + static void setHotspot(bool value) { VM::_hotspot = value; } +}; + class JvmSupportThreadClassificationTest : public ::testing::Test { protected: using JniFunction = void (JNICALL*)(); @@ -55,6 +68,7 @@ class JvmSupportThreadClassificationTest : public ::testing::Test { JNIEnv jni{}; _jobject thread_object; jthread thread = &thread_object; + bool _orig_hotspot = false; static jint JNICALL getVersion(JNIEnv*) { return jni_version; } @@ -65,6 +79,8 @@ class JvmSupportThreadClassificationTest : public ::testing::Test { } void SetUp() override { + _orig_hotspot = VMTestAccessor::getHotspot(); + VMTestAccessor::setHotspot(true); jni_version = 0x00150000; virtual_thread = JNI_FALSE; is_virtual_thread_calls = 0; @@ -76,6 +92,10 @@ class JvmSupportThreadClassificationTest : public ::testing::Test { jni.functions = reinterpret_cast(function_table); } + + void TearDown() override { + VMTestAccessor::setHotspot(_orig_hotspot); + } }; TEST_F(JvmSupportThreadClassificationTest, NullInputsFailClosed) { @@ -160,15 +180,63 @@ TEST_F(JvmSupportThreadClassificationTest, MissingJni21FunctionFailsClosed) { } // --------------------------------------------------------------------------- -// VMTestAccessor — friend of VM, lets tests swap VM::_jvmti for a mock so -// JVMThread::currentThreadSlow() can be exercised without a live JVM. +// JvmSupportNonHotspotTest — verifies isPlatformThread() short-circuits to +// true on non-HotSpot JVMs at JNI>=19 without ever indexing the HotSpot-only +// IsVirtualThread vtable slot (which may not even be a valid function +// pointer there). // --------------------------------------------------------------------------- -class VMTestAccessor { -public: - static jvmtiEnv* getJvmti() { return VM::_jvmti; } - static void setJvmti(jvmtiEnv* env) { VM::_jvmti = env; } +class JvmSupportNonHotspotTest : 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 int is_virtual_thread_calls; + + JniFunction function_table[FUNCTION_TABLE_SIZE]{}; + JNIEnv jni{}; + _jobject thread_object; + jthread thread = &thread_object; + bool _orig_hotspot = false; + + static jint JNICALL getVersion(JNIEnv*) { return jni_version; } + static jboolean JNICALL isVirtualThread(JNIEnv*, jobject) { + is_virtual_thread_calls++; + return JNI_TRUE; + } + + void SetUp() override { + _orig_hotspot = VMTestAccessor::getHotspot(); + VMTestAccessor::setHotspot(false); + jni_version = 0x00150000; + is_virtual_thread_calls = 0; + function_table[GET_VERSION_INDEX] = + reinterpret_cast(&getVersion); + function_table[IS_VIRTUAL_THREAD_INDEX] = + reinterpret_cast(&isVirtualThread); + jni.functions = + reinterpret_cast(function_table); + } + + void TearDown() override { + VMTestAccessor::setHotspot(_orig_hotspot); + } }; +TEST_F(JvmSupportNonHotspotTest, Jni21ThreadIsAcceptedWithoutIndexingVtable) { + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportNonHotspotTest, PreJni19ThreadIsStillAccepted) { + jni_version = 0x000a0000; + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + // --------------------------------------------------------------------------- // ProfilerTestAccessor — friend of Profiler, lets tests force the internal // state machine to a known value so checkState()/start()/check() can be diff --git a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp index 9745609ad6..652f4e6bb6 100644 --- a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -137,6 +137,27 @@ TEST_F(TaskBlockRecorderTest, RotationWaitsForInflightActivity) { profiler->leaveTaskBlockActivity(); } +TEST_F(TaskBlockRecorderTest, RotationTimesOutOnStuckInflightActivity) { + Profiler* profiler = Profiler::instance(); + ASSERT_TRUE(profiler->tryEnterTaskBlockActivity()); // leaked on purpose: simulates a stuck recorder + ASSERT_EQ(1, profiler->taskBlockInflightForTest()); + + auto start = std::chrono::steady_clock::now(); + bool result = profiler->beginTaskBlockRotationForTest(); + auto elapsed = std::chrono::steady_clock::now() - start; + + EXPECT_FALSE(result); + // Lower bound is deliberately looser than the 200ms timeout to avoid flakiness + // from clock/scheduling jitter around the deadline boundary. + EXPECT_GE(elapsed, std::chrono::milliseconds(150)); + EXPECT_LT(elapsed, std::chrono::milliseconds(2000)); // sanity bound, not a tight timing assertion + EXPECT_FALSE(profiler->taskBlockRotationActiveForTest()); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_ROTATION_TIMEOUT)); + + profiler->leaveTaskBlockActivity(); // clean up the leaked inflight count for later tests + ASSERT_EQ(0, profiler->taskBlockInflightForTest()); +} + TEST_F(TaskBlockRecorderTest, RotationRejectsEndWithoutStrandingLifecycle) { constexpr int tid = 12345; ThreadFilter filter; @@ -157,9 +178,7 @@ TEST_F(TaskBlockRecorderTest, RotationRejectsEndWithoutStrandingLifecycle) { 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); + current.get(), &filter, nullptr, 1, token, 0, 0); }); std::future_status status = result.wait_for(std::chrono::seconds(1)); diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java index 54b9106196..68208fac73 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ProfilerOwnedBlockHooks.java @@ -24,4 +24,13 @@ public static long blockEnter(JavaProfiler profiler, int state) { public static void blockExit(JavaProfiler profiler, long token) { profiler.blockExit(token); } + + public static long beginTaskBlockForThread(JavaProfiler profiler, Thread thread) { + return profiler.beginTaskBlockForThread(thread); + } + + public static boolean endTaskBlockForThread(JavaProfiler profiler, Thread thread, long token, + long blocker, long unblockingSpanId) { + return profiler.endTaskBlockForThread(thread, token, blocker, unblockingSpanId); + } } 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 9551ee946f..f91a0f79cd 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 @@ -19,6 +19,7 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.ProfilerOwnedBlockHooks; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -138,6 +139,32 @@ public void traceContextRejectsAtEntry() throws Exception { "a traced interval must not arm timer-side suppression"); } + @Test + public void mismatchedThreadIsRejectedAtBeginAndEnd() throws Exception { + AtomicLong beginToken = new AtomicLong(-1L); + AtomicBoolean endResult = new AtomicBoolean(true); + AtomicLong ownToken = new AtomicLong(); + Thread other = new Thread(() -> { }, "taskblock-mismatch-other"); + + runWorker(() -> { + beginToken.set(ProfilerOwnedBlockHooks.beginTaskBlockForThread(profiler, other)); + + ownToken.set(profiler.beginTaskBlock()); + assertTrue(ownToken.get() != 0); + endResult.set(ProfilerOwnedBlockHooks.endTaskBlockForThread( + profiler, other, ownToken.get(), BLOCKER, UNBLOCKING_SPAN_ID)); + profiler.endTaskBlock(ownToken.get(), BLOCKER, UNBLOCKING_SPAN_ID); + }); + + assertEquals(0L, beginToken.get(), + "beginTaskBlock0 must reject a jthread that does not identify the calling thread"); + assertFalse(endResult.get(), + "endTaskBlock0 must reject a jthread that does not identify the calling thread"); + + stopProfiler(); + assertTrue(getRecordedCounterValue("task_block_skipped_thread_mismatch") > 0); + } + @Test public void virtualThreadCannotMutateCarrierTaskBlockState() throws Exception { Method startVirtualThread; 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 ca1770d7cf..507cc5ec95 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 @@ -63,6 +63,21 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { } } + @Test + public void testBlockEnterRejectedWithActiveTraceContext() { + Assumptions.assumeTrue(!Platform.isJ9()); + Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); + + profiler.setTraceContext(0x5100L, 0x5101L, 0L, 0x5101L, -1, null, -1, null); + try { + long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); + assertEquals(0L, token, + "Expected blockEnter to reject arming while an active trace context is set"); + } finally { + profiler.clearTraceContext(); + } + } + @Test public void unownedSleepingThreadIsNotExactOncePerRunSuppressed() throws Exception { Assumptions.assumeTrue(!Platform.isJ9()); From 51e879e46f5917762459b7eea0bcf98133199d6d Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 19 Aug 2026 09:29:48 +0200 Subject: [PATCH 13/19] fix: address review comments Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/main/cpp/counters.h | 2 + ddprof-lib/src/main/cpp/profiler.cpp | 72 ++++++++++----------- ddprof-lib/src/main/cpp/profiler.h | 10 +++ ddprof-lib/src/main/cpp/threadFilter.cpp | 10 +++ ddprof-lib/src/main/cpp/threadFilter.h | 7 ++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 55 ++++++++++++++++ 6 files changed, 117 insertions(+), 39 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 8d4d0e827d..8420c03caf 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -64,6 +64,8 @@ X(THREAD_REGISTRY_INDEX_FAILURES, "thread_registry_index_failures") \ X(THREAD_REGISTRY_CONTEXT_RESET_RACE_DETECTED, "thread_registry_context_reset_race_detected") \ X(THREAD_REGISTRY_JAVACRITICAL_REREGISTRATION, "thread_registry_javacritical_reregistration") \ + X(THREAD_REGISTRY_BLOCK_GENERATION_SATURATED, "thread_registry_block_generation_saturated") \ + X(THREAD_REGISTRY_UNREGISTER_ACTIVE_BLOCK_RUN, "thread_registry_unregister_active_block_run") \ X(JMETHODID_SKIPPED, "jmethodid_skipped_count") \ X(CODECACHE_NATIVE_SIZE_BYTES, "codecache_native_size_bytes") \ X(CODECACHE_NATIVE_COUNT, "native_codecache_count") \ diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 427b570ea0..38d4242d5c 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -546,6 +546,34 @@ int Profiler::convertNativeTrace(int native_frames, const void **callchain, return depth; } +int Profiler::captureJVMTIFrames(jthread thread, int start_depth, + CallTraceBuffer* buffer) { + ASGCT_CallFrame *frames = buffer->_asgct_frames; + jvmtiFrameInfo *jvmti_frames = buffer->_jvmti_frames; + jint num_frames = 0; + if (VM::jvmti()->GetStackTrace(thread, start_depth, _max_stack_depth, + jvmti_frames, &num_frames) != JVMTI_ERROR_NONE || + num_frames <= 0) { + return 0; + } + copyJvmtiFrames(frames, jvmti_frames, num_frames); + // See recordJVMTISample's original comment: GetStackTrace on a JDK21+ virtual + // thread returns only the VT's logical stack, stopping at the continuation + // boundary. Append a synthetic root frame so the UI doesn't report it as + // "Missing Frames". + if (VM::isHotspot() && VM::hotspot_version() >= 21 && + num_frames < _max_stack_depth) { + VMThread* carrier = VMThread::current(); + if (carrier != nullptr && carrier->isCarryingVirtualThread()) { + frames[num_frames].bci = BCI_NATIVE_FRAME; + frames[num_frames].method_id = (jmethodID) "JVM Continuation"; + LP64_ONLY(frames[num_frames].padding = 0;) + num_frames++; + } + } + return num_frames; +} + u64 Profiler::recordJVMTISample(u64 counter, int tid, jthread thread, jint event_type, Event *event, bool deferred) { // Called from non-signal based sampler ProfiledThread* prof_thread = ProfiledThread::initCurrentThreadSignalSafe(); @@ -581,37 +609,8 @@ u64 Profiler::recordJVMTISample(u64 counter, int tid, jthread thread, jint event #ifdef COUNTERS u64 startTime = TSC::ticks(); #endif // COUNTERS - ASGCT_CallFrame *frames = buf->_asgct_frames; - jvmtiFrameInfo *jvmti_frames = buf->_jvmti_frames; - - int num_frames = 0; - - 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. - 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 - // truncated to the UI backend, which attributes it to "Missing Frames". - // Detect the VT case via JavaThread::_cont_entry being non-null on the - // carrier. This field is in gHotSpotVMStructs on all JDK 21+ builds so - // isCarryingVirtualThread() works regardless of JDK version. Append a - // synthetic "JVM Continuation" root frame to mark the boundary - // explicitly, matching the behaviour of walkVM without carrier_frames. - if (VM::isHotspot() && VM::hotspot_version() >= 21 && - num_frames < _max_stack_depth) { - VMThread* carrier = VMThread::current(); - if (carrier != nullptr && carrier->isCarryingVirtualThread()) { - frames[num_frames].bci = BCI_NATIVE_FRAME; - frames[num_frames].method_id = (jmethodID) "JVM Continuation"; - LP64_ONLY(frames[num_frames].padding = 0;) - num_frames++; - } - } - } - - call_trace_id = _call_trace_storage.put(num_frames, frames, false, counter); + int num_frames = captureJVMTIFrames(thread, 0, buf); + call_trace_id = _call_trace_storage.put(num_frames, buf->_asgct_frames, false, counter); #ifdef COUNTERS u64 duration = TSC::ticks() - startTime; if (duration > 0) { @@ -816,22 +815,17 @@ Profiler::TaskBlockRecordResult Profiler::recordTaskBlock( } 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) { + int num_frames = captureJVMTIFrames(thread, start_depth, buffer); + if (num_frames <= 0) { _locks[lock_index].unlock(); return TaskBlockRecordResult::STACK_CAPTURE_FAILED; } - copyJvmtiFrames(frames, jvmti_frames, num_frames); u64 call_trace_id = - _call_trace_storage.put(num_frames, frames, false, 1); + _call_trace_storage.put(num_frames, buffer->_asgct_frames, false, 1); #ifdef COUNTERS u64 stack_duration = TSC::ticks() - stack_start; if (stack_duration > 0) { diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 3d1228c957..759531e5ea 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -457,6 +457,16 @@ class alignas(alignof(SpinLock)) Profiler { // RequestStackTrace as user_data. bool recordSampleDelegated(void *ucontext, u64 weight, int tid, jint event_type, Event *event); + // Shared by recordJVMTISample()/recordTaskBlock(): performs the JVMTI stack walk for + // `thread` starting at `start_depth`, converts to ASGCT format, and applies the + // JDK21+ virtual-thread continuation-boundary fixup (a real stack, from a carrier's + // perspective, that GetStackTrace on a VT truncates at the continuation boundary). + // Caller must already hold _locks[lock_index] and pass a buffer sized for + // _max_stack_depth frames. Returns the number of frames written into + // buffer->_asgct_frames, or 0 if JVMTI failed to produce any frame — callers differ + // on whether that is a hard failure or a valid empty trace, so this function does + // not decide that, and does not call _call_trace_storage.put() itself. + int captureJVMTIFrames(jthread thread, int start_depth, CallTraceBuffer* buffer); u64 recordJVMTISample(u64 weight, int tid, jthread thread, jint event_type, Event *event, bool deferred); void recordDeferredSample(int tid, u64 call_trace_id, jint event_type, Event *event); void recordExternalSample(u64 weight, int tid, int num_frames, diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index b3187a157a..a7536057e9 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -164,6 +164,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { if (reused_slot >= 0) { Slot* slot = slotForId(reused_slot); slot->lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); + slot->block_generation.store(0, std::memory_order_relaxed); slot->recording_epoch.store(0, std::memory_order_relaxed); slot->context_window_state.store(0, std::memory_order_relaxed); slot->enableUnownedBlockedFallback(); @@ -504,6 +505,14 @@ void ThreadFilter::unregisterThreadLocked(SlotID slot_id, int expected_tid) { int tid = slot->nativeTid(); if (expected_tid >= 0 && tid != expected_tid) return; unindexSlot(slot_id, tid); + if (slot->activeBlockOwner() != BlockRunOwner::NONE) { + // A thread should never unregister while still holding an active block + // run (its own synchronous begin/end pair must complete on the same + // still-live thread first). If this ever fires, block_generation resets + // on the next reuse of this slot are relying on an invariant that just + // broke -- investigate immediately rather than trusting the reset is safe. + Counters::increment(THREAD_REGISTRY_UNREGISTER_ACTIVE_BLOCK_RUN); + } 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); @@ -537,6 +546,7 @@ void ThreadFilter::resetRegistrationsLocked() { if (slot.nativeTid() != -1) { slot.lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); } + slot.block_generation.store(0, std::memory_order_relaxed); 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); diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 4d734c5b4f..e029ec7323 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -25,6 +25,7 @@ #include #include "arch.h" +#include "counters.h" #include "threadState.h" class ProfiledThread; @@ -168,6 +169,11 @@ class ThreadFilter { inline u64 blockGeneration() const { return block_generation.load(std::memory_order_acquire); } +#ifdef UNIT_TEST + inline void setBlockGenerationForTest(u64 value) { + block_generation.store(value, std::memory_order_relaxed); + } +#endif inline u64 sampledBlockGeneration() const { return sampled_block_generation.load(std::memory_order_acquire); } @@ -253,6 +259,7 @@ class ThreadFilter { if (generation == kMaxBlockRunGeneration) { active_block_owner.store(static_cast(BlockRunOwner::NONE), std::memory_order_release); + Counters::increment(THREAD_REGISTRY_BLOCK_GENERATION_SATURATED); return false; } generation++; diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index c0dbf71a35..b0ab362e5a 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -597,6 +597,61 @@ TEST_F(ThreadFilterTest, NewGenerationRejectsStaleToken) { EXPECT_TRUE(filter->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(current_token))); } +TEST_F(ThreadFilterTest, SaturatedGenerationIsCountedAndRecoversAfterSlotReuse) { + int slot_id = filter->registerThread(); + ASSERT_GE(slot_id, 0); + filter->add(3333, slot_id); + + ThreadFilter::Slot *slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); +#ifdef UNIT_TEST + slot->setBlockGenerationForTest(ThreadFilter::kMaxBlockRunGeneration); +#endif + +#ifdef COUNTERS + long long saturated_before = + Counters::getCounter(THREAD_REGISTRY_BLOCK_GENERATION_SATURATED); +#endif + EXPECT_EQ(0ULL, filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING)); +#ifdef COUNTERS + EXPECT_EQ(saturated_before + 1, + Counters::getCounter(THREAD_REGISTRY_BLOCK_GENERATION_SATURATED)); +#endif + + filter->unregisterThread(slot_id); + int reused_slot_id = filter->registerThread(); + ASSERT_EQ(slot_id, reused_slot_id) + << "test relies on the free list handing back the just-freed slot"; + filter->add(3334, reused_slot_id); + + ThreadFilter::Slot *reused_slot = filter->slotForId(reused_slot_id); + ASSERT_NE(nullptr, reused_slot); + EXPECT_EQ(0ULL, reused_slot->blockGeneration()); + + u64 token = filter->enterBlockedRun(reused_slot_id, OSThreadState::SLEEPING); + EXPECT_NE(0ULL, token); + EXPECT_TRUE(filter->exitBlockedRun(reused_slot_id, ThreadFilter::tokenGeneration(token))); +} + +TEST_F(ThreadFilterTest, UnregisterWhileBlockRunActiveIsCounted) { + int slot_id = filter->registerThread(); + ASSERT_GE(slot_id, 0); + filter->add(4444, slot_id); + + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0ULL, token); + +#ifdef COUNTERS + long long before = + Counters::getCounter(THREAD_REGISTRY_UNREGISTER_ACTIVE_BLOCK_RUN); +#endif + filter->unregisterThread(slot_id); +#ifdef COUNTERS + EXPECT_EQ(before + 1, + Counters::getCounter(THREAD_REGISTRY_UNREGISTER_ACTIVE_BLOCK_RUN)); +#endif +} + TEST_F(ThreadFilterTest, TokenRoundTripPreservesNegativeJavaLongBitPattern) { ThreadFilter::SlotID slot_id = 7; u64 generation = 1ULL << 52; From fdae4ffbb3e6708bdf6e20e5f9aad65eb696b959 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 19 Aug 2026 09:54:47 +0200 Subject: [PATCH 14/19] fix: trim unused BlockRunSnapshot fields owner/generation/active aren't consumed within this PR's diff; they'll be reintroduced in a follow-up PR once a real caller needs them. Co-Authored-By: Claude Sonnet 5 --- ddprof-lib/src/main/cpp/threadFilter.h | 7 ------- ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 5 +---- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index e029ec7323..efba474c55 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -40,9 +40,6 @@ enum class BlockRunOwner : int { struct BlockRunSnapshot { OSThreadState active_state{OSThreadState::UNKNOWN}; - BlockRunOwner owner{BlockRunOwner::NONE}; - u64 generation{0}; - bool active{false}; bool context_eligible{false}; }; @@ -288,10 +285,6 @@ class ThreadFilter { 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; } diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index b0ab362e5a..057ac0086f 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -723,14 +723,11 @@ TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { ASSERT_NE(0ULL, token); BlockRunSnapshot snapshot = slot->snapshotBlockRun(); - 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(slot->snapshotBlockRun().active); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->snapshotBlockRun().active_state); } TEST_F(ThreadFilterTest, OwnedBlockSuppressesOnlyAfterSuccessfulWallSample) { From c632b1f0a9dddc381690791b318640ef617f6ddc Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 19 Aug 2026 12:21:21 +0200 Subject: [PATCH 15/19] fix: fix bug --- ddprof-lib/src/main/cpp/threadFilter.cpp | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index a7536057e9..d4463b62e5 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -546,7 +546,12 @@ void ThreadFilter::resetRegistrationsLocked() { if (slot.nativeTid() != -1) { slot.lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); } - slot.block_generation.store(0, std::memory_order_relaxed); + // block_generation is intentionally left untouched here, mirroring + // unregisterThreadLocked(): it must stay monotonic for the life of + // the slot so a stale token from before a stop/restart can never + // numerically collide with a token issued after. Resetting it to 0 + // is what previously let a pre-restart token satisfy the + // post-restart blockGeneration() check. 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); From 554f450b8f488a22fe326c16a1ab2c724bc545d3 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 19 Aug 2026 15:25:51 +0200 Subject: [PATCH 16/19] feat: squash commit for PR 664 rebase --- .../native/config/ConfigurationPresets.kt | 15 + ddprof-lib/src/main/cpp/javaApi.cpp | 90 ++-- ddprof-lib/src/main/cpp/jvmSupport.cpp | 16 +- ddprof-lib/src/main/cpp/profiler.cpp | 31 +- ddprof-lib/src/main/cpp/profiler.h | 5 + ddprof-lib/src/main/cpp/threadFilter.h | 7 + ddprof-lib/src/main/cpp/threadLocalData.h | 96 +++- ddprof-lib/src/main/cpp/vmEntry.cpp | 231 +++++++- ddprof-lib/src/main/cpp/vmEntry.h | 25 +- .../com/datadoghq/profiler/JavaProfiler.java | 62 ++- ddprof-lib/src/test/cpp/jvmSupport_ut.cpp | 5 - ddprof-lib/src/test/cpp/park_state_ut.cpp | 73 +++ .../src/test/cpp/taskBlockRecorder_ut.cpp | 97 ++++ ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 5 +- ddprof-lib/src/test/cpp/vmEntry_ut.cpp | 500 ++++++++++++++++++ .../datadoghq/profiler/ExternalLauncher.java | 136 +++++ .../profiler/JavaProfilerApiSurfaceTest.java | 10 + .../datadoghq/profiler/JavaProfilerTest.java | 197 ++++++- .../JvmtiBasedMonitorTaskBlockTest.java | 30 ++ .../JvmtiBasedParkTaskBlockTest.java | 30 ++ .../wallclock/MonitorTaskBlockTest.java | 240 +++++++++ .../profiler/wallclock/ParkTaskBlockTest.java | 169 ++++++ .../wallclock/TaskBlockAssertions.java | 9 + 23 files changed, 2006 insertions(+), 73 deletions(-) create mode 100644 ddprof-lib/src/test/cpp/vmEntry_ut.cpp 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/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt index 06bd64d4dd..8ec470b078 100644 --- a/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt +++ b/build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt @@ -1,3 +1,18 @@ +/* + * 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.native.config diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index fccfd229b6..94560f97fc 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -70,7 +70,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()); @@ -79,13 +80,22 @@ 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 - if (VM::initProfilerBridge(nullptr, true)) { - // Attach ProfiledThread - ProfiledThread::initCurrentThreadSignalSafe(); - return JNI_TRUE; - } else { + 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; + } + // Attach ProfiledThread + ProfiledThread::initCurrentThreadSignalSafe(); + return JNI_TRUE; } extern "C" DLLEXPORT void JNICALL @@ -110,6 +120,12 @@ Java_com_datadoghq_profiler_JavaProfiler_getTid0(JNIEnv *env, jclass unused) { return OS::threadId(); } +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_monitorWaitEventsDelegated0( + JNIEnv *env, jclass unused) { + return VM::monitorWaitEventsDelegated(); +} + extern "C" DLLEXPORT jstring JNICALL Java_com_datadoghq_profiler_JavaProfiler_execute0(JNIEnv *env, jobject unused, jstring command) { @@ -389,44 +405,55 @@ Java_com_datadoghq_profiler_JavaProfiler_recordQueueEnd0( } extern "C" DLLEXPORT jboolean 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 JNI_FALSE; + } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); if (current == nullptr) { return JNI_FALSE; } + Context context = ContextApi::snapshot(); + if (!current->parkEnter(TSC::ticks(), context)) { + return JNI_FALSE; + } - bool first_park = current->parkEnter(); - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (first_park && tf->registryActive()) { + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + if (context.spanId == 0 && tf->registryActive() && + (profiler->taskBlockEnabled() || tf->enabled())) { ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(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)); } } - return first_park ? JNI_TRUE : JNI_FALSE; + return JNI_TRUE; } 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)); - } - } + finishTaskBlockAtExit(current, Profiler::instance()->threadFilter(), thread, + 1, park_block_token, start_ticks, context, + static_cast(blocker), + static_cast(unblockingSpanId)); } static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { @@ -454,13 +481,14 @@ static bool isCurrentJniThread(JNIEnv* env, jthread thread) { extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( - JNIEnv *env, jclass unused, jint state) { - ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); - if (current == nullptr) { + JNIEnv *env, jclass unused, jthread thread, jint state) { + OSThreadState decoded; + if (!decodeJavaBlockState(state, decoded) || + !JVMSupport::isPlatformThread(env, thread)) { return 0; } - OSThreadState decoded; - if (!decodeJavaBlockState(state, decoded)) { + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); + if (current == nullptr) { return 0; } u64 span_id = 0, root_span_id = 0; @@ -480,9 +508,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; } diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index 72fb1bc8b0..3c5f67b24c 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -18,6 +18,8 @@ #include +#include + using JniFunction = void (JNICALL*)(); using IsVirtualThreadFunction = jboolean (JNICALL*)(JNIEnv*, jobject); @@ -44,11 +46,21 @@ bool JVMSupport::isPlatformThread(JNIEnv* jni, jthread thread) { const JniFunction* functions = reinterpret_cast(jni->functions); + if (functions == nullptr) return false; IsVirtualThreadFunction is_virtual_thread = reinterpret_cast( functions[IS_VIRTUAL_THREAD_INDEX]); - return is_virtual_thread != nullptr && - is_virtual_thread(jni, thread) == JNI_FALSE; + if (is_virtual_thread == nullptr) { + static std::atomic warning_emitted{false}; + bool expected = false; + if (warning_emitted.compare_exchange_strong(expected, true, + std::memory_order_relaxed)) { + LOG_WARN("JNI version 19 or later does not expose IsVirtualThread; " + "JVM producer callbacks will be ignored"); + } + return false; + } + return is_virtual_thread(jni, thread) == JNI_FALSE; } bool JVMSupport::initialize() { diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 38d4242d5c..51630d6042 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1541,6 +1541,26 @@ Error Profiler::init() { return Error::OK; } +void Profiler::setTaskBlockEnabled(bool enabled) { + if (enabled) { + // Keep callback admission closed until native setup has either completed + // or rolled back, so partial event enablement cannot create paired state. + bool monitor_events_enabled = + VM::nativeMonitorEventsAvailable() && + VM::setNativeMonitorEventsEnabled(true); + _task_block_monitor_events_enabled.store(monitor_events_enabled, + std::memory_order_release); + _task_block_enabled.store(true, std::memory_order_release); + return; + } + + _task_block_enabled.store(false, std::memory_order_release); + if (_task_block_monitor_events_enabled.exchange( + false, std::memory_order_acq_rel)) { + VM::setNativeMonitorEventsEnabled(false); + } +} + Error Profiler::start(Arguments &args, bool reset) { MutexLocker ml(_state_lock); Error error = checkState(); @@ -1876,9 +1896,8 @@ 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); + setTaskBlockEnabled( + (activated & EM_WALL) && args._wall_precheck && track_unfiltered_wall); _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1903,7 +1922,7 @@ Error Profiler::stop() { if (state() != RUNNING) { return Error("Profiler is not active"); } - _task_block_enabled.store(false, std::memory_order_release); + setTaskBlockEnabled(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 @@ -2090,7 +2109,9 @@ Error Profiler::dump(const char *path, const int length) { // rotateDictsAndRun rotates the dictionaries, takes lockAll() around the // dump (fences ASGCT/JNI writers to CallTraceStorage), then clearStandby()s // the rotated buffers. StringDictionary's RefCountGuard protocol handles - // its own writer/reader coordination. + // its own writer/reader coordination; #527's classMapSharedGuard readers + // (deferred vtable receiver resolution) are coordinated through + // _class_map_lock. if (beginTaskBlockRotation()) { rotateDictsAndRun([&]{ err = _jfr.dump(path, length); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 759531e5ea..e743ed3e88 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -133,6 +133,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}; + std::atomic _task_block_monitor_events_enabled{false}; std::atomic _task_block_rotation{false}; std::atomic _task_block_inflight{0}; @@ -185,6 +186,7 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); + void setTaskBlockEnabled(bool enabled); bool beginTaskBlockRotation(); void endTaskBlockRotation(); @@ -494,6 +496,9 @@ class alignas(alignof(SpinLock)) Profiler { bool taskBlockEnabled() const { return _task_block_enabled.load(std::memory_order_acquire); } + bool nativeMonitorTaskBlockEnabled() const { + return _task_block_monitor_events_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, diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index efba474c55..e029ec7323 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -40,6 +40,9 @@ enum class BlockRunOwner : int { struct BlockRunSnapshot { OSThreadState active_state{OSThreadState::UNKNOWN}; + BlockRunOwner owner{BlockRunOwner::NONE}; + u64 generation{0}; + bool active{false}; bool context_eligible{false}; }; @@ -285,6 +288,10 @@ class ThreadFilter { 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; } diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 96ba77eaa5..1509cd497e 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -56,7 +56,8 @@ class ProfiledThread : public ThreadLocalData { }; static constexpr u32 FLAG_PARKED = 0x4u; // next free bit after TYPE_MASK (0x1|0x2) - static constexpr u32 FLAG_CLAIMED = 0x8u; // Used by ThreadLocalDataPool only + static constexpr u32 FLAG_CLAIMED = 0x8u; // Used by ThreadLocalDataPool only + static constexpr u32 FLAG_MONITOR_BLOCKED = 0x10u; // We are allowing several levels of nesting because we can be // eg. in a crash handler when wallclock signal kicks in, @@ -86,10 +87,17 @@ class ProfiledThread : public ThreadLocalData { u64 _call_trace_id; u32 _recording_epoch; volatile 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) volatile uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) @@ -112,8 +120,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), @@ -412,11 +423,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; } @@ -439,16 +463,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 ef1561a8d3..6583b0d129 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" @@ -15,10 +16,13 @@ #include "jvmThread.h" #include "libraries.h" #include "log.h" +#include "mutex.h" #include "os.h" #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,8 +52,16 @@ bool VM::_hotspot = false; bool VM::_zing = false; bool VM::_can_sample_objects = false; bool VM::_can_intercept_binding = false; +bool VM::_monitor_wait_events_delegated = false; +bool VM::_native_monitor_events_available = false; +bool VM::_profiler_bridge_initialized = 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; + jvmtiExtensionFunction VM::_request_stack_trace = nullptr; jvmtiExtensionFunction VM::_init_request_stack_trace = nullptr; @@ -67,6 +79,118 @@ 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() || + !profiler->nativeMonitorTaskBlockEnabled() || + !JVMSupport::isPlatformThread(jni, thread)) { + return; + } + ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); + 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); + 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; + } + current->clearMonitorBlock(); + if (!current->monitorEnter(TSC::ticks(), context, + monitorBlockerHash(jvmti, object), state)) { + return; + } + } + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); + if (!tf->unfilteredWallTrackingActive() || 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(); + finishTaskBlockAtExit(current, profiler->threadFilter(), thread, 0, token, + start_ticks, context, blocker, 0); +} + +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::monitorWaitEventsDelegated()) { + 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::monitorWaitEventsDelegated()) { + 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 @@ -385,6 +509,11 @@ bool VM::initShared(JavaVM* vm) { } bool VM::initLibrary(JavaVM *vm) { + MutexLocker init_locker(profiler_bridge_init_lock); + if (_profiler_bridge_initialized) { + return true; + } + TEST_LOG("VM::initLibrary"); if (!initShared(vm)) { return false; @@ -443,15 +572,31 @@ bool VM::initializeRequestStackTrace() { return false; } -bool VM::initProfilerBridge(JavaVM *vm, bool attach) { +void VM::configureMonitorEvents(bool delegateMonitorWaitEvents) { + jvmtiCapabilities actual_capabilities = {0}; + _jvmti->GetCapabilities(&actual_capabilities); + _native_monitor_events_available = + actual_capabilities.can_generate_monitor_events; + _monitor_wait_events_delegated = delegateMonitorWaitEvents; +} + +ProfilerBridgeInitResult VM::initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorWaitEvents) { + MutexLocker init_locker(profiler_bridge_init_lock); + if (_profiler_bridge_initialized) { + return delegateMonitorWaitEvents == _monitor_wait_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; } // Under Agent_OnLoad (attach == false), this is the first native entry point and @@ -482,6 +627,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; @@ -498,11 +645,13 @@ 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); + configureMonitorEvents(delegateMonitorWaitEvents); + if (_hotspot) { probeJFRRequestStackTrace(); } @@ -519,6 +668,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); @@ -571,7 +726,70 @@ 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) { + if (!_native_monitor_events_available) return false; + + jvmtiError enter = JVMTI_ERROR_NONE; + jvmtiError entered = JVMTI_ERROR_NONE; + jvmtiError wait = JVMTI_ERROR_NONE; + jvmtiError waited = JVMTI_ERROR_NONE; + + if (enabled) { + // JVMTI enables each event independently and does not queue events that + // occur while disabled. Install every terminal notification before its + // entry notification so an admitted interval always has an exit path. + entered = _jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, NULL); + if (entered != JVMTI_ERROR_NONE) goto enable_failed; + + if (!_monitor_wait_events_delegated) { + waited = _jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAITED, NULL); + if (waited != JVMTI_ERROR_NONE) goto enable_failed; + } + + enter = _jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER, NULL); + if (enter != JVMTI_ERROR_NONE) goto enable_failed; + + if (!_monitor_wait_events_delegated) { + wait = _jvmti->SetEventNotificationMode( + JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAIT, NULL); + if (wait != JVMTI_ERROR_NONE) goto enable_failed; + } + return true; + +enable_failed: + Log::warn("Unable to enable JVMTI monitor events: %d/%d/%d/%d", + enter, entered, wait, waited); + setNativeMonitorEventsEnabled(false); + return false; + } + + // Stop admitting new intervals before removing the terminal notifications. + // Disable all four events even when Object.wait is delegated so teardown + // also cleans up modes established before ownership was configured. + enter = _jvmti->SetEventNotificationMode( + JVMTI_DISABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER, NULL); + wait = _jvmti->SetEventNotificationMode( + JVMTI_DISABLE, JVMTI_EVENT_MONITOR_WAIT, NULL); + entered = _jvmti->SetEventNotificationMode( + JVMTI_DISABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, NULL); + waited = _jvmti->SetEventNotificationMode( + JVMTI_DISABLE, 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 disable JVMTI monitor events: %d/%d/%d/%d", + enter, entered, wait, waited); + return false; } // Run late initialization when JVM is ready. May be called more than once (from @@ -708,7 +926,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 75725ef151..35268a62af 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 requested Object.wait +// ownership, independently of native monitor-event availability. +enum class ProfilerBridgeInitResult { + SUCCESS, + FAILURE, + MONITOR_EVENTS_DELEGATION_CONFLICT, +}; + class VM { friend class VMTestAccessor; @@ -147,6 +156,9 @@ class VM { static bool _zing; static bool _can_sample_objects; static bool _can_intercept_binding; + static bool _monitor_wait_events_delegated; + static bool _native_monitor_events_available; + static bool _profiler_bridge_initialized; static bool _is_adaptive_gc_boundary_flag_set; static CodeCache *_libjvm; @@ -168,6 +180,7 @@ class VM { static void *getLibraryHandle(const char *name); static bool initShared(JavaVM *vm); + static void configureMonitorEvents(bool delegateMonitorWaitEvents); static void probeJFRRequestStackTrace(); static CodeCache* openJvmLibrary(); @@ -183,7 +196,8 @@ class VM { static JVM_GetManagement _getManagement; static bool initLibrary(JavaVM *vm); - static bool initProfilerBridge(JavaVM *vm, bool attach); + static ProfilerBridgeInitResult initProfilerBridge( + JavaVM *vm, bool attach, bool delegateMonitorWaitEvents = false); static jvmtiEnv *jvmti() { return _jvmti; } @@ -218,6 +232,15 @@ class VM { static bool canSampleObjects() { return _can_sample_objects; } + static bool monitorWaitEventsDelegated() { + return _monitor_wait_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 c5b127ee08..7ea7fe4318 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -104,7 +104,35 @@ 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 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. Ownership is + * preserved independently of whether the JVM provides native monitor-event capability. + * + * @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 + * @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 { if (instance != null) { + if (monitorWaitEventsDelegated0() != delegateMonitorWaitEvents) { + throw new IllegalStateException( + "Monitor-event ownership conflicts with the profiler's " + + "process-wide initialization"); + } return instance; } @@ -113,12 +141,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; @@ -134,6 +161,18 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s return profiler; } + /** + * Reports whether Java instrumentation owns {@code Object.wait} TaskBlock intervals instead + * of native JVMTI {@code MonitorWait} and {@code MonitorWaited} callbacks. Synchronized-monitor + * contention remains owned by native JVMTI callbacks. This reports the process-wide ownership + * selected during bridge initialization, independently of native monitor-event capability. + * + * @return {@code true} when {@code Object.wait} handling is delegated to Java instrumentation + */ + public boolean isMonitorWaitEventsDelegated() { + return monitorWaitEventsDelegated0(); + } + /** * Stop profiling (without dumping results) * @@ -400,7 +439,7 @@ public void recordQueueTime(long startTicks, * @return {@code true} when this call owns a park interval that must be closed */ boolean parkEnter() { - return parkEnter0(); + return parkEnter0(Thread.currentThread()); } /** @@ -408,7 +447,7 @@ boolean 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); } /** @@ -420,14 +459,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); } /** @@ -515,7 +554,7 @@ public boolean isThreadRegistryActiveForTest() { return isThreadRegistryActiveForTest0(); } - 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; @@ -523,6 +562,7 @@ public boolean isThreadRegistryActiveForTest() { private static native void filterThreadRemove0(); private static native int getTid0(); + private static native boolean monitorWaitEventsDelegated0(); private static native boolean recordTrace0(long rootSpanId, String endpoint, String operation, int sizeLimit); @@ -540,13 +580,13 @@ public boolean isThreadRegistryActiveForTest() { private static native void recordQueueEnd0(long startTicks, long endTicks, String task, String scheduler, Thread origin, String queueType, int queueLength); - private static native boolean parkEnter0(); + private static native boolean 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); diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index b1863f72ad..c8801376bd 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -38,11 +38,6 @@ class JvmSupportGlobalSetup { }; static JvmSupportGlobalSetup jvm_support_global_setup; -// --------------------------------------------------------------------------- -// VMTestAccessor — friend of VM, lets tests swap VM::_jvmti/_hotspot for a -// mock/forced value so JVM-vendor-dependent code paths can be exercised -// deterministically without a live JVM. -// --------------------------------------------------------------------------- class VMTestAccessor { public: static jvmtiEnv* getJvmti() { return VM::_jvmti; } diff --git a/ddprof-lib/src/test/cpp/park_state_ut.cpp b/ddprof-lib/src/test/cpp/park_state_ut.cpp index 5a236994e0..a7f558fc45 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-lib/src/test/cpp/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp index 652f4e6bb6..d16910dc68 100644 --- a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -209,6 +209,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-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index 057ac0086f..b0ab362e5a 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -723,11 +723,14 @@ TEST_F(ThreadFilterTest, SnapshotCapturesOwnedLifecycle) { ASSERT_NE(0ULL, token); BlockRunSnapshot snapshot = slot->snapshotBlockRun(); + 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_EQ(OSThreadState::UNKNOWN, slot->snapshotBlockRun().active_state); + EXPECT_FALSE(slot->snapshotBlockRun().active); } TEST_F(ThreadFilterTest, OwnedBlockSuppressesOnlyAfterSuccessfulWallSample) { diff --git a/ddprof-lib/src/test/cpp/vmEntry_ut.cpp b/ddprof-lib/src/test/cpp/vmEntry_ut.cpp new file mode 100644 index 0000000000..fa740cd17e --- /dev/null +++ b/ddprof-lib/src/test/cpp/vmEntry_ut.cpp @@ -0,0 +1,500 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include + +#include + +#include "profiler.h" +#include "vmEntry.h" + +class VMTestAccessor { + public: + static jvmtiEnv* jvmti() { return VM::_jvmti; } + static void setJvmti(jvmtiEnv* jvmti) { VM::_jvmti = jvmti; } + + static bool nativeMonitorEventsAvailable() { + return VM::_native_monitor_events_available; + } + static void setNativeMonitorEventsAvailable(bool available) { + VM::_native_monitor_events_available = available; + } + + static bool monitorWaitEventsDelegated() { + return VM::_monitor_wait_events_delegated; + } + static void setMonitorWaitEventsDelegated(bool delegated) { + VM::_monitor_wait_events_delegated = delegated; + } + + static bool profilerBridgeInitialized() { + return VM::_profiler_bridge_initialized; + } + static void setProfilerBridgeInitialized(bool initialized) { + VM::_profiler_bridge_initialized = initialized; + } + + static void configureMonitorEvents(bool delegate_monitor_wait_events) { + VM::configureMonitorEvents(delegate_monitor_wait_events); + } +}; + +class ProfilerTestAccessor { + public: + static void setTaskBlockEnabled(Profiler* profiler, bool enabled) { + profiler->setTaskBlockEnabled(enabled); + } + + static void setTaskBlockState(Profiler* profiler, bool enabled, + bool monitor_events_enabled) { + profiler->_task_block_enabled.store(enabled, std::memory_order_release); + profiler->_task_block_monitor_events_enabled.store( + monitor_events_enabled, std::memory_order_release); + } + + static bool monitorEventsEnabled(Profiler* profiler) { + return profiler->_task_block_monitor_events_enabled.load( + std::memory_order_acquire); + } +}; + +class MonitorEventConfigurationTest : public ::testing::Test { + protected: + inline static MonitorEventConfigurationTest* active_test = nullptr; + + jvmtiInterface_1_ functions{}; + _jvmtiEnv mock_env{}; + jvmtiEnv* original_jvmti = nullptr; + bool original_initialized = false; + bool original_available = false; + bool original_delegated = false; + bool capability_available = false; + int get_capabilities_calls = 0; + + static jvmtiError JNICALL getCapabilities( + jvmtiEnv*, jvmtiCapabilities* capabilities) { + MonitorEventConfigurationTest* test = active_test; + *capabilities = jvmtiCapabilities{}; + capabilities->can_generate_monitor_events = test->capability_available; + test->get_capabilities_calls++; + return JVMTI_ERROR_NONE; + } + + void SetUp() override { + original_jvmti = VMTestAccessor::jvmti(); + original_initialized = VMTestAccessor::profilerBridgeInitialized(); + original_available = VMTestAccessor::nativeMonitorEventsAvailable(); + original_delegated = VMTestAccessor::monitorWaitEventsDelegated(); + + functions.GetCapabilities = &getCapabilities; + mock_env.functions = &functions; + VMTestAccessor::setJvmti(&mock_env); + VMTestAccessor::setProfilerBridgeInitialized(false); + active_test = this; + } + + void TearDown() override { + active_test = nullptr; + VMTestAccessor::setMonitorWaitEventsDelegated(original_delegated); + VMTestAccessor::setNativeMonitorEventsAvailable(original_available); + VMTestAccessor::setProfilerBridgeInitialized(original_initialized); + VMTestAccessor::setJvmti(original_jvmti); + } +}; + +TEST_F(MonitorEventConfigurationTest, + StoresRequestedOwnershipIndependentlyOfCapability) { + for (bool available : {false, true}) { + for (bool delegated : {false, true}) { + SCOPED_TRACE(::testing::Message() + << "available=" << available + << ", delegated=" << delegated); + capability_available = available; + get_capabilities_calls = 0; + + VMTestAccessor::configureMonitorEvents(delegated); + + EXPECT_EQ(1, get_capabilities_calls); + EXPECT_EQ(available, VMTestAccessor::nativeMonitorEventsAvailable()); + EXPECT_EQ(delegated, VMTestAccessor::monitorWaitEventsDelegated()); + EXPECT_FALSE(VMTestAccessor::profilerBridgeInitialized()); + } + } +} + +class ProfilerBridgeDelegationTest : public ::testing::Test { + protected: + bool original_initialized = false; + bool original_available = false; + bool original_delegated = false; + + void SetUp() override { + original_initialized = VMTestAccessor::profilerBridgeInitialized(); + original_available = VMTestAccessor::nativeMonitorEventsAvailable(); + original_delegated = VMTestAccessor::monitorWaitEventsDelegated(); + VMTestAccessor::setProfilerBridgeInitialized(true); + } + + void TearDown() override { + VMTestAccessor::setMonitorWaitEventsDelegated(original_delegated); + VMTestAccessor::setNativeMonitorEventsAvailable(original_available); + VMTestAccessor::setProfilerBridgeInitialized(original_initialized); + } + + static void expectNegotiation(bool available, bool delegated, + bool requested, + ProfilerBridgeInitResult expected) { + VMTestAccessor::setNativeMonitorEventsAvailable(available); + VMTestAccessor::setMonitorWaitEventsDelegated(delegated); + + EXPECT_EQ(expected, VM::initProfilerBridge(nullptr, true, requested)); + EXPECT_TRUE(VMTestAccessor::profilerBridgeInitialized()); + EXPECT_EQ(available, VMTestAccessor::nativeMonitorEventsAvailable()); + EXPECT_EQ(delegated, VMTestAccessor::monitorWaitEventsDelegated()); + } +}; + +TEST_F(ProfilerBridgeDelegationTest, + ReusesMatchingOwnershipWhenCapabilityIsUnavailable) { + expectNegotiation(false, false, false, ProfilerBridgeInitResult::SUCCESS); + expectNegotiation(false, true, true, ProfilerBridgeInitResult::SUCCESS); +} + +TEST_F(ProfilerBridgeDelegationTest, + RejectsConflictingOwnershipWhenCapabilityIsUnavailable) { + expectNegotiation( + false, false, true, + ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT); + expectNegotiation( + false, true, false, + ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT); +} + +TEST_F(ProfilerBridgeDelegationTest, + ReusesMatchingOwnershipWhenCapabilityIsAvailable) { + expectNegotiation(true, false, false, ProfilerBridgeInitResult::SUCCESS); + expectNegotiation(true, true, true, ProfilerBridgeInitResult::SUCCESS); +} + +TEST_F(ProfilerBridgeDelegationTest, + RejectsConflictingOwnershipWhenCapabilityIsAvailable) { + expectNegotiation( + true, false, true, + ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT); + expectNegotiation( + true, true, false, + ProfilerBridgeInitResult::MONITOR_EVENTS_DELEGATION_CONFLICT); +} + +class NativeMonitorEventsTest : public ::testing::Test { + protected: + struct EventCall { + jvmtiEventMode mode; + jvmtiEvent event; + bool task_block_enabled; + }; + + static constexpr std::array MONITOR_EVENTS = { + JVMTI_EVENT_MONITOR_CONTENDED_ENTER, + JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, + JVMTI_EVENT_MONITOR_WAIT, + JVMTI_EVENT_MONITOR_WAITED, + }; + + inline static NativeMonitorEventsTest* active_test = nullptr; + + jvmtiInterface_1_ functions{}; + _jvmtiEnv mock_env{}; + std::vector calls; + std::array event_enabled{}; + bool inject_failure = false; + bool fail_all_disables = false; + jvmtiEventMode failure_mode = JVMTI_ENABLE; + jvmtiEvent failure_event = JVMTI_EVENT_MONITOR_CONTENDED_ENTER; + + Profiler* profiler = Profiler::instance(); + jvmtiEnv* original_jvmti = nullptr; + bool original_available = false; + bool original_delegated = false; + bool original_task_block_enabled = false; + bool original_monitor_events_enabled = false; + + static jvmtiError JNICALL setEventNotificationMode( + jvmtiEnv*, jvmtiEventMode mode, jvmtiEvent event, jthread, ...) { + NativeMonitorEventsTest* test = active_test; + test->calls.push_back( + {mode, event, test->profiler->taskBlockEnabled()}); + if (test->inject_failure && mode == test->failure_mode && + event == test->failure_event) { + return JVMTI_ERROR_INTERNAL; + } + if (test->fail_all_disables && mode == JVMTI_DISABLE) { + return JVMTI_ERROR_INTERNAL; + } + + test->event_enabled[test->eventIndex(event)] = mode == JVMTI_ENABLE; + return JVMTI_ERROR_NONE; + } + + void SetUp() override { + original_jvmti = VMTestAccessor::jvmti(); + original_available = VMTestAccessor::nativeMonitorEventsAvailable(); + original_delegated = VMTestAccessor::monitorWaitEventsDelegated(); + original_task_block_enabled = profiler->taskBlockEnabled(); + original_monitor_events_enabled = + ProfilerTestAccessor::monitorEventsEnabled(profiler); + + functions.SetEventNotificationMode = &setEventNotificationMode; + mock_env.functions = &functions; + VMTestAccessor::setJvmti(&mock_env); + VMTestAccessor::setNativeMonitorEventsAvailable(true); + VMTestAccessor::setMonitorWaitEventsDelegated(false); + ProfilerTestAccessor::setTaskBlockState(profiler, false, false); + active_test = this; + } + + void TearDown() override { + active_test = nullptr; + ProfilerTestAccessor::setTaskBlockState( + profiler, original_task_block_enabled, original_monitor_events_enabled); + VMTestAccessor::setMonitorWaitEventsDelegated(original_delegated); + VMTestAccessor::setNativeMonitorEventsAvailable(original_available); + VMTestAccessor::setJvmti(original_jvmti); + } + + static size_t eventIndex(jvmtiEvent event) { + for (size_t i = 0; i < MONITOR_EVENTS.size(); i++) { + if (MONITOR_EVENTS[i] == event) return i; + } + ADD_FAILURE() << "Unexpected JVMTI event " << event; + return 0; + } + + bool eventIsEnabled(jvmtiEvent event) const { + return event_enabled[eventIndex(event)]; + } + + void setAllEventsEnabled(bool enabled) { + event_enabled.fill(enabled); + } + + void resetObservations() { + calls.clear(); + event_enabled.fill(false); + inject_failure = false; + fail_all_disables = false; + } + + void fail(jvmtiEventMode mode, jvmtiEvent event) { + inject_failure = true; + failure_mode = mode; + failure_event = event; + } + + void expectCalls( + const std::vector>& expected) { + ASSERT_EQ(expected.size(), calls.size()); + for (size_t i = 0; i < expected.size(); i++) { + EXPECT_EQ(expected[i].first, calls[i].mode) << "call " << i; + EXPECT_EQ(expected[i].second, calls[i].event) << "call " << i; + } + } + + static std::vector> disableCalls() { + return { + {JVMTI_DISABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER}, + {JVMTI_DISABLE, JVMTI_EVENT_MONITOR_WAIT}, + {JVMTI_DISABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED}, + {JVMTI_DISABLE, JVMTI_EVENT_MONITOR_WAITED}, + }; + } +}; + +TEST_F(NativeMonitorEventsTest, EnablesTerminalEventsBeforeEntryEvents) { + EXPECT_TRUE(VM::setNativeMonitorEventsEnabled(true)); + + expectCalls({ + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED}, + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAITED}, + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER}, + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAIT}, + }); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_TRUE(eventIsEnabled(event)); + } +} + +TEST_F(NativeMonitorEventsTest, DelegatedEnableOnlyInstallsContendedPair) { + VMTestAccessor::setMonitorWaitEventsDelegated(true); + + EXPECT_TRUE(VM::setNativeMonitorEventsEnabled(true)); + + expectCalls({ + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED}, + {JVMTI_ENABLE, JVMTI_EVENT_MONITOR_CONTENDED_ENTER}, + }); + EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_CONTENDED_ENTER)); + EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_CONTENDED_ENTERED)); + EXPECT_FALSE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAIT)); + EXPECT_FALSE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAITED)); +} + +TEST_F(NativeMonitorEventsTest, DisableRemovesEntriesBeforeTerminalEvents) { + for (bool delegated : {false, true}) { + SCOPED_TRACE(delegated); + resetObservations(); + setAllEventsEnabled(true); + VMTestAccessor::setMonitorWaitEventsDelegated(delegated); + + EXPECT_TRUE(VM::setNativeMonitorEventsEnabled(false)); + + expectCalls(disableCalls()); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_FALSE(eventIsEnabled(event)); + } + } +} + +TEST_F(NativeMonitorEventsTest, EnableFailureStopsAndRollsBackAllEvents) { + const std::array enable_order = { + JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, + JVMTI_EVENT_MONITOR_WAITED, + JVMTI_EVENT_MONITOR_CONTENDED_ENTER, + JVMTI_EVENT_MONITOR_WAIT, + }; + + for (size_t failure_index = 0; failure_index < enable_order.size(); + failure_index++) { + SCOPED_TRACE(failure_index); + resetObservations(); + fail(JVMTI_ENABLE, enable_order[failure_index]); + + EXPECT_FALSE(VM::setNativeMonitorEventsEnabled(true)); + + std::vector> expected; + for (size_t i = 0; i <= failure_index; i++) { + expected.push_back({JVMTI_ENABLE, enable_order[i]}); + } + std::vector> rollback = + disableCalls(); + expected.insert(expected.end(), rollback.begin(), rollback.end()); + expectCalls(expected); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_FALSE(eventIsEnabled(event)); + } + } +} + +TEST_F(NativeMonitorEventsTest, DelegatedEnableFailureRollsBackAllEvents) { + VMTestAccessor::setMonitorWaitEventsDelegated(true); + const std::array enable_order = { + JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, + JVMTI_EVENT_MONITOR_CONTENDED_ENTER, + }; + + for (size_t failure_index = 0; failure_index < enable_order.size(); + failure_index++) { + SCOPED_TRACE(failure_index); + resetObservations(); + fail(JVMTI_ENABLE, enable_order[failure_index]); + + EXPECT_FALSE(VM::setNativeMonitorEventsEnabled(true)); + + std::vector> expected; + for (size_t i = 0; i <= failure_index; i++) { + expected.push_back({JVMTI_ENABLE, enable_order[i]}); + } + std::vector> rollback = + disableCalls(); + expected.insert(expected.end(), rollback.begin(), rollback.end()); + expectCalls(expected); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_FALSE(eventIsEnabled(event)); + } + } +} + +TEST_F(NativeMonitorEventsTest, DisableFailureStillAttemptsEveryEvent) { + for (jvmtiEvent failed_event : MONITOR_EVENTS) { + SCOPED_TRACE(failed_event); + resetObservations(); + setAllEventsEnabled(true); + fail(JVMTI_DISABLE, failed_event); + + EXPECT_FALSE(VM::setNativeMonitorEventsEnabled(false)); + + expectCalls(disableCalls()); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_EQ(event == failed_event, eventIsEnabled(event)); + } + } +} + +TEST_F(NativeMonitorEventsTest, UnavailableCapabilityDoesNotCallJvmti) { + VMTestAccessor::setNativeMonitorEventsAvailable(false); + + EXPECT_FALSE(VM::setNativeMonitorEventsEnabled(true)); + EXPECT_TRUE(calls.empty()); +} + +TEST_F(NativeMonitorEventsTest, AdmissionRemainsClosedDuringSuccessfulSetup) { + ProfilerTestAccessor::setTaskBlockEnabled(profiler, true); + + ASSERT_FALSE(calls.empty()); + for (const EventCall& call : calls) { + EXPECT_FALSE(call.task_block_enabled); + } + EXPECT_TRUE(profiler->taskBlockEnabled()); + EXPECT_TRUE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); +} + +TEST_F(NativeMonitorEventsTest, + AdmissionRemainsClosedDuringFailedSetupAndRollback) { + fail(JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAIT); + + ProfilerTestAccessor::setTaskBlockEnabled(profiler, true); + + ASSERT_FALSE(calls.empty()); + for (const EventCall& call : calls) { + EXPECT_FALSE(call.task_block_enabled); + } + EXPECT_TRUE(profiler->taskBlockEnabled()); + EXPECT_FALSE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_FALSE(eventIsEnabled(event)); + } +} + +TEST_F(NativeMonitorEventsTest, + NativeAdmissionRemainsClosedWhenSetupAndRollbackFail) { + fail(JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAIT); + fail_all_disables = true; + + ProfilerTestAccessor::setTaskBlockEnabled(profiler, true); + + EXPECT_TRUE(profiler->taskBlockEnabled()); + EXPECT_FALSE(profiler->nativeMonitorTaskBlockEnabled()); + EXPECT_FALSE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); + EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_CONTENDED_ENTER)); + EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_CONTENDED_ENTERED)); + EXPECT_FALSE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAIT)); + EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAITED)); +} + +TEST_F(NativeMonitorEventsTest, AdmissionClosesBeforeNativeTeardown) { + setAllEventsEnabled(true); + ProfilerTestAccessor::setTaskBlockState(profiler, true, true); + + ProfilerTestAccessor::setTaskBlockEnabled(profiler, false); + + expectCalls(disableCalls()); + for (const EventCall& call : calls) { + EXPECT_FALSE(call.task_block_enabled); + } + EXPECT_FALSE(profiler->taskBlockEnabled()); + EXPECT_FALSE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java index 2dbb429668..5268c050bd 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; /** @@ -30,6 +37,14 @@ * CPU concurrently on the main thread, on a plain {@code new Thread(Runnable)} and on a * two-level {@link Thread} subclass, and stops the profiler again. The resulting recording * holds samples rooted at each of the three thread entry points; see {@code EntryFrameTest} + *

  • profiler-agent-compatible - reuses native monitor ownership after agent initialization
  • + *
  • profiler-delegation-conflict - requests delegated monitor ownership after agent initialization
  • + *
  • profiler-java-default-delegation-reuse - verifies explicit native ownership after default initialization
  • + *
  • profiler-java-default-delegation-conflict - verifies delegated ownership conflicts after default initialization
  • + *
  • profiler-java-delegation-reuse:<delegated> - verifies compatible Java singleton ownership reuse
  • + *
  • profiler-java-delegation-conflict:<initial>:<requested> - verifies conflicting Java singleton ownership requests
  • + *
  • 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 { @@ -119,6 +134,63 @@ private static void entryFrameBurn(long millis) { entryFrameSink = acc; } + /** 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 { @@ -139,6 +211,70 @@ public static void main(String[] args) throws Exception { } }); vt.join(); + JavaProfiler initial = JavaProfiler.getInstance(); + JavaProfiler reused = JavaProfiler.getInstance(); + System.out.println("[virtual-thread-recovery] " + + (initial == reused) + " " + reused.isMonitorWaitEventsDelegated()); + } 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) { + JavaProfiler recovered = + JavaProfiler.getInstance(libraryPath, null, false); + System.out.println("[delegation-conflict] " + + recovered.isMonitorWaitEventsDelegated()); + } + } else if (args[0].equals("profiler-java-default-delegation-reuse")) { + JavaProfiler initial = JavaProfiler.getInstance(); + JavaProfiler reused = JavaProfiler.getInstance(null, null, false); + System.out.println("[java-default-delegation-reuse] " + + (initial == reused) + " " + reused.isMonitorWaitEventsDelegated()); + } else if (args[0].equals("profiler-java-default-delegation-conflict")) { + JavaProfiler initial = JavaProfiler.getInstance(); + try { + JavaProfiler.getInstance(null, null, true); + System.out.println("[java-default-delegation-conflict-missed]"); + } catch (IllegalStateException expected) { + JavaProfiler recovered = + JavaProfiler.getInstance(null, null, false); + System.out.println("[java-default-delegation-conflict] " + + (initial == recovered) + " " + + recovered.isMonitorWaitEventsDelegated()); + } + } else if (args[0].startsWith("profiler-java-delegation-reuse:")) { + boolean delegated = Boolean.parseBoolean( + args[0].substring("profiler-java-delegation-reuse:".length())); + JavaProfiler initial = JavaProfiler.getInstance(null, null, delegated); + JavaProfiler reused = JavaProfiler.getInstance(null, null, delegated); + System.out.println("[java-delegation-reuse] " + + (initial == reused) + " " + reused.isMonitorWaitEventsDelegated()); + } else if (args[0].startsWith("profiler-java-delegation-conflict:")) { + String[] delegationModes = args[0].split(":"); + boolean initialDelegation = Boolean.parseBoolean(delegationModes[1]); + boolean requestedDelegation = Boolean.parseBoolean(delegationModes[2]); + JavaProfiler initial = + JavaProfiler.getInstance(null, null, initialDelegation); + try { + JavaProfiler.getInstance(null, null, requestedDelegation); + System.out.println("[java-delegation-conflict-missed]"); + } catch (IllegalStateException expected) { + JavaProfiler recovered = + JavaProfiler.getInstance(null, null, initialDelegation); + System.out.println("[java-delegation-conflict] " + + (initial == recovered) + " " + + recovered.isMonitorWaitEventsDelegated()); + } + } 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.isMonitorWaitEventsDelegated()); + } 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 058bd52944..c74e26fa29 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java @@ -14,6 +14,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 { @@ -31,6 +32,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("isMonitorWaitEventsDelegated").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/JavaProfilerTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java index 2023c4757c..02a378d3e2 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"); @@ -118,20 +154,173 @@ void testJ9ForceJvmtiSanity() throws Exception { void getInstanceFromVirtualThreadThrowsIOException() throws Exception { assumeTrue(Platform.isJavaVersionAtLeast(21)); - AtomicReference resultLine = new AtomicReference<>(); + AtomicReference attemptLine = new AtomicReference<>(); + AtomicReference recoveryLine = new AtomicReference<>(); boolean val = launch("profiler-virtual-thread", Collections.emptyList(), "", l -> { if (l.startsWith("[virtual-thread-")) { - resultLine.set(l); - return LineConsumerResult.STOP; + if (l.startsWith("[virtual-thread-recovery]")) { + recoveryLine.set(l); + return LineConsumerResult.STOP; + } + attemptLine.set(l); + return LineConsumerResult.CONTINUE; } return LineConsumerResult.CONTINUE; }, null).inTime; assertTrue(val); - String result = resultLine.get(); + String result = attemptLine.get(); assertNotNull(result, "getInstance() did not report a result from the virtual thread"); assertTrue(result.startsWith("[virtual-thread-ioexception]"), "Expected IOException from getInstance() on a virtual thread, got: " + result); + assertEquals("[virtual-thread-recovery] true false", recoveryLine.get()); + } + + @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); + assertEquals("[delegation-conflict] false", resultLine.get()); + } + + @Test + void defaultJavaSingletonMonitorDelegationIsReused() throws Exception { + assertJavaDelegationScenario( + "profiler-java-default-delegation-reuse", + "[java-default-delegation-reuse]", + "[java-default-delegation-reuse] true false"); + } + + @Test + void conflictingDefaultJavaSingletonMonitorDelegationDoesNotPoisonInstance() + throws Exception { + assertJavaDelegationScenario( + "profiler-java-default-delegation-conflict", + "[java-default-delegation-conflict", + "[java-default-delegation-conflict] true false"); + } + + @Test + void conflictingJavaSingletonMonitorDelegationIsRejected() throws Exception { + assertJavaSingletonDelegationConflict(false, true); + assertJavaSingletonDelegationConflict(true, false); + } + + @Test + void compatibleJavaSingletonMonitorDelegationIsReused() throws Exception { + assertJavaSingletonDelegationReuse(false); + assertJavaSingletonDelegationReuse(true); + } + + /** Launches a fresh JVM and verifies that repeated ownership returns the same singleton. */ + private void assertJavaSingletonDelegationReuse(boolean delegated) throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch( + "profiler-java-delegation-reuse:" + delegated, + Collections.emptyList(), "", line -> { + if (line.startsWith("[java-delegation-reuse]")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }, null); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertEquals("[java-delegation-reuse] true " + delegated, resultLine.get()); + } + + /** Launches a fresh JVM and verifies that a second ownership mode is rejected. */ + private void assertJavaSingletonDelegationConflict(boolean initialDelegation, + boolean requestedDelegation) throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch( + "profiler-java-delegation-conflict:" + initialDelegation + ":" + requestedDelegation, + Collections.emptyList(), "", line -> { + if (line.startsWith("[java-delegation-conflict")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }, null); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertEquals( + "[java-delegation-conflict] true " + initialDelegation, + resultLine.get()); + } + + /** Launches a fresh JVM and verifies the exact output of a delegation scenario. */ + private void assertJavaDelegationScenario( + String target, String marker, String expected) throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch( + target, Collections.emptyList(), "", line -> { + if (line.startsWith(marker)) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }, null); + + assertTrue(result.inTime); + assertEquals(0, result.exitCode); + assertEquals(expected, 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 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..ff6df5c970 --- /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,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 new file mode 100644 index 0000000000..63e56c3805 --- /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,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 new file mode 100644 index 0000000000..25d81b8e4a --- /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 com.datadoghq.profiler.JfrEvents; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; + +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(); + + JfrEvents 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(); + + JfrEvents 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.setTraceContext(0x4400L, 0x4401L, 0L, 0x4401L, -1, null, -1, null); + synchronized (monitor) { + monitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } finally { + profiler.clearTraceContext(); + 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,filter=,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; + + JfrEvents 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(); + + JfrEvents events = verifyEvents("datadog.TaskBlock", false); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(waitMonitor))); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(contentionMonitor))); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(JfrEvents 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..a1c288be5b --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java @@ -0,0 +1,169 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvents; +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 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 { + 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(); + + JfrEvents 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.setTraceContext(0x3100L, 0x3101L, 0L, 0x3101L, -1, null, -1, null); + try { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + } finally { + profiler.clearTraceContext(); + 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(); + + JfrEvents events = verifyEvents("datadog.TaskBlock"); + assertFalse(TaskBlockAssertions.containsBlocker(events, virtualBlocker)); + 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,filter=,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(JfrEvents 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); + } + } + + 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/TaskBlockAssertions.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java index 435a18dc8d..5b54c2981c 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 @@ -26,6 +26,15 @@ final class TaskBlockAssertions { private TaskBlockAssertions() {} + static boolean containsBlocker(JfrEvents events, long blocker) { + for (JfrEvent item : events) { + if (item.getLong(BLOCKER, Long.MIN_VALUE) == blocker) { + return true; + } + } + return false; + } + static void assertContains(JfrEvents events, long rootSpanId, long spanId, long blocker, long unblockingSpanId) { for (JfrEvent item : events) { From f4dc61bd1bc7a4fdb292fdc34122bb5fb8691d53 Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Thu, 20 Aug 2026 14:25:10 +0200 Subject: [PATCH 17/19] fix --- ddprof-lib/src/main/cpp/javaApi.cpp | 23 ++-- ddprof-lib/src/main/cpp/profiler.cpp | 11 +- ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 5 +- ddprof-lib/src/main/cpp/taskBlockRecorder.h | 5 +- ddprof-lib/src/main/cpp/vmEntry.cpp | 38 ++++-- .../com/datadoghq/profiler/JavaProfiler.java | 38 ++++-- ddprof-lib/src/test/cpp/vmEntry_ut.cpp | 29 +++- .../datadoghq/profiler/ExternalLauncher.java | 15 ++ .../datadoghq/profiler/JavaProfilerTest.java | 30 ++++ .../JvmtiBasedMonitorTaskBlockTest.java | 17 +++ .../wallclock/MonitorTaskBlockTest.java | 128 +++++++++++++++++- .../profiler/wallclock/ParkTaskBlockTest.java | 7 +- 12 files changed, 302 insertions(+), 44 deletions(-) diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 94560f97fc..c0400574d5 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -406,8 +406,10 @@ Java_com_datadoghq_profiler_JavaProfiler_recordQueueEnd0( extern "C" DLLEXPORT jboolean JNICALL Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( - JNIEnv *env, jclass unused, jthread thread) { - if (!JVMSupport::isPlatformThread(env, thread)) { + JNIEnv *env, jclass unused, jthread thread, jboolean isVirtual) { + // Virtuality is resolved once on the Java side; re-deriving it here would cost a + // GetVersion() plus an IsVirtualThread() JNI round-trip on every park. + if (isVirtual != JNI_FALSE) { return JNI_FALSE; } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -434,9 +436,9 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_parkExit0( - JNIEnv *env, jclass unused, jthread thread, jlong blocker, - jlong unblockingSpanId) { - if (!JVMSupport::isPlatformThread(env, thread)) { + JNIEnv *env, jclass unused, jthread thread, jboolean isVirtual, + jlong blocker, jlong unblockingSpanId) { + if (isVirtual != JNI_FALSE) { return; } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -481,10 +483,10 @@ static bool isCurrentJniThread(JNIEnv* env, jthread thread) { extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( - JNIEnv *env, jclass unused, jthread thread, jint state) { + JNIEnv *env, jclass unused, jthread thread, jboolean isVirtual, + jint state) { OSThreadState decoded; - if (!decodeJavaBlockState(state, decoded) || - !JVMSupport::isPlatformThread(env, thread)) { + if (!decodeJavaBlockState(state, decoded) || isVirtual != JNI_FALSE) { return 0; } ProfiledThread *current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -508,9 +510,10 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockExit0( - JNIEnv *env, jclass unused, jthread thread, jlong token) { + JNIEnv *env, jclass unused, jthread thread, jboolean isVirtual, + jlong token) { u64 block_token = static_cast(token); - if (block_token == 0 || !JVMSupport::isPlatformThread(env, thread)) { + if (block_token == 0 || isVirtual != JNI_FALSE) { return; } diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 51630d6042..ff7f4c8f74 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -1555,10 +1555,13 @@ void Profiler::setTaskBlockEnabled(bool enabled) { } _task_block_enabled.store(false, std::memory_order_release); - if (_task_block_monitor_events_enabled.exchange( - false, std::memory_order_acq_rel)) { - VM::setNativeMonitorEventsEnabled(false); - } + // Clear the admission flag first so no consumer can observe enabled events, then + // always attempt teardown. A previous enable whose setup AND rollback both failed + // left the flag false while JVMTI events stayed on; retrying unconditionally is the + // only way that leak is ever reclaimed. setNativeMonitorEventsEnabled(false) is + // documented as a no-op when the capability was never enabled. + _task_block_monitor_events_enabled.exchange(false, std::memory_order_acq_rel); + VM::setNativeMonitorEventsEnabled(false); } Error Profiler::start(Arguments &args, bool reset) { diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index 2a82c67dce..ecfd37f29a 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -42,7 +42,8 @@ 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) { + u64 unblocking_span_id, u64 end_ticks) { + if (end_ticks == 0) end_ticks = TSC::ticks(); Profiler* profiler = Profiler::instance(); bool recording_enabled = profiler->taskBlockEnabled(); TaskBlockActivity activity; @@ -70,6 +71,6 @@ bool finishTaskBlockAtExit(ProfiledThread* current, } return recordTaskBlockIfEligible( - current->tid(), thread, start_depth, start_ticks, TSC::ticks(), context, + current->tid(), thread, start_depth, start_ticks, end_ticks, context, blocker, unblocking_span_id, snapshot.active_state, true); } diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.h b/ddprof-lib/src/main/cpp/taskBlockRecorder.h index 172b0f43c1..cec8e51d2a 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.h +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.h @@ -24,11 +24,14 @@ bool recordTaskBlockAtExit(ProfiledThread* current, ThreadFilter* thread_filter, // Cleanup is deliberately performed even when admission is rejected so an // application thread never waits for rotation and suppression cannot be left // armed. +// 'end_ticks' lets a caller that already had to sample the clock (e.g. to decide +// whether the interval is worth resolving a blocker identity for) share the exact +// same end timestamp with the eligibility check; 0 means "sample it here". 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); + u64 unblocking_span_id, u64 end_ticks = 0); class TaskBlockActivity { private: diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index 6583b0d129..4fd06e2271 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -86,8 +86,12 @@ static u64 monitorBlockerHash(jvmtiEnv *jvmti, jobject object) { return static_cast(static_cast(hash)); } -static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, - jobject object, OSThreadState state) { +// Deliberately takes no jvmtiEnv: no JVMTI call may run on this hot path. The +// blocker identity hash is resolved lazily in monitorBlockExit, and only for +// intervals that pass the minimum-duration filter (GetObjectHashCode mutates the +// object's mark word on HotSpot). +static void monitorBlockEnter(JNIEnv *jni, jthread thread, + OSThreadState state) { Profiler *profiler = Profiler::instance(); if (!profiler->taskBlockEnabled() || !profiler->nativeMonitorTaskBlockEnabled() || @@ -102,8 +106,7 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, return; } - if (!current->monitorEnter(TSC::ticks(), context, - monitorBlockerHash(jvmti, object), state)) { + if (!current->monitorEnter(TSC::ticks(), context, /*blocker=*/0, state)) { u64 token = current->monitorBlockToken(); ThreadFilter *tf = profiler->threadFilter(); bool current_owner = false; @@ -123,8 +126,7 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, return; } current->clearMonitorBlock(); - if (!current->monitorEnter(TSC::ticks(), context, - monitorBlockerHash(jvmti, object), state)) { + if (!current->monitorEnter(TSC::ticks(), context, /*blocker=*/0, state)) { return; } } @@ -148,13 +150,15 @@ static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, current->setMonitorBlockToken(token); } -static void monitorBlockExit(JNIEnv *jni, jthread thread, OSThreadState state) { +static void monitorBlockExit(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, OSThreadState state) { if (!JVMSupport::isPlatformThread(jni, thread)) return; ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) return; u64 start_ticks = 0; Context context{}; + // The entry side no longer records a blocker; it is resolved lazily below. u64 blocker = 0; u64 token = 0; if (!current->monitorExit(state, start_ticks, context, blocker, token) || @@ -162,32 +166,42 @@ static void monitorBlockExit(JNIEnv *jni, jthread thread, OSThreadState state) { return; } + // Resolve the blocker identity hash only for intervals that will actually pass + // the eligibility filter. GetObjectHashCode mutates the object's mark word on + // HotSpot, so it must not run for short, high-frequency contention that gets + // discarded anyway. These conditions mirror taskBlockPassesBasicEligibility, and + // the same end_ticks is handed down so there is no boundary drift. + u64 end_ticks = TSC::ticks(); + if (context.spanId == 0 && exceedsMinTaskBlockDuration(start_ticks, end_ticks)) { + blocker = monitorBlockerHash(jvmti, object); + } + Profiler *profiler = Profiler::instance(); finishTaskBlockAtExit(current, profiler->threadFilter(), thread, 0, token, - start_ticks, context, blocker, 0); + start_ticks, context, blocker, 0, end_ticks); } static void JNICALL MonitorContendedEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jobject object) { - monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::MONITOR_WAIT); + monitorBlockEnter(jni, thread, OSThreadState::MONITOR_WAIT); } static void JNICALL MonitorContendedEntered(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jobject object) { - monitorBlockExit(jni, thread, OSThreadState::MONITOR_WAIT); + monitorBlockExit(jvmti, jni, thread, object, OSThreadState::MONITOR_WAIT); } static void JNICALL MonitorWait(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jobject object, jlong timeout) { if (!VM::monitorWaitEventsDelegated()) { - monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::OBJECT_WAIT); + monitorBlockEnter(jni, thread, OSThreadState::OBJECT_WAIT); } } static void JNICALL MonitorWaited(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, jobject object, jboolean timed_out) { if (!VM::monitorWaitEventsDelegated()) { - monitorBlockExit(jni, thread, OSThreadState::OBJECT_WAIT); + monitorBlockExit(jvmti, jni, thread, object, OSThreadState::OBJECT_WAIT); } } 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 7ea7fe4318..75ddde5916 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -81,6 +81,10 @@ private JavaProfiler() { * Get a {@linkplain JavaProfiler} instance backed by the bundled native library and using * the default temp directory as the scratch where the bundled library will be exploded * before linking. + * + *

    This overload expresses no preference about monitor-event ownership: when the + * process-wide instance already exists it is returned unchanged, whatever its + * {@code delegateMonitorWaitEvents} setting is. */ public static JavaProfiler getInstance() throws IOException { return getInstance(null, null); @@ -91,6 +95,10 @@ public static JavaProfiler getInstance() throws IOException { * the given directory as the scratch where the bundled library will be exploded * before linking. * @param scratchDir directory where the bundled library will be exploded before linking + * + *

    This overload expresses no preference about monitor-event ownership: when the + * process-wide instance already exists it is returned unchanged, whatever its + * {@code delegateMonitorWaitEvents} setting is. */ public static JavaProfiler getInstance(String scratchDir) throws IOException { return getInstance(null, scratchDir); @@ -102,8 +110,18 @@ public static JavaProfiler getInstance(String scratchDir) throws IOException { * before linking. * @param libLocation the path to the native library to be used instead of the bundled one * @param scratchDir directory where the bundled library will be exploded before linking; ignored when 'libLocation' is {@literal null} + * + *

    This overload expresses no preference about monitor-event ownership: when the + * process-wide instance already exists it is returned unchanged, whatever its + * {@code delegateMonitorWaitEvents} setting is. Only the explicit three-argument + * overload enforces the ownership-conflict check. */ public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir) throws IOException { + // No preference expressed: an already-initialized singleton is acceptable as-is. + if (instance != null) { + return instance; + } + // 'false' is the default for the *first* initialization only. return getInstance(libLocation, scratchDir, false); } @@ -439,7 +457,8 @@ public void recordQueueTime(long startTicks, * @return {@code true} when this call owns a park interval that must be closed */ boolean parkEnter() { - return parkEnter0(Thread.currentThread()); + Thread thread = Thread.currentThread(); + return parkEnter0(thread, isVirtualThread(thread)); } /** @@ -447,7 +466,8 @@ boolean parkEnter() { * {@code blocker} and {@code unblockingSpanId} are reserved for park instrumentation. */ void parkExit(long blocker, long unblockingSpanId) { - parkExit0(Thread.currentThread(), blocker, unblockingSpanId); + Thread thread = Thread.currentThread(); + parkExit0(thread, isVirtualThread(thread), blocker, unblockingSpanId); } /** @@ -459,14 +479,16 @@ 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(Thread.currentThread(), state); + Thread thread = Thread.currentThread(); + return blockEnter0(thread, isVirtualThread(thread), state); } /** * Clears a blocked interval previously armed by {@link #blockEnter(int)}. */ void blockExit(long token) { - blockExit0(Thread.currentThread(), token); + Thread thread = Thread.currentThread(); + blockExit0(thread, isVirtualThread(thread), token); } /** @@ -580,13 +602,13 @@ public boolean isThreadRegistryActiveForTest() { private static native void recordQueueEnd0(long startTicks, long endTicks, String task, String scheduler, Thread origin, String queueType, int queueLength); - private static native boolean parkEnter0(Thread thread); + private static native boolean parkEnter0(Thread thread, boolean isVirtual); - private static native void parkExit0(Thread thread, long blocker, long unblockingSpanId); + private static native void parkExit0(Thread thread, boolean isVirtual, long blocker, long unblockingSpanId); - private static native long blockEnter0(Thread thread, int state); + private static native long blockEnter0(Thread thread, boolean isVirtual, int state); - private static native void blockExit0(Thread thread, long token); + private static native void blockExit0(Thread thread, boolean isVirtual, long token); private static native long beginTaskBlock0(Thread thread); diff --git a/ddprof-lib/src/test/cpp/vmEntry_ut.cpp b/ddprof-lib/src/test/cpp/vmEntry_ut.cpp index fa740cd17e..e40e20f450 100644 --- a/ddprof-lib/src/test/cpp/vmEntry_ut.cpp +++ b/ddprof-lib/src/test/cpp/vmEntry_ut.cpp @@ -469,13 +469,18 @@ TEST_F(NativeMonitorEventsTest, } } -TEST_F(NativeMonitorEventsTest, - NativeAdmissionRemainsClosedWhenSetupAndRollbackFail) { +// Guards the "leaked JVMTI monitor events" defect: when the enable partially fails +// *and* its own rollback fails, the events stay enabled with no consumer. The disable +// path must therefore retry the teardown unconditionally instead of skipping it because +// the monitor-events flag was already stored false. +TEST_F(NativeMonitorEventsTest, FailedRollbackIsRetriedOnDisable) { fail(JVMTI_ENABLE, JVMTI_EVENT_MONITOR_WAIT); fail_all_disables = true; ProfilerTestAccessor::setTaskBlockEnabled(profiler, true); + // Pre-disable state: the leak is still present, the fix does not repair a failed + // rollback in place. EXPECT_TRUE(profiler->taskBlockEnabled()); EXPECT_FALSE(profiler->nativeMonitorTaskBlockEnabled()); EXPECT_FALSE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); @@ -483,6 +488,26 @@ TEST_F(NativeMonitorEventsTest, EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_CONTENDED_ENTERED)); EXPECT_FALSE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAIT)); EXPECT_TRUE(eventIsEnabled(JVMTI_EVENT_MONITOR_WAITED)); + + // The fixture is sticky across calls, so stop forcing disables to fail explicitly. + inject_failure = false; + fail_all_disables = false; + calls.clear(); + + ProfilerTestAccessor::setTaskBlockEnabled(profiler, false); + + // The disable path retried the teardown and reclaimed the leaked events. + EXPECT_FALSE(profiler->taskBlockEnabled()); + EXPECT_FALSE(ProfilerTestAccessor::monitorEventsEnabled(profiler)); + for (jvmtiEvent event : MONITOR_EVENTS) { + EXPECT_FALSE(eventIsEnabled(event)) << "event " << event << " left enabled"; + } + + // Admission still closes before native teardown. + ASSERT_FALSE(calls.empty()); + for (const EventCall& call : calls) { + EXPECT_FALSE(call.task_block_enabled); + } } TEST_F(NativeMonitorEventsTest, AdmissionClosesBeforeNativeTeardown) { 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 5268c050bd..d97dc74033 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/ExternalLauncher.java @@ -43,6 +43,7 @@ *

  • profiler-java-default-delegation-conflict - verifies delegated ownership conflicts after default initialization
  • *
  • profiler-java-delegation-reuse:<delegated> - verifies compatible Java singleton ownership reuse
  • *
  • profiler-java-delegation-conflict:<initial>:<requested> - verifies conflicting Java singleton ownership requests
  • + *
  • profiler-java-delegation-legacy-reuse:<initial> - verifies the legacy no-preference overloads reuse the existing singleton
  • *
  • 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
  • * @@ -250,6 +251,20 @@ public static void main(String[] args) throws Exception { JavaProfiler reused = JavaProfiler.getInstance(null, null, delegated); System.out.println("[java-delegation-reuse] " + (initial == reused) + " " + reused.isMonitorWaitEventsDelegated()); + } else if (args[0].startsWith("profiler-java-delegation-legacy-reuse:")) { + // A legacy overload expresses no preference about monitor-event ownership: + // it must return the existing singleton whatever its delegation setting is, + // never throw IllegalStateException. An escaping ISE is the failure signal. + boolean initialDelegation = Boolean.parseBoolean(args[0].substring( + "profiler-java-delegation-legacy-reuse:".length())); + JavaProfiler initial = + JavaProfiler.getInstance(null, null, initialDelegation); + JavaProfiler reused = JavaProfiler.getInstance(); + JavaProfiler reused2 = JavaProfiler.getInstance(null, null); + System.out.println("[java-delegation-legacy-reuse] " + + (initial == reused) + " " + + (initial == reused2) + " " + + initial.isMonitorWaitEventsDelegated()); } else if (args[0].startsWith("profiler-java-delegation-conflict:")) { String[] delegationModes = args[0].split(":"); boolean initialDelegation = Boolean.parseBoolean(delegationModes[1]); 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 02a378d3e2..54be8b9fd5 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerTest.java @@ -237,6 +237,36 @@ void compatibleJavaSingletonMonitorDelegationIsReused() throws Exception { assertJavaSingletonDelegationReuse(true); } + @Test + void legacyGetInstanceOverloadsReuseAnyExistingSingleton() throws Exception { + // A legacy overload expresses no preference about monitor-event ownership, so it must + // return the existing singleton instead of throwing - including when that singleton was + // initialized with delegateMonitorWaitEvents=true. + assertJavaSingletonLegacyReuse(true); + assertJavaSingletonLegacyReuse(false); + } + + /** Launches a fresh JVM and verifies the legacy overloads never conflict with the singleton. */ + private void assertJavaSingletonLegacyReuse(boolean initialDelegation) throws Exception { + AtomicReference resultLine = new AtomicReference<>(); + LaunchResult result = launch( + "profiler-java-delegation-legacy-reuse:" + initialDelegation, + Collections.emptyList(), "", line -> { + if (line.startsWith("[java-delegation-legacy-reuse]")) { + resultLine.set(line); + return LineConsumerResult.STOP; + } + return LineConsumerResult.CONTINUE; + }, null); + + assertTrue(result.inTime); + // An IllegalStateException escaping the launcher shows up here as a non-zero exit, + // distinguishing a thrown exception from a missing-output flake. + assertEquals(0, result.exitCode); + assertEquals("[java-delegation-legacy-reuse] true true " + initialDelegation, + resultLine.get()); + } + /** Launches a fresh JVM and verifies that repeated ownership returns the same singleton. */ private void assertJavaSingletonDelegationReuse(boolean delegated) throws Exception { AtomicReference resultLine = new AtomicReference<>(); 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 ff6df5c970..f8434f8cab 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 @@ -7,6 +7,7 @@ import com.datadoghq.profiler.Platform; import java.util.Map; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assumptions; /** Verifies synchronous monitor production when delegated wall-clock stacks are enabled. */ @@ -27,4 +28,20 @@ protected void withTestAssumptions() { protected String getProfilerCommand() { return "wall=1ms,filter=,wallprecheck=true,jvmtistacks=true"; } + + /** + * Proves the restarted recording really ran with {@code jvmtistacks=true}: JVMTI stacks + * must have been requested again after the restart. Catches a regression back to a + * hardcoded restart command that drops this class's configuration. + * + *

    Starting the restarted recording resets the native counters, so any non-zero count + * here was accumulated by the restarted recording alone. + */ + @Override + protected void assertRestartedConfiguration(Map counters) { + long requested = counters.getOrDefault("jvmti_stacks_requested", 0L); + Assertions.assertTrue(requested > 0, + "restarted recording did not use the JVMTI stack path: jvmti_stacks_requested " + + requested); + } } 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 25d81b8e4a..967624a18c 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 @@ -9,6 +9,7 @@ import java.lang.reflect.Method; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; @@ -78,9 +79,70 @@ public void monitorContentionEmitsTaskBlockOutsideContextWindow() throws Excepti TaskBlockAssertions.assertContainsObservedState(events, "CONTENDED"); } + @Test + public void shortMonitorContentionIsFilteredAndDoesNotSuppressLongerOnes() throws Exception { + Object shortMonitor = new Object(); + Object longMonitor = new Object(); + + // Burst of genuinely contended, microsecond-long enters: two threads hammer the same + // monitor with an empty critical section, so no interval can reach the 1ms threshold. + CountDownLatch start = new CountDownLatch(1); + AtomicReference burstFailure = new AtomicReference<>(); + int[] counter = new int[1]; + Thread[] burst = new Thread[2]; + for (int i = 0; i < burst.length; i++) { + burst[i] = new Thread(() -> { + try { + assertTrue(start.await(5, TimeUnit.SECONDS)); + for (int n = 0; n < 20_000; n++) { + synchronized (shortMonitor) { + counter[0]++; + } + } + } catch (Throwable t) { + burstFailure.set(t); + } + }, "taskblock-short-contention-" + i); + burst[i].start(); + } + start.countDown(); + for (Thread thread : burst) { + assertCompleted(thread, burstFailure); + } + + // One long contended enter, the positive control: it proves the producer was alive. + CountDownLatch attempting = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker; + synchronized (longMonitor) { + worker = new Thread(() -> { + try { + attempting.countDown(); + synchronized (longMonitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-long-contention"); + worker.start(); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(worker, failure); + stopProfiler(); + + JfrEvents events = verifyEvents("datadog.TaskBlock"); + assertTrue(TaskBlockAssertions.containsBlocker(events, identityHash(longMonitor)), + "long contention was not emitted"); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(shortMonitor)), + "sub-threshold contention must be filtered"); + assertTaskBlockStackReference(events); + } + @Test public void contextWindowObjectWaitDoesNotEmitTaskBlock() throws Exception { Object monitor = new Object(); + Object controlMonitor = new Object(); AtomicReference failure = new AtomicReference<>(); Thread worker = new Thread(() -> { try { @@ -99,10 +161,29 @@ public void contextWindowObjectWaitDoesNotEmitTaskBlock() throws Exception { worker.start(); assertCompleted(worker, failure); + + // Positive control: an untraced platform-thread wait in the same recording must be + // produced, so this test also fails when the producer stops emitting anything at all. + AtomicReference controlFailure = new AtomicReference<>(); + Thread control = new Thread(() -> { + try { + synchronized (controlMonitor) { + controlMonitor.wait(100); + } + } catch (Throwable t) { + controlFailure.set(t); + } + }, "taskblock-control-object-wait"); + control.start(); + assertCompleted(control, controlFailure); + stopProfiler(); - assertFalse(TaskBlockAssertions.containsBlocker( - verifyEvents("datadog.TaskBlock", false), identityHash(monitor))); + JfrEvents events = verifyEvents("datadog.TaskBlock"); + assertTrue(TaskBlockAssertions.containsBlocker(events, identityHash(controlMonitor)), + "control wait was not produced"); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(monitor)), + "traced wait was not suppressed"); } @Test @@ -141,8 +222,10 @@ public void staleWaitStateIsRecoveredAfterProfilerRestart() throws Exception { Path recording = Files.createTempFile("MonitorTaskBlockTest-restart-", ".jfr"); boolean restarted = false; + // Built from getProfilerCommand() so subclasses exercise their own configuration on the + // restarted recording too, not just on the initial one. try { - profiler.execute("start,wall=1ms,filter=,wallprecheck=true,jfr,file=" + profiler.execute("start," + getProfilerCommand() + ",jfr,file=" + recording.toAbsolutePath()); restarted = true; synchronized (contentionMonitor) { @@ -158,6 +241,7 @@ public void staleWaitStateIsRecoveredAfterProfilerRestart() throws Exception { assertTaskBlockStackReference(events); assertTrue(TaskBlockAssertions.containsBlocker( events, identityHash(contentionMonitor))); + assertRestartedConfiguration(profiler.getDebugCounters()); } finally { restartReady.countDown(); synchronized (waitMonitor) { @@ -209,9 +293,34 @@ public void virtualMonitorCallbacksDoNotEmitCarrierTaskBlocks() throws Exception Thread.sleep(100); } assertCompleted(contender, failure); + + // Positive control: the same contention shape on a platform thread must be produced, + // so this test fails when carrier suppression breaks *and* when production breaks. + Object platformMonitor = new Object(); + CountDownLatch platformAttempting = new CountDownLatch(1); + AtomicReference platformFailure = new AtomicReference<>(); + Thread platformContender; + synchronized (platformMonitor) { + platformContender = new Thread(() -> { + try { + platformAttempting.countDown(); + synchronized (platformMonitor) { + } + } catch (Throwable t) { + platformFailure.set(t); + } + }, "taskblock-control-monitor-contention"); + platformContender.start(); + assertTrue(platformAttempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(platformContender, platformFailure); + stopProfiler(); - JfrEvents events = verifyEvents("datadog.TaskBlock", false); + JfrEvents events = verifyEvents("datadog.TaskBlock"); + assertTrue(TaskBlockAssertions.containsBlocker(events, identityHash(platformMonitor)), + "platform control was not produced"); assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(waitMonitor))); assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(contentionMonitor))); } @@ -221,6 +330,17 @@ protected String getProfilerCommand() { return "wall=1ms,filter=,wallprecheck=true"; } + /** + * Hook for subclasses to assert that the restarted recording ran under their own + * configuration. No-op here so the base class stays configuration-agnostic. + * + *

    The restart's {@code start,...} command resets the native debug counters, so + * {@code counters} only accumulates over the restarted recording: subclasses can assert + * absolute values rather than deltas against a pre-restart baseline. + */ + protected void assertRestartedConfiguration(Map counters) { + } + protected void assertTaskBlockStackReference(JfrEvents events) { TaskBlockAssertions.assertContainsStackTrace(events); TaskBlockAssertions.assertContainsJavaType(events, "MonitorTaskBlockTest"); 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 a1c288be5b..e169447ad2 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 @@ -85,6 +85,8 @@ public void virtualParkDoesNotMutateCarrierProducerState() throws Exception { virtual.join(5_000); assertFalse(virtual.isAlive()); + // Positive control on a platform thread, well above the 1ms threshold: proves the park + // producer is alive, so the virtual-thread short-circuit assertion is not vacuous. ProfilerOwnedBlockHooks.parkEnter(profiler); try { parkForMillis(200); @@ -94,7 +96,10 @@ public void virtualParkDoesNotMutateCarrierProducerState() throws Exception { stopProfiler(); JfrEvents events = verifyEvents("datadog.TaskBlock"); - assertFalse(TaskBlockAssertions.containsBlocker(events, virtualBlocker)); + assertTrue(TaskBlockAssertions.containsBlocker(events, BLOCKER), + "platform control park was not produced"); + assertFalse(TaskBlockAssertions.containsBlocker(events, virtualBlocker), + "virtual-thread park must not reach the carrier producer"); TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); } From cc4d7b8de2fc3adc0c3d281ae4bc0b550769fb5c Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Wed, 19 Aug 2026 17:10:02 +0200 Subject: [PATCH 18/19] feat(taskblock): squashed commit: instrument blocking native I/O for TaskBlock events --- .../com/datadoghq/native/gtest/GtestPlugin.kt | 7 + ddprof-lib/build.gradle.kts | 5 + ddprof-lib/src/main/cpp/codeCache.cpp | 163 +- ddprof-lib/src/main/cpp/codeCache.h | 42 +- ddprof-lib/src/main/cpp/counters.h | 3 + ddprof-lib/src/main/cpp/event.h | 1 + ddprof-lib/src/main/cpp/flightRecorder.cpp | 26 +- ddprof-lib/src/main/cpp/flightRecorder.h | 3 +- ddprof-lib/src/main/cpp/javaApi.cpp | 24 +- ddprof-lib/src/main/cpp/jfrMetadata.cpp | 2 + ddprof-lib/src/main/cpp/libraryPatcher.h | 86 +- .../src/main/cpp/libraryPatcher_linux.cpp | 620 ++++- ddprof-lib/src/main/cpp/nativeBlock.cpp | 150 ++ ddprof-lib/src/main/cpp/nativeBlock.h | 68 + .../src/main/cpp/nativeFdClassifier.cpp | 310 +++ ddprof-lib/src/main/cpp/nativeFdClassifier.h | 86 + .../src/main/cpp/nativeSocketInterposer.cpp | 824 +++++++ .../src/main/cpp/nativeSocketInterposer.h | 236 ++ .../src/main/cpp/nativeSocketSampler.cpp | 166 +- ddprof-lib/src/main/cpp/nativeSocketSampler.h | 109 +- ddprof-lib/src/main/cpp/profiler.cpp | 258 +- ddprof-lib/src/main/cpp/profiler.h | 67 +- ddprof-lib/src/main/cpp/symbols.h | 5 + ddprof-lib/src/main/cpp/symbols_linux.cpp | 125 +- ddprof-lib/src/main/cpp/symbols_macos.cpp | 26 + ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 16 +- ddprof-lib/src/main/cpp/threadFilter.cpp | 11 +- ddprof-lib/src/main/cpp/threadLocalData.h | 20 + ddprof-lib/src/main/cpp/threadState.h | 8 +- ddprof-lib/src/main/cpp/vmEntry.cpp | 13 +- ddprof-lib/src/main/cpp/wallClock.cpp | 42 +- ddprof-lib/src/test/cpp/elfparser_ut.cpp | 28 + ddprof-lib/src/test/cpp/nativeBlock_ut.cpp | 370 +++ .../test/cpp/nativeSocketInterposer_ut.cpp | 2100 +++++++++++++++++ .../src/test/cpp/nativeSocketSampler_ut.cpp | 297 +++ .../src/test/cpp/taskBlockRecorder_ut.cpp | 42 + ddprof-lib/src/test/cpp/threadFilter_ut.cpp | 99 + .../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 +++ .../J9WallClockPrecheckCapabilityTest.java | 78 + .../JavaProfilerTaskBlockApiTest.java | 46 +- .../JvmtiBasedNativeSocketTaskBlockTest.java | 32 + .../NativeSocketTaskBlockLifecycleTest.java | 259 ++ .../wallclock/NativeSocketTaskBlockTest.java | 390 +++ .../OpenJ9NativeSocketTaskBlockTest.java | 146 ++ .../wallclock/TaskBlockAssertions.java | 78 +- 48 files changed, 7419 insertions(+), 423 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/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedNativeSocketTaskBlockTest.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/OpenJ9NativeSocketTaskBlockTest.java 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 diff --git a/ddprof-lib/build.gradle.kts b/ddprof-lib/build.gradle.kts index fd6fb6fc52..1643b906fb 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 @@ -35,6 +38,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 9062b555b1..82ae49a5fc 100644 --- a/ddprof-lib/src/main/cpp/codeCache.cpp +++ b/ddprof-lib/src/main/cpp/codeCache.cpp @@ -10,6 +10,8 @@ #include "safeAccess.h" #include +#include +#include #include #include #include @@ -60,7 +62,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; @@ -99,7 +103,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; @@ -355,29 +362,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': @@ -385,6 +407,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); @@ -399,6 +428,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); } @@ -408,6 +441,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); } @@ -417,6 +454,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': @@ -427,46 +466,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) { @@ -531,4 +621,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 d8ac7d661e..7d8964e5e9 100644 --- a/ddprof-lib/src/main/cpp/codeCache.h +++ b/ddprof-lib/src/main/cpp/codeCache.h @@ -14,6 +14,7 @@ #include #include +#include #include #include #include @@ -28,6 +29,8 @@ const int MAX_NATIVE_LIBS = 2048; enum ImportId { im_dlopen, + im_dup2, + im_dup3, im_pthread_create, im_pthread_exit, im_pthread_setspecific, @@ -43,15 +46,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, @@ -137,6 +145,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; @@ -152,7 +167,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; @@ -177,7 +195,8 @@ class CodeCache { std::atomic _published; void expand(); - void makeImportsPatchable(); + void finalizeImports(); + bool makeImportsPatchable(); void saveImport(ImportId id, void** entry); void copyFrom(const CodeCache& other); @@ -264,8 +283,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); diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 8420c03caf..0d20483036 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -89,6 +89,7 @@ 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_SEGMENT_STACKLESS, "task_block_segment_stackless") \ X(TASK_BLOCK_DROPPED_ROTATION, "task_block_dropped_rotation") \ X(TASK_BLOCK_SKIPPED_THREAD_MISMATCH, "task_block_skipped_thread_mismatch") \ X(TASK_BLOCK_ROTATION_TIMEOUT, "task_block_rotation_timeout") \ @@ -143,6 +144,8 @@ X(JVMTI_STACKS_REQUESTED, "jvmti_stacks_requested") \ X(NATIVE_TRACE_HOOK_PREFIX_NOT_FOUND, "native_trace_hook_prefix_not_found") \ X(NATIVE_HOOK_MARK_RESOLVE_FAILED, "native_hook_mark_resolve_failed") \ + X(NATIVE_IO_STANDARD_HOOKS_PATCHED, "native_io_standard_hooks_patched") \ + X(NATIVE_IO_IBM_BRIDGE_HOOKS_PATCHED, "native_io_ibm_bridge_hooks_patched") \ X(JVMTI_STACKS_FAILED_WRONG_PHASE, "jvmti_stacks_failed_wrong_phase") \ X(JVMTI_STACKS_FAILED_OTHER, "jvmti_stacks_failed_other") \ /* Delegated stacks dropped at slot-lock. Rec-lock drops from all recording \ diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index 01ca053af8..823657a7ff 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -213,6 +213,7 @@ typedef struct TaskBlockEvent { u64 _unblockingSpanId; Context _ctx; u64 _callTraceId; + u64 _correlationId; OSThreadState _observedBlockingState; } TaskBlockEvent; diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index a8c1606eb7..23b31d1e30 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1535,7 +1535,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)); @@ -1556,6 +1556,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); } @@ -1980,6 +1982,7 @@ void Recording::recordTaskBlock(Buffer *buf, int tid, TaskBlockEvent *event) { buf->putVar64(event->_blocker); buf->putVar64(event->_unblockingSpanId); buf->putVar64(event->_callTraceId); + buf->putVar64(event->_correlationId); buf->put8(static_cast(event->_observedBlockingState)); writeContextSnapshot(buf, event->_ctx); writeEventSizePrefix(buf, start); @@ -2199,7 +2202,8 @@ void FlightRecorder::stop() { } } -Error FlightRecorder::dump(const char *filename, const int length) { +Error FlightRecorder::prepareDump(const char *filename, const int length, + int *fd) { DEBUG_ASSERT_NOT_IN_SIGNAL(); assert(length >= 0); ExclusiveLockGuard locker(&_rec_lock); @@ -2209,12 +2213,10 @@ Error FlightRecorder::dump(const char *filename, const int length) { strncmp(filename, _filename.c_str(), length) != 0) { // if the filename to dump the recording to is specified move the current // working file there - int copy_fd = open(filename, O_CREAT | O_RDWR | O_TRUNC, 0644); - if (copy_fd == -1) { + *fd = open(filename, O_CREAT | O_RDWR | O_TRUNC, 0644); + if (*fd == -1) { return Error("Could not open recording file for dump"); } - rec->switchChunk(copy_fd); - close(copy_fd); return Error::OK; } return Error( @@ -2223,6 +2225,18 @@ Error FlightRecorder::dump(const char *filename, const int length) { return Error("No active recording"); } +Error FlightRecorder::dump(int fd) { + DEBUG_ASSERT_NOT_IN_SIGNAL(); + assert(fd >= 0); + ExclusiveLockGuard locker(&_rec_lock); + Recording* rec = _rec; + if (rec == nullptr) { + return Error("No active recording"); + } + rec->switchChunk(fd); + return Error::OK; +} + void FlightRecorder::wallClockEpoch(int lock_index, WallClockEpochEvent *event) { OptionalSharedLockGuard locker(&_rec_lock); diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index bf72ca13f1..2a0d62e350 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -436,7 +436,8 @@ class FlightRecorder { FlightRecorder() : _rec(NULL) {} Error start(Arguments &args, bool reset); void stop(); - Error dump(const char *filename, const int length); + Error prepareDump(const char *filename, const int length, int *fd); + Error dump(int fd); 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); diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index c0400574d5..6cc7f11086 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -417,7 +417,8 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( return JNI_FALSE; } Context context = ContextApi::snapshot(); - if (!current->parkEnter(TSC::ticks(), context)) { + u64 start_ticks = TSC::ticks(); + if (!current->parkEnter(start_ticks, context)) { return JNI_FALSE; } @@ -427,8 +428,17 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( (profiler->taskBlockEnabled() || tf->enabled())) { ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id >= 0) { - current->setParkBlockToken(tf->enterBlockedRun( - slot_id, OSThreadState::CONDVAR_WAIT, BlockRunOwner::JAVA)); + u64 token = tf->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT, BlockRunOwner::JAVA); + if (token != 0 && + (!profiler->taskBlockEnabled() || + profiler->registerTaskBlockRun( + slot_id, ThreadFilter::tokenGeneration(token), current->tid(), + start_ticks, context, 0, OSThreadState::CONDVAR_WAIT))) { + current->setParkBlockToken(token); + } else if (token != 0) { + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); + } } } return JNI_TRUE; @@ -560,9 +570,15 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( } u64 token = tf->enterBlockedRun( slot_id, OSThreadState::SLEEPING, BlockRunOwner::JAVA); - if (!current->taskBlockEnter(token, TSC::ticks(), context)) { + u64 start_ticks = TSC::ticks(); + bool registered = token != 0 && profiler->registerTaskBlockRun( + slot_id, ThreadFilter::tokenGeneration(token), current->tid(), + start_ticks, context, 0, OSThreadState::SLEEPING); + if (!registered || !current->taskBlockEnter(token, start_ticks, context)) { if (token != 0) { tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); + profiler->clearTaskBlockRun(slot_id, + ThreadFilter::tokenGeneration(token)); } return 0; } diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index ddbcf22a66..2d6b5256e5 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -237,6 +237,8 @@ void JfrMetadata::initialize( << field("blocker", T_LONG, "Blocker Identity Hash") << field("unblockingSpanId", T_LONG, "Unblocking Span ID") << field("stackTrace", T_STACK_TRACE, "Stack Trace", F_CPOOL) + << field("correlationId", T_LONG, + "Stack Trace Request Correlation ID") << field("observedBlockingState", T_THREAD_STATE, "Observed Blocking State", F_CPOOL) << field("spanId", T_LONG, "Span ID") diff --git a/ddprof-lib/src/main/cpp/libraryPatcher.h b/ddprof-lib/src/main/cpp/libraryPatcher.h index 97a3f26e3c..f54e2dc1d9 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher.h +++ b/ddprof-lib/src/main/cpp/libraryPatcher.h @@ -1,21 +1,49 @@ +/* + * 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; + +enum SocketPatchTarget : u8 { + SOCKET_PATCH_NONE = 0, + SOCKET_PATCH_STANDARD_JDK_NETWORK, + SOCKET_PATCH_IBM_JCL_BRIDGE +}; + +const int SOCKET_BASE_TABLE_SIZE = MAX_NATIVE_LIBS * 2; class LibraryPatcher { friend class LibraryPatcherTestAccessor; @@ -36,10 +64,11 @@ 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(); @@ -50,27 +79,42 @@ class LibraryPatcher { static const void* self_anchor(); // True when `lib` is the profiler's own library, which must never be patched. static bool is_profiler_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(); + static SocketPatchTarget socket_patch_target_for_test( + CodeCache* lib, const char* library_name, bool in_jdk_directory); + static void* socket_hook_for_target_for_test(SocketPatchTarget target, + int hook_index); +#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 @@ -81,8 +125,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 fd4f708347..1949252166 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp +++ b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp @@ -9,13 +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*); @@ -25,10 +33,130 @@ 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"); + +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 SocketPatchTarget socket_patch_target_for_library( + CodeCache* lib, const char* name, bool in_jdk_directory) { + if (lib == nullptr || name == nullptr || !in_jdk_directory) { + return SOCKET_PATCH_NONE; + } + + if (strcmp(name, "libnet.so") == 0 || strcmp(name, "libnio.so") == 0) { + return SOCKET_PATCH_STANDARD_JDK_NETWORK; + } + + // IBM Java 8 routes java.net through JCL_* exports in libjava before + // reaching libc. Require the complete observed bridge signature so an + // OpenJDK libjava or an arbitrary JNI DSO is never selected by name alone. + if (strcmp(name, "libjava.so") == 0 && + lib->findSymbol("JCL_Send") != nullptr && + lib->findSymbol("JCL_Recv") != nullptr && + lib->findSymbol("JCL_Connect") != nullptr && + lib->findSymbol("JCL_Accept") != nullptr) { + return SOCKET_PATCH_IBM_JCL_BRIDGE; + } + + return SOCKET_PATCH_NONE; +} + +static SocketPatchTarget socket_patch_target( + CodeCache* lib, const char* path, const char* jdk_library_directory) { + if (path == nullptr || jdk_library_directory == nullptr) { + return SOCKET_PATCH_NONE; + } + const char* path_separator = strrchr(path, '/'); + if (path_separator == nullptr) { + return SOCKET_PATCH_NONE; + } + size_t directory_length = static_cast(path_separator - path); + bool in_jdk_directory = + strlen(jdk_library_directory) == directory_length && + strncmp(path, jdk_library_directory, directory_length) == 0; + return socket_patch_target_for_library( + lib, library_basename(path), in_jdk_directory); +} + +static void* socket_hook_for_target( + SocketPatchTarget target, + const NativeSocketInterposer::NativeIoHookSpec& hook) { + return target == SOCKET_PATCH_IBM_JCL_BRIDGE ? hook.fork_safe_hook + : hook.hook; +} + +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); + 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 (!_initialized.load(std::memory_order_acquire)) { _size = 0; @@ -555,7 +683,41 @@ void LibraryPatcher::patch_sigaction() { } } -bool LibraryPatcher::patch_socket_functions() { +class SocketPatchCandidate { +public: + CodeCache* _lib; + UnloadProtection _protection; + size_t _patch_count; + SocketPatchTarget _target; + + SocketPatchCandidate(CodeCache* lib, UnloadProtection&& protection, + size_t patch_count, SocketPatchTarget target) + : _lib(lib), _protection(std::move(protection)), + _patch_count(patch_count), _target(target) {} + + 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 @@ -569,157 +731,395 @@ 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 || + original == hooks[hook_index].fork_safe_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(); } + // Publish the process that owns profiler state before any fork-safe hook can + // become reachable. A post-fork child observes a different getpid() value + // and calls the original libc function without touching profiler state. + NativeSocketInterposer::setHookOwnerPid(getpid()); + const CodeCacheArray& native_libs = Libraries::instance()->native_libs(); int num_of_libs = native_libs.count(); - int capped = (num_of_libs <= MAX_NATIVE_LIBS) ? num_of_libs : MAX_NATIVE_LIBS; - - 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; + 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(); } - // 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; - } - 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() : "?"); - } - }; + 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); - if (lib == nullptr) continue; - if (lib->name() == nullptr) continue; - + if (lib == nullptr || lib->name() == nullptr) { + continue; + } // Checked here rather than in a pre-pass keyed by index: the library array // can grow between the two, and a flag applied to the wrong entry could let // us patch ourselves. if (is_profiler_library(lib)) { continue; } + char path[PATH_MAX]; + char* resolved_path = realpath(lib->name(), path); + if (resolved_path == nullptr) { + continue; + } + SocketPatchTarget target = + socket_patch_target(lib, resolved_path, jdk_library_directory); + if (target == SOCKET_PATCH_NONE) { + continue; + } - 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); + 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 (patch_count == 0 || !mappingMatches(lib)) { + 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, target); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to retain DSO candidate %s", + lib->name()); + return disable_and_unpatch(); + } + } + + std::vector libraries_to_release; + bool success = true; + size_t standard_slots_patched = 0; + size_t ibm_bridge_slots_patched = 0; + { + 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++; + } + } - 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); + 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; + } - // 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) { + 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; + } + } + } + + 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 && success; + 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); + if (location == nullptr) { + Log::warn("native I/O hooks disabled: missing import slot for " + "%s in %s", hooks[hook_index].name, lib->name()); + success = false; + break; + } + void* original = + reinterpret_cast(__atomic_load_n(location, __ATOMIC_ACQUIRE)); + _socket_entries.push_back({location, original}); + void* hook = socket_hook_for_target(candidate._target, + hooks[hook_index]); + __atomic_store_n(location, hook, __ATOMIC_RELEASE); + if (candidate._target == SOCKET_PATCH_IBM_JCL_BRIDGE) { + ibm_bridge_slots_patched++; + } else { + standard_slots_patched++; + } + } + } + if (!success) { + break; + } + _socket_libraries.push_back( + {lib->imageBase(), candidate._protection.release(), first_patch, + _socket_entries.size() - first_patch}); + remember_socket_library_unlocked(lib->imageBase()); + } + if (success) { + _socket_active.store(true, std::memory_order_release); + } else { + unpatch_socket_functions_unlocked(libraries_to_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); + Counters::increment(NATIVE_IO_STANDARD_HOOKS_PATCHED, + standard_slots_patched); + Counters::increment(NATIVE_IO_IBM_BRIDGE_HOOKS_PATCHED, + ibm_bridge_slots_patched); + TEST_LOG("patch_socket_functions DONE total_slots=%zu standard_new=%zu " + "ibm_bridge_new=%zu num_libs_scanned=%d", + _socket_entries.size(), standard_slots_patched, + ibm_bridge_slots_patched, 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); + if (location == nullptr) { + continue; + } + 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()); +} + +SocketPatchTarget LibraryPatcher::socket_patch_target_for_test( + CodeCache* lib, const char* library_name, bool in_jdk_directory) { + return socket_patch_target_for_library(lib, library_name, in_jdk_directory); +} + +void* LibraryPatcher::socket_hook_for_target_for_test( + SocketPatchTarget target, int hook_index) { + if (hook_index < 0 || + hook_index >= NativeSocketInterposer::NUM_NATIVE_IO_HOOKS) { + return nullptr; + } + return socket_hook_for_target(target, + NativeSocketInterposer::hookSpecs()[hook_index]); +} +#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..d21c995198 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.cpp @@ -0,0 +1,150 @@ +/* + * 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 "threadLocalData.inline.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::current(); + 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) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + 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; + if (!profiler->registerTaskBlockRun(_slot_id, _generation, _tid, + _start_ticks, _context, _blocker, + _state)) { + thread_filter->exitBlockedRun(_slot_id, _generation); + _active = false; + } + 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(); + ThreadFilter* thread_filter = profiler->threadFilter(); + bool recording_enabled = + profiler->taskBlockEnabled() && thread_filter->registryActive(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) { + // The rotation boundary is captured before new exits are rejected, so a + // fresh timestamp places this completion unambiguously in the next chunk. + end_ticks = TSC::ticks(); + } + BlockRunSnapshot snapshot{}; + bool exited = thread_filter->snapshotAndExitBlockedRun( + _slot_id, _generation, &snapshot); + profiler->completeTaskBlockRun(_slot_id, _generation, end_ticks, _blocker, 0); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + + if (!recording_enabled || !exited) { + profiler->clearTaskBlockRun(_slot_id, _generation); + profiler->leaveTaskBlockActivity(); + return; + } + + u64 segment_start = profiler->taskBlockSegmentStart(_slot_id, _generation); + if (segment_start == 0) segment_start = _start_ticks; + recordTaskBlockIfEligible(_tid, nullptr, 0, segment_start, end_ticks, + _context, _blocker, 0, + snapshot.active_state, true); + profiler->clearTaskBlockRun(_slot_id, _generation); + 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..06a90f6cd6 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.h @@ -0,0 +1,68 @@ +/* + * 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, + STREAM_SOCKET_READ = 8, + STREAM_SOCKET_WRITE = 9, +}; + +// 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..b5cf8f440b --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp @@ -0,0 +1,310 @@ +/* + * 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() {} + +NativeFdClassifier::~NativeFdClassifier() { + delete[] _fd_type_cache; + delete[] _high_fd_type_cache; +} + +void NativeFdClassifier::ensureCachesAllocated() { + std::call_once(_cache_alloc_once, [this]() { + std::atomic* fd_type_cache = + new std::atomic[FD_TYPE_CACHE_SIZE]; + std::atomic* high_fd_type_cache = + new std::atomic[HIGH_FD_TYPE_CACHE_SIZE]; + 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); + } + // std::call_once establishes happens-before for every caller that + // observed it return, so a plain store is sufficient to publish these. + _fd_type_cache = fd_type_cache; + _high_fd_type_cache = high_fd_type_cache; + }); +} + +#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; +} + +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)) { + 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 { + 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 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); +} + +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) + && highFdEntryEpoch(entry) == (epoch & HIGH_FD_EPOCH_MASK); +} + +bool NativeFdClassifier::highFdEntryMatchesFd(uint64_t entry, int 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) { + return static_cast(static_cast(fd) % + static_cast(HIGH_FD_TYPE_CACHE_SIZE)); +} + +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, epoch)) { + 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) { + 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; +} + +uint8_t NativeFdClassifier::fdType(int fd) { + if (fd < 0) { + return 0; + } + + if (static_cast(fd) >= static_cast(FD_TYPE_CACHE_SIZE)) { + return highFdType(fd); + } + + 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; + } + } + + 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) { + 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; +} + +bool NativeFdClassifier::isStreamSocket(int fd) { + ensureCachesAllocated(); + return fdType(fd) == FD_TYPE_STREAM_SOCKET; +} + +bool NativeFdClassifier::isDatagramSocket(int fd) { + ensureCachesAllocated(); + return fdType(fd) == FD_TYPE_DATAGRAM_SOCKET; +} + +void NativeFdClassifier::cacheNonSocket(int fd) { + if (fd < 0) { + return; + } + ensureCachesAllocated(); + 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); + 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, desired, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return; + } + epoch = _fd_cache_gen.load(std::memory_order_acquire); + } +} + +void NativeFdClassifier::clearFdType(int fd) { + if (fd < 0) { + return; + } + ensureCachesAllocated(); + if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { + 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_acq_rel); +} + +#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..2550107042 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.h @@ -0,0 +1,86 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_FD_CLASSIFIER_H +#define _NATIVE_FD_CLASSIFIER_H + +#include +#include +#include + +#if defined(__linux__) + +class NativeFdClassifier { +public: + NativeFdClassifier(); + ~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 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}; + // Allocated lazily on first fd classification so an interposer/sampler + // instance that never actually classifies an fd (e.g. disabled at start, + // or a process with no matching native I/O) never pays the ~544 KiB RSS + // cost of these two tables. + std::once_flag _cache_alloc_once; + std::atomic* _fd_type_cache = nullptr; + std::atomic* _high_fd_type_cache = nullptr; + void ensureCachesAllocated(); + + static uint8_t probeFdType(int fd); + 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); + uint8_t highFdType(int fd); + 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..dcda7a2028 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp @@ -0,0 +1,824 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeSocketInterposer.h" + +#if defined(__linux__) + +#include "counters.h" +#include "libraries.h" +#include "libraryPatcher.h" +#include "log.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, + NativeBlockKind block_kind, + Call call) { + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + + bool sampler_active = NativeSocketSampler::active(); + + // 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); + } + + ssize_t ret; + u64 t0, t1; + { + NativeBlockScope block(block_kind, fd); + t0 = TSC::ticks(); + ret = call(fn); + t1 = TSC::ticks(); + } + if (sampler_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) { + if (fn == nullptr) { + errno = ENOSYS; + return static_cast(-1); + } + if (!NativeSocketInterposer::instance()->active()) { + return call(fn); + } + 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}; +std::atomic NativeSocketInterposer::_hook_owner_pid{0}; + +static_assert(std::atomic::is_always_lock_free, + "native I/O hook function pointers must be lock-free"); +static_assert(std::atomic::is_always_lock_free, + "native I/O hook owner PID must be lock-free"); + +const NativeSocketInterposer::NativeIoHookSpec* NativeSocketInterposer::hookSpecs() { + static const NativeIoHookSpec specs[NUM_NATIVE_IO_HOOKS] = { + {im_send, "send", reinterpret_cast(send_hook), + reinterpret_cast(fork_safe_send_hook)}, + {im_recv, "recv", reinterpret_cast(recv_hook), + reinterpret_cast(fork_safe_recv_hook)}, + {im_write, "write", reinterpret_cast(write_hook), + reinterpret_cast(fork_safe_write_hook)}, + {im_read, "read", reinterpret_cast(read_hook), + reinterpret_cast(fork_safe_read_hook)}, + {im_close, "close", reinterpret_cast(close_hook), + reinterpret_cast(fork_safe_close_hook)}, + {im_dup2, "dup2", reinterpret_cast(dup2_hook), + reinterpret_cast(fork_safe_dup2_hook)}, + {im_dup3, "dup3", reinterpret_cast(dup3_hook), + reinterpret_cast(fork_safe_dup3_hook)}, + {im_connect, "connect", reinterpret_cast(connect_hook), + reinterpret_cast(fork_safe_connect_hook)}, + {im_accept, "accept", reinterpret_cast(accept_hook), + reinterpret_cast(fork_safe_accept_hook)}, + {im_accept4, "accept4", reinterpret_cast(accept4_hook), + reinterpret_cast(fork_safe_accept4_hook)}, + {im_recvfrom, "recvfrom", reinterpret_cast(recvfrom_hook), + reinterpret_cast(fork_safe_recvfrom_hook)}, + {im_recvmsg, "recvmsg", reinterpret_cast(recvmsg_hook), + reinterpret_cast(fork_safe_recvmsg_hook)}, + {im_epoll_wait, "epoll_wait", reinterpret_cast(epoll_wait_hook), + reinterpret_cast(fork_safe_epoll_wait_hook)}, + {im_epoll_pwait, "epoll_pwait", reinterpret_cast(epoll_pwait_hook), + reinterpret_cast(fork_safe_epoll_pwait_hook)}, + {im_poll, "poll", reinterpret_cast(poll_hook), + reinterpret_cast(fork_safe_poll_hook)}, + {im_ppoll, "ppoll", reinterpret_cast(ppoll_hook), + reinterpret_cast(fork_safe_ppoll_hook)}, + {im_select, "select", reinterpret_cast(select_hook), + reinterpret_cast(fork_safe_select_hook)}, + {im_pselect, "pselect", reinterpret_cast(pselect_hook), + reinterpret_cast(fork_safe_pselect_hook)}, + }; + return specs; +} + +static bool markJavaProfilerHook(void* fn_addr) { + CodeCache* lib = Libraries::instance()->findLibraryByAddress(fn_addr); + if (lib == nullptr) { + Counters::increment(NATIVE_HOOK_MARK_RESOLVE_FAILED); + return false; + } + const char* name = nullptr; + lib->binarySearch(fn_addr, &name); + if (name == nullptr) { + Counters::increment(NATIVE_HOOK_MARK_RESOLVE_FAILED); + return false; + } + NativeFunc::set_mark(name, MARK_JAVA_PROFILER); + return true; +} + +bool NativeSocketInterposer::markProfilerHooks() { + bool hooks_marked = + markJavaProfilerHook(reinterpret_cast(NativeSocketSampler::send_hook)); + hooks_marked &= + markJavaProfilerHook(reinterpret_cast(NativeSocketSampler::recv_hook)); + hooks_marked &= + markJavaProfilerHook(reinterpret_cast(NativeSocketSampler::write_hook)); + hooks_marked &= + markJavaProfilerHook(reinterpret_cast(NativeSocketSampler::read_hook)); + + const NativeIoHookSpec* specs = hookSpecs(); + for (int hook_index = HOOK_SEND; hook_index <= HOOK_READ; hook_index++) { + hooks_marked &= markJavaProfilerHook(specs[hook_index].hook); + hooks_marked &= markJavaProfilerHook(specs[hook_index].fork_safe_hook); + } + return hooks_marked; +} + +bool NativeSocketInterposer::isForkChild() { + pid_t current_pid = getpid(); + return current_pid != _hook_owner_pid.load(std::memory_order_acquire); +} + +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(); + if (!markProfilerHooks()) { + Log::warn("NativeSocketInterposer: failed to mark one or more hook symbols; " + "native call stacks for socket samples may contain profiler frames"); + } + _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, + NativeBlockKind::STREAM_SOCKET_WRITE, + [&](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, + NativeBlockKind::STREAM_SOCKET_READ, + [&](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, + NativeBlockKind::STREAM_SOCKET_WRITE, + [&](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, + NativeBlockKind::STREAM_SOCKET_READ, + [&](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) { + connect_fn fn = _orig_connect.load(std::memory_order_acquire); + auto call = [&](connect_fn fn) { return fn(fd, addr, addrlen); }; + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + if (!NativeSocketInterposer::instance()->active()) { + return call(fn); + } + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::CONNECT, fd, fn, call); +} + +int NativeSocketInterposer::accept_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen) { + accept_fn fn = _orig_accept.load(std::memory_order_acquire); + auto call = [&](accept_fn fn) { return fn(fd, addr, addrlen); }; + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + if (!NativeSocketInterposer::instance()->active()) { + return call(fn); + } + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, fn, call); +} + +int NativeSocketInterposer::accept4_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen, int flags) { + accept4_fn fn = _orig_accept4.load(std::memory_order_acquire); + auto call = [&](accept4_fn fn) { return fn(fd, addr, addrlen, flags); }; + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + if (!NativeSocketInterposer::instance()->active()) { + return call(fn); + } + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, fn, call); +} + +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); + }); +} + +ssize_t NativeSocketInterposer::fork_safe_send_hook(int fd, const void* buf, + size_t len, int flags) { + if (!isForkChild()) { + return send_hook(fd, buf, len, flags); + } + send_fn original = _orig_send.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, buf, len, flags); +} + +ssize_t NativeSocketInterposer::fork_safe_recv_hook(int fd, void* buf, + size_t len, int flags) { + if (!isForkChild()) { + return recv_hook(fd, buf, len, flags); + } + recv_fn original = _orig_recv.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, buf, len, flags); +} + +ssize_t NativeSocketInterposer::fork_safe_write_hook(int fd, const void* buf, + size_t len) { + if (!isForkChild()) { + return write_hook(fd, buf, len); + } + write_fn original = _orig_write.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, buf, len); +} + +ssize_t NativeSocketInterposer::fork_safe_read_hook(int fd, void* buf, + size_t len) { + if (!isForkChild()) { + return read_hook(fd, buf, len); + } + read_fn original = _orig_read.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, buf, len); +} + +int NativeSocketInterposer::fork_safe_close_hook(int fd) { + if (!isForkChild()) { + return close_hook(fd); + } + close_fn original = _orig_close.load(std::memory_order_acquire); + return original == nullptr ? static_cast(syscall(SYS_close, fd)) + : original(fd); +} + +int NativeSocketInterposer::fork_safe_dup2_hook(int oldfd, int newfd) { + if (!isForkChild()) { + return dup2_hook(oldfd, newfd); + } + dup2_fn original = _orig_dup2.load(std::memory_order_acquire); + if (original != nullptr) { + return original(oldfd, newfd); + } +#ifdef SYS_dup2 + return static_cast(syscall(SYS_dup2, oldfd, newfd)); +#else + errno = ENOSYS; + return -1; +#endif +} + +int NativeSocketInterposer::fork_safe_dup3_hook(int oldfd, int newfd, int flags) { + if (!isForkChild()) { + return dup3_hook(oldfd, newfd, flags); + } + dup3_fn original = _orig_dup3.load(std::memory_order_acquire); + if (original != nullptr) { + return original(oldfd, newfd, flags); + } +#ifdef SYS_dup3 + return static_cast(syscall(SYS_dup3, oldfd, newfd, flags)); +#else + errno = ENOSYS; + return -1; +#endif +} + +int NativeSocketInterposer::fork_safe_connect_hook( + int fd, const struct sockaddr* addr, socklen_t addrlen) { + if (!isForkChild()) { + return connect_hook(fd, addr, addrlen); + } + connect_fn original = _orig_connect.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, addr, addrlen); +} + +int NativeSocketInterposer::fork_safe_accept_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen) { + if (!isForkChild()) { + return accept_hook(fd, addr, addrlen); + } + accept_fn original = _orig_accept.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, addr, addrlen); +} + +int NativeSocketInterposer::fork_safe_accept4_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen, + int flags) { + if (!isForkChild()) { + return accept4_hook(fd, addr, addrlen, flags); + } + accept4_fn original = _orig_accept4.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, addr, addrlen, flags); +} + +ssize_t NativeSocketInterposer::fork_safe_recvfrom_hook( + int fd, void* buf, size_t len, int flags, struct sockaddr* src_addr, + socklen_t* addrlen) { + if (!isForkChild()) { + return recvfrom_hook(fd, buf, len, flags, src_addr, addrlen); + } + recvfrom_fn original = _orig_recvfrom.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, buf, len, flags, src_addr, addrlen); +} + +ssize_t NativeSocketInterposer::fork_safe_recvmsg_hook(int fd, + struct msghdr* msg, + int flags) { + if (!isForkChild()) { + return recvmsg_hook(fd, msg, flags); + } + recvmsg_fn original = _orig_recvmsg.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fd, msg, flags); +} + +int NativeSocketInterposer::fork_safe_epoll_wait_hook( + int epfd, struct epoll_event* events, int maxevents, int timeout) { + if (!isForkChild()) { + return epoll_wait_hook(epfd, events, maxevents, timeout); + } + epoll_wait_fn original = _orig_epoll_wait.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(epfd, events, maxevents, timeout); +} + +int NativeSocketInterposer::fork_safe_epoll_pwait_hook( + int epfd, struct epoll_event* events, int maxevents, int timeout, + const sigset_t* sigmask) { + if (!isForkChild()) { + return epoll_pwait_hook(epfd, events, maxevents, timeout, sigmask); + } + epoll_pwait_fn original = _orig_epoll_pwait.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(epfd, events, maxevents, timeout, sigmask); +} + +int NativeSocketInterposer::fork_safe_poll_hook(struct pollfd* fds, + nfds_t nfds, int timeout) { + if (!isForkChild()) { + return poll_hook(fds, nfds, timeout); + } + poll_fn original = _orig_poll.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fds, nfds, timeout); +} + +int NativeSocketInterposer::fork_safe_ppoll_hook( + struct pollfd* fds, nfds_t nfds, const struct timespec* timeout_ts, + const sigset_t* sigmask) { + if (!isForkChild()) { + return ppoll_hook(fds, nfds, timeout_ts, sigmask); + } + ppoll_fn original = _orig_ppoll.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(fds, nfds, timeout_ts, sigmask); +} + +int NativeSocketInterposer::fork_safe_select_hook( + int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, + struct timeval* timeout) { + if (!isForkChild()) { + return select_hook(nfds, readfds, writefds, exceptfds, timeout); + } + select_fn original = _orig_select.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(nfds, readfds, writefds, exceptfds, timeout); +} + +int NativeSocketInterposer::fork_safe_pselect_hook( + int nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, + const struct timespec* timeout_ts, const sigset_t* sigmask) { + if (!isForkChild()) { + return pselect_hook(nfds, readfds, writefds, exceptfds, timeout_ts, sigmask); + } + pselect_fn original = _orig_pselect.load(std::memory_order_acquire); + if (original == nullptr) { + errno = ENOSYS; + return -1; + } + return original(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..8186ab8fee --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.h @@ -0,0 +1,236 @@ +/* + * 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; + void* fork_safe_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); + } + static pid_t setHookOwnerPidForTest(pid_t pid) { + return _hook_owner_pid.exchange(pid, std::memory_order_acq_rel); + } +#endif + + bool isStreamSocket(int fd); + bool isDatagramSocket(int fd); + void clearFdType(int fd); + void clearFdTypeCache(); + + static const NativeIoHookSpec* hookSpecs(); + // Marks every wrapper that can appear in a sampled socket call chain. + // Both sampler-only and TaskBlock startup paths call this because IBM JCL + // bridge imports use the fork-safe interposer layer even when TaskBlock is off. + static bool markProfilerHooks(); + static bool setOriginalFunction(int hook_index, void* original); + static void setHookOwnerPid(pid_t pid) { + _hook_owner_pid.store(pid, std::memory_order_release); + } + + 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); + + // These hooks are installed only in JDK libraries that may also execute in + // a post-fork child. In that child they call libc directly, before touching + // profiler TLS, caches, locks, clocks, or recording state. + static ssize_t fork_safe_send_hook(int fd, const void* buf, size_t len, int flags); + static ssize_t fork_safe_recv_hook(int fd, void* buf, size_t len, int flags); + static ssize_t fork_safe_write_hook(int fd, const void* buf, size_t len); + static ssize_t fork_safe_read_hook(int fd, void* buf, size_t len); + static int fork_safe_close_hook(int fd); + static int fork_safe_dup2_hook(int oldfd, int newfd); + static int fork_safe_dup3_hook(int oldfd, int newfd, int flags); + static int fork_safe_connect_hook(int fd, const struct sockaddr* addr, + socklen_t addrlen); + static int fork_safe_accept_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen); + static int fork_safe_accept4_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen, int flags); + static ssize_t fork_safe_recvfrom_hook(int fd, void* buf, size_t len, int flags, + struct sockaddr* src_addr, + socklen_t* addrlen); + static ssize_t fork_safe_recvmsg_hook(int fd, struct msghdr* msg, int flags); + static int fork_safe_epoll_wait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout); + static int fork_safe_epoll_pwait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout, + const sigset_t* sigmask); + static int fork_safe_poll_hook(struct pollfd* fds, nfds_t nfds, int timeout); + static int fork_safe_ppoll_hook(struct pollfd* fds, nfds_t nfds, + const struct timespec* timeout_ts, + const sigset_t* sigmask); + static int fork_safe_select_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, struct timeval* timeout); + static int fork_safe_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; + static std::atomic _hook_owner_pid; + + static bool isForkChild(); + + 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 2a1bd9c66e..ddd506b14d 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp @@ -7,13 +7,12 @@ #if defined(__linux__) -#include "codeCache.h" #include "common.h" #include "counters.h" #include "flightRecorder.h" -#include "libraries.h" #include "libraryPatcher.h" #include "log.h" +#include "nativeSocketInterposer.h" #include "os.h" #include "profiler.h" #include "tsc.h" @@ -30,30 +29,6 @@ static thread_local PoissonSampler _send_sampler; static thread_local PoissonSampler _recv_sampler; -// Marks the hook wrapper's own symbol as MARK_JAVA_PROFILER so native call-stack -// unwinding (Profiler::convertNativeTrace) can recognize the boundary between -// profiler-internal frames and the real caller, mirroring MallocHooker::initialize(). -// Resolved by address (not by symbol-name predicate) because these are mangled -// C++ static member functions, unlike malloc_hook's extern "C" free functions. -// Returns false if the symbol could not be resolved/marked, in which case the -// hook boundary is never recognized and every socket sample's native stack -// comes back empty (see NATIVE_TRACE_HOOK_PREFIX_NOT_FOUND). -static bool markJavaProfilerHook(void* fn_addr) { - CodeCache* lib = Libraries::instance()->findLibraryByAddress(fn_addr); - if (lib == nullptr) { - Counters::increment(NATIVE_HOOK_MARK_RESOLVE_FAILED); - return false; - } - const char* name = nullptr; - lib->binarySearch(fn_addr, &name); - if (name == nullptr) { - Counters::increment(NATIVE_HOOK_MARK_RESOLVE_FAILED); - return false; - } - NativeFunc::set_mark(name, MARK_JAVA_PROFILER); - return true; -} - // Debug-only hook-fire counters, paired with TEST_LOG (common.h). Gated at // compile time to keep release hot paths free of cross-thread atomic writes. #ifdef DEBUG @@ -71,6 +46,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; @@ -111,51 +114,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) { @@ -173,18 +132,28 @@ void NativeSocketSampler::insertFdAddrLocked(int fd, std::string addr) { } } +void NativeSocketSampler::clearFdCacheEntry(int fd) { + // Always invalidate the classifier entry: this is called from the + // interposer's close/dup hooks whenever native I/O patching is active, + // regardless of whether the sampler itself is currently active, so a + // stale type from this fd's previous lifetime is never resurrected. + _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; } @@ -403,12 +372,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); @@ -420,20 +386,23 @@ Error NativeSocketSampler::start(Arguments &args) { TEST_LOG("NativeSocketSampler::start interval_ticks=%ld tsc_freq=%llu", init_interval, (unsigned long long)TSC::frequency()); #endif - bool hooks_marked = markJavaProfilerHook((void*)&NativeSocketSampler::send_hook); - hooks_marked &= markJavaProfilerHook((void*)&NativeSocketSampler::recv_hook); - hooks_marked &= markJavaProfilerHook((void*)&NativeSocketSampler::write_hook); - hooks_marked &= markJavaProfilerHook((void*)&NativeSocketSampler::read_hook); - if (!hooks_marked) { + if (!NativeSocketInterposer::markProfilerHooks()) { // Not fatal: hooks are still installed and sampling still works, but - // native stacks for socket samples will come back empty because the - // hook boundary frame can't be recognized during unwinding. + // native stacks may be empty or contain profiler frames because every + // installed wrapper must be recognized during unwinding. Log::warn("NativeSocketSampler: failed to mark one or more hook symbols; " - "native call stacks for socket samples may be empty"); + "native call stacks for socket samples may be incomplete"); } + _active.store(true, std::memory_order_release); if (!LibraryPatcher::patch_socket_functions()) { - return Error("failed to install native socket hooks (dlsym returned NULL)"); + _active.store(false, std::memory_order_release); + // patch_socket_functions() covers several distinct, already-logged + // failure modes (unresolvable JDK directory, mprotect/import-table + // failure, allocation failure, dlsym failure) - do not claim a + // specific cause here. Matches NativeSocketInterposer::start()'s + // message for the same underlying failure. + return Error("failed to install native I/O hooks"); } return Error::OK; } @@ -448,18 +417,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..d67e709ee4 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 @@ -25,26 +26,22 @@ 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 +// in the JDK's libnet and libnio DSOs. IBM's JCL networking bridge in libjava +// uses separate wrappers that bypass all profiler state in a post-fork child. +// Arbitrary JNI libraries remain excluded 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 // 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 +68,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 +103,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 +161,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 +184,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 +225,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 +246,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 ff7f4c8f74..fb8f092284 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" @@ -717,7 +718,8 @@ bool Profiler::recordSample(void *ucontext, u64 counter, int tid, } bool Profiler::recordSampleDelegated(void *ucontext, u64 weight, int tid, - jint event_type, Event *event) { + jint event_type, Event *event, + u64 *recorded_correlation_id) { if (!VM::canRequestStackTrace()) { return false; } @@ -752,6 +754,9 @@ bool Profiler::recordSampleDelegated(void *ucontext, u64 weight, int tid, bool recorded = _jfr.recordEventDelegated(lock_index, tid, correlation_id, event_type, event); + if (recorded && recorded_correlation_id != nullptr) { + *recorded_correlation_id = correlation_id; + } _locks[lock_index].unlock(); return recorded; } @@ -859,11 +864,15 @@ void Profiler::leaveTaskBlockActivity() { _task_block_inflight.fetch_sub(1, std::memory_order_release); } -bool Profiler::beginTaskBlockRotation() { +u64 Profiler::beginTaskBlockRotation() { static const long TASK_BLOCK_ROTATION_TIMEOUT_NS = 200000000L; // 200ms, matches SignalInflight::drain() + // Establish the cut before rejecting exits. An exit that observes the + // rotation flag can then publish a completion timestamp that is guaranteed + // to belong to the next segment. + u64 boundary_ticks = TSC::ticks(); _task_block_rotation.store(true, std::memory_order_release); if (_task_block_inflight.load(std::memory_order_acquire) == 0) { - return true; // fast path: nothing in flight + return boundary_ticks; // fast path: nothing in flight } struct timespec deadline; @@ -871,7 +880,7 @@ bool Profiler::beginTaskBlockRotation() { Log::error("Profiler::beginTaskBlockRotation: clock_gettime(CLOCK_MONOTONIC) failed " "(errno=%d). Skipping task-block rotation to avoid a stuck wait.", errno); _task_block_rotation.store(false, std::memory_order_release); - return false; + return 0; } deadline.tv_nsec += TASK_BLOCK_ROTATION_TIMEOUT_NS; if (deadline.tv_nsec >= 1000000000L) { @@ -885,7 +894,7 @@ bool Profiler::beginTaskBlockRotation() { Log::error("Profiler::beginTaskBlockRotation: clock_gettime(CLOCK_MONOTONIC) failed " "(errno=%d). Skipping task-block rotation to avoid a stuck wait.", errno); _task_block_rotation.store(false, std::memory_order_release); - return false; + return 0; } if (now.tv_sec > deadline.tv_sec || (now.tv_sec == deadline.tv_sec && now.tv_nsec >= deadline.tv_nsec)) { @@ -897,17 +906,212 @@ bool Profiler::beginTaskBlockRotation() { (unsigned long long)remaining); Counters::increment(TASK_BLOCK_ROTATION_TIMEOUT); _task_block_rotation.store(false, std::memory_order_release); - return false; + return 0; } std::this_thread::yield(); } - return true; + return boundary_ticks; } void Profiler::endTaskBlockRotation() { _task_block_rotation.store(false, std::memory_order_release); } +bool Profiler::registerTaskBlockRun(ThreadFilter::SlotID slot_id, + u64 generation, int tid, u64 start_ticks, + const Context &context, u64 blocker, + OSThreadState state) { + if (slot_id < 0 || slot_id >= ThreadFilter::kMaxThreads || generation == 0 || + !taskBlockEnabled() || taskBlockRotationActive()) { + return false; + } + TaskBlockRun &run = _task_block_runs[slot_id]; + u64 expected = 0; + if (!run.generation.compare_exchange_strong( + expected, UINT64_MAX, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return false; + } + run.start_ticks = start_ticks; + run.segment_start_ticks.store(start_ticks, std::memory_order_relaxed); + run.entry_blocker = blocker; + run.tid = tid; + run.context = context; + run.state = state; + run.end_ticks.store(0, std::memory_order_relaxed); + run.call_trace_id.store(0, std::memory_order_relaxed); + run.correlation_id.store(0, std::memory_order_relaxed); + run.final_blocker.store(0, std::memory_order_relaxed); + run.unblocking_span_id.store(0, std::memory_order_relaxed); + run.generation.store(generation, std::memory_order_release); + if (taskBlockRotationActive()) { + clearTaskBlockRun(slot_id, generation); + return false; + } + return true; +} + +void Profiler::recordTaskBlockAnchor(ThreadFilter::SlotID slot_id, + u64 generation, u64 call_trace_id, + u64 correlation_id) { + if (slot_id < 0 || slot_id >= ThreadFilter::kMaxThreads || + (call_trace_id == 0 && correlation_id == 0)) { + return; + } + TaskBlockRun &run = _task_block_runs[slot_id]; + if (run.generation.load(std::memory_order_acquire) != generation) return; + if (call_trace_id != 0) { + if (run.correlation_id.load(std::memory_order_acquire) != 0) return; + u64 expected = 0; + run.call_trace_id.compare_exchange_strong( + expected, call_trace_id, std::memory_order_release, + std::memory_order_relaxed); + } else { + if (run.call_trace_id.load(std::memory_order_acquire) != 0) return; + u64 expected = 0; + run.correlation_id.compare_exchange_strong( + expected, correlation_id, std::memory_order_release, + std::memory_order_relaxed); + } +} + +void Profiler::completeTaskBlockRun(ThreadFilter::SlotID slot_id, + u64 generation, u64 end_ticks, + u64 blocker, u64 unblocking_span_id) { + if (slot_id < 0 || slot_id >= ThreadFilter::kMaxThreads) return; + TaskBlockRun &run = _task_block_runs[slot_id]; + if (run.generation.load(std::memory_order_acquire) != generation) return; + run.final_blocker.store(blocker, std::memory_order_relaxed); + run.unblocking_span_id.store(unblocking_span_id, std::memory_order_relaxed); + run.end_ticks.store(end_ticks, std::memory_order_release); +} + +u64 Profiler::taskBlockSegmentStart(ThreadFilter::SlotID slot_id, + u64 generation) const { + if (slot_id < 0 || slot_id >= ThreadFilter::kMaxThreads) return 0; + const TaskBlockRun &run = _task_block_runs[slot_id]; + if (run.generation.load(std::memory_order_acquire) != generation) return 0; + return run.segment_start_ticks.load(std::memory_order_acquire); +} + +void Profiler::clearTaskBlockRun(ThreadFilter::SlotID slot_id, + u64 generation) { + if (slot_id < 0 || slot_id >= ThreadFilter::kMaxThreads) return; + TaskBlockRun &run = _task_block_runs[slot_id]; + u64 expected = generation; + run.generation.compare_exchange_strong( + expected, 0, std::memory_order_acq_rel, std::memory_order_acquire); +} + +bool Profiler::recordTaskBlockSegmentLocked(int tid, TaskBlockEvent *event) { + return _jfr.recordTaskBlock(getLockIndex(tid), tid, event); +} + +bool Profiler::recordTaskBlockSegment(int tid, TaskBlockEvent *event) { + 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 false; + } + bool recorded = _jfr.recordTaskBlock(lock_index, tid, event); + _locks[lock_index].unlock(); + return recorded; +} + +void Profiler::emitTaskBlockBoundary(u64 boundary_ticks) { + for (int slot_id = 0; slot_id < ThreadFilter::kMaxThreads; slot_id++) { + TaskBlockRun &run = _task_block_runs[slot_id]; + u64 generation = run.generation.load(std::memory_order_acquire); + if (generation == 0 || generation == UINT64_MAX) continue; + u64 segment_start = run.segment_start_ticks.load(std::memory_order_acquire); + u64 end_ticks = run.end_ticks.load(std::memory_order_acquire); + u64 segment_end = end_ticks != 0 && end_ticks < boundary_ticks + ? end_ticks + : boundary_ticks; + if (segment_end <= segment_start) continue; + if (!taskBlockPassesBasicEligibility(segment_start, segment_end, run.context)) { + continue; + } + TaskBlockEvent event{}; + event._start = segment_start; + event._end = segment_end; + u64 final_blocker = run.final_blocker.load(std::memory_order_acquire); + event._blocker = final_blocker != 0 ? final_blocker : run.entry_blocker; + event._unblockingSpanId = + run.unblocking_span_id.load(std::memory_order_acquire); + event._ctx = run.context; + event._callTraceId = run.call_trace_id.load(std::memory_order_acquire); + event._correlationId = run.correlation_id.load(std::memory_order_acquire); + event._observedBlockingState = run.state; + if (event._callTraceId == 0 && event._correlationId == 0) { + Counters::increment(TASK_BLOCK_SEGMENT_STACKLESS); + } + if (recordTaskBlockSegmentLocked(run.tid, &event)) { + Counters::increment(TASK_BLOCK_EMITTED); + } else { + Counters::increment(TASK_BLOCK_RECORD_FAILED); + } + } +} + +void Profiler::commitTaskBlockBoundary(u64 boundary_ticks) { + for (int slot_id = 0; slot_id < ThreadFilter::kMaxThreads; slot_id++) { + TaskBlockRun &run = _task_block_runs[slot_id]; + u64 generation = run.generation.load(std::memory_order_acquire); + if (generation == 0 || generation == UINT64_MAX) continue; + u64 end_ticks = run.end_ticks.load(std::memory_order_acquire); + if (end_ticks != 0 && end_ticks <= boundary_ticks) { + clearTaskBlockRun(slot_id, generation); + continue; + } + run.segment_start_ticks.store(boundary_ticks, std::memory_order_release); + run.call_trace_id.store(0, std::memory_order_release); + run.correlation_id.store(0, std::memory_order_release); + ThreadFilter::Slot *slot = _thread_filter.slotForId(slot_id); + if (slot != nullptr && slot->blockGeneration() == generation) { + slot->resetSampledBlockGeneration(); + } + } +} + +void Profiler::flushCompletedTaskBlockRuns() { + for (int slot_id = 0; slot_id < ThreadFilter::kMaxThreads; slot_id++) { + TaskBlockRun &run = _task_block_runs[slot_id]; + u64 generation = run.generation.load(std::memory_order_acquire); + if (generation == 0 || generation == UINT64_MAX) continue; + u64 end_ticks = run.end_ticks.load(std::memory_order_acquire); + u64 segment_start = run.segment_start_ticks.load(std::memory_order_acquire); + if (end_ticks == 0 || end_ticks <= segment_start) continue; + if (!taskBlockPassesBasicEligibility(segment_start, end_ticks, run.context)) { + clearTaskBlockRun(slot_id, generation); + continue; + } + TaskBlockEvent event{}; + event._start = segment_start; + event._end = end_ticks; + u64 final_blocker = run.final_blocker.load(std::memory_order_acquire); + event._blocker = final_blocker != 0 ? final_blocker : run.entry_blocker; + event._unblockingSpanId = + run.unblocking_span_id.load(std::memory_order_acquire); + event._ctx = run.context; + event._observedBlockingState = run.state; + Counters::increment(TASK_BLOCK_SEGMENT_STACKLESS); + if (recordTaskBlockSegment(run.tid, &event)) { + Counters::increment(TASK_BLOCK_EMITTED); + clearTaskBlockRun(slot_id, generation); + } else { + Counters::increment(TASK_BLOCK_RECORD_FAILED); + } + } +} + +void Profiler::clearAllTaskBlockRuns() { + for (TaskBlockRun &run : _task_block_runs) { + run.generation.store(0, std::memory_order_release); + } +} + void Profiler::recordExternalSample(u64 weight, int tid, int num_frames, ASGCT_CallFrame *frames, bool truncated, jint event_type, Event *event) { @@ -1901,6 +2105,12 @@ Error Profiler::start(Arguments &args, bool reset) { setTaskBlockEnabled( (activated & EM_WALL) && args._wall_precheck && track_unfiltered_wall); + 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); @@ -1946,7 +2156,7 @@ Error Profiler::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. - if (!beginTaskBlockRotation()) { + if (beginTaskBlockRotation() == 0) { return Error("task-block rotation did not drain; teardown skipped, retry stop()"); } @@ -1960,6 +2170,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) @@ -2020,7 +2231,10 @@ Error Profiler::stop() { // correct counts in the recording _thread_info.reportCounters(); + // Unlike a dump rotation, a full stop discards paired intervals that have + // not completed. Their exits may race with teardown after recording ends. rotateDictsAndRun([&]{ _jfr.stop(); }); + clearAllTaskBlockRuns(); endTaskBlockRotation(); // Unpatch libraries AFTER JFR serialization completes @@ -2097,6 +2311,10 @@ Error Profiler::dump(const char *path, const int length) { } if (cur_state == RUNNING) { + int dump_fd = -1; + Error err = _jfr.prepareDump(path, length, &dump_fd); + if (err) return err; + std::set thread_ids; // flush the liveness tracker instance and note all the threads referenced // by the live objects @@ -2108,21 +2326,33 @@ Error Profiler::dump(const char *path, const int length) { updateNativeLibMemStats(); - Error err = Error::OK; // rotateDictsAndRun rotates the dictionaries, takes lockAll() around the // dump (fences ASGCT/JNI writers to CallTraceStorage), then clearStandby()s // the rotated buffers. StringDictionary's RefCountGuard protocol handles // its own writer/reader coordination; #527's classMapSharedGuard readers // (deferred vtable receiver resolution) are coordinated through // _class_map_lock. - if (beginTaskBlockRotation()) { + u64 task_block_boundary = beginTaskBlockRotation(); + if (task_block_boundary == 0) { + close(dump_fd); + err = Error("task-block rotation did not drain; dump skipped"); + } else { rotateDictsAndRun([&]{ - err = _jfr.dump(path, length); - __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); + emitTaskBlockBoundary(task_block_boundary); + err = _jfr.dump(dump_fd); + if (!err) { + commitTaskBlockBoundary(task_block_boundary); + __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); + } }); + close(dump_fd); endTaskBlockRotation(); - } else { - err = Error("task-block rotation did not drain; dump skipped"); + // Run unconditionally: TaskBlockRuns completed while this dump's rotation + // was in flight (TASK_BLOCK_DROPPED_ROTATION) have end_ticks set but no + // clearTaskBlockRun() call of their own, so a failed dump must not skip + // reclamation or their slot stays stranded, blocking that thread's next + // registerTaskBlockRun() indefinitely. + flushCompletedTaskBlockRuns(); } _thread_info.clearAll(thread_ids); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index e743ed3e88..7f41e0b4ec 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -29,6 +29,7 @@ #include "trap.h" #include "vmEntry.h" #include +#include #include #include #include @@ -80,6 +81,21 @@ class alignas(alignof(SpinLock)) Profiler { friend class ProfilerTestAccessor; private: + struct TaskBlockRun { + std::atomic generation{0}; + std::atomic end_ticks{0}; + std::atomic call_trace_id{0}; + std::atomic correlation_id{0}; + std::atomic final_blocker{0}; + std::atomic unblocking_span_id{0}; + u64 start_ticks{0}; + std::atomic segment_start_ticks{0}; + u64 entry_blocker{0}; + int tid{-1}; + Context context{}; + OSThreadState state{OSThreadState::UNKNOWN}; + }; + // signal handlers static volatile bool _signals_initialized; @@ -136,6 +152,7 @@ class alignas(alignof(SpinLock)) Profiler { std::atomic _task_block_monitor_events_enabled{false}; std::atomic _task_block_rotation{false}; std::atomic _task_block_inflight{0}; + std::array _task_block_runs{}; SpinLock _class_map_lock; SpinLock _locks[CONCURRENCY_LEVEL]; @@ -187,8 +204,14 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); void setTaskBlockEnabled(bool enabled); - bool beginTaskBlockRotation(); + u64 beginTaskBlockRotation(); void endTaskBlockRotation(); + bool recordTaskBlockSegment(int tid, TaskBlockEvent *event); + bool recordTaskBlockSegmentLocked(int tid, TaskBlockEvent *event); + void emitTaskBlockBoundary(u64 boundary_ticks); + void commitTaskBlockBoundary(u64 boundary_ticks); + void flushCompletedTaskBlockRuns(); + void clearAllTaskBlockRuns(); // Rotate all three dictionaries, then run jfr_op under lockAll(). // @@ -458,7 +481,8 @@ class alignas(alignof(SpinLock)) Profiler { // stack-trace reference, tagged by the correlation ID we passed to // RequestStackTrace as user_data. bool recordSampleDelegated(void *ucontext, u64 weight, int tid, - jint event_type, Event *event); + jint event_type, Event *event, + u64 *recorded_correlation_id = nullptr); // Shared by recordJVMTISample()/recordTaskBlock(): performs the JVMTI stack walk for // `thread` starting at `start_depth`, converts to ASGCT format, and applies the // JDK21+ virtual-thread continuation-boundary fixup (a real stack, from a carrier's @@ -490,9 +514,26 @@ 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(); + bool registerTaskBlockRun(ThreadFilter::SlotID slot_id, u64 generation, + int tid, u64 start_ticks, const Context &context, + u64 blocker, OSThreadState state); + void recordTaskBlockAnchor(ThreadFilter::SlotID slot_id, u64 generation, + u64 call_trace_id, u64 correlation_id); + void completeTaskBlockRun(ThreadFilter::SlotID slot_id, u64 generation, + u64 end_ticks, u64 blocker, + u64 unblocking_span_id); + u64 taskBlockSegmentStart(ThreadFilter::SlotID slot_id, + u64 generation) const; + void clearTaskBlockRun(ThreadFilter::SlotID slot_id, u64 generation); + bool taskBlockRotationActive() const { + return _task_block_rotation.load(std::memory_order_acquire); + } bool taskBlockEnabled() const { return _task_block_enabled.load(std::memory_order_acquire); } @@ -522,14 +563,32 @@ class alignas(alignof(SpinLock)) Profiler { static void unregisterThread(int tid); #ifdef UNIT_TEST - bool beginTaskBlockRotationForTest() { return beginTaskBlockRotation(); } + u64 beginTaskBlockRotationForTest() { return beginTaskBlockRotation(); } void endTaskBlockRotationForTest() { endTaskBlockRotation(); } bool taskBlockRotationActiveForTest() const { return _task_block_rotation.load(std::memory_order_acquire); } - int taskBlockInflightForTest() const { + u64 taskBlockInflightForTest() const { return _task_block_inflight.load(std::memory_order_acquire); } + u64 taskBlockRunGenerationForTest(ThreadFilter::SlotID slot_id) const { + return _task_block_runs[slot_id].generation.load(std::memory_order_acquire); + } + u64 taskBlockRunEndForTest(ThreadFilter::SlotID slot_id) const { + return _task_block_runs[slot_id].end_ticks.load(std::memory_order_acquire); + } + u64 taskBlockRunCallTraceForTest(ThreadFilter::SlotID slot_id) const { + return _task_block_runs[slot_id].call_trace_id.load(std::memory_order_acquire); + } + u64 taskBlockRunCorrelationForTest(ThreadFilter::SlotID slot_id) const { + return _task_block_runs[slot_id].correlation_id.load(std::memory_order_acquire); + } + u64 taskBlockRunSegmentStartForTest(ThreadFilter::SlotID slot_id) const { + return _task_block_runs[slot_id].segment_start_ticks.load(std::memory_order_acquire); + } + void commitTaskBlockBoundaryForTest(u64 boundary_ticks) { + commitTaskBlockBoundary(boundary_ticks); + } // Returns the tid most recently passed to unregisterThread(), or -1 if it // has never been called (or since the last resetUnregisterObservableForTest). 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..9da8470c33 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 */ @@ -348,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) { @@ -357,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, @@ -450,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; + } + offset += extra_offset; + + uintptr_t load_bias = _header->e_type == ET_EXEC ? 0 : _load_bias; + if (load_bias > UINTPTR_MAX - offset) { + return NULL; } - return _vaddr_diff == NULL ? (const char*)pheader->p_vaddr : _vaddr_diff + pheader->p_vaddr; + 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); @@ -569,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() { @@ -665,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) { @@ -674,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); + } } } } @@ -691,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); + } } } } @@ -792,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); + } } } } @@ -942,45 +962,31 @@ 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 // `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] != '$') { - 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; + if (sym.st_size != 0 || sym.st_info != 0 || sym_name[0] != '$') { + const char* addr = addressAt(sym.st_value); + if (addr == NULL) { + continue; } - _cc->add(addr, (int)sym->st_size, sym_name); + _cc->add(addr, (int)sym.st_size, sym_name); } } } @@ -1183,7 +1189,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 @@ -1258,6 +1269,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/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index ecfd37f29a..364282120a 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -58,19 +58,29 @@ bool finishTaskBlockAtExit(ProfiledThread* current, bool exited = current_slot == slot_id && thread_filter->snapshotAndExitBlockedRun(slot_id, generation, &snapshot); + profiler->completeTaskBlockRun(slot_id, generation, end_ticks, blocker, + unblocking_span_id); + if (!activity.active()) { // TaskBlockActivity's constructor already incremented TASK_BLOCK_DROPPED_ROTATION. + profiler->clearTaskBlockRun(slot_id, generation); return false; } if (!recording_enabled || !exited) { + profiler->clearTaskBlockRun(slot_id, generation); return false; } if (!snapshot.context_eligible) { - Counters::increment(TASK_BLOCK_SKIPPED_CONTEXT_WINDOW); + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + profiler->clearTaskBlockRun(slot_id, generation); return false; } - return recordTaskBlockIfEligible( - current->tid(), thread, start_depth, start_ticks, end_ticks, context, + u64 segment_start = profiler->taskBlockSegmentStart(slot_id, generation); + if (segment_start == 0) segment_start = start_ticks; + bool recorded = recordTaskBlockIfEligible( + current->tid(), thread, start_depth, segment_start, end_ticks, context, blocker, unblocking_span_id, snapshot.active_state, true); + profiler->clearTaskBlockRun(slot_id, generation); + return recorded; } diff --git a/ddprof-lib/src/main/cpp/threadFilter.cpp b/ddprof-lib/src/main/cpp/threadFilter.cpp index d4463b62e5..fa68c37754 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -798,7 +798,8 @@ bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, 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(); @@ -833,7 +834,13 @@ bool ThreadFilter::ownedBlockGeneration(const ThreadEntry& entry, return false; } generation = block_generation; - already_sampled = sampled_generation == block_generation; + // Native hooks own TaskBlock recording at completion, so waiting for a + // MethodSample would let the first wall signal interrupt the syscall and + // end this generation before suppression can take effect. Java and JVMTI + // owners retain their first successful MethodSample. + already_sampled = owner == BlockRunOwner::NATIVE + ? true + : sampled_generation == block_generation; return true; } diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 1509cd497e..39e2337baa 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -9,6 +9,7 @@ #include "common.h" #include "context.h" #include "nativeMem.h" +#include "context_api.h" #include "otel_context.h" #include "os.h" #include "threadLocal.h" @@ -423,6 +424,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 210fdc5b69..d8b595e1c8 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 { @@ -38,7 +39,8 @@ 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; } #endif // _THREADSTATE_H diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index 4fd06e2271..3146604d8a 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -106,7 +106,8 @@ static void monitorBlockEnter(JNIEnv *jni, jthread thread, return; } - if (!current->monitorEnter(TSC::ticks(), context, /*blocker=*/0, state)) { + u64 start_ticks = TSC::ticks(); + if (!current->monitorEnter(start_ticks, context, /*blocker=*/0, state)) { u64 token = current->monitorBlockToken(); ThreadFilter *tf = profiler->threadFilter(); bool current_owner = false; @@ -126,7 +127,8 @@ static void monitorBlockEnter(JNIEnv *jni, jthread thread, return; } current->clearMonitorBlock(); - if (!current->monitorEnter(TSC::ticks(), context, /*blocker=*/0, state)) { + start_ticks = TSC::ticks(); + if (!current->monitorEnter(start_ticks, context, /*blocker=*/0, state)) { return; } } @@ -147,6 +149,13 @@ static void monitorBlockEnter(JNIEnv *jni, jthread thread, current->clearMonitorBlock(); return; } + if (!profiler->registerTaskBlockRun( + slot_id, ThreadFilter::tokenGeneration(token), current->tid(), + start_ticks, context, /*blocker=*/0, state)) { + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); + current->clearMonitorBlock(); + return; + } current->setMonitorBlockToken(token); } diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index 110124b7a7..65dafe2c82 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -57,6 +57,7 @@ static inline bool hasKnownActiveTraceContext(ProfiledThread* thread) { struct WallPrecheckResult { bool suppress = false; ThreadFilter::Slot* owned_block_slot = nullptr; + ThreadFilter::SlotID owned_block_slot_id = -1; u64 owned_block_generation = 0; OSThreadState observed_state = OSThreadState::UNKNOWN; bool observed_state_valid = false; @@ -68,6 +69,11 @@ struct WallPrecheckResult { OSThreadState flush_state = OSThreadState::UNKNOWN; }; +enum class UnownedBlockedFallback { + DISABLED, + ENABLED, +}; + static inline void incrementSuppressedOwnedBlock() { Counters::increment(WC_SIGNAL_SUPPRESSED_OWNED_BLOCK); WallClockCounters::incrementSuppressedOwnedBlock(); @@ -82,7 +88,8 @@ static inline bool suppressAlreadySampledBlock(const ThreadEntry& entry) { } static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, - bool precheck) { + bool precheck, + UnownedBlockedFallback fallback) { WallPrecheckResult result; if (current == nullptr || !precheck || hasKnownActiveTraceContext(current)) { return result; @@ -110,9 +117,9 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - // Owned blocks replace repeated signals only after their current generation - // has produced one MethodSample. Context-scoped profiling must continue - // sampling its selected threads normally. + // Native-owned blocks suppress immediately because their completion hook + // records an eligible TaskBlock stack. Other owners retain their first + // successful MethodSample. Context-scoped profiling continues sampling normally. if (!registry->unfilteredWallTrackingActive() || slot->inContextWindow()) { return result; } @@ -130,6 +137,7 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, // Arm only after recordSample succeeds. A skipped JFR write must leave the // run eligible so the next signal retries instead of losing its only stack. result.owned_block_slot = slot; + result.owned_block_slot_id = current->filterSlotId(); result.owned_block_generation = block_generation; return result; } @@ -137,6 +145,13 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } + // Suppressed tails require a recorded call trace for deferred replay. The + // delegated JVMTI path does not return one, so it keeps unowned observations + // on ordinary per-signal sampling. + if (fallback == UnownedBlockedFallback::DISABLED) { + return result; + } + result.observed_state = getOSThreadState(); result.observed_state_valid = true; if (isPrecheckSuppressionState(result.observed_state)) { @@ -156,8 +171,12 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, static inline void finishWallPrecheck(const WallPrecheckResult& precheck, bool recorded, - u64 recorded_call_trace_id = 0) { + u64 recorded_call_trace_id = 0, + u64 recorded_correlation_id = 0) { if (recorded && precheck.owned_block_slot != nullptr) { + Profiler::instance()->recordTaskBlockAnchor( + precheck.owned_block_slot_id, precheck.owned_block_generation, + recorded_call_trace_id, recorded_correlation_id); precheck.owned_block_slot->markBlockGenerationSampled( precheck.owned_block_generation); } @@ -263,7 +282,8 @@ void WallClockASGCT::signalHandler(int signo, siginfo_t *siginfo, void *ucontext // its first successful MethodSample and suppresses subsequent signals. // Unowned blocked observations use weighted fallback sampling because raw OS // state cannot distinguish one long sleep from several shorter runs. - WallPrecheckResult precheck = prepareWallPrecheck(current, _precheck); + WallPrecheckResult precheck = prepareWallPrecheck( + current, _precheck, UnownedBlockedFallback::ENABLED); if (precheck.suppress) { return; } @@ -506,8 +526,8 @@ void WallClockJvmti::signalHandler(int signo, siginfo_t *siginfo, errno = saved_errno; return; } - - WallPrecheckResult precheck = prepareWallPrecheck(current, _precheck); + WallPrecheckResult precheck = prepareWallPrecheck( + current, _precheck, UnownedBlockedFallback::DISABLED); if (precheck.suppress) { errno = saved_errno; return; @@ -535,9 +555,11 @@ void WallClockJvmti::signalHandler(int signo, siginfo_t *siginfo, // the thread is currently inside JVM-internal (non-Java) code. // JVMTI-delegated samples carry no call_trace_id, so unowned tail flushing // remains limited to the ASGCT wall engine. + u64 recorded_correlation_id = 0; bool recorded = Profiler::instance()->recordSampleDelegated( - nullptr, last_sample, tid, BCI_WALL, &event); - finishWallPrecheck(precheck, recorded); + nullptr, last_sample, tid, BCI_WALL, &event, + &recorded_correlation_id); + finishWallPrecheck(precheck, recorded, 0, recorded_correlation_id); Shims::instance().setSighandlerTid(-1); errno = saved_errno; } 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..f101e43e60 --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp @@ -0,0 +1,370 @@ +/* + * 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 +#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::current() != 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); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_SKIPPED_TRACE_CONTEXT)); + 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, FinishAfterFilterDisableExitsWithoutRecording) { + 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::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) { + 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->slotForId(slot_id)->snapshotBlockRun(); + 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..ff1f8cb5dd --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp @@ -0,0 +1,2100 @@ +/* + * 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_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}; +std::atomic g_taskblock_exit_sequence{0}; +std::atomic g_taskblock_kind{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); +} + +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) { + 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; +}; + +class ScopedHookOwnerPid { +public: + explicit ScopedHookOwnerPid(pid_t pid) + : _saved(NativeSocketInterposer::setHookOwnerPidForTest(pid)) {} + ~ScopedHookOwnerPid() { + NativeSocketInterposer::setHookOwnerPidForTest(_saved); + } + +private: + pid_t _saved; +}; + +void expectJavaProfilerMark(void* fn_addr) { + CodeCache* lib = Libraries::instance()->findLibraryByAddress(fn_addr); + ASSERT_NE(nullptr, lib); + const char* name = nullptr; + lib->binarySearch(fn_addr, &name); + ASSERT_NE(nullptr, name); + EXPECT_EQ(MARK_JAVA_PROFILER, NativeFunc::read_mark(name)) << name; +} + +TEST(NativeSocketInterposerMarkTest, MarksEverySampledSocketHookLayer) { + Libraries::instance()->updateSymbols(false); + ASSERT_TRUE(NativeSocketInterposer::markProfilerHooks()); + + void* sampler_hooks[] = { + reinterpret_cast(NativeSocketSampler::send_hook), + reinterpret_cast(NativeSocketSampler::recv_hook), + reinterpret_cast(NativeSocketSampler::write_hook), + reinterpret_cast(NativeSocketSampler::read_hook), + }; + for (void* hook : sampler_hooks) { + expectJavaProfilerMark(hook); + } + + const NativeSocketInterposer::NativeIoHookSpec* specs = + NativeSocketInterposer::hookSpecs(); + for (int hook_index = NativeSocketInterposer::HOOK_SEND; + hook_index <= NativeSocketInterposer::HOOK_READ; hook_index++) { + expectJavaProfilerMark(specs[hook_index].hook); + expectJavaProfilerMark(specs[hook_index].fork_safe_hook); + } +} + +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 kind, int) { + int sequence = g_sequence.fetch_add(1) + 1; + if (strcmp(phase, "enter") == 0) { + g_taskblock_kind.store(static_cast(kind), std::memory_order_release); + 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_taskblock_kind = 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] = {}; + NativeBlockScope::setHookObserverForTest(native_block_observer); + + 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(static_cast(NativeBlockKind::STREAM_SOCKET_WRITE), + g_taskblock_kind.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketReadUsesReadBlockKind) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_read_ret = 5; + char buf[8] = {}; + NativeBlockScope::setHookObserverForTest(native_block_observer); + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + ssize_t ret = NativeSocketInterposer::read_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(5, ret); + EXPECT_EQ(1, g_read_calls.load()); + EXPECT_EQ(static_cast(NativeBlockKind::STREAM_SOCKET_READ), + g_taskblock_kind.load()); + 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, + ForkSafeOwnerProcessUsesInstrumentedHook) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedHookOwnerPid owner(getpid()); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + NativeBlockScope::setHookObserverForTest(native_block_observer); + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_write_ret = 37; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + ssize_t ret = NativeSocketInterposer::fork_safe_write_hook( + fds[0], buf, sizeof(buf)); + + EXPECT_EQ(37, ret); + EXPECT_EQ(1, g_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()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + ForkSafeChildBypassesProfilerState) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedHookOwnerPid owner(getpid() + 1); + 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_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_send_ret = 41; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::fork_safe_send_hook( + fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(41, ret); + EXPECT_EQ(1, g_send_calls.load()); + EXPECT_EQ(0, g_sampler_send_calls.load()); + EXPECT_EQ(0, g_fd_probe_calls.load()); + EXPECT_EQ(0, g_taskblock_enter_sequence.load()); + EXPECT_EQ(0, g_taskblock_exit_sequence.load()); + EXPECT_EQ(0, g_sampler_record_sequence.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + ForkSafeChildReadAndPollBypassProfilerState) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedHookOwnerPid owner(getpid() + 1); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + NativeBlockScope::setHookObserverForTest(native_block_observer); + g_read_ret = 47; + g_poll_ret = 3; + char buf[8] = {}; + struct pollfd poll_fd = {fds[0], POLLIN, 0}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + EXPECT_EQ(47, NativeSocketInterposer::fork_safe_read_hook( + fds[0], buf, sizeof(buf))); + EXPECT_EQ(3, NativeSocketInterposer::fork_safe_poll_hook(&poll_fd, 1, 10)); + + EXPECT_EQ(1, g_read_calls.load()); + EXPECT_EQ(1, g_poll_calls.load()); + EXPECT_EQ(0, g_fd_probe_calls.load()); + EXPECT_EQ(0, g_taskblock_enter_sequence.load()); + EXPECT_EQ(0, g_taskblock_exit_sequence.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + ForkSafeChildNullOriginalReturnsEnosysWithoutInstrumentation) { + ScopedHookOwnerPid owner(getpid() + 1); + setOriginalFunction(NativeSocketInterposer::HOOK_SEND, nullptr); + char buf[8] = {}; + + errno = 0; + ssize_t ret = NativeSocketInterposer::fork_safe_send_hook( + -1, buf, sizeof(buf), 0); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_send_calls.load()); + EXPECT_EQ(0, g_fd_probe_calls.load()); +} + +TEST_F(NativeSocketInterposerHookTest, + ForkSafeChildCloseDoesNotClearProfilerCaches) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedHookOwnerPid owner(getpid() + 1); + ScopedFdProbeOverride override(recording_fd_probe); + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_close_ret = 0; + g_close_errno = E2BIG; + + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(fds[0])); + NativeSocketSampler::instance()->fdAddrCacheInsertForTest(fds[0], "cached"); + g_fd_probe_calls = 0; + errno = ERANGE; + int ret = NativeSocketInterposer::fork_safe_close_hook(fds[0]); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_close_calls.load()); + EXPECT_EQ(E2BIG, errno); + EXPECT_TRUE(NativeSocketSampler::instance()->fdAddrCacheContainsForTest(fds[0])); + EXPECT_TRUE(NativeSocketInterposer::instance()->isStreamSocket(fds[0])); + EXPECT_EQ(0, g_fd_probe_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ForkSafeHookDetectsRealForkChild) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedHookOwnerPid owner(getpid()); + ScopedNativeSocketInterposerActive interposer_active(true); + g_send_ret = 43; + char buf[8] = {}; + + pid_t child = fork(); + ASSERT_GE(child, 0); + if (child == 0) { + ssize_t ret = NativeSocketInterposer::fork_safe_send_hook( + fds[0], buf, sizeof(buf), 0); + _exit(ret == 43 ? 0 : 1); + } + + int status = 0; + ASSERT_EQ(child, waitpid(child, &status, 0)); + EXPECT_TRUE(WIFEXITED(status)); + EXPECT_EQ(0, WEXITSTATUS(status)); + 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(LibraryPatcherSocketStateTest, + RestrictsSocketHooksToJdkNetworkingLibraries) { + CodeCache standard("libnet.so"); + CodeCache openjdk_java("libjava.so"); + CodeCache partial_ibm_java("libjava.so"); + CodeCache ibm_java("libjava.so"); + CodeCache marked_other("libother.so"); + + char marker_addresses[4] = {}; + partial_ibm_java.add(&marker_addresses[0], 1, "JCL_Send"); + const char* markers[] = { + "JCL_Send", "JCL_Recv", "JCL_Connect", "JCL_Accept"}; + for (size_t index = 0; index < 4; index++) { + ibm_java.add(&marker_addresses[index], 1, markers[index]); + marked_other.add(&marker_addresses[index], 1, markers[index]); + } + + EXPECT_EQ(SOCKET_PATCH_STANDARD_JDK_NETWORK, + LibraryPatcher::socket_patch_target_for_test( + &standard, "libnet.so", true)); + EXPECT_EQ(SOCKET_PATCH_STANDARD_JDK_NETWORK, + LibraryPatcher::socket_patch_target_for_test( + &standard, "libnio.so", true)); + + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &openjdk_java, "libjava.so", true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &partial_ibm_java, "libjava.so", true)); + EXPECT_EQ(SOCKET_PATCH_IBM_JCL_BRIDGE, + LibraryPatcher::socket_patch_target_for_test( + &ibm_java, "libjava.so", true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &marked_other, "libother.so", true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &ibm_java, "libjava.so", false)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &standard, "libnative-plugin.so", true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &standard, "libnet.so.backup", true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &standard, "libnet.so", false)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + &standard, nullptr, true)); + EXPECT_EQ(SOCKET_PATCH_NONE, + LibraryPatcher::socket_patch_target_for_test( + nullptr, "libnet.so", true)); + + void* standard_hook = LibraryPatcher::socket_hook_for_target_for_test( + SOCKET_PATCH_STANDARD_JDK_NETWORK, NativeSocketInterposer::HOOK_SEND); + void* ibm_hook = LibraryPatcher::socket_hook_for_target_for_test( + SOCKET_PATCH_IBM_JCL_BRIDGE, NativeSocketInterposer::HOOK_SEND); + EXPECT_EQ(reinterpret_cast(NativeSocketInterposer::send_hook), + standard_hook); + EXPECT_EQ(reinterpret_cast(NativeSocketInterposer::fork_safe_send_hook), + ibm_hook); + EXPECT_NE(standard_hook, ibm_hook); + for (int hook_index = 0; + hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; hook_index++) { + void* regular = LibraryPatcher::socket_hook_for_target_for_test( + SOCKET_PATCH_STANDARD_JDK_NETWORK, hook_index); + void* fork_safe = LibraryPatcher::socket_hook_for_target_for_test( + SOCKET_PATCH_IBM_JCL_BRIDGE, hook_index); + EXPECT_NE(nullptr, regular) << hook_index; + EXPECT_NE(nullptr, fork_safe) << hook_index; + EXPECT_NE(regular, fork_safe) << hook_index; + } +} + +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) { + // 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(); + + 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, + 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)); + 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/taskBlockRecorder_ut.cpp b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp index d16910dc68..1d972202c2 100644 --- a/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp +++ b/ddprof-lib/src/test/cpp/taskBlockRecorder_ut.cpp @@ -99,6 +99,48 @@ TEST_F(TaskBlockRecorderTest, RotationRejectsNewActivity) { profiler->leaveTaskBlockActivity(); } +TEST_F(TaskBlockRecorderTest, RunRegistryPreservesFirstAnchorAndResetsAtBoundary) { + Profiler* profiler = Profiler::instance(); + bool previous = profiler->setTaskBlockEnabledForTest(true); + constexpr ThreadFilter::SlotID slot_id = 7; + constexpr u64 generation = 42; + Context context{}; + context.rootSpanId = 123; + + ASSERT_TRUE(profiler->registerTaskBlockRun( + slot_id, generation, 999, 1000, context, 0, + OSThreadState::SLEEPING)); + profiler->recordTaskBlockAnchor(slot_id, generation, 111, 0); + profiler->recordTaskBlockAnchor(slot_id, generation, 222, 0); + EXPECT_EQ(111ULL, profiler->taskBlockRunCallTraceForTest(slot_id)); + EXPECT_EQ(0ULL, profiler->taskBlockRunCorrelationForTest(slot_id)); + + profiler->commitTaskBlockBoundaryForTest(2000); + EXPECT_EQ(generation, profiler->taskBlockRunGenerationForTest(slot_id)); + EXPECT_EQ(2000ULL, profiler->taskBlockRunSegmentStartForTest(slot_id)); + EXPECT_EQ(0ULL, profiler->taskBlockRunCallTraceForTest(slot_id)); + + profiler->recordTaskBlockAnchor(slot_id, generation, 0, 333); + EXPECT_EQ(333ULL, profiler->taskBlockRunCorrelationForTest(slot_id)); + profiler->completeTaskBlockRun(slot_id, generation, 2500, 17, 19); + EXPECT_EQ(2500ULL, profiler->taskBlockRunEndForTest(slot_id)); + profiler->clearTaskBlockRun(slot_id, generation); + EXPECT_EQ(0ULL, profiler->taskBlockRunGenerationForTest(slot_id)); + profiler->setTaskBlockEnabledForTest(previous); +} + +TEST_F(TaskBlockRecorderTest, RunRegistryRejectsEntryDuringRotation) { + Profiler* profiler = Profiler::instance(); + bool previous = profiler->setTaskBlockEnabledForTest(true); + profiler->beginTaskBlockRotationForTest(); + Context context{}; + EXPECT_FALSE(profiler->registerTaskBlockRun( + 8, 43, 1000, 100, context, 0, OSThreadState::SLEEPING)); + EXPECT_EQ(0ULL, profiler->taskBlockRunGenerationForTest(8)); + profiler->endTaskBlockRotationForTest(); + profiler->setTaskBlockEnabledForTest(previous); +} + TEST_F(TaskBlockRecorderTest, RotationWaitsForInflightActivity) { Profiler* profiler = Profiler::instance(); ASSERT_TRUE(profiler->tryEnterTaskBlockActivity()); diff --git a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp index b0ab362e5a..777db8bba3 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -827,6 +827,105 @@ 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(), + slot->recordingEpoch()}; + u64 generation = ThreadFilter::tokenGeneration(token); + EXPECT_EQ(0u, slot->sampledBlockGeneration()); + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( + {1235, slot, slot->lifecycleGeneration(), slot->recordingEpoch()})); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate( + {1234, slot, slot->lifecycleGeneration() + 1, + slot->recordingEpoch()})); + + ASSERT_TRUE(filter->exitBlockedRun(slot_id, generation)); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + +TEST_F(ThreadFilterTest, ConcurrentNativeExitInvalidatesSuppressionSnapshot) { + 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(), + slot->recordingEpoch()}; + struct SnapshotPause { + std::atomic reached{false}; + std::atomic resume{false}; + } pause; + filter->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(filter->isOwnedBlockSuppressionCandidate(entry), + 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(); + filter->setSuppressionSnapshotHookForTest(nullptr, nullptr); + GTEST_FAIL() << "Suppression reader did not reach the snapshot barrier"; + } + + EXPECT_TRUE(filter->exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + pause.resume.store(true, std::memory_order_release); + reader.join(); + filter->setSuppressionSnapshotHookForTest(nullptr, nullptr); + + EXPECT_FALSE(suppressed.load(std::memory_order_acquire)); +} + +TEST_F(ThreadFilterTest, OwnedJvmtiBlockSuppressesOnlyAfterSuccessfulWallSample) { + 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::MONITOR_WAIT, BlockRunOwner::JVMTI); + ASSERT_NE(0ULL, token); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration(), + slot->recordingEpoch()}; + u64 generation = ThreadFilter::tokenGeneration(token); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); + + slot->markBlockGenerationSampled(generation); + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + ASSERT_TRUE(filter->exitBlockedRun(slot_id, generation)); + 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..c6ebc61d6a --- /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,filter=", "wall=1s,filter=,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/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java index f91eeef578..c628fc206d 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/J9WallClockPrecheckCapabilityTest.java @@ -6,15 +6,27 @@ package com.datadoghq.profiler.wallclock; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvents; import com.datadoghq.profiler.Platform; import com.datadoghq.profiler.ProfilerOwnedBlockHooks; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; 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; + private static final int BLOCK_HOLD_MILLIS = 500; /** Ensures owned-block hooks stay inactive when the selected wall engine cannot consume them. */ @Test @@ -24,6 +36,35 @@ public void unsupportedEngineDoesNotActivateRegistry() { assertEquals(0L, token, "J9WallClock must not activate unfiltered precheck tracking"); } + /** Ensures unsupported precheck falls back to ordinary wall samples without a profiling gap. */ + @Test + public void blockingSocketReadFallsBackToMethodSample() throws Exception { + String workerName = "taskblock-j9-jvmti-fallback"; + Map before = profiler.getDebugCounters(); + + runBlockingSocketRead(workerName); + + Map after = profiler.getDebugCounters(); + assertEquals( + before.getOrDefault("task_block_emitted", 0L), + after.getOrDefault("task_block_emitted", 0L), + "J9WallClock must not emit TaskBlock events"); + assertEquals( + before.getOrDefault("wc_signals_suppressed_owned_block", 0L), + after.getOrDefault("wc_signals_suppressed_owned_block", 0L), + "J9WallClock must not suppress wall signals"); + + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock", false); + assertFalse( + TaskBlockAssertions.containsEventThread(taskBlocks, workerName), + "J9WallClock fallback must not emit a TaskBlock for the worker"); + JfrEvents methodSamples = verifyEvents("datadog.MethodSample", false); + assertTrue( + TaskBlockAssertions.containsEventThread(methodSamples, workerName), + "J9WallClock fallback must retain wall-clock MethodSample coverage for the worker"); + } + @Override protected boolean isPlatformSupported() { return Platform.isJ9(); @@ -33,4 +74,41 @@ protected boolean isPlatformSupported() { protected String getProfilerCommand() { return "wall=1ms,wallsampler=jvmti,filter=,wallprecheck=true"; } + + private static void runBlockingSocketRead(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 enter 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()); + } + } + } } 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 f91a0f79cd..7c8bfca168 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 @@ -194,41 +194,69 @@ public void virtualThreadCannotMutateCarrierTaskBlockState() throws Exception { @Test public void liveDumpPreservesTaskBlockAfterEntrySample() throws Exception { + String workerName = "taskblock-live-dump"; CountDownLatch armed = new CountDownLatch(1); CountDownLatch release = new CountDownLatch(1); AtomicBoolean recorded = new AtomicBoolean(); + AtomicLong logicalStart = new AtomicLong(); + AtomicLong logicalEnd = new AtomicLong(); AtomicReference error = new AtomicReference<>(); long before = profiler.getDebugCounters() .getOrDefault("wc_signals_suppressed_owned_block", 0L); Thread worker = new Thread(() -> { try { + logicalStart.set(System.nanoTime()); long token = profiler.beginTaskBlock(); assertTrue(token != 0); armed.countDown(); assertTrue(release.await(5, TimeUnit.SECONDS)); recorded.set(profiler.endTaskBlock(token, BLOCKER, UNBLOCKING_SPAN_ID)); + logicalEnd.set(System.nanoTime()); } catch (Throwable t) { error.set(t); } - }, "taskblock-live-dump"); + }, workerName); worker.start(); assertTrue(armed.await(5, TimeUnit.SECONDS)); waitForCounterAbove("wc_signals_suppressed_owned_block", before, 5_000L); + Thread.sleep(150L); Path snapshot = Files.createTempFile("taskblock-live-dump-", ".jfr"); try { dump(snapshot); + JfrEvents prefix = verifyEvents(snapshot, "datadog.TaskBlock", true); + assertEquals(1, TaskBlockAssertions.countEventsForThread(prefix, workerName)); + TaskBlockAssertions.assertContainsStackTrace(prefix); + TaskBlockAssertions.assertNoCorrelationId(prefix); + + Thread.sleep(150L); + release.countDown(); + worker.join(5_000L); + assertFalse(worker.isAlive()); + if (error.get() != null) throw new AssertionError(error.get()); + assertTrue(recorded.get()); + + stopProfiler(); + JfrEvents suffix = verifyEvents("datadog.TaskBlock"); + assertEquals(1, TaskBlockAssertions.countEventsForThread(suffix, workerName)); + TaskBlockAssertions.assertContainsStackTrace(suffix); + TaskBlockAssertions.assertNoCorrelationId(suffix); + + double segmentedDuration = + TaskBlockAssertions.durationNanosForThread(prefix, workerName) + + TaskBlockAssertions.durationNanosForThread(suffix, workerName); + long logicalDuration = logicalEnd.get() - logicalStart.get(); + assertTrue(segmentedDuration >= logicalDuration * 0.75, + "rotation lost TaskBlock duration: segmented=" + segmentedDuration + + ", logical=" + logicalDuration); + assertTrue(segmentedDuration <= logicalDuration * 1.25, + "rotation duplicated TaskBlock duration: segmented=" + segmentedDuration + + ", logical=" + logicalDuration); } finally { + release.countDown(); + worker.join(5_000L); 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 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..f8a77e856f --- /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,filter=,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 new file mode 100644 index 0000000000..8aaed4d78a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java @@ -0,0 +1,259 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +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.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** 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 preloadJdkNetworking() throws Exception { + if (Platform.isLinux()) { + 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 { + String enabledWorkerName = "taskblock-native-lifecycle-enabled"; + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runCompletedSocketRead(enabledWorkerName); + } + stopProfiler(); + assertIoWaitTaskBlockPresent( + verifyEvents("datadog.TaskBlock", false), enabledWorkerName); + + Path disabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_disabled_", ".jfr"); + boolean disabledRunning = false; + try { + profiler.execute("start,wall=1ms,filter=,wallprecheck=false,jfr,file=" + + disabledRecording.toAbsolutePath()); + disabledRunning = true; + String disabledWorkerName = "taskblock-native-lifecycle-disabled"; + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runCompletedSocketRead(disabledWorkerName); + } + profiler.stop(); + disabledRunning = false; + + JfrEvents disabledTaskBlocks = + verifyEvents(disabledRecording, "datadog.TaskBlock", false); + assertFalse(TaskBlockAssertions.containsEventThread( + disabledTaskBlocks, disabledWorkerName), + "wallprecheck=false restart must not emit a socket TaskBlock for the worker"); + } finally { + if (disabledRunning) { + profiler.stop(); + } + Files.deleteIfExists(disabledRecording); + } + + Path reenabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_reenabled_", ".jfr"); + boolean reenabledRunning = false; + try { + profiler.execute("start,wall=1ms,filter=,wallprecheck=true,jfr,file=" + + reenabledRecording.toAbsolutePath()); + reenabledRunning = true; + String reenabledWorkerName = "taskblock-native-lifecycle-reenabled"; + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runCompletedSocketRead(reenabledWorkerName); + } + profiler.stop(); + reenabledRunning = false; + + assertIoWaitTaskBlockPresent(verifyEvents( + reenabledRecording, "datadog.TaskBlock", false), reenabledWorkerName); + } finally { + if (reenabledRunning) { + profiler.stop(); + } + 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,filter=,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; + + JfrEvents 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,filter=,wallprecheck=true"; + } + + private void assertIoWaitTaskBlockPresent( + JfrEvents taskBlockEvents, String workerName) { + if (!taskBlockEvents.hasItems()) { + fail(missingTaskBlockDiagnostic()); + } + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType( + taskBlockEvents, "NativeSocketTaskBlockLifecycleTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsObservedStateForEventThread( + taskBlockEvents, "IO_WAIT", workerName), + "Expected native IO_WAIT TaskBlock for " + workerName); + } + + 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..79ac088593 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java @@ -0,0 +1,390 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.api.Test; + +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Method; +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.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; + + @Test + public void blockingSocketReadEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingSocketReadOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained("taskblock-native-socket-read"); + } + + 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("taskblock-native-socket-accept"); + } + + 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("taskblock-native-datagram-receive"); + } + + 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 { + String workerName = "taskblock-native-selector-select"; + long suppressedBefore = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + runBlockingSelectorSelectOnce(); + long suppressedAfter = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + assertTrue(suppressedAfter > suppressedBefore, + "Expected wall signals to be suppressed during the native selector wait"); + + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock", false); + assertIoWaitTaskBlockSelfContained(taskBlocks, workerName); + int workerEvents = TaskBlockAssertions.countEventsForThread(taskBlocks, workerName); + assertTrue(workerEvents >= 1 && workerEvents <= 2, + "Expected one logical selector wait to produce at most two TaskBlocks, got: " + + workerEvents); + } + + 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 blockingSocketReadFromVirtualThreadIdentifiesCarrierThread() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + AtomicReference virtualThreadRef = new AtomicReference<>(); + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Runnable task = () -> { + virtualThreadRef.set(Thread.currentThread()); + 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); + } + }; + Thread virtual = (Thread) startVirtualThread.invoke(null, task); + 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(virtual, error); + } + + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock"); + long virtualThreadId = virtualThreadRef.get().getId(); + for (long blocker : TaskBlockAssertions.distinctBlockers(taskBlocks)) { + TaskBlockAssertions.assertBlockerEventThreadDiffers(taskBlocks, blocker, virtualThreadId); + } + } + + @Test + public void tracedBlockingSocketReadDoesNotEmitTaskBlock() throws Exception { + String workerName = "taskblock-traced-native-socket-read"; + long skippedBefore = profiler.getDebugCounters() + .getOrDefault("task_block_skipped_trace_context", 0L); + 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(); + profiler.setTraceContext(0x5100L, 0x5101L, 0L, 0x5101L, -1, null, -1, null); + try { + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } finally { + profiler.clearTraceContext(); + } + } + } catch (Throwable t) { + error.set(t); + } + }, workerName); + + 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); + } + + assertTrue(profiler.getDebugCounters() + .getOrDefault("task_block_skipped_trace_context", 0L) > skippedBefore, + "Native socket hook did not reject the traced blocking read"); + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock", false); + assertFalse( + TaskBlockAssertions.containsEventThread(taskBlocks, workerName), + "Traced socket I/O must not emit a TaskBlock for the worker"); + JfrEvents methodSamples = verifyEvents("datadog.MethodSample", false); + assertTrue(TaskBlockAssertions.containsEventThread(methodSamples, workerName), + "Traced socket I/O must retain MethodSample wall-clock data for the worker"); + assertTrue(TaskBlockAssertions.containsSpan(methodSamples, 0x5101L), + "Traced socket MethodSample must retain its span context"); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,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(String workerName) { + assertIoWaitTaskBlockSelfContained( + verifyEvents("datadog.TaskBlock", false), workerName); + } + + private void assertIoWaitTaskBlockSelfContained( + JfrEvents taskBlockEvents, String workerName) { + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsObservedStateForEventThread( + taskBlockEvents, "IO_WAIT", workerName), + "Expected native IO_WAIT TaskBlock for " + workerName); + } + + protected void assertTaskBlockStackReference(JfrEvents taskBlockEvents) { + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType(taskBlockEvents, "NativeSocketTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + } + + private void assertNativeTaskBlockPresent(JfrEvents 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 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/OpenJ9NativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/OpenJ9NativeSocketTaskBlockTest.java new file mode 100644 index 0000000000..bf3946b96a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/OpenJ9NativeSocketTaskBlockTest.java @@ -0,0 +1,146 @@ +/* + * 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.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.JfrEvents; +import com.datadoghq.profiler.Platform; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +/** Verifies that OpenJ9's ASGCT wall engine supports native socket TaskBlock production. */ +public class OpenJ9NativeSocketTaskBlockTest extends AbstractProfilerTest { + private static final int BLOCK_HOLD_MILLIS = 250; + + /** Requires hook installation, signal suppression, and a self-contained worker TaskBlock. */ + @Test + public void asgctWallEngineEmitsNativeSocketTaskBlock() throws Exception { + String workerName = "taskblock-openj9-native-socket-read"; + Map before = profiler.getDebugCounters(); + assertExpectedHookPath(before); + + runBlockingSocketRead(workerName); + + Map after = profiler.getDebugCounters(); + assertTrue( + after.getOrDefault("task_block_emitted", 0L) + > before.getOrDefault("task_block_emitted", 0L), + "OpenJ9 native socket read did not emit a TaskBlock"); + assertTrue( + after.getOrDefault("wc_signals_suppressed_owned_block", 0L) + > before.getOrDefault("wc_signals_suppressed_owned_block", 0L), + "OpenJ9 ASGCT wall precheck did not suppress the owned native block"); + assertEquals( + before.getOrDefault("task_block_stack_capture_failed", 0L), + after.getOrDefault("task_block_stack_capture_failed", 0L), + "OpenJ9 TaskBlock stack capture failed"); + assertEquals( + before.getOrDefault("task_block_record_failed", 0L), + after.getOrDefault("task_block_record_failed", 0L), + "OpenJ9 TaskBlock recording failed"); + + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock", false); + assertTrue( + TaskBlockAssertions.containsObservedStateForEventThread( + taskBlocks, "IO_WAIT", workerName), + "Expected an OpenJ9 native IO_WAIT TaskBlock for " + workerName); + TaskBlockAssertions.assertContainsStackTrace(taskBlocks); + TaskBlockAssertions.assertContainsJavaType(taskBlocks, "OpenJ9NativeSocketTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlocks); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux() && Platform.isJ9(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,filter=,wallprecheck=true"; + } + + private static void assertExpectedHookPath(Map counters) { + long standardHooks = counters.getOrDefault("native_io_standard_hooks_patched", 0L); + long ibmBridgeHooks = counters.getOrDefault("native_io_ibm_bridge_hooks_patched", 0L); + if (isLegacyIbmJ9()) { + assertTrue( + ibmBridgeHooks > 0, + "Legacy IBM J9 must install native I/O hooks in the IBM JCL bridge; " + + hookDiagnostic(standardHooks, ibmBridgeHooks)); + } else { + assertTrue( + standardHooks > 0, + "Semeru/OpenJ9 must install native I/O hooks in standard JDK networking libraries; " + + hookDiagnostic(standardHooks, ibmBridgeHooks)); + } + } + + private static boolean isLegacyIbmJ9() { + String vmName = System.getProperty("java.vm.name", ""); + String fullVersion = System.getProperty("java.fullversion", ""); + return vmName.contains("IBM J9") || fullVersion.startsWith("JRE 1.8.0 IBM"); + } + + private static String hookDiagnostic(long standardHooks, long ibmBridgeHooks) { + return "java.vm.name=" + + System.getProperty("java.vm.name", "") + + ", java.fullversion=" + + System.getProperty("java.fullversion", "") + + ", standard hooks=" + + standardHooks + + ", IBM bridge hooks=" + + ibmBridgeHooks; + } + + private static void runBlockingSocketRead(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 enter 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()); + } + } + } +} 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 5b54c2981c..69ccd8c512 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 @@ -12,6 +12,8 @@ import java.util.HashSet; import java.util.Set; +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; @@ -23,6 +25,8 @@ final class TaskBlockAssertions { private static final String SUPPRESSED_SAMPLE_COUNT = "suppressedSampleCount"; private static final String OBSERVED_BLOCKING_STATE = "observedBlockingState"; private static final String CORRELATION_ID = "correlationId"; + private static final String EVENT_THREAD = "eventThread"; + private static final String DURATION = "duration"; private TaskBlockAssertions() {} @@ -90,7 +94,8 @@ static void assertContainsJavaType(JfrEvents events, String expected) { static void assertNoCorrelationId(JfrEvents events) { for (JfrEvent item : events) { - assertNull(item.get(CORRELATION_ID)); + assertTrue(item.getLong(CORRELATION_ID, Long.MIN_VALUE) == 0, + "Direct-stack TaskBlock must have correlationId=0"); } } @@ -100,4 +105,75 @@ static void assertNoAnchorFields(JfrEvents events) { assertNull(item.get(SUPPRESSED_SAMPLE_COUNT)); } } + + static boolean containsObservedStateForEventThread( + JfrEvents events, String observedState, String threadName) { + for (JfrEvent item : events) { + if (observedState.equals(item.getString(OBSERVED_BLOCKING_STATE)) + && threadName.equals(item.getThreadName(EVENT_THREAD))) { + return true; + } + } + return false; + } + + static boolean containsEventThread(JfrEvents events, String threadName) { + for (JfrEvent item : events) { + if (threadName.equals(item.getThreadName(EVENT_THREAD))) { + return true; + } + } + return false; + } + + static int countEventsForThread(JfrEvents events, String threadName) { + int count = 0; + for (JfrEvent item : events) { + if (threadName.equals(item.getThreadName(EVENT_THREAD))) { + count++; + } + } + return count; + } + + static double durationNanosForThread(JfrEvents events, String threadName) { + double durationNanos = 0; + for (JfrEvent item : events) { + if (threadName.equals(item.getThreadName(EVENT_THREAD))) { + durationNanos += item.getLong(DURATION, 0L); + } + } + return durationNanos; + } + + static boolean containsSpan(JfrEvents events, long spanId) { + for (JfrEvent item : events) { + if (item.getLong(AbstractProfilerTest.SPAN_ID, Long.MIN_VALUE) == spanId) { + return true; + } + } + return false; + } + + static void assertBlockerEventThreadDiffers( + JfrEvents events, long blocker, long logicalThreadId) { + int checked = 0; + for (JfrEvent item : events) { + if (item.getLong(BLOCKER, Long.MIN_VALUE) != blocker) continue; + Long eventThreadId = item.getThreadJavaId(EVENT_THREAD); + assertNotNull(eventThreadId, "TaskBlock eventThread must not be null"); + assertNotEquals(Long.valueOf(logicalThreadId), eventThreadId, + "Native TaskBlock must identify the physical carrier, not the virtual thread"); + checked++; + } + assertTrue(checked > 0, "Expected TaskBlock eventThread for blocker=" + blocker); + } + + static Set distinctBlockers(JfrEvents events) { + Set blockers = new HashSet<>(); + for (JfrEvent item : events) { + blockers.add(item.getLong(BLOCKER, Long.MIN_VALUE)); + } + return blockers; + } } From f57e4ae6f5a0a1b35f150794db4e7242777b883d Mon Sep 17 00:00:00 2001 From: Paul Fournillon Date: Fri, 21 Aug 2026 12:34:11 +0200 Subject: [PATCH 19/19] feat: squashed commit for native_socket_netty rebase onto taskblock-native-io --- ddprof-lib/src/main/cpp/counters.h | 1 + ddprof-lib/src/main/cpp/event.h | 3 +- ddprof-lib/src/main/cpp/javaApi.cpp | 13 +++- ddprof-lib/src/main/cpp/nativeBlock.cpp | 6 ++ ddprof-lib/src/main/cpp/profiler.cpp | 26 ++++++++ ddprof-lib/src/main/cpp/taskBlockRecorder.cpp | 2 +- ddprof-lib/src/main/cpp/threadLocalData.h | 16 +++++ ddprof-lib/src/test/cpp/nativeBlock_ut.cpp | 14 +++++ .../wallclock/MonitorTaskBlockTest.java | 60 +++++++++++-------- .../wallclock/NativeSocketTaskBlockTest.java | 58 ++++++++++++++++++ .../wallclock/TaskBlockAssertions.java | 10 ++++ 11 files changed, 181 insertions(+), 28 deletions(-) diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 0d20483036..dcb139eb20 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -87,6 +87,7 @@ X(TASK_BLOCK_SKIPPED_TRACE_CONTEXT, "task_block_skipped_trace_context") \ X(TASK_BLOCK_SKIPPED_CONTEXT_WINDOW, "task_block_skipped_context_window") \ X(TASK_BLOCK_SKIPPED_TOO_SHORT, "task_block_skipped_too_short") \ + X(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD, "task_block_skipped_jvm_internal_thread") \ X(TASK_BLOCK_STACK_CAPTURE_FAILED, "task_block_stack_capture_failed") \ X(TASK_BLOCK_RECORD_FAILED, "task_block_record_failed") \ X(TASK_BLOCK_SEGMENT_STACKLESS, "task_block_segment_stackless") \ diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index 823657a7ff..46f2d4f7f8 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -58,10 +58,11 @@ class ExecutionEvent : public Event { OSThreadState _thread_state; ExecutionMode _execution_mode; u64 _weight; + u64 _call_trace_id; ExecutionEvent() : Event(), _thread_state(OSThreadState::RUNNABLE), _execution_mode(ExecutionMode::UNKNOWN), - _weight(1) {} + _weight(1), _call_trace_id(0) {} }; class AllocEvent : public Event { diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 6cc7f11086..50824f1275 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -424,7 +424,8 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( Profiler *profiler = Profiler::instance(); ThreadFilter *tf = profiler->threadFilter(); - if (context.spanId == 0 && tf->registryActive() && + if (context.spanId == 0 && !current->isJvmInternalThread() && + tf->registryActive() && (profiler->taskBlockEnabled() || tf->enabled())) { ThreadFilter::SlotID slot_id = tf->ensureCurrentThreadSlot(current); if (slot_id >= 0) { @@ -440,6 +441,8 @@ Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); } } + } else if (context.spanId == 0 && current->isJvmInternalThread()) { + Counters::increment(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD); } return JNI_TRUE; } @@ -503,6 +506,10 @@ Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( if (current == nullptr) { return 0; } + if (current->isJvmInternalThread()) { + Counters::increment(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD); + return 0; + } u64 span_id = 0, root_span_id = 0; ContextApi::get(span_id, root_span_id); if (span_id != 0) { @@ -551,6 +558,10 @@ Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( !profiler->taskBlockEnabled()) { return 0; } + if (current->isJvmInternalThread()) { + Counters::increment(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD); + return 0; + } ThreadFilter *tf = profiler->threadFilter(); if (!tf->unfilteredWallTrackingActive()) return 0; if (!isCurrentJniThread(env, thread)) { diff --git a/ddprof-lib/src/main/cpp/nativeBlock.cpp b/ddprof-lib/src/main/cpp/nativeBlock.cpp index d21c995198..c4eb7a2f42 100644 --- a/ddprof-lib/src/main/cpp/nativeBlock.cpp +++ b/ddprof-lib/src/main/cpp/nativeBlock.cpp @@ -59,6 +59,12 @@ NativeBlockScope::NativeBlockScope(NativeBlockKind kind, int blocker_id, return; } + if (current->isJvmInternalThread()) { + Counters::increment(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD); + errno = saved_errno; + return; + } + ThreadFilter::SlotID slot_id = current->filterSlotId(); if (slot_id < 0) { errno = saved_errno; diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index fb8f092284..57849d1b6a 100644 --- a/ddprof-lib/src/main/cpp/profiler.cpp +++ b/ddprof-lib/src/main/cpp/profiler.cpp @@ -83,6 +83,31 @@ static ITimerJvmti itimer_jvmti; static CTimer ctimer; static CTimerJvmti ctimer_jvmti; +// jdk.internal.misc.InnocuousThread is the JDK's own marker class for its +// security-context-free housekeeping threads (Read-Poller/Write-Poller, +// Common-Cleaner, VirtualThread-unblocker). These are JVM plumbing, never +// application "task" work, so TaskBlock producers must not attribute +// blocking activity to them. +static bool isJvmInternalThread(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { + jclass cls = jni->GetObjectClass(thread); + if (cls == nullptr) { + if (jni->ExceptionCheck()) { + jni->ExceptionClear(); + } + return false; + } + char *sig = nullptr; + bool result = false; + if (jvmti->GetClassSignature(cls, &sig, nullptr) == 0 && sig != nullptr) { + result = strcmp(sig, "Ljdk/internal/misc/InnocuousThread;") == 0; + } + if (sig != nullptr) { + jvmti->Deallocate(reinterpret_cast(sig)); + } + jni->DeleteLocalRef(cls); + return result; +} + void Profiler::onThreadStart(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { // JVMTI callback - outside signal handler ProfiledThread* current = ProfiledThread::initCurrentThreadSignalSafe(); @@ -91,6 +116,7 @@ void Profiler::onThreadStart(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { } current->setJavaThread(true); + current->setJvmInternalThread(thread != NULL && isJvmInternalThread(jvmti, jni, thread)); int tid = current->tid(); // Java lifecycle callbacks own registry allocation. The wall timer only // looks up these entries and must never allocate slots for arbitrary OS diff --git a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp index 364282120a..f38298b6a4 100644 --- a/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp +++ b/ddprof-lib/src/main/cpp/taskBlockRecorder.cpp @@ -71,7 +71,7 @@ bool finishTaskBlockAtExit(ProfiledThread* current, return false; } if (!snapshot.context_eligible) { - Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + Counters::increment(TASK_BLOCK_SKIPPED_CONTEXT_WINDOW); profiler->clearTaskBlockRun(slot_id, generation); return false; } diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index 39e2337baa..c86b759b73 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -59,6 +59,10 @@ class ProfiledThread : public ThreadLocalData { static constexpr u32 FLAG_PARKED = 0x4u; // next free bit after TYPE_MASK (0x1|0x2) static constexpr u32 FLAG_CLAIMED = 0x8u; // Used by ThreadLocalDataPool only static constexpr u32 FLAG_MONITOR_BLOCKED = 0x10u; + // Set once at JVMTI ThreadStart for threads whose runtime class is + // jdk.internal.misc.InnocuousThread (e.g. Read-Poller/Write-Poller, + // Common-Cleaner): JDK housekeeping threads, never application "task" work. + static constexpr u32 FLAG_JVM_INTERNAL_THREAD = 0x20u; // We are allowing several levels of nesting because we can be // eg. in a crash handler when wallclock signal kicks in, @@ -411,6 +415,18 @@ class ProfiledThread : public ThreadLocalData { return static_cast(flags & TYPE_MASK); } + inline void setJvmInternalThread(bool is_internal) { + if (is_internal) { + __atomic_fetch_or(&_misc_flags, FLAG_JVM_INTERNAL_THREAD, __ATOMIC_RELEASE); + } else { + __atomic_fetch_and(&_misc_flags, ~FLAG_JVM_INTERNAL_THREAD, __ATOMIC_ACQ_REL); + } + } + + inline bool isJvmInternalThread() const { + return (__atomic_load_n(&_misc_flags, __ATOMIC_ACQUIRE) & FLAG_JVM_INTERNAL_THREAD) != 0; + } + // JFR tag encoding sidecar — populated by JNI thread, read by signal handler // (flightRecorder.cpp writeCurrentContext / wallClock.cpp collapsing). inline u32* getOtelTagEncodingsPtr() { return _otel_tag_encodings; } diff --git a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp index f101e43e60..90df2d2ed1 100644 --- a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp +++ b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp @@ -166,6 +166,20 @@ TEST_F(NativeBlockScopeTest, NonJavaThreadGateLeavesScopeInactiveAndPreservesErr EXPECT_EQ(E2BIG, errno); } +TEST_F(NativeBlockScopeTest, JvmInternalThreadGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + registerCurrentJavaThread(current.thread()); + current.thread()->setJvmInternalThread(true); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); + EXPECT_EQ(1, Counters::getCounter(TASK_BLOCK_SKIPPED_JVM_INTERNAL_THREAD)); +} + TEST_F(NativeBlockScopeTest, MissingSlotGateLeavesScopeInactiveAndPreservesErrno) { CurrentThreadScope current; ScopedTaskBlockEnabled task_block_enabled(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 967624a18c..cca35464a9 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 @@ -86,29 +86,7 @@ public void shortMonitorContentionIsFilteredAndDoesNotSuppressLongerOnes() throw // Burst of genuinely contended, microsecond-long enters: two threads hammer the same // monitor with an empty critical section, so no interval can reach the 1ms threshold. - CountDownLatch start = new CountDownLatch(1); - AtomicReference burstFailure = new AtomicReference<>(); - int[] counter = new int[1]; - Thread[] burst = new Thread[2]; - for (int i = 0; i < burst.length; i++) { - burst[i] = new Thread(() -> { - try { - assertTrue(start.await(5, TimeUnit.SECONDS)); - for (int n = 0; n < 20_000; n++) { - synchronized (shortMonitor) { - counter[0]++; - } - } - } catch (Throwable t) { - burstFailure.set(t); - } - }, "taskblock-short-contention-" + i); - burst[i].start(); - } - start.countDown(); - for (Thread thread : burst) { - assertCompleted(thread, burstFailure); - } + runContentionBurst(shortMonitor); // One long contended enter, the positive control: it proves the producer was alive. CountDownLatch attempting = new CountDownLatch(1); @@ -134,11 +112,43 @@ public void shortMonitorContentionIsFilteredAndDoesNotSuppressLongerOnes() throw JfrEvents events = verifyEvents("datadog.TaskBlock"); assertTrue(TaskBlockAssertions.containsBlocker(events, identityHash(longMonitor)), "long contention was not emitted"); - assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(shortMonitor)), - "sub-threshold contention must be filtered"); + // The burst does 40,000 contended enters with an empty critical section, so almost all + // should fall under the 1ms filter threshold. A rare enter can still legitimately exceed + // it if the OS descheduled the lock holder mid-critical-section (safepoint, GC, container + // CPU contention) - that is correct profiler behavior, not a filtering bug. Tolerate a + // small number of such outliers; a real filtering regression would leak most/all of them. + int leaked = TaskBlockAssertions.countBlockerEvents(events, identityHash(shortMonitor)); + assertTrue(leaked <= 5, + "sub-threshold contention must be filtered, but " + leaked + " events leaked through"); assertTaskBlockStackReference(events); } + private void runContentionBurst(Object monitor) throws Exception { + CountDownLatch start = new CountDownLatch(1); + AtomicReference burstFailure = new AtomicReference<>(); + int[] counter = new int[1]; + Thread[] burst = new Thread[2]; + for (int i = 0; i < burst.length; i++) { + burst[i] = new Thread(() -> { + try { + assertTrue(start.await(5, TimeUnit.SECONDS)); + for (int n = 0; n < 20_000; n++) { + synchronized (monitor) { + counter[0]++; + } + } + } catch (Throwable t) { + burstFailure.set(t); + } + }, "taskblock-short-contention-" + i); + burst[i].start(); + } + start.countDown(); + for (Thread thread : burst) { + assertCompleted(thread, burstFailure); + } + } + @Test public void contextWindowObjectWaitDoesNotEmitTaskBlock() throws Exception { Object monitor = new Object(); 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 79ac088593..143fb7fcfd 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 @@ -252,6 +252,12 @@ public void blockingSocketReadFromVirtualThreadIdentifiesCarrierThread() throws assertCompleted(virtual, error); } + // Positive control: a platform thread blocking on the same native path proves the + // producer is alive, so the check below isn't vacuously true. Virtual-thread I/O alone + // legitimately emits no TaskBlock: it never enters epoll_wait itself, and the JDK's + // internal Read-Poller thread that does is excluded from attribution. + runBlockingSocketReadOnce(); + stopProfiler(); JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock"); long virtualThreadId = virtualThreadRef.get().getId(); @@ -315,6 +321,58 @@ public void tracedBlockingSocketReadDoesNotEmitTaskBlock() throws Exception { "Traced socket MethodSample must retain its span context"); } + @Test + public void virtualThreadReadPollerDoesNotEmitTaskBlock() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Runnable task = () -> { + 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); + } + }; + // Starting a virtual thread that performs blocking socket I/O causes the JDK + // to spin up its internal Read-Poller (jdk.internal.misc.InnocuousThread), which + // then loops calling epoll_wait for the remainder of the JVM's lifetime. + Thread virtual = (Thread) startVirtualThread.invoke(null, task); + 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(virtual, error); + } + + // Give Read-Poller a chance to cycle through epoll_wait a few times while the + // profiler is still running, so a pre-fix build would have emitted TaskBlocks for it. + Thread.sleep(BLOCK_HOLD_MILLIS); + + stopProfiler(); + JfrEvents taskBlocks = verifyEvents("datadog.TaskBlock", false); + assertFalse(TaskBlockAssertions.containsEventThread(taskBlocks, "Read-Poller"), + "JDK-internal Read-Poller thread must not emit TaskBlock events"); + } + @Override protected boolean isPlatformSupported() { return Platform.isLinux(); 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 69ccd8c512..8ffb831992 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 @@ -39,6 +39,16 @@ static boolean containsBlocker(JfrEvents events, long blocker) { return false; } + static int countBlockerEvents(JfrEvents events, long blocker) { + int count = 0; + for (JfrEvent item : events) { + if (item.getLong(BLOCKER, Long.MIN_VALUE) == blocker) { + count++; + } + } + return count; + } + static void assertContains(JfrEvents events, long rootSpanId, long spanId, long blocker, long unblockingSpanId) { for (JfrEvent item : events) {