Skip to content

Commit 07dfb19

Browse files
authored
Merge branch 'main' into jb/sframe
2 parents 649718f + 57916f4 commit 07dfb19

14 files changed

Lines changed: 623 additions & 47 deletions

File tree

build-logic/conventions/src/main/kotlin/com/datadoghq/native/fuzz/FuzzTargetsPlugin.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,13 @@ class FuzzTargetsPlugin : Plugin<Project> {
7474
}
7575

7676
if (!hasFuzzer) {
77+
val msg = if (PlatformUtils.currentPlatform == Platform.MACOS) {
78+
"WARNING: libFuzzer not available on macOS — skipping fuzz targets. " +
79+
"Install LLVM via Homebrew and ensure 'clang' resolves to the Homebrew clang."
80+
} else {
81+
"WARNING: libFuzzer not available — skipping fuzz targets (requires clang with -fsanitize=fuzzer)."
82+
}
83+
project.logger.lifecycle(msg)
7784
createListFuzzTargetsTask(project, extension)
7885
return
7986
}

build-logic/conventions/src/main/kotlin/com/datadoghq/native/gtest/GtestTaskBuilder.kt

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,9 @@ class GtestTaskBuilder(
192192
// causes "incompatible ASan runtimes" → immediate abort before any test runs.
193193
config.testEnvironment.get()
194194
.filter { (key, _) -> key != "LD_PRELOAD" }
195-
.forEach { (key, value) -> environment(key, value) }
195+
.forEach { (key, value) ->
196+
environment(key, hardenSanitizerOptions(key, value))
197+
}
196198

197199
inputs.files(binary)
198200

@@ -257,4 +259,17 @@ class GtestTaskBuilder(
257259

258260
return args
259261
}
262+
263+
// Gtest binaries have no JVM, so halt_on_error=0 / abort_on_error=0 (which exists
264+
// to avoid conflicts with JVM signal handlers in Java integration tests) is wrong:
265+
// it silently swallows ASan/TSan findings and lets the test exit 0 despite errors.
266+
// Promote both flags to =1 so any sanitizer finding fails the gtest task immediately.
267+
private fun hardenSanitizerOptions(key: String, value: String): String {
268+
if (key != "ASAN_OPTIONS" && key != "TSAN_OPTIONS" && key != "UBSAN_OPTIONS") {
269+
return value
270+
}
271+
return value
272+
.replace("halt_on_error=0", "halt_on_error=1")
273+
.replace("abort_on_error=0", "abort_on_error=1")
274+
}
260275
}

