Skip to content

Commit ed3b012

Browse files
authored
fix(tsan): clean TSan gtest suite; fix races and RefCountGuard activation window (#554)
1 parent 810fc4b commit ed3b012

15 files changed

Lines changed: 295 additions & 45 deletions

File tree

.gitlab/sanitizer-tests/.gitlab-ci.yml

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,6 @@
4747

4848
gtest-asan-amd64:
4949
extends: .sanitizer_job
50-
allow_failure: true
5150
tags: [ "arch:amd64" ]
5251
image: $BUILD_IMAGE_X64
5352
variables:
@@ -56,12 +55,10 @@ gtest-asan-amd64:
5655

5756
gtest-tsan-amd64:
5857
extends: .sanitizer_job
59-
allow_failure: true
6058
# docker-in-docker:amd64 = Kata Containers (kata-qemu micro VMs).
6159
# Kata maps host-guest communication structures at fixed high addresses
6260
# that land in TSan's shadow region regardless of LLVM version or sysctl.
6361
# TSan on amd64 requires a non-Kata runner (EC2 or bare metal).
64-
# Kept allow_failure so it runs and provides coverage if the environment is fixed.
6562
tags: [ "docker-in-docker:amd64" ]
6663
image: $BUILD_IMAGE_X64
6764
variables:
@@ -75,12 +72,14 @@ gtest-tsan-amd64:
7572
echo "=== $(basename $binary) ==="
7673
GTEST_DEATH_TEST_STYLE=threadsafe "$binary"
7774
rc=$?
78-
[ $rc -ne 0 ] && { echo "FAILED: $(basename $binary) exited $rc"; exit $rc; }
75+
if [ $rc -ne 0 ]; then
76+
echo "FAILED: $(basename $binary) exited $rc"
77+
exit $rc
78+
fi
7979
done
8080
8181
gtest-asan-arm64:
8282
extends: .sanitizer_job
83-
allow_failure: true
8483
tags: [ "arch:arm64" ]
8584
image: $BUILD_IMAGE_ARM64
8685
variables:
@@ -89,7 +88,6 @@ gtest-asan-arm64:
8988

9089
gtest-tsan-arm64:
9190
extends: .sanitizer_job
92-
allow_failure: true
9391
# docker-in-docker:arm64 = EC2 VM. sysctl works directly.
9492
# vm.mmap_rnd_bits=28 is sufficient — TSan's LLVM re-exec handles the rare
9593
# case where a library lands in the shadow region by re-running the process
@@ -111,5 +109,8 @@ gtest-tsan-arm64:
111109
echo "=== $(basename $binary) ==="
112110
GTEST_DEATH_TEST_STYLE=threadsafe "$binary"
113111
rc=$?
114-
[ $rc -ne 0 ] && { echo "FAILED: $(basename $binary) exited $rc"; exit $rc; }
112+
if [ $rc -ne 0 ]; then
113+
echo "FAILED: $(basename $binary) exited $rc"
114+
exit $rc
115+
fi
115116
done

build-logic/conventions/src/main/kotlin/com/datadoghq/native/config/ConfigurationPresets.kt

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -250,24 +250,38 @@ object ConfigurationPresets {
250250
config.compilerArgs.set(tsanCompilerArgs + commonLinuxCompilerArgs(version))
251251

252252
val libtsan = PlatformUtils.locateLibtsan(compiler)
253+
// Use the library name from the resolved path so that clang's own
254+
// libclang_rt.tsan-<arch>.so is linked by name (not as -ltsan).
253255
val tsanLinkerArgs = if (libtsan != null) {
256+
val tsanLibDir = File(libtsan).parent
257+
val tsanLibName = File(libtsan).nameWithoutExtension.removePrefix("lib")
254258
listOf(
255-
"-L${File(libtsan).parent}",
256-
"-ltsan",
259+
"-L$tsanLibDir",
260+
"-l$tsanLibName",
261+
"-Wl,-rpath,$tsanLibDir",
257262
"-fsanitize=thread",
258263
"-fno-omit-frame-pointer"
259264
)
260265
} else {
261-
emptyList()
266+
listOf("-fsanitize=thread", "-fno-omit-frame-pointer")
262267
}
263268

264269
config.linkerArgs.set(commonLinuxLinkerArgs() + tsanLinkerArgs)
265270

266-
if (libtsan != null) {
267-
config.testEnvironment.apply {
271+
config.testEnvironment.apply {
272+
if (libtsan != null) {
268273
put("LD_PRELOAD", libtsan)
269-
put("TSAN_OPTIONS", "suppressions=$rootDir/gradle/sanitizers/tsan.supp")
274+
// handle_segv=0 / handle_sigbus=0: let the JVM handle these signals
275+
// (SafeFetch, NullPointerException, memory bus errors).
276+
// use_sigaltstack=0: JVM manages its own alternate signal stack.
277+
// halt_on_error=0: report all races; process exits with code 66 at end.
278+
// abort_on_error=0: use exit() not abort() so Java shutdown hooks run.
279+
// io_sync=0: disable TSan's own FD-tracking, which races internally
280+
// when the JVM concurrently closes/reads file descriptors.
281+
put("TSAN_OPTIONS", "handle_segv=0:handle_sigbus=0:use_sigaltstack=0:halt_on_error=0:abort_on_error=0:io_sync=0:suppressions=$rootDir/gradle/sanitizers/tsan.supp")
270282
}
283+
// fork() is unsupported under TSan; threadsafe style uses execve instead.
284+
put("GTEST_DEATH_TEST_STYLE", "threadsafe")
271285
}
272286
}
273287
Platform.MACOS -> {

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

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -128,13 +128,11 @@ class GtestTaskBuilder(
128128
}
129129

130130
private fun buildLinkTask(compileTask: TaskProvider<NativeCompileTask>): TaskProvider<NativeLinkExecutableTask> {
131-
// For executables, clang's -fsanitize=address statically embeds the full
132-
// ASan runtime (--whole-archive libclang_rt.asan*.a). Adding an explicit
133-
// -lclang_rt.asan or -lasan on top produces a second dynamic NEEDED entry,
134-
// which triggers "incompatible ASan runtimes" at startup (two __asan_init
135-
// calls). Strip the explicit sanitizer -l/-L/-rpath flags here so the
136-
// executable relies solely on clang's automatic static embedding.
137-
val sanitizerLibPattern = Regex("^(-lasan|-lubsan|-lclang_rt\\.asan.*|-lclang_rt\\.ubsan.*|-L.*/clang.*/|-Wl,-rpath,.*/clang.*/)")
131+
// Strip explicit sanitizer -l/-L/-rpath flags so the executable relies solely
132+
// on clang's automatic static embedding of the sanitizer runtime. Mixing
133+
// clang's statically-embedded runtime with an explicit GCC libtsan/libasan
134+
// causes "incompatible runtimes" at startup (two __tsan_init/__asan_init calls).
135+
val sanitizerLibPattern = Regex("^(-lasan|-lubsan|-ltsan|-lclang_rt\\.asan.*|-lclang_rt\\.ubsan.*|-lclang_rt\\.tsan.*|-L.*/clang.*/|-Wl,-rpath,.*/clang/.*)")
138136
val linkerArgs = config.linkerArgs.get().filter { !sanitizerLibPattern.containsMatchIn(it) }
139137
val objDir = project.file("${project.layout.buildDirectory.get()}/obj/gtest/${config.name}/$testName")
140138
val binary = project.file("${project.layout.buildDirectory.get()}/bin/gtest/${config.name}_$testName/$testName")

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

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,23 @@ object PlatformUtils {
145145
return locateLibrary("libasan", compiler)
146146
}
147147

148-
fun locateLibtsan(compiler: String = "gcc"): String? = locateLibrary("libtsan", compiler)
148+
fun locateLibtsan(compiler: String = "gcc"): String? {
149+
if (currentPlatform != Platform.LINUX) return null
150+
// For clang, prefer the architecture-specific clang_rt.tsan library over
151+
// GCC's libtsan. GCC 11's libtsan only handles 39-bit VMA on aarch64;
152+
// clang's compiler-rt tsan handles both 39-bit and 48-bit VMA.
153+
if (compiler.contains("clang")) {
154+
val archSuffix = when (currentArchitecture) {
155+
Architecture.X64 -> "x86_64"
156+
Architecture.ARM64 -> "aarch64"
157+
Architecture.X86 -> "i386"
158+
Architecture.ARM -> "arm"
159+
}
160+
val clangTsan = locateLibrary("libclang_rt.tsan-$archSuffix", compiler)
161+
if (clangTsan != null) return clangTsan
162+
}
163+
return locateLibrary("libtsan", compiler)
164+
}
149165

150166
fun checkFuzzerSupport(): Boolean {
151167
return try {

build-logic/conventions/src/main/kotlin/com/datadoghq/profiler/ProfilerTestPlugin.kt

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,11 @@ class ProfilerTestPlugin : Plugin<Project> {
259259
// https://github.com/eclipse-openj9/openj9/issues/23514
260260
!PlatformUtils.isTestJvmJ9()
261261
}
262-
"tsan" -> testTask.onlyIf { PlatformUtils.locateLibtsan() != null }
262+
// TSan + JVM integration tests are incompatible: the profiler's signal
263+
// handlers (SIGPROF at 1ms) are TSan-instrumented; when a signal fires
264+
// while TSan is updating its shadow memory it causes re-entrance and a
265+
// SIGSEGV. TSan coverage is provided by the C++ gtest suite (gtestTsan).
266+
"tsan" -> testTask.onlyIf { false }
263267
}
264268
}
265269
}
@@ -347,7 +351,7 @@ class ProfilerTestPlugin : Plugin<Project> {
347351
// https://github.com/eclipse-openj9/openj9/issues/23514
348352
!PlatformUtils.isTestJvmJ9()
349353
}
350-
"tsan" -> execTask.onlyIf { PlatformUtils.locateLibtsan() != null }
354+
"tsan" -> execTask.onlyIf { false } // same reason as testTask above
351355
}
352356
}
353357
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,10 @@ u64 CallTraceHashTable::put(int num_frames, ASGCT_CallFrame *frames,
251251
bool truncated, u64 weight) {
252252
u64 hash = calcHash(num_frames, frames, truncated);
253253

254-
LongHashTable *table = _table;
254+
// ACQUIRE pairs with the ACQ_REL CAS in the expansion path below, ensuring
255+
// that if another thread published a new (expanded) table we see its fully
256+
// initialised contents.
257+
LongHashTable *table = __atomic_load_n(&_table, __ATOMIC_ACQUIRE);
255258
if (table == nullptr) {
256259
// Table allocation failed or was cleared - drop sample
257260
Counters::increment(CALLTRACE_STORAGE_DROPPED);

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,14 @@ void RefCountGuard::waitForRefCountToClear(void* table_to_delete) {
170170
bool all_clear = true;
171171
for (int i = 0; i < MAX_THREADS; ++i) {
172172
uint32_t count = __atomic_load_n(&refcount_slots[i].count, __ATOMIC_ACQUIRE);
173-
if (count == 0) continue;
173+
if (count == 0) {
174+
// Check active_ptr to cover the non-reentrant constructor's activation window:
175+
// active_ptr is stored (RELEASE) before count++ (RELEASE), so count==0 with
176+
// active_ptr set means the thread is in the window and must be waited for.
177+
void* aptr = __atomic_load_n(&refcount_slots[i].active_ptr, __ATOMIC_ACQUIRE);
178+
if (aptr == table_to_delete) { all_clear = false; break; }
179+
continue;
180+
}
174181
if (slotReferences(refcount_slots[i], table_to_delete)) { all_clear = false; break; }
175182
}
176183
if (all_clear) return;
@@ -183,7 +190,11 @@ void RefCountGuard::waitForRefCountToClear(void* table_to_delete) {
183190
bool all_clear = true;
184191
for (int i = 0; i < MAX_THREADS; ++i) {
185192
uint32_t count = __atomic_load_n(&refcount_slots[i].count, __ATOMIC_ACQUIRE);
186-
if (count == 0) continue;
193+
if (count == 0) {
194+
void* aptr = __atomic_load_n(&refcount_slots[i].active_ptr, __ATOMIC_ACQUIRE);
195+
if (aptr == table_to_delete) { all_clear = false; break; }
196+
continue;
197+
}
187198
if (slotReferences(refcount_slots[i], table_to_delete)) { all_clear = false; break; }
188199
}
189200
if (all_clear) return;
@@ -208,18 +219,23 @@ void RefCountGuard::waitForAllRefCountsToClear() {
208219
bool any = false;
209220
for (int i = 0; i < MAX_THREADS; ++i) {
210221
if (__atomic_load_n(&refcount_slots[i].count, __ATOMIC_ACQUIRE) > 0) { any = true; break; }
222+
// Also check active_ptr: non-reentrant constructor stores it (RELEASE) before
223+
// count++ (RELEASE), so count==0 with active_ptr!=null means the thread is in
224+
// the activation window and must be waited for.
225+
if (__atomic_load_n(&refcount_slots[i].active_ptr, __ATOMIC_ACQUIRE) != nullptr) { any = true; break; }
211226
}
212227
if (!any) return;
213228
spinPause();
214229
}
215230

216231
const int MAX_WAIT_ITERATIONS = 5000;
217232
struct timespec sleep_time = {0, 100000};
218-
int last_nonzero_slot = -1; // for timeout diagnostic below
233+
int last_nonzero_slot = -1;
219234
for (int wait_count = 0; wait_count < MAX_WAIT_ITERATIONS; ++wait_count) {
220235
bool any = false;
221236
for (int i = 0; i < MAX_THREADS; ++i) {
222237
if (__atomic_load_n(&refcount_slots[i].count, __ATOMIC_ACQUIRE) > 0) { any = true; last_nonzero_slot = i; break; }
238+
if (__atomic_load_n(&refcount_slots[i].active_ptr, __ATOMIC_ACQUIRE) != nullptr) { any = true; last_nonzero_slot = i; break; }
223239
}
224240
if (!any) return;
225241
nanosleep(&sleep_time, nullptr);

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

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,17 +15,16 @@
1515
* Each slot occupies a full cache line (64 bytes) to eliminate false sharing.
1616
*
1717
* ACTIVATION PROTOCOL (pointer-first):
18-
* - Constructor: store active_ptr first, then increment count
19-
* - Destructor: decrement count first, then clear active_ptr
20-
* - Scanner: check count; if 0, skip slot (treats it as inactive)
18+
* - Constructor: store active_ptr (RELEASE) first, then increment count (RELEASE)
19+
* - Destructor: decrement count (RELEASE) first, then clear active_ptr (RELEASE)
20+
* - Scanner: load count (ACQUIRE); if 0, also load active_ptr (ACQUIRE);
21+
* treat the slot as active if either count > 0 or active_ptr != null
2122
*
22-
* There is a brief activation window between the store of active_ptr and the
23-
* increment of count where the scanner sees count=0 and may skip the slot.
24-
* For signal handlers this window is never observed in practice: handlers
25-
* complete within microseconds while a buffer can only be cleared after TWO
26-
* full dump cycles (typically 60+ seconds). If the window were hit, the caller
27-
* observes a miss (e.g. 0 or equivalent sentinel) and handles it gracefully
28-
* — a dropped trace or generic vtable frame, not a crash.
23+
* The scanner checks active_ptr even when count==0 to cover the non-reentrant
24+
* constructor's activation window (active_ptr stored but count not yet incremented)
25+
* and the destructor's deactivation window (count decremented but active_ptr not yet
26+
* cleared). The RELEASE/ACQUIRE pairing on active_ptr itself guarantees the scanner
27+
* sees the stored value if it was written before the load in program order.
2928
*
3029
* REENTRANT NESTING: when a signal fires inside an outer guard (same thread),
3130
* the displaced active_ptr is parked in outer_stack[] so the scanner can still
@@ -64,7 +63,7 @@ struct alignas(DEFAULT_CACHE_LINE_SIZE) RefCountSlot {
6463
* Correctness:
6564
* - Pointer stored BEFORE count increment (activation)
6665
* - Count decremented BEFORE pointer cleared (deactivation)
67-
* - Scanner checks count first, ensuring consistent view
66+
* - Scanner checks both count and active_ptr to close the activation window
6867
*
6968
* Reentrancy:
7069
* - A signal handler may create a RefCountGuard while a JNI thread already

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,10 @@ class StringDictionaryBuffer {
347347
return stored_id;
348348
}
349349
}
350-
if (!row->next) {
350+
// Relaxed is fine here: the optimization hint may be stale; the CAS
351+
// below will handle that, and the ACQUIRE load of row->next below
352+
// provides the necessary happens-before for the newly-created SBTable's contents.
353+
if (!__atomic_load_n(&row->next, __ATOMIC_RELAXED)) {
351354
SBTable* nt = static_cast<SBTable*>(calloc(1, sizeof(SBTable)));
352355
if (nt == nullptr) return 0;
353356
if (!__sync_bool_compare_and_swap(&row->next, nullptr, nt)) {

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,11 +87,18 @@ class ProfiledThread : public ThreadLocalData {
8787
#ifdef UNIT_TEST
8888
// Simulates the moment inside release() after pthread_setspecific(NULL) but
8989
// before delete — the race window the clearCurrentThreadTLS fix covers.
90-
static void clearCurrentThreadTLS() {
90+
// Returns the detached pointer so the caller can delete it after assertions.
91+
static ProfiledThread* clearCurrentThreadTLS() {
9192
if (__atomic_load_n(&_tls_key_initialized, __ATOMIC_ACQUIRE)) {
93+
ProfiledThread *pt = (ProfiledThread *)pthread_getspecific(_tls_key);
9294
pthread_setspecific(_tls_key, nullptr);
95+
return pt;
9396
}
97+
return nullptr;
9498
}
99+
// Deletes a ProfiledThread returned by clearCurrentThreadTLS().
100+
// Needed because the destructor is private.
101+
static void deleteForTest(ProfiledThread *pt) { delete pt; }
95102
#endif
96103

97104
static ProfiledThread *current();

0 commit comments

Comments
 (0)