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 b39dbfc249..a86e1dd8a3 100644 --- a/ddprof-lib/build.gradle.kts +++ b/ddprof-lib/build.gradle.kts @@ -1,3 +1,6 @@ +// Copyright 2026, Datadog, Inc. +// SPDX-License-Identifier: Apache-2.0 + import com.datadoghq.native.model.Platform import com.datadoghq.native.util.PlatformUtils import org.gradle.api.publish.maven.tasks.AbstractPublishToMaven @@ -36,6 +39,8 @@ nativeBuild { gtest { testSourceDir.set(layout.projectDirectory.dir("src/test/cpp")) mainSourceDir.set(layout.projectDirectory.dir("src/main/cpp")) + nativeLibsSourceDir.set(layout.projectDirectory.dir("src/test/resources/native-libs")) + nativeLibsOutputDir.set(rootProject.layout.buildDirectory.dir("test/resources/native-libs")) // Include paths for compilation val javaHome = PlatformUtils.javaHome() diff --git a/ddprof-lib/src/main/cpp/arguments.cpp b/ddprof-lib/src/main/cpp/arguments.cpp index b43f99fccb..5d60a647f2 100644 --- a/ddprof-lib/src/main/cpp/arguments.cpp +++ b/ddprof-lib/src/main/cpp/arguments.cpp @@ -410,6 +410,17 @@ Error Arguments::parse(const char *args) { } } + CASE("wallscope") + if (value == NULL || value[0] == 0) { + msg = "wallscope must be 'context' or 'all'"; + } else if (strcmp(value, "context") == 0) { + _wall_scope = WALL_SCOPE_CONTEXT; + } else if (strcmp(value, "all") == 0) { + _wall_scope = WALL_SCOPE_ALL; + } else { + msg = "wallscope must be 'context' or 'all'"; + } + CASE("nativemem") _nativemem = value == NULL ? 0 : parseUnits(value, BYTES); if (_nativemem < 0) { diff --git a/ddprof-lib/src/main/cpp/arguments.h b/ddprof-lib/src/main/cpp/arguments.h index 16efe9c8ba..b14c334339 100644 --- a/ddprof-lib/src/main/cpp/arguments.h +++ b/ddprof-lib/src/main/cpp/arguments.h @@ -87,6 +87,12 @@ enum WallclockSampler { JVMTI }; +enum WallScope { + WALL_SCOPE_LEGACY, + WALL_SCOPE_CONTEXT, + WALL_SCOPE_ALL +}; + enum Clock { CLK_DEFAULT, CLK_TSC, @@ -161,6 +167,10 @@ class Arguments { } public: + bool wallScopeAllThreads() const { + return _wall_scope == WALL_SCOPE_ALL; + } + Action _action; Ring _ring; const char *_event; @@ -171,6 +181,7 @@ class Arguments { bool _wall_precheck; int _wall_threads_per_tick; WallclockSampler _wallclock_sampler; + WallScope _wall_scope; long _memory; bool _record_allocations; bool _record_liveness; @@ -212,6 +223,7 @@ class Arguments { _wall_precheck(false), _wall_threads_per_tick(DEFAULT_WALL_THREADS_PER_TICK), _wallclock_sampler(ASGCT), + _wall_scope(WALL_SCOPE_CONTEXT), _memory(-1), _record_allocations(false), _record_liveness(false), diff --git a/ddprof-lib/src/main/cpp/codeCache.cpp b/ddprof-lib/src/main/cpp/codeCache.cpp index 5203d5c5d2..30547693bc 100644 --- a/ddprof-lib/src/main/cpp/codeCache.cpp +++ b/ddprof-lib/src/main/cpp/codeCache.cpp @@ -9,6 +9,8 @@ #include "os.h" #include "safeAccess.h" +#include +#include #include #include #include @@ -59,7 +61,9 @@ CodeCache::CodeCache(const char *name, short lib_index, _build_id_len = 0; _load_bias = 0; - memset(_imports, 0, sizeof(_imports)); + memset(_import_offsets, 0, sizeof(_import_offsets)); + _incomplete_imports = 0; + _imports_finalized = true; _imports_patchable = imports_patchable; _dwarf_table = NULL; @@ -96,7 +100,10 @@ void CodeCache::copyFrom(const CodeCache& other) { } _load_bias = other._load_bias; - memset(_imports, 0, sizeof(_imports)); + _imports.clear(); + memset(_import_offsets, 0, sizeof(_import_offsets)); + _incomplete_imports = 0; + _imports_finalized = true; _imports_patchable = other._imports_patchable; _dwarf_table_length = other._dwarf_table_length; @@ -299,29 +306,44 @@ void CodeCache::findSymbolsByPrefix(std::vector &prefixes, } void CodeCache::saveImport(ImportId id, void** entry) { - for (int ty = 0; ty < NUM_IMPORT_TYPES; ty++) { - if (_imports[id][ty] == nullptr) { - _imports[id][ty] = entry; - return; - } + if (entry == nullptr || id < 0 || id >= NUM_IMPORTS) { + return; + } + try { + _imports.push_back({id, entry}); + _imports_finalized = false; + } catch (const std::bad_alloc&) { + _incomplete_imports |= 1ULL << id; } } void CodeCache::addImport(void **entry, const char *name) { switch (name[0]) { case 'a': - if (strcmp(name, "aligned_alloc") == 0) { + if (strcmp(name, "accept") == 0) { + saveImport(im_accept, entry); + } else if (strcmp(name, "accept4") == 0) { + saveImport(im_accept4, entry); + } else if (strcmp(name, "aligned_alloc") == 0) { saveImport(im_aligned_alloc, entry); } break; case 'c': if (strcmp(name, "calloc") == 0) { saveImport(im_calloc, entry); + } else if (strcmp(name, "close") == 0) { + saveImport(im_close, entry); + } else if (strcmp(name, "connect") == 0) { + saveImport(im_connect, entry); } break; case 'd': if (strcmp(name, "dlopen") == 0) { saveImport(im_dlopen, entry); + } else if (strcmp(name, "dup2") == 0) { + saveImport(im_dup2, entry); + } else if (strcmp(name, "dup3") == 0) { + saveImport(im_dup3, entry); } break; case 'f': @@ -329,6 +351,13 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_free, entry); } break; + case 'e': + if (strcmp(name, "epoll_wait") == 0) { + saveImport(im_epoll_wait, entry); + } else if (strcmp(name, "epoll_pwait") == 0) { + saveImport(im_epoll_pwait, entry); + } + break; case 'm': if (strcmp(name, "malloc") == 0) { saveImport(im_malloc, entry); @@ -343,6 +372,10 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_pthread_setspecific, entry); } else if (strcmp(name, "poll") == 0) { saveImport(im_poll, entry); + } else if (strcmp(name, "ppoll") == 0) { + saveImport(im_ppoll, entry); + } else if (strcmp(name, "pselect") == 0) { + saveImport(im_pselect, entry); } else if (strcmp(name, "posix_memalign") == 0) { saveImport(im_posix_memalign, entry); } @@ -352,6 +385,10 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_realloc, entry); } else if (strcmp(name, "recv") == 0) { saveImport(im_recv, entry); + } else if (strcmp(name, "recvfrom") == 0) { + saveImport(im_recvfrom, entry); + } else if (strcmp(name, "recvmsg") == 0) { + saveImport(im_recvmsg, entry); } else if (strcmp(name, "read") == 0) { saveImport(im_read, entry); } @@ -361,6 +398,8 @@ void CodeCache::addImport(void **entry, const char *name) { saveImport(im_send, entry); } else if (strcmp(name, "sigaction") == 0) { saveImport(im_sigaction, entry); + } else if (strcmp(name, "select") == 0) { + saveImport(im_select, entry); } break; case 'w': @@ -371,46 +410,97 @@ void CodeCache::addImport(void **entry, const char *name) { } } -void **CodeCache::findImport(ImportId id) { - if (!_imports_patchable) { - makeImportsPatchable(); - _imports_patchable = true; +void CodeCache::finalizeImports() { + if (_imports_finalized) { + return; } - return _imports[id][PRIMARY]; -} -void CodeCache::patchImport(ImportId id, void *hook_func) { - if (!_imports_patchable) { - makeImportsPatchable(); - _imports_patchable = true; + std::sort(_imports.begin(), _imports.end(), [](const ImportLocation& a, + const ImportLocation& b) { + if (a._id != b._id) { + return a._id < b._id; } + return reinterpret_cast(a._location) < + reinterpret_cast(b._location); + }); + _imports.erase(std::unique(_imports.begin(), _imports.end(), + [](const ImportLocation& a, const ImportLocation& b) { + return a._id == b._id && a._location == b._location; + }), _imports.end()); + + memset(_import_offsets, 0, sizeof(_import_offsets)); + for (const ImportLocation& entry : _imports) { + _import_offsets[entry._id + 1]++; + } + for (int id = 0; id < NUM_IMPORTS; id++) { + _import_offsets[id + 1] += _import_offsets[id]; + } + _imports_finalized = true; +} - for (int ty = 0; ty < NUM_IMPORT_TYPES; ty++) {void **entry = _imports[id][ty]; - if (entry != NULL) { - *entry = hook_func; - }} +size_t CodeCache::importCount(ImportId id) { + if (id < 0 || id >= NUM_IMPORTS) { + return 0; + } + finalizeImports(); + return _import_offsets[id + 1] - _import_offsets[id]; } -void CodeCache::makeImportsPatchable() { - void **min_import = (void **)-1; - void **max_import = NULL; - for (int i = 0; i < NUM_IMPORTS; i++) { - for (int j = 0; j < NUM_IMPORT_TYPES; j++) { - void** entry = _imports[i][j]; - if (entry == NULL) continue; - if (entry < min_import) +bool CodeCache::importsComplete(ImportId id) const { + return id >= 0 && id < NUM_IMPORTS && + (_incomplete_imports & (1ULL << id)) == 0; +} + +bool CodeCache::prepareImportsForPatch() { + if (_imports_patchable) { + return true; + } + return makeImportsPatchable(); +} + +void **CodeCache::findImport(ImportId id, size_t index) { + if (id < 0 || id >= NUM_IMPORTS || index >= importCount(id)) { + return nullptr; + } + if (!prepareImportsForPatch()) { + return nullptr; + } + return _imports[_import_offsets[id] + index]._location; +} + +bool CodeCache::patchImport(ImportId id, void *hook_func) { + if (!prepareImportsForPatch()) { + return false; + } + size_t count = importCount(id); + for (size_t index = 0; index < count; index++) { + *_imports[_import_offsets[id] + index]._location = hook_func; + } + return true; +} + +bool CodeCache::makeImportsPatchable() { + finalizeImports(); + uintptr_t min_import = UINTPTR_MAX; + uintptr_t max_import = 0; + for (const ImportLocation& import : _imports) { + uintptr_t entry = reinterpret_cast(import._location); + if (entry < min_import) min_import = entry; if (entry > max_import) max_import = entry; - } } - if (max_import != NULL) { - uintptr_t patch_start = (uintptr_t)min_import & ~OS::page_mask; - uintptr_t patch_end = (uintptr_t)max_import & ~OS::page_mask; - mprotect((void *)patch_start, patch_end - patch_start + OS::page_size, - PROT_READ | PROT_WRITE); + if (max_import != 0) { + uintptr_t patch_start = min_import & ~OS::page_mask; + uintptr_t patch_end = max_import & ~OS::page_mask; + if (mprotect((void *)patch_start, patch_end - patch_start + OS::page_size, + PROT_READ | PROT_WRITE) != 0) { + return false; + } } + _imports_patchable = true; + return true; } void CodeCache::setDwarfTable(FrameDesc *table, int length, const FrameDesc &default_frame) { @@ -467,4 +557,3 @@ void CodeCache::setBuildId(const char* build_id, size_t build_id_len) { } } } - diff --git a/ddprof-lib/src/main/cpp/codeCache.h b/ddprof-lib/src/main/cpp/codeCache.h index 92b45bf472..452ac60245 100644 --- a/ddprof-lib/src/main/cpp/codeCache.h +++ b/ddprof-lib/src/main/cpp/codeCache.h @@ -13,6 +13,7 @@ #include "utils.h" #include +#include #include #include #include @@ -27,6 +28,8 @@ const int MAX_NATIVE_LIBS = 2048; enum ImportId { im_dlopen, + im_dup2, + im_dup3, im_pthread_create, im_pthread_exit, im_pthread_setspecific, @@ -42,15 +45,20 @@ enum ImportId { im_recv, im_write, im_read, + im_close, + im_connect, + im_accept, + im_accept4, + im_recvfrom, + im_recvmsg, + im_epoll_wait, + im_epoll_pwait, + im_ppoll, + im_select, + im_pselect, NUM_IMPORTS }; -enum ImportType { - PRIMARY, - SECONDARY, - NUM_IMPORT_TYPES -}; - enum Mark { MARK_VM_RUNTIME = 1, MARK_INTERPRETER = 2, @@ -126,6 +134,13 @@ class CodeBlob { class CodeCache { private: + static_assert(NUM_IMPORTS <= 64, "import completeness mask must cover every import"); + + struct ImportLocation { + ImportId _id; + void** _location; + }; + char *_name; short _lib_index; const void *_min_address; @@ -141,7 +156,10 @@ class CodeCache { size_t _build_id_len; // Build-id length in bytes (raw, not hex string length) uintptr_t _load_bias; // Load bias (image_base - file_base address) - void **_imports[NUM_IMPORTS][NUM_IMPORT_TYPES]; + std::vector _imports; + size_t _import_offsets[NUM_IMPORTS + 1]; + u64 _incomplete_imports; + bool _imports_finalized; bool _imports_patchable; bool _debug_symbols; @@ -154,7 +172,8 @@ class CodeCache { CodeBlob *_blobs; void expand(); - void makeImportsPatchable(); + void finalizeImports(); + bool makeImportsPatchable(); void saveImport(ImportId id, void** entry); void copyFrom(const CodeCache& other); @@ -236,8 +255,11 @@ class CodeCache { } void addImport(void **entry, const char *name); - void **findImport(ImportId id); - void patchImport(ImportId, void *hook_func); + size_t importCount(ImportId id); + bool importsComplete(ImportId id) const; + bool prepareImportsForPatch(); + void **findImport(ImportId id, size_t index = 0); + bool patchImport(ImportId, void *hook_func); CodeBlob *findBlob(const char *name); CodeBlob *findBlobByAddress(const void *address); @@ -252,7 +274,8 @@ class CodeCache { FrameDesc findFrameDesc(const void *pc); long long memoryUsage() { - return _capacity * sizeof(CodeBlob *) + _count * sizeof(NativeFunc); + return _capacity * sizeof(CodeBlob *) + _count * sizeof(NativeFunc) + + _imports.capacity() * sizeof(ImportLocation); } int count() { return _count; } diff --git a/ddprof-lib/src/main/cpp/counters.h b/ddprof-lib/src/main/cpp/counters.h index 34b2e908dd..4866cbc7a7 100644 --- a/ddprof-lib/src/main/cpp/counters.h +++ b/ddprof-lib/src/main/cpp/counters.h @@ -57,6 +57,7 @@ X(THREAD_NAMES_COUNT, "thread_names_count") \ X(THREAD_FILTER_PAGES, "thread_filter_pages") \ X(THREAD_FILTER_BYTES, "thread_filter_bytes") \ + X(THREAD_FILTER_CAPACITY_EXHAUSTED, "thread_filter_capacity_exhausted") \ X(JMETHODID_SKIPPED, "jmethodid_skipped_count") \ X(CODECACHE_NATIVE_SIZE_BYTES, "codecache_native_size_bytes") \ X(CODECACHE_NATIVE_COUNT, "native_codecache_count") \ @@ -66,10 +67,16 @@ X(AGCT_NATIVE_NO_JAVA_CONTEXT, "agct_native_no_java_context") \ X(AGCT_BLOCKED_IN_VM, "agct_blocked_in_vm") \ X(SKIPPED_WALLCLOCK_UNWINDS, "skipped_wallclock_unwinds") \ - X(WC_SIGNAL_SUPPRESSED_SAMPLED_RUN, "wc_signals_suppressed_sampled_run") \ + X(WC_SIGNAL_SUPPRESSED_OWNED_BLOCK, "wc_signals_suppressed_owned_block") \ X(WC_UNOWNED_BLOCKED_SUPPRESSED, "wc_unowned_blocked_suppressed") \ X(WC_UNOWNED_BLOCKED_RECORDED, "wc_unowned_blocked_recorded") \ X(WC_SIGNAL_QUEUE_FULL, "wc_signals_queue_full") \ + X(TASK_BLOCK_EMITTED, "task_block_emitted") \ + X(TASK_BLOCK_SKIPPED_TRACE_CONTEXT, "task_block_skipped_trace_context") \ + X(TASK_BLOCK_SKIPPED_TOO_SHORT, "task_block_skipped_too_short") \ + X(TASK_BLOCK_STACK_CAPTURE_FAILED, "task_block_stack_capture_failed") \ + X(TASK_BLOCK_RECORD_FAILED, "task_block_record_failed") \ + X(TASK_BLOCK_DROPPED_ROTATION, "task_block_dropped_rotation") \ X(UNWINDING_TIME_ASYNC, "unwinding_ticks_async") \ X(UNWINDING_TIME_JVMTI, "unwinding_ticks_jvmti") \ X(CALLTRACE_STORAGE_DROPPED, "calltrace_storage_dropped_traces") \ diff --git a/ddprof-lib/src/main/cpp/event.h b/ddprof-lib/src/main/cpp/event.h index 0747f2a4fb..d63b5489cb 100644 --- a/ddprof-lib/src/main/cpp/event.h +++ b/ddprof-lib/src/main/cpp/event.h @@ -57,7 +57,7 @@ class ExecutionEvent : public Event { OSThreadState _thread_state; ExecutionMode _execution_mode; u64 _weight; - u32 _call_trace_id; + u64 _call_trace_id; ExecutionEvent() : Event(), _thread_state(OSThreadState::RUNNABLE), _execution_mode(ExecutionMode::UNKNOWN), @@ -122,13 +122,13 @@ class WallClockEpochEvent { u32 _num_failed_samples; u32 _num_exited_threads; u32 _num_permission_denied; - u64 _num_suppressed_sampled_run; + u64 _num_suppressed_owned_block; WallClockEpochEvent(u64 start_time) : _dirty(false), _start_time(start_time), _duration_millis(0), _num_samplable_threads(0), _num_successful_samples(0), _num_failed_samples(0), _num_exited_threads(0), - _num_permission_denied(0), _num_suppressed_sampled_run(0) {} + _num_permission_denied(0), _num_suppressed_owned_block(0) {} bool hasChanged() { return _dirty; } @@ -167,10 +167,10 @@ class WallClockEpochEvent { } } - void addNumSuppressedSampledRun(u64 n) { + void addNumSuppressedOwnedBlock(u64 n) { if (n > 0) { _dirty = true; - _num_suppressed_sampled_run += n; + _num_suppressed_owned_block += n; } } @@ -181,7 +181,7 @@ class WallClockEpochEvent { void newEpoch(u64 start_time) { _dirty = false; _start_time = start_time; - _num_suppressed_sampled_run = 0; + _num_suppressed_owned_block = 0; } }; @@ -206,4 +206,14 @@ typedef struct QueueTimeEvent { u32 _queueLength; } QueueTimeEvent; +typedef struct TaskBlockEvent { + u64 _start; + u64 _end; + u64 _blocker; + u64 _unblockingSpanId; + Context _ctx; + u64 _callTraceId; + OSThreadState _observedBlockingState; +} TaskBlockEvent; + #endif // _EVENT_H diff --git a/ddprof-lib/src/main/cpp/flightRecorder.cpp b/ddprof-lib/src/main/cpp/flightRecorder.cpp index 86134652cc..a8290e5ad3 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.cpp +++ b/ddprof-lib/src/main/cpp/flightRecorder.cpp @@ -1501,7 +1501,7 @@ void Recording::writeFrameTypes(Buffer *buf) { void Recording::writeThreadStates(Buffer *buf) { buf->putVar64(T_THREAD_STATE); - buf->put8(10); + buf->put8(11); buf->put8(static_cast(OSThreadState::UNKNOWN)); buf->putUtf8("UNKNOWN"); buf->put8(static_cast(OSThreadState::NEW)); @@ -1522,6 +1522,8 @@ void Recording::writeThreadStates(Buffer *buf) { buf->putUtf8("TERMINATED"); buf->put8(static_cast(OSThreadState::SYSCALL)); buf->putUtf8("SYSCALL"); + buf->put8(static_cast(OSThreadState::IO_WAIT)); + buf->putUtf8("IO_WAIT"); flushIfNeeded(buf); } @@ -1873,6 +1875,21 @@ void Recording::recordMethodSample(Buffer *buf, int tid, u64 call_trace_id, flushIfNeeded(buf); } +void Recording::recordTaskBlock(Buffer *buf, int tid, TaskBlockEvent *event) { + int start = buf->skip(1); + buf->putVar64(T_TASK_BLOCK); + buf->putVar64(event->_start); + buf->putVar64(event->_end - event->_start); + buf->putVar64(tid); + buf->putVar64(event->_blocker); + buf->putVar64(event->_unblockingSpanId); + buf->putVar64(event->_callTraceId); + buf->put8(static_cast(event->_observedBlockingState)); + writeContextSnapshot(buf, event->_ctx); + writeEventSizePrefix(buf, start); + flushIfNeeded(buf); +} + void Recording::recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event) { int start = buf->skip(1); buf->putVar64(T_WALLCLOCK_SAMPLE_EPOCH); @@ -1883,7 +1900,7 @@ void Recording::recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event) { buf->putVar64(event->_num_failed_samples); buf->putVar64(event->_num_exited_threads); buf->putVar64(event->_num_permission_denied); - buf->putVar64(event->_num_suppressed_sampled_run); + buf->putVar64(event->_num_suppressed_owned_block); writeEventSizePrefix(buf, start); flushIfNeeded(buf); } @@ -2138,6 +2155,21 @@ void FlightRecorder::recordQueueTime(int lock_index, int tid, } } +bool FlightRecorder::recordTaskBlock(int lock_index, int tid, + TaskBlockEvent *event) { + OptionalSharedLockGuard locker(&_rec_lock); + if (locker.ownsLock()) { + Recording* rec = _rec; + if (rec != nullptr) { + Buffer *buf = rec->buffer(lock_index); + rec->addThread(lock_index, tid); + rec->recordTaskBlock(buf, tid, event); + return true; + } + } + return false; +} + void FlightRecorder::recordDatadogSetting(int lock_index, int length, const char *name, const char *value, const char *unit) { diff --git a/ddprof-lib/src/main/cpp/flightRecorder.h b/ddprof-lib/src/main/cpp/flightRecorder.h index fd5bffda59..070d59ccff 100644 --- a/ddprof-lib/src/main/cpp/flightRecorder.h +++ b/ddprof-lib/src/main/cpp/flightRecorder.h @@ -316,6 +316,7 @@ class Recording { void recordWallClockEpoch(Buffer *buf, WallClockEpochEvent *event); void recordTraceRoot(Buffer *buf, int tid, TraceRootEvent *event); void recordQueueTime(Buffer *buf, int tid, QueueTimeEvent *event); + void recordTaskBlock(Buffer *buf, int tid, TaskBlockEvent *event); void recordAllocation(RecordingBuffer *buf, int tid, u64 call_trace_id, AllocEvent *event); void recordMallocSample(Buffer *buf, int tid, u64 call_trace_id, @@ -424,6 +425,7 @@ class FlightRecorder { void wallClockEpoch(int lock_index, WallClockEpochEvent *event); void recordTraceRoot(int lock_index, int tid, TraceRootEvent *event); void recordQueueTime(int lock_index, int tid, QueueTimeEvent *event); + bool recordTaskBlock(int lock_index, int tid, TaskBlockEvent *event); bool active() const { return _rec != NULL; } diff --git a/ddprof-lib/src/main/cpp/frames.h b/ddprof-lib/src/main/cpp/frames.h index 15549e6e82..3ab52320ca 100644 --- a/ddprof-lib/src/main/cpp/frames.h +++ b/ddprof-lib/src/main/cpp/frames.h @@ -1,9 +1,39 @@ +/* + * Copyright 2026 Datadog, Inc + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ #ifndef _FRAMES_H #define _FRAMES_H #include +#include #include "vmEntry.h" +inline void copyJvmtiFrames(ASGCT_CallFrame *frames, + const jvmtiFrameInfo *jvmti_frames, + jint num_frames) { + // The source and destination commonly refer to the two views of the same + // CallTraceBuffer union. Read both source fields before either write. + for (jint i = 0; i < num_frames; ++i) { + jmethodID method = jvmti_frames[i].method; + jlocation location = jvmti_frames[i].location; + frames[i].method_id = method; + frames[i].bci = static_cast(location); + LP64_ONLY(frames[i].padding = 0;) + } +} + inline int makeFrame(ASGCT_CallFrame *frames, jint type, jmethodID id) { frames[0].bci = type; frames[0].method_id = id; diff --git a/ddprof-lib/src/main/cpp/javaApi.cpp b/ddprof-lib/src/main/cpp/javaApi.cpp index 4711d06de7..8066aacc1b 100644 --- a/ddprof-lib/src/main/cpp/javaApi.cpp +++ b/ddprof-lib/src/main/cpp/javaApi.cpp @@ -26,11 +26,13 @@ #include "engine.h" #include "hotspot/vmStructs.inline.h" #include "incbin.h" +#include "jvmSupport.h" #include "jvmThread.h" #include "os.h" #include "otel_process_ctx.h" #include "profiler.h" #include "threadLocalData.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "vmEntry.h" #include @@ -67,9 +69,10 @@ class JniString { }; extern "C" DLLEXPORT jboolean JNICALL -Java_com_datadoghq_profiler_JavaProfiler_init0(JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_init0( + JNIEnv *env, jclass unused, jboolean delegateMonitorWaitEvents) { // JavaVM* has already been stored when the native library was loaded so we can pass nullptr here - return VM::initProfilerBridge(nullptr, true); + return VM::initProfilerBridge(nullptr, true, delegateMonitorWaitEvents); } extern "C" DLLEXPORT void JNICALL @@ -86,6 +89,12 @@ Java_com_datadoghq_profiler_JavaProfiler_getTid0(JNIEnv *env, jclass unused) { return OS::threadId(); } +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_monitorEventsDelegated0( + JNIEnv *env, jclass unused) { + return VM::monitorEventsDelegated(); +} + extern "C" DLLEXPORT jstring JNICALL Java_com_datadoghq_profiler_JavaProfiler_execute0(JNIEnv *env, jobject unused, jstring command) { @@ -144,7 +153,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } @@ -152,16 +161,13 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadAdd0() { if (unlikely(slot_id == -1)) { // Thread doesn't have a slot ID yet (e.g., main thread), so register it // Happens when we are not enabled before thread start - slot_id = thread_filter->registerThread(); + slot_id = thread_filter->registerThread(tid); current->setFilterSlotId(slot_id); } if (unlikely(slot_id == -1)) { return; // Failed to register thread } - // Reset suppression state so a new thread occupying this slot does not inherit - // stale state from its predecessor. Must happen before add(). - thread_filter->resetSlotRunState(slot_id); thread_filter->add(tid, slot_id); } @@ -174,7 +180,7 @@ JavaCritical_com_datadoghq_profiler_JavaProfiler_filterThreadRemove0() { return; } ThreadFilter *thread_filter = Profiler::instance()->threadFilter(); - if (unlikely(!thread_filter->enabled())) { + if (unlikely(!thread_filter->registryActive())) { return; } @@ -318,40 +324,80 @@ Java_com_datadoghq_profiler_JavaProfiler_recordQueueEnd0( } extern "C" DLLEXPORT void JNICALL -Java_com_datadoghq_profiler_JavaProfiler_parkEnter0(JNIEnv *env, jclass unused) { +Java_com_datadoghq_profiler_JavaProfiler_parkEnter0( + JNIEnv *env, jclass unused, jthread thread) { + if (!JVMSupport::isPlatformThread(env, thread)) { + return; + } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return; } - bool first_park = current->parkEnter(); - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (first_park && tf->enabled()) { - ThreadFilter::SlotID slot_id = current->filterSlotId(); - if (slot_id >= 0) { - current->setParkBlockToken( - tf->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT)); - } + Context context = ContextApi::snapshot(); + if (!current->parkEnter(TSC::ticks(), context)) { + return; + } + + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0 && tf->allThreads()) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (context.spanId == 0 && slot_id >= 0 && + (profiler->taskBlockEnabled() || tf->enabled())) { + current->setParkBlockToken(tf->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT, BlockRunOwner::JAVA)); } } extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_parkExit0( - JNIEnv *env, jclass unused, jlong blocker, jlong unblockingSpanId) { + JNIEnv *env, jclass unused, jthread thread, jlong blocker, + jlong unblockingSpanId) { + if (!JVMSupport::isPlatformThread(env, thread)) { + return; + } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return; } + u64 start_ticks = 0; u64 park_block_token = 0; - if (!current->parkExit(park_block_token) || park_block_token == 0) { + Context context{}; + if (!current->parkExit(start_ticks, context, park_block_token) || + park_block_token == 0) { return; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { - ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); - if (current->filterSlotId() == slot_id) { - tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(park_block_token)); - } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(park_block_token); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot{}; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(park_block_token), &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + if (recording_enabled && exited && snapshot.context_eligible) { + recordTaskBlockIfEligible( + current->tid(), thread, 1, start_ticks, TSC::ticks(), context, + static_cast(blocker), static_cast(unblockingSpanId), + snapshot.active_state, true); + } else if (recording_enabled && exited && !snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); } + profiler->leaveTaskBlockActivity(); } static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { @@ -365,45 +411,146 @@ static bool decodeJavaBlockState(jint state, OSThreadState &decoded) { extern "C" DLLEXPORT jlong JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockEnter0( - JNIEnv *env, jclass unused, jint state) { + JNIEnv *env, jclass unused, jthread thread, jint state) { OSThreadState decoded; - if (!decodeJavaBlockState(state, decoded)) { + if (!decodeJavaBlockState(state, decoded) || + !JVMSupport::isPlatformThread(env, thread)) { return 0; } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return 0; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (!tf->enabled()) { + if (ContextApi::snapshot().spanId != 0) { return 0; } - ThreadFilter::SlotID slot_id = current->filterSlotId(); - if (slot_id < 0) { + Profiler *profiler = Profiler::instance(); + ThreadFilter *tf = profiler->threadFilter(); + if (!profiler->taskBlockEnabled() && !tf->enabled()) { return 0; } + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0 && tf->allThreads()) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (slot_id < 0) return 0; return static_cast(tf->enterBlockedRun(slot_id, decoded)); } extern "C" DLLEXPORT void JNICALL Java_com_datadoghq_profiler_JavaProfiler_blockExit0( - JNIEnv *env, jclass unused, jlong token) { + JNIEnv *env, jclass unused, jthread thread, jlong token) { u64 block_token = static_cast(token); - if (block_token == 0) { + if (block_token == 0 || !JVMSupport::isPlatformThread(env, thread)) { return; } ProfiledThread *current = ProfiledThread::current(); if (current == nullptr) { return; } + ThreadFilter *tf = Profiler::instance()->threadFilter(); ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(block_token); - if (current->filterSlotId() != slot_id) { + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0 && tf->allThreads()) { + current_slot = tf->slotIdByTid(current->tid()); + if (current_slot >= 0) current->setFilterSlotId(current_slot); + } + if (current_slot != slot_id) { return; } - ThreadFilter *tf = Profiler::instance()->threadFilter(); - if (tf->enabled()) { - tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(block_token)); + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(block_token)); +} + +extern "C" DLLEXPORT jlong JNICALL +Java_com_datadoghq_profiler_JavaProfiler_beginTaskBlock0( + JNIEnv *env, jclass unused, jthread thread, jint state) { + OSThreadState decoded; + if (!decodeJavaBlockState(state, decoded) || + !JVMSupport::isPlatformThread(env, thread)) { + return 0; } + ProfiledThread *current = ProfiledThread::current(); + Profiler *profiler = Profiler::instance(); + if (current == nullptr || !profiler->isRunning() || + !profiler->taskBlockEnabled()) { + return 0; + } + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (!tf->allThreads() || slot_id < 0) return 0; + + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + return 0; + } + u64 token = tf->enterBlockedRun(slot_id, decoded, BlockRunOwner::JAVA); + if (!current->taskBlockEnter(token, TSC::ticks(), context)) { + if (token != 0) { + tf->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token)); + } + return 0; + } + return static_cast(token); +} + +extern "C" DLLEXPORT jboolean JNICALL +Java_com_datadoghq_profiler_JavaProfiler_endTaskBlock0( + JNIEnv *env, jclass unused, jthread thread, jlong token, jlong blocker, + jlong unblockingSpanId) { + u64 block_token = static_cast(token); + ThreadFilter::SlotID slot_id = -1; + u64 generation = 0; + if (!ThreadFilter::decodeBlockRunToken(block_token, slot_id, generation) || + !JVMSupport::isPlatformThread(env, thread)) { + return JNI_FALSE; + } + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return JNI_FALSE; + + u64 start_ticks = 0; + Context context{}; + if (!current->taskBlockExit(block_token, start_ticks, context)) { + return JNI_FALSE; + } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun(slot_id, generation, &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return JNI_FALSE; + } + if (!recording_enabled || !exited) { + profiler->leaveTaskBlockActivity(); + return JNI_FALSE; + } + if (!snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + profiler->leaveTaskBlockActivity(); + return JNI_FALSE; + } + + bool recorded = recordTaskBlockIfEligible( + current->tid(), thread, 1, start_ticks, TSC::ticks(), context, + static_cast(blocker), static_cast(unblockingSpanId), + snapshot.active_state, true); + profiler->leaveTaskBlockActivity(); + return recorded ? JNI_TRUE : JNI_FALSE; } extern "C" DLLEXPORT jlong JNICALL diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.cpp b/ddprof-lib/src/main/cpp/jfrMetadata.cpp index f0f425ef77..4e48d7eacb 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.cpp +++ b/ddprof-lib/src/main/cpp/jfrMetadata.cpp @@ -156,8 +156,8 @@ void JfrMetadata::initialize( "Number of Exited Threads Before Handling Signal") << field("numPermissionDenied", T_INT, "Number of Permission Denied Errors") - << field("numSuppressedSampledRun", T_LONG, - "Signals suppressed by the wall-clock once-per-run filter")) + << field("numSuppressedOwnedBlock", T_LONG, + "Signals suppressed for lifecycle-owned blocked intervals")) << (type("datadog.ObjectSample", T_ALLOC, "Allocation sample") << category("Datadog", "Profiling") @@ -209,6 +209,20 @@ void JfrMetadata::initialize( << field("localRootSpanId", T_LONG, "Local Root Span ID") || contextAttributes) + << (type("datadog.TaskBlock", T_TASK_BLOCK, "Task Block") + << category("Datadog") + << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) + << field("duration", T_LONG, "Duration", F_DURATION_TICKS) + << field("eventThread", T_THREAD, "Event Thread", F_CPOOL) + << field("blocker", T_LONG, "Blocker Identity Hash") + << field("unblockingSpanId", T_LONG, "Unblocking Span ID") + << field("stackTrace", T_STACK_TRACE, "Stack Trace", F_CPOOL) + << field("observedBlockingState", T_THREAD_STATE, + "Observed Blocking State", F_CPOOL) + << field("spanId", T_LONG, "Span ID") + << field("localRootSpanId", T_LONG, "Local Root Span ID") || + contextAttributes) + << (type("datadog.HeapUsage", T_HEAP_USAGE, "JVM Heap Usage") << category("Datadog") << field("startTime", T_LONG, "Start Time", F_TIME_TICKS) diff --git a/ddprof-lib/src/main/cpp/jfrMetadata.h b/ddprof-lib/src/main/cpp/jfrMetadata.h index ac241a7a84..f5102ef705 100644 --- a/ddprof-lib/src/main/cpp/jfrMetadata.h +++ b/ddprof-lib/src/main/cpp/jfrMetadata.h @@ -81,6 +81,7 @@ enum JfrType { T_UNWIND_FAILURE = 126, T_MALLOC = 127, T_NATIVE_SOCKET = 128, + T_TASK_BLOCK = 129, T_ANNOTATION = 200, T_LABEL = 201, T_CATEGORY = 202, diff --git a/ddprof-lib/src/main/cpp/jvmSupport.cpp b/ddprof-lib/src/main/cpp/jvmSupport.cpp index cff98c33b5..7ef95debde 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.cpp +++ b/ddprof-lib/src/main/cpp/jvmSupport.cpp @@ -16,10 +16,33 @@ #include +using JniFunction = void (JNICALL*)(); +using IsVirtualThreadFunction = jboolean (JNICALL*)(JNIEnv*, jobject); + +static constexpr jint JNI_VERSION_21_VALUE = 0x00150000; +static constexpr int IS_VIRTUAL_THREAD_INDEX = 234; + +static_assert(sizeof(JniFunction) == sizeof(void*), + "JNI function table entries must be pointer-sized"); volatile JVMSupport::JMethodIDLoadStats JVMSupport::jmethodID_load_state = JVMSupport::No_loaded; Mutex JVMSupport::_initialization_lock; +bool JVMSupport::isPlatformThread(JNIEnv* jni, jthread thread) { + if (jni == nullptr || thread == nullptr) return false; + jint jni_version = jni->GetVersion(); + if (jni_version <= 0) return false; + if (jni_version < JNI_VERSION_21_VALUE) return true; + + const JniFunction* functions = + reinterpret_cast(jni->functions); + IsVirtualThreadFunction is_virtual_thread = + reinterpret_cast( + functions[IS_VIRTUAL_THREAD_INDEX]); + return is_virtual_thread != nullptr && + is_virtual_thread(jni, thread) == JNI_FALSE; +} + bool JVMSupport::initialize() { MutexLocker locker(_initialization_lock); diff --git a/ddprof-lib/src/main/cpp/jvmSupport.h b/ddprof-lib/src/main/cpp/jvmSupport.h index 5ba33ae5ae..f21fb4e3f8 100644 --- a/ddprof-lib/src/main/cpp/jvmSupport.h +++ b/ddprof-lib/src/main/cpp/jvmSupport.h @@ -44,6 +44,10 @@ class JVMSupport { static bool isInitialized(); public: + // Java-owned profiler state is carrier-local and may only be used by platform threads. + // IsVirtualThread was added to the JNI function table in JDK 21. + static bool isPlatformThread(JNIEnv* jni, jthread thread); + // Initialize JVM support - check JVM related resources are available. // Return false if any critical resource is not available, which should // result in disabling profiling. diff --git a/ddprof-lib/src/main/cpp/libraryPatcher.h b/ddprof-lib/src/main/cpp/libraryPatcher.h index 70be3659b7..7bccb65d73 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher.h +++ b/ddprof-lib/src/main/cpp/libraryPatcher.h @@ -1,21 +1,43 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + #ifndef _LIBRARYPATCHER_H #define _LIBRARYPATCHER_H #include "codeCache.h" #include "spinLock.h" + #include +#include #ifdef __linux__ -// Patch libraries' @plt entries +// Patch libraries' imported function relocation slots. typedef struct _patchEntry { CodeCache* _lib; - // library's @plt location + // Library import location. void** _location; // original function void* _func; } PatchEntry; +// Native I/O patching only needs the slot and its exact pre-patch value. +// Each retained import location is tracked independently for restoration. +typedef struct _socketPatchEntry { + void** _location; + void* _func; +} SocketPatchEntry; + +typedef struct _socketPatchedLibrary { + const void* _image_base; + void* _unload_protection; + size_t _first_patch; + size_t _patch_count; +} SocketPatchedLibrary; + +const int SOCKET_BASE_TABLE_SIZE = MAX_NATIVE_LIBS * 2; class LibraryPatcher { private: @@ -29,36 +51,48 @@ class LibraryPatcher { static PatchEntry _sigaction_entries[MAX_NATIVE_LIBS]; static int _sigaction_size; - // Separate tracking for socket (send/recv/write/read) patches. - // Each library can contribute up to 4 GOT slots (send/recv/write/read). - static PatchEntry _socket_entries[4 * MAX_NATIVE_LIBS]; - static int _socket_size; + // Separate tracking for native I/O patches. + // Each library can contribute any number of retained import slots per I/O hook. + static std::vector _socket_entries; + static std::vector _socket_libraries; + static const void* _socket_bases[SOCKET_BASE_TABLE_SIZE]; static void patch_library_unlocked(CodeCache* lib); static void patch_pthread_create(); static void patch_pthread_setspecific(); static void patch_sigaction_in_library(CodeCache* lib); + static bool socket_library_patched_unlocked(const void* image_base); + static void remember_socket_library_unlocked(const void* image_base); + static void unpatch_socket_functions_unlocked( + std::vector& libraries_to_release); + static void release_socket_libraries( + std::vector& libraries); public: - // True while socket hooks are installed; read by Profiler::dlopen_hook + // True while native I/O hooks are installed; read by library refresh paths // to decide whether to re-patch after a new library is loaded. // Set to true after the first batch of libraries is patched in patch_socket_functions(). - // Libraries loaded after profiler start are picked up on the next dlopen_hook call, + // Libraries loaded after profiler start are picked up on the next refresh, // which calls install_socket_hooks() to patch them if _socket_active is true. - // Low-probability race: stop() is called only on JVM exit; atomic is zero-cost insurance. + // start()/stop() and the library refresher can observe this state from different + // threads, so keep the flag atomic even though stop normally happens at JVM shutdown. static std::atomic _socket_active; static void initialize(); static void patch_libraries(); static void unpatch_libraries(); static void patch_sigaction(); - static bool patch_socket_functions(); + static bool patch_socket_functions(bool require_active = false); static void unpatch_socket_functions(); - // Called from Profiler::dlopen_hook after a new library is loaded. - // No-op when socket hooks are not active. - static inline void install_socket_hooks() { - if (_socket_active.load(std::memory_order_acquire)) { - patch_socket_functions(); - } - } + static bool unpatch_socket_functions_if_inactive(); +#ifdef UNIT_TEST + static int patch_socket_import_for_test(CodeCache* lib, ImportId import_id, + void* hook, const char* name, + bool retain_library = false); + static int socket_patch_count_for_test(); + static int socket_library_count_for_test(); +#endif + // Called after a new library is loaded and the library list is refreshed. + // No-op when native I/O hooks are not active. + static void install_socket_hooks(); }; #else @@ -69,8 +103,14 @@ class LibraryPatcher { static void patch_libraries() { } static void unpatch_libraries() { } static void patch_sigaction() { } - static bool patch_socket_functions() { return false; } + static bool patch_socket_functions(bool require_active = false) { + (void)require_active; + return false; + } static void unpatch_socket_functions() { } + static bool unpatch_socket_functions_if_inactive() { + return false; + } static void install_socket_hooks() { } }; diff --git a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp index 0b14bd1449..8a14cf3ae7 100644 --- a/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp +++ b/ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp @@ -9,16 +9,21 @@ #include "counters.h" #include "guards.h" #include "jvmThread.h" +#include "nativeSocketInterposer.h" #include "nativeSocketSampler.h" #include "profiler.h" +#include "symbols.h" +#include #include #include +#include #include #include #include #include #include +#include typedef void* (*func_start_routine)(void*); @@ -28,10 +33,40 @@ PatchEntry LibraryPatcher::_patched_entries[MAX_NATIVE_LIBS]; int LibraryPatcher::_size = 0; PatchEntry LibraryPatcher::_sigaction_entries[MAX_NATIVE_LIBS]; int LibraryPatcher::_sigaction_size = 0; -PatchEntry LibraryPatcher::_socket_entries[4 * MAX_NATIVE_LIBS]; -int LibraryPatcher::_socket_size = 0; +std::vector LibraryPatcher::_socket_entries; +std::vector LibraryPatcher::_socket_libraries; +const void* LibraryPatcher::_socket_bases[SOCKET_BASE_TABLE_SIZE] = {}; std::atomic LibraryPatcher::_socket_active{false}; +static_assert(SOCKET_BASE_TABLE_SIZE > 0 && + (SOCKET_BASE_TABLE_SIZE & (SOCKET_BASE_TABLE_SIZE - 1)) == 0, + "socket DSO lookup table size must be a power of two"); + +bool LibraryPatcher::socket_library_patched_unlocked(const void* image_base) { + size_t slot = (reinterpret_cast(image_base) >> 12) & + (SOCKET_BASE_TABLE_SIZE - 1); + for (int probe = 0; probe < SOCKET_BASE_TABLE_SIZE; probe++) { + const void* value = _socket_bases[slot]; + if (value == nullptr) { + return false; + } + if (value == image_base) { + return true; + } + slot = (slot + 1) & (SOCKET_BASE_TABLE_SIZE - 1); + } + return false; +} + +void LibraryPatcher::remember_socket_library_unlocked(const void* image_base) { + size_t slot = (reinterpret_cast(image_base) >> 12) & + (SOCKET_BASE_TABLE_SIZE - 1); + while (_socket_bases[slot] != nullptr && _socket_bases[slot] != image_base) { + slot = (slot + 1) & (SOCKET_BASE_TABLE_SIZE - 1); + } + _socket_bases[slot] = image_base; +} + void LibraryPatcher::initialize() { if (_profiler_name == nullptr) { Dl_info info; @@ -535,7 +570,40 @@ void LibraryPatcher::patch_sigaction() { } } -bool LibraryPatcher::patch_socket_functions() { +class SocketPatchCandidate { +public: + CodeCache* _lib; + UnloadProtection _protection; + size_t _patch_count; + + SocketPatchCandidate(CodeCache* lib, UnloadProtection&& protection, + size_t patch_count) + : _lib(lib), _protection(std::move(protection)), + _patch_count(patch_count) {} + + SocketPatchCandidate(const SocketPatchCandidate&) = delete; + SocketPatchCandidate& operator=(const SocketPatchCandidate&) = delete; + SocketPatchCandidate(SocketPatchCandidate&&) noexcept = default; + SocketPatchCandidate& operator=(SocketPatchCandidate&&) noexcept = default; +}; + +static bool mappingMatches(const CodeCache* lib) { + if (lib->imageBase() == nullptr) { + return false; + } + Dl_info info; + return dladdr(lib->imageBase(), &info) != 0 && + info.dli_fbase == lib->imageBase(); +} + +bool LibraryPatcher::patch_socket_functions(bool require_active) { + auto disable_and_unpatch = []() { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + LibraryPatcher::unpatch_socket_functions(); + return false; + }; + // Resolve the real libc symbols ONCE at first call and cache them. On a // restart cycle (stop()→start()) we MUST NOT re-resolve via RTLD_NEXT: if // any GOT slot in another DSO was missed during unpatch (e.g. its CodeCache @@ -549,167 +617,329 @@ bool LibraryPatcher::patch_socket_functions() { // May resolve to an LD_PRELOAD interposer (e.g. libasan) — intentional. // On musl, RTLD_NEXT returns NULL when libc is loaded before this DSO in the // link map; fall back to RTLD_DEFAULT which finds symbols globally. - // The four statics and the `cached` flag are written once and then - // read-only. They live outside the ExclusiveLockGuard intentionally (dlsym - // must not be called while holding _lock because dlsym may acquire the + // The cached originals and the `has_cached_original` flag are written once + // and then read-only. They live outside the ExclusiveLockGuard intentionally + // (dlsym must not be called while holding _lock because dlsym may acquire the // linker lock, which is also acquired during dlopen — inverting the order // would deadlock). Guard the one-time init with a dedicated once_flag so // that concurrent callers serialise on the dlsym block rather than racing // to write the statics. - static NativeSocketSampler::send_fn cached_send = nullptr; - static NativeSocketSampler::recv_fn cached_recv = nullptr; - static NativeSocketSampler::write_fn cached_write = nullptr; - static NativeSocketSampler::read_fn cached_read = nullptr; + static void* cached_originals[NativeSocketInterposer::NUM_NATIVE_IO_HOOKS] = {}; + static bool has_cached_original = false; static std::once_flag dlsym_once; + + const NativeSocketInterposer::NativeIoHookSpec* hooks = + NativeSocketInterposer::hookSpecs(); + std::call_once(dlsym_once, [&]() { - cached_send = (NativeSocketSampler::send_fn) dlsym(RTLD_NEXT, "send"); - if (!cached_send) cached_send = (NativeSocketSampler::send_fn) dlsym(RTLD_DEFAULT, "send"); - cached_recv = (NativeSocketSampler::recv_fn) dlsym(RTLD_NEXT, "recv"); - if (!cached_recv) cached_recv = (NativeSocketSampler::recv_fn) dlsym(RTLD_DEFAULT, "recv"); - cached_write = (NativeSocketSampler::write_fn) dlsym(RTLD_NEXT, "write"); - if (!cached_write) cached_write = (NativeSocketSampler::write_fn) dlsym(RTLD_DEFAULT, "write"); - cached_read = (NativeSocketSampler::read_fn) dlsym(RTLD_NEXT, "read"); - if (!cached_read) cached_read = (NativeSocketSampler::read_fn) dlsym(RTLD_DEFAULT, "read"); - // If dlsym resolves to one of our own hooks the linker is already serving - // the patched copy. Null the pointers so the early-return below fires. - if (cached_send == &NativeSocketSampler::send_hook || - cached_recv == &NativeSocketSampler::recv_hook || - cached_write == &NativeSocketSampler::write_hook || - cached_read == &NativeSocketSampler::read_hook) { - TEST_LOG("patch_socket_functions dlsym returned hook address; refusing to self-reference"); - cached_send = nullptr; cached_recv = nullptr; - cached_write = nullptr; cached_read = nullptr; + for (int hook_index = 0; hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + void* original = dlsym(RTLD_NEXT, hooks[hook_index].name); + if (original == nullptr) { + original = dlsym(RTLD_DEFAULT, hooks[hook_index].name); + } + if (original == hooks[hook_index].hook) { + TEST_LOG("patch_socket_functions dlsym returned hook address for %s", + hooks[hook_index].name); + // If dlsym resolves to one of our own hooks the linker is already serving + // the patched copy. Null this pointer so the hook is not installed. + original = nullptr; + } + cached_originals[hook_index] = original; + has_cached_original |= original != nullptr; + if (original != nullptr) { + NativeSocketInterposer::setOriginalFunction(hook_index, original); + } } + NativeSocketSampler::setOriginalFunctions( + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_SEND]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_RECV]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_WRITE]), + reinterpret_cast( + cached_originals[NativeSocketInterposer::HOOK_READ])); }); - auto pre_send = cached_send; - auto pre_recv = cached_recv; - auto pre_write = cached_write; - auto pre_read = cached_read; - TEST_LOG("patch_socket_functions dlsym send=%p recv=%p write=%p read=%p", - (void*)pre_send, (void*)pre_recv, (void*)pre_write, (void*)pre_read); - if (!pre_send || !pre_recv || !pre_write || !pre_read) { - TEST_LOG("patch_socket_functions EARLY RETURN: at least one dlsym returned NULL"); - return false; + + if (!has_cached_original) { + Log::warn("native I/O hooks disabled: all original symbol lookups failed"); + return disable_and_unpatch(); } const CodeCacheArray& native_libs = Libraries::instance()->native_libs(); int num_of_libs = native_libs.count(); - // Pre-resolve all library paths before acquiring the lock: realpath() may - // block on I/O and must not be called while holding _lock. - // We only need the is-self flag per library, so avoid a huge stack allocation. - static_assert(MAX_NATIVE_LIBS > 0, "MAX_NATIVE_LIBS must be positive"); - bool is_self[MAX_NATIVE_LIBS]; - int capped = (num_of_libs <= MAX_NATIVE_LIBS) ? num_of_libs : MAX_NATIVE_LIBS; + int capped = num_of_libs <= MAX_NATIVE_LIBS ? num_of_libs : MAX_NATIVE_LIBS; + std::vector candidates; + try { + candidates.reserve(capped); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to allocate DSO candidate table"); + return disable_and_unpatch(); + } + for (int index = 0; index < capped; index++) { CodeCache* lib = native_libs.at(index); - is_self[index] = false; - if (lib == nullptr || lib->name() == nullptr) continue; + if (lib == nullptr || lib->name() == nullptr) { + continue; + } char path[PATH_MAX]; - char* rp = realpath(lib->name(), path); - is_self[index] = (rp != nullptr && strcmp(rp, _profiler_name) == 0); - } + char* resolved_path = realpath(lib->name(), path); + if (_profiler_name != nullptr && resolved_path != nullptr && + strcmp(resolved_path, _profiler_name) == 0) { + continue; + } - ExclusiveLockGuard locker(&_lock); - // Re-check under the lock only on re-entry (when hooks are already installed): - // a concurrent unpatch_socket_functions() may have cleared _socket_active - // between the acquire-load in install_socket_hooks() and this lock acquisition. - // The initial call from NativeSocketSampler::start() always has _socket_size == 0 - // and must proceed regardless of _socket_active. - if (_socket_size > 0 && !_socket_active.load(std::memory_order_relaxed)) { - return false; - } - // Only assign orig pointers on the first call (no hooks installed yet). - // On re-entry via dlopen, RTLD_NEXT would resolve to the hook itself. - if (_socket_size == 0) { - NativeSocketSampler::setOriginalFunctions(pre_send, pre_recv, pre_write, pre_read); - } - // TODO: hook table (name + hook fn) should be owned by NativeSocketSampler; - // LibraryPatcher should iterate an externally-provided table rather than - // hardcoding the four socket hooks here. - auto try_patch_slot = [&](void** location, void* hook_fn, const char* fn_name, CodeCache* lib) { - if (location == nullptr) return; - for (int i = 0; i < _socket_size; i++) { - if (_socket_entries[i]._location == location) return; + size_t patch_count = 0; + for (int hook_index = 0; hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + ImportId import_id = hooks[hook_index].import_id; + size_t count = lib->importCount(import_id); + if (count == 0) { + continue; + } + if (!lib->importsComplete(import_id)) { + Log::warn("native I/O hooks disabled: incomplete %s imports in %s", + hooks[hook_index].name, lib->name()); + return disable_and_unpatch(); + } + if (cached_originals[hook_index] == nullptr) { + Log::warn("native I/O hooks disabled: no original for imported %s in %s", + hooks[hook_index].name, lib->name()); + return disable_and_unpatch(); + } + patch_count += count; } - if (_socket_size < 4 * MAX_NATIVE_LIBS) { - void* orig = (void*)__atomic_load_n(location, __ATOMIC_ACQUIRE); - _socket_entries[_socket_size]._lib = lib; - _socket_entries[_socket_size]._location = location; - _socket_entries[_socket_size]._func = orig; - __atomic_store_n(location, hook_fn, __ATOMIC_RELEASE); - _socket_size++; - } else { - Log::warn("socket patch table full (%d slots), skipping %s in %s", 4 * MAX_NATIVE_LIBS, fn_name, lib ? lib->name() : "?"); + if (patch_count == 0 || !mappingMatches(lib)) { + continue; } - }; - for (int index = 0; index < capped; index++) { - CodeCache* lib = native_libs.at(index); - if (lib == nullptr) continue; - if (lib->name() == nullptr) continue; - if (is_self[index]) { - continue; + UnloadProtection protection(lib); + if (!protection.isValid()) { + if (!mappingMatches(lib)) { + continue; + } + Log::warn("native I/O hooks disabled: cannot retain mapped DSO %s", + lib->name()); + return disable_and_unpatch(); + } + try { + candidates.emplace_back(lib, std::move(protection), + patch_count); + } catch (const std::bad_alloc&) { + Log::warn("native I/O hooks disabled: unable to retain DSO candidate %s", + lib->name()); + return disable_and_unpatch(); + } + } + + std::vector libraries_to_release; + bool success = true; + { + ExclusiveLockGuard locker(&_lock); + if (require_active && + !_socket_active.load(std::memory_order_relaxed)) { + return false; + } + + 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++; + } } - 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); + 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; + } - if (send_location == nullptr && recv_location == nullptr - && write_location == nullptr && read_location == nullptr) continue; + 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; + } + } + } - TEST_LOG("patch_socket_functions PATCH %s send=%p recv=%p write=%p read=%p", - lib->name(), (void*)send_location, (void*)recv_location, - (void*)write_location, (void*)read_location); + if (success) { + for (SocketPatchCandidate& candidate : candidates) { + CodeCache* lib = candidate._lib; + if (socket_library_patched_unlocked(lib->imageBase())) { + continue; + } + size_t first_patch = _socket_entries.size(); + for (int hook_index = 0; + hook_index < NativeSocketInterposer::NUM_NATIVE_IO_HOOKS; + hook_index++) { + ImportId import_id = hooks[hook_index].import_id; + size_t count = lib->importCount(import_id); + for (size_t import_index = 0; import_index < count; import_index++) { + void** location = lib->findImport(import_id, import_index); + void* original = + reinterpret_cast(__atomic_load_n(location, __ATOMIC_ACQUIRE)); + _socket_entries.push_back({location, original}); + __atomic_store_n(location, hooks[hook_index].hook, __ATOMIC_RELEASE); + } + } + _socket_libraries.push_back( + {lib->imageBase(), candidate._protection.release(), first_patch, + _socket_entries.size() - first_patch}); + remember_socket_library_unlocked(lib->imageBase()); + } + _socket_active.store(true, std::memory_order_release); + } else { + unpatch_socket_functions_unlocked(libraries_to_release); + } + } - // 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); + release_socket_libraries(libraries_to_release); + if (!success) { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + return false; } - TEST_LOG("patch_socket_functions DONE total_slots=%d num_libs_scanned=%d", - _socket_size, capped); - _socket_active.store(true, std::memory_order_release); + TEST_LOG("patch_socket_functions DONE total_slots=%zu num_libs_scanned=%d", + _socket_entries.size(), capped); return true; } -void LibraryPatcher::unpatch_socket_functions() { +#ifdef UNIT_TEST +int LibraryPatcher::patch_socket_import_for_test(CodeCache* lib, ImportId import_id, + void* hook, const char* name, + bool retain_library) { + (void)name; + UnloadProtection protection(lib); + if (retain_library && + (!mappingMatches(lib) || !protection.isValid())) { + return -1; + } ExclusiveLockGuard locker(&_lock); + if (!lib->importsComplete(import_id) || !lib->prepareImportsForPatch()) { + return -1; + } + size_t count = lib->importCount(import_id); + size_t initial_size = _socket_entries.size(); + try { + _socket_entries.reserve(initial_size + count); + if (retain_library) { + _socket_libraries.reserve(_socket_libraries.size() + 1); + } + } catch (const std::bad_alloc&) { + return -1; + } + for (size_t index = 0; index < count; index++) { + void** location = lib->findImport(import_id, index); + bool already_patched = std::any_of( + _socket_entries.begin(), _socket_entries.end(), + [location](const SocketPatchEntry& entry) { + return entry._location == location; + }); + if (already_patched) { + continue; + } + void* original = + reinterpret_cast(__atomic_load_n(location, __ATOMIC_ACQUIRE)); + _socket_entries.push_back({location, original}); + __atomic_store_n(location, hook, __ATOMIC_RELEASE); + } + size_t patched = _socket_entries.size() - initial_size; + if (retain_library && patched != 0) { + _socket_libraries.push_back( + {lib->imageBase(), protection.release(), initial_size, patched}); + remember_socket_library_unlocked(lib->imageBase()); + } + return static_cast(patched); +} + +int LibraryPatcher::socket_patch_count_for_test() { + ExclusiveLockGuard locker(&_lock); + return static_cast(_socket_entries.size()); +} + +int LibraryPatcher::socket_library_count_for_test() { + ExclusiveLockGuard locker(&_lock); + return static_cast(_socket_libraries.size()); +} +#endif + +void LibraryPatcher::unpatch_socket_functions_unlocked( + std::vector& libraries_to_release) { // Clear _socket_active FIRST so that any concurrent install_socket_hooks() // thread that already passed the acquire-load on _socket_active (before we // acquired the lock) will see false when it checks again after acquiring the // lock — preventing it from re-patching slots we are about to restore. // Hooks that already entered the hook body before this store are benign: they // hold no lock and will complete normally using the still-valid orig pointers. - // - // ASSUMPTION (dlclose UAF): we write through _socket_entries[i]._location - // without checking that the owning library is still mapped. If a patched - // DSO were actually unmapped between patch and unpatch, this store would - // corrupt freed memory or SEGV. In practice this is benign because (a) the - // host JVM does not dlclose libc-importing DSOs, (b) glibc's dlclose - // refcounts and only unmaps when the final reference is dropped, and - // (c) the same risk is already accepted by unpatch_libraries() and - // unpatch_socket_functions has the same trust model. If a host that - // routinely unmaps libc-importing libraries is ever supported, gate each - // store on a /proc/self/maps lookup or hold a dlopen handle on each lib - // for the patch lifetime. _socket_active.store(false, std::memory_order_release); - TEST_LOG("unpatch_socket_functions restoring %d slot(s)", _socket_size); - for (int index = 0; index < _socket_size; index++) { - __atomic_store_n(_socket_entries[index]._location, _socket_entries[index]._func, __ATOMIC_RELEASE); + TEST_LOG("unpatch_socket_functions restoring %zu slot(s)", _socket_entries.size()); + for (const SocketPatchEntry& entry : _socket_entries) { + __atomic_store_n(entry._location, entry._func, + __ATOMIC_RELEASE); } - _socket_size = 0; - // _orig_send/_orig_recv/_orig_write/_orig_read are intentionally NOT nulled. + _socket_entries.clear(); + memset(_socket_bases, 0, sizeof(_socket_bases)); + _socket_libraries.swap(libraries_to_release); + // Original function pointers are intentionally NOT nulled. // In-flight hook invocations that entered before PLT entries were restored // above may still be executing and will dereference these pointers. // They remain valid (pointing to the real libc functions) until the next // patch_socket_functions() call. } +void LibraryPatcher::release_socket_libraries( + std::vector& libraries) { + for (const SocketPatchedLibrary& library : libraries) { + if (library._unload_protection != nullptr) { + dlclose(library._unload_protection); + } + } + libraries.clear(); +} + +void LibraryPatcher::unpatch_socket_functions() { + std::vector libraries_to_release; + { + ExclusiveLockGuard locker(&_lock); + unpatch_socket_functions_unlocked(libraries_to_release); + } + release_socket_libraries(libraries_to_release); +} + +bool LibraryPatcher::unpatch_socket_functions_if_inactive() { + std::vector libraries_to_release; + { + ExclusiveLockGuard locker(&_lock); + if (NativeSocketInterposer::instance()->active() || NativeSocketSampler::active()) { + return false; + } + if (!_socket_active.load(std::memory_order_relaxed) && + _socket_entries.empty()) { + return false; + } + unpatch_socket_functions_unlocked(libraries_to_release); + } + release_socket_libraries(libraries_to_release); + return true; +} + +void LibraryPatcher::install_socket_hooks() { + if (_socket_active.load(std::memory_order_acquire) && + !patch_socket_functions(true)) { + NativeSocketInterposer::instance()->disableAfterPatchFailure(); + NativeSocketSampler::disableAfterPatchFailure(); + } +} + #endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeBlock.cpp b/ddprof-lib/src/main/cpp/nativeBlock.cpp new file mode 100644 index 0000000000..962d839bdf --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.cpp @@ -0,0 +1,140 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeBlock.h" + +#if defined(__linux__) + +#include "context_api.h" +#include "profiler.h" +#include "taskBlockRecorder.h" +#include "threadLocalData.h" +#include "tsc.h" + +#include +#include + +#ifdef UNIT_TEST +static std::atomic _native_block_observer{nullptr}; + +void NativeBlockScope::setHookObserverForTest(HookObserver observer) { + _native_block_observer.store(observer, std::memory_order_release); +} + +static void observeNativeBlockPhase(const char* phase, NativeBlockKind kind, int blocker_id) { + NativeBlockScope::HookObserver observer = + _native_block_observer.load(std::memory_order_acquire); + if (observer != nullptr) { + observer(phase, kind, blocker_id); + } +} +#endif + +NativeBlockScope::NativeBlockScope(NativeBlockKind kind, int blocker_id, + OSThreadState state) + : _blocker(blocker(kind, blocker_id)), _state(state) { + int saved_errno = errno; +#ifdef UNIT_TEST + observeNativeBlockPhase("enter", kind, blocker_id); +#endif + + Profiler* profiler = Profiler::instance(); + if (!profiler->taskBlockEnabled()) { + errno = saved_errno; + return; + } + + ThreadFilter* thread_filter = profiler->threadFilter(); + if (!thread_filter->registryActive()) { + errno = saved_errno; + return; + } + + ProfiledThread* current = ProfiledThread::currentSignalSafe(); + if (current == nullptr || current->threadType() != ProfiledThread::TYPE_JAVA_THREAD) { + errno = saved_errno; + return; + } + + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + errno = saved_errno; + return; + } + + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + errno = saved_errno; + return; + } + + u64 token = thread_filter->enterBlockedRun(slot_id, state, BlockRunOwner::NATIVE); + if (token == 0) { + errno = saved_errno; + return; + } + + _active = true; + _tid = current->tid(); + _slot_id = slot_id; + _generation = ThreadFilter::tokenGeneration(token); + _start_ticks = TSC::ticks(); + _context = context; + errno = saved_errno; +} + +NativeBlockScope::~NativeBlockScope() { +#ifdef UNIT_TEST + observeNativeBlockPhase("exit", static_cast(_blocker >> 32), + static_cast(_blocker & 0xffffffff)); +#endif + if (!_active) { + return; + } + int saved_errno = errno; + finish(TSC::ticks()); + errno = saved_errno; +} + +void NativeBlockScope::finish(u64 end_ticks) { + if (!_active) { + return; + } + _active = false; + + Profiler* profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) { + profiler->waitForTaskBlockRotation(); + } + ThreadFilter* thread_filter = profiler->threadFilter(); + BlockRunSnapshot snapshot{}; + snapshot.active_state = _state; + snapshot.owner = BlockRunOwner::NATIVE; + if (!thread_filter->registryActive() || + !thread_filter->snapshotAndExitBlockedRun(_slot_id, _generation, &snapshot)) { + if (activity) { + profiler->leaveTaskBlockActivity(); + } else { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + } + return; + } + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + + if (recording_enabled) { + recordTaskBlockIfEligible(_tid, nullptr, 0, _start_ticks, end_ticks, + _context, _blocker, 0, + snapshot.active_state, true); + } + profiler->leaveTaskBlockActivity(); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeBlock.h b/ddprof-lib/src/main/cpp/nativeBlock.h new file mode 100644 index 0000000000..565bbe0365 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeBlock.h @@ -0,0 +1,66 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_BLOCK_H +#define _NATIVE_BLOCK_H + +#include "arch.h" + +#if defined(__linux__) + +#include "context.h" +#include "threadFilter.h" +#include "threadState.h" + +enum class NativeBlockKind : u32 { + STREAM_SOCKET = 1, + CONNECT = 2, + ACCEPT = 3, + UDP_RECEIVE = 4, + POLL = 5, + SELECT = 6, + EPOLL_WAIT = 7, +}; + +// Describes physical blocking by the current OS thread. When called by a virtual +// thread in JNI, the recorded thread is the pinned carrier, not the logical thread. +class NativeBlockScope { +public: + NativeBlockScope(NativeBlockKind kind, int blocker_id, + OSThreadState state = OSThreadState::IO_WAIT); + ~NativeBlockScope(); + + NativeBlockScope(const NativeBlockScope&) = delete; + NativeBlockScope& operator=(const NativeBlockScope&) = delete; + + bool active() const { return _active; } + + static u64 blocker(NativeBlockKind kind, int blocker_id) { + return (static_cast(kind) << 32) | static_cast(blocker_id); + } + +#ifdef UNIT_TEST + using HookObserver = void (*)(const char* phase, NativeBlockKind kind, int blocker_id); + static void setHookObserverForTest(HookObserver observer); + u64 startTicksForTest() const { return _start_ticks; } + void finishForTest(u64 end_ticks) { finish(end_ticks); } +#endif + +private: + bool _active = false; + int _tid = -1; + ThreadFilter::SlotID _slot_id = -1; + u64 _generation = 0; + u64 _start_ticks = 0; + u64 _blocker = 0; + OSThreadState _state = OSThreadState::UNKNOWN; + Context _context = {}; + + void finish(u64 end_ticks); +}; + +#endif // __linux__ + +#endif // _NATIVE_BLOCK_H diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp new file mode 100644 index 0000000000..b6549629f1 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.cpp @@ -0,0 +1,197 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeFdClassifier.h" + +#if defined(__linux__) + +#include +#include +#include + +#ifdef UNIT_TEST +std::atomic NativeFdClassifier::_probe_override{nullptr}; +std::atomic NativeFdClassifier::_probe_count{0}; +#endif + +NativeFdClassifier::NativeFdClassifier() { + for (int index = 0; index < FD_TYPE_CACHE_SIZE; index++) { + _fd_type_cache[index].store(0, std::memory_order_relaxed); + } + for (int index = 0; index < HIGH_FD_TYPE_CACHE_SIZE; index++) { + _high_fd_type_cache[index].store(0, std::memory_order_relaxed); + } +} + +#ifdef UNIT_TEST +void NativeFdClassifier::setProbeOverrideForTest(ProbeOverride probe) { + _probe_override.store(probe, std::memory_order_release); +} + +uint64_t NativeFdClassifier::probeCountForTest() { + return _probe_count.load(std::memory_order_acquire); +} + +void NativeFdClassifier::resetProbeCountForTest() { + _probe_count.store(0, std::memory_order_release); +} +#endif + +uint8_t NativeFdClassifier::probeFdType(int fd) { +#ifdef UNIT_TEST + _probe_count.fetch_add(1, std::memory_order_relaxed); +#endif + int so_type; + socklen_t solen = sizeof(so_type); + int rc; +#ifdef UNIT_TEST + ProbeOverride probe = _probe_override.load(std::memory_order_acquire); + int probe_errno = 0; + if (probe != nullptr) { + rc = probe(fd, &so_type, &probe_errno); + if (rc != 0) { + errno = probe_errno; + } + } else +#endif + { + rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); + } + if (rc == 0) { + if (so_type == SOCK_STREAM) { + return FD_TYPE_STREAM_SOCKET; + } + if (so_type == SOCK_DGRAM) { + return FD_TYPE_DATAGRAM_SOCKET; + } + return FD_TYPE_OTHER_SOCKET; + } + return errno == ENOTSOCK ? FD_TYPE_NON_SOCKET : 0; +} + +void NativeFdClassifier::cacheFdType(int fd, uint8_t type, uint32_t gen) { + if (fd < 0 || type == 0) { + return; + } + if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { + _fd_type_cache[fd].store((gen << FD_TYPE_GEN_SHIFT) | type, + std::memory_order_release); + } else { + _high_fd_type_cache[highFdCacheIndex(fd)].store(highFdEntry(fd, gen, type), + std::memory_order_release); + } +} + +uint64_t NativeFdClassifier::highFdEntry(int fd, uint32_t gen, uint8_t type) { + return (static_cast(static_cast(fd)) << 32) + | (static_cast(gen & FD_TYPE_GEN_MASK) << FD_TYPE_GEN_SHIFT) + | static_cast(type); +} + +bool NativeFdClassifier::highFdEntryMatches(uint64_t entry, int fd, uint32_t gen) { + return highFdEntryMatchesFd(entry, fd) + && (((entry >> FD_TYPE_GEN_SHIFT) & FD_TYPE_GEN_MASK) + == (gen & FD_TYPE_GEN_MASK)); +} + +bool NativeFdClassifier::highFdEntryMatchesFd(uint64_t entry, int fd) { + return static_cast(entry >> 32) == static_cast(fd); +} + +int NativeFdClassifier::highFdCacheIndex(int fd) { + return static_cast(static_cast(fd) % + static_cast(HIGH_FD_TYPE_CACHE_SIZE)); +} + +uint8_t NativeFdClassifier::highFdType(int fd, uint32_t gen) { + int index = highFdCacheIndex(fd); + uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); + if (highFdEntryMatches(cached, fd, gen)) { + uint8_t type = static_cast(cached & FD_TYPE_MASK); + if (type != 0) { + return type; + } + } + + uint8_t type = probeFdType(fd); + // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: + // the same fd number may later be reused for a socket. + if (type != 0) { + _high_fd_type_cache[index].store(highFdEntry(fd, gen, type), + std::memory_order_release); + } + return type; +} + +uint8_t NativeFdClassifier::fdType(int fd) { + if (fd < 0) { + return 0; + } + + uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); + if (static_cast(fd) >= static_cast(FD_TYPE_CACHE_SIZE)) { + return highFdType(fd, gen); + } + + uint32_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); + if ((cached >> FD_TYPE_GEN_SHIFT) == gen) { + uint8_t type = static_cast(cached & FD_TYPE_MASK); + if (type != 0) { + return type; + } + } + + uint8_t type = probeFdType(fd); + // probeFdType() returns 0 for transient errors such as EBADF. Do not cache those: + // the same fd number may later be reused for a socket. + if (type != 0) { + cacheFdType(fd, type, gen); + } + return type; +} + +bool NativeFdClassifier::isStreamSocket(int fd) { + return fdType(fd) == FD_TYPE_STREAM_SOCKET; +} + +bool NativeFdClassifier::isDatagramSocket(int fd) { + return fdType(fd) == FD_TYPE_DATAGRAM_SOCKET; +} + +void NativeFdClassifier::cacheNonSocket(int fd) { + if (fd < 0) { + return; + } + uint32_t gen = _fd_cache_gen.load(std::memory_order_acquire); + cacheFdType(fd, FD_TYPE_NON_SOCKET, gen); +} + +void NativeFdClassifier::clearHighFdType(int fd) { + int index = highFdCacheIndex(fd); + uint64_t cached = _high_fd_type_cache[index].load(std::memory_order_acquire); + while (highFdEntryMatchesFd(cached, fd)) { + if (_high_fd_type_cache[index].compare_exchange_weak( + cached, 0, std::memory_order_acq_rel, std::memory_order_acquire)) { + return; + } + } +} + +void NativeFdClassifier::clearFdType(int fd) { + if (fd < 0) { + return; + } + if (static_cast(fd) < static_cast(FD_TYPE_CACHE_SIZE)) { + _fd_type_cache[fd].store(0, std::memory_order_release); + } else { + clearHighFdType(fd); + } +} + +void NativeFdClassifier::clearFdTypeCache() { + _fd_cache_gen.fetch_add(1, std::memory_order_release); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/main/cpp/nativeFdClassifier.h b/ddprof-lib/src/main/cpp/nativeFdClassifier.h new file mode 100644 index 0000000000..7523da76ea --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeFdClassifier.h @@ -0,0 +1,64 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_FD_CLASSIFIER_H +#define _NATIVE_FD_CLASSIFIER_H + +#include +#include + +#if defined(__linux__) + +class NativeFdClassifier { +public: + NativeFdClassifier(); + + bool isStreamSocket(int fd); + bool isDatagramSocket(int fd); + void cacheNonSocket(int fd); + void clearFdType(int fd); + void clearFdTypeCache(); + +#ifdef UNIT_TEST + using ProbeOverride = int (*)(int fd, int *so_type, int *probe_errno); + static void setProbeOverrideForTest(ProbeOverride probe); + static uint64_t probeCountForTest(); + static void resetProbeCountForTest(); +#endif + +private: + static const int FD_TYPE_CACHE_SIZE = 65536; + static const int HIGH_FD_TYPE_CACHE_SIZE = 4096; + static const uint32_t FD_TYPE_MASK = 0xf; + static const uint32_t FD_TYPE_GEN_SHIFT = 4; + static const uint32_t FD_TYPE_GEN_MASK = 0x0fffffff; + static const uint8_t FD_TYPE_STREAM_SOCKET = 1; + static const uint8_t FD_TYPE_DATAGRAM_SOCKET = 2; + static const uint8_t FD_TYPE_OTHER_SOCKET = 3; + static const uint8_t FD_TYPE_NON_SOCKET = 4; + + std::atomic _fd_cache_gen{1}; + std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; + std::atomic _high_fd_type_cache[HIGH_FD_TYPE_CACHE_SIZE]; + + static uint8_t probeFdType(int fd); + static uint64_t highFdEntry(int fd, uint32_t gen, uint8_t type); + static bool highFdEntryMatches(uint64_t entry, int fd, uint32_t gen); + static bool highFdEntryMatchesFd(uint64_t entry, int fd); + static int highFdCacheIndex(int fd); + void cacheFdType(int fd, uint8_t type, uint32_t gen); + uint8_t highFdType(int fd, uint32_t gen); + void clearHighFdType(int fd); + uint8_t fdType(int fd); + +#ifdef UNIT_TEST + static std::atomic _probe_override; + static std::atomic _probe_count; +#endif +}; + +#endif // __linux__ + +#endif // _NATIVE_FD_CLASSIFIER_H diff --git a/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp b/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp new file mode 100644 index 0000000000..341dcdc31d --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.cpp @@ -0,0 +1,477 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include "nativeSocketInterposer.h" + +#if defined(__linux__) + +#include "libraryPatcher.h" +#include "nativeSocketSampler.h" +#include "tsc.h" + +#include +#include +#include + +static inline bool nonZeroTimeval(const struct timeval* timeout) { + return timeout == nullptr || timeout->tv_sec != 0 || timeout->tv_usec != 0; +} + +static inline bool nonZeroTimespec(const struct timespec* timeout) { + return timeout == nullptr || timeout->tv_sec != 0 || timeout->tv_nsec != 0; +} + +class ErrnoGuard { +public: + ErrnoGuard() : _saved(errno) {} + ~ErrnoGuard() { errno = _saved; } + +private: + int _saved; +}; + +template +static inline Ret runNativeIoHook(bool eligible, NativeBlockKind kind, int fd, + Fn fn, Call call) { + if (fn == nullptr) { + errno = ENOSYS; + return static_cast(-1); + } + if (!NativeSocketInterposer::instance()->active() || !eligible) { + return call(fn); + } + + NativeBlockScope block(kind, fd); + Ret ret = call(fn); + return ret; +} + +template +static inline ssize_t runStreamSocketHook(int fd, Fn fn, u8 op, Call call) { + if (fn == nullptr) { + errno = ENOSYS; + return -1; + } + + // read/write are intentionally routed through the socket classifier because + // socket I/O often uses generic fd APIs. Non-socket fds are cached after the + // first stable ENOTSOCK probe; high or transiently failing fds may be probed + // again on later calls. + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + if (!eligible) { + return call(fn); + } + + u64 t0 = TSC::ticks(); + ssize_t ret; + { + NativeBlockScope block(NativeBlockKind::STREAM_SOCKET, fd); + ret = call(fn); + } + u64 t1 = TSC::ticks(); + if (NativeSocketSampler::active()) { + ErrnoGuard errno_guard; + return NativeSocketSampler::recordHookResult(fd, ret, t0, t1, op); + } + return ret; +} + +template +static inline Ret runDatagramSocketHook(int fd, Fn fn, Call call) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isDatagramSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::UDP_RECEIVE, fd, fn, + call); +} + +NativeSocketInterposer* const NativeSocketInterposer::_instance = new NativeSocketInterposer(); +std::atomic NativeSocketInterposer::_orig_send{nullptr}; +std::atomic NativeSocketInterposer::_orig_recv{nullptr}; +std::atomic NativeSocketInterposer::_orig_write{nullptr}; +std::atomic NativeSocketInterposer::_orig_read{nullptr}; +std::atomic NativeSocketInterposer::_orig_close{nullptr}; +std::atomic NativeSocketInterposer::_orig_dup2{nullptr}; +std::atomic NativeSocketInterposer::_orig_dup3{nullptr}; +std::atomic NativeSocketInterposer::_orig_connect{nullptr}; +std::atomic NativeSocketInterposer::_orig_accept{nullptr}; +std::atomic NativeSocketInterposer::_orig_accept4{nullptr}; +std::atomic NativeSocketInterposer::_orig_recvfrom{nullptr}; +std::atomic NativeSocketInterposer::_orig_recvmsg{nullptr}; +std::atomic NativeSocketInterposer::_orig_epoll_wait{nullptr}; +std::atomic NativeSocketInterposer::_orig_epoll_pwait{nullptr}; +std::atomic NativeSocketInterposer::_orig_poll{nullptr}; +std::atomic NativeSocketInterposer::_orig_ppoll{nullptr}; +std::atomic NativeSocketInterposer::_orig_select{nullptr}; +std::atomic NativeSocketInterposer::_orig_pselect{nullptr}; + +static_assert(std::atomic::is_always_lock_free, + "native I/O hook function pointers must be lock-free"); + +const NativeSocketInterposer::NativeIoHookSpec* NativeSocketInterposer::hookSpecs() { + static const NativeIoHookSpec specs[NUM_NATIVE_IO_HOOKS] = { + {im_send, "send", reinterpret_cast(send_hook)}, + {im_recv, "recv", reinterpret_cast(recv_hook)}, + {im_write, "write", reinterpret_cast(write_hook)}, + {im_read, "read", reinterpret_cast(read_hook)}, + {im_close, "close", reinterpret_cast(close_hook)}, + {im_dup2, "dup2", reinterpret_cast(dup2_hook)}, + {im_dup3, "dup3", reinterpret_cast(dup3_hook)}, + {im_connect, "connect", reinterpret_cast(connect_hook)}, + {im_accept, "accept", reinterpret_cast(accept_hook)}, + {im_accept4, "accept4", reinterpret_cast(accept4_hook)}, + {im_recvfrom, "recvfrom", reinterpret_cast(recvfrom_hook)}, + {im_recvmsg, "recvmsg", reinterpret_cast(recvmsg_hook)}, + {im_epoll_wait, "epoll_wait", reinterpret_cast(epoll_wait_hook)}, + {im_epoll_pwait, "epoll_pwait", reinterpret_cast(epoll_pwait_hook)}, + {im_poll, "poll", reinterpret_cast(poll_hook)}, + {im_ppoll, "ppoll", reinterpret_cast(ppoll_hook)}, + {im_select, "select", reinterpret_cast(select_hook)}, + {im_pselect, "pselect", reinterpret_cast(pselect_hook)}, + }; + return specs; +} + +bool NativeSocketInterposer::setOriginalFunction(int hook_index, void* original) { + switch (hook_index) { + case HOOK_SEND: + _orig_send.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECV: + _orig_recv.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_WRITE: + _orig_write.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_READ: + _orig_read.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_CLOSE: + _orig_close.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_DUP2: + _orig_dup2.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_DUP3: + _orig_dup3.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_CONNECT: + _orig_connect.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_ACCEPT: + _orig_accept.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_ACCEPT4: + _orig_accept4.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECVFROM: + _orig_recvfrom.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_RECVMSG: + _orig_recvmsg.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_EPOLL_WAIT: + _orig_epoll_wait.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_EPOLL_PWAIT: + _orig_epoll_pwait.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_POLL: + _orig_poll.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_PPOLL: + _orig_ppoll.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_SELECT: + _orig_select.store(reinterpret_cast(original), + std::memory_order_release); + return true; + case HOOK_PSELECT: + _orig_pselect.store(reinterpret_cast(original), + std::memory_order_release); + return true; + default: + return false; + } +} + +bool NativeSocketInterposer::isStreamSocket(int fd) { + return _fd_classifier.isStreamSocket(fd); +} + +bool NativeSocketInterposer::isDatagramSocket(int fd) { + return _fd_classifier.isDatagramSocket(fd); +} + +void NativeSocketInterposer::clearFdType(int fd) { + _fd_classifier.clearFdType(fd); +} + +void NativeSocketInterposer::clearFdTypeCache() { + _fd_classifier.clearFdTypeCache(); +} + +Error NativeSocketInterposer::start() { + clearFdTypeCache(); + _active.store(true, std::memory_order_release); + if (!LibraryPatcher::patch_socket_functions()) { + _active.store(false, std::memory_order_release); + return Error("failed to install native I/O hooks"); + } + return Error::OK; +} + +void NativeSocketInterposer::stop() { + _active.store(false, std::memory_order_release); + LibraryPatcher::unpatch_socket_functions_if_inactive(); + clearFdTypeCache(); +} + +void NativeSocketInterposer::disableAfterPatchFailure() { + _active.store(false, std::memory_order_release); + clearFdTypeCache(); +} + +ssize_t NativeSocketInterposer::send_hook(int fd, const void* buf, size_t len, + int flags) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::send_hook(fd, buf, len, flags); + } + return runStreamSocketHook(fd, _orig_send.load(std::memory_order_acquire), 0, + [&](send_fn fn) { return fn(fd, buf, len, flags); }); +} + +ssize_t NativeSocketInterposer::recv_hook(int fd, void* buf, size_t len, + int flags) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::recv_hook(fd, buf, len, flags); + } + return runStreamSocketHook(fd, _orig_recv.load(std::memory_order_acquire), 1, + [&](recv_fn fn) { return fn(fd, buf, len, flags); }); +} + +ssize_t NativeSocketInterposer::write_hook(int fd, const void* buf, size_t len) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::write_hook(fd, buf, len); + } + return runStreamSocketHook(fd, _orig_write.load(std::memory_order_acquire), 2, + [&](write_fn fn) { return fn(fd, buf, len); }); +} + +ssize_t NativeSocketInterposer::read_hook(int fd, void* buf, size_t len) { + if (!NativeSocketInterposer::instance()->active() && NativeSocketSampler::active()) { + return NativeSocketSampler::read_hook(fd, buf, len); + } + return runStreamSocketHook(fd, _orig_read.load(std::memory_order_acquire), 3, + [&](read_fn fn) { return fn(fd, buf, len); }); +} + +int NativeSocketInterposer::close_hook(int fd) { + int ret; + close_fn original = _orig_close.load(std::memory_order_acquire); + if (original == nullptr) { + ret = static_cast(syscall(SYS_close, fd)); + } else { + ret = original(fd); + } + { + ErrnoGuard errno_guard; + NativeSocketInterposer::instance()->clearFdType(fd); + NativeSocketSampler::instance()->clearFdCacheEntry(fd); + } + return ret; +} + +int NativeSocketInterposer::dup2_hook(int oldfd, int newfd) { + int ret; + dup2_fn original = _orig_dup2.load(std::memory_order_acquire); + if (original == nullptr) { +#ifdef SYS_dup2 + ret = static_cast(syscall(SYS_dup2, oldfd, newfd)); +#else + errno = ENOSYS; + ret = -1; +#endif + } else { + ret = original(oldfd, newfd); + } + { + ErrnoGuard errno_guard; + if (ret >= 0) { + // dup2() implicitly closes newfd before reusing it, so clear stale fd + // classification and address state for the target descriptor. + NativeSocketInterposer::instance()->clearFdType(newfd); + NativeSocketSampler::instance()->clearFdCacheEntry(newfd); + } + } + return ret; +} + +int NativeSocketInterposer::dup3_hook(int oldfd, int newfd, int flags) { + int ret; + dup3_fn original = _orig_dup3.load(std::memory_order_acquire); + if (original == nullptr) { +#ifdef SYS_dup3 + ret = static_cast(syscall(SYS_dup3, oldfd, newfd, flags)); +#else + errno = ENOSYS; + ret = -1; +#endif + } else { + ret = original(oldfd, newfd, flags); + } + { + ErrnoGuard errno_guard; + if (ret >= 0) { + // dup3() implicitly closes newfd before reusing it, so clear stale fd + // classification and address state for the target descriptor. + NativeSocketInterposer::instance()->clearFdType(newfd); + NativeSocketSampler::instance()->clearFdCacheEntry(newfd); + } + } + return ret; +} + +int NativeSocketInterposer::connect_hook(int fd, const struct sockaddr* addr, + socklen_t addrlen) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::CONNECT, fd, + _orig_connect.load(std::memory_order_acquire), + [&](connect_fn fn) { return fn(fd, addr, addrlen); }); +} + +int NativeSocketInterposer::accept_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, + _orig_accept.load(std::memory_order_acquire), + [&](accept_fn fn) { return fn(fd, addr, addrlen); }); +} + +int NativeSocketInterposer::accept4_hook(int fd, struct sockaddr* addr, + socklen_t* addrlen, int flags) { + bool eligible; + { + ErrnoGuard errno_guard; + eligible = NativeSocketInterposer::instance()->isStreamSocket(fd); + } + return runNativeIoHook(eligible, NativeBlockKind::ACCEPT, fd, + _orig_accept4.load(std::memory_order_acquire), + [&](accept4_fn fn) { return fn(fd, addr, addrlen, flags); }); +} + +ssize_t NativeSocketInterposer::recvfrom_hook(int fd, void* buf, size_t len, + int flags, struct sockaddr* src_addr, + socklen_t* addrlen) { + return runDatagramSocketHook( + fd, _orig_recvfrom.load(std::memory_order_acquire), [&](recvfrom_fn fn) { + return fn(fd, buf, len, flags, src_addr, addrlen); + }); +} + +ssize_t NativeSocketInterposer::recvmsg_hook(int fd, struct msghdr* msg, int flags) { + return runDatagramSocketHook( + fd, _orig_recvmsg.load(std::memory_order_acquire), [&](recvmsg_fn fn) { + return fn(fd, msg, flags); + }); +} + +int NativeSocketInterposer::epoll_wait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout) { + bool eligible = maxevents > 0 && timeout != 0; + return runNativeIoHook( + eligible, NativeBlockKind::EPOLL_WAIT, epfd, + _orig_epoll_wait.load(std::memory_order_acquire), + [&](epoll_wait_fn fn) { + return fn(epfd, events, maxevents, timeout); + }); +} + +int NativeSocketInterposer::epoll_pwait_hook(int epfd, struct epoll_event* events, + int maxevents, int timeout, + const sigset_t* sigmask) { + bool eligible = maxevents > 0 && timeout != 0; + return runNativeIoHook( + eligible, NativeBlockKind::EPOLL_WAIT, epfd, + _orig_epoll_pwait.load(std::memory_order_acquire), + [&](epoll_pwait_fn fn) { + return fn(epfd, events, maxevents, timeout, sigmask); + }); +} + +int NativeSocketInterposer::poll_hook(struct pollfd* fds, nfds_t nfds, int timeout) { + bool eligible = fds != nullptr && nfds > 0 && timeout != 0; + return runNativeIoHook(eligible, NativeBlockKind::POLL, 0, + _orig_poll.load(std::memory_order_acquire), + [&](poll_fn fn) { return fn(fds, nfds, timeout); }); +} + +int NativeSocketInterposer::ppoll_hook(struct pollfd* fds, nfds_t nfds, + const struct timespec* timeout_ts, + const sigset_t* sigmask) { + bool eligible = fds != nullptr && nfds > 0 && nonZeroTimespec(timeout_ts); + return runNativeIoHook(eligible, NativeBlockKind::POLL, 0, + _orig_ppoll.load(std::memory_order_acquire), + [&](ppoll_fn fn) { return fn(fds, nfds, timeout_ts, sigmask); }); +} + +int NativeSocketInterposer::select_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, struct timeval* timeout) { + bool eligible = nfds > 0 && nonZeroTimeval(timeout); + return runNativeIoHook(eligible, NativeBlockKind::SELECT, 0, + _orig_select.load(std::memory_order_acquire), + [&](select_fn fn) { + return fn(nfds, readfds, writefds, exceptfds, timeout); + }); +} + +int NativeSocketInterposer::pselect_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, + const struct timespec* timeout_ts, + const sigset_t* sigmask) { + bool eligible = nfds > 0 && nonZeroTimespec(timeout_ts); + return runNativeIoHook(eligible, NativeBlockKind::SELECT, 0, + _orig_pselect.load(std::memory_order_acquire), + [&](pselect_fn fn) { + return fn(nfds, readfds, writefds, exceptfds, + timeout_ts, sigmask); + }); +} + +#else + +NativeSocketInterposer* const NativeSocketInterposer::_instance = new NativeSocketInterposer(); + +#endif diff --git a/ddprof-lib/src/main/cpp/nativeSocketInterposer.h b/ddprof-lib/src/main/cpp/nativeSocketInterposer.h new file mode 100644 index 0000000000..23f03830d7 --- /dev/null +++ b/ddprof-lib/src/main/cpp/nativeSocketInterposer.h @@ -0,0 +1,186 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#ifndef _NATIVE_SOCKET_INTERPOSER_H +#define _NATIVE_SOCKET_INTERPOSER_H + +#include "arguments.h" + +#include +#include + +#if defined(__linux__) + +#include "codeCache.h" +#include "nativeBlock.h" +#include "nativeFdClassifier.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +class NativeSocketInterposer { +public: + typedef ssize_t (*send_fn)(int, const void*, size_t, int); + typedef ssize_t (*recv_fn)(int, void*, size_t, int); + typedef ssize_t (*write_fn)(int, const void*, size_t); + typedef ssize_t (*read_fn)(int, void*, size_t); + typedef int (*close_fn)(int); + typedef int (*dup2_fn)(int, int); + typedef int (*dup3_fn)(int, int, int); + typedef int (*connect_fn)(int, const struct sockaddr*, socklen_t); + typedef int (*accept_fn)(int, struct sockaddr*, socklen_t*); + typedef int (*accept4_fn)(int, struct sockaddr*, socklen_t*, int); + typedef ssize_t (*recvfrom_fn)(int, void*, size_t, int, struct sockaddr*, socklen_t*); + typedef ssize_t (*recvmsg_fn)(int, struct msghdr*, int); + typedef int (*epoll_wait_fn)(int, struct epoll_event*, int, int); + typedef int (*epoll_pwait_fn)(int, struct epoll_event*, int, int, const sigset_t*); + typedef int (*poll_fn)(struct pollfd*, nfds_t, int); + typedef int (*ppoll_fn)(struct pollfd*, nfds_t, const struct timespec*, const sigset_t*); + typedef int (*select_fn)(int, fd_set*, fd_set*, fd_set*, struct timeval*); + typedef int (*pselect_fn)(int, fd_set*, fd_set*, fd_set*, const struct timespec*, + const sigset_t*); + + enum NativeIoHookIndex : int { + HOOK_SEND = 0, + HOOK_RECV, + HOOK_WRITE, + HOOK_READ, + HOOK_CLOSE, + HOOK_DUP2, + HOOK_DUP3, + HOOK_CONNECT, + HOOK_ACCEPT, + HOOK_ACCEPT4, + HOOK_RECVFROM, + HOOK_RECVMSG, + HOOK_EPOLL_WAIT, + HOOK_EPOLL_PWAIT, + HOOK_POLL, + HOOK_PPOLL, + HOOK_SELECT, + HOOK_PSELECT, + NUM_NATIVE_IO_HOOKS + }; + + struct NativeIoHookSpec { + ImportId import_id; + const char* name; + void* hook; + }; + + static NativeSocketInterposer* instance() { return _instance; } + + Error start(); + void stop(); + void disableAfterPatchFailure(); + bool active() const { return _active.load(std::memory_order_acquire); } + +#ifdef UNIT_TEST + bool setActiveForTest(bool active) { + return _active.exchange(active, std::memory_order_acq_rel); + } +#endif + + bool isStreamSocket(int fd); + bool isDatagramSocket(int fd); + void clearFdType(int fd); + void clearFdTypeCache(); + + static const NativeIoHookSpec* hookSpecs(); + static bool setOriginalFunction(int hook_index, void* original); + + static ssize_t send_hook(int fd, const void* buf, size_t len, int flags); + static ssize_t recv_hook(int fd, void* buf, size_t len, int flags); + static ssize_t write_hook(int fd, const void* buf, size_t len); + static ssize_t read_hook(int fd, void* buf, size_t len); + static int close_hook(int fd); + static int dup2_hook(int oldfd, int newfd); + static int dup3_hook(int oldfd, int newfd, int flags); + static int connect_hook(int fd, const struct sockaddr* addr, socklen_t addrlen); + static int accept_hook(int fd, struct sockaddr* addr, socklen_t* addrlen); + static int accept4_hook(int fd, struct sockaddr* addr, socklen_t* addrlen, int flags); + static ssize_t recvfrom_hook(int fd, void* buf, size_t len, int flags, + struct sockaddr* src_addr, socklen_t* addrlen); + static ssize_t recvmsg_hook(int fd, struct msghdr* msg, int flags); + static int epoll_wait_hook(int epfd, struct epoll_event* events, int maxevents, + int timeout); + static int epoll_pwait_hook(int epfd, struct epoll_event* events, int maxevents, + int timeout, const sigset_t* sigmask); + static int poll_hook(struct pollfd* fds, nfds_t nfds, int timeout); + static int ppoll_hook(struct pollfd* fds, nfds_t nfds, + const struct timespec* timeout_ts, const sigset_t* sigmask); + static int select_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, struct timeval* timeout); + static int pselect_hook(int nfds, fd_set* readfds, fd_set* writefds, + fd_set* exceptfds, const struct timespec* timeout_ts, + const sigset_t* sigmask); + + static void setOriginalFunctions(send_fn s, recv_fn r, write_fn w, read_fn rd) { + _orig_send.store(s, std::memory_order_release); + _orig_recv.store(r, std::memory_order_release); + _orig_write.store(w, std::memory_order_release); + _orig_read.store(rd, std::memory_order_release); + } + + static void getOriginalFunctions(send_fn& s, recv_fn& r, write_fn& w, read_fn& rd) { + s = _orig_send.load(std::memory_order_acquire); + r = _orig_recv.load(std::memory_order_acquire); + w = _orig_write.load(std::memory_order_acquire); + rd = _orig_read.load(std::memory_order_acquire); + } + +private: + static NativeSocketInterposer* const _instance; + // Production publishes these once before installing any import hook. Atomic + // access also keeps test overrides and hook reads data-race-free. + static std::atomic _orig_send; + static std::atomic _orig_recv; + static std::atomic _orig_write; + static std::atomic _orig_read; + static std::atomic _orig_close; + static std::atomic _orig_dup2; + static std::atomic _orig_dup3; + static std::atomic _orig_connect; + static std::atomic _orig_accept; + static std::atomic _orig_accept4; + static std::atomic _orig_recvfrom; + static std::atomic _orig_recvmsg; + static std::atomic _orig_epoll_wait; + static std::atomic _orig_epoll_pwait; + static std::atomic _orig_poll; + static std::atomic _orig_ppoll; + static std::atomic _orig_select; + static std::atomic _orig_pselect; + + NativeFdClassifier _fd_classifier; + std::atomic _active{false}; + + NativeSocketInterposer() = default; +}; + +#else + +class NativeSocketInterposer { +public: + static NativeSocketInterposer* instance() { return _instance; } + Error start() { return Error::OK; } + void stop() {} + void disableAfterPatchFailure() {} + void clearFdTypeCache() {} + +private: + static NativeSocketInterposer* const _instance; + NativeSocketInterposer() = default; +}; + +#endif + +#endif // _NATIVE_SOCKET_INTERPOSER_H diff --git a/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp b/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp index 79591e7d5a..69e1923fff 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.cpp @@ -11,6 +11,7 @@ #include "flightRecorder.h" #include "libraryPatcher.h" #include "log.h" +#include "nativeSocketInterposer.h" #include "os.h" #include "profiler.h" #include "tsc.h" @@ -44,6 +45,34 @@ std::atomic NativeSocketSampler::_orig_send{nullp std::atomic NativeSocketSampler::_orig_recv{nullptr}; std::atomic NativeSocketSampler::_orig_write{nullptr}; std::atomic NativeSocketSampler::_orig_read{nullptr}; +std::atomic NativeSocketSampler::_active{false}; + +#ifdef UNIT_TEST +static std::atomic _native_socket_sampler_observer{nullptr}; + +void NativeSocketSampler::setHookObserverForTest(HookObserver observer) { + _native_socket_sampler_observer.store(observer, std::memory_order_release); +} + +uint64_t NativeSocketSampler::socketProbeCountForTest() { + return NativeFdClassifier::probeCountForTest(); +} + +void NativeSocketSampler::resetSocketProbeCountForTest() { + NativeFdClassifier::resetProbeCountForTest(); +} + +void NativeSocketSampler::setProbeOverrideForTest(ProbeOverride probe) { + NativeFdClassifier::setProbeOverrideForTest(probe); +} + +void NativeSocketSampler::observeHookPhaseForTest(const char* phase, int fd, u8 op, ssize_t ret) { + HookObserver observer = _native_socket_sampler_observer.load(std::memory_order_acquire); + if (observer != nullptr) { + observer(phase, fd, op, ret); + } +} +#endif std::string NativeSocketSampler::resolveAddr(int fd) { struct sockaddr_storage ss; @@ -84,51 +113,7 @@ bool NativeSocketSampler::isSocket(int fd) { // Accepts any SOCK_STREAM socket (including AF_UNIX); AF_INET/AF_INET6 filtering // is deferred to resolveAddr() which is only called for sampled events. AF_UNIX // will produce an empty remoteAddress field in the JFR event. - if (fd < 0) return false; - if ((size_t)fd >= (size_t)FD_TYPE_CACHE_SIZE) { - int so_type; - socklen_t solen = sizeof(so_type); - return getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen) == 0 - && so_type == SOCK_STREAM; - } - // Acquire on the gen load pairs with the release on the gen-bump in start() - // and on the cache cell store below; without it, on a weakly-ordered arch - // (aarch64) a thread could observe a freshly written cell without the matching - // gen bump (or vice versa), defeating the generation-tag invalidation contract. - uint8_t gen = _fd_cache_gen.load(std::memory_order_acquire); - uint8_t cached = _fd_type_cache[fd].load(std::memory_order_acquire); - // High nibble encodes generation; entry is valid only when it matches current gen mod 16. - if ((cached >> 4) == (gen & 0xF)) { - uint8_t type = cached & 0xF; - // A cached NON_SOCKET verdict is safe to trust: the worst case is that a - // newly-socketed fd reuse under-samples until the next gen reset, which is - // the documented accepted staleness tradeoff. - if (type == FD_TYPE_NON_SOCKET) return false; - // Cached SOCKET: trust the verdict on the hot path; revalidation is deferred - // to recordEvent() on sampled write/read events (see revalidateSocket()). - if (type == FD_TYPE_SOCKET) return true; - } - - int so_type; - socklen_t solen = sizeof(so_type); - int rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); - if (rc == 0) { - bool tcp = (so_type == SOCK_STREAM); - uint8_t type = tcp ? FD_TYPE_SOCKET : FD_TYPE_NON_SOCKET; - _fd_type_cache[fd].store((uint8_t)(((gen & 0xF) << 4) | type), - std::memory_order_release); - return tcp; - } - // Only cache the non-socket verdict when getsockopt definitively says - // "not a socket" (ENOTSOCK). Transient errors (EBADF on a racing close, - // EINTR, etc.) must NOT poison the cache: a sticky misclassification - // would survive fd reuse via dup2() and silently suppress sampling for - // the rest of the session. - if (errno == ENOTSOCK) { - _fd_type_cache[fd].store((uint8_t)(((gen & 0xF) << 4) | FD_TYPE_NON_SOCKET), - std::memory_order_release); - } - return false; + return _fd_classifier.isStreamSocket(fd); } void NativeSocketSampler::insertFdAddrLocked(int fd, std::string addr) { @@ -146,18 +131,24 @@ void NativeSocketSampler::insertFdAddrLocked(int fd, std::string addr) { } } +void NativeSocketSampler::clearFdCacheEntry(int fd) { + _fd_classifier.clearFdType(fd); + + std::lock_guard lock(_fd_cache_mutex); + auto it = _fd_cache.find(fd); + if (it != _fd_cache.end()) { + _fd_lru_list.erase(it->second); + _fd_cache.erase(it); + } +} + bool NativeSocketSampler::revalidateSocket(int fd) { int so_type; socklen_t solen = sizeof(so_type); int rc = getsockopt(fd, SOL_SOCKET, SO_TYPE, &so_type, &solen); if (rc == 0 && so_type == SOCK_STREAM) return true; // fd was reused for a non-socket or is already closed; update the type cache. - if (fd >= 0 && (size_t)fd < (size_t)FD_TYPE_CACHE_SIZE) { - uint8_t gen = _fd_cache_gen.load(std::memory_order_acquire); - _fd_type_cache[fd].store( - (uint8_t)(((gen & 0xF) << 4) | FD_TYPE_NON_SOCKET), - std::memory_order_release); - } + _fd_classifier.cacheNonSocket(fd); return false; } @@ -369,12 +360,9 @@ Error NativeSocketSampler::start(Arguments &args) { // (which carry no latency signal) are suppressed when the interval is large. _rate_limiter.start(init_interval, TARGET_EVENTS_PER_SECOND, PID_WINDOW_SECS, PID_P_GAIN, PID_I_GAIN, PID_D_GAIN, PID_CUTOFF_S); - // Clear the fd->addr cache and reset the fd-type cache generation for the new - // session so stale entries from a prior run cannot produce misattributed events - // even if stop() was not called. clearFdCache() bumps _fd_cache_gen under the - // mutex so the clear and the gen bump are atomic with respect to concurrent - // isSocket() calls. A single call per start() keeps the mod-16 generation-wrap - // budget at the full 16 cycles documented in nativeSocketSampler.h. + // Clear the fd->addr cache and reset the fd-type classifier generation for + // the new session so stale entries from a prior run cannot produce + // misattributed events even if stop() was not called. clearFdCache(); #ifdef DEBUG _send_hook_calls.store(0, std::memory_order_relaxed); @@ -386,7 +374,9 @@ Error NativeSocketSampler::start(Arguments &args) { TEST_LOG("NativeSocketSampler::start interval_ticks=%ld tsc_freq=%llu", init_interval, (unsigned long long)TSC::frequency()); #endif + _active.store(true, std::memory_order_release); if (!LibraryPatcher::patch_socket_functions()) { + _active.store(false, std::memory_order_release); return Error("failed to install native socket hooks (dlsym returned NULL)"); } return Error::OK; @@ -402,18 +392,21 @@ void NativeSocketSampler::stop() { (unsigned long long)_record_accept_calls.load(std::memory_order_relaxed), (unsigned long long)_record_reject_calls.load(std::memory_order_relaxed)); #endif - LibraryPatcher::unpatch_socket_functions(); + _active.store(false, std::memory_order_release); + LibraryPatcher::unpatch_socket_functions_if_inactive(); clearFdCache(); } +void NativeSocketSampler::disableAfterPatchFailure() { + _active.store(false, std::memory_order_release); + _instance->clearFdCache(); +} + void NativeSocketSampler::clearFdCache() { std::lock_guard lock(_fd_cache_mutex); _fd_cache.clear(); _fd_lru_list.clear(); - // Bump the generation under the lock so the clear and the bump are atomic - // with respect to concurrent isSocket() calls: no thread can insert an - // entry tagged with the old generation after the map is cleared. - _fd_cache_gen.fetch_add(1, std::memory_order_release); + _fd_classifier.clearFdTypeCache(); } #else // !__linux__ diff --git a/ddprof-lib/src/main/cpp/nativeSocketSampler.h b/ddprof-lib/src/main/cpp/nativeSocketSampler.h index e45bf60113..d18bebc88f 100644 --- a/ddprof-lib/src/main/cpp/nativeSocketSampler.h +++ b/ddprof-lib/src/main/cpp/nativeSocketSampler.h @@ -13,6 +13,7 @@ #if defined(__linux__) +#include "nativeFdClassifier.h" #include "poissonSampler.h" #include "rateLimiter.h" #include @@ -34,17 +35,12 @@ class LibraryPatcher; // released for resolveAddr(); a concurrent thread may // emplace the same fd before re-acquisition. emplace() // is idempotent in that case (first writer wins). -// Address staleness on fd reuse is accepted: worst case -// is one misattributed event per reuse. -// _fd_type_cache : std::atomic array, lock-free. Entry encoding: -// bits [7:4] = generation mod 16, bits [3:0] = type -// (0=unknown, 1=TCP socket, 2=non-TCP). Valid only when -// high nibble matches _fd_cache_gen mod 16. A cached SOCKET -// verdict is trusted on the hot path; revalidation via -// getsockopt() is deferred to recordEvent() for sampled -// write/read events (revalidateSocket()). A cached NON_SOCKET -// verdict is trusted (worst case: a reused fd under-samples -// until the next gen reset). +// Address staleness is possible only after fd reuse +// through unobserved lifecycle paths. +// _fd_classifier : lock-free fd-type classifier shared as code with the +// native I/O interposer. A cached stream-socket verdict +// is trusted on the hot path; sampled write/read events +// revalidate before recording (revalidateSocket()). // _rate_limiter : RateLimiter — owns std::atomic interval, epoch, and // event count. PID update races are resolved by CAS // inside RateLimiter::maybeUpdateInterval(). @@ -71,17 +67,23 @@ class NativeSocketSampler : public Engine { Error check(Arguments &args) override; Error start(Arguments &args) override; void stop() override; + static void disableAfterPatchFailure(); + static bool active() { return _active.load(std::memory_order_acquire); } // Clears the fd-to-address cache and resets the fd-type cache. // Called from both start() (to reset state on restart) and stop(). // Intentionally NOT called on JFR chunk boundaries. void clearFdCache(); + void clearFdCacheEntry(int fd); // PLT hooks installed by LibraryPatcher::patch_socket_functions(). static ssize_t send_hook(int fd, const void* buf, size_t len, int flags); static ssize_t recv_hook(int fd, void* buf, size_t len, int flags); static ssize_t write_hook(int fd, const void* buf, size_t len); static ssize_t read_hook(int fd, void* buf, size_t len); + static ssize_t recordHookResult(int fd, ssize_t ret, u64 t0, u64 t1, u8 op) { + return recordResultForHook(fd, ret, t0, t1, op); + } // Called once by LibraryPatcher::patch_socket_functions() to install the // real libc function pointers before any PLT entries are patched. @@ -100,19 +102,30 @@ class NativeSocketSampler : public Engine { rd = _orig_read.load(std::memory_order_acquire); } +#ifdef UNIT_TEST + static bool setActiveForTest(bool active) { + return _active.exchange(active, std::memory_order_acq_rel); + } + using HookObserver = void (*)(const char* phase, int fd, u8 op, ssize_t ret); + static void setHookObserverForTest(HookObserver observer); + // Compatibility wrappers for sampler tests; probe override/counting is owned + // by NativeFdClassifier now that sampler delegates fd classification to it. + static uint64_t socketProbeCountForTest(); + static void resetSocketProbeCountForTest(); + using ProbeOverride = int (*)(int fd, int *so_type, int *probe_errno); + static void setProbeOverrideForTest(ProbeOverride probe); +#endif + private: static NativeSocketSampler* const _instance; - // Set by setOriginalFunctions() (called under _lock, before PLT patching) and - // read by the hooks on arbitrary application threads. Declared std::atomic with - // release/acquire pairing so a stop()→start() restart cycle, which rewrites these - // pointers while a stale-epoch hook may still be in flight, has no data race and no - // value tearing on any memory model. The acquire load in each hook also pairs with - // the release store here to publish the pointer before the hook observes it. + // Production publishes these once before PLT patching. Atomic access pairs + // that publication with hook reads and also keeps test overrides data-race-free. static std::atomic _orig_send; static std::atomic _orig_recv; static std::atomic _orig_write; static std::atomic _orig_read; + static std::atomic _active; // Target aggregate event rate: ~83 events/s (~5000/min) across all four hooks // (send/write and recv/read) combined. @@ -147,30 +160,7 @@ class NativeSocketSampler : public Engine { std::unordered_map _fd_cache; std::mutex _fd_cache_mutex; - // fd-type cache for write/read hooks. Lock-free: one atomic byte per fd number. - // Encoding: bits [7:4] = generation mod 16, bits [3:0] = type (0=unknown/invalid - // — implicit zero in fresh array, never written explicitly; 1=TCP socket; - // 2=non-TCP). An entry is valid only when its high nibble equals _fd_cache_gen - // mod 16. Incrementing _fd_cache_gen invalidates all entries in O(1) without - // touching the 65536-entry array. - // - // KNOWN LIMITATION (mod-16 generation wrap): _fd_cache_gen is only consulted via - // its low 4 bits. After 16 start() cycles the generation wraps and stale entries - // from a previous incarnation become indistinguishable from current ones until each - // fd is naturally re-probed. Profiler restarts are not exercised in production - // (only in tests), so the wrap is benign in practice. If restart-in-prod ever - // becomes a supported mode, widen _fd_cache_gen to uint32_t and store the full - // generation in a wider per-fd cell. - // Fds outside [0, FD_TYPE_CACHE_SIZE) are probed on every call. - static const int FD_TYPE_CACHE_SIZE = 65536; - // FD_TYPE_UNKNOWN is the implicit value-zero sentinel for never-written entries - // and gen-mismatch entries; it is decoded by the (cached >> 4) != gen path in - // isSocket(), not by an explicit comparison against this constant. - static const uint8_t FD_TYPE_UNKNOWN = 0; - static const uint8_t FD_TYPE_SOCKET = 1; - static const uint8_t FD_TYPE_NON_SOCKET = 2; - std::atomic _fd_cache_gen{0}; // incremented on each cache reset - std::atomic _fd_type_cache[FD_TYPE_CACHE_SIZE]; + NativeFdClassifier _fd_classifier; NativeSocketSampler() = default; @@ -193,17 +183,28 @@ class NativeSocketSampler : public Engine { std::lock_guard lock(_fd_cache_mutex); return (int)_fd_cache.size(); } + bool fdAddrCacheContainsForTest(int fd) { + std::lock_guard lock(_fd_cache_mutex); + return _fd_cache.find(fd) != _fd_cache.end(); + } void fdAddrCacheInsertForTest(int fd, const std::string& addr) { std::lock_guard lock(_fd_cache_mutex); insertFdAddrLocked(fd, addr); } + bool isSocketForTest(int fd) { + return isSocket(fd); + } +#ifdef UNIT_TEST + bool revalidateSocketForTest(int fd) { + return revalidateSocket(fd); + } +#endif private: // Returns true if fd is a SOCK_STREAM socket (including AF_UNIX). - // Uses the fd-type cache; calls getsockopt on first encounter per fd and on - // every cached-SOCKET hit to revalidate against fd reuse (a closed socket fd - // reassigned to a regular file/pipe must not keep emitting socket events). + // Uses the fd classifier; calls getsockopt on first encounter per fd. + // Cached SOCKET verdicts are revalidated only on sampled write/read events. bool isSocket(int fd); // Decide whether to sample and compute weight. @@ -223,6 +224,17 @@ class NativeSocketSampler : public Engine { if (ret > 0) _instance->recordEvent(fd, t0, t1, ret, op); return ret; } + + static inline ssize_t recordResultForHook(int fd, ssize_t ret, u64 t0, u64 t1, u8 op) { +#ifdef UNIT_TEST + observeHookPhaseForTest("record", fd, op, ret); +#endif + return record_if_positive(fd, ret, t0, t1, op); + } + +#ifdef UNIT_TEST + static void observeHookPhaseForTest(const char* phase, int fd, u8 op, ssize_t ret); +#endif }; #else // !__linux__ @@ -233,7 +245,9 @@ class NativeSocketSampler : public Engine { Error check(Arguments &args) override { return Error::OK; } Error start(Arguments &args) override { return Error::OK; } void stop() override {} + static void disableAfterPatchFailure() {} void clearFdCache() {} + void clearFdCacheEntry(int fd) { (void)fd; } private: static NativeSocketSampler* const _instance; NativeSocketSampler() {} diff --git a/ddprof-lib/src/main/cpp/profiler.cpp b/ddprof-lib/src/main/cpp/profiler.cpp index 05dc7ea624..cfac634a47 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" @@ -32,6 +33,7 @@ #include "stackFrame.h" #include "stackWalker.h" #include "symbols.h" +#include "taskBlockRecorder.h" #include "tsc.h" #include "utils.h" #include "wallClock.h" @@ -44,6 +46,7 @@ #include #include #include +#include #include #include #include @@ -78,11 +81,9 @@ void Profiler::onThreadStart(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { ProfiledThread *current = ProfiledThread::current(); current->setJavaThread(true); int tid = current->tid(); - if (_thread_filter.enabled()) { - int slot_id = _thread_filter.registerThread(); + if (_thread_filter.registryActive()) { + int slot_id = _thread_filter.registerThread(tid); current->setFilterSlotId(slot_id); - _thread_filter.resetSlotRunState(slot_id); - _thread_filter.remove(slot_id); // Remove from filtering initially } if (thread != NULL) { updateThreadName(jvmti, jni, thread, true); @@ -101,9 +102,11 @@ void Profiler::onThreadEnd(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { int slot_id = current->filterSlotId(); tid = current->tid(); - if (_thread_filter.enabled()) { + if (slot_id >= 0) { _thread_filter.unregisterThread(slot_id); current->setFilterSlotId(-1); + } else { + _thread_filter.unregisterThreadByTid(tid); } updateThreadName(jvmti, jni, thread, false); @@ -127,6 +130,7 @@ void Profiler::onThreadEnd(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread) { } updateThreadName(jvmti, jni, thread, false); + _thread_filter.unregisterThreadByTid(tid); _cpu_engine->unregisterThread(tid); _wall_engine->unregisterThread(tid); } @@ -137,6 +141,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); @@ -144,6 +150,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) { @@ -512,14 +523,7 @@ u64 Profiler::recordJVMTISample(u64 counter, int tid, jthread thread, jint event if (VM::jvmti()->GetStackTrace(thread, 0, _max_stack_depth, jvmti_frames, &num_frames) == JVMTI_ERROR_NONE && num_frames > 0) { // Convert to AsyncGetCallTrace format. // Note: jvmti_frames and frames may overlap. - for (int i = 0; i < num_frames; i++) { - jint bci = jvmti_frames[i].location; - jmethodID mid = jvmti_frames[i].method; - frames[i].method_id = mid; - frames[i].bci = bci; - // see https://github.com/async-profiler/async-profiler/pull/1090 - LP64_ONLY(frames[i].padding = 0;) - } + copyJvmtiFrames(frames, jvmti_frames, num_frames); // On JDK 21+, GetStackTrace on a virtual thread returns only the VT's // logical stack; it stops at the continuation boundary and never includes // carrier-thread frames. Without a synthetic root the trace appears @@ -720,6 +724,95 @@ 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; + } + + 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); + } +#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) { @@ -1051,6 +1144,32 @@ void Profiler::updateJavaThreadNames() { jvmti->Deallocate((unsigned char *)thread_objects); } +void Profiler::registerExistingJavaThreads() { + if (!_thread_filter.allThreads()) { + return; + } + + jvmtiEnv *jvmti = VM::jvmti(); + JNIEnv *jni = VM::jni(); + jint thread_count; + jthread *thread_objects; + if (jvmti->GetAllThreads(&thread_count, &thread_objects) != JVMTI_ERROR_NONE) { + return; + } + + for (int i = 0; i < thread_count; ++i) { + jthread thread = thread_objects[i]; + if (thread != nullptr) { + int tid = JVMThread::nativeThreadId(jni, thread); + if (tid >= 0) { + _thread_filter.registerThread(tid); + } + jni->DeleteLocalRef(thread); + } + } + jvmti->Deallocate(reinterpret_cast(thread_objects)); +} + void Profiler::updateNativeThreadNames(bool defer_initializing) { ThreadList *thread_list = OS::listThreads(); constexpr size_t buffer_size = 64; @@ -1237,6 +1356,7 @@ Error Profiler::checkState() { Error Profiler::start(Arguments &args, bool reset) { MutexLocker ml(_state_lock); + _task_block_enabled.store(false, std::memory_order_release); Error error = checkState(); if (error) { return error; @@ -1354,19 +1474,22 @@ Error Profiler::start(Arguments &args, bool reset) { } // TODO: Current way of setting filter is weird with the recent changes - _thread_filter.init(args._filter ? args._filter : "0"); + const bool all_threads = args.wallScopeAllThreads(); + const char *filter = args._wall_scope == WALL_SCOPE_CONTEXT + ? "0" + : (args._filter ? args._filter : "0"); + _thread_filter.init(filter, all_threads); // Minor optim: Register the current thread (start thread won't be called) - if (_thread_filter.enabled()) { + if (_thread_filter.registryActive()) { _thread_filter.clearActive(); ProfiledThread *current = ProfiledThread::current(); assert(current != nullptr); int slot_id = current->filterSlotId(); if (slot_id < 0) { - slot_id = _thread_filter.registerThread(); + slot_id = _thread_filter.registerThread(current->tid()); current->setFilterSlotId(slot_id); } - _thread_filter.remove(slot_id); // Remove from filtering initially (matches onThreadStart behavior) } _cpu_engine = selectCpuEngine(args); @@ -1425,6 +1548,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) { @@ -1496,6 +1620,10 @@ Error Profiler::start(Arguments &args, bool reset) { if (activated) { switchThreadEvents(JVMTI_ENABLE); + // ThreadStart events cover only threads created after the callbacks are + // enabled. Bootstrap registry identity for Java threads that already exist. + registerExistingJavaThreads(); + // Initialize this thread // Note: passing all nullptrs results in not able to resolve the thread name here. // However, the thread name will be updated later in updateJavaThreadNames(). @@ -1511,6 +1639,18 @@ Error Profiler::start(Arguments &args, bool reset) { // Paired with drainInflight() on the stop side. _cpu_engine->enableEvents(true); + _task_block_enabled.store( + (activated & EM_WALL) && args._wall_precheck && all_threads, + std::memory_order_release); + _task_block_monitor_events_enabled = + taskBlockEnabled() && VM::nativeMonitorEventsAvailable() && + VM::setNativeMonitorEventsEnabled(true); + if (taskBlockEnabled()) { + Error native_io_error = NativeSocketInterposer::instance()->start(); + if (native_io_error) { + Log::warn("%s", native_io_error.message()); + } + } _state.store(RUNNING, std::memory_order_release); _start_time = time(NULL); __atomic_add_fetch(&_epoch, 1, __ATOMIC_RELAXED); @@ -1534,6 +1674,11 @@ Error Profiler::stop() { if (state() != RUNNING) { return Error("Profiler is not active"); } + _task_block_enabled.store(false, std::memory_order_release); + if (_task_block_monitor_events_enabled) { + VM::setNativeMonitorEventsEnabled(false); + _task_block_monitor_events_enabled = false; + } // Order matters: disable engines first so the _enabled check inside signal // handlers will fail for any new signal delivered from now on. drain() then @@ -1551,6 +1696,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) @@ -1561,6 +1711,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) @@ -1617,6 +1768,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 @@ -1698,10 +1850,12 @@ Error Profiler::dump(const char *path, const int length) { // its own writer/reader coordination; #527's classMapSharedGuard readers // (deferred vtable receiver resolution) are coordinated through // _class_map_lock. + beginTaskBlockRotation(); rotateDictsAndRun([&]{ err = _jfr.dump(path, length); __atomic_add_fetch(&_epoch, 1, __ATOMIC_SEQ_CST); }); + endTaskBlockRotation(); _thread_info.clearAll(thread_ids); _thread_info.reportCounters(); diff --git a/ddprof-lib/src/main/cpp/profiler.h b/ddprof-lib/src/main/cpp/profiler.h index 0e4369fdba..06a0891d3d 100644 --- a/ddprof-lib/src/main/cpp/profiler.h +++ b/ddprof-lib/src/main/cpp/profiler.h @@ -119,6 +119,10 @@ 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}; + bool _task_block_monitor_events_enabled = false; + std::atomic _task_block_rotation{false}; + std::atomic _task_block_inflight{0}; SpinLock _class_map_lock; SpinLock _locks[CONCURRENCY_LEVEL]; @@ -152,6 +156,7 @@ class alignas(alignof(SpinLock)) Profiler { void updateThreadName(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, bool self = false); void updateJavaThreadNames(); + void registerExistingJavaThreads(); void mangle(const char *name, char *buf, size_t size); Engine *selectCpuEngine(Arguments &args); @@ -161,6 +166,8 @@ class alignas(alignof(SpinLock)) Profiler { void lockAll(); void unlockAll(); + void beginTaskBlockRotation(); + void endTaskBlockRotation(); // Rotate all three dictionaries, then run jfr_op under lockAll(). // @@ -421,6 +428,29 @@ 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); + bool setTaskBlockEnabledForTest(bool enabled) { + return _task_block_enabled.exchange(enabled, std::memory_order_acq_rel); + } +#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, @@ -441,6 +471,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/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 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 531ce75a1a..d08629befb 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.cpp +++ b/ddprof-lib/src/main/cpp/threadFilter.cpp @@ -21,6 +21,7 @@ #include "threadFilter.h" #include "arch.h" +#include "counters.h" #include "os.h" #include "threadLocalData.h" #include @@ -32,12 +33,24 @@ ThreadFilter::ShardHead ThreadFilter::_free_heads[ThreadFilter::kShardCount] {}; -ThreadFilter::ThreadFilter() : _enabled(false) { +#ifdef UNIT_TEST +std::atomic + ThreadFilter::_block_run_publish_observer{nullptr}; + +void ThreadFilter::setBlockRunPublishObserverForTest(BlockRunPublishObserver observer) { + _block_run_publish_observer.store(observer, std::memory_order_release); +} +#endif + +ThreadFilter::ThreadFilter() : _enabled(false), _registry_active(false), _all_threads(false) { // Initialize chunk pointers to null (lazy allocation) for (int i = 0; i < kMaxChunks; ++i) { _chunks[i].store(nullptr, std::memory_order_relaxed); } _free_list = std::make_unique(kFreeListSize); + for (auto& entry : _tid_index) { + entry.store(0, std::memory_order_relaxed); + } // Initialize the first chunk initializeChunk(0); @@ -51,6 +64,8 @@ ThreadFilter::ThreadFilter() : _enabled(false) { ThreadFilter::~ThreadFilter() { // Make the filter inert for any concurrent readers _enabled.store(false, std::memory_order_release); + _registry_active.store(false, std::memory_order_release); + _all_threads.store(false, std::memory_order_release); // Reset free-list heads and nodes first for (int s = 0; s < kShardCount; ++s) { _free_heads[s].head.store(-1, std::memory_order_relaxed); @@ -59,6 +74,9 @@ ThreadFilter::~ThreadFilter() { _free_list[i].value.store(-1, std::memory_order_relaxed); _free_list[i].next.store(-1, std::memory_order_relaxed); } + for (auto& entry : _tid_index) { + entry.store(0, std::memory_order_relaxed); + } // Publish 0 chunks to stop range scans (collect) _num_chunks.store(0, std::memory_order_release); // Detach and delete chunks @@ -78,7 +96,8 @@ void ThreadFilter::initializeChunk(int chunk_idx) { // Allocate and initialize new chunk completely before swapping ChunkStorage* new_chunk = new ChunkStorage(); for (auto& slot : new_chunk->slots) { - slot.value.store(-1, std::memory_order_relaxed); + slot.tid.store(-1, std::memory_order_relaxed); + slot.context_window_state.store(0, std::memory_order_relaxed); slot.active_block_state.store(OSThreadState::UNKNOWN, std::memory_order_relaxed); } @@ -92,15 +111,33 @@ void ThreadFilter::initializeChunk(int chunk_idx) { } } -ThreadFilter::SlotID ThreadFilter::registerThread() { - // If disabled, block new registrations - if (!_enabled.load(std::memory_order_acquire)) { +ThreadFilter::SlotID ThreadFilter::registerThread(int tid) { + if (!_registry_active.load(std::memory_order_acquire)) { return -1; } + std::lock_guard lock(_registry_lock); + + if (tid >= 0) { + SlotID existing = lookupSlotIdByTid(tid); + if (existing >= 0) { + return existing; + } + } // First, try to get a slot from the free list (lock-free stack) SlotID reused_slot = popFromFreeList(); if (reused_slot >= 0) { + Slot* slot = slotForId(reused_slot); + slot->lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); + slot->context_window_state.store(0, std::memory_order_relaxed); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->tid.store(tid, std::memory_order_release); + if (tid >= 0 && !indexSlot(reused_slot, tid)) { + slot->tid.store(-1, std::memory_order_release); + pushToFreeList(reused_slot); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); + return -1; + } return reused_slot; } @@ -109,6 +146,7 @@ ThreadFilter::SlotID ThreadFilter::registerThread() { if (index >= kMaxThreads) { // Revert the increment and return failure _next_index.fetch_sub(1, std::memory_order_relaxed); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); return -1; } @@ -131,9 +169,86 @@ ThreadFilter::SlotID ThreadFilter::registerThread() { // Initialize the chunk if needed initializeChunk(chunk_idx); + Slot* slot = slotForId(index); + slot->lifecycle_generation.fetch_add(1, std::memory_order_acq_rel); + slot->context_window_state.store(0, std::memory_order_relaxed); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); + slot->tid.store(tid, std::memory_order_release); + if (tid >= 0 && !indexSlot(index, tid)) { + slot->tid.store(-1, std::memory_order_release); + pushToFreeList(index); + Counters::increment(THREAD_FILTER_CAPACITY_EXHAUSTED); + return -1; + } + return index; } +bool ThreadFilter::indexSlot(SlotID slot_id, int tid) { + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value <= 0) { + _tid_index[index].store(slot_id + 1, std::memory_order_release); + return true; + } + if (value > 0) { + Slot* slot = slotForId(value - 1); + if (slot != nullptr && slot->nativeTid() == tid) { + return value - 1 == slot_id; + } + } + } + return false; +} + +void ThreadFilter::unindexSlot(SlotID slot_id, int tid) { + if (tid < 0) return; + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value == 0) return; + if (value == slot_id + 1) { + int next = (index + 1) & kTidIndexMask; + int replacement = + _tid_index[next].load(std::memory_order_acquire) == 0 ? 0 : -1; + _tid_index[index].store(replacement, std::memory_order_release); + if (replacement == 0) { + int previous = (index - 1) & kTidIndexMask; + while (_tid_index[previous].load(std::memory_order_acquire) == -1) { + _tid_index[previous].store(0, std::memory_order_release); + previous = (previous - 1) & kTidIndexMask; + } + } + return; + } + } +} + +ThreadFilter::SlotID ThreadFilter::lookupSlotIdByTid(int tid) const { + if (tid < 0) return -1; + unsigned start = hashTid(tid) & kTidIndexMask; + for (int probe = 0; probe < kTidIndexSize; ++probe) { + int index = (start + probe) & kTidIndexMask; + int value = _tid_index[index].load(std::memory_order_acquire); + if (value == 0) return -1; + if (value > 0) { + Slot* slot = slotForId(value - 1); + if (slot != nullptr && slot->nativeTid() == tid) { + return value - 1; + } + } + } + return -1; +} + +ThreadFilter::Slot* ThreadFilter::lookupByTid(int tid) const { + SlotID slot_id = lookupSlotIdByTid(tid); + return slot_id < 0 ? nullptr : slotForId(slot_id); +} + void ThreadFilter::initFreeList() { // Initialize the free list storage for (int i = 0; i < kFreeListSize; ++i) { @@ -160,7 +275,7 @@ bool ThreadFilter::accept(SlotID slot_id) const { // This is not a fast path like the add operation. ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (likely(chunk != nullptr)) { - return chunk->slots[slot_idx].value.load(std::memory_order_relaxed) != -1; + return chunk->slots[slot_idx].inContextWindow(); } return false; } @@ -176,7 +291,19 @@ void ThreadFilter::add(int tid, SlotID slot_id) { // Fast path: assume valid slot_id from registerThread() ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (likely(chunk != nullptr)) { - chunk->slots[slot_idx].value.store(tid, std::memory_order_release); + Slot& slot = chunk->slots[slot_idx]; + if (slot.nativeTid() == -1) { + std::lock_guard lock(_registry_lock); + if (slot.nativeTid() == -1) { + slot.tid.store(tid, std::memory_order_release); + if (_all_threads.load(std::memory_order_acquire) && + !indexSlot(slot_id, tid)) { + slot.tid.store(-1, std::memory_order_release); + return; + } + } + } + slot.enterContextWindow(); } } @@ -198,16 +325,34 @@ void ThreadFilter::remove(SlotID slot_id) { return; } - chunk->slots[slot_idx].value.store(-1, std::memory_order_release); + chunk->slots[slot_idx].exitContextWindow(); } void ThreadFilter::unregisterThread(SlotID slot_id) { + std::lock_guard lock(_registry_lock); + unregisterThreadLocked(slot_id); +} + +void ThreadFilter::unregisterThreadLocked(SlotID slot_id) { if (slot_id < 0) return; - remove(slot_id); - resetSlotRunState(slot_id); + Slot* slot = slotForId(slot_id); + if (slot == nullptr) return; + int tid = slot->nativeTid(); + unindexSlot(slot_id, tid); + slot->tid.store(-1, std::memory_order_release); + slot->context_window_state.store(0, std::memory_order_release); + slot->clearActiveBlockRun(OSThreadState::UNKNOWN); pushToFreeList(slot_id); } +void ThreadFilter::unregisterThreadByTid(int tid) { + std::lock_guard lock(_registry_lock); + SlotID slot_id = lookupSlotIdByTid(tid); + if (slot_id >= 0) { + unregisterThreadLocked(slot_id); + } +} + bool ThreadFilter::pushToFreeList(SlotID slot_id) { // Lock-free sharded Treiber stack push const int shard = shardOfSlot(slot_id); @@ -274,8 +419,8 @@ void ThreadFilter::collect(std::vector& tids) const { } for (const auto& slot : chunk->slots) { - int slot_tid = slot.value.load(std::memory_order_relaxed); - if (slot_tid != -1) { + int slot_tid = slot.nativeTid(); + if (slot_tid != -1 && slot.inContextWindow()) { tids.push_back(slot_tid); } } @@ -299,9 +444,25 @@ void ThreadFilter::collect(std::vector& entries) const { } for (auto& slot : chunk->slots) { - int slot_tid = slot.value.load(std::memory_order_acquire); + int slot_tid = slot.nativeTid(); + if (slot_tid != -1 && slot.inContextWindow()) { + entries.push_back({slot_tid, &slot, slot.lifecycleGeneration()}); + } + } + } +} + +void ThreadFilter::collectRegistered(std::vector& entries) const { + entries.clear(); + entries.reserve(512); + int num_chunks = _num_chunks.load(std::memory_order_acquire); + for (int chunk_idx = 0; chunk_idx < num_chunks; ++chunk_idx) { + ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); + if (chunk == nullptr) continue; + for (auto& slot : chunk->slots) { + int slot_tid = slot.nativeTid(); if (slot_tid != -1) { - entries.push_back({slot_tid, &slot}); + entries.push_back({slot_tid, &slot, slot.lifecycleGeneration()}); } } } @@ -315,8 +476,9 @@ void ThreadFilter::clearActive() { continue; } - for (auto& slot : chunk->slots) { - slot.value.store(-1, std::memory_order_release); + for (int slot_idx = 0; slot_idx < kChunkSize; ++slot_idx) { + Slot& slot = chunk->slots[slot_idx]; + slot.exitContextWindow(); slot.clearActiveBlockRun(OSThreadState::UNKNOWN); } } @@ -328,20 +490,27 @@ void ThreadFilter::resetSlotRunState(SlotID slot_id) { int slot_idx = slot_id & kChunkMask; ChunkStorage* chunk = _chunks[chunk_idx].load(std::memory_order_acquire); if (chunk != nullptr) { - // Clear stale suppression state so a new thread in this slot cannot inherit - // its predecessor's active block or once-per-run sampled marker. + // Clear stale suppression state so a new thread in this slot cannot + // inherit its predecessor's active block. chunk->slots[slot_idx].clearActiveBlockRun(OSThreadState::UNKNOWN); } } u64 ThreadFilter::enterBlockedRun(SlotID slot_id, OSThreadState state, BlockRunOwner owner) { + if (state == OSThreadState::UNKNOWN) return 0; Slot* s = slotForId(slot_id); if (s != nullptr) { - u32 generation = 0; - if (!s->trySetActiveBlockRun(state, owner, &generation)) { + u64 generation = 0; + if (!s->tryPrepareActiveBlockRun(owner, &generation, allThreads())) { return 0; } + s->publishActiveBlockRun(state); +#ifdef UNIT_TEST + BlockRunPublishObserver observer = + _block_run_publish_observer.load(std::memory_order_acquire); + if (observer != nullptr) observer(this, slot_id); +#endif return encodeBlockRunToken(slot_id, generation); } return 0; @@ -354,23 +523,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; } -void ThreadFilter::init(const char* filter) { - // Simple logic: any filter value (including "0") enables filtering - // Only explicitly registered threads via addThread() will be sampled - // Previously we had a syntax where we could manually force some thread IDs. - // This is no longer supported. - _enabled.store(filter != nullptr && strlen(filter) > 0, std::memory_order_release); +bool ThreadFilter::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 (!allThreads() || slot == nullptr || slot->nativeTid() != entry.tid || + slot->lifecycleGeneration() != entry.lifecycle_generation || + !slot->activeBlockRemainedOutsideContextWindow() || + slot->activeBlockOwner() == BlockRunOwner::NONE) { + return false; + } + OSThreadState state = slot->activeBlockState(); + return state == OSThreadState::SLEEPING || + state == OSThreadState::CONDVAR_WAIT || + state == OSThreadState::OBJECT_WAIT || + state == OSThreadState::MONITOR_WAIT || + state == OSThreadState::IO_WAIT; +} + +void ThreadFilter::init(const char* filter, bool all_threads) { + // Legacy/context scope uses any non-empty filter value (including "0") as + // an explicit context allow-list. All-thread scope keeps registry identity + // active but makes the allow-list irrelevant to ordinary wall sampling. + bool context_filter = !all_threads && filter != nullptr && strlen(filter) > 0; + _all_threads.store(all_threads, std::memory_order_release); + _registry_active.store(all_threads || context_filter, std::memory_order_release); + _enabled.store(context_filter, std::memory_order_release); } bool ThreadFilter::enabled() const { return _enabled.load(std::memory_order_acquire); } + +bool ThreadFilter::registryActive() const { + return _registry_active.load(std::memory_order_acquire); +} + +bool ThreadFilter::allThreads() const { + return _all_threads.load(std::memory_order_acquire); +} diff --git a/ddprof-lib/src/main/cpp/threadFilter.h b/ddprof-lib/src/main/cpp/threadFilter.h index 541249e4c1..451b8a83bd 100644 --- a/ddprof-lib/src/main/cpp/threadFilter.h +++ b/ddprof-lib/src/main/cpp/threadFilter.h @@ -21,6 +21,7 @@ #include #include #include +#include #include "arch.h" #include "threadState.h" @@ -34,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; @@ -44,9 +53,16 @@ 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 = 1024; // power-of-two for fast modulo + static constexpr int kFreeListSize = kMaxThreads; static constexpr int kShardCount = 64; // power-of-two for fast modulo + static constexpr int kTidIndexSize = 8192; // 4x maximum live slots + static constexpr int kTidIndexMask = kTidIndexSize - 1; // One cache line per slot to avoid false sharing. Slot instances are never freed // (ChunkStorage is process-lifetime), so a captured Slot* is always dereferenceable. @@ -56,47 +72,70 @@ class ThreadFilter { std::atomic unowned_blocked_pending_weight{0}; std::atomic unowned_blocked_decision_count{0}; std::atomic unowned_blocked_call_trace_id{0}; + // Packed as (epoch << 1) | in_context_window so a transition and its + // epoch change are observed atomically by block admission and exit. + std::atomic context_window_state{0}; + std::atomic lifecycle_generation{0}; + std::atomic active_block_context_epoch{0}; + std::atomic block_generation{0}; std::atomic unowned_blocked_state{OSThreadState::UNKNOWN}; - std::atomic value{-1}; + // The native TID and context-window membership are deliberately + // independent. A live thread remains ordinarily sampleable in all-thread + // scope while addThread()/removeThread() changes only the context marker. + std::atomic tid{-1}; std::atomic active_block_owner{static_cast(BlockRunOwner::NONE)}; - std::atomic block_generation{0}; - // Wall-clock once-per-run suppression state. The signal handler records the - // 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) + - 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) + - sizeof(std::atomic) - sizeof(std::atomic) - - sizeof(std::atomic)]; + - sizeof(std::atomic)]; - inline bool sampledThisRun() const { - return sampled_this_run.load(std::memory_order_acquire); + inline int nativeTid() const { + return tid.load(std::memory_order_acquire); } - inline OSThreadState lastSampledState() const { - return last_sampled_state.load(std::memory_order_relaxed); + inline u64 lifecycleGeneration() const { + return lifecycle_generation.load(std::memory_order_acquire); } - inline void markSampledThisRun(OSThreadState state) { - last_sampled_state.store(state, std::memory_order_relaxed); - sampled_this_run.store(true, std::memory_order_release); + inline bool inContextWindow() const { + return (context_window_state.load(std::memory_order_acquire) & 1) != 0; } - 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 u64 contextWindowEpoch() const { + return context_window_state.load(std::memory_order_acquire) >> 1; } + inline bool enterContextWindow() { + u64 current = context_window_state.load(std::memory_order_acquire); + while ((current & 1) == 0) { + if (context_window_state.compare_exchange_weak( + current, current + 3, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return true; + } + } + return false; + } + inline bool exitContextWindow() { + u64 current = context_window_state.load(std::memory_order_acquire); + while ((current & 1) != 0) { + if (context_window_state.compare_exchange_weak( + current, current + 1, std::memory_order_acq_rel, + std::memory_order_acquire)) { + return true; + } + } + return false; + } + inline OSThreadState activeBlockState() const { return active_block_state.load(std::memory_order_acquire); } @@ -106,7 +145,7 @@ class ThreadFilter { inline BlockRunOwner activeBlockOwner() const { return static_cast(active_block_owner.load(std::memory_order_acquire)); } - inline u32 blockGeneration() const { + inline u64 blockGeneration() const { return block_generation.load(std::memory_order_acquire); } inline void resetUnownedBlockedSampling() { @@ -146,46 +185,82 @@ class ThreadFilter { } return true; } - inline bool trySetActiveBlockRun(OSThreadState state, BlockRunOwner owner, - u32* generation_out) { + 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; + } int expected_owner = static_cast(BlockRunOwner::NONE); if (!active_block_owner.compare_exchange_strong( expected_owner, static_cast(owner), std::memory_order_acq_rel, std::memory_order_acquire)) { return false; } - u32 generation = block_generation.fetch_add(1, std::memory_order_acq_rel) + 1; + if (outside_context_required && + context_window_state.load(std::memory_order_acquire) != context_state) { + active_block_owner.store(static_cast(BlockRunOwner::NONE), + std::memory_order_release); + return false; + } + 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 { + u64 context_state = context_window_state.load(std::memory_order_acquire); + return (context_state & 1) == 0 && + active_block_context_epoch.load(std::memory_order_acquire) == + (context_state >> 1); + } + inline BlockRunSnapshot snapshotBlockRun() const { + BlockRunSnapshot snapshot; + snapshot.active_state = activeBlockState(); + snapshot.owner = activeBlockOwner(); + snapshot.generation = blockGeneration(); + snapshot.active = snapshot.owner != BlockRunOwner::NONE && + snapshot.active_state != OSThreadState::UNKNOWN; + snapshot.context_eligible = activeBlockRemainedOutsideContextWindow(); + return snapshot; + } }; static_assert(sizeof(Slot) == 2 * DEFAULT_CACHE_LINE_SIZE, "Slot must be exactly two cache lines"); static_assert(std::atomic::is_always_lock_free, "Slot OSThreadState fields must be lock-free for signal-handler safety"); - static_assert(std::atomic::is_always_lock_free, - "Slot::sampled_this_run must be lock-free for signal-handler safety"); ThreadFilter(); ~ThreadFilter(); - void init(const char* filter); + void init(const char* filter, bool all_threads = false); void initFreeList(); bool enabled() const; + bool registryActive() const; + bool allThreads() const; // Hot path methods - slot_id MUST be from registerThread(), undefined behavior otherwise bool accept(SlotID slot_id) const; void add(int tid, SlotID slot_id); void remove(SlotID slot_id); void collect(std::vector& tids) const; void collect(std::vector& entries) const; + void collectRegistered(std::vector& entries) const; // Clears per-recording membership and suppression state while keeping // process-lifetime slot ownership intact. Threads must opt in again with add(). void clearActive(); @@ -196,18 +271,34 @@ class ThreadFilter { // lifecycles must use the generation-checked overload so they cannot clear // another owner. void exitBlockedRun(SlotID slot_id); - bool exitBlockedRun(SlotID slot_id, u32 generation); + bool exitBlockedRun(SlotID slot_id, u64 generation); + bool snapshotAndExitBlockedRun(SlotID slot_id, u64 generation, + BlockRunSnapshot* snapshot); + BlockRunSnapshot snapshotBlockedRun(SlotID slot_id) const; + bool isOwnedBlockSuppressionCandidate(const ThreadEntry& entry) const; - static inline u64 encodeBlockRunToken(SlotID slot_id, u32 generation) { - return (static_cast(generation) << 32) | static_cast(slot_id + 1); + static inline u64 encodeBlockRunToken(SlotID slot_id, u64 generation) { + return (generation << kBlockRunSlotBits) | static_cast(slot_id); } static inline SlotID tokenSlotId(u64 token) { - return static_cast(static_cast(token) - 1); + return static_cast(token & kBlockRunSlotMask); + } + static inline u64 tokenGeneration(u64 token) { + return token >> kBlockRunSlotBits; } - static inline u32 tokenGeneration(u64 token) { - return static_cast(token >> 32); + static inline bool decodeBlockRunToken(u64 token, SlotID& slot_id, + u64& generation) { + if (token == 0) return false; + slot_id = tokenSlotId(token); + generation = tokenGeneration(token); + return generation != 0; } +#ifdef UNIT_TEST + using BlockRunPublishObserver = void (*)(ThreadFilter*, SlotID); + static void setBlockRunPublishObserverForTest(BlockRunPublishObserver observer); +#endif + // Returns nullptr if slot_id is invalid or its chunk has not been allocated. inline Slot* slotForId(SlotID slot_id) const { if (slot_id < 0) return nullptr; @@ -218,8 +309,11 @@ class ThreadFilter { return chunk != nullptr ? &chunk->slots[slot_idx] : nullptr; } - SlotID registerThread(); + SlotID registerThread(int tid = -1); void unregisterThread(SlotID slot_id); + void unregisterThreadByTid(int tid); + Slot* lookupByTid(int tid) const; + SlotID slotIdByTid(int tid) const { return lookupSlotIdByTid(tid); } private: @@ -235,6 +329,8 @@ class ThreadFilter { }; std::atomic _enabled{false}; + std::atomic _registry_active{false}; + std::atomic _all_threads{false}; // Lazily allocated storage for chunks std::atomic _chunks[kMaxChunks]; @@ -243,6 +339,16 @@ class ThreadFilter { // Lock-free slot allocation std::atomic _next_index{0}; std::unique_ptr _free_list; + // Entries contain slot_id + 1. Zero terminates a lookup probe; -1 is a + // tombstone left by unregister. The slot's published TID is the key. + std::array, kTidIndexSize> _tid_index; + // Registration and teardown never run in a signal handler. Serializing + // writers prevents duplicate TID mappings while lookups remain lock-free. + std::mutex _registry_lock; + +#ifdef UNIT_TEST + static std::atomic _block_run_publish_observer; +#endif // Cache line aligned to prevent false sharing between shards struct alignas(DEFAULT_CACHE_LINE_SIZE) ShardHead { std::atomic head{-1}; }; @@ -254,12 +360,20 @@ class ThreadFilter { void initializeChunk(int chunk_idx); bool pushToFreeList(SlotID slot_id); SlotID popFromFreeList(); + bool indexSlot(SlotID slot_id, int tid); + void unindexSlot(SlotID slot_id, int tid); + void unregisterThreadLocked(SlotID slot_id); + SlotID lookupSlotIdByTid(int tid) const; + static inline unsigned hashTid(int tid) { + return static_cast(tid) * 2654435761u; + } }; // Snapshot entry produced by ThreadFilter::collect for the wall-clock timer. struct ThreadEntry { int tid; ThreadFilter::Slot* slot; + u64 lifecycle_generation; }; #endif // _THREADFILTER_H diff --git a/ddprof-lib/src/main/cpp/threadLocalData.h b/ddprof-lib/src/main/cpp/threadLocalData.h index e8b9a30c46..b781a6340d 100644 --- a/ddprof-lib/src/main/cpp/threadLocalData.h +++ b/ddprof-lib/src/main/cpp/threadLocalData.h @@ -7,6 +7,7 @@ #define THREAD_LOCAL_DATA_H #include "context.h" +#include "context_api.h" #include "otel_context.h" #include "os.h" #include "threadState.h" @@ -43,7 +44,8 @@ class ProfiledThread : public ThreadLocalData { TYPE_MASK = TYPE_JAVA_THREAD | TYPE_NOT_JAVA_THREAD }; - static constexpr u32 FLAG_PARKED = 0x4u; // next free bit after TYPE_MASK (0x1|0x2) + static constexpr u32 FLAG_PARKED = 0x4u; + static constexpr u32 FLAG_MONITOR_BLOCKED = 0x8u; // We are allowing several levels of nesting because we can be // eg. in a crash handler when wallclock signal kicks in, @@ -79,7 +81,17 @@ class ProfiledThread : public ThreadLocalData { u64 _call_trace_id; u32 _recording_epoch; u32 _misc_flags; + u64 _park_start_ticks; u64 _park_block_token; + Context _park_context; + u64 _task_block_start_ticks; + u64 _task_block_token; + Context _task_block_context; + u64 _monitor_start_ticks; + Context _monitor_context; + u64 _monitor_blocker; + u64 _monitor_block_token; + OSThreadState _monitor_block_state; int _filter_slot_id; // Slot ID for thread filtering uint8_t _init_window; // Countdown for JVM thread init race window (PROF-13072) uint8_t _signal_depth; // Nested signal-handler depth (see SignalHandlerScope) @@ -100,7 +112,12 @@ 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_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), _otel_ctx_record{}, _otel_tag_encodings{}, _otel_local_root_span_id(0) {}; @@ -298,26 +315,133 @@ 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; +#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) { + _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; } + 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) { + 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/threadState.h b/ddprof-lib/src/main/cpp/threadState.h index 786c96fb6f..0fc0141dc0 100644 --- a/ddprof-lib/src/main/cpp/threadState.h +++ b/ddprof-lib/src/main/cpp/threadState.h @@ -16,8 +16,9 @@ enum class OSThreadState : int { BREAKPOINTED = 6, // Suspended at breakpoint SLEEPING = 7, // Thread.sleep() TERMINATED = 8, // All done, but not reclaimed yet - SYSCALL = 9 // does not originate in the JVM, used when the current frame is - // known to be a syscall + SYSCALL = 9, // does not originate in the JVM, used when the current frame is + // known to be a syscall + IO_WAIT = 10 // Physical platform/carrier thread blocked in native I/O }; enum class ExecutionMode : int { diff --git a/ddprof-lib/src/main/cpp/vmEntry.cpp b/ddprof-lib/src/main/cpp/vmEntry.cpp index bcea72d07f..3cc04af51c 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.cpp +++ b/ddprof-lib/src/main/cpp/vmEntry.cpp @@ -8,6 +8,7 @@ #include "vmEntry.h" #include "arguments.h" #include "context.h" +#include "context_api.h" #include "counters.h" #include "j9/j9Support.h" #include "jniHelper.h" @@ -18,6 +19,8 @@ #include "os.h" #include "profiler.h" #include "safeAccess.h" +#include "taskBlockRecorder.h" +#include "tsc.h" // Pulls in vmStructs.h plus the definitions of crashProtectionActive()/cast_to() that its inline // accessors odr-use here; the light vmStructs.h alone leaves those unresolved in assertion-enabled // builds (see the note in hotspotStackFrame_aarch64.cpp). @@ -47,6 +50,8 @@ bool VM::_hotspot = false; bool VM::_zing = false; bool VM::_can_sample_objects = false; bool VM::_can_intercept_binding = false; +bool VM::_monitor_events_delegated = false; +bool VM::_native_monitor_events_available = false; bool VM::_is_adaptive_gc_boundary_flag_set = false; jvmtiExtensionFunction VM::_request_stack_trace = nullptr; @@ -66,6 +71,139 @@ static void wakeupHandler(int signo) { // Dummy handler for interrupting syscalls } +static u64 monitorBlockerHash(jvmtiEnv *jvmti, jobject object) { + if (object == NULL) return 0; + jint hash = 0; + if (jvmti->GetObjectHashCode(object, &hash) != JVMTI_ERROR_NONE) return 0; + return static_cast(static_cast(hash)); +} + +static void monitorBlockEnter(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, OSThreadState state) { + Profiler *profiler = Profiler::instance(); + if (!profiler->taskBlockEnabled() || + !JVMSupport::isPlatformThread(jni, thread)) { + return; + } + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return; + Context context = ContextApi::snapshot(); + if (context.spanId != 0) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + return; + } + + if (!current->monitorEnter(TSC::ticks(), context, + monitorBlockerHash(jvmti, object), state)) { + u64 token = current->monitorBlockToken(); + ThreadFilter *tf = profiler->threadFilter(); + bool current_owner = false; + if (token != 0) { + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(token); + BlockRunSnapshot snapshot = tf->snapshotBlockedRun(slot_id); + current_owner = current->filterSlotId() == slot_id && snapshot.active && + snapshot.owner == BlockRunOwner::JVMTI && + snapshot.generation == ThreadFilter::tokenGeneration(token); + } + if (current_owner) { + return; + } + current->clearMonitorBlock(); + if (!current->monitorEnter(TSC::ticks(), context, + monitorBlockerHash(jvmti, object), state)) { + return; + } + } + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + if (slot_id < 0) { + slot_id = tf->slotIdByTid(current->tid()); + if (slot_id >= 0) current->setFilterSlotId(slot_id); + } + if (!tf->allThreads() || slot_id < 0) { + current->clearMonitorBlock(); + return; + } + u64 token = + tf->enterBlockedRun(slot_id, state, BlockRunOwner::JVMTI); + if (token == 0) { + ThreadFilter::Slot *slot = tf->slotForId(slot_id); + if (slot != nullptr && slot->inContextWindow()) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + } + current->clearMonitorBlock(); + return; + } + current->setMonitorBlockToken(token); +} + +static void monitorBlockExit(JNIEnv *jni, jthread thread, OSThreadState state) { + if (!JVMSupport::isPlatformThread(jni, thread)) return; + ProfiledThread *current = ProfiledThread::current(); + if (current == nullptr) return; + + u64 start_ticks = 0; + Context context{}; + u64 blocker = 0; + u64 token = 0; + if (!current->monitorExit(state, start_ticks, context, blocker, token) || + token == 0) { + return; + } + + Profiler *profiler = Profiler::instance(); + bool recording_enabled = profiler->taskBlockEnabled(); + bool activity = profiler->tryEnterTaskBlockActivity(); + if (!activity) profiler->waitForTaskBlockRotation(); + + ThreadFilter *tf = profiler->threadFilter(); + ThreadFilter::SlotID slot_id = ThreadFilter::tokenSlotId(token); + ThreadFilter::SlotID current_slot = current->filterSlotId(); + if (current_slot < 0) current_slot = tf->slotIdByTid(current->tid()); + BlockRunSnapshot snapshot{}; + bool exited = current_slot == slot_id && + tf->snapshotAndExitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token), &snapshot); + + if (!activity) { + Counters::increment(TASK_BLOCK_DROPPED_ROTATION); + return; + } + if (recording_enabled && exited && snapshot.context_eligible) { + recordTaskBlockIfEligible(current->tid(), thread, 0, start_ticks, + TSC::ticks(), context, blocker, 0, + snapshot.active_state, true); + } else if (recording_enabled && exited && !snapshot.context_eligible) { + Counters::increment(TASK_BLOCK_SKIPPED_TRACE_CONTEXT); + } + profiler->leaveTaskBlockActivity(); +} + +static void JNICALL MonitorContendedEnter(jvmtiEnv *jvmti, JNIEnv *jni, + jthread thread, jobject object) { + monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::MONITOR_WAIT); +} + +static void JNICALL MonitorContendedEntered(jvmtiEnv *jvmti, JNIEnv *jni, + jthread thread, jobject object) { + monitorBlockExit(jni, thread, OSThreadState::MONITOR_WAIT); +} + +static void JNICALL MonitorWait(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, jlong timeout) { + if (!VM::monitorEventsDelegated()) { + monitorBlockEnter(jvmti, jni, thread, object, OSThreadState::OBJECT_WAIT); + } +} + +static void JNICALL MonitorWaited(jvmtiEnv *jvmti, JNIEnv *jni, jthread thread, + jobject object, jboolean timed_out) { + if (!VM::monitorEventsDelegated()) { + monitorBlockExit(jni, thread, OSThreadState::OBJECT_WAIT); + } +} + static bool isVmRuntimeEntry(const char* blob_name) { return strcmp(blob_name, "_ZNK12MemAllocator8allocateEv") == 0 || strncmp(blob_name, "_Z22post_allocation_notify", 26) == 0 @@ -437,7 +575,8 @@ bool VM::initializeRequestStackTrace() { return false; } -bool VM::initProfilerBridge(JavaVM *vm, bool attach) { +bool VM::initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorEvents) { TEST_LOG("VM::initProfilerBridge"); if (!initShared(vm)) { return false; @@ -467,6 +606,8 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { _can_intercept_binding = potential_capabilities.can_generate_native_method_bind_events && HeapUsage::needsNativeBindingInterception(); + bool can_add_monitor_events = + potential_capabilities.can_generate_monitor_events; jvmtiCapabilities capabilities = {0}; capabilities.can_generate_all_class_hook_events = 1; @@ -483,11 +624,18 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { capabilities.can_get_source_file_name = 1; capabilities.can_get_line_numbers = 1; capabilities.can_generate_compiled_method_load_events = 1; - capabilities.can_generate_monitor_events = 1; + capabilities.can_generate_monitor_events = can_add_monitor_events ? 1 : 0; capabilities.can_tag_objects = 1; _jvmti->AddCapabilities(&capabilities); + jvmtiCapabilities actual_capabilities = {0}; + _jvmti->GetCapabilities(&actual_capabilities); + _native_monitor_events_available = + actual_capabilities.can_generate_monitor_events; + _monitor_events_delegated = + delegateMonitorEvents && _native_monitor_events_available; + if (_hotspot) { probeJFRRequestStackTrace(); } @@ -504,6 +652,12 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { callbacks.SampledObjectAlloc = ObjectSampler::SampledObjectAlloc; callbacks.GarbageCollectionFinish = LivenessTracker::GarbageCollectionFinish; callbacks.NativeMethodBind = VMStructs::NativeMethodBind; + if (_native_monitor_events_available) { + callbacks.MonitorContendedEnter = MonitorContendedEnter; + callbacks.MonitorContendedEntered = MonitorContendedEntered; + callbacks.MonitorWait = MonitorWait; + callbacks.MonitorWaited = MonitorWaited; + } _jvmti->SetEventCallbacks(&callbacks, sizeof(callbacks)); _jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_VM_DEATH, NULL); @@ -556,6 +710,37 @@ bool VM::initProfilerBridge(JavaVM *vm, bool attach) { return true; } +bool VM::setNativeMonitorEventsEnabled(bool enabled) { + if (!_native_monitor_events_available) return false; + + jvmtiEventMode mode = enabled ? JVMTI_ENABLE : JVMTI_DISABLE; + jvmtiError enter = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_CONTENDED_ENTER, NULL); + jvmtiError entered = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_CONTENDED_ENTERED, NULL); + jvmtiError wait = JVMTI_ERROR_NONE; + jvmtiError waited = JVMTI_ERROR_NONE; + // When Java instrumentation owns Object.wait, do not enable the native wait + // notifications at all. Disable still addresses all four events so teardown + // is complete even if ownership was configured before this initialization. + if (!enabled || !_monitor_events_delegated) { + wait = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_WAIT, NULL); + waited = _jvmti->SetEventNotificationMode( + mode, JVMTI_EVENT_MONITOR_WAITED, NULL); + } + + if (enter == JVMTI_ERROR_NONE && entered == JVMTI_ERROR_NONE && + wait == JVMTI_ERROR_NONE && waited == JVMTI_ERROR_NONE) { + return true; + } + + Log::warn("Unable to %s JVMTI monitor events: %d/%d/%d/%d", + enabled ? "enable" : "disable", enter, entered, wait, waited); + if (enabled) setNativeMonitorEventsEnabled(false); + return false; +} + // Run late initialization when JVM is ready void VM::ready(jvmtiEnv *jvmti, JNIEnv *jni) { Profiler::check_JDK_8313796_workaround(); diff --git a/ddprof-lib/src/main/cpp/vmEntry.h b/ddprof-lib/src/main/cpp/vmEntry.h index d97fa6af3a..593981ab3a 100644 --- a/ddprof-lib/src/main/cpp/vmEntry.h +++ b/ddprof-lib/src/main/cpp/vmEntry.h @@ -147,6 +147,8 @@ class VM { static bool _zing; static bool _can_sample_objects; static bool _can_intercept_binding; + static bool _monitor_events_delegated; + static bool _native_monitor_events_available; static bool _is_adaptive_gc_boundary_flag_set; // HotSpot JFR async stack-trace extension (optional, JDK 27+). @@ -177,7 +179,8 @@ class VM { static JVM_GetManagement _getManagement; static bool initLibrary(JavaVM *vm); - static bool initProfilerBridge(JavaVM *vm, bool attach); + static bool initProfilerBridge(JavaVM *vm, bool attach, + bool delegateMonitorEvents = false); static jvmtiEnv *jvmti() { return _jvmti; } @@ -212,6 +215,13 @@ class VM { static bool canSampleObjects() { return _can_sample_objects; } + static bool monitorEventsDelegated() { return _monitor_events_delegated; } + + static bool nativeMonitorEventsAvailable() { + return _native_monitor_events_available; + } + static bool setNativeMonitorEventsEnabled(bool enabled); + static bool isZing() { return _zing; } static bool isUseAdaptiveGCBoundarySet() { diff --git a/ddprof-lib/src/main/cpp/wallClock.cpp b/ddprof-lib/src/main/cpp/wallClock.cpp index c29365dfcf..9fb9e870b0 100644 --- a/ddprof-lib/src/main/cpp/wallClock.cpp +++ b/ddprof-lib/src/main/cpp/wallClock.cpp @@ -33,7 +33,8 @@ static inline bool isPrecheckSuppressionState(OSThreadState state) { return state == OSThreadState::SLEEPING || state == OSThreadState::CONDVAR_WAIT || state == OSThreadState::OBJECT_WAIT || - state == OSThreadState::MONITOR_WAIT; + state == OSThreadState::MONITOR_WAIT || + state == OSThreadState::IO_WAIT; } static inline u64 loadSpanId(OtelThreadContextRecord* record) { @@ -59,8 +60,6 @@ static inline bool hasKnownActiveTraceContext(ProfiledThread* thread) { struct WallPrecheckResult { bool suppress = false; - ThreadFilter::Slot* slot_to_arm = nullptr; - OSThreadState state_to_arm = OSThreadState::UNKNOWN; OSThreadState observed_state = OSThreadState::UNKNOWN; bool observed_state_valid = false; ThreadFilter::Slot* unowned_weight_slot = nullptr; @@ -71,21 +70,14 @@ struct WallPrecheckResult { OSThreadState flush_state = OSThreadState::UNKNOWN; }; -static inline void incrementSuppressedSampledRun() { - Counters::increment(WC_SIGNAL_SUPPRESSED_SAMPLED_RUN); - WallClockCounters::incrementSuppressedSampledRun(); +static inline void incrementSuppressedOwnedBlock() { + Counters::increment(WC_SIGNAL_SUPPRESSED_OWNED_BLOCK); + WallClockCounters::incrementSuppressedOwnedBlock(); } -static inline bool suppressAlreadySampledBlock(ThreadFilter::Slot* slot) { - if (slot == nullptr) { - return false; - } - OSThreadState block_state = slot->activeBlockState(); - if (slot->activeBlockOwner() != BlockRunOwner::NONE && - isPrecheckSuppressionState(block_state) && - slot->sampledThisRun() && - block_state == slot->lastSampledState()) { - incrementSuppressedSampledRun(); +static inline bool suppressOwnedBlock(const ThreadEntry& entry) { + if (Profiler::instance()->threadFilter()->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); return true; } return false; @@ -98,29 +90,28 @@ static inline WallPrecheckResult prepareWallPrecheck(ProfiledThread* current, return result; } - ThreadFilter::Slot* slot = - Profiler::instance()->threadFilter()->slotForId(current->filterSlotId()); + ThreadFilter* thread_filter = Profiler::instance()->threadFilter(); + ThreadFilter::SlotID slot_id = current->filterSlotId(); + ThreadFilter::Slot* slot = thread_filter->slotForId(slot_id); + if (slot == nullptr) { + slot_id = thread_filter->slotIdByTid(current->tid()); + slot = thread_filter->slotForId(slot_id); + } if (slot == nullptr) { 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); - 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; + // TaskBlock replaces signals only for threads that all-thread wall-clock + // profiling observes outside the tracing context window. Context-scoped + // profiling must continue sampling its selected threads normally. + if (!thread_filter->allThreads() || slot->inContextWindow()) { + return result; + } + + ThreadEntry entry{current->tid(), slot, slot->lifecycleGeneration()}; + if (thread_filter->isOwnedBlockSuppressionCandidate(entry)) { + incrementSuppressedOwnedBlock(); + result.suppress = true; return result; } @@ -154,9 +145,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, @@ -311,6 +299,7 @@ Error BaseWallClock::start(Arguments &args) { _reservoir_size = args._wall_threads_per_tick ? args._wall_threads_per_tick : DEFAULT_WALL_THREADS_PER_TICK; + _all_threads = args.wallScopeAllThreads(); initialize(args); @@ -353,7 +342,7 @@ void WallClockASGCT::timerLoop() { auto collectThreads = [&](std::vector& entries) { // Get thread IDs from the filter if it's enabled // Otherwise list all threads in the system - if (Profiler::instance()->threadFilter()->enabled()) { + if (!_all_threads && Profiler::instance()->threadFilter()->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { const int refresher_tid = Libraries::instance()->refresherTid(); @@ -365,20 +354,26 @@ void WallClockASGCT::timerLoop() { // enough; we also want to avoid the kill() round-trip and any // pending-signal accumulation). if (tid != OS::threadId() && tid != refresher_tid) { - entries.push_back({tid, nullptr}); // no-filter: precheck fast path is skipped (null guards) + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->lookupByTid(tid); + entries.push_back({tid, slot, + slot == nullptr ? 0 : slot->lifecycleGeneration()}); } } delete thread_list; } + if (_precheck) { + entries.erase(std::remove_if(entries.begin(), entries.end(), + suppressOwnedBlock), + entries.end()); + } }; auto sampleThreads = [&](ThreadEntry entry, int& num_failures, int& threads_already_exited, int& permission_denied) { - // Timer-thread fast path (wallprecheck=true): skip the kernel IPI entirely - // only when an explicit lifecycle hook still owns an already-sampled blocked - // run. Raw OS thread state is intentionally not used here because the timer - // thread cannot prove run boundaries for the target thread. - if (_precheck && suppressAlreadySampledBlock(entry.slot)) { + // Skip the signal while an explicit lifecycle hook owns a suppressible + // blocked interval. Raw OS thread state cannot prove interval boundaries. + if (_precheck && suppressOwnedBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { @@ -479,8 +474,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); @@ -499,7 +494,7 @@ void WallClockJvmti::initialize(Arguments &args) { void WallClockJvmti::timerLoop() { auto collectThreads = [&](std::vector &entries) { const int refresher_tid = Libraries::instance()->refresherTid(); - if (Profiler::instance()->threadFilter()->enabled()) { + if (!_all_threads && Profiler::instance()->threadFilter()->enabled()) { Profiler::instance()->threadFilter()->collect(entries); } else { ThreadList *thread_list = OS::listThreads(); @@ -508,16 +503,24 @@ void WallClockJvmti::timerLoop() { // Exclude the wallclock timer thread itself and the Libraries // refresher (profiler-internal). if (tid != OS::threadId() && tid != refresher_tid) { - entries.push_back({tid, nullptr}); + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->lookupByTid(tid); + entries.push_back({tid, slot, + slot == nullptr ? 0 : slot->lifecycleGeneration()}); } } delete thread_list; } + if (_precheck) { + entries.erase(std::remove_if(entries.begin(), entries.end(), + suppressOwnedBlock), + entries.end()); + } }; auto sampleThreads = [&](ThreadEntry entry, int &num_failures, int &threads_already_exited, int &permission_denied) { - if (_precheck && suppressAlreadySampledBlock(entry.slot)) { + if (_precheck && suppressOwnedBlock(entry)) { return false; } if (!OS::sendSignalWithCookie(entry.tid, SIGVTALRM, SignalCookie::wallclock())) { diff --git a/ddprof-lib/src/main/cpp/wallClock.h b/ddprof-lib/src/main/cpp/wallClock.h index 14e3f88aa3..57adcad531 100644 --- a/ddprof-lib/src/main/cpp/wallClock.h +++ b/ddprof-lib/src/main/cpp/wallClock.h @@ -30,6 +30,7 @@ class BaseWallClock : public Engine { // limit low enough helps to avoid contention on a spin lock inside // Profiler::recordSample(). int _reservoir_size; + bool _all_threads; pthread_t _thread; virtual void timerLoop() = 0; @@ -94,7 +95,7 @@ class BaseWallClock : public Engine { epoch.updateNumSamplableThreads(threads.size()); epoch.updateNumFailedSamples(num_failures); epoch.updateNumSuccessfulSamples(num_successful_samples); - epoch.addNumSuppressedSampledRun(WallClockCounters::drainSuppressedSampledRun()); + epoch.addNumSuppressedOwnedBlock(WallClockCounters::drainSuppressedOwnedBlock()); epoch.updateNumExitedThreads(threads_already_exited); epoch.updateNumPermissionDenied(permission_denied); u64 endTime = TSC::ticks(); @@ -123,6 +124,7 @@ class BaseWallClock : public Engine { _running(false), _interval(LONG_MAX), _reservoir_size(0), + _all_threads(false), _thread(0) {} virtual ~BaseWallClock() = default; diff --git a/ddprof-lib/src/main/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 8f647ba418..ba35e806b4 100644 --- a/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java +++ b/ddprof-lib/src/main/java/com/datadoghq/profiler/JavaProfiler.java @@ -106,6 +106,25 @@ public static JavaProfiler getInstance(String scratchDir) throws IOException { * @param scratchDir directory where the bundled library will be exploded before linking; ignored when 'libLocation' is {@literal null} */ public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir) throws IOException { + return getInstance(libLocation, scratchDir, false); + } + + /** + * Get a {@linkplain JavaProfiler} instance with explicit monitor-event ownership. + * + *

