Repository navigation
Data race: TextIOWrapper.detach() stores self->buffer = NULL non-atomically #154523
Copy link
Copy link
Closed
Labels
extension-modulesC modules in the Modules dirC modules in the Modules dirtopic-IOtopic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Description
Bug report
Bug description:
In the free-threaded build, TextIOWrapper.detach() clears the self->buffer slot with a plain store, while reading tw.buffer goes through a LOAD_ATTR_SLOT specialization that reads the same slot with FT_ATOMIC_LOAD_PTR.
Writer — _io_TextIOWrapper_detach_impl:
Lines 1630 to 1645 in a2a8466
| _io_TextIOWrapper_detach_impl(textio *self) | |
| /*[clinic end generated code: output=7ba3715cd032d5f2 input=c908a3b4ef203b0f]*/ | |
| { | |
| PyObject *buffer; | |
| if (_PyFile_Flush((PyObject *)self) < 0) { | |
| return NULL; | |
| } | |
| /* _PyFile_Flush could detach before returning; raise an exception. */ | |
| buffer = buffer_access_safe(self); | |
| if (buffer == NULL) { | |
| return NULL; | |
| } | |
| self->buffer = NULL; | |
| self->detached = 1; | |
| return buffer; | |
| } |
Reader — LOAD_ATTR_SLOT reads the slot atomically:
cpython/Python/generated_cases.c.h
Lines 9493 to 9503 in a2a8466
| // _LOAD_ATTR_SLOT | |
| { | |
| uint16_t index = read_u16(&this_instr[4].cache); | |
| PyObject *owner_o = PyStackRef_AsPyObjectBorrow(owner); | |
| PyObject **addr = (PyObject **)((char *)owner_o + index); | |
| PyObject *attr_o = FT_ATOMIC_LOAD_PTR(*addr); | |
| if (attr_o == NULL) { | |
| UPDATE_MISS_STATS(LOAD_ATTR); | |
| assert(_PyOpcode_Deopt[opcode] == (LOAD_ATTR)); | |
| JUMP_TO_PREDICTED(LOAD_ATTR); | |
| } |
So tw.buffer racing tw.detach() is a plain-store-vs-atomic-load data race on the buffer slot. detach() should store the slot atomically.
Reproducer:
import io
from threading import Thread
def make():
return io.TextIOWrapper(io.BytesIO(b'x' * 16))
slot = [make()]
def reader():
for _ in range(20000):
try:
_ = slot[0].buffer
except Exception:
pass
def detacher():
for _ in range(20000):
try:
slot[0].detach()
except Exception:
pass
slot[0] = make()
threads = [Thread(target=reader) for _ in range(6)]
threads += [Thread(target=detacher) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()TSAN Report:
WARNING: ThreadSanitizer: data race (pid=1522499)
Write of size 8 at 0x7fffd0100110 by thread T7:
#0 _io_TextIOWrapper_detach_impl /cpython/./Modules/_io/textio.c:1642:18
#1 _io_TextIOWrapper_detach /cpython/./Modules/_io/clinic/textio.c.h:793:20
#2 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:4330:35
Previous atomic read of size 8 at 0x7fffd0100110 by thread T6:
#0 _Py_atomic_load_ptr /cpython/./Include/cpython/pyatomic_gcc.h:300:18
#1 _PyEval_EvalFrameDefault /cpython/Python/generated_cases.c.h:9588:36
#2 _PyEval_EvalFrame /cpython/./Include/internal/pycore_ceval.h:122:16
SUMMARY: ThreadSanitizer: data race /cpython/./Modules/_io/textio.c:1642:18 in _io_TextIOWrapper_detach_impl
==================
CPython versions tested on:
CPython main branch
Operating systems tested on:
macOS, Linux
Linked PRs
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
extension-modulesC modules in the Modules dirC modules in the Modules dirtopic-IOtopic-free-threadingtype-bugAn unexpected behavior, bug, or errorAn unexpected behavior, bug, or error
Activity
devdanzin commentedon Jul 23, 2026
Agree this is a genuine C-level data race worth the "store the slot atomically" fix, but as a severity note: it looks value-benign on aligned hardware — I couldn't turn it into a crash.
Tried on a free-threaded debug build and a free-threaded ASan build of
main: the reader/detacher race (amplified — readers also touch the returned buffer), and separately concurrentdetach()on a shared wrapper (barrier-synchronized, to try for a double-Py_CLEAR/double-DECREF), both run clean across many attempts.That matches the code:
_io_TextIOWrapper_detach_implis@critical_section(so concurrentdetach()can't double-free — the second one getsNULLfrombuffer_access_safeand raises), and it transfers ownership of the buffer to the caller viareturn bufferrather thanPy_CLEAR-ing it — so theLOAD_ATTR_SLOTreader's atomic-load-plus-try-incref never increfs a freed object. What remains is exactly a plain pointer store (self->buffer = NULL) vs an atomic pointer load on the aligned slot: real UB that TSan correctly flags, but the reader only ever observes the old valid pointer orNULL. So the atomic-store fix is right as UB hygiene; there doesn't appear to be a memory-safety issue behind it.Checked and drafted with AI assistance (Claude Code), verified by hand on free-threaded debug/ASan builds of
main.kumaraditya303 commentedon Jul 30, 2026
The fix should change
bufferto be a getset descriptor and use critical section to access it.gh-154523: Fix data-race in `TextIOWrapper.detach()` (#154565)
pythongh-154523: Fix data-race in `TextIOWrapper.detach()` (python#15…
[3.15] gh-157364: Fix use-after-free in io.TextIOWrapper during reent…
[3.14] gh-157364: Fix use-after-free in io.TextIOWrapper during reent…