diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index d725f9212ceaae..e0e110fae589da 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -1560,6 +1560,52 @@ 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) + + 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) + self.assertRaisesRegex(RuntimeError, "reentrant", 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(RuntimeError, "reentrant", 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 f9881952561292..b5b1ae247af3cc 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -894,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; @@ -950,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; @@ -996,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) { @@ -1536,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; \ @@ -1547,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 @@ -1565,7 +1579,18 @@ _io_TextIOWrapper_detach_impl(textio *self) { PyObject *buffer; CHECK_ATTACHED(self); + if (self->detached < 0) { + PyErr_SetString(PyExc_RuntimeError, + "reentrant call to detach() is not allowed"); + return NULL; + } + 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; @@ -1636,9 +1661,15 @@ _textiowrapper_writeflush(textio *self) Py_DECREF(pending); PyObject *ret; + 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()); + 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. @@ -3123,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] @@ -3150,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 {