build-logic/conventions/src/main/kotlin/com/datadoghq/native/util/PlatformUtils.kt

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -166,23 +166,34 @@ object PlatformUtils {
166166
fun checkFuzzerSupport(): Boolean {
167167
return try {
168168
val testFile = createTempFile("fuzzer_check", ".cpp")
169+
val outFile = createTempFile("fuzzer_check", "")
170+
// Remove the pre-created output file so the compiler writes its own binary
171+
outFile.deleteIfExists()
169172
try {
170173
testFile.writeText("extern \"C\" int LLVMFuzzerTestOneInput(const char*, long) { return 0; }")
171174

175+
// Link (not just compile) to catch missing libclang_rt.fuzzer_osx.a on Apple clang
172176
val process = ProcessBuilder(
173177
"clang++",
174178
"-fsanitize=fuzzer",
175-
"-c",
176179
testFile.toAbsolutePath().toString(),
177180
"-o",
178-
"/dev/null"
181+
outFile.toAbsolutePath().toString()
179182
).redirectErrorStream(true).start()
180183

181-
process.waitFor()
184+
if (!process.waitFor(30, TimeUnit.SECONDS)) {
185+
process.destroyForcibly()
186+
process.waitFor(5, TimeUnit.SECONDS)
187+
return false
188+
}
182189
process.exitValue() == 0
183190
} finally {
184191
testFile.deleteIfExists()
192+
outFile.deleteIfExists()
185193
}
194+
} catch (e: InterruptedException) {
195+
Thread.currentThread().interrupt()
196+
false
186197
} catch (e: Exception) {
187198
false
188199
}

ddprof-lib/src/main/cpp/callTraceHashTable.cpp

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -105,8 +105,11 @@ CallTraceHashTable::~CallTraceHashTable() {
105105
void CallTraceHashTable::decrementCounters() {
106106
#ifdef COUNTERS
107107
// Compute and decrement the global counters for everything in this table.
108-
// Must only be called after waitForAllRefCountsToClear() so there are no
109-
// concurrent writers and plain iteration is safe.
108+
// Safe to call when (a) this is a standby/scratch table (never _active_storage,
109+
// so no signal-handler put() can target it), or (b) the active-table path is
110+
// guarded by lockAll() — both conditions are enforced by the only caller,
111+
// clearTableOnly(). The _prev traversal is safe because waitForRefCountToClear(this)
112+
// in clearTableOnly() has already drained any in-flight put() operations.
110113
// Use a set to deduplicate: put() may store the same CallTrace* pointer in
111114
// both a newer and an older table (when findCallTrace finds it in prev()),
112115
// but the counter was only incremented once, so we must only count it once.
@@ -141,16 +144,27 @@ void CallTraceHashTable::decrementCounters() {
141144
}
142145

143146
ChunkList CallTraceHashTable::clearTableOnly() {
144-
// Wait for all refcount guards to clear before detaching chunks
145-
RefCountGuard::waitForAllRefCountsToClear();
147+
// Wait only for in-flight put() operations that hold a RefCountGuard on THIS
148+
// table. Waiting globally (waitForAllRefCountsToClear) would block on
149+
// unrelated puts to the currently-active table, causing 500 ms timeouts under
150+
// sustained wall-clock profiling and leaving collect() racing with a still-
151+
// running put(). Since standby and scratch tables never appear as the
152+
// _active_storage, this wait returns instantly for them; for the active table
153+
// (called from clear() -> clearTableOnly()) the protection comes from the caller
154+
// holding lockAll() (which blocks signal-handler puts) and from this in-function
155+
// targeted wait — there is no prior caller-side drain.
156+
RefCountGuard::waitForRefCountToClear(this);
146157
decrementCounters();
147158

148-
// Clear previous chain pointers to prevent traversal during deallocation
149-
for (LongHashTable *table = _table; table != nullptr; table = table->prev()) {
150-
LongHashTable *prev_table = table->prev();
151-
if (prev_table != nullptr) {
152-
table->setPrev(nullptr); // Clear link before deallocation
153-
}
159+
// Disconnect the full _prev chain before freeing chunks. The advance step
160+
// must use a pre-saved pointer because setPrev(nullptr) clears the link that
161+
// the original loop used for advancement, causing early termination after only
162+
// the first node on an expanded (multi-node) table.
163+
for (LongHashTable *table = __atomic_load_n(&_table, __ATOMIC_ACQUIRE);
164+
table != nullptr; ) {
165+
LongHashTable *next = table->prev();
166+
table->setPrev(nullptr);
167+
table = next;
154168
}
155169

156170
// Detach chunks for deferred deallocation - keeps trace memory alive
@@ -161,7 +175,11 @@ ChunkList CallTraceHashTable::clearTableOnly() {
161175
// _tail will be nullptr. LongHashTable::allocate will try to allocate,
162176
// which will call LinearAllocator::alloc(), which needs to handle nullptr _tail.
163177
// This is already handled in alloc() by checking _tail before use.
164-
_table = LongHashTable::allocate(nullptr, INITIAL_CAPACITY, &_allocator);
178+
// RELEASE: pairs with ACQUIRE loads in collect() and put() to ensure the
179+
// freshly-initialised table is visible on weakly-ordered architectures (aarch64).
180+
__atomic_store_n(&_table,
181+
LongHashTable::allocate(nullptr, INITIAL_CAPACITY, &_allocator),
182+
__ATOMIC_RELEASE);
165183
_overflow = 0;
166184

167185
return detached_chunks;
@@ -392,11 +410,12 @@ u64 CallTraceHashTable::put(int num_frames, ASGCT_CallFrame *frames,
392410
}
393411

394412
void CallTraceHashTable::collect(std::unordered_set<CallTrace *> &traces, std::function<void(CallTrace*)> trace_hook) {
395-
// Lock-free collection for read-only tables
396-
// No new put() operations can occur, so no synchronization needed
397-
398-
// Collect from all tables in the chain (current and previous tables)
399-
for (LongHashTable *table = _table; table != nullptr; table = table->prev()) {
413+
// Lock-free collection for read-only tables.
414+
// Use ACQUIRE to pair with the ACQ_REL CAS in put()'s expansion path and the
415+
// RELEASE store in clearTableOnly(); ensures we see the fully-initialised table
416+
// on weakly-ordered architectures (aarch64).
417+
for (LongHashTable *table = __atomic_load_n(&_table, __ATOMIC_ACQUIRE);
418+
table != nullptr; table = table->prev()) {
400419
u64 *keys = table->keys();
401420
CallTraceSample *values = table->values();
402421
u32 capacity = table->capacity();
@@ -429,16 +448,19 @@ void CallTraceHashTable::putWithExistingId(CallTrace* source_trace, u64 weight)
429448

430449
u64 hash = calcHash(source_trace->num_frames, source_trace->frames, source_trace->truncated);
431450

432-
// First check if trace already exists in any table in the chain
433-
for (LongHashTable *search_table = _table; search_table != nullptr; search_table = search_table->prev()) {
451+
// First check if trace already exists in any table in the chain.
452+
// Use ACQUIRE to match the RELEASE store in clearTableOnly(); putWithExistingId()
453+
// is only called on scratch/standby tables with no concurrent writers, so the
454+
// load is safe, but consistent ordering prevents latent issues if callers change.
455+
for (LongHashTable *search_table = __atomic_load_n(&_table, __ATOMIC_ACQUIRE);
456+
search_table != nullptr; search_table = search_table->prev()) {
434457
CallTrace *existing_trace = findCallTrace(search_table, hash);
435458
if (existing_trace != nullptr) {
436-
// Trace already exists in the chain
437459
return;
438460
}
439461
}
440-
441-
LongHashTable *table = _table;
462+
463+
LongHashTable *table = __atomic_load_n(&_table, __ATOMIC_ACQUIRE);
442464
if (table == nullptr) {
443465
return; // Table allocation failed
444466
}

ddprof-lib/src/main/cpp/callTraceHashTable.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,13 @@ class CallTraceHashTable {
6363

6464
LinearAllocator _allocator;
6565

66-
// Single large pre-allocated table - no expansion needed!
67-
LongHashTable* _table; // Simple pointer, no atomics needed
66+
// Expandable hash table; put() doubles capacity when fill reaches 75%.
67+
// Memory-ordering protocol:
68+
// - ACQ_REL CAS in put() when installing the expanded table
69+
// - RELEASE store in clearTableOnly() when resetting to a fresh table
70+
// - ACQUIRE loads in collect(), put(), and putWithExistingId()
71+
// Required for correct visibility on weakly-ordered architectures (aarch64).
72+
LongHashTable* _table;
6873

6974
volatile u64 _overflow;
7075

ddprof-lib/src/main/cpp/linearAllocator.cpp

Lines changed: 88 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
#include <sanitizer/asan_interface.h>
3737
#endif
3838

39+
#ifdef __SANITIZE_THREAD__
40+
#include <sanitizer/tsan_interface.h>
41+
#endif
42+
3943
LinearAllocator::LinearAllocator(size_t chunk_size) {
4044
_chunk_size = chunk_size;
4145
_reserve = _tail = allocateChunk(NULL);
@@ -47,9 +51,21 @@ LinearAllocator::~LinearAllocator() {
4751
}
4852

4953
void LinearAllocator::clear() {
54+
// OS::safeAlloc/safeFree use raw syscalls not intercepted by TSan, so TSan
55+
// never clears shadow memory on munmap. Add explicit acquire/release around
56+
// every plain prev-field read so the happens-before chain from freeChunk's
57+
// __tsan_release reaches any thread that later reuses the same VA.
58+
#ifdef __SANITIZE_THREAD__
59+
__tsan_acquire(_reserve);
60+
#endif
5061
if (_reserve->prev == _tail) {
51-
freeChunk(_reserve);
62+
freeChunk(_reserve); // __tsan_release inside
5263
}
64+
#ifdef __SANITIZE_THREAD__
65+
else {
66+
__tsan_release(_reserve); // not freed here; release for future VA-reuse acquirers
67+
}
68+
#endif
5369

5470
// ASAN POISONING: Mark all allocated memory as poisoned BEFORE freeing chunks
5571
// This catches use-after-free even when memory isn't munmap'd (kept in _tail)
@@ -71,12 +87,26 @@ void LinearAllocator::clear() {
7187
}
7288
#endif
7389

74-
while (_tail->prev != NULL) {
90+
// Walk the chain freeing all chunks except the last (prev==NULL).
91+
// Acquire each chunk BEFORE reading its prev field so the TSan happens-before
92+
// chain covers the condition check, not just the assignment inside the body.
93+
{
7594
Chunk *current = _tail;
76-
_tail = _tail->prev;
77-
freeChunk(current);
95+
#ifdef __SANITIZE_THREAD__
96+
__tsan_acquire(current); // before the first current->prev read (loop condition)
97+
#endif
98+
while (current->prev != NULL) {
99+
Chunk *next = current->prev;
100+
freeChunk(current); // __tsan_release(current) + safeFree inside
101+
current = next;
102+
#ifdef __SANITIZE_THREAD__
103+
__tsan_acquire(current); // before the next iteration's current->prev read
104+
#endif
105+
}
106+
// current is the last chunk (prev==NULL); keep it as the new allocator base.
107+
_reserve = current;
108+
_tail = current;
78109
}
79-
_reserve = _tail;
80110
_tail->offs = sizeof(Chunk);
81111

82112
// DON'T UNPOISON HERE - let alloc() do it on-demand!
@@ -88,11 +118,20 @@ ChunkList LinearAllocator::detachChunks() {
88118
// Capture current state before detaching
89119
ChunkList result(_tail, _chunk_size);
90120

91-
// Handle reserve chunk: if it's ahead of tail, it needs special handling
92-
if (_reserve->prev == _tail) {
93-
// Reserve is a separate chunk ahead of tail - it becomes part of detached list
94-
// We need to include it in the chain by making it the new head
95-
result.head = _reserve;
121+
// Handle reserve chunk: if it's ahead of tail, it becomes part of detached list.
122+
// Acquire TSan ownership before reading _reserve->prev: the reserve chunk may
123+
// have been allocated by another thread via reserveChunk() → allocateChunk(),
124+
// which released ownership with __tsan_release after writing chunk->prev.
125+
if (_reserve != _tail) {
126+
#ifdef __SANITIZE_THREAD__
127+
__tsan_acquire(_reserve);
128+
#endif
129+
if (_reserve->prev == _tail) {
130+
result.head = _reserve;
131+
}
132+
#ifdef __SANITIZE_THREAD__
133+
__tsan_release(_reserve);
134+
#endif
96135
}
97136

98137
// Allocate a fresh chunk for new allocations
@@ -118,17 +157,25 @@ void LinearAllocator::freeChunks(ChunkList& chunks) {
118157
return;
119158
}
120159

121-
// Walk the chain and free each chunk
122160
Chunk* current = chunks.head;
123161
while (current != nullptr) {
162+
// Acquire TSan ownership before reading chunk->prev: pairs with the
163+
// __tsan_release in allocateChunk() that published the initialized chunk.
164+
// Without this, TSan cannot connect the writer's (e.g. reserveChunk thread)
165+
// initialization of chunk->prev to this read, and reports a false data race.
166+
#ifdef __SANITIZE_THREAD__
167+
__tsan_acquire(current);
168+
#endif
124169
Chunk* prev = current->prev;
170+
#ifdef __SANITIZE_THREAD__
171+
__tsan_release(current);
172+
#endif
125173
OS::safeFree(current, chunks.chunk_size);
126174
Counters::decrement(LINEAR_ALLOCATOR_BYTES, chunks.chunk_size);
127175
Counters::decrement(LINEAR_ALLOCATOR_CHUNKS);
128176
current = prev;
129177
}
130178

131-
// Mark as freed to prevent double-free
132179
chunks.head = nullptr;
133180
chunks.chunk_size = 0;
134181
}
@@ -172,16 +219,28 @@ void *LinearAllocator::alloc(size_t size) {
172219
Chunk *LinearAllocator::allocateChunk(Chunk *current) {
173220
Chunk *chunk = (Chunk *)OS::safeAlloc(_chunk_size);
174221
if (chunk != NULL) {
222+
// OS::safeAlloc uses a raw mmap syscall that bypasses ASan and TSan
223+
// interceptors by design (to avoid profiling self-instrumentation).
224+
// When the OS reuses a VA that had stale sanitizer state from a previous
225+
// allocation at that address, writing to the chunk header triggers:
226+
// ASan: use-after-poison (f7 shadow from prior ASAN_POISON_MEMORY_REGION)
227+
// TSan: data-race (prior access history for the same VA not cleared by munmap)
228+
// Fix: unpoison the entire chunk and acquire TSan ownership BEFORE the first
229+
// write, establishing a clean sanitizer baseline for this logical allocation.
230+
#ifdef ASAN_ENABLED
231+
ASAN_UNPOISON_MEMORY_REGION(chunk, _chunk_size);
232+
#endif
233+
#ifdef __SANITIZE_THREAD__
234+
__tsan_acquire(chunk);
235+
#endif
236+
175237
chunk->prev = current;
176238
chunk->offs = sizeof(Chunk);
177239

178-
// ASAN UNPOISONING: New chunks from mmap are clean, unpoison them for use
179-
// mmap returns memory that ASan may track as unallocated, so we need to
180-
// explicitly unpoison it to allow allocations
181-
#ifdef ASAN_ENABLED
182-
size_t usable_size = _chunk_size - sizeof(Chunk);
183-
void* data_start = (char*)chunk + sizeof(Chunk);
184-
ASAN_UNPOISON_MEMORY_REGION(data_start, usable_size);
240+
// Publish the initialized chunk: release TSan ownership so that any thread
241+
// which later acquires this chunk (via __tsan_acquire) will see these writes.
242+
#ifdef __SANITIZE_THREAD__
243+
__tsan_release(chunk);
185244
#endif
186245

187246
Counters::increment(LINEAR_ALLOCATOR_BYTES, _chunk_size);
@@ -191,6 +250,13 @@ Chunk *LinearAllocator::allocateChunk(Chunk *current) {
191250
}
192251

193252
void LinearAllocator::freeChunk(Chunk *current) {
253+
// Release TSan ownership before munmap so the sanitizer knows this thread is
254+
// done with the memory. The paired __tsan_acquire in allocateChunk() ensures
255+
// the next thread to receive this VA (after OS VA reuse) starts with a clean
256+
// happens-before baseline rather than seeing stale access history.
257+
#ifdef __SANITIZE_THREAD__
258+
__tsan_release(current);
259+
#endif
194260
OS::safeFree(current, _chunk_size);
195261
Counters::decrement(LINEAR_ALLOCATOR_BYTES, _chunk_size);
196262
Counters::decrement(LINEAR_ALLOCATOR_CHUNKS);
@@ -206,7 +272,9 @@ void LinearAllocator::reserveChunk(Chunk *current) {
206272
}
207273

208274
Chunk *LinearAllocator::getNextChunk(Chunk *current) {
209-
Chunk *reserve = _reserve;
275+
// _reserve is written via CAS in reserveChunk(); load it atomically so TSan
276+
// sees the acquire-release relationship with the CAS store.
277+
Chunk *reserve = __atomic_load_n(&_reserve, __ATOMIC_ACQUIRE);
210278

211279
if (reserve == current) {
212280
// Unlikely case: no reserve yet.

0 commit comments

Comments
 (0)