The first successful initialization fixes this process-wide setting because the native + * profiler is a singleton. When delegation is enabled, Java instrumentation owns + * {@code Object.wait} TaskBlock intervals and native JVMTI wait callbacks are suppressed; + * native JVMTI callbacks continue to own synchronized monitor contention. + * + * @param libLocation the path to the native library to use, or {@literal null} for the bundled library + * @param scratchDir directory where the bundled library will be exploded before linking + * @param delegateMonitorWaitEvents whether Java instrumentation owns {@code Object.wait} intervals + * @return the process-wide profiler instance + * @throws IOException if the native library cannot be loaded + */ + public static synchronized JavaProfiler getInstance(String libLocation, String scratchDir, + boolean delegateMonitorWaitEvents) throws IOException { if (instance != null) { return instance; } @@ -115,7 +134,7 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s if (!result.succeeded) { throw new IOException("Failed to load Datadog Java profiler library", result.error); } - init0(); + init0(delegateMonitorWaitEvents); instance = profiler; @@ -131,6 +150,16 @@ public static synchronized JavaProfiler getInstance(String libLocation, String s return profiler; } + /** + * Reports whether Java instrumentation, rather than JVMTI callbacks, owns + * {@code Object.wait} TaskBlock intervals. + * + * @return {@code true} when native wait callbacks are delegated + */ + public boolean isMonitorEventsDelegated() { + return monitorEventsDelegated0(); + } + /** * Stop profiling (without dumping results) * @@ -205,16 +234,21 @@ public boolean recordTraceRoot(long rootSpanId, String endpoint, int sizeLimit) } /** - * Add the given thread to the set of profiled threads. - * 'filter' option must be enabled to use this method. + * Marks the current thread as inside the profiling context window. + * + *

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

Repeated calls are idempotent. With {@code wallscope=all}, leaving the window + * does not remove the thread from ordinary wall-clock profiling. */ public void removeThread() { filterThreadRemove0(); @@ -500,38 +534,66 @@ 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(); + parkEnter0(Thread.currentThread()); } /** * 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); + parkExit0(Thread.currentThread(), blocker, 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 * @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); + } + + /** + * 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); } /** @@ -568,7 +630,7 @@ private static ThreadContext initializeThreadContext() { return new ThreadContext(buffer, metadata); } - private static native boolean init0(); + private static native boolean init0(boolean delegateMonitorWaitEvents); private native void stop0() throws IllegalStateException; private native String execute0(String command) throws IllegalArgumentException, IllegalStateException, IOException; @@ -576,6 +638,7 @@ private static ThreadContext initializeThreadContext() { private static native void filterThreadRemove0(); private static native int getTid0(); + private static native boolean monitorEventsDelegated0(); private static native boolean recordTrace0(long rootSpanId, String endpoint, String operation, int sizeLimit); @@ -589,13 +652,18 @@ private static ThreadContext initializeThreadContext() { private static native void recordQueueEnd0(long startTicks, long endTicks, String task, String scheduler, Thread origin, String queueType, int queueLength); - private static native void parkEnter0(); + private static native void parkEnter0(Thread thread); + + private static native void parkExit0(Thread thread, long blocker, long unblockingSpanId); + + private static native long blockEnter0(Thread thread, int state); - private static native void parkExit0(long blocker, long unblockingSpanId); + private static native void blockExit0(Thread thread, long token); - private static native long blockEnter0(int state); + private static native long beginTaskBlock0(Thread thread, int state); - private static native void blockExit0(long token); + private static native boolean endTaskBlock0(Thread thread, long token, long blocker, + long unblockingSpanId); private static native long currentTicks0(); 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/frame_ut.cpp b/ddprof-lib/src/test/cpp/frame_ut.cpp index 951db75fb8..83779a8021 100644 --- a/ddprof-lib/src/test/cpp/frame_ut.cpp +++ b/ddprof-lib/src/test/cpp/frame_ut.cpp @@ -4,7 +4,9 @@ #include #include +#include #include "../../main/cpp/frame.h" +#include "../../main/cpp/frames.h" #include "../../main/cpp/gtest_crash_handler.h" // Test-only friend accessor for VM internals. It exists solely so these unit @@ -44,6 +46,26 @@ class GlobalSetup { static GlobalSetup global_setup; +TEST(CopyJvmtiFramesTest, PreservesOverlappingSourceFields) { + union { + ASGCT_CallFrame asgct[2]; + jvmtiFrameInfo jvmti[2]; + } buffer; + jmethodID first_method = + reinterpret_cast(static_cast(0x12340)); + jmethodID second_method = + reinterpret_cast(static_cast(0x56780)); + buffer.jvmti[0] = {first_method, 17}; + buffer.jvmti[1] = {second_method, 29}; + + copyJvmtiFrames(buffer.asgct, buffer.jvmti, 2); + + EXPECT_EQ(buffer.asgct[0].method_id, first_method); + EXPECT_EQ(buffer.asgct[0].bci, 17); + EXPECT_EQ(buffer.asgct[1].method_id, second_method); + EXPECT_EQ(buffer.asgct[1].bci, 29); +} + // ---- encode ---------------------------------------------------------------- TEST(FrameTypeEncodeTest, EncodedMarkerBitIsSet) { diff --git a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp index 3a1af6dade..20b2fe00e0 100644 --- a/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp +++ b/ddprof-lib/src/test/cpp/jvmSupport_ut.cpp @@ -37,6 +37,83 @@ class JvmSupportGlobalSetup { }; static JvmSupportGlobalSetup jvm_support_global_setup; +class JvmSupportThreadClassificationTest : public ::testing::Test { +protected: + using JniFunction = void (JNICALL*)(); + + static constexpr int GET_VERSION_INDEX = 4; + static constexpr int IS_VIRTUAL_THREAD_INDEX = 234; + static constexpr int FUNCTION_TABLE_SIZE = IS_VIRTUAL_THREAD_INDEX + 1; + + inline static jint jni_version; + inline static jboolean virtual_thread; + inline static int is_virtual_thread_calls; + inline static jobject last_thread; + + JniFunction function_table[FUNCTION_TABLE_SIZE]{}; + JNIEnv jni{}; + _jobject thread_object; + jthread thread = &thread_object; + + static jint JNICALL getVersion(JNIEnv*) { return jni_version; } + + static jboolean JNICALL isVirtualThread(JNIEnv*, jobject candidate) { + is_virtual_thread_calls++; + last_thread = candidate; + return virtual_thread; + } + + void SetUp() override { + jni_version = 0x00150000; + virtual_thread = JNI_FALSE; + is_virtual_thread_calls = 0; + last_thread = nullptr; + function_table[GET_VERSION_INDEX] = + reinterpret_cast(&getVersion); + function_table[IS_VIRTUAL_THREAD_INDEX] = + reinterpret_cast(&isVirtualThread); + jni.functions = + reinterpret_cast(function_table); + } +}; + +TEST_F(JvmSupportThreadClassificationTest, NullInputsFailClosed) { + EXPECT_FALSE(JVMSupport::isPlatformThread(nullptr, thread)); + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, nullptr)); +} + +TEST_F(JvmSupportThreadClassificationTest, InvalidJniVersionFailsClosed) { + jni_version = 0; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportThreadClassificationTest, PreJni21ThreadIsPlatform) { + jni_version = 0x000a0000; + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni21PlatformThreadIsAccepted) { + EXPECT_TRUE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, Jni21VirtualThreadIsRejected) { + virtual_thread = JNI_TRUE; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(1, is_virtual_thread_calls); + EXPECT_EQ(thread, last_thread); +} + +TEST_F(JvmSupportThreadClassificationTest, MissingJni21FunctionFailsClosed) { + function_table[IS_VIRTUAL_THREAD_INDEX] = nullptr; + EXPECT_FALSE(JVMSupport::isPlatformThread(&jni, thread)); + EXPECT_EQ(0, is_virtual_thread_calls); +} + // --------------------------------------------------------------------------- // VMTestAccessor — friend of VM, lets tests swap VM::_jvmti for a mock so // JVMThread::currentThreadSlow() can be exercised without a live JVM. diff --git a/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp new file mode 100644 index 0000000000..d175a4b212 --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeBlock_ut.cpp @@ -0,0 +1,338 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include + +#if defined(__linux__) + +#include "context_api.h" +#include "counters.h" +#include "nativeBlock.h" +#include "profiler.h" +#include "threadLocalData.h" +#include "tsc.h" + +#include +#include +#include + +namespace { + +std::atomic g_record_calls{0}; +int g_record_tid = -1; +jthread g_record_thread = reinterpret_cast(1); +int g_record_start_depth = -1; +TaskBlockEvent g_record_event{}; + +Profiler::TaskBlockRecordResult recordTaskBlockSuccessForTest( + int tid, jthread thread, int start_depth, TaskBlockEvent* event) { + g_record_tid = tid; + g_record_thread = thread; + g_record_start_depth = start_depth; + g_record_event = *event; + g_record_calls.fetch_add(1, std::memory_order_relaxed); + return Profiler::TaskBlockRecordResult::RECORDED; +} + +class ScopedTaskBlockEnabled { +public: + explicit ScopedTaskBlockEnabled(bool enabled) + : _saved(Profiler::instance()->setTaskBlockEnabledForTest(enabled)) {} + ~ScopedTaskBlockEnabled() { + Profiler::instance()->setTaskBlockEnabledForTest(_saved); + } + +private: + bool _saved; +}; + +class CurrentThreadScope { +public: + CurrentThreadScope() { + ProfiledThread::initCurrentThread(); + _thread = ProfiledThread::current(); + _thread->clearContextForTest(); + _thread->setFilterSlotId(-1); + _thread->setJavaThread(false); + } + ~CurrentThreadScope() { + if (_thread != nullptr) { + _thread->clearContextForTest(); + } + ProfiledThread::release(); + } + + ProfiledThread* thread() const { return _thread; } + + void releaseOwnership() { _thread = nullptr; } + +private: + ProfiledThread* _thread; +}; + +class DetachedCurrentThread { +public: + explicit DetachedCurrentThread(CurrentThreadScope& current) + : _thread(ProfiledThread::clearCurrentThreadTLS()) { + current.releaseOwnership(); + } + ~DetachedCurrentThread() { + if (_thread != nullptr) { + ProfiledThread::deleteForTest(_thread); + } + } + +private: + ProfiledThread* _thread; +}; + +class NativeBlockScopeTest : public ::testing::Test { +protected: + void SetUp() override { + Counters::reset(); + Profiler::setTaskBlockRecordOverrideForTest(recordTaskBlockSuccessForTest); + g_record_calls = 0; + g_record_tid = -1; + g_record_thread = reinterpret_cast(1); + g_record_start_depth = -1; + g_record_event = {}; + Profiler::instance()->threadFilter()->init("enabled"); + Profiler::instance()->threadFilter()->clearActive(); + } + + void TearDown() override { + if (ProfiledThread::currentSignalSafe() != nullptr) { + ProfiledThread::release(); + } + Profiler::setTaskBlockRecordOverrideForTest(nullptr); + Profiler::instance()->setTaskBlockEnabledForTest(false); + Profiler::instance()->threadFilter()->clearActive(); + Counters::reset(); + } + + int registerCurrentJavaThread(ProfiledThread* thread) { + ThreadFilter* filter = Profiler::instance()->threadFilter(); + int slot_id = filter->registerThread(); + EXPECT_GE(slot_id, 0); + thread->setJavaThread(true); + thread->setFilterSlotId(slot_id); + filter->add(thread->tid(), slot_id); + return slot_id; + } +}; + +u64 eligibleEndTicks(u64 start_ticks) { + return start_ticks + (TSC::frequency() / 1000) + 1; +} + +} // namespace + +TEST_F(NativeBlockScopeTest, DisabledTaskBlockGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(false); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, NullCurrentThreadGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + DetachedCurrentThread detached(current); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, NonJavaThreadGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + current.thread()->setJavaThread(false); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, MissingSlotGateLeavesScopeInactiveAndPreservesErrno) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + current.thread()->setJavaThread(true); + current.thread()->setFilterSlotId(-1); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeBlockScopeTest, AllThreadRegistryWorksWithoutLegacyContextFilter) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + filter->init(nullptr, true); + int slot_id = filter->registerThread(current.thread()->tid()); + ASSERT_GE(slot_id, 0); + current.thread()->setJavaThread(true); + current.thread()->setFilterSlotId(slot_id); + ASSERT_FALSE(filter->enabled()); + ASSERT_TRUE(filter->registryActive()); + + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_TRUE(scope.active()); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); +} + +TEST_F(NativeBlockScopeTest, TraceContextGateLeavesScopeInactiveAndSlotUnowned) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + current.thread()->setContextForTest(0x1234, 0x5678); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); + ThreadFilter::Slot* slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, EnterBlockedRunFailureLeavesExistingOwnerIntact) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + u64 token = filter->enterBlockedRun(slot_id, OSThreadState::CONDVAR_WAIT, + BlockRunOwner::JVMTI); + ASSERT_NE(0ULL, token); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17); + + EXPECT_FALSE(scope.active()); + EXPECT_EQ(E2BIG, errno); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::JVMTI, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::CONDVAR_WAIT, slot->activeBlockState()); + EXPECT_TRUE(filter->exitBlockedRun(slot_id, ThreadFilter::tokenGeneration(token))); +} + +TEST_F(NativeBlockScopeTest, ActiveScopeExitsSlotAndRecordsSynchronousIoWaitEvent) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + + errno = E2BIG; + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + ThreadFilter::Slot* active_slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, active_slot); + EXPECT_EQ(E2BIG, errno); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + + ThreadFilter::Slot* slot = Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + EXPECT_EQ(1, g_record_calls.load(std::memory_order_relaxed)); + EXPECT_EQ(current.thread()->tid(), g_record_tid); + EXPECT_EQ(nullptr, g_record_thread); + EXPECT_EQ(0, g_record_start_depth); + EXPECT_EQ(OSThreadState::IO_WAIT, g_record_event._observedBlockingState); + EXPECT_EQ(NativeBlockScope::blocker(NativeBlockKind::STREAM_SOCKET, 17), + g_record_event._blocker); +} + +TEST_F(NativeBlockScopeTest, FinishAfterTaskBlockDisableExitsWithoutRecording) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + + NativeBlockScope scope(NativeBlockKind::CONNECT, 19, OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + Profiler::instance()->setTaskBlockEnabledForTest(false); + scope.finishForTest(eligibleEndTicks(scope.startTicksForTest())); + + EXPECT_EQ(0, g_record_calls.load(std::memory_order_relaxed)); + ThreadFilter::Slot* slot = + Profiler::instance()->threadFilter()->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, FinishSkipsAndLeavesSlotOwnedWhenFilterDisabledBeforeExit) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + + { + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + filter->init(nullptr); + } + + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(BlockRunOwner::NATIVE, slot->activeBlockOwner()); + EXPECT_EQ(OSThreadState::IO_WAIT, slot->activeBlockState()); +} + +TEST_F(NativeBlockScopeTest, ConcurrentScopeLifecyclePreservesSlotOwnership) { + CurrentThreadScope current; + ScopedTaskBlockEnabled task_block_enabled(true); + int slot_id = registerCurrentJavaThread(current.thread()); + ThreadFilter* filter = Profiler::instance()->threadFilter(); + std::atomic stop{false}; + std::atomic failures{0}; + + std::thread observer([&]() { + while (!stop.load(std::memory_order_acquire)) { + BlockRunSnapshot snapshot = filter->snapshotBlockedRun(slot_id); + if (snapshot.active && snapshot.owner != BlockRunOwner::NATIVE) { + failures.fetch_add(1, std::memory_order_relaxed); + } + std::this_thread::yield(); + } + }); + + for (int i = 0; i < 1000; i++) { + { + NativeBlockScope scope(NativeBlockKind::STREAM_SOCKET, 17, + OSThreadState::IO_WAIT); + ASSERT_TRUE(scope.active()); + } + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_EQ(BlockRunOwner::NONE, slot->activeBlockOwner()); + ASSERT_EQ(OSThreadState::UNKNOWN, slot->activeBlockState()); + } + + stop.store(true, std::memory_order_release); + observer.join(); + EXPECT_EQ(0, failures.load(std::memory_order_relaxed)); +} + +#endif // __linux__ diff --git a/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp new file mode 100644 index 0000000000..0466a229cc --- /dev/null +++ b/ddprof-lib/src/test/cpp/nativeSocketInterposer_ut.cpp @@ -0,0 +1,1748 @@ +/* + * 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 + +namespace { + +static const int kFdTypeCacheSizeForTest = 65536; +static const int kHighFdCacheSizeForTest = 4096; + +std::atomic g_send_calls{0}; +std::atomic g_sampler_send_calls{0}; +std::atomic g_recv_calls{0}; +std::atomic g_write_calls{0}; +std::atomic g_sampler_write_calls{0}; +std::atomic g_read_calls{0}; +std::atomic g_close_calls{0}; +std::atomic g_connect_calls{0}; +std::atomic g_accept_calls{0}; +std::atomic g_accept4_calls{0}; +std::atomic g_recvfrom_calls{0}; +std::atomic g_recvmsg_calls{0}; +std::atomic g_epoll_wait_calls{0}; +std::atomic g_epoll_pwait_calls{0}; +std::atomic g_poll_calls{0}; +std::atomic g_ppoll_calls{0}; +std::atomic g_select_calls{0}; +std::atomic g_pselect_calls{0}; +std::atomic g_fd_probe_calls{0}; +std::atomic g_fd_probe_rc{0}; +std::atomic g_fd_probe_errno{0}; +std::atomic g_fd_probe_so_type{0}; +std::atomic g_fd_probe_last_fd{0}; +std::atomic g_sequence{0}; +std::atomic g_raw_syscall_sequence{0}; +std::atomic g_taskblock_enter_sequence{0}; +std::atomic g_taskblock_exit_sequence{0}; +std::atomic g_sampler_record_sequence{0}; +std::atomic g_send_ret{0}; +std::atomic g_sampler_send_ret{0}; +std::atomic g_recv_ret{0}; +std::atomic g_write_ret{0}; +std::atomic g_sampler_write_ret{0}; +std::atomic g_read_ret{0}; +std::atomic g_close_ret{0}; +std::atomic g_close_errno{0}; +std::atomic g_connect_ret{0}; +std::atomic g_accept_ret{0}; +std::atomic g_accept4_ret{0}; +std::atomic g_recvfrom_ret{0}; +std::atomic g_recvmsg_ret{0}; +std::atomic g_epoll_wait_ret{0}; +std::atomic g_epoll_pwait_ret{0}; +std::atomic g_poll_ret{0}; +std::atomic g_ppoll_ret{0}; +std::atomic g_select_ret{0}; +std::atomic g_pselect_ret{0}; + +ssize_t stub_send(int, const void*, size_t, int) { + g_send_calls++; + return g_send_ret.load(); +} + +ssize_t sampler_stub_send(int, const void*, size_t, int) { + g_sampler_send_calls++; + return g_sampler_send_ret.load(); +} + +int stub_fd_probe(int, int *so_type, int *probe_errno) { + g_fd_probe_calls++; + *so_type = g_fd_probe_so_type.load(std::memory_order_acquire); + *probe_errno = g_fd_probe_errno.load(std::memory_order_acquire); + return g_fd_probe_rc.load(std::memory_order_acquire); +} + +int recording_fd_probe(int fd, int *so_type, int *probe_errno) { + g_fd_probe_last_fd.store(fd, std::memory_order_release); + return stub_fd_probe(fd, so_type, probe_errno); +} + +class ScopedFdProbeOverride { +public: + explicit ScopedFdProbeOverride(NativeFdClassifier::ProbeOverride probe) { + NativeFdClassifier::setProbeOverrideForTest(probe); + } + ~ScopedFdProbeOverride() { + NativeFdClassifier::setProbeOverrideForTest(nullptr); + } +}; + +class ScopedTaskBlockEnabled { +public: + explicit ScopedTaskBlockEnabled(bool enabled) + : _saved(Profiler::instance()->setTaskBlockEnabledForTest(enabled)) {} + ~ScopedTaskBlockEnabled() { + Profiler::instance()->setTaskBlockEnabledForTest(_saved); + } + +private: + bool _saved; +}; + +class ScopedNativeSocketInterposerActive { +public: + explicit ScopedNativeSocketInterposerActive(bool active) + : _saved(NativeSocketInterposer::instance()->setActiveForTest(active)) {} + ~ScopedNativeSocketInterposerActive() { + NativeSocketInterposer::instance()->setActiveForTest(_saved); + } + +private: + bool _saved; +}; + +class ScopedNativeSocketSamplerActive { +public: + explicit ScopedNativeSocketSamplerActive(bool active) + : _saved(NativeSocketSampler::setActiveForTest(active)) {} + ~ScopedNativeSocketSamplerActive() { + NativeSocketSampler::setActiveForTest(_saved); + } + +private: + bool _saved; +}; + +ssize_t stub_recv(int, void*, size_t, int) { + g_recv_calls++; + return g_recv_ret.load(); +} + +ssize_t stub_write(int, const void*, size_t) { + g_write_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_write_ret.load(); +} + +ssize_t sampler_stub_write(int, const void*, size_t) { + g_sampler_write_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_sampler_write_ret.load(); +} + +ssize_t stub_read(int, void*, size_t) { + g_read_calls++; + g_raw_syscall_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + return g_read_ret.load(); +} + +void native_block_observer(const char* phase, NativeBlockKind, int) { + int sequence = g_sequence.fetch_add(1) + 1; + if (strcmp(phase, "enter") == 0) { + g_taskblock_enter_sequence.store(sequence, std::memory_order_release); + } else if (strcmp(phase, "exit") == 0) { + g_taskblock_exit_sequence.store(sequence, std::memory_order_release); + } +} + +void native_socket_sampler_observer(const char* phase, int, u8, ssize_t) { + if (strcmp(phase, "record") == 0) { + g_sampler_record_sequence.store(g_sequence.fetch_add(1) + 1, + std::memory_order_release); + } +} + +int stub_close(int) { + g_close_calls++; + errno = g_close_errno.load(); + return g_close_ret.load(); +} + +int stub_connect(int, const struct sockaddr*, socklen_t) { + g_connect_calls++; + return g_connect_ret.load(); +} + +int stub_accept(int, struct sockaddr*, socklen_t*) { + g_accept_calls++; + return g_accept_ret.load(); +} + +int stub_accept4(int, struct sockaddr*, socklen_t*, int) { + g_accept4_calls++; + return g_accept4_ret.load(); +} + +ssize_t stub_recvfrom(int, void*, size_t, int, struct sockaddr*, socklen_t*) { + g_recvfrom_calls++; + return g_recvfrom_ret.load(); +} + +ssize_t stub_recvmsg(int, struct msghdr*, int) { + g_recvmsg_calls++; + return g_recvmsg_ret.load(); +} + +int stub_epoll_wait(int, struct epoll_event*, int, int) { + g_epoll_wait_calls++; + return g_epoll_wait_ret.load(); +} + +int stub_epoll_pwait(int, struct epoll_event*, int, int, const sigset_t*) { + g_epoll_pwait_calls++; + return g_epoll_pwait_ret.load(); +} + +int stub_poll(struct pollfd*, nfds_t, int) { + g_poll_calls++; + return g_poll_ret.load(); +} + +int stub_ppoll(struct pollfd*, nfds_t, const struct timespec*, const sigset_t*) { + g_ppoll_calls++; + return g_ppoll_ret.load(); +} + +int stub_select(int, fd_set*, fd_set*, fd_set*, struct timeval*) { + g_select_calls++; + return g_select_ret.load(); +} + +int stub_pselect(int, fd_set*, fd_set*, fd_set*, const struct timespec*, + const sigset_t*) { + g_pselect_calls++; + return g_pselect_ret.load(); +} + +void setOriginalFunction(NativeSocketInterposer::NativeIoHookIndex hook, void* fn) { + ASSERT_TRUE(NativeSocketInterposer::setOriginalFunction(hook, fn)); +} + +class NativeSocketInterposerHookTest : public ::testing::Test { +protected: + NativeSocketInterposer::send_fn saved_send = nullptr; + NativeSocketInterposer::recv_fn saved_recv = nullptr; + NativeSocketInterposer::write_fn saved_write = nullptr; + NativeSocketInterposer::read_fn saved_read = nullptr; + NativeSocketSampler::send_fn saved_sampler_send = nullptr; + NativeSocketSampler::recv_fn saved_sampler_recv = nullptr; + NativeSocketSampler::write_fn saved_sampler_write = nullptr; + NativeSocketSampler::read_fn saved_sampler_read = nullptr; + bool saved_active = false; + + void SetUp() override { + NativeSocketInterposer::getOriginalFunctions(saved_send, saved_recv, saved_write, + saved_read); + NativeSocketSampler::getOriginalFunctions(saved_sampler_send, saved_sampler_recv, + saved_sampler_write, saved_sampler_read); + NativeSocketInterposer::setOriginalFunctions(stub_send, stub_recv, stub_write, + stub_read); + NativeSocketSampler::setOriginalFunctions(sampler_stub_send, stub_recv, + sampler_stub_write, stub_read); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(stub_close)); + setOriginalFunction(NativeSocketInterposer::HOOK_CONNECT, + reinterpret_cast(stub_connect)); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT, + reinterpret_cast(stub_accept)); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT4, + reinterpret_cast(stub_accept4)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVFROM, + reinterpret_cast(stub_recvfrom)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVMSG, + reinterpret_cast(stub_recvmsg)); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_WAIT, + reinterpret_cast(stub_epoll_wait)); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_PWAIT, + reinterpret_cast(stub_epoll_pwait)); + setOriginalFunction(NativeSocketInterposer::HOOK_POLL, + reinterpret_cast(stub_poll)); + setOriginalFunction(NativeSocketInterposer::HOOK_PPOLL, + reinterpret_cast(stub_ppoll)); + setOriginalFunction(NativeSocketInterposer::HOOK_SELECT, + reinterpret_cast(stub_select)); + setOriginalFunction(NativeSocketInterposer::HOOK_PSELECT, + reinterpret_cast(stub_pselect)); + saved_active = LibraryPatcher::_socket_active.load(std::memory_order_acquire); + LibraryPatcher::_socket_active.store(false, std::memory_order_release); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + NativeSocketSampler::resetSocketProbeCountForTest(); + NativeBlockScope::setHookObserverForTest(nullptr); + NativeSocketSampler::setHookObserverForTest(nullptr); + g_send_calls = 0; + g_sampler_send_calls = 0; + g_recv_calls = 0; + g_write_calls = 0; + g_sampler_write_calls = 0; + g_read_calls = 0; + g_close_calls = 0; + g_connect_calls = 0; + g_accept_calls = 0; + g_accept4_calls = 0; + g_recvfrom_calls = 0; + g_recvmsg_calls = 0; + g_epoll_wait_calls = 0; + g_epoll_pwait_calls = 0; + g_poll_calls = 0; + g_ppoll_calls = 0; + g_select_calls = 0; + g_pselect_calls = 0; + g_fd_probe_calls = 0; + g_sequence = 0; + g_raw_syscall_sequence = 0; + g_taskblock_enter_sequence = 0; + g_taskblock_exit_sequence = 0; + g_sampler_record_sequence = 0; + g_send_ret = 0; + g_sampler_send_ret = 0; + g_recv_ret = 0; + g_write_ret = 0; + g_sampler_write_ret = 0; + g_read_ret = 0; + g_close_ret = 0; + g_close_errno = 0; + g_connect_ret = 0; + g_accept_ret = 0; + g_accept4_ret = 0; + g_recvfrom_ret = 0; + g_recvmsg_ret = 0; + g_epoll_wait_ret = 0; + g_epoll_pwait_ret = 0; + g_poll_ret = 0; + g_ppoll_ret = 0; + g_select_ret = 0; + g_pselect_ret = 0; + } + + void TearDown() override { + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); + NativeSocketInterposer::setOriginalFunctions(saved_send, saved_recv, saved_write, + saved_read); + NativeSocketSampler::setOriginalFunctions(saved_sampler_send, saved_sampler_recv, + saved_sampler_write, saved_sampler_read); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_CONNECT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_ACCEPT4, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVFROM, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_RECVMSG, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_WAIT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_EPOLL_PWAIT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_POLL, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_PPOLL, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_SELECT, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_PSELECT, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + NativeSocketSampler::resetSocketProbeCountForTest(); + NativeBlockScope::setHookObserverForTest(nullptr); + NativeSocketSampler::setHookObserverForTest(nullptr); + } +}; + +class NativeSocketInterposerFdTest : public ::testing::Test { +protected: + void SetUp() override { + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, + reinterpret_cast(::close)); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP2, + reinterpret_cast(::dup2)); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP3, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + } + + void TearDown() override { + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP2, nullptr); + setOriginalFunction(NativeSocketInterposer::HOOK_DUP3, nullptr); + NativeSocketInterposer::instance()->clearFdTypeCache(); + NativeSocketSampler::instance()->clearFdCache(); + } + + int closeThroughHook(int fd) { + return NativeSocketInterposer::close_hook(fd); + } + + int dup2ThroughHook(int oldfd, int newfd) { + return NativeSocketInterposer::dup2_hook(oldfd, newfd); + } + + int dup3ThroughHook(int oldfd, int newfd, int flags) { + return NativeSocketInterposer::dup3_hook(oldfd, newfd, flags); + } + + int datagramSocketAtFd(int target_fd) { + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0 || fd == target_fd) { + return fd; + } + + int ret = ::dup2(fd, target_fd); + int saved_errno = errno; + ::close(fd); + errno = saved_errno; + return ret; + } +}; + +class LibraryPatcherImportTest : public ::testing::Test { +protected: + void SetUp() override { + LibraryPatcher::unpatch_socket_functions(); + } + + void TearDown() override { + LibraryPatcher::unpatch_socket_functions(); + cache.reset(); + } + + void initializeImports(size_t count) { + imports[0] = reinterpret_cast(stub_read); + imports[1] = reinterpret_cast(stub_write); + imports[2] = reinterpret_cast(stub_recv); + cache = std::make_unique("import-test", -1, + NO_MIN_ADDRESS, NO_MAX_ADDRESS, + nullptr, true); + for (size_t index = 0; index < count; index++) { + cache->addImport(&imports[index], "read"); + } + } + + std::unique_ptr cache; + void* imports[3] = {}; +}; + +} // namespace + +TEST_F(NativeSocketInterposerHookTest, InactiveHookForwardsWithoutChangingErrno) { + g_send_ret = 13; + char buf[8] = {}; + + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::send_hook(0, buf, sizeof(buf), 0); + + EXPECT_EQ(13, ret); + EXPECT_EQ(1, g_send_calls.load()); + EXPECT_EQ(E2BIG, errno); +} + +TEST_F(NativeSocketInterposerHookTest, + OriginalFunctionCanBePublishedWhileHooksReadIt) { + constexpr int iterations = 100000; + std::atomic start{false}; + char buffer[1] = {}; + + std::thread reader([&]() { + while (!start.load(std::memory_order_acquire)) { + } + for (int index = 0; index < iterations; index++) { + NativeSocketInterposer::send_hook(0, buffer, sizeof(buffer), 0); + } + }); + + start.store(true, std::memory_order_release); + for (int index = 0; index < iterations; index++) { + setOriginalFunction( + NativeSocketInterposer::HOOK_SEND, + reinterpret_cast(index % 2 == 0 ? stub_send : sampler_stub_send)); + } + reader.join(); + + EXPECT_EQ(iterations, g_send_calls.load() + g_sampler_send_calls.load()); + setOriginalFunction(NativeSocketInterposer::HOOK_SEND, + reinterpret_cast(stub_send)); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveNonSocketReadPreservesEntryErrno) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + g_read_ret = 7; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::read_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(7, ret); + EXPECT_EQ(1, g_read_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, ActiveStreamSocketWriteForwards) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + g_write_ret = 5; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(5, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamSendUsesSharedRawSyscall) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + g_send_ret = 11; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::send_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(11, ret); + EXPECT_EQ(1, g_send_calls.load()); + EXPECT_EQ(0, g_sampler_send_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamWriteUsesSharedRawSyscall) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + g_write_ret = 11; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(11, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(0, g_sampler_write_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + SamplerOnlyStreamWriteClassifiesThroughSamplerClassifier) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(false); + ScopedNativeSocketSamplerActive sampler_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_sampler_write_ret = 23; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = E2BIG; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(23, ret); + EXPECT_EQ(0, g_write_calls.load()); + EXPECT_EQ(1, g_sampler_write_calls.load()); + EXPECT_EQ(1, g_fd_probe_calls.load()) + << "sampler-only path must classify through its NativeFdClassifier instance"; + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, + CombinedActiveStreamWriteClassifiesOnceAndRecordsAfterTaskBlockScope) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + ScopedNativeSocketInterposerActive interposer_active(true); + ScopedNativeSocketSamplerActive sampler_active(true); + ScopedFdProbeOverride override(recording_fd_probe); + NativeBlockScope::setHookObserverForTest(native_block_observer); + NativeSocketSampler::setHookObserverForTest(native_socket_sampler_observer); + g_fd_probe_calls = 0; + g_fd_probe_rc = 0; + g_fd_probe_errno = 0; + g_fd_probe_so_type = SOCK_STREAM; + g_write_ret = 31; + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(31, ret); + EXPECT_EQ(1, g_write_calls.load()); + EXPECT_EQ(0, g_sampler_write_calls.load()); + EXPECT_EQ(1, g_fd_probe_calls.load()); + EXPECT_LT(0, g_taskblock_enter_sequence.load()); + EXPECT_LT(g_taskblock_enter_sequence.load(), g_raw_syscall_sequence.load()); + EXPECT_LT(g_raw_syscall_sequence.load(), g_taskblock_exit_sequence.load()); + EXPECT_LT(g_taskblock_exit_sequence.load(), g_sampler_record_sequence.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, CloseForwardsAndPreservesErrno) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + g_close_ret = 0; + g_close_errno = E2BIG; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = ERANGE; + int ret = NativeSocketInterposer::close_hook(fds[0]); + + EXPECT_EQ(0, ret); + EXPECT_EQ(1, g_close_calls.load()); + EXPECT_EQ(E2BIG, errno); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamSendOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_SEND, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::send_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_send_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamRecvOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_RECV, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::recv_hook(fds[0], buf, sizeof(buf), 0); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_recv_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamWriteOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_WRITE, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::write_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_write_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerHookTest, NullStreamReadOriginalReturnsEnosys) { + int fds[2]; + ASSERT_EQ(0, socketpair(AF_UNIX, SOCK_STREAM, 0, fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_READ, nullptr); + char buf[8] = {}; + + LibraryPatcher::_socket_active.store(true, std::memory_order_release); + errno = 0; + ssize_t ret = NativeSocketInterposer::read_hook(fds[0], buf, sizeof(buf)); + + EXPECT_EQ(-1, ret); + EXPECT_EQ(ENOSYS, errno); + EXPECT_EQ(0, g_read_calls.load()); + close(fds[0]); + close(fds[1]); +} + +TEST_F(NativeSocketInterposerFdTest, CloseFallsBackToSyscallWhenOriginalIsMissing) { + int fds[2]; + ASSERT_EQ(0, pipe(fds)); + setOriginalFunction(NativeSocketInterposer::HOOK_CLOSE, nullptr); + + errno = E2BIG; + int ret = closeThroughHook(fds[0]); + + EXPECT_EQ(0, ret); + EXPECT_EQ(E2BIG, errno); + errno = 0; + EXPECT_EQ(-1, close(fds[0])); + EXPECT_EQ(EBADF, errno); + close(fds[1]); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchClearsSocketActiveWhenOwnersInactive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + + EXPECT_TRUE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_FALSE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchKeepsSocketActiveWhenSamplerActive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + ScopedNativeSocketSamplerActive sampler_active(true); + + EXPECT_FALSE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_TRUE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, ConditionalUnpatchKeepsSocketActiveWhenInterposerActive) { + bool saved_active = + LibraryPatcher::_socket_active.exchange(true, std::memory_order_acq_rel); + ScopedNativeSocketInterposerActive interposer_active(true); + + EXPECT_FALSE(LibraryPatcher::unpatch_socket_functions_if_inactive()); + EXPECT_TRUE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + + LibraryPatcher::_socket_active.store(saved_active, std::memory_order_release); +} + +TEST(LibraryPatcherSocketStateTest, + RefreshDoesNotPatchAfterSocketHooksBecomeInactive) { + LibraryPatcher::unpatch_socket_functions(); + + EXPECT_FALSE(LibraryPatcher::patch_socket_functions(true)); + EXPECT_FALSE(LibraryPatcher::_socket_active.load(std::memory_order_acquire)); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); + EXPECT_EQ(0, LibraryPatcher::socket_library_count_for_test()); +} + +TEST_F(LibraryPatcherImportTest, PatchesAndRestoresEveryImportLocation) { + initializeImports(3); + void* originals[3] = {imports[0], imports[1], imports[2]}; + void* hook = reinterpret_cast(NativeSocketInterposer::read_hook); + + EXPECT_EQ(3, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(3, LibraryPatcher::socket_patch_count_for_test()); + EXPECT_EQ(hook, imports[0]); + EXPECT_EQ(hook, imports[1]); + EXPECT_EQ(hook, imports[2]); + + EXPECT_EQ(0, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(3, LibraryPatcher::socket_patch_count_for_test()); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(originals[0], imports[0]); + EXPECT_EQ(originals[1], imports[1]); + EXPECT_EQ(originals[2], imports[2]); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); +} + +TEST_F(LibraryPatcherImportTest, PatchesSingleImportLocation) { + initializeImports(1); + void* original = imports[0]; + void* hook = reinterpret_cast(NativeSocketInterposer::read_hook); + + EXPECT_EQ(1, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_read, hook, "read")); + EXPECT_EQ(hook, imports[0]); + EXPECT_EQ(1, LibraryPatcher::socket_patch_count_for_test()); + + LibraryPatcher::unpatch_socket_functions(); + EXPECT_EQ(original, imports[0]); +} + +TEST_F(LibraryPatcherImportTest, MissingImportDoesNotConsumePatchSlot) { + initializeImports(1); + void* hook = reinterpret_cast(NativeSocketInterposer::send_hook); + + EXPECT_EQ(0, LibraryPatcher::patch_socket_import_for_test( + cache.get(), im_send, hook, "send")); + EXPECT_EQ(0, LibraryPatcher::socket_patch_count_for_test()); +} + +TEST(LibraryPatcherDsoLifetimeTest, RetainsPatchedLibraryUntilImportsAreRestored) { + // 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, 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/park_state_ut.cpp b/ddprof-lib/src/test/cpp/park_state_ut.cpp index 69f3792424..c5219c96a3 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,91 @@ TEST(ProfiledThreadParkStateTest, ParkExitReturnsZeroTokenWhenBlockRunWasNotArme EXPECT_EQ(0ULL, park_block_token); } -TEST(WallClockOncePerRunFilterTest, SlotStateTransitions) { +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; - 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 +375,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 4608e11697..91d0db6781 100644 --- a/ddprof-lib/src/test/cpp/threadFilter_ut.cpp +++ b/ddprof-lib/src/test/cpp/threadFilter_ut.cpp @@ -1,5 +1,5 @@ /* - * Copyright 2025 Datadog, Inc + * Copyright 2025, 2026 Datadog, Inc * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ */ #include +#include "counters.h" #include "threadFilter.h" #include "../../main/cpp/gtest_crash_handler.h" #include @@ -484,7 +485,6 @@ TEST_F(ThreadFilterTest, ClearActiveDropsPreviousRecordingMembership) { filter->enterBlockedRun(stale_slot, OSThreadState::SLEEPING); ThreadFilter::Slot *stale = filter->slotForId(stale_slot); ASSERT_NE(nullptr, stale); - stale->markSampledThisRun(OSThreadState::SLEEPING); filter->clearActive(); @@ -493,8 +493,6 @@ TEST_F(ThreadFilterTest, ClearActiveDropsPreviousRecordingMembership) { EXPECT_TRUE(collected_tids.empty()); EXPECT_FALSE(filter->accept(stale_slot)); EXPECT_FALSE(filter->accept(current_slot)); - EXPECT_FALSE(stale->sampledThisRun()); - EXPECT_EQ(OSThreadState::UNKNOWN, stale->lastSampledState()); EXPECT_EQ(OSThreadState::UNKNOWN, stale->activeBlockState()); filter->add(2222, current_slot); @@ -542,13 +540,276 @@ 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, ContextScopeNeverSuppressesOwnedBlock) { + filter->init("0", false); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + filter->add(1234, slot_id); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_NE(0ULL, filter->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT)); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + +TEST_F(ThreadFilterTest, OwnedNativeIoSuppressesBeforeAnyWallSample) { + filter->init(nullptr, true); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + u64 token = filter->enterBlockedRun( + slot_id, OSThreadState::IO_WAIT, BlockRunOwner::NATIVE); + ASSERT_NE(0ULL, token); + + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + EXPECT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + ASSERT_TRUE(filter->exitBlockedRun( + slot_id, ThreadFilter::tokenGeneration(token))); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + +TEST_F(ThreadFilterTest, ContextEpochDisablesOwnedBlockSuppression) { + filter->init(nullptr, true); + int slot_id = filter->registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = filter->slotForId(slot_id); + ASSERT_NE(nullptr, slot); + ASSERT_NE(0ULL, filter->enterBlockedRun( + slot_id, OSThreadState::CONDVAR_WAIT)); + ThreadEntry entry{1234, slot, slot->lifecycleGeneration()}; + ASSERT_TRUE(filter->isOwnedBlockSuppressionCandidate(entry)); + + filter->add(1234, slot_id); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); + filter->remove(slot_id); + EXPECT_FALSE(filter->isOwnedBlockSuppressionCandidate(entry)); +} + +TEST(ThreadRegistryTest, AllThreadScopeSeparatesRegistrationFromContextWindow) { + ThreadFilter registry; + registry.init("0", true); + + EXPECT_TRUE(registry.registryActive()); + EXPECT_TRUE(registry.allThreads()); + EXPECT_FALSE(registry.enabled()); + + int slot_id = registry.registerThread(1234); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + EXPECT_EQ(1234, slot->nativeTid()); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(slot, registry.lookupByTid(1234)); + + std::vector registered; + registry.collectRegistered(registered); + ASSERT_EQ(1u, registered.size()); + EXPECT_EQ(1234, registered[0].tid); + + std::vector context; + registry.collect(context); + EXPECT_TRUE(context.empty()); + + registry.add(1234, slot_id); + registry.collect(context); + ASSERT_EQ(1u, context.size()); + registry.remove(slot_id); + registry.collect(context); + EXPECT_TRUE(context.empty()); + + registry.collectRegistered(registered); + ASSERT_EQ(1u, registered.size()); + EXPECT_EQ(1234, registered[0].tid); +} + +TEST(ThreadRegistryTest, ContextWindowTransitionsAreIdempotent) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(5678); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + u64 initial_epoch = slot->contextWindowEpoch(); + registry.add(5678, slot_id); + EXPECT_TRUE(slot->inContextWindow()); + EXPECT_EQ(initial_epoch + 1, slot->contextWindowEpoch()); + + registry.add(5678, slot_id); + EXPECT_EQ(initial_epoch + 1, slot->contextWindowEpoch()); + + registry.remove(slot_id); + EXPECT_FALSE(slot->inContextWindow()); + EXPECT_EQ(initial_epoch + 2, slot->contextWindowEpoch()); + + registry.remove(slot_id); + EXPECT_EQ(initial_epoch + 2, slot->contextWindowEpoch()); +} + +TEST(ThreadRegistryTest, SlotReuseChangesLifecycleGenerationAndTidMapping) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(1111); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + u64 first_generation = slot->lifecycleGeneration(); + + registry.unregisterThread(slot_id); + EXPECT_EQ(nullptr, registry.lookupByTid(1111)); + + int reused_id = registry.registerThread(2222); + ASSERT_EQ(slot_id, reused_id); + EXPECT_GT(slot->lifecycleGeneration(), first_generation); + EXPECT_EQ(nullptr, registry.lookupByTid(1111)); + EXPECT_EQ(slot, registry.lookupByTid(2222)); +} + +TEST(ThreadRegistryTest, ContextTransitionInvalidatesOwnedRunSuppression) { + ThreadFilter registry; + registry.init("0", true); + int slot_id = registry.registerThread(3333); + ASSERT_GE(slot_id, 0); + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_NE(nullptr, slot); + + u64 token = registry.enterBlockedRun(slot_id, OSThreadState::SLEEPING); + ASSERT_NE(0u, token); + EXPECT_TRUE(slot->activeBlockRemainedOutsideContextWindow()); + + registry.add(3333, slot_id); + registry.remove(slot_id); + EXPECT_FALSE(slot->activeBlockRemainedOutsideContextWindow()); +} + +TEST(ThreadRegistryTest, TidIndexRemainsReusableAcrossLongThreadChurn) { + ThreadFilter registry; + registry.init("0", true); + + for (int tid = 1; tid <= ThreadFilter::kTidIndexSize * 3; ++tid) { + int slot_id = registry.registerThread(tid); + ASSERT_GE(slot_id, 0) << "tid=" << tid; + ThreadFilter::Slot* slot = registry.slotForId(slot_id); + ASSERT_EQ(slot, registry.lookupByTid(tid)); + registry.unregisterThread(slot_id); + ASSERT_EQ(nullptr, registry.lookupByTid(tid)); + } +} + +TEST(ThreadRegistryTest, CapacityExhaustionIsCountedAndDoesNotDisableSampling) { + Counters::reset(); + ThreadFilter registry; + registry.init("0", true); + + for (int tid = 1; tid <= ThreadFilter::kMaxThreads; ++tid) { + ASSERT_GE(registry.registerThread(tid), 0); + } + EXPECT_EQ(-1, registry.registerThread(ThreadFilter::kMaxThreads + 1)); + EXPECT_EQ(1, Counters::getCounter(THREAD_FILTER_CAPACITY_EXHAUSTED)); + EXPECT_TRUE(registry.allThreads()); + EXPECT_FALSE(registry.enabled()); } diff --git a/ddprof-lib/src/test/cpp/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-lib/src/test/cpp/wallprecheck_args_ut.cpp b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp index 5579e354fe..cd3c94fb6a 100644 --- a/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp +++ b/ddprof-lib/src/test/cpp/wallprecheck_args_ut.cpp @@ -56,3 +56,52 @@ TEST(WallPrecheckArgsTest, EnabledWithinLongerArgString) { EXPECT_TRUE(args._wall_precheck); } +TEST(WallPrecheckArgsTest, WallScopeDefaultsToContext) { + Arguments args; + + EXPECT_EQ(WALL_SCOPE_CONTEXT, args._wall_scope); + EXPECT_FALSE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, WallScopeAcceptsContextAndAll) { + Arguments context_args; + Error error = context_args.parse("wallscope=context"); + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_CONTEXT, context_args._wall_scope); + EXPECT_FALSE(context_args.wallScopeAllThreads()); + + Arguments all_args; + error = all_args.parse("wallscope=all"); + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_ALL, all_args._wall_scope); + EXPECT_TRUE(all_args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, ExplicitFilterPreservesContextScope) { + Arguments args; + Error error = args.parse("filter=0"); + + EXPECT_FALSE(error); + EXPECT_EQ(WALL_SCOPE_CONTEXT, args._wall_scope); + EXPECT_FALSE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, ExplicitAllScopeOverridesLegacyFilter) { + Arguments args; + Error error = args.parse("filter=0,wallscope=all"); + + EXPECT_FALSE(error); + EXPECT_TRUE(args.wallScopeAllThreads()); +} + +TEST(WallPrecheckArgsTest, WallScopeRejectsMissingAndUnknownValues) { + Arguments missing_args; + Error error = missing_args.parse("wallscope"); + ASSERT_TRUE(static_cast(error)); + EXPECT_STREQ("wallscope must be 'context' or 'all'", error.message()); + + Arguments unknown_args; + error = unknown_args.parse("wallscope=everything"); + ASSERT_TRUE(static_cast(error)); + EXPECT_STREQ("wallscope must be 'context' or 'all'", error.message()); +} diff --git a/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c b/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c index 4d87f57edf..01e7691d70 100644 --- a/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c +++ b/ddprof-lib/src/test/resources/native-libs/reladyn-lib/reladyn.c @@ -1,13 +1,18 @@ /* * Copyright The async-profiler authors + * Copyright 2026, Datadog, Inc. * SPDX-License-Identifier: Apache-2.0 */ #include #include +#include // Force pthread_setspecific into .rela.dyn with R_X86_64_GLOB_DAT. int (*indirect_pthread_setspecific)(pthread_key_t, const void*); // Force pthread_exit into .rela.dyn with R_X86_64_64. void (*static_pthread_exit)(void*) = pthread_exit; +// Force read into .rela.plt (direct call) and two distinct .rela.dyn slots. +ssize_t (*static_read)(int, void*, size_t) = read; +ssize_t (*static_read_second)(int, void*, size_t) = read; void* thread_function(void* arg) { printf("Thread running\n"); return NULL; @@ -25,4 +30,16 @@ int reladyn() { // Use pthread_exit via the static pointer, forces into .rela.dyn as R_X86_64_64. static_pthread_exit(NULL); return 0; -} \ No newline at end of file +} + +ssize_t reladyn_direct_read(int fd, void* buffer, size_t size) { + return read(fd, buffer, size); +} + +ssize_t reladyn_indirect_read(int fd, void* buffer, size_t size) { + return static_read(fd, buffer, size); +} + +ssize_t reladyn_second_indirect_read(int fd, void* buffer, size_t size) { + return static_read_second(fd, buffer, size); +} diff --git a/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile new file mode 100644 index 0000000000..fca9ddac41 --- /dev/null +++ b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/Makefile @@ -0,0 +1,6 @@ +# Copyright 2026, Datadog, Inc. +# SPDX-License-Identifier: Apache-2.0 + +TARGET_DIR = ../build/test/resources/native-libs/unloadable-io-lib +all: + gcc -fPIC -shared -o $(TARGET_DIR)/libunloadable-io.so unloadable_io.c diff --git a/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c new file mode 100644 index 0000000000..f73e5d00c8 --- /dev/null +++ b/ddprof-lib/src/test/resources/native-libs/unloadable-io-lib/unloadable_io.c @@ -0,0 +1,12 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#include +#include +#include + +ssize_t unloadable_read(int fd, void* buffer, size_t size) { + return read(fd, buffer, size); +} diff --git a/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java new file mode 100644 index 0000000000..5e592bcf14 --- /dev/null +++ b/ddprof-stresstest/src/jmh/java/com/datadoghq/profiler/stresstest/scenarios/throughput/NativeSocketIoBenchmark.java @@ -0,0 +1,318 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.stresstest.scenarios.throughput; + +import com.datadoghq.profiler.JavaProfiler; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.channels.Pipe; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +@State(Scope.Benchmark) +/** Compares representative I/O costs with native TaskBlock interposition disabled and enabled. */ +public class NativeSocketIoBenchmark { + @Param({"none", "wall=1s,wallscope=all", "wall=1s,wallscope=all,wallprecheck=true"}) + public String command; + + private JavaProfiler profiler; + private Path jfr; + private InetAddress loopback; + private ServerSocket serverSocket; + private Socket clientSocket; + private Socket serverSideSocket; + private InputStream clientInput; + private OutputStream clientOutput; + private InputStream serverInput; + private OutputStream serverOutput; + private Path file; + private FileInputStream fileInput; + private ServerSocket connectServerSocket; + private volatile boolean connectAcceptorRunning; + private Thread connectAcceptorThread; + private ServerSocket acceptServerSocket; + private volatile boolean acceptConnectorRunning; + private Thread acceptConnectorThread; + private DatagramSocket udpReceiverSocket; + private DatagramSocket udpSenderSocket; + private byte[] udpReceiveBuffer; + private byte[] udpSendBuffer; + private DatagramPacket udpReceivePacket; + private DatagramPacket udpSendPacket; + private Selector selector; + private Pipe selectorPipe; + private ByteBuffer selectorWriteBuffer; + private ByteBuffer selectorReadBuffer; + private final AtomicReference backgroundError = new AtomicReference<>(); + + @Setup(Level.Trial) + public void setup() throws IOException { + backgroundError.set(null); + if (!"none".equals(command)) { + profiler = JavaProfiler.getInstance(); + jfr = Files.createTempFile("native-socket-io-benchmark", ".jfr"); + profiler.execute("start," + command + ",jfr,file=" + jfr.toAbsolutePath()); + } + + loopback = InetAddress.getLoopbackAddress(); + + serverSocket = new ServerSocket(0, 1, loopback); + clientSocket = new Socket(loopback, serverSocket.getLocalPort()); + serverSideSocket = serverSocket.accept(); + clientSocket.setTcpNoDelay(true); + serverSideSocket.setTcpNoDelay(true); + clientInput = clientSocket.getInputStream(); + clientOutput = clientSocket.getOutputStream(); + serverInput = serverSideSocket.getInputStream(); + serverOutput = serverSideSocket.getOutputStream(); + + file = Files.createTempFile("native-socket-io-benchmark", ".bin"); + byte[] data = new byte[1024 * 1024]; + Files.write(file, data); + fileInput = new FileInputStream(file.toFile()); + + connectServerSocket = new ServerSocket(0, 50, loopback); + connectAcceptorRunning = true; + connectAcceptorThread = new Thread(this::acceptConnectBenchmarkSockets, + "native-io-connect-acceptor"); + connectAcceptorThread.setDaemon(true); + connectAcceptorThread.start(); + + acceptServerSocket = new ServerSocket(0, 50, loopback); + acceptConnectorRunning = true; + acceptConnectorThread = new Thread(this::connectAcceptBenchmarkSockets, + "native-io-accept-connector"); + acceptConnectorThread.setDaemon(true); + acceptConnectorThread.start(); + + udpReceiverSocket = new DatagramSocket(new InetSocketAddress(loopback, 0)); + udpSenderSocket = new DatagramSocket(); + udpReceiveBuffer = new byte[64]; + udpSendBuffer = new byte[]{1}; + udpReceivePacket = new DatagramPacket(udpReceiveBuffer, udpReceiveBuffer.length); + udpSendPacket = new DatagramPacket( + udpSendBuffer, udpSendBuffer.length, loopback, udpReceiverSocket.getLocalPort()); + + selector = Selector.open(); + selectorPipe = Pipe.open(); + selectorPipe.source().configureBlocking(false); + selectorPipe.source().register(selector, SelectionKey.OP_READ); + selectorWriteBuffer = ByteBuffer.allocate(1); + selectorReadBuffer = ByteBuffer.allocate(64); + } + + @TearDown(Level.Trial) + public void tearDown() throws IOException { + connectAcceptorRunning = false; + acceptConnectorRunning = false; + closeQuietly(connectServerSocket); + closeQuietly(acceptServerSocket); + joinQuietly(connectAcceptorThread); + joinQuietly(acceptConnectorThread); + closeQuietly(udpReceiverSocket); + closeQuietly(udpSenderSocket); + closeQuietly(selector); + if (selectorPipe != null) { + closeQuietly(selectorPipe.source()); + closeQuietly(selectorPipe.sink()); + } + closeQuietly(fileInput); + closeQuietly(clientSocket); + closeQuietly(serverSideSocket); + closeQuietly(serverSocket); + if (file != null) { + Files.deleteIfExists(file); + } + if (profiler != null) { + profiler.execute("stop"); + } + if (jfr != null) { + Files.deleteIfExists(jfr); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int socketRoundTrip() throws IOException { + clientOutput.write(1); + clientOutput.flush(); + int value = serverInput.read(); + serverOutput.write(value); + serverOutput.flush(); + return clientInput.read(); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int connectClose() throws IOException { + assertBackgroundHealthy(); + try (Socket socket = new Socket()) { + socket.connect(new InetSocketAddress(loopback, connectServerSocket.getLocalPort())); + return socket.getLocalPort(); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int acceptClose() throws IOException { + assertBackgroundHealthy(); + try (Socket accepted = acceptServerSocket.accept()) { + return accepted.getPort(); + } + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int datagramReceive() throws IOException { + udpSendBuffer[0]++; + udpSenderSocket.send(udpSendPacket); + udpReceivePacket.setLength(udpReceiveBuffer.length); + udpReceiverSocket.receive(udpReceivePacket); + return udpReceivePacket.getLength(); + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int selectorSelect() throws IOException { + selectorWriteBuffer.clear(); + selectorWriteBuffer.put((byte) 1); + selectorWriteBuffer.flip(); + while (selectorWriteBuffer.hasRemaining()) { + selectorPipe.sink().write(selectorWriteBuffer); + } + int selected = selector.select(1_000L); + selector.selectedKeys().clear(); + selectorReadBuffer.clear(); + while (selectorPipe.source().read(selectorReadBuffer) > 0) { + selectorReadBuffer.clear(); + } + return selected; + } + + @Benchmark + @BenchmarkMode(Mode.AverageTime) + @Fork(value = 1, warmups = 1) + @Warmup(iterations = 3) + @Measurement(iterations = 5) + @Threads(1) + @OutputTimeUnit(TimeUnit.NANOSECONDS) + public int regularFileRead() throws IOException { + int value = fileInput.read(); + if (value >= 0) { + return value; + } + fileInput.close(); + fileInput = new FileInputStream(file.toFile()); + return fileInput.read(); + } + + private void acceptConnectBenchmarkSockets() { + while (connectAcceptorRunning) { + try (Socket ignored = connectServerSocket.accept()) { + } catch (IOException e) { + if (connectAcceptorRunning) { + backgroundError.compareAndSet(null, e); + } + } + } + } + + private void connectAcceptBenchmarkSockets() { + while (acceptConnectorRunning) { + try (Socket ignored = new Socket(loopback, acceptServerSocket.getLocalPort())) { + } catch (IOException e) { + if (acceptConnectorRunning) { + backgroundError.compareAndSet(null, e); + } + } + } + } + + private void assertBackgroundHealthy() throws IOException { + Throwable failure = backgroundError.get(); + if (failure == null) { + return; + } + if (failure instanceof IOException) { + throw (IOException) failure; + } + throw new IOException(failure); + } + + private static void closeQuietly(AutoCloseable closeable) { + if (closeable == null) { + return; + } + try { + closeable.close(); + } catch (Exception ignored) { + } + } + + private static void joinQuietly(Thread thread) { + if (thread == null) { + return; + } + try { + thread.join(1_000L); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/ddprof-test-native/src/main/cpp/nativeioblock.c b/ddprof-test-native/src/main/cpp/nativeioblock.c new file mode 100644 index 0000000000..67e2b50a17 --- /dev/null +++ b/ddprof-test-native/src/main/cpp/nativeioblock.c @@ -0,0 +1,403 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +#define _GNU_SOURCE + +#include + +#if defined(__linux__) + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +struct delayed_write_args { + int fd; + int delay_ms; + int close_after_write; +}; + +struct delayed_connect_args { + int port; + int delay_ms; +}; + +enum native_block_kind { + NATIVE_BLOCK_ACCEPT = 3, + NATIVE_BLOCK_POLL = 5, + NATIVE_BLOCK_SELECT = 6, + NATIVE_BLOCK_EPOLL_WAIT = 7 +}; + +static jlong native_blocker(enum native_block_kind kind, int blocker_id) { + return (jlong)(((uint64_t)kind << 32) | (uint32_t)blocker_id); +} + +static void sleep_ms(int delay_ms) { + if (delay_ms <= 0) { + return; + } + struct timespec ts; + ts.tv_sec = delay_ms / 1000; + ts.tv_nsec = (long)(delay_ms % 1000) * 1000000L; + while (nanosleep(&ts, &ts) != 0 && errno == EINTR) { + } +} + +static void throw_io_exception(JNIEnv* env, const char* operation) { + jclass exception_class = (*env)->FindClass(env, "java/io/IOException"); + if (exception_class == NULL) { + return; + } + char message[256]; + snprintf(message, sizeof(message), "%s failed: %s", operation, strerror(errno)); + (*env)->ThrowNew(env, exception_class, message); +} + +static void throw_out_of_memory(JNIEnv* env) { + jclass exception_class = (*env)->FindClass(env, "java/lang/OutOfMemoryError"); + if (exception_class != NULL) { + (*env)->ThrowNew(env, exception_class, "native allocation failed"); + } +} + +static void throw_timeout(JNIEnv* env, const char* operation) { + jclass exception_class = (*env)->FindClass(env, "java/io/IOException"); + if (exception_class != NULL) { + char message[256]; + snprintf(message, sizeof(message), "%s timed out", operation); + (*env)->ThrowNew(env, exception_class, message); + } +} + +static void* delayed_write(void* arg) { + struct delayed_write_args* args = (struct delayed_write_args*)arg; + sleep_ms(args->delay_ms); + uint64_t value = 1; + while (write(args->fd, &value, sizeof(value)) < 0 && errno == EINTR) { + } + if (args->close_after_write) { + close(args->fd); + } + free(args); + return NULL; +} + +static void* delayed_connect(void* arg) { + struct delayed_connect_args* args = (struct delayed_connect_args*)arg; + sleep_ms(args->delay_ms); + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd >= 0) { + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = htons((uint16_t)args->port); + connect(fd, (struct sockaddr*)&addr, sizeof(addr)); + close(fd); + } + free(args); + return NULL; +} + +static int start_delayed_write(JNIEnv* env, int fd, int delay_ms, int close_after_write) { + struct delayed_write_args* args = + (struct delayed_write_args*)malloc(sizeof(struct delayed_write_args)); + if (args == NULL) { + throw_out_of_memory(env); + return -1; + } + args->fd = fd; + args->delay_ms = delay_ms; + args->close_after_write = close_after_write; + pthread_t thread; + int rc = pthread_create(&thread, NULL, delayed_write, args); + if (rc != 0) { + free(args); + errno = rc; + throw_io_exception(env, "pthread_create"); + return -1; + } + pthread_detach(thread); + return 0; +} + +static int start_delayed_connect(JNIEnv* env, int port, int delay_ms) { + struct delayed_connect_args* args = + (struct delayed_connect_args*)malloc(sizeof(struct delayed_connect_args)); + if (args == NULL) { + throw_out_of_memory(env); + return -1; + } + args->port = port; + args->delay_ms = delay_ms; + pthread_t thread; + int rc = pthread_create(&thread, NULL, delayed_connect, args); + if (rc != 0) { + free(args); + errno = rc; + throw_io_exception(env, "pthread_create"); + return -1; + } + pthread_detach(thread); + return 0; +} + +static jlong block_with_poll_like(JNIEnv* env, jint delay_ms, int use_ppoll) { + int fds[2]; + if (pipe(fds) != 0) { + throw_io_exception(env, "pipe"); + return 0; + } + if (start_delayed_write(env, fds[1], delay_ms, 1) != 0) { + close(fds[0]); + close(fds[1]); + return 0; + } + + struct pollfd pfd; + pfd.fd = fds[0]; + pfd.events = POLLIN; + pfd.revents = 0; + int rc; + do { + if (use_ppoll) { + struct timespec timeout_ts = {5, 0}; + rc = ppoll(&pfd, 1, &timeout_ts, NULL); + } else { + rc = poll(&pfd, 1, 5000); + } + } while (rc < 0 && errno == EINTR); + close(fds[0]); + if (rc < 0) { + throw_io_exception(env, use_ppoll ? "ppoll" : "poll"); + return 0; + } else if (rc == 0) { + throw_timeout(env, use_ppoll ? "ppoll" : "poll"); + return 0; + } + return native_blocker(NATIVE_BLOCK_POLL, 0); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPpoll( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_poll_like(env, delay_ms, 1); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPselect( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + int fds[2]; + if (pipe(fds) != 0) { + throw_io_exception(env, "pipe"); + return 0; + } + if (start_delayed_write(env, fds[1], delay_ms, 1) != 0) { + close(fds[0]); + close(fds[1]); + return 0; + } + + int rc; + do { + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(fds[0], &readfds); + struct timespec timeout = {5, 0}; + rc = pselect(fds[0] + 1, &readfds, NULL, NULL, &timeout, NULL); + } while (rc < 0 && errno == EINTR); + close(fds[0]); + if (rc < 0) { + throw_io_exception(env, "pselect"); + return 0; + } else if (rc == 0) { + throw_timeout(env, "pselect"); + return 0; + } + return native_blocker(NATIVE_BLOCK_SELECT, 0); +} + +static jlong block_with_epoll(JNIEnv* env, jint delay_ms, int use_pwait) { + int event_fd = eventfd(0, EFD_CLOEXEC); + if (event_fd < 0) { + throw_io_exception(env, "eventfd"); + return 0; + } + int epfd = epoll_create1(EPOLL_CLOEXEC); + if (epfd < 0) { + close(event_fd); + throw_io_exception(env, "epoll_create1"); + return 0; + } + struct epoll_event event; + memset(&event, 0, sizeof(event)); + event.events = EPOLLIN; + event.data.fd = event_fd; + if (epoll_ctl(epfd, EPOLL_CTL_ADD, event_fd, &event) != 0) { + close(epfd); + close(event_fd); + throw_io_exception(env, "epoll_ctl"); + return 0; + } + if (start_delayed_write(env, event_fd, delay_ms, 0) != 0) { + close(epfd); + close(event_fd); + return 0; + } + + struct epoll_event out_event; + int rc; + do { + if (use_pwait) { + rc = epoll_pwait(epfd, &out_event, 1, 5000, NULL); + } else { + rc = epoll_wait(epfd, &out_event, 1, 5000); + } + } while (rc < 0 && errno == EINTR); + jlong blocker = native_blocker(NATIVE_BLOCK_EPOLL_WAIT, epfd); + close(epfd); + close(event_fd); + if (rc < 0) { + throw_io_exception(env, use_pwait ? "epoll_pwait" : "epoll_wait"); + return 0; + } else if (rc == 0) { + throw_timeout(env, use_pwait ? "epoll_pwait" : "epoll_wait"); + return 0; + } + return blocker; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollWait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_epoll(env, delay_ms, 0); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollPwait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + return block_with_epoll(env, delay_ms, 1); +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingAccept4( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + int server_fd = socket(AF_INET, SOCK_STREAM, 0); + if (server_fd < 0) { + throw_io_exception(env, "socket"); + return 0; + } + int one = 1; + setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one)); + + struct sockaddr_in addr; + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) != 0 || + listen(server_fd, 1) != 0) { + close(server_fd); + throw_io_exception(env, "bind/listen"); + return 0; + } + + socklen_t addr_len = sizeof(addr); + if (getsockname(server_fd, (struct sockaddr*)&addr, &addr_len) != 0) { + close(server_fd); + throw_io_exception(env, "getsockname"); + return 0; + } + if (start_delayed_connect(env, ntohs(addr.sin_port), delay_ms) != 0) { + close(server_fd); + return 0; + } + + jlong blocker = native_blocker(NATIVE_BLOCK_ACCEPT, server_fd); + int accepted_fd = accept4(server_fd, NULL, NULL, SOCK_CLOEXEC); + if (accepted_fd >= 0) { + close(accepted_fd); + } + close(server_fd); + if (accepted_fd < 0) { + throw_io_exception(env, "accept4"); + return 0; + } + return blocker; +} + +#else + +static void unsupported(JNIEnv* env) { + jclass exception_class = (*env)->FindClass(env, "java/lang/UnsupportedOperationException"); + if (exception_class != NULL) { + (*env)->ThrowNew(env, exception_class, "Native I/O block helper is Linux-only"); + } +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingAccept4( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPpoll( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingPselect( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollWait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +JNIEXPORT jlong JNICALL +Java_com_datadoghq_profiler_wallclock_NativeIoBlockHelper_blockingEpollPwait( + JNIEnv* env, jclass clazz, jint delay_ms) { + (void)clazz; + (void)delay_ms; + unsupported(env); + return 0; +} + +#endif diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/JavaProfilerApiSurfaceTest.java index b3052f3a20..54daefb50a 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,35 @@ import java.lang.reflect.Modifier; 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 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())); + } + + @Test + public void monitorWaitOwnershipIsExplicitPublicApi() throws Exception { + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("getInstance", String.class, String.class, boolean.class) + .getModifiers())); + assertTrue(Modifier.isPublic(JavaProfiler.class + .getDeclaredMethod("isMonitorEventsDelegated").getModifiers())); } private static void assertNotPublic(Method method) { assertFalse(Modifier.isPublic(method.getModifiers()), - method.getName() + " 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/AllThreadWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java new file mode 100644 index 0000000000..1dc3b510de --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/AllThreadWallScopeTest.java @@ -0,0 +1,88 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.HashSet; +import java.util.Set; +import org.junitpioneer.jupiter.RetryingTest; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.flightrecorder.jdk.JdkAttributes; + +/** Verifies that all-thread wall scope is independent of context-window membership. */ +public class AllThreadWallScopeTest extends AbstractProfilerTest { + private static final long WORK_MILLIS = 250; + + /** + * Samples threads that never enter, remain inside, and have already left the context window. + * + * @throws Exception if a workload thread cannot complete + */ + @RetryingTest(3) + public void samplesEveryContextWindowState() throws Exception { + runWorker("wall-scope-never-added", WindowAction.NONE); + runWorker("wall-scope-inside-window", WindowAction.ENTER); + runWorker("wall-scope-after-window", WindowAction.ENTER_AND_EXIT); + + stopProfiler(); + Set sampledThreads = sampledThreadNames(verifyEvents("datadog.MethodSample")); + assertTrue(sampledThreads.contains("wall-scope-never-added"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-scope-inside-window"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-scope-after-window"), sampledThreads::toString); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all"; + } + + private void runWorker(String name, WindowAction action) throws Exception { + Thread worker = + new Thread( + () -> { + if (action != WindowAction.NONE) { + profiler.addThread(); + } + if (action == WindowAction.ENTER_AND_EXIT) { + profiler.removeThread(); + } + long deadline = System.nanoTime() + WORK_MILLIS * 1_000_000L; + while (System.nanoTime() < deadline) { + // Deliberately remain runnable so the assertion does not depend on + // idle-thread state classification. + } + if (action == WindowAction.ENTER) { + profiler.removeThread(); + } + }, + name); + worker.start(); + worker.join(); + } + + private static Set sampledThreadNames(IItemCollection events) { + Set names = new HashSet<>(); + for (IItemIterable samples : events) { + IMemberAccessor accessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(samples.getType()); + for (IItem sample : samples) { + names.add(accessor.getMember(sample)); + } + } + return names; + } + + private enum WindowAction { + NONE, + ENTER, + ENTER_AND_EXIT + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java new file mode 100644 index 0000000000..e91dc5009a --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ContextWallScopeTest.java @@ -0,0 +1,71 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.util.HashSet; +import java.util.Set; +import org.junitpioneer.jupiter.RetryingTest; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.flightrecorder.jdk.JdkAttributes; + +/** Verifies the explicit context-filtered wall-clock compatibility mode. */ +public class ContextWallScopeTest extends AbstractProfilerTest { + private static final long WORK_MILLIS = 250; + + /** Verifies that only a worker inside the context window is sampled. */ + @RetryingTest(3) + public void samplesOnlyContextWindowThread() throws Exception { + runWorker("wall-context-outside", false); + runWorker("wall-context-inside", true); + + stopProfiler(); + Set sampledThreads = sampledThreadNames(verifyEvents("datadog.MethodSample")); + assertFalse(sampledThreads.contains("wall-context-outside"), sampledThreads::toString); + assertTrue(sampledThreads.contains("wall-context-inside"), sampledThreads::toString); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=context"; + } + + private void runWorker(String name, boolean inContextWindow) throws Exception { + Thread worker = + new Thread( + () -> { + if (inContextWindow) { + profiler.addThread(); + } + long deadline = System.nanoTime() + WORK_MILLIS * 1_000_000L; + while (System.nanoTime() < deadline) {} + if (inContextWindow) { + profiler.removeThread(); + } + }, + name); + worker.start(); + worker.join(); + } + + private static Set sampledThreadNames(IItemCollection events) { + Set names = new HashSet<>(); + for (IItemIterable samples : events) { + IMemberAccessor accessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(samples.getType()); + for (IItem sample : samples) { + names.add(accessor.getMember(sample)); + } + } + return names; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/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/JvmtiBasedAllThreadWallScopeTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java new file mode 100644 index 0000000000..502c67a0e9 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedAllThreadWallScopeTest.java @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +/** Runs all-thread wall-scope coverage through delegated JVMTI stack collection. */ +public class JvmtiBasedAllThreadWallScopeTest extends AllThreadWallScopeTest { + @Override + protected String getProfilerCommand() { + return super.getProfilerCommand() + ",jvmtistacks=true"; + } +} 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..bc334861fc --- /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,wallscope=all,wallprecheck=true,jvmtistacks=true"; + } +} 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..34876a72ee --- /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,wallscope=all,wallprecheck=true,jvmtistacks=true"; + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedParkTaskBlockTest.java new file mode 100644 index 0000000000..dbcd4d80f6 --- /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,wallscope=all,wallprecheck=true,jvmtistacks=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 5190e7da61..22a2926d18 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/JvmtiBasedPrecheckTest.java @@ -54,11 +54,11 @@ protected void withTestAssumptions() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=true,jvmtistacks=true"; } @Override protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallprecheck=false,filter=0,jvmtistacks=true"; + return "wall=1ms,wallscope=all,wallprecheck=false,jvmtistacks=true"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java new file mode 100644 index 0000000000..f781d0a5c4 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/MonitorTaskBlockTest.java @@ -0,0 +1,240 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import java.lang.reflect.Method; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies TaskBlock production from native JVMTI monitor callbacks. */ +public class MonitorTaskBlockTest extends AbstractProfilerTest { + @Test + public void objectWaitEmitsTaskBlockOutsideContextWindow() throws Exception { + Object monitor = new Object(); + CountDownLatch entered = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + synchronized (monitor) { + entered.countDown(); + monitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-object-wait"); + + worker.start(); + assertTrue(entered.await(5, TimeUnit.SECONDS)); + assertCompleted(worker, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, identityHash(monitor), 0); + TaskBlockAssertions.assertContainsObservedState(events, "WAITING"); + } + + @Test + public void monitorContentionEmitsTaskBlockOutsideContextWindow() throws Exception { + Object monitor = new Object(); + CountDownLatch attempting = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker; + synchronized (monitor) { + worker = new Thread(() -> { + try { + attempting.countDown(); + synchronized (monitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-monitor-contention"); + worker.start(); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + + assertCompleted(worker, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, identityHash(monitor), 0); + TaskBlockAssertions.assertContainsObservedState(events, "CONTENDED"); + } + + @Test + public void contextWindowObjectWaitDoesNotEmitTaskBlock() throws Exception { + Object monitor = new Object(); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + registerCurrentThreadForWallClockProfiling(); + profiler.setContext(0x4400L, 0x4401L, 0L, 0x4401L); + synchronized (monitor) { + monitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } finally { + profiler.clearContext(); + profiler.removeThread(); + } + }, "taskblock-traced-object-wait"); + + worker.start(); + assertCompleted(worker, failure); + stopProfiler(); + + assertFalse(TaskBlockAssertions.containsBlocker( + verifyEvents("datadog.TaskBlock", false), identityHash(monitor))); + } + + @Test + public void staleWaitStateIsRecoveredAfterProfilerRestart() throws Exception { + Object waitMonitor = new Object(); + Object contentionMonitor = new Object(); + CountDownLatch waiting = new CountDownLatch(1); + CountDownLatch waitCompleted = new CountDownLatch(1); + CountDownLatch restartReady = new CountDownLatch(1); + CountDownLatch attemptingContention = new CountDownLatch(1); + AtomicReference failure = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + synchronized (waitMonitor) { + waiting.countDown(); + waitMonitor.wait(); + } + waitCompleted.countDown(); + assertTrue(restartReady.await(5, TimeUnit.SECONDS)); + attemptingContention.countDown(); + synchronized (contentionMonitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }, "taskblock-monitor-restart"); + + worker.start(); + assertTrue(waiting.await(5, TimeUnit.SECONDS)); + Thread.sleep(50); + stopProfiler(); + synchronized (waitMonitor) { + waitMonitor.notifyAll(); + } + assertTrue(waitCompleted.await(5, TimeUnit.SECONDS)); + + Path recording = Files.createTempFile("MonitorTaskBlockTest-restart-", ".jfr"); + boolean restarted = false; + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + + recording.toAbsolutePath()); + restarted = true; + synchronized (contentionMonitor) { + restartReady.countDown(); + assertTrue(attemptingContention.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(worker, failure); + profiler.stop(); + restarted = false; + + IItemCollection events = verifyEvents(recording, "datadog.TaskBlock", false); + assertTaskBlockStackReference(events); + assertTrue(TaskBlockAssertions.containsBlocker( + events, identityHash(contentionMonitor))); + } finally { + restartReady.countDown(); + synchronized (waitMonitor) { + waitMonitor.notifyAll(); + } + if (restarted) profiler.stop(); + worker.join(5_000); + Files.deleteIfExists(recording); + } + } + + @Test + public void virtualMonitorCallbacksDoNotEmitCarrierTaskBlocks() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + Object waitMonitor = new Object(); + AtomicReference failure = new AtomicReference<>(); + Thread waiter = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + try { + synchronized (waitMonitor) { + waitMonitor.wait(100); + } + } catch (Throwable t) { + failure.set(t); + } + }); + assertCompleted(waiter, failure); + + Object contentionMonitor = new Object(); + CountDownLatch attempting = new CountDownLatch(1); + Thread contender; + synchronized (contentionMonitor) { + contender = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + try { + attempting.countDown(); + synchronized (contentionMonitor) { + } + } catch (Throwable t) { + failure.set(t); + } + }); + assertTrue(attempting.await(5, TimeUnit.SECONDS)); + Thread.sleep(100); + } + assertCompleted(contender, failure); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock", false); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(waitMonitor))); + assertFalse(TaskBlockAssertions.containsBlocker(events, identityHash(contentionMonitor))); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(IItemCollection events) { + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "MonitorTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + } + + private static void assertCompleted(Thread thread, AtomicReference failure) + throws InterruptedException { + thread.join(5_000); + assertFalse(thread.isAlive(), "worker did not complete"); + if (failure.get() != null) throw new AssertionError(failure.get()); + } + + private static long identityHash(Object object) { + return Integer.toUnsignedLong(System.identityHashCode(object)); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java new file mode 100644 index 0000000000..f40853df8d --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeIoBlockHelper.java @@ -0,0 +1,30 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +/** JNI workloads that block in Linux APIs not exposed directly by the Java standard library. */ +final class NativeIoBlockHelper { + static { + System.loadLibrary("ddproftest"); + } + + /** Blocks in {@code accept4} and returns the expected native blocker encoding. */ + static native long blockingAccept4(int delayMillis); + + /** Blocks in {@code ppoll} and returns the expected native blocker encoding. */ + static native long blockingPpoll(int delayMillis); + + /** Blocks in {@code pselect} and returns the expected native blocker encoding. */ + static native long blockingPselect(int delayMillis); + + /** Blocks in {@code epoll_wait} and returns the expected native blocker encoding. */ + static native long blockingEpollWait(int delayMillis); + + /** Blocks in {@code epoll_pwait} and returns the expected native blocker encoding. */ + static native long blockingEpollPwait(int delayMillis); + + private NativeIoBlockHelper() {} +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java new file mode 100644 index 0000000000..8a0a2d70cc --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockLifecycleTest.java @@ -0,0 +1,257 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies native I/O hooks are fully restored and can be reinstalled across profiler restarts. */ +public class NativeSocketTaskBlockLifecycleTest extends AbstractProfilerTest { + private static final int BLOCK_HOLD_MILLIS = 250; + private static final int NATIVE_BLOCK_ATTEMPTS = 5; + + @BeforeAll + static void preloadNativeHelpers() throws Exception { + if (Platform.isLinux()) { + NativeIoBlockHelper.blockingPpoll(0); + try (ServerSocket server = new ServerSocket(0); + Socket client = new Socket("127.0.0.1", server.getLocalPort()); + Socket accepted = server.accept()) { + // Exercise Java networking before profiler start so the native libraries + // needed by socket reads are loaded before the initial patch pass. + } + } + } + + @Test + public void restartWithWallPrecheckDisabledStopsNativeSocketTaskBlocks() throws Exception { + long enabledBlocker = runNativeIoBlock(); + stopProfiler(); + assertIoWaitTaskBlockPresent(verifyEvents("datadog.TaskBlock", false), enabledBlocker); + + Path disabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_disabled_", ".jfr"); + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=false,jfr,file=" + + disabledRecording.toAbsolutePath()); + runNativeIoBlock(); + profiler.stop(); + + IItemCollection disabledTaskBlocks = + verifyEvents(disabledRecording, "datadog.TaskBlock", false); + assertFalse(disabledTaskBlocks.hasItems(), + "wallprecheck=false restart must disable native socket TaskBlock emission"); + } finally { + Files.deleteIfExists(disabledRecording); + } + + Path reenabledRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_reenabled_", ".jfr"); + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + + reenabledRecording.toAbsolutePath()); + long reenabledBlocker = runNativeIoBlock(); + profiler.stop(); + + assertIoWaitTaskBlockPresent(verifyEvents( + reenabledRecording, "datadog.TaskBlock", false), reenabledBlocker); + } finally { + Files.deleteIfExists(reenabledRecording); + } + } + + @Test + public void stopWhileSocketReadIsBlockedCleansUpAndAllowsReinstall() throws Exception { + String workerName = "taskblock-native-stop-inflight"; + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + ServerSocket server = new ServerSocket(0); + Socket accepted = null; + boolean stopped = false; + + Thread reader = new Thread(() -> { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + input.read(); + } catch (Throwable t) { + error.set(t); + } + }, workerName); + + try { + reader.start(); + accepted = server.accept(); + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), + "reader did not attempt the blocking socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + assertTrue(reader.isAlive(), + "socket read returned before the profiler was stopped"); + + stopProfiler(); + stopped = true; + assertTrue(reader.isAlive(), + "socket read returned before profiler shutdown completed"); + } finally { + if (!stopped) { + stopProfiler(); + } + if (accepted != null) { + accepted.close(); + } + server.close(); + reader.join(5_000L); + } + + assertFalse(reader.isAlive(), "blocked socket reader did not terminate"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + assertFalse(TaskBlockAssertions.containsObservedStateForEventThread( + verifyEvents("datadog.TaskBlock", false), "IO_WAIT", workerName), + "a native I/O operation returning after stop must not emit TaskBlock"); + + Path restartedRecording = Files.createTempFile(Paths.get("/tmp/recordings"), + "NativeSocketTaskBlockLifecycleTest_inflight_restart_", ".jfr"); + boolean restarted = false; + try { + profiler.execute("start,wall=1ms,wallscope=all,wallprecheck=true,jfr,file=" + + restartedRecording.toAbsolutePath()); + restarted = true; + String restartedWorkerName = "taskblock-native-stop-reinstalled"; + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runCompletedSocketRead(restartedWorkerName); + } + profiler.stop(); + restarted = false; + + IItemCollection restartedTaskBlocks = + verifyEvents(restartedRecording, "datadog.TaskBlock", false); + TaskBlockAssertions.assertNoAnchorFields(restartedTaskBlocks); + TaskBlockAssertions.assertContainsStackTrace(restartedTaskBlocks); + TaskBlockAssertions.assertContainsJavaType( + restartedTaskBlocks, "NativeSocketTaskBlockLifecycleTest"); + assertTrue(TaskBlockAssertions.containsObservedStateForEventThread( + restartedTaskBlocks, "IO_WAIT", restartedWorkerName), + "socket TaskBlock hooks were not reinstalled after profiler shutdown"); + } finally { + if (restarted) { + profiler.stop(); + } + Files.deleteIfExists(restartedRecording); + } + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all,wallprecheck=true"; + } + + private void assertIoWaitTaskBlockPresent(IItemCollection taskBlockEvents, long expectedBlocker) { + assertTrue(expectedBlocker != 0L, "native lifecycle helper must report the expected blocker"); + if (!taskBlockEvents.hasItems()) { + assertTrue(false, missingTaskBlockDiagnostic()); + } + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType( + taskBlockEvents, "NativeSocketTaskBlockLifecycleTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + private long runNativeIoBlock() throws InterruptedException { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + blocker.set(NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-lifecycle-helper"); + worker.start(); + worker.join(5_000L); + assertFalse(worker.isAlive(), "native lifecycle helper did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + return blocker.get(); + } + + private void runCompletedSocketRead(String workerName) throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } catch (Throwable t) { + error.set(t); + } + }, workerName); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), + "reader did not attempt the blocking socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + reader.join(5_000L); + assertFalse(reader.isAlive(), "socket reader did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + } + } + + private String missingTaskBlockDiagnostic() { + return "Expected lifecycle native TaskBlock after " + NATIVE_BLOCK_ATTEMPTS + + " blocked interval(s); emitted=" + getRecordedCounterValue("task_block_emitted") + + ", stack_capture_failed=" + + getRecordedCounterValue("task_block_stack_capture_failed") + + ", skipped_too_short=" + getRecordedCounterValue("task_block_skipped_too_short") + + ", skipped_trace_context=" + + getRecordedCounterValue("task_block_skipped_trace_context") + + ", record_failed=" + getRecordedCounterValue("task_block_record_failed"); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java new file mode 100644 index 0000000000..aa9a64b630 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/NativeSocketTaskBlockTest.java @@ -0,0 +1,442 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import java.io.InputStream; +import java.io.OutputStream; +import java.net.DatagramPacket; +import java.net.DatagramSocket; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.ByteBuffer; +import java.nio.channels.Pipe; +import java.nio.channels.SelectionKey; +import java.nio.channels.Selector; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies Linux native socket and readiness waits produce self-contained TaskBlock events. */ +public class NativeSocketTaskBlockTest extends AbstractProfilerTest { + private static final int BLOCK_HOLD_MILLIS = 250; + private static final int NATIVE_BLOCK_ATTEMPTS = 5; + + @BeforeAll + static void preloadNativeHelper() { + if (Platform.isLinux()) { + NativeIoBlockHelper.blockingPpoll(0); + } + } + + @Test + public void blockingSocketReadEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingSocketReadOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingSocketReadOnce() throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try { + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-socket-read"); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), "reader did not enter socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + assertCompleted(reader, error); + } + } + + @Test + public void blockingServerSocketAcceptEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingServerSocketAcceptOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingServerSocketAcceptOnce() throws Exception { + CountDownLatch acceptAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + InetAddress loopback = InetAddress.getLoopbackAddress(); + + try (ServerSocket server = new ServerSocket(0, 1, loopback)) { + Thread accepter = new Thread(() -> { + try { + acceptAttempted.countDown(); + try (Socket accepted = server.accept()) { + assertTrue(accepted.isConnected()); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-socket-accept"); + + accepter.start(); + assertTrue(acceptAttempted.await(5, TimeUnit.SECONDS), "accept did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + try (Socket ignored = new Socket(loopback, server.getLocalPort())) { + assertTrue(ignored.isConnected()); + } + assertCompleted(accepter, error); + } + } + + @Test + public void blockingDatagramReceiveEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingDatagramReceiveOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingDatagramReceiveOnce() throws Exception { + CountDownLatch receiveAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + InetAddress loopback = InetAddress.getLoopbackAddress(); + + try (DatagramSocket receiver = new DatagramSocket(new InetSocketAddress(loopback, 0))) { + Thread receiverThread = new Thread(() -> { + try { + byte[] data = new byte[1]; + DatagramPacket packet = new DatagramPacket(data, data.length); + receiveAttempted.countDown(); + receiver.receive(packet); + assertEquals(1, packet.getLength()); + assertEquals(7, data[0]); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-datagram-receive"); + + receiverThread.start(); + assertTrue(receiveAttempted.await(5, TimeUnit.SECONDS), "receive did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + try (DatagramSocket sender = new DatagramSocket()) { + byte[] data = new byte[]{7}; + DatagramPacket packet = new DatagramPacket( + data, data.length, loopback, receiver.getLocalPort()); + sender.send(packet); + } + assertCompleted(receiverThread, error); + } + } + + @Test + public void blockingSelectorSelectEmitsIoWaitTaskBlock() throws Exception { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + runBlockingSelectorSelectOnce(); + } + + stopProfiler(); + assertIoWaitTaskBlockSelfContained(); + } + + private void runBlockingSelectorSelectOnce() throws Exception { + CountDownLatch selectAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + Pipe pipe = Pipe.open(); + try (Selector selector = Selector.open(); + Pipe.SourceChannel source = pipe.source(); + Pipe.SinkChannel sink = pipe.sink()) { + source.configureBlocking(false); + source.register(selector, SelectionKey.OP_READ); + + Thread selectorThread = new Thread(() -> { + try { + selectAttempted.countDown(); + int selected = selectUntilReady(selector, 5_000L); + assertTrue(selected > 0, "selector did not observe pipe readiness"); + selector.selectedKeys().clear(); + ByteBuffer data = ByteBuffer.allocate(1); + while (data.hasRemaining() && source.read(data) > 0) { + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-selector-select"); + + selectorThread.start(); + assertTrue(selectAttempted.await(5, TimeUnit.SECONDS), "select did not start"); + Thread.sleep(BLOCK_HOLD_MILLIS); + sink.write(ByteBuffer.wrap(new byte[]{1})); + assertCompleted(selectorThread, error); + } + } + + @Test + public void blockingAccept4EmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingAccept4(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingPpollEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void ownedNativeIoSuppressesWallSignalsBeforeCompletion() throws Exception { + long before = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + blocker.set(NativeIoBlockHelper.blockingPpoll(BLOCK_HOLD_MILLIS)); + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-suppression"); + + worker.start(); + waitForCounterAbove("wc_signals_suppressed_owned_block", before, 5_000L); + assertCompleted(worker, error); + + stopProfiler(); + assertNativeIoHelperCompleted(blocker.get()); + } + + @Test + public void blockingPselectEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingPselect(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingEpollWaitEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingEpollWait(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void blockingEpollPwaitEmitsIoWaitTaskBlock() { + long expectedBlocker = + runNativeIoBlockRepeated(() -> NativeIoBlockHelper.blockingEpollPwait(BLOCK_HOLD_MILLIS)); + stopProfiler(); + assertNativeIoHelperCompleted(expectedBlocker); + } + + @Test + public void tracedBlockingSocketReadDoesNotEmitTaskBlock() throws Exception { + CountDownLatch readAttempted = new CountDownLatch(1); + AtomicReference error = new AtomicReference<>(); + + try (ServerSocket server = new ServerSocket(0)) { + Thread reader = new Thread(() -> { + try { + profiler.setContext(0x5100L, 0x5101L, 0L, 0x5101L); + try (Socket socket = new Socket("127.0.0.1", server.getLocalPort())) { + InputStream input = socket.getInputStream(); + readAttempted.countDown(); + int value = input.read(); + if (value != 1) { + throw new AssertionError("unexpected socket byte: " + value); + } + } finally { + profiler.clearContext(); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-traced-native-socket-read"); + + reader.start(); + try (Socket accepted = server.accept()) { + assertTrue(readAttempted.await(5, TimeUnit.SECONDS), "reader did not enter socket read"); + Thread.sleep(BLOCK_HOLD_MILLIS); + OutputStream output = accepted.getOutputStream(); + output.write(1); + output.flush(); + } + assertCompleted(reader, error); + } + + stopProfiler(); + assertFalse( + TaskBlockAssertions.containsSpan(verifyEvents("datadog.TaskBlock", false), 0x5101L), + "Traced socket I/O must keep MethodSample wall-clock data instead of emitting TaskBlock"); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux(); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all,wallprecheck=true"; + } + + private static int selectUntilReady(Selector selector, long timeoutMillis) throws Exception { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + int selected; + do { + long remainingNanos = deadline - System.nanoTime(); + if (remainingNanos <= 0L) { + return 0; + } + selected = selector.select(Math.max(1L, TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + } while (selected == 0); + return selected; + } + + protected void assertIoWaitTaskBlockSelfContained() { + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + } + + protected void assertIoWaitTaskBlockSelfContained(long expectedBlocker) { + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + private void assertNativeIoHelperCompleted(long expectedBlocker) { + assertTrue(expectedBlocker != 0L, "native I/O helper must report the expected blocker"); + IItemCollection taskBlockEvents = verifyEvents("datadog.TaskBlock", false); + assertNativeTaskBlockPresent(taskBlockEvents); + TaskBlockAssertions.assertNoAnchorFields(taskBlockEvents); + assertTaskBlockStackReference(taskBlockEvents); + TaskBlockAssertions.assertContainsObservedState(taskBlockEvents, "IO_WAIT"); + assertTrue(TaskBlockAssertions.containsBlocker(taskBlockEvents, expectedBlocker), + "Expected native blocker " + expectedBlocker); + } + + protected void assertTaskBlockStackReference(IItemCollection taskBlockEvents) { + TaskBlockAssertions.assertContainsStackTrace(taskBlockEvents); + TaskBlockAssertions.assertContainsJavaType(taskBlockEvents, "NativeSocketTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(taskBlockEvents); + } + + private long runNativeIoBlockRepeated(NativeIoBlockWorkload workload) { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread worker = new Thread(() -> { + try { + for (int attempt = 0; attempt < NATIVE_BLOCK_ATTEMPTS; attempt++) { + blocker.set(workload.run()); + } + } catch (Throwable t) { + error.set(t); + } + }, "taskblock-native-helper"); + worker.start(); + try { + worker.join(5_000L); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new AssertionError("interrupted while waiting for native helper", interrupted); + } + assertFalse(worker.isAlive(), "native helper did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + return blocker.get(); + } + + private void assertNativeTaskBlockPresent(IItemCollection taskBlockEvents) { + if (!taskBlockEvents.hasItems()) { + String diagnostic = missingTaskBlockDiagnostic(); + System.out.println(diagnostic); + assertTrue(false, diagnostic); + } + } + + private String missingTaskBlockDiagnostic() { + return "Expected native socket TaskBlock after " + NATIVE_BLOCK_ATTEMPTS + + " blocked interval(s); emitted=" + getRecordedCounterValue("task_block_emitted") + + ", stack_capture_failed=" + + getRecordedCounterValue("task_block_stack_capture_failed") + + ", skipped_too_short=" + getRecordedCounterValue("task_block_skipped_too_short") + + ", skipped_trace_context=" + + getRecordedCounterValue("task_block_skipped_trace_context") + + ", record_failed=" + getRecordedCounterValue("task_block_record_failed"); + } + + private void waitForCounterAbove(String name, long baseline, long timeoutMillis) + throws Exception { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis); + while (System.nanoTime() < deadline) { + if (profiler.getDebugCounters().getOrDefault(name, 0L) > baseline) { + return; + } + Thread.sleep(10L); + } + throw new AssertionError("Counter did not increase: " + name); + } + + @FunctionalInterface + private interface NativeIoBlockWorkload { + long run(); + } + + private static void assertCompleted(Thread thread, AtomicReference error) + throws InterruptedException { + thread.join(5_000L); + assertFalse(thread.isAlive(), thread.getName() + " did not complete"); + if (error.get() != null) { + throw new AssertionError(error.get()); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/ParkTaskBlockTest.java new file mode 100644 index 0000000000..78e8dc4366 --- /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.ProfilerOwnedBlockHooks; +import java.lang.reflect.Method; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.LockSupport; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Assumptions; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Verifies TaskBlock production from Java-owned platform-thread park hooks. */ +public class ParkTaskBlockTest extends AbstractProfilerTest { + private static final long BLOCKER = 0x3102L; + private static final long UNBLOCKING_SPAN_ID = 0x3103L; + + @Test + public void platformParkEmitsTaskBlockOutsideContextWindow() { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertNoAnchorFields(events); + assertTaskBlockStackReference(events); + TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); + TaskBlockAssertions.assertContainsObservedState(events, "PARKED"); + } + + @Test + public void contextWindowParkDoesNotEmitTaskBlock() { + registerCurrentThreadForWallClockProfiling(); + profiler.setContext(0x3100L, 0x3101L, 0L, 0x3101L); + try { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + } finally { + profiler.clearContext(); + profiler.removeThread(); + } + stopProfiler(); + + assertFalse(verifyEvents("datadog.TaskBlock", false).hasItems(), + "A park inside the context window must remain ordinary wall-clock data"); + } + + @Test + public void virtualParkDoesNotMutateCarrierProducerState() throws Exception { + Method startVirtualThread; + try { + startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + } catch (NoSuchMethodException unavailableBeforeJdk21) { + Assumptions.assumeTrue(false, "virtual threads require JDK 21"); + return; + } + + long virtualBlocker = 0x3201L; + Thread virtual = (Thread) startVirtualThread.invoke(null, (Runnable) () -> { + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(20); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, virtualBlocker, 0); + } + }); + virtual.join(5_000); + assertFalse(virtual.isAlive()); + + ProfilerOwnedBlockHooks.parkEnter(profiler); + try { + parkForMillis(200); + } finally { + ProfilerOwnedBlockHooks.parkExit(profiler, BLOCKER, UNBLOCKING_SPAN_ID); + } + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + assertFalse(TaskBlockAssertions.containsBlocker(events, virtualBlocker)); + TaskBlockAssertions.assertContains(events, 0, 0, BLOCKER, UNBLOCKING_SPAN_ID); + } + + @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,wallscope=all,wallprecheck=true"; + } + + protected void assertTaskBlockStackReference(IItemCollection events) { + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "ParkTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + } + + private static void parkForMillis(long millis) { + long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(millis); + long remaining; + while ((remaining = deadline - System.nanoTime()) > 0) { + LockSupport.parkNanos(remaining); + } + } + + 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/PrecheckEfficiencyTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java index 3582cf74d1..5dc3607b50 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckEfficiencyTest.java @@ -22,9 +22,9 @@ /** * Measures the theoretical upper bound on {@code SIGVTALRM} suppression by running with - * {@code wallprecheck=false} and classifying sample states. The once-per-run filter + * {@code wallprecheck=false} and classifying sample states. Lifecycle ownership * ({@code wallprecheck=true}) suppresses {@code SLEEPING}, {@code CONDVAR_WAIT}, and - * {@code OBJECT_WAIT} after the entry sample; {@code RUNNABLE} is not skipped. Monitor + * suppresses {@code OBJECT_WAIT}; {@code RUNNABLE} is not skipped. Monitor * contention ({@code MONITOR_WAIT}) is also suppressible when monitor hooks identify the blocked * interval. */ @@ -43,23 +43,20 @@ public void compareSuppressionRates() throws Exception { AtomicBoolean stop = new AtomicBoolean(false); Object monitor = new Object(); - // SLEEPING / CONDVAR_WAIT — suppressed by once-per-run filter + // SLEEPING / CONDVAR_WAIT — suppressible with lifecycle ownership Thread sleeping = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); try { Thread.sleep(10_000); } catch (InterruptedException ignored) {} }, EFFICIENCY_SLEEPING); - // CONDVAR_WAIT — suppressed by once-per-run filter + // CONDVAR_WAIT — suppressible with lifecycle ownership Thread parked = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); LockSupport.parkNanos(10_000_000_000L); }, EFFICIENCY_PARKED); - // OBJECT_WAIT — suppressed by the once-per-run filter. + // OBJECT_WAIT — suppressible with lifecycle ownership. Thread waiting = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); synchronized (monitor) { try { monitor.wait(10_000); } catch (InterruptedException ignored) {} @@ -68,7 +65,6 @@ public void compareSuppressionRates() throws Exception { // RUNNABLE — not skipped Thread working = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); ready.countDown(); long x = 0; while (!stop.get()) { x++; } @@ -205,10 +201,7 @@ public void realisticServiceWorkload() throws Exception { AtomicInteger threadIndex = new AtomicInteger(0); ExecutorService pool = Executors.newFixedThreadPool(POOL_SIZE, r -> { - Thread t = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); - r.run(); - }); + Thread t = new Thread(r); t.setName("realistic-pool-" + threadIndex.incrementAndGet()); t.setDaemon(true); return t; @@ -222,7 +215,6 @@ public void realisticServiceWorkload() throws Exception { Thread.sleep(50); Thread scheduler = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); while (!stop.get()) { try { Thread.sleep(SCHEDULE_INTERVAL_MS); @@ -241,7 +233,6 @@ public void realisticServiceWorkload() throws Exception { scheduler.start(); Thread hotThread = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); long x = 0; while (!stop.get()) { x++; } }, "realistic-hot"); @@ -264,8 +255,16 @@ public void realisticServiceWorkload() throws Exception { for (IItemIterable batch : events) { IMemberAccessor stackAccessor = JdkAttributes.STACK_TRACE_STRING.getAccessor(batch.getType()); - if (stackAccessor == null) continue; + IMemberAccessor threadNameAccessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(batch.getType()); + if (stackAccessor == null || threadNameAccessor == null) continue; for (IItem item : batch) { + String threadName = threadNameAccessor.getMember(item); + if (threadName == null || (!threadName.startsWith("realistic-pool-") + && !"realistic-scheduler".equals(threadName) + && !"realistic-hot".equals(threadName))) { + continue; + } String stack = stackAccessor.getMember(item); if (stack == null) { otherSamples++; @@ -309,6 +308,8 @@ public void realisticServiceWorkload() throws Exception { @Override protected String getProfilerCommand() { - return "wall=1ms"; + // The workload deliberately has no tracing context, so its samples + // require the explicit all-thread wall-clock scope. + return "wall=1ms,wallscope=all"; } } diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java index 43d840b4a4..188e135785 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/PrecheckTest.java @@ -16,7 +16,6 @@ import org.openjdk.jmc.common.item.IItemCollection; import org.openjdk.jmc.common.item.IItemIterable; import org.openjdk.jmc.common.item.IMemberAccessor; -import org.openjdk.jmc.common.item.Aggregators; import org.openjdk.jmc.common.unit.IQuantity; import org.openjdk.jmc.common.unit.UnitLookup; import org.openjdk.jmc.flightrecorder.jdk.JdkAttributes; @@ -30,8 +29,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; /** - * Verifies once-per-run signal suppression ({@code wallprecheck=true}): a sleeping thread - * should produce a handful of {@code MethodSample} events (entry + boundary jitter), not ~300. + * Verifies lifecycle-owned signal suppression ({@code wallprecheck=true}): a sleeping thread + * should produce at most boundary-race {@code MethodSample} events, not ~300. * Requires JDK 11+ — JDK 8 HotSpot reports inconsistent OSThread states for sleep. */ public class PrecheckTest extends AbstractProfilerTest { @@ -49,7 +48,6 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); leaveClearedInitializedContext(); - registerCurrentThreadForWallClockProfiling(); long token = ProfilerOwnedBlockHooks.blockEnter(profiler, OSTHREAD_STATE_SLEEPING); assertTrue(token != 0, "Expected native blockEnter to arm SLEEPING state"); @@ -61,17 +59,16 @@ public void testSleepingThreadIsNotSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); - // Explicitly owned once-per-run filter: entry signal emits, subsequent signals are - // suppressed until blockExit clears the owned run. + long sampleCount = samplesForThread(Thread.currentThread().getName()); + // Lifecycle ownership suppresses deliberate signals from blockEnter until blockExit. + // A few boundary-race samples remain possible. assertTrue(sampleCount < 10, "Expected nearly no MethodSample events for a sleeping thread with wallprecheck=true, got: " + sampleCount); Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { - assertTrue(counters.get("wc_signals_suppressed_sampled_run") > 0, - "wc_signals_suppressed_sampled_run should be > 0 for a 300 ms Thread.sleep()"); + if (counters.containsKey("wc_signals_suppressed_owned_block")) { + assertTrue(counters.get("wc_signals_suppressed_owned_block") > 0, + "wc_signals_suppressed_owned_block should be > 0 for a 300 ms Thread.sleep()"); } } @@ -80,16 +77,19 @@ public void unownedSleepingThreadIsNotExactOncePerRunSuppressed() throws Excepti Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); leaveClearedInitializedContext(); - registerCurrentThreadForWallClockProfiling(); Thread.sleep(300); stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); - assertTrue(sampleCount >= 10, - "Unowned Thread.sleep must not be exact once-per-run suppressed; got: " + sampleCount); + long sampleCount = samplesForThread(Thread.currentThread().getName()); + assertTrue(sampleCount > 0, + "Unowned Thread.sleep must remain sampled; got: " + sampleCount); + Map counters = profiler.getDebugCounters(); + assertTrue(counters.getOrDefault("wc_unowned_blocked_recorded", 0L) > 0, + "Expected the weighted unowned-block fallback to record samples"); + assertTrue(counters.getOrDefault("wc_unowned_blocked_suppressed", 0L) > 0, + "Expected the weighted unowned-block fallback to suppress intermediate samples"); } @Test @@ -98,7 +98,6 @@ public void unownedSleepingTailWeightIsPreserved() throws Exception { Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); Thread sleeper = new Thread(() -> { - registerCurrentThreadForWallClockProfiling(); try { for (int i = 0; i < TAIL_WEIGHT_ITERATIONS; i++) { Thread.sleep(TAIL_WEIGHT_SLEEP_MILLIS); @@ -120,20 +119,21 @@ 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.setContext(0x5100L, 0x5101L, 0L, 0x5101L); try { Thread.sleep(300); @@ -143,15 +143,16 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { stopProfiler(); - long sampleCount = verifyEvents("datadog.MethodSample", false) - .getAggregate(Aggregators.count()).longValue(); + long sampleCount = samplesForThread(Thread.currentThread().getName()); assertTrue(sampleCount >= 10, "Expected normal MethodSample volume for traced sleep, got: " + sampleCount); - Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { - assertEquals(0L, counters.get("wc_signals_suppressed_sampled_run"), - "wc_signals_suppressed_sampled_run must not increment for traced sleep"); + if (countersBefore.containsKey("wc_signals_suppressed_owned_block")) { + long suppressedBefore = countersBefore.get("wc_signals_suppressed_owned_block"); + long suppressedAfter = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); + assertEquals(suppressedBefore, suppressedAfter, + "wc_signals_suppressed_owned_block must not increment for traced sleep"); } } @@ -159,16 +160,15 @@ public void tracedSleepingThreadIsSampled() throws InterruptedException { public void suppressionCounterIsZeroWhenPrecheckDisabled() throws Exception { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue(Platform.isJavaVersionAtLeast(11)); - registerCurrentThreadForWallClockProfiling(); // Stop the wallprecheck=true recording started by @BeforeEach before starting a new one. stopProfiler(); Map before = profiler.getDebugCounters(); - if (!before.containsKey("wc_signals_suppressed_sampled_run")) { + if (!before.containsKey("wc_signals_suppressed_owned_block")) { return; // counter not available in this build } - long suppressedBefore = before.get("wc_signals_suppressed_sampled_run"); + long suppressedBefore = before.get("wc_signals_suppressed_owned_block"); Path recordingB = Files.createTempFile(Paths.get("/tmp/recordings"), "PrecheckTest_disabled_", ".jfr"); @@ -178,11 +178,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"); } /** @@ -198,11 +198,32 @@ private void leaveClearedInitializedContext() { @Override protected String getProfilerCommand() { - return "wall=1ms,wallprecheck=true"; + // This suite verifies sampling and suppression for threads outside a + // tracing-context window. Keep that population in scope explicitly; + // the production default remains wallscope=context. + return "wall=1ms,wallscope=all,wallprecheck=true"; } protected String getPrecheckDisabledProfilerCommand() { - return "wall=1ms,wallprecheck=false,filter=0"; + return "wall=1ms,wallscope=all,wallprecheck=false"; + } + + private long samplesForThread(String threadName) { + long count = 0; + IItemCollection events = verifyEvents("datadog.MethodSample", false); + for (IItemIterable batch : events) { + IMemberAccessor threadNameAccessor = + JdkAttributes.EVENT_THREAD_NAME.getAccessor(batch.getType()); + if (threadNameAccessor == null) { + continue; + } + for (IItem item : batch) { + if (threadName.equals(threadNameAccessor.getMember(item))) { + count++; + } + } + } + return count; } private WeightedSamples weightedSamplesForThread(String threadName) { 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..e893d62a14 --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/TaskBlockAssertions.java @@ -0,0 +1,194 @@ +/* + * 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.IMCThread; +import org.openjdk.jmc.common.item.IAttribute; +import org.openjdk.jmc.common.item.IItem; +import org.openjdk.jmc.common.item.IItemCollection; +import org.openjdk.jmc.common.item.IItemIterable; +import org.openjdk.jmc.common.item.IMemberAccessor; +import org.openjdk.jmc.common.unit.IQuantity; +import org.openjdk.jmc.flightrecorder.JfrAttributes; + +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openjdk.jmc.common.item.Attribute.attr; +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 boolean containsBlocker(IItemCollection events, long blocker) { + for (IItemIterable iterable : events) { + IMemberAccessor accessor = BLOCKER.getAccessor(iterable.getType()); + if (accessor == null) continue; + for (IItem item : iterable) { + if (accessor.getMember(item).longValue() == blocker) return true; + } + } + return false; + } + + static boolean containsObservedStateForEventThread( + IItemCollection events, String observedState, String threadName) { + for (IItemIterable iterable : events) { + IMemberAccessor stateAccessor = + OBSERVED_BLOCKING_STATE.getAccessor(iterable.getType()); + IMemberAccessor threadAccessor = + JfrAttributes.EVENT_THREAD.getAccessor(iterable.getType()); + if (stateAccessor == null || threadAccessor == null) continue; + for (IItem item : iterable) { + IMCThread thread = threadAccessor.getMember(item); + if (observedState.equals(stateAccessor.getMember(item)) + && thread != null + && threadName.equals(thread.getThreadName())) { + return true; + } + } + } + return false; + } + + static boolean containsSpan(IItemCollection events, long spanId) { + for (IItemIterable iterable : events) { + IMemberAccessor spanAccessor = + AbstractProfilerTest.SPAN_ID.getAccessor(iterable.getType()); + if (spanAccessor == null) continue; + for (IItem item : iterable) { + if (spanAccessor.getMember(item).longValue() == spanId) return true; + } + } + return false; + } + + static void assertBlockerEventThreadDiffers( + IItemCollection events, long blocker, long logicalThreadId) { + int checked = 0; + for (IItemIterable iterable : events) { + IMemberAccessor blockerAccessor = + BLOCKER.getAccessor(iterable.getType()); + IMemberAccessor threadAccessor = + JfrAttributes.EVENT_THREAD.getAccessor(iterable.getType()); + if (blockerAccessor == null || threadAccessor == null) continue; + for (IItem item : iterable) { + if (blockerAccessor.getMember(item).longValue() != blocker) continue; + IMCThread eventThread = threadAccessor.getMember(item); + assertNotNull(eventThread, "TaskBlock eventThread must not be null"); + assertNotEquals(Long.valueOf(logicalThreadId), eventThread.getThreadId(), + "Native TaskBlock must identify the physical carrier, not the virtual thread"); + checked++; + } + } + assertTrue(checked > 0, "Expected TaskBlock eventThread for blocker=" + blocker); + } + + static void assertNoAnchorFields(IItemCollection events) { + for (IItemIterable iterable : events) { + assertNull(ANCHOR_SAMPLE_ID.getAccessor(iterable.getType())); + assertNull(SUPPRESSED_SAMPLE_COUNT.getAccessor(iterable.getType())); + } + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java new file mode 100644 index 0000000000..89181b863b --- /dev/null +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/VirtualNativeIoTaskBlockTest.java @@ -0,0 +1,63 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + */ + +package com.datadoghq.profiler.wallclock; + +import com.datadoghq.profiler.AbstractProfilerTest; +import com.datadoghq.profiler.Platform; +import java.lang.reflect.Method; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.openjdk.jmc.common.item.IItemCollection; + +import static org.junit.jupiter.api.Assertions.assertFalse; + +/** Verifies pinned virtual-thread native I/O is attributed to its physical carrier. */ +public class VirtualNativeIoTaskBlockTest extends AbstractProfilerTest { + @Test + public void nativeBlockOnVirtualThreadRemainsPhysicalCarrierTaskBlock() throws Exception { + AtomicLong blocker = new AtomicLong(); + AtomicReference error = new AtomicReference<>(); + Thread thread = startVirtualThread(() -> { + try { + for (int attempt = 0; attempt < 3; attempt++) { + blocker.set(NativeIoBlockHelper.blockingPpoll(200)); + } + } catch (Throwable t) { + error.set(t); + } + }); + + long logicalThreadId = thread.getId(); + thread.join(5_000L); + assertFalse(thread.isAlive(), "virtual thread did not complete"); + if (error.get() != null) throw new AssertionError(error.get()); + stopProfiler(); + + IItemCollection events = verifyEvents("datadog.TaskBlock"); + TaskBlockAssertions.assertContainsObservedState(events, "IO_WAIT"); + TaskBlockAssertions.assertContainsStackTrace(events); + TaskBlockAssertions.assertContainsJavaType(events, "VirtualNativeIoTaskBlockTest"); + TaskBlockAssertions.assertNoCorrelationId(events); + TaskBlockAssertions.assertBlockerEventThreadDiffers( + events, blocker.get(), logicalThreadId); + } + + @Override + protected boolean isPlatformSupported() { + return Platform.isLinux() && Platform.isJavaVersionAtLeast(21); + } + + @Override + protected String getProfilerCommand() { + return "wall=1ms,wallscope=all,wallprecheck=true"; + } + + private static Thread startVirtualThread(Runnable task) throws Exception { + Method startVirtualThread = Thread.class.getMethod("startVirtualThread", Runnable.class); + return (Thread) startVirtualThread.invoke(null, task); + } +} diff --git a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java index a2caa1b8a5..bbaf518b5a 100644 --- a/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java +++ b/ddprof-test/src/test/java/com/datadoghq/profiler/wallclock/WallclockMitigationsCombinedTest.java @@ -5,6 +5,7 @@ package com.datadoghq.profiler.wallclock; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertTrue; import com.datadoghq.profiler.AbstractProfilerTest; @@ -23,15 +24,12 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicBoolean; -/** - * Verifies once-per-run suppression ({@code wallprecheck=true}) with a mix of sleeping, - * parked, and runnable threads. - */ +/** Verifies that {@code wallprecheck=true} does not suppress context-scoped threads. */ public class WallclockMitigationsCombinedTest extends AbstractProfilerTest { private static final int OSTHREAD_STATE_SLEEPING = 7; @Test - public void precheckAndParkSuppressionWorkTogether() throws Exception { + public void contextScopedThreadsRemainSampled() throws Exception { Assumptions.assumeTrue(!Platform.isJ9()); Assumptions.assumeTrue( Platform.isJavaVersionAtLeast(11), @@ -39,6 +37,8 @@ public void precheckAndParkSuppressionWorkTogether() throws Exception { CountDownLatch ready = new CountDownLatch(3); AtomicBoolean stop = new AtomicBoolean(false); + long suppressedBefore = profiler.getDebugCounters() + .getOrDefault("wc_signals_suppressed_owned_block", 0L); Thread sleeping = new Thread( @@ -109,20 +109,17 @@ public void precheckAndParkSuppressionWorkTogether() throws Exception { long parkedSamples = samplesByThread.getOrDefault("combined-parked", 0L); long runnableSamples = samplesByThread.getOrDefault("combined-runnable", 0L); - assertTrue(sleepingSamples < 10, - "Expected nearly no samples from owned sleeping thread, got: " + sleepingSamples); + assertTrue(sleepingSamples > 0, + "Expected samples from context-scoped sleeping thread, got: " + sleepingSamples); assertTrue(parkedSamples > 0, "Expected samples from traced parked thread, got: " + parkedSamples); assertTrue(runnableSamples > 0, "Expected samples from runnable thread, got: " + runnableSamples); - // Sleeping thread's suppression counter must have incremented. - Map counters = profiler.getDebugCounters(); - if (counters.containsKey("wc_signals_suppressed_sampled_run")) { - assertTrue( - counters.get("wc_signals_suppressed_sampled_run") > 0, - "Expected once-per-run 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