From 42caa08ed68701af7e039caea089312fb8b47341 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sat, 14 Mar 2026 18:50:02 -0700 Subject: [PATCH 01/11] gh-143008: Safer TextIOWrapper underlying stream access TextIOWrapper keeps its underlying stream in a member called `self->buffer`. That stream can be detached by user code, such as custom `.flush` implementations resulting in `self->buffer` being set to NULL. The implementation often checked at the start of functions if `self->buffer` is in a good state, but did not always recheck after other Python code was called which could modify `self->buffer`. The cases which need to be re-checked are hard to spot so rather than rely on reviewer effort create better safety by making all self->buffer access go through helper functions. Thank you yihong0618 for the test, NEWS and initial implementation in gh-143041. Co-authored-by: yihong0618 --- Lib/test/test_io/test_textio.py | 49 +++++++ ...-12-21-17-56-37.gh-issue-143008.aakErJ.rst | 1 + Modules/_io/textio.c | 127 +++++++++++++----- 3 files changed, 144 insertions(+), 33 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index d725f9212ceaae..1ecba967afea93 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1560,6 +1560,55 @@ def closed(self): wrapper = self.TextIOWrapper(raw) wrapper.close() # should not crash + def test_reentrant_detach_during_flush(self): + # gh-143008: Reentrant detach() during flush should raise RuntimeError + # instead of crashing. + wrapper = None + wrapper_ref = None + + class EvilBuffer(self.BufferedRandom): + detach_on_write = False + + def flush(self): + wrapper = wrapper_ref() if wrapper_ref is not None else None + if wrapper is not None and not self.detach_on_write: + wrapper.detach() + return super().flush() + + def write(self, b): + wrapper = wrapper_ref() if wrapper_ref is not None else None + if wrapper is not None and self.detach_on_write: + wrapper.detach() + return len(b) + + # Many calls could result in the same null self->buffer crash. + tests = [ + ('truncate', lambda: wrapper.truncate(0)), + ('close', lambda: wrapper.close()), + ('detach', lambda: wrapper.detach()), + ('seek', lambda: wrapper.seek(0)), + ('tell', lambda: wrapper.tell()), + ('reconfigure', lambda: wrapper.reconfigure(line_buffering=True)), + ] + for name, method in tests: + with self.subTest(name): + wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') + wrapper_ref = weakref.ref(wrapper) + # These used to crash; now either return detached or keep + # running until out of stack. + self.assertRaisesRegex(RuntimeError, "detached|recursion depth exceeded", method) + wrapper_ref = None + del wrapper + + with self.subTest('read via writeflush'): + EvilBuffer.detach_on_write = True + wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') + wrapper_ref = weakref.ref(wrapper) + wrapper.write('x') + self.assertRaisesRegex(ValueError, "detached", wrapper.read) + wrapper_ref = None + del wrapper + class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase): shutdown_error = "LookupError: unknown encoding: ascii" diff --git a/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst new file mode 100644 index 00000000000000..cac314452eaa4c --- /dev/null +++ b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst @@ -0,0 +1 @@ +Fix crash in :class:`io.TextIOWrapper` when reentrant :meth:`io.TextIOBase.detach` called. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 347bfe976619e8..2d3a62e3eec185 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -672,6 +672,8 @@ struct textio int ok; /* initialized? */ int detached; Py_ssize_t chunk_size; + /* Use helpers like _textiowrapper_buffer_get to access buffer; many + operations can set it to NULL (ref: gh-143008, gh-142594). */ PyObject *buffer; PyObject *encoding; PyObject *encoder; @@ -729,6 +731,55 @@ struct textio #define textio_CAST(op) ((textio *)(op)) +/* Helpers to safely operate on self->buffer. + +self->buffer can be detached (set to NULL) by any user code that is called +leading to NULL pointer dereferences (ex. gh-143008, gh-142594). Protect against +that by using helpers to check self->buffer validity at callsites. */ +static PyObject * +_textiowrapper_buffer_safe(textio *self) { + /* Check self->buffer directly but match errors of CHECK_ATTACHED since this + is called during construction and destruction where self->ok which + CHECK_ATTACHED uses does not imply self->buffer state. */ + if (self->buffer == NULL) { + if (self->ok <= 0) + PyErr_SetString(PyExc_ValueError, + "I/O operation on uninitialized object"); + else + PyErr_SetString(PyExc_ValueError, + "underlying buffer has been detached"); + return NULL; + } + return self->buffer; +} + +static PyObject * +_textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) { + PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { + return NULL; + } + return PyObject_GetAttr(buffer, attr_name); +} + +static PyObject * +_textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) { + PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { + return NULL; + } + return PyObject_CallMethodNoArgs(buffer, name); +} + +static PyObject * +_textiowrapper_buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) { + PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { + return NULL; + } + return PyObject_CallMethodOneArg(buffer, name, arg); +} + static void textiowrapper_set_decoded_chars(textio *self, PyObject *chars); @@ -898,7 +949,7 @@ _textiowrapper_set_decoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(readable)); + res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(readable)); if (res == NULL) return -1; @@ -954,7 +1005,7 @@ _textiowrapper_set_encoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(writable)); + res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(writable)); if (res == NULL) return -1; @@ -1000,8 +1051,8 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; - PyObject *cookieObj = PyObject_CallMethodNoArgs( - self->buffer, &_Py_ID(tell)); + PyObject *cookieObj = _textiowrapper_buffer_callmethod_noargs( + self, &_Py_ID(tell)); if (cookieObj == NULL) { return -1; } @@ -1641,7 +1692,7 @@ _textiowrapper_writeflush(textio *self) PyObject *ret; do { - ret = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(write), b); + ret = _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(write), b); } while (ret == NULL && _PyIO_trap_eintr()); Py_DECREF(b); // NOTE: We cleared buffer but we don't know how many bytes are actually written @@ -1787,7 +1838,8 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } if (needflush) { - if (_PyFile_Flush(self->buffer) < 0) { + PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL || _PyFile_Flush(buffer) < 0) { return NULL; } } @@ -1917,9 +1969,9 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t size_hint) if (chunk_size == NULL) goto fail; - input_chunk = PyObject_CallMethodOneArg(self->buffer, - (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), - chunk_size); + input_chunk = _textiowrapper_buffer_callmethod_onearg(self, + (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), + chunk_size); Py_DECREF(chunk_size); if (input_chunk == NULL) goto fail; @@ -2003,7 +2055,8 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n) if (n < 0) { /* Read everything */ - PyObject *bytes = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(read)); + PyObject *bytes = _textiowrapper_buffer_callmethod_noargs(self, + &_Py_ID(read)); PyObject *decoded; if (bytes == NULL) goto fail; @@ -2600,7 +2653,11 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) Py_DECREF(res); } - res = _PyObject_CallMethod(self->buffer, &_Py_ID(seek), "ii", 0, 2); + PyObject *buf = _textiowrapper_buffer_safe(self); + if (buf == NULL) { + goto fail; + } + res = _PyObject_CallMethod(buf, &_Py_ID(seek), "ii", 0, 2); Py_CLEAR(cookieObj); if (res == NULL) goto fail; @@ -2648,7 +2705,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) posobj = PyLong_FromOff_t(cookie.start_pos); if (posobj == NULL) goto fail; - res = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(seek), posobj); + res = _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(seek), posobj); Py_DECREF(posobj); if (res == NULL) goto fail; @@ -2665,8 +2722,14 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) if (cookie.chars_to_skip) { /* Just like _read_chunk, feed the decoder and save a snapshot. */ - PyObject *input_chunk = _PyObject_CallMethod(self->buffer, &_Py_ID(read), - "i", cookie.bytes_to_feed); + PyObject *bytes_to_feed = PyLong_FromLong(cookie.bytes_to_feed); + if (bytes_to_feed == NULL) { + goto fail; + } + PyObject *input_chunk = _textiowrapper_buffer_callmethod_onearg(self, + &_Py_ID(read), bytes_to_feed); + Py_DECREF(bytes_to_feed); + PyObject *decoded; if (input_chunk == NULL) @@ -2765,7 +2828,7 @@ _io_TextIOWrapper_tell_impl(textio *self) goto fail; } - posobj = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(tell)); + posobj = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(tell)); if (posobj == NULL) goto fail; @@ -2975,7 +3038,7 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) return NULL; } - return PyObject_CallMethodOneArg(self->buffer, &_Py_ID(truncate), pos); + return _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(truncate), pos); } static PyObject * @@ -3057,8 +3120,7 @@ static PyObject * _io_TextIOWrapper_fileno_impl(textio *self) /*[clinic end generated code: output=21490a4c3da13e6c input=515e1196aceb97ab]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(fileno)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(fileno)); } /*[clinic input] @@ -3070,8 +3132,7 @@ static PyObject * _io_TextIOWrapper_seekable_impl(textio *self) /*[clinic end generated code: output=ab223dbbcffc0f00 input=71c4c092736c549b]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(seekable)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(seekable)); } /*[clinic input] @@ -3083,8 +3144,7 @@ static PyObject * _io_TextIOWrapper_readable_impl(textio *self) /*[clinic end generated code: output=72ff7ba289a8a91b input=80438d1f01b0a89b]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(readable)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(readable)); } /*[clinic input] @@ -3096,8 +3156,7 @@ static PyObject * _io_TextIOWrapper_writable_impl(textio *self) /*[clinic end generated code: output=a728c71790d03200 input=9d6c22befb0c340a]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(writable)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(writable)); } /*[clinic input] @@ -3109,8 +3168,7 @@ static PyObject * _io_TextIOWrapper_isatty_impl(textio *self) /*[clinic end generated code: output=12be1a35bace882e input=7f83ff04d4d1733d]*/ { - CHECK_ATTACHED(self); - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(isatty)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(isatty)); } /*[clinic input] @@ -3127,7 +3185,7 @@ _io_TextIOWrapper_flush_impl(textio *self) self->telling = self->seekable; if (_textiowrapper_writeflush(self) < 0) return NULL; - return PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(flush)); + return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(flush)); } /*[clinic input] @@ -3160,8 +3218,8 @@ _io_TextIOWrapper_close_impl(textio *self) else { PyObject *exc = NULL; if (self->finalizing) { - res = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(_dealloc_warn), - (PyObject *)self); + res = _textiowrapper_buffer_callmethod_onearg(self, + &_Py_ID(_dealloc_warn), (PyObject *)self); if (res) { Py_DECREF(res); } @@ -3173,7 +3231,7 @@ _io_TextIOWrapper_close_impl(textio *self) exc = PyErr_GetRaisedException(); } - res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(close)); + res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(close)); if (exc != NULL) { _PyErr_ChainExceptions1(exc); Py_CLEAR(res); @@ -3241,8 +3299,7 @@ static PyObject * _io_TextIOWrapper_name_get_impl(textio *self) /*[clinic end generated code: output=8c2f1d6d8756af40 input=26ecec9b39e30e07]*/ { - CHECK_ATTACHED(self); - return PyObject_GetAttr(self->buffer, &_Py_ID(name)); + return _textiowrapper_buffer_get_attr(self, &_Py_ID(name)); } /*[clinic input] @@ -3255,8 +3312,12 @@ static PyObject * _io_TextIOWrapper_closed_get_impl(textio *self) /*[clinic end generated code: output=b49b68f443a85e3c input=7dfcf43f63c7003d]*/ { + /* During destruction self->ok is 0 but self->buffer is non-NULL and this + needs to error in that case which the safe buffer wrapper does not. + + Match original behavior by calling CHECK_ATTACHED explicitly. */ CHECK_ATTACHED(self); - return PyObject_GetAttr(self->buffer, &_Py_ID(closed)); + return _textiowrapper_buffer_get_attr(self, &_Py_ID(closed)); } /*[clinic input] From 551c3eb5f5d57d97374c297a558e6802077edb64 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Sat, 14 Mar 2026 19:12:24 -0700 Subject: [PATCH 02/11] fixup: Refer to existing helpers --- Modules/_io/textio.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 2d3a62e3eec185..497412f484def3 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -672,7 +672,7 @@ struct textio int ok; /* initialized? */ int detached; Py_ssize_t chunk_size; - /* Use helpers like _textiowrapper_buffer_get to access buffer; many + /* Use helpers _textiowrapper_buffer_* to access buffer; many operations can set it to NULL (ref: gh-143008, gh-142594). */ PyObject *buffer; PyObject *encoding; From 4f455f4f6dbab848542fb5255530d017a5b48500 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Mon, 16 Mar 2026 22:48:49 -0700 Subject: [PATCH 03/11] fixup: More precise recursion errorcatching --- Lib/test/test_io/test_textio.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 1ecba967afea93..3a642e027d8547 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -11,7 +11,11 @@ import weakref from collections import UserList from test import support -from test.support import os_helper, threading_helper +from test.support import ( + os_helper, + set_recursion_limit, + threading_helper +) from test.support.script_helper import assert_python_ok from .utils import CTestCase, PyTestCase @@ -1591,12 +1595,12 @@ def write(self, b): ('reconfigure', lambda: wrapper.reconfigure(line_buffering=True)), ] for name, method in tests: - with self.subTest(name): + with self.subTest(name), set_recursion_limit(100): wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') wrapper_ref = weakref.ref(wrapper) # These used to crash; now either return detached or keep # running until out of stack. - self.assertRaisesRegex(RuntimeError, "detached|recursion depth exceeded", method) + self.assertRaises((RecursionError, RuntimeError), method) wrapper_ref = None del wrapper From ec39a6118f238cb8bc2f34088c2e4e5ee18acf00 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Mon, 16 Mar 2026 23:08:52 -0700 Subject: [PATCH 04/11] fixup: Formatting fixes, simplify test --- Lib/test/test_io/test_textio.py | 15 +++++++-------- Modules/_io/textio.c | 12 ++++++------ 2 files changed, 13 insertions(+), 14 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 3a642e027d8547..7ecfc2d151036f 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1568,19 +1568,19 @@ def test_reentrant_detach_during_flush(self): # gh-143008: Reentrant detach() during flush should raise RuntimeError # instead of crashing. wrapper = None - wrapper_ref = None + wrapper_ref = lambda: None class EvilBuffer(self.BufferedRandom): detach_on_write = False def flush(self): - wrapper = wrapper_ref() if wrapper_ref is not None else None + wrapper = wrapper_ref() if wrapper is not None and not self.detach_on_write: wrapper.detach() return super().flush() def write(self, b): - wrapper = wrapper_ref() if wrapper_ref is not None else None + wrapper = wrapper_ref() if wrapper is not None and self.detach_on_write: wrapper.detach() return len(b) @@ -1598,10 +1598,9 @@ def write(self, b): with self.subTest(name), set_recursion_limit(100): wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') wrapper_ref = weakref.ref(wrapper) - # These used to crash; now either return detached or keep - # running until out of stack. - self.assertRaises((RecursionError, RuntimeError), method) - wrapper_ref = None + # Used to crash; now will run out of stack. + self.assertRaises(RecursionError, method) + wrapper_ref = lambda: None del wrapper with self.subTest('read via writeflush'): @@ -1610,7 +1609,7 @@ def write(self, b): wrapper_ref = weakref.ref(wrapper) wrapper.write('x') self.assertRaisesRegex(ValueError, "detached", wrapper.read) - wrapper_ref = None + wrapper_ref = lambda: None del wrapper diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 497412f484def3..765de2b818f809 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1052,7 +1052,7 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; PyObject *cookieObj = _textiowrapper_buffer_callmethod_noargs( - self, &_Py_ID(tell)); + self, &_Py_ID(tell)); if (cookieObj == NULL) { return -1; } @@ -1970,8 +1970,8 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t size_hint) goto fail; input_chunk = _textiowrapper_buffer_callmethod_onearg(self, - (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), - chunk_size); + (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), + chunk_size); Py_DECREF(chunk_size); if (input_chunk == NULL) goto fail; @@ -2056,7 +2056,7 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n) if (n < 0) { /* Read everything */ PyObject *bytes = _textiowrapper_buffer_callmethod_noargs(self, - &_Py_ID(read)); + &_Py_ID(read)); PyObject *decoded; if (bytes == NULL) goto fail; @@ -2727,7 +2727,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } PyObject *input_chunk = _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(read), bytes_to_feed); + &_Py_ID(read), bytes_to_feed); Py_DECREF(bytes_to_feed); PyObject *decoded; @@ -3219,7 +3219,7 @@ _io_TextIOWrapper_close_impl(textio *self) PyObject *exc = NULL; if (self->finalizing) { res = _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(_dealloc_warn), (PyObject *)self); + &_Py_ID(_dealloc_warn), (PyObject *)self); if (res) { Py_DECREF(res); } From 98bc8653ea6f23900b7dc0d93fe0b06315094a6a Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Mon, 16 Mar 2026 23:10:09 -0700 Subject: [PATCH 05/11] fixup: correct commment --- Lib/test/test_io/test_textio.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 7ecfc2d151036f..9eba9a51208af7 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1565,8 +1565,7 @@ def closed(self): wrapper.close() # should not crash def test_reentrant_detach_during_flush(self): - # gh-143008: Reentrant detach() during flush should raise RuntimeError - # instead of crashing. + # gh-143008: Reentrant detach() during flush should not crash. wrapper = None wrapper_ref = lambda: None From c52559be04be348697cafd24584b93af381d82f5 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Mon, 30 Mar 2026 00:33:56 -0700 Subject: [PATCH 06/11] PEP 7 fixes; simplify some comments --- Modules/_io/textio.c | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 765de2b818f809..e728b1d2a53fc0 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -673,7 +673,7 @@ struct textio int detached; Py_ssize_t chunk_size; /* Use helpers _textiowrapper_buffer_* to access buffer; many - operations can set it to NULL (ref: gh-143008, gh-142594). */ + operations can set it to NULL (see gh-143008, gh-142594). */ PyObject *buffer; PyObject *encoding; PyObject *encoder; @@ -734,7 +734,7 @@ struct textio /* Helpers to safely operate on self->buffer. self->buffer can be detached (set to NULL) by any user code that is called -leading to NULL pointer dereferences (ex. gh-143008, gh-142594). Protect against +leading to NULL pointer dereferences (see gh-143008, gh-142594). Protect against that by using helpers to check self->buffer validity at callsites. */ static PyObject * _textiowrapper_buffer_safe(textio *self) { @@ -742,12 +742,14 @@ _textiowrapper_buffer_safe(textio *self) { is called during construction and destruction where self->ok which CHECK_ATTACHED uses does not imply self->buffer state. */ if (self->buffer == NULL) { - if (self->ok <= 0) + if (self->ok <= 0) { PyErr_SetString(PyExc_ValueError, "I/O operation on uninitialized object"); - else + } + else { PyErr_SetString(PyExc_ValueError, "underlying buffer has been detached"); + } return NULL; } return self->buffer; From 150fceef9c727a99d657dc342b207ec71700853a Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Mon, 20 Apr 2026 00:48:21 -0700 Subject: [PATCH 07/11] Improve PEP-7 --- Modules/_io/textio.c | 49 ++++++++++++++++++++++++++++---------------- 1 file changed, 31 insertions(+), 18 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index e728b1d2a53fc0..ade20ee7729612 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -733,22 +733,23 @@ struct textio /* Helpers to safely operate on self->buffer. -self->buffer can be detached (set to NULL) by any user code that is called -leading to NULL pointer dereferences (see gh-143008, gh-142594). Protect against -that by using helpers to check self->buffer validity at callsites. */ + self->buffer can be detached (set to NULL) by any user code that is called + leading to NULL pointer dereferences (see gh-143008, gh-142594). Protect against + that by using helpers to check self->buffer validity at callsites. */ static PyObject * -_textiowrapper_buffer_safe(textio *self) { +_textiowrapper_buffer_safe(textio *self) +{ /* Check self->buffer directly but match errors of CHECK_ATTACHED since this is called during construction and destruction where self->ok which CHECK_ATTACHED uses does not imply self->buffer state. */ if (self->buffer == NULL) { if (self->ok <= 0) { PyErr_SetString(PyExc_ValueError, - "I/O operation on uninitialized object"); + "I/O operation on uninitialized object"); } else { PyErr_SetString(PyExc_ValueError, - "underlying buffer has been detached"); + "underlying buffer has been detached"); } return NULL; } @@ -756,8 +757,10 @@ _textiowrapper_buffer_safe(textio *self) { } static PyObject * -_textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) { +_textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) +{ PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { return NULL; } @@ -765,8 +768,10 @@ _textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) { } static PyObject * -_textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) { +_textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) +{ PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { return NULL; } @@ -774,8 +779,10 @@ _textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) { } static PyObject * -_textiowrapper_buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) { +_textiowrapper_buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) +{ PyObject *buffer = _textiowrapper_buffer_safe(self); + if (buffer == NULL) { return NULL; } @@ -1053,14 +1060,14 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; - PyObject *cookieObj = _textiowrapper_buffer_callmethod_noargs( - self, &_Py_ID(tell)); - if (cookieObj == NULL) { + PyObject *cookie = _textiowrapper_buffer_callmethod_noargs(self, + &_Py_ID(tell)); + if (cookie == NULL) { return -1; } - int cmp = PyObject_RichCompareBool(cookieObj, _PyLong_GetZero(), Py_EQ); - Py_DECREF(cookieObj); + int cmp = PyObject_RichCompareBool(cookie, _PyLong_GetZero(), Py_EQ); + Py_DECREF(cookie); if (cmp < 0) { return -1; } @@ -1972,7 +1979,10 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t size_hint) goto fail; input_chunk = _textiowrapper_buffer_callmethod_onearg(self, - (self->has_read1 ? &_Py_ID(read1): &_Py_ID(read)), + (self->has_read1 ? + &_Py_ID(read1): + &_Py_ID(read) + ), chunk_size); Py_DECREF(chunk_size); if (input_chunk == NULL) @@ -2058,7 +2068,7 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n) if (n < 0) { /* Read everything */ PyObject *bytes = _textiowrapper_buffer_callmethod_noargs(self, - &_Py_ID(read)); + &_Py_ID(read)); PyObject *decoded; if (bytes == NULL) goto fail; @@ -3040,7 +3050,9 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) return NULL; } - return _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(truncate), pos); + return _textiowrapper_buffer_callmethod_onearg(self, + &_Py_ID(truncate), + pos); } static PyObject * @@ -3221,7 +3233,8 @@ _io_TextIOWrapper_close_impl(textio *self) PyObject *exc = NULL; if (self->finalizing) { res = _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(_dealloc_warn), (PyObject *)self); + &_Py_ID(_dealloc_warn), + (PyObject *)self); if (res) { Py_DECREF(res); } From d7f14fc87152d86ba5c6b478550f3dbfacdbc559 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Tue, 21 Apr 2026 00:06:11 -0700 Subject: [PATCH 08/11] PEP-7: Move to shorter function names, reformat arg lists --- Modules/_io/textio.c | 83 +++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 44 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index ade20ee7729612..939fdcace057d1 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -737,7 +737,7 @@ struct textio leading to NULL pointer dereferences (see gh-143008, gh-142594). Protect against that by using helpers to check self->buffer validity at callsites. */ static PyObject * -_textiowrapper_buffer_safe(textio *self) +buffer_access_safe(textio *self) { /* Check self->buffer directly but match errors of CHECK_ATTACHED since this is called during construction and destruction where self->ok which @@ -757,9 +757,9 @@ _textiowrapper_buffer_safe(textio *self) } static PyObject * -_textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) +buffer_getattr(textio *self, PyObject *attr_name) { - PyObject *buffer = _textiowrapper_buffer_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; @@ -768,9 +768,9 @@ _textiowrapper_buffer_get_attr(textio *self, PyObject *attr_name) } static PyObject * -_textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) +buffer_callmethod_noargs(textio *self, PyObject *name) { - PyObject *buffer = _textiowrapper_buffer_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; @@ -779,9 +779,9 @@ _textiowrapper_buffer_callmethod_noargs(textio *self, PyObject *name) } static PyObject * -_textiowrapper_buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) +buffer_callmethod_onearg(textio *self, PyObject *name, PyObject *arg) { - PyObject *buffer = _textiowrapper_buffer_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL) { return NULL; @@ -958,7 +958,7 @@ _textiowrapper_set_decoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(readable)); + res = buffer_callmethod_noargs(self, &_Py_ID(readable)); if (res == NULL) return -1; @@ -1014,7 +1014,7 @@ _textiowrapper_set_encoder(textio *self, PyObject *codec_info, PyObject *res; int r; - res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(writable)); + res = buffer_callmethod_noargs(self, &_Py_ID(writable)); if (res == NULL) return -1; @@ -1060,14 +1060,13 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; - PyObject *cookie = _textiowrapper_buffer_callmethod_noargs(self, - &_Py_ID(tell)); - if (cookie == NULL) { + PyObject *cookieObj = buffer_callmethod_noargs(self, &_Py_ID(tell)); + if (cookieObj == NULL) { return -1; } - int cmp = PyObject_RichCompareBool(cookie, _PyLong_GetZero(), Py_EQ); - Py_DECREF(cookie); + int cmp = PyObject_RichCompareBool(cookieObj, _PyLong_GetZero(), Py_EQ); + Py_DECREF(cookieObj); if (cmp < 0) { return -1; } @@ -1701,7 +1700,7 @@ _textiowrapper_writeflush(textio *self) PyObject *ret; do { - ret = _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(write), b); + ret = buffer_callmethod_onearg(self, &_Py_ID(write), b); } while (ret == NULL && _PyIO_trap_eintr()); Py_DECREF(b); // NOTE: We cleared buffer but we don't know how many bytes are actually written @@ -1847,7 +1846,7 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } if (needflush) { - PyObject *buffer = _textiowrapper_buffer_safe(self); + PyObject *buffer = buffer_access_safe(self); if (buffer == NULL || _PyFile_Flush(buffer) < 0) { return NULL; } @@ -1978,12 +1977,10 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t size_hint) if (chunk_size == NULL) goto fail; - input_chunk = _textiowrapper_buffer_callmethod_onearg(self, - (self->has_read1 ? - &_Py_ID(read1): - &_Py_ID(read) - ), - chunk_size); + input_chunk = buffer_callmethod_onearg(self, + (self->has_read1 ? &_Py_ID(read1) : + &_Py_ID(read)), + chunk_size); Py_DECREF(chunk_size); if (input_chunk == NULL) goto fail; @@ -2067,8 +2064,7 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n) if (n < 0) { /* Read everything */ - PyObject *bytes = _textiowrapper_buffer_callmethod_noargs(self, - &_Py_ID(read)); + PyObject *bytes = buffer_callmethod_noargs(self, &_Py_ID(read)); PyObject *decoded; if (bytes == NULL) goto fail; @@ -2665,7 +2661,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) Py_DECREF(res); } - PyObject *buf = _textiowrapper_buffer_safe(self); + PyObject *buf = buffer_access_safe(self); if (buf == NULL) { goto fail; } @@ -2717,7 +2713,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) posobj = PyLong_FromOff_t(cookie.start_pos); if (posobj == NULL) goto fail; - res = _textiowrapper_buffer_callmethod_onearg(self, &_Py_ID(seek), posobj); + res = buffer_callmethod_onearg(self, &_Py_ID(seek), posobj); Py_DECREF(posobj); if (res == NULL) goto fail; @@ -2738,8 +2734,9 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) if (bytes_to_feed == NULL) { goto fail; } - PyObject *input_chunk = _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(read), bytes_to_feed); + PyObject *input_chunk = buffer_callmethod_onearg(self, + &_Py_ID(read), + bytes_to_feed); Py_DECREF(bytes_to_feed); PyObject *decoded; @@ -2840,7 +2837,7 @@ _io_TextIOWrapper_tell_impl(textio *self) goto fail; } - posobj = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(tell)); + posobj = buffer_callmethod_noargs(self, &_Py_ID(tell)); if (posobj == NULL) goto fail; @@ -3050,9 +3047,7 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) return NULL; } - return _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(truncate), - pos); + return buffer_callmethod_onearg(self, &_Py_ID(truncate), pos); } static PyObject * @@ -3134,7 +3129,7 @@ static PyObject * _io_TextIOWrapper_fileno_impl(textio *self) /*[clinic end generated code: output=21490a4c3da13e6c input=515e1196aceb97ab]*/ { - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(fileno)); + return buffer_callmethod_noargs(self, &_Py_ID(fileno)); } /*[clinic input] @@ -3146,7 +3141,7 @@ static PyObject * _io_TextIOWrapper_seekable_impl(textio *self) /*[clinic end generated code: output=ab223dbbcffc0f00 input=71c4c092736c549b]*/ { - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(seekable)); + return buffer_callmethod_noargs(self, &_Py_ID(seekable)); } /*[clinic input] @@ -3158,7 +3153,7 @@ static PyObject * _io_TextIOWrapper_readable_impl(textio *self) /*[clinic end generated code: output=72ff7ba289a8a91b input=80438d1f01b0a89b]*/ { - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(readable)); + return buffer_callmethod_noargs(self, &_Py_ID(readable)); } /*[clinic input] @@ -3170,7 +3165,7 @@ static PyObject * _io_TextIOWrapper_writable_impl(textio *self) /*[clinic end generated code: output=a728c71790d03200 input=9d6c22befb0c340a]*/ { - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(writable)); + return buffer_callmethod_noargs(self, &_Py_ID(writable)); } /*[clinic input] @@ -3182,7 +3177,7 @@ static PyObject * _io_TextIOWrapper_isatty_impl(textio *self) /*[clinic end generated code: output=12be1a35bace882e input=7f83ff04d4d1733d]*/ { - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(isatty)); + return buffer_callmethod_noargs(self, &_Py_ID(isatty)); } /*[clinic input] @@ -3199,7 +3194,7 @@ _io_TextIOWrapper_flush_impl(textio *self) self->telling = self->seekable; if (_textiowrapper_writeflush(self) < 0) return NULL; - return _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(flush)); + return buffer_callmethod_noargs(self, &_Py_ID(flush)); } /*[clinic input] @@ -3232,9 +3227,9 @@ _io_TextIOWrapper_close_impl(textio *self) else { PyObject *exc = NULL; if (self->finalizing) { - res = _textiowrapper_buffer_callmethod_onearg(self, - &_Py_ID(_dealloc_warn), - (PyObject *)self); + res = buffer_callmethod_onearg(self, + &_Py_ID(_dealloc_warn), + (PyObject *)self); if (res) { Py_DECREF(res); } @@ -3246,7 +3241,7 @@ _io_TextIOWrapper_close_impl(textio *self) exc = PyErr_GetRaisedException(); } - res = _textiowrapper_buffer_callmethod_noargs(self, &_Py_ID(close)); + res = buffer_callmethod_noargs(self, &_Py_ID(close)); if (exc != NULL) { _PyErr_ChainExceptions1(exc); Py_CLEAR(res); @@ -3314,7 +3309,7 @@ static PyObject * _io_TextIOWrapper_name_get_impl(textio *self) /*[clinic end generated code: output=8c2f1d6d8756af40 input=26ecec9b39e30e07]*/ { - return _textiowrapper_buffer_get_attr(self, &_Py_ID(name)); + return buffer_getattr(self, &_Py_ID(name)); } /*[clinic input] @@ -3332,7 +3327,7 @@ _io_TextIOWrapper_closed_get_impl(textio *self) Match original behavior by calling CHECK_ATTACHED explicitly. */ CHECK_ATTACHED(self); - return _textiowrapper_buffer_get_attr(self, &_Py_ID(closed)); + return buffer_getattr(self, &_Py_ID(closed)); } /*[clinic input] From df212a5c2cca674355395e2373f62ff45fe1cf97 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Tue, 21 Apr 2026 00:17:21 -0700 Subject: [PATCH 09/11] Improve NEWS grammer --- .../Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst index cac314452eaa4c..907cdb770ab65f 100644 --- a/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst +++ b/Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst @@ -1 +1,2 @@ -Fix crash in :class:`io.TextIOWrapper` when reentrant :meth:`io.TextIOBase.detach` called. +Fix crash in :class:`io.TextIOWrapper` when reentrant +:meth:`io.TextIOBase.detach` is called reentrantly from the underlying buffer. From d1a1a98036b996ccaefee919236b402b66989c56 Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Tue, 21 Apr 2026 00:20:23 -0700 Subject: [PATCH 10/11] Simplify comment --- Modules/_io/textio.c | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 939fdcace057d1..3b997b6c9bc91e 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -740,8 +740,7 @@ static PyObject * buffer_access_safe(textio *self) { /* Check self->buffer directly but match errors of CHECK_ATTACHED since this - is called during construction and destruction where self->ok which - CHECK_ATTACHED uses does not imply self->buffer state. */ + is called during construction and finalization where self->ok == 0. */ if (self->buffer == NULL) { if (self->ok <= 0) { PyErr_SetString(PyExc_ValueError, From 009cf121ea6ccd0ea7c0f58666fcc58e00f17f9b Mon Sep 17 00:00:00 2001 From: Cody Maloney Date: Tue, 21 Apr 2026 00:47:34 -0700 Subject: [PATCH 11/11] Fix comment referring to function name --- Modules/_io/textio.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 3b997b6c9bc91e..e1068b52d78b30 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -672,8 +672,8 @@ struct textio int ok; /* initialized? */ int detached; Py_ssize_t chunk_size; - /* Use helpers _textiowrapper_buffer_* to access buffer; many - operations can set it to NULL (see gh-143008, gh-142594). */ + /* Use helpers buffer_* to access buffer; many operations can set it to + NULL (see gh-143008, gh-142594). */ PyObject *buffer; PyObject *encoding; PyObject *encoder;