From f7eca0d67f7c3e670f51f238506fd363dce555ff Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Sun, 21 Dec 2025 17:59:39 +0800 Subject: [PATCH 1/3] fix: TextIOWrapper.truncate via re-entrant flush Signed-off-by: yihong0618 --- Lib/test/test_io/test_textio.py | 48 +++++++++++++++++++ ...-12-21-17-56-37.gh-issue-143008.aakErJ.rst | 1 + Modules/_io/textio.c | 33 ++++++++++--- 3 files changed, 75 insertions(+), 7 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..bd5c6ae92e62f6 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1560,6 +1560,54 @@ 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 + + class BadRaw(self.RawIOBase): + def write(self, b): return len(b) + def read(self, n=-1): return b'' + def readable(self): return True + def writable(self): return True + def seekable(self): return True + def seek(self, pos, whence=0): return 0 + def tell(self): return 0 + + class EvilBuffer(self.BufferedRandom): + detach_on_write = False + + def flush(self): + if wrapper is not None and not self.detach_on_write: + wrapper.detach() + return super().flush() + + def write(self, b): + if wrapper is not None and self.detach_on_write: + wrapper.detach() + return len(b) + + 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(BadRaw()), encoding='utf-8') + self.assertRaisesRegex(RuntimeError, "reentrant", method) + wrapper = None + + with self.subTest('read via writeflush'): + EvilBuffer.detach_on_write = True + wrapper = self.TextIOWrapper(EvilBuffer(BadRaw()), encoding='utf-8') + wrapper.write('x') + self.assertRaisesRegex(RuntimeError, "reentrant", wrapper.read) + wrapper = None + 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..37a62190b80e40 --- /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 ``detach()`` called. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index f9881952561292..4b35f4e7df3d9e 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -667,6 +667,7 @@ struct textio PyObject_HEAD int ok; /* initialized? */ int detached; + int flushing; /* prevent reentrant detach during flush */ Py_ssize_t chunk_size; PyObject *buffer; PyObject *encoding; @@ -725,6 +726,16 @@ struct textio #define textio_CAST(op) ((textio *)(op)) +/* gh-143007 need to check for reentrant flush */ +static inline int +_textiowrapper_flush(textio *self) +{ + self->flushing = 1; + int result = _PyFile_Flush((PyObject *)self); + self->flushing = 0; + return result; +} + static void textiowrapper_set_decoded_chars(textio *self, PyObject *chars); @@ -1108,6 +1119,7 @@ _io_TextIOWrapper___init___impl(textio *self, PyObject *buffer, self->ok = 0; self->detached = 0; + self->flushing = 0; if (encoding == NULL) { PyInterpreterState *interp = _PyInterpreterState_GET(); @@ -1422,7 +1434,7 @@ _io_TextIOWrapper_reconfigure_impl(textio *self, PyObject *encoding, return NULL; } - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { return NULL; } self->b2cratio = 0; @@ -1565,7 +1577,12 @@ _io_TextIOWrapper_detach_impl(textio *self) { PyObject *buffer; CHECK_ATTACHED(self); - if (_PyFile_Flush((PyObject *)self) < 0) { + if (self->flushing) { + PyErr_SetString(PyExc_RuntimeError, + "reentrant call to detach() is not allowed"); + return NULL; + } + if (_textiowrapper_flush(self) < 0) { return NULL; } buffer = self->buffer; @@ -1636,9 +1653,11 @@ _textiowrapper_writeflush(textio *self) Py_DECREF(pending); PyObject *ret; + self->flushing = 1; do { ret = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(write), b); } while (ret == NULL && _PyIO_trap_eintr()); + self->flushing = 0; Py_DECREF(b); // NOTE: We cleared buffer but we don't know how many bytes are actually written // when an error occurred. @@ -2583,7 +2602,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { goto fail; } @@ -2630,7 +2649,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { goto fail; } @@ -2757,7 +2776,7 @@ _io_TextIOWrapper_tell_impl(textio *self) if (_textiowrapper_writeflush(self) < 0) return NULL; - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { goto fail; } @@ -2967,7 +2986,7 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) { CHECK_ATTACHED(self) - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { return NULL; } @@ -3165,7 +3184,7 @@ _io_TextIOWrapper_close_impl(textio *self) PyErr_Clear(); } } - if (_PyFile_Flush((PyObject *)self) < 0) { + if (_textiowrapper_flush(self) < 0) { exc = PyErr_GetRaisedException(); } From a96757bd4de50191d72531b369ca8a66b7bbb1b4 Mon Sep 17 00:00:00 2001 From: yihong Date: Mon, 22 Dec 2025 08:45:05 +0800 Subject: [PATCH 2/3] Update Misc/NEWS.d/next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst Co-authored-by: Cody Maloney --- .../next/Library/2025-12-21-17-56-37.gh-issue-143008.aakErJ.rst | 2 +- 1 file changed, 1 insertion(+), 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 37a62190b80e40..cac314452eaa4c 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 @@ -Fix crash in :class:`io.TextIOWrapper` when reentrant ``detach()`` called. +Fix crash in :class:`io.TextIOWrapper` when reentrant :meth:`io.TextIOBase.detach` called. From 65d7dbb9204d4e3584c036d458858d13a4894383 Mon Sep 17 00:00:00 2001 From: yihong0618 Date: Mon, 29 Dec 2025 17:42:45 +0800 Subject: [PATCH 3/3] fix: apply some suggestion Signed-off-by: yihong0618 --- Lib/test/test_io/test_textio.py | 24 +++++------ Modules/_io/textio.c | 73 +++++++++++++++++++++------------ 2 files changed, 57 insertions(+), 40 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index bd5c6ae92e62f6..e0e110fae589da 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1564,25 +1564,19 @@ def test_reentrant_detach_during_flush(self): # gh-143008: Reentrant detach() during flush should raise RuntimeError # instead of crashing. wrapper = None - - class BadRaw(self.RawIOBase): - def write(self, b): return len(b) - def read(self, n=-1): return b'' - def readable(self): return True - def writable(self): return True - def seekable(self): return True - def seek(self, pos, whence=0): return 0 - def tell(self): return 0 + 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) @@ -1597,16 +1591,20 @@ def write(self, b): ] for name, method in tests: with self.subTest(name): - wrapper = self.TextIOWrapper(EvilBuffer(BadRaw()), encoding='utf-8') + wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') + wrapper_ref = weakref.ref(wrapper) self.assertRaisesRegex(RuntimeError, "reentrant", method) - wrapper = None + wrapper_ref = None + del wrapper with self.subTest('read via writeflush'): EvilBuffer.detach_on_write = True - wrapper = self.TextIOWrapper(EvilBuffer(BadRaw()), encoding='utf-8') + wrapper = self.TextIOWrapper(EvilBuffer(self.MockRawIO()), encoding='utf-8') + wrapper_ref = weakref.ref(wrapper) wrapper.write('x') self.assertRaisesRegex(RuntimeError, "reentrant", wrapper.read) - wrapper = None + wrapper_ref = None + del wrapper class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase): diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 4b35f4e7df3d9e..b5b1ae247af3cc 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -667,7 +667,6 @@ struct textio PyObject_HEAD int ok; /* initialized? */ int detached; - int flushing; /* prevent reentrant detach during flush */ Py_ssize_t chunk_size; PyObject *buffer; PyObject *encoding; @@ -726,16 +725,6 @@ struct textio #define textio_CAST(op) ((textio *)(op)) -/* gh-143007 need to check for reentrant flush */ -static inline int -_textiowrapper_flush(textio *self) -{ - self->flushing = 1; - int result = _PyFile_Flush((PyObject *)self); - self->flushing = 0; - return result; -} - static void textiowrapper_set_decoded_chars(textio *self, PyObject *chars); @@ -905,6 +894,11 @@ _textiowrapper_set_decoder(textio *self, PyObject *codec_info, PyObject *res; int r; + if (self->detached > 0) { + PyErr_SetString(PyExc_ValueError, + "underlying buffer has been detached"); + return -1; + } res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(readable)); if (res == NULL) return -1; @@ -961,6 +955,11 @@ _textiowrapper_set_encoder(textio *self, PyObject *codec_info, PyObject *res; int r; + if (self->detached > 0) { + PyErr_SetString(PyExc_ValueError, + "underlying buffer has been detached"); + return -1; + } res = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(writable)); if (res == NULL) return -1; @@ -1007,6 +1006,11 @@ _textiowrapper_fix_encoder_state(textio *self) self->encoding_start_of_stream = 1; + if (self->detached > 0) { + PyErr_SetString(PyExc_ValueError, + "underlying buffer has been detached"); + return -1; + } PyObject *cookieObj = PyObject_CallMethodNoArgs( self->buffer, &_Py_ID(tell)); if (cookieObj == NULL) { @@ -1119,7 +1123,6 @@ _io_TextIOWrapper___init___impl(textio *self, PyObject *buffer, self->ok = 0; self->detached = 0; - self->flushing = 0; if (encoding == NULL) { PyInterpreterState *interp = _PyInterpreterState_GET(); @@ -1434,7 +1437,7 @@ _io_TextIOWrapper_reconfigure_impl(textio *self, PyObject *encoding, return NULL; } - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { return NULL; } self->b2cratio = 0; @@ -1548,7 +1551,7 @@ _io_TextIOWrapper_closed_get_impl(textio *self); #define CHECK_ATTACHED(self) \ CHECK_INITIALIZED(self); \ - if (self->detached) { \ + if (self->detached > 0) { \ PyErr_SetString(PyExc_ValueError, \ "underlying buffer has been detached"); \ return NULL; \ @@ -1559,13 +1562,12 @@ _io_TextIOWrapper_closed_get_impl(textio *self); PyErr_SetString(PyExc_ValueError, \ "I/O operation on uninitialized object"); \ return -1; \ - } else if (self->detached) { \ + } else if (self->detached > 0) { \ PyErr_SetString(PyExc_ValueError, \ "underlying buffer has been detached"); \ return -1; \ } - /*[clinic input] @critical_section _io.TextIOWrapper.detach @@ -1577,12 +1579,18 @@ _io_TextIOWrapper_detach_impl(textio *self) { PyObject *buffer; CHECK_ATTACHED(self); - if (self->flushing) { + if (self->detached < 0) { PyErr_SetString(PyExc_RuntimeError, "reentrant call to detach() is not allowed"); return NULL; } - if (_textiowrapper_flush(self) < 0) { + int entered = (self->detached == 0); + if (entered) + self->detached = -1; + if (_PyFile_Flush((PyObject *)self) < 0) { + if (entered && self->detached < 0) { + self->detached = 0; + } return NULL; } buffer = self->buffer; @@ -1653,11 +1661,15 @@ _textiowrapper_writeflush(textio *self) Py_DECREF(pending); PyObject *ret; - self->flushing = 1; + CHECK_ATTACHED_INT(self); + int entered = (self->detached == 0); + if (entered) + self->detached = -1; do { ret = PyObject_CallMethodOneArg(self->buffer, &_Py_ID(write), b); } while (ret == NULL && _PyIO_trap_eintr()); - self->flushing = 0; + if (entered && self->detached < 0) + self->detached = 0; Py_DECREF(b); // NOTE: We cleared buffer but we don't know how many bytes are actually written // when an error occurred. @@ -2602,7 +2614,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { goto fail; } @@ -2649,7 +2661,7 @@ _io_TextIOWrapper_seek_impl(textio *self, PyObject *cookieObj, int whence) goto fail; } - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { goto fail; } @@ -2776,7 +2788,7 @@ _io_TextIOWrapper_tell_impl(textio *self) if (_textiowrapper_writeflush(self) < 0) return NULL; - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { goto fail; } @@ -2986,7 +2998,7 @@ _io_TextIOWrapper_truncate_impl(textio *self, PyObject *pos) { CHECK_ATTACHED(self) - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { return NULL; } @@ -3142,7 +3154,14 @@ _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)); + int entered = (self->detached == 0); + if (entered) { + self->detached = -1; + } + PyObject *ret = PyObject_CallMethodNoArgs(self->buffer, &_Py_ID(flush)); + if (entered && self->detached < 0) + self->detached = 0; + return ret; } /*[clinic input] @@ -3169,7 +3188,7 @@ _io_TextIOWrapper_close_impl(textio *self) if (r > 0) { Py_RETURN_NONE; /* stream already closed */ } - if (self->detached) { + if (self->detached > 0) { Py_RETURN_NONE; /* gh-142594 null pointer issue */ } else { @@ -3184,7 +3203,7 @@ _io_TextIOWrapper_close_impl(textio *self) PyErr_Clear(); } } - if (_textiowrapper_flush(self) < 0) { + if (_PyFile_Flush((PyObject *)self) < 0) { exc = PyErr_GetRaisedException(); }