Skip to content

Commit d855876

Browse files
authored
test: add fuzzer for thread local (#645)
1 parent 6d392a7 commit d855876

8 files changed

Lines changed: 275 additions & 0 deletions

File tree

ddprof-lib/src/test/fuzz/README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,11 +154,35 @@ the 49152-entry expansion threshold, forcing the multi-node `_prev` chain that e
154154
**Regression guard — detects if these bugs are reintroduced**: heap-use-after-free (ASan), data race on `_table` (TSan), null-deref in
155155
`processTraces` from use-after-free when `_prev` chain is not fully disconnected.
156156

157+
### fuzz_threadLocal.cpp
158+
**Target**: `ThreadLocal<T, CREATE_FUNC, CLEAN_FUNC>` (and the `double`/generic-pointer
159+
specializations) - the pthread-TSD-based alternative to `thread_local` used because
160+
`pthread_(get/set)specific()` cannot be safely introduced mid-signal-handling on some
161+
platforms (see the comment block in `threadLocal.h`).
162+
163+
Each input is replayed on a freshly spawned thread that is joined before returning, so
164+
the pthread key destructor for whatever is left in a slot fires synchronously inside
165+
that `join()` - the one part of the lifecycle a persistent driver thread would never
166+
exercise. A shadow model tracks the expected create/get/set/clear state and traps on
167+
any mismatch.
168+
169+
**Expected bugs**: stale/mismatched values from `get()`, `create_tracked()`/`free_tracked()`
170+
running the wrong number of times (double free or leak across `clear()`, an overwriting
171+
`set()`, or thread-exit teardown), `set(nullptr)` failing to trigger a lazy recreate on
172+
the next `get()`, and non-bit-exact round-trips for the `double` specialization (NaN,
173+
infinities, subnormals, -0.0).
174+
157175
## Corpus
158176

159177
Seed corpus files are in `corpus/<target_name>/`. These provide starting points
160178
for the fuzzer to understand the expected input format.
161179

180+
`corpus/fuzz_threadLocal/` seeds a few inputs per opcode range documented in
181+
`fuzz_threadLocal.cpp` (tracked lifecycle/overwrite, `nullptr` recreate, double
182+
and intptr bit-exact round-trips including NaN/inf/-0.0/subnormal, and a
183+
truncated-payload case) to speed up initial coverage discovery. Not required —
184+
the fuzzer runs fine from an empty corpus — but these give it a head start.
185+
162186
During fuzzing, libFuzzer will add new interesting inputs to the corpus
163187
directory. These additions are machine-generated and should not be committed.
164188

Binary file not shown.
Binary file not shown.
48 Bytes
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
/*
2+
* Copyright 2026, Datadog, Inc.
3+
* SPDX-License-Identifier: Apache-2.0
4+
*
5+
* libFuzzer target for ThreadLocal<T, CREATE_FUNC, CLEAN_FUNC> (threadLocal.h).
6+
*
7+
* ThreadLocal wraps pthread TSD to work around pthread_(get/set)specific() not
8+
* being safe to introduce mid-signal-handling (see the big comment at the top
9+
* of threadLocal.h). The interesting bug surface is the lazy create/cleanup
10+
* state machine across a *fresh thread's* full lifecycle — a slot that starts
11+
* unset, is get()/set()/clear()'d in some order, and is then torn down by the
12+
* pthread key destructor when the thread exits. Running everything on
13+
* libFuzzer's persistent driver thread would never exercise the exit-time
14+
* destructor path, so each input is replayed on a freshly spawned thread that
15+
* is joined before returning — the destructor for whatever is left in the
16+
* slot fires synchronously inside that join().
17+
*
18+
* Input bytes are consumed as a stream of operations against three static
19+
* ThreadLocal specializations that live side by side in the same input:
20+
* 0x00-0x0F -> tracked.get() (lazy-creates via create_tracked() if empty)
21+
* 0x10-0x1F -> tracked.clear() (invokes free_tracked() if non-empty)
22+
* 0x20-0x2F -> tracked.set(fresh pointer) (caller frees any old value first,
23+
* mirroring the documented contract)
24+
* 0x30-0x3F -> tracked.set(nullptr) (frees old value; next get() must
25+
* lazily recreate, per threadLocal.h)
26+
* 0x40-0x4F -> double.set(next 8 bytes as raw bit pattern)
27+
* 0x50-0x5F -> double.get() (verify exact bit round-trip)
28+
* 0x60-0x6F -> double.clear()
29+
* 0x70-0x7F -> intptr.set(next 8 bytes as raw bit pattern)
30+
* 0x80-0x8F -> intptr.get() (verify exact round-trip)
31+
* 0x90-0x9F -> intptr.clear()
32+
* 0xA0-0xFF -> no-op (padding / density filler for the mutator)
33+
*
34+
* Invariants verified (violation -> __builtin_trap() -> ASan/fuzzer crash):
35+
* I1. get() never returns a stale/mismatched value: the payload behind the
36+
* pointer/bits returned always matches what the model last stored.
37+
* I2. create_tracked() runs at most once per "empty -> get()" transition and
38+
* free_tracked() runs exactly once per value that ever occupied the slot
39+
* (via clear(), an overwriting set(), or the pthread key destructor at
40+
* thread exit) - checked via the create/free counters delta across the
41+
* whole spawned-thread run.
42+
* I3. set(nullptr) followed by get() lazily recreates (documented contract).
43+
* I4. double/intptr specializations round-trip bit-for-bit, including NaN,
44+
* infinities, subnormals and -0.0, which plain == comparison would hide.
45+
*/
46+
47+
#include <stddef.h>
48+
#include <stdint.h>
49+
50+
#include <atomic>
51+
#include <cstring>
52+
#include <system_error>
53+
#include <thread>
54+
55+
#include "threadLocal.h"
56+
57+
namespace {
58+
59+
std::atomic<uint64_t> g_create_count{0};
60+
std::atomic<uint64_t> g_free_count{0};
61+
62+
void *create_tracked() {
63+
g_create_count.fetch_add(1, std::memory_order_relaxed);
64+
return new int(1234);
65+
}
66+
67+
void free_tracked(void *p) {
68+
g_free_count.fetch_add(1, std::memory_order_relaxed);
69+
delete static_cast<int *>(p);
70+
}
71+
72+
ThreadLocal<int *, create_tracked, free_tracked> g_tracked_tl;
73+
ThreadLocal<double> g_double_tl;
74+
ThreadLocal<intptr_t> g_intptr_tl;
75+
76+
u64 take8(const uint8_t *data, size_t pos) {
77+
u64 v = 0;
78+
for (int i = 0; i < 8; i++) {
79+
v = (v << 8) | data[pos + i];
80+
}
81+
return v;
82+
}
83+
84+
// Runs the whole decoded op sequence on the calling thread. Executed inside a
85+
// freshly spawned std::thread so the tracked slot starts empty and whatever
86+
// is left occupying it is torn down by the pthread key destructor at thread
87+
// exit (join() below), exercising the path a persistent fuzzer-driver thread
88+
// never would.
89+
void runOnWorkerThread(const uint8_t *data, size_t size, uint64_t *expected_creates,
90+
uint64_t *expected_frees) {
91+
bool tracked_present = false;
92+
int *tracked_ptr = nullptr;
93+
int tracked_expected = 0;
94+
int manual_marker_seq = 1;
95+
96+
bool double_present = false;
97+
u64 double_expected_bits = 0;
98+
99+
bool intptr_present = false;
100+
intptr_t intptr_expected = 0;
101+
102+
size_t pos = 0;
103+
while (pos < size) {
104+
uint8_t op = data[pos++];
105+
106+
if (op < 0x10) {
107+
// tracked.get()
108+
int *p = g_tracked_tl.get();
109+
if (!tracked_present) {
110+
// Empty slot with a non-null CREATE_FUNC must lazily create.
111+
if (p == nullptr) __builtin_trap();
112+
tracked_present = true;
113+
tracked_ptr = p;
114+
tracked_expected = 1234;
115+
(*expected_creates)++;
116+
} else if (p != tracked_ptr) {
117+
__builtin_trap(); // pointer identity must be stable across get()s
118+
}
119+
if (*p != tracked_expected) __builtin_trap(); // I1: payload corruption
120+
121+
} else if (op < 0x20) {
122+
// tracked.clear()
123+
g_tracked_tl.clear();
124+
if (tracked_present) {
125+
(*expected_frees)++;
126+
tracked_present = false;
127+
tracked_ptr = nullptr;
128+
}
129+
130+
} else if (op < 0x30) {
131+
// tracked.set(fresh manually-owned pointer). Per the documented
132+
// contract the caller frees any prior value itself before overwriting.
133+
if (tracked_present) {
134+
free_tracked(tracked_ptr);
135+
(*expected_frees)++;
136+
tracked_present = false;
137+
}
138+
// Use a negative, monotonically distinct marker so it can never be
139+
// confused with create_tracked()'s 1234 sentinel.
140+
int marker = -(manual_marker_seq++);
141+
int *fresh = new int(marker);
142+
g_tracked_tl.set(fresh);
143+
tracked_present = true;
144+
tracked_ptr = fresh;
145+
tracked_expected = marker;
146+
147+
} else if (op < 0x40) {
148+
// tracked.set(nullptr): caller frees any prior value, then the next
149+
// get() must lazily recreate (I3).
150+
if (tracked_present) {
151+
free_tracked(tracked_ptr);
152+
(*expected_frees)++;
153+
}
154+
g_tracked_tl.set(nullptr);
155+
tracked_present = false;
156+
tracked_ptr = nullptr;
157+
158+
} else if (op < 0x50) {
159+
// double.set(next 8 bytes as raw bits)
160+
if (pos + 7 >= size) break;
161+
u64 bits = take8(data, pos);
162+
pos += 8;
163+
double v;
164+
memcpy(&v, &bits, sizeof(v));
165+
g_double_tl.set(v);
166+
double_present = true;
167+
double_expected_bits = bits;
168+
169+
} else if (op < 0x60) {
170+
// double.get() - verify exact bit pattern, not value equality (NaN-safe)
171+
double v = g_double_tl.get();
172+
u64 bits;
173+
memcpy(&bits, &v, sizeof(bits));
174+
u64 expected = double_present ? double_expected_bits : 0;
175+
if (bits != expected) __builtin_trap(); // I4
176+
177+
} else if (op < 0x70) {
178+
// double.clear()
179+
g_double_tl.clear();
180+
double_present = false;
181+
182+
} else if (op < 0x80) {
183+
// intptr.set(next 8 bytes as raw bits)
184+
if (pos + 7 >= size) break;
185+
u64 bits = take8(data, pos);
186+
pos += 8;
187+
intptr_t v = static_cast<intptr_t>(bits);
188+
g_intptr_tl.set(v);
189+
intptr_present = true;
190+
intptr_expected = v;
191+
192+
} else if (op < 0x90) {
193+
// intptr.get()
194+
intptr_t v = g_intptr_tl.get();
195+
intptr_t expected = intptr_present ? intptr_expected : 0;
196+
if (v != expected) __builtin_trap(); // I4
197+
198+
} else if (op < 0xA0) {
199+
// intptr.clear()
200+
g_intptr_tl.clear();
201+
intptr_present = false;
202+
203+
}
204+
// 0xA0-0xFF: no-op padding, left for the mutator to grow/shrink sequences.
205+
}
206+
207+
// Whatever is left in the tracked slot when this thread exits is torn down
208+
// by the pthread key destructor, synchronized-with by the caller's join().
209+
if (tracked_present) {
210+
(*expected_frees)++;
211+
}
212+
}
213+
214+
} // namespace
215+
216+
extern "C" int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
217+
if (size == 0) return 0;
218+
219+
uint64_t expected_creates = 0;
220+
uint64_t expected_frees = 0;
221+
uint64_t create_before = g_create_count.load(std::memory_order_relaxed);
222+
uint64_t free_before = g_free_count.load(std::memory_order_relaxed);
223+
224+
try {
225+
std::thread worker(runOnWorkerThread, data, size, &expected_creates,
226+
&expected_frees);
227+
worker.join();
228+
} catch (const std::system_error &e) {
229+
// Transient thread-creation failure (e.g. resource exhaustion under a
230+
// heavily parallel fuzzer run) - not a bug in ThreadLocal itself.
231+
// Anything else (including a failure from join() itself) is unexpected
232+
// and should surface as a crash rather than be silently swallowed.
233+
if (e.code() == std::errc::resource_unavailable_try_again) {
234+
return 0;
235+
}
236+
throw;
237+
}
238+
239+
uint64_t create_after = g_create_count.load(std::memory_order_relaxed);
240+
uint64_t free_after = g_free_count.load(std::memory_order_relaxed);
241+
242+
if (create_after - create_before != expected_creates) {
243+
__builtin_trap(); // I2: create_tracked() ran the wrong number of times
244+
}
245+
if (free_after - free_before != expected_frees) {
246+
__builtin_trap(); // I2: free_tracked() ran the wrong number of times
247+
// (double free / leak from the TSD destructor path)
248+
}
249+
250+
return 0;
251+
}

0 commit comments

Comments
 (0)