Crash report
What happened?
ThreadSanitizer fuzzing of free-threaded CPython main turned up 15 data races where a built-in object or interpreter-global state is accessed concurrently without the atomics or critical section the surrounding code (or the documentation) already assumes. Each has a minimal, stdlib-only reproducer, the raw TSan report, a root-cause analysis against current-main source, and a suggested fix, published as a self-contained gist (linked below).
They split into two groups: 9 are new defects, and 6 are residuals of free-threading work that is already merged or documented — a specific reader/field/path that a completed conversion left behind. The residuals are the lowest-risk to fix (the pattern and the fix are already in the tree next to them); the related issue/PR is named in the table.
I'm filing them under one umbrella so they can be picked off individually rather than flooding the tracker. To take one: open a normal CPython issue or PR and drop a comment here with the link — I'll mark it in the table. If any is a duplicate or a non-bug, say so and I'll annotate it.
Found with fusil's --tsan mode (fusil originally by @vstinner). Reports and reproducers were drafted with AI assistance (Claude Code) and then reviewed and re-verified by hand — see Disclosure.
Reproducing
- Found on
main (3.16.0a0) on a --disable-gil --with-thread-sanitizer debug build. These are concurrency bugs, not tied to an exact revision.
- Each gist ships a minimal stdlib-only
TSAN-NNNN-repro.py. Run it on a free-threaded TSan build with PYTHON_GIL=0; a real race exits non-zero and prints WARNING: ThreadSanitizer: data race. (TSan needs ASLR reduced — e.g. setarch -R — and an unlimited RLIMIT_AS, or it runs degraded.)
- Most are value-benign on aligned hardware but are genuine C-level data races (formally UB, and TSan-reported); a few carry a latent UAF/leak/crash, called out per report. All 15 reproduce in isolation with a stdlib-only script (some probabilistically — loop them; the debug-only finalization assert TSAN-0034 hits ~44 %/run).
New free-threading defects (9)
Status legend: blank = not yet filed · #N = open issue/PR · #N FIXED = filed and fixed.
| Report |
Race |
Suggested fix |
Status |
| TSAN-0001 |
multibytecodec.c: MultibyteIncrementalDecoder.getstate()/reset()/decode() on a shared decoder race its pending/pendingsize/state fields — the incremental codecs have no critical sections (incl. the iso-2022/HZ face) |
per-object critical sections on the incremental codec methods |
|
| TSAN-0005 |
_decimal.c: Decimal.__hash__ writes its lazy hash cache (self->hash) with plain stores; concurrent hash() of a shared Decimal races |
relaxed atomics on self->hash |
|
| TSAN-0006 |
itertoolsmodule.c: count.__repr__ plain-reads cnt while count_next writes it with an atomic CAS — the writer was hardened, the reader missed |
_Py_atomic_load_ssize_relaxed in count_repr |
#153908 FIXED |
| TSAN-0011 |
sysmodule.c: sys.addaudithook lazily creates interp->audit_hooks with no lock, racing should_audit on every audit event — security-relevant (PEP 578); the sibling C-level hook list is already mutex-guarded |
serialize under runtime->audit_hooks.mutex; atomics on the pointer |
|
| TSAN-0018 |
dictobject.c: readers of a shared dict's dk_nentries use plain loads while setattr/insert bump it atomically — including the public PyDict_Next C-API (via _PyType_GetSubclasses), so any extension iterating a shared dict is exposed. LOAD_KEYS_NENTRIES already exists at :237 |
LOAD_KEYS_NENTRIES at the reader sites (_PyObject_IsInstanceDictEmpty, clear_lock_held, _PyDict_Next) |
#153881 |
| TSAN-0030 |
instrumentation.c: sys.monitoring.use_tool_id() is an unsynchronized check-then-act on the interpreter-global monitoring_tool_names[] — both threads pass the guard → leak + dup ownership; free_tool_id's Py_CLEAR is a UAF/double-free. All four accessors are unlocked |
lock/atomic the tool-id registry; fix all four accessors together |
|
| TSAN-0031 |
_elementtree.c: concurrent feed of one shared TreeBuilder races its parse state (this/last/data/index/stack) — the module has zero critical sections, yet declares Py_MOD_GIL_NOT_USED |
@critical_section the whole TreeBuilder feed path (start/data/end/comment/pi/close) |
(abandoned PR gh-145569 covered only handle_end) |
| TSAN-0035 |
socketmodule.c: sock_timeout is read/written with plain accesses (gettimeout() vs setblocking()) — the one per-socket scalar the module's free-threading conversion (gh-128277) missed, while the sibling sock_fd and state->defaulttimeout were made atomic. _ssl.c inherits it |
get/set_sock_timeout over _Py_atomic_{load,store}_int64_relaxed, mirroring sock_fd |
#153935 |
| TSAN-0036 |
instrumentation.c/ceval.c: the eval loop reads a code object's active_monitors.tools[] lock-free (no_tools_for_local_event, via gen_close) while _Py_Instrument replaces the struct under LOCK_CODE — which the eval loop never takes; lazy re-instrumentation runs with the world running |
relaxed atomics on the tools[] bytes (matching the file's opcode discipline) |
(PR gh-136994 did exactly this for the bytecode tool bytes but not active_monitors) |
Residuals of existing / documented free-threading work (6)
These are a reader/field/path left behind by a completed (or documented) conversion — likely best handled as a follow-up to the named issue rather than as fresh bugs.
| Report |
Race |
Related upstream |
Status |
| TSAN-0002 |
_zstd: ZstdCompressor.last_mode is stored plain but read lock-free via its Py_T_INT member descriptor |
residual of gh-133885 / gh-134289 (added locks, left last_mode plain) |
|
| TSAN-0013 |
shared list: non-atomic readers (Py_SIZE/unpack, stringlib_bytes_join, marshal) race list_resize's atomic ob_item/ob_size publish |
reader-side residual of gh-129069; contract documented in gh-142519 |
|
| TSAN-0014 |
shared list: list.sort()'s in-place binarysort rewrite (no critical section) races a concurrent lock-free reader |
sort-path residual of the gh-129069 / gh-142519 list class |
|
| TSAN-0025 |
readline.c: set_auto_history() writes the module-global should_auto_add_history (a plain static int) unsynchronized |
belongs with the readline FT cleanup (gh-153291) |
|
| TSAN-0029 |
frameobject.c/sysmodule.c: trace_trampoline writes a running frame's f_trace with no critical section while the f_trace/f_trace_opcodes accessors are @critical_section |
the legacy settrace path not brought under the recent frame-accessor FT hardening; gh-116738 remit |
(low; needs mutating another thread's live frame) |
| TSAN-0034 |
finalization: handle_thread_shutdown_exception reads interp->threads.head in an assert() before _PyEval_StopTheWorld, racing an exiting thread's HEAD_LOCK-held tstate_delete_common write |
— |
(debug-only: the read is inside the assert; reproduced in isolation, ~44 %/run) |
Disclosure
These findings were produced with AI assistance: fusil's --tsan mode generated the concurrency stress that surfaced them, and Claude Code drafted the reports and reduced the reproducers. Every reproducer was then run and re-verified by hand on the free-threaded TSan build, and every root cause was checked against current-main source. All 15 reproduce in isolation (some probabilistically).
CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:37:00) [Clang 21.1.8 (6ubuntu1)]
Crash report
What happened?
ThreadSanitizer fuzzing of free-threaded CPython
mainturned up 15 data races where a built-in object or interpreter-global state is accessed concurrently without the atomics or critical section the surrounding code (or the documentation) already assumes. Each has a minimal, stdlib-only reproducer, the raw TSan report, a root-cause analysis against current-mainsource, and a suggested fix, published as a self-contained gist (linked below).They split into two groups: 9 are new defects, and 6 are residuals of free-threading work that is already merged or documented — a specific reader/field/path that a completed conversion left behind. The residuals are the lowest-risk to fix (the pattern and the fix are already in the tree next to them); the related issue/PR is named in the table.
I'm filing them under one umbrella so they can be picked off individually rather than flooding the tracker. To take one: open a normal CPython issue or PR and drop a comment here with the link — I'll mark it in the table. If any is a duplicate or a non-bug, say so and I'll annotate it.
Found with fusil's
--tsanmode (fusil originally by @vstinner). Reports and reproducers were drafted with AI assistance (Claude Code) and then reviewed and re-verified by hand — see Disclosure.Reproducing
main(3.16.0a0) on a--disable-gil --with-thread-sanitizerdebug build. These are concurrency bugs, not tied to an exact revision.TSAN-NNNN-repro.py. Run it on a free-threaded TSan build withPYTHON_GIL=0; a real race exits non-zero and printsWARNING: ThreadSanitizer: data race. (TSan needs ASLR reduced — e.g.setarch -R— and an unlimitedRLIMIT_AS, or it runs degraded.)New free-threading defects (9)
Status legend: blank = not yet filed ·
#N= open issue/PR ·#N FIXED= filed and fixed.multibytecodec.c:MultibyteIncrementalDecoder.getstate()/reset()/decode()on a shared decoder race itspending/pendingsize/statefields — the incremental codecs have no critical sections (incl. the iso-2022/HZ face)_decimal.c:Decimal.__hash__writes its lazy hash cache (self->hash) with plain stores; concurrenthash()of a sharedDecimalracesself->hashitertoolsmodule.c:count.__repr__plain-readscntwhilecount_nextwrites it with an atomic CAS — the writer was hardened, the reader missed_Py_atomic_load_ssize_relaxedincount_reprsysmodule.c:sys.addaudithooklazily createsinterp->audit_hookswith no lock, racingshould_auditon every audit event — security-relevant (PEP 578); the sibling C-level hook list is already mutex-guardedruntime->audit_hooks.mutex; atomics on the pointerdictobject.c: readers of a shared dict'sdk_nentriesuse plain loads whilesetattr/insert bump it atomically — including the publicPyDict_NextC-API (via_PyType_GetSubclasses), so any extension iterating a shared dict is exposed.LOAD_KEYS_NENTRIESalready exists at:237LOAD_KEYS_NENTRIESat the reader sites (_PyObject_IsInstanceDictEmpty,clear_lock_held,_PyDict_Next)instrumentation.c:sys.monitoring.use_tool_id()is an unsynchronized check-then-act on the interpreter-globalmonitoring_tool_names[]— both threads pass the guard → leak + dup ownership;free_tool_id'sPy_CLEARis a UAF/double-free. All four accessors are unlocked_elementtree.c: concurrent feed of one sharedTreeBuilderraces its parse state (this/last/data/index/stack) — the module has zero critical sections, yet declaresPy_MOD_GIL_NOT_USED@critical_sectionthe wholeTreeBuilderfeed path (start/data/end/comment/pi/close)handle_end)socketmodule.c:sock_timeoutis read/written with plain accesses (gettimeout()vssetblocking()) — the one per-socket scalar the module's free-threading conversion (gh-128277) missed, while the siblingsock_fdandstate->defaulttimeoutwere made atomic._ssl.cinherits itget/set_sock_timeoutover_Py_atomic_{load,store}_int64_relaxed, mirroringsock_fdinstrumentation.c/ceval.c: the eval loop reads a code object'sactive_monitors.tools[]lock-free (no_tools_for_local_event, viagen_close) while_Py_Instrumentreplaces the struct underLOCK_CODE— which the eval loop never takes; lazy re-instrumentation runs with the world runningtools[]bytes (matching the file's opcode discipline)active_monitors)Residuals of existing / documented free-threading work (6)
These are a reader/field/path left behind by a completed (or documented) conversion — likely best handled as a follow-up to the named issue rather than as fresh bugs.
_zstd:ZstdCompressor.last_modeis stored plain but read lock-free via itsPy_T_INTmember descriptorlast_modeplain)list: non-atomic readers (Py_SIZE/unpack,stringlib_bytes_join,marshal) racelist_resize's atomicob_item/ob_sizepublishlist:list.sort()'s in-placebinarysortrewrite (no critical section) races a concurrent lock-free readerreadline.c:set_auto_history()writes the module-globalshould_auto_add_history(a plainstatic int) unsynchronizedframeobject.c/sysmodule.c:trace_trampolinewrites a running frame'sf_tracewith no critical section while thef_trace/f_trace_opcodesaccessors are@critical_sectionsettracepath not brought under the recent frame-accessor FT hardening; gh-116738 remithandle_thread_shutdown_exceptionreadsinterp->threads.headin anassert()before_PyEval_StopTheWorld, racing an exiting thread'sHEAD_LOCK-heldtstate_delete_commonwriteDisclosure
These findings were produced with AI assistance: fusil's
--tsanmode generated the concurrency stress that surfaced them, and Claude Code drafted the reports and reduced the reproducers. Every reproducer was then run and re-verified by hand on the free-threaded TSan build, and every root cause was checked against current-mainsource. All 15 reproduce in isolation (some probabilistically).CPython versions tested on:
CPython main branch
Operating systems tested on:
Linux
Output from running 'python -VV' on the command line:
Python 3.16.0a0 free-threading build (heads/main:bcf98ddbc40, Jul 4 2026, 15:37:00) [Clang 21.1.8 (6ubuntu1)]