From 7e3fe6a208b63dbe88934ef38243c5e96ff27afe Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Thu, 9 Jul 2026 14:25:57 +0000 Subject: [PATCH 1/3] test: add fuzzer for thread local --- ddprof-lib/src/test/fuzz/README.md | 18 ++ ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp | 246 ++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp diff --git a/ddprof-lib/src/test/fuzz/README.md b/ddprof-lib/src/test/fuzz/README.md index 5714df7b7e..864ab8bcd0 100644 --- a/ddprof-lib/src/test/fuzz/README.md +++ b/ddprof-lib/src/test/fuzz/README.md @@ -154,6 +154,24 @@ the 49152-entry expansion threshold, forcing the multi-node `_prev` chain that e **Regression guard — detects if these bugs are reintroduced**: heap-use-after-free (ASan), data race on `_table` (TSan), null-deref in `processTraces` from use-after-free when `_prev` chain is not fully disconnected. +### fuzz_threadLocal.cpp +**Target**: `ThreadLocal` (and the `double`/generic-pointer +specializations) - the pthread-TSD-based alternative to `thread_local` used because +`pthread_(get/set)specific()` cannot be safely introduced mid-signal-handling on some +platforms (see the comment block in `threadLocal.h`). + +Each input is replayed on a freshly spawned thread that is joined before returning, so +the pthread key destructor for whatever is left in a slot fires synchronously inside +that `join()` - the one part of the lifecycle a persistent driver thread would never +exercise. A shadow model tracks the expected create/get/set/clear state and traps on +any mismatch. + +**Expected bugs**: stale/mismatched values from `get()`, `create_tracked()`/`free_tracked()` +running the wrong number of times (double free or leak across `clear()`, an overwriting +`set()`, or thread-exit teardown), `set(nullptr)` failing to trigger a lazy recreate on +the next `get()`, and non-bit-exact round-trips for the `double` specialization (NaN, +infinities, subnormals, -0.0). + ## Corpus Seed corpus files are in `corpus//`. These provide starting points diff --git a/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp b/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp new file mode 100644 index 0000000000..912f8f29f6 --- /dev/null +++ b/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp @@ -0,0 +1,246 @@ +/* + * Copyright 2026, Datadog, Inc. + * SPDX-License-Identifier: Apache-2.0 + * + * libFuzzer target for ThreadLocal (threadLocal.h). + * + * ThreadLocal wraps pthread TSD to work around pthread_(get/set)specific() not + * being safe to introduce mid-signal-handling (see the big comment at the top + * of threadLocal.h). The interesting bug surface is the lazy create/cleanup + * state machine across a *fresh thread's* full lifecycle — a slot that starts + * unset, is get()/set()/clear()'d in some order, and is then torn down by the + * pthread key destructor when the thread exits. Running everything on + * libFuzzer's persistent driver thread would never exercise the exit-time + * destructor path, so each input is replayed on a freshly spawned thread that + * is joined before returning — the destructor for whatever is left in the + * slot fires synchronously inside that join(). + * + * Input bytes are consumed as a stream of operations against three static + * ThreadLocal specializations that live side by side in the same input: + * 0x00-0x0F -> tracked.get() (lazy-creates via create_tracked() if empty) + * 0x10-0x1F -> tracked.clear() (invokes free_tracked() if non-empty) + * 0x20-0x2F -> tracked.set(fresh pointer) (caller frees any old value first, + * mirroring the documented contract) + * 0x30-0x3F -> tracked.set(nullptr) (frees old value; next get() must + * lazily recreate, per threadLocal.h) + * 0x40-0x4F -> double.set(next 8 bytes as raw bit pattern) + * 0x50-0x5F -> double.get() (verify exact bit round-trip) + * 0x60-0x6F -> double.clear() + * 0x70-0x7F -> intptr.set(next 8 bytes as raw bit pattern) + * 0x80-0x8F -> intptr.get() (verify exact round-trip) + * 0x90-0x9F -> intptr.clear() + * 0xA0-0xFF -> no-op (padding / density filler for the mutator) + * + * Invariants verified (violation -> __builtin_trap() -> ASan/fuzzer crash): + * I1. get() never returns a stale/mismatched value: the payload behind the + * pointer/bits returned always matches what the model last stored. + * I2. create_tracked() runs at most once per "empty -> get()" transition and + * free_tracked() runs exactly once per value that ever occupied the slot + * (via clear(), an overwriting set(), or the pthread key destructor at + * thread exit) - checked via the create/free counters delta across the + * whole spawned-thread run. + * I3. set(nullptr) followed by get() lazily recreates (documented contract). + * I4. double/intptr specializations round-trip bit-for-bit, including NaN, + * infinities, subnormals and -0.0, which plain == comparison would hide. + */ + +#include +#include + +#include +#include +#include +#include + +#include "threadLocal.h" + +namespace { + +std::atomic g_create_count{0}; +std::atomic g_free_count{0}; + +void *create_tracked() { + g_create_count.fetch_add(1, std::memory_order_relaxed); + return new int(1234); +} + +void free_tracked(void *p) { + g_free_count.fetch_add(1, std::memory_order_relaxed); + delete static_cast(p); +} + +ThreadLocal g_tracked_tl; +ThreadLocal g_double_tl; +ThreadLocal g_intptr_tl; + +u64 take8(const uint8_t *data, size_t pos) { + u64 v = 0; + for (int i = 0; i < 8; i++) { + v = (v << 8) | data[pos + i]; + } + return v; +} + +// Runs the whole decoded op sequence on the calling thread. Executed inside a +// freshly spawned std::thread so the tracked slot starts empty and whatever +// is left occupying it is torn down by the pthread key destructor at thread +// exit (join() below), exercising the path a persistent fuzzer-driver thread +// never would. +void runOnWorkerThread(const uint8_t *data, size_t size, int *expected_creates, + int *expected_frees) { + bool tracked_present = false; + int *tracked_ptr = nullptr; + int tracked_expected = 0; + int manual_marker_seq = 1; + + bool double_present = false; + u64 double_expected_bits = 0; + + bool intptr_present = false; + intptr_t intptr_expected = 0; + + size_t pos = 0; + while (pos < size) { + uint8_t op = data[pos++]; + + if (op < 0x10) { + // tracked.get() + int *p = g_tracked_tl.get(); + if (!tracked_present) { + // Empty slot with a non-null CREATE_FUNC must lazily create. + if (p == nullptr) __builtin_trap(); + tracked_present = true; + tracked_ptr = p; + tracked_expected = 1234; + (*expected_creates)++; + } else if (p != tracked_ptr) { + __builtin_trap(); // pointer identity must be stable across get()s + } + if (*p != tracked_expected) __builtin_trap(); // I1: payload corruption + + } else if (op < 0x20) { + // tracked.clear() + g_tracked_tl.clear(); + if (tracked_present) { + (*expected_frees)++; + tracked_present = false; + tracked_ptr = nullptr; + } + + } else if (op < 0x30) { + // tracked.set(fresh manually-owned pointer). Per the documented + // contract the caller frees any prior value itself before overwriting. + if (tracked_present) { + free_tracked(tracked_ptr); + (*expected_frees)++; + tracked_present = false; + } + // Use a negative, monotonically distinct marker so it can never be + // confused with create_tracked()'s 1234 sentinel. + int marker = -(manual_marker_seq++); + int *fresh = new int(marker); + g_tracked_tl.set(fresh); + tracked_present = true; + tracked_ptr = fresh; + tracked_expected = marker; + + } else if (op < 0x40) { + // tracked.set(nullptr): caller frees any prior value, then the next + // get() must lazily recreate (I3). + if (tracked_present) { + free_tracked(tracked_ptr); + (*expected_frees)++; + } + g_tracked_tl.set(nullptr); + tracked_present = false; + tracked_ptr = nullptr; + + } else if (op < 0x50) { + // double.set(next 8 bytes as raw bits) + if (pos + 7 >= size) break; + u64 bits = take8(data, pos); + pos += 8; + double v; + memcpy(&v, &bits, sizeof(v)); + g_double_tl.set(v); + double_present = true; + double_expected_bits = bits; + + } else if (op < 0x60) { + // double.get() - verify exact bit pattern, not value equality (NaN-safe) + double v = g_double_tl.get(); + u64 bits; + memcpy(&bits, &v, sizeof(bits)); + u64 expected = double_present ? double_expected_bits : 0; + if (bits != expected) __builtin_trap(); // I4 + + } else if (op < 0x70) { + // double.clear() + g_double_tl.clear(); + double_present = false; + + } else if (op < 0x80) { + // intptr.set(next 8 bytes as raw bits) + if (pos + 7 >= size) break; + u64 bits = take8(data, pos); + pos += 8; + intptr_t v = static_cast(bits); + g_intptr_tl.set(v); + intptr_present = true; + intptr_expected = v; + + } else if (op < 0x90) { + // intptr.get() + intptr_t v = g_intptr_tl.get(); + intptr_t expected = intptr_present ? intptr_expected : 0; + if (v != expected) __builtin_trap(); // I4 + + } else if (op < 0xA0) { + // intptr.clear() + g_intptr_tl.clear(); + intptr_present = false; + + } + // 0xA0-0xFF: no-op padding, left for the mutator to grow/shrink sequences. + } + + // Whatever is left in the tracked slot when this thread exits is torn down + // by the pthread key destructor, synchronized-with by the caller's join(). + if (tracked_present) { + (*expected_frees)++; + } +} + +} // namespace + +extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { + if (size == 0) return 0; + + int expected_creates = 0; + int expected_frees = 0; + uint64_t create_before = g_create_count.load(std::memory_order_relaxed); + uint64_t free_before = g_free_count.load(std::memory_order_relaxed); + + try { + std::thread worker(runOnWorkerThread, data, size, &expected_creates, + &expected_frees); + worker.join(); + } catch (const std::system_error &) { + // Transient thread-creation failure (e.g. resource exhaustion under a + // heavily parallel fuzzer run) - not a bug in ThreadLocal itself. + return 0; + } + + uint64_t create_after = g_create_count.load(std::memory_order_relaxed); + uint64_t free_after = g_free_count.load(std::memory_order_relaxed); + + if (create_after - create_before != static_cast(expected_creates)) { + __builtin_trap(); // I2: create_tracked() ran the wrong number of times + } + if (free_after - free_before != static_cast(expected_frees)) { + __builtin_trap(); // I2: free_tracked() ran the wrong number of times + // (double free / leak from the TSD destructor path) + } + + return 0; +} From e05caedb837085196f0258899b1c9d6318b8de0a Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Thu, 9 Jul 2026 19:24:31 +0000 Subject: [PATCH 2/3] AI review --- ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp b/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp index 912f8f29f6..580d098a68 100644 --- a/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp +++ b/ddprof-lib/src/test/fuzz/fuzz_threadLocal.cpp @@ -86,8 +86,8 @@ u64 take8(const uint8_t *data, size_t pos) { // is left occupying it is torn down by the pthread key destructor at thread // exit (join() below), exercising the path a persistent fuzzer-driver thread // never would. -void runOnWorkerThread(const uint8_t *data, size_t size, int *expected_creates, - int *expected_frees) { +void runOnWorkerThread(const uint8_t *data, size_t size, uint64_t *expected_creates, + uint64_t *expected_frees) { bool tracked_present = false; int *tracked_ptr = nullptr; int tracked_expected = 0; @@ -216,8 +216,8 @@ void runOnWorkerThread(const uint8_t *data, size_t size, int *expected_creates, extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { if (size == 0) return 0; - int expected_creates = 0; - int expected_frees = 0; + uint64_t expected_creates = 0; + uint64_t expected_frees = 0; uint64_t create_before = g_create_count.load(std::memory_order_relaxed); uint64_t free_before = g_free_count.load(std::memory_order_relaxed); @@ -225,19 +225,24 @@ extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) { std::thread worker(runOnWorkerThread, data, size, &expected_creates, &expected_frees); worker.join(); - } catch (const std::system_error &) { + } catch (const std::system_error &e) { // Transient thread-creation failure (e.g. resource exhaustion under a // heavily parallel fuzzer run) - not a bug in ThreadLocal itself. - return 0; + // Anything else (including a failure from join() itself) is unexpected + // and should surface as a crash rather than be silently swallowed. + if (e.code() == std::errc::resource_unavailable_try_again) { + return 0; + } + throw; } uint64_t create_after = g_create_count.load(std::memory_order_relaxed); uint64_t free_after = g_free_count.load(std::memory_order_relaxed); - if (create_after - create_before != static_cast(expected_creates)) { + if (create_after - create_before != expected_creates) { __builtin_trap(); // I2: create_tracked() ran the wrong number of times } - if (free_after - free_before != static_cast(expected_frees)) { + if (free_after - free_before != expected_frees) { __builtin_trap(); // I2: free_tracked() ran the wrong number of times // (double free / leak from the TSD destructor path) } From 5726c0b33e9c0efc782e0842246b3bc7d7ce6f38 Mon Sep 17 00:00:00 2001 From: Zhengyu Gu Date: Fri, 10 Jul 2026 21:16:46 +0000 Subject: [PATCH 3/3] Add corpus for thread_local --- ddprof-lib/src/test/fuzz/README.md | 6 ++++++ .../corpus/fuzz_threadLocal/double_special_values | Bin 0 -> 48 bytes .../fuzz_threadLocal/double_truncated_payload | Bin 0 -> 5 bytes .../fuzz/corpus/fuzz_threadLocal/intptr_roundtrip | Bin 0 -> 48 bytes .../fuzz/corpus/fuzz_threadLocal/tracked_lifecycle | Bin 0 -> 8 bytes .../fuzz_threadLocal/tracked_nullptr_recreate | Bin 0 -> 4 bytes .../fuzz_threadLocal/tracked_overwrite_padding | Bin 0 -> 8 bytes 7 files changed, 6 insertions(+) create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/double_special_values create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/double_truncated_payload create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/intptr_roundtrip create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/tracked_lifecycle create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/tracked_nullptr_recreate create mode 100644 ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/tracked_overwrite_padding diff --git a/ddprof-lib/src/test/fuzz/README.md b/ddprof-lib/src/test/fuzz/README.md index 864ab8bcd0..ef0c63bd99 100644 --- a/ddprof-lib/src/test/fuzz/README.md +++ b/ddprof-lib/src/test/fuzz/README.md @@ -177,6 +177,12 @@ infinities, subnormals, -0.0). Seed corpus files are in `corpus//`. These provide starting points for the fuzzer to understand the expected input format. +`corpus/fuzz_threadLocal/` seeds a few inputs per opcode range documented in +`fuzz_threadLocal.cpp` (tracked lifecycle/overwrite, `nullptr` recreate, double +and intptr bit-exact round-trips including NaN/inf/-0.0/subnormal, and a +truncated-payload case) to speed up initial coverage discovery. Not required — +the fuzzer runs fine from an empty corpus — but these give it a head start. + During fuzzing, libFuzzer will add new interesting inputs to the corpus directory. These additions are machine-generated and should not be committed. diff --git a/ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/double_special_values b/ddprof-lib/src/test/fuzz/corpus/fuzz_threadLocal/double_special_values new file mode 100644 index 0000000000000000000000000000000000000000..13aa2f37db8371af5c630ee9356eabc083e2d81b GIT binary patch literal 48 ecmZ>$|G@wT0SN&P^&b%I1_+Y@Bo1XV0@(oW(g+T0fPVl4XXm0 literal 0 HcmV?d00001