https://github.com/python/cpython/commit/665bda98e861aad314457481a25f9406dedc122b
commit: 665bda98e861aad314457481a25f9406dedc122b
branch: main
author: Jelle Zijlstra <[email protected]>
committer: JelleZijlstra <[email protected]>
date: 2026-10-24T13:45:41-07:00
summary:

gh-159739: Keep text I/O state valid across reentrant callbacks (#160398)

A raw read can reinitialize a TextIOWrapper as write-only, and codec callbacks 
or finalizers can release an encoder, decoder, or decoded chunk while a read or 
write still needs it. This can crash the interpreter. Check that a decoder is 
still available after reading, keep codecs alive across calls, and read the 
chunk length before transferring ownership. Also recheck StringIO after 
converting the truncate size because __index__() can close the stream.

files:
A Misc/NEWS.d/next/Library/2026-10-22-16-18-42.gh-issue-159739.TioR3a.rst
M Lib/test/test_io/test_memoryio.py
M Lib/test/test_io/test_textio.py
M Modules/_io/stringio.c
M Modules/_io/textio.c

diff --git a/Lib/test/test_io/test_memoryio.py 
b/Lib/test/test_io/test_memoryio.py
index 6dafc308bf4e62..6a286e5a62ef44 100644
--- a/Lib/test/test_io/test_memoryio.py
+++ b/Lib/test/test_io/test_memoryio.py
@@ -1138,6 +1138,18 @@ class CStringIOTest(PyStringIOTest):
     ioclass = io.StringIO
     UnsupportedOperation = io.UnsupportedOperation
 
+    def test_truncate_size_closes_stream(self):
+        stream = self.ioclass()
+        stream.write("abc")
+
+        class Size:
+            def __index__(self):
+                stream.close()
+                return 0
+
+        with self.assertRaises(ValueError):
+            stream.truncate(Size())
+
     # XXX: For the Python version of io.StringIO, this is highly
     # dependent on the encoding used for the underlying buffer.
     def test_widechar(self):
diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py
index cd3abd49ba8dde..a18386d68569ae 100644
--- a/Lib/test/test_io/test_textio.py
+++ b/Lib/test/test_io/test_textio.py
@@ -1684,6 +1684,17 @@ def make_text(buffer):
             wrapper.write('x')
             self.assertRaisesRegex(ValueError, "detached", wrapper.read)
 
+    def test_read_reinitialized_as_write_only(self):
+        class Buffer(self.BytesIO):
+            def read(self, size=-1):
+                stream.__init__(writer, encoding="ascii")
+                return b"a"
+
+        with (self.BufferedWriter(self.BytesIO()) as writer,
+              self.TextIOWrapper(Buffer(), encoding="ascii") as stream):
+            with self.assertRaises(self.UnsupportedOperation):
+                stream.read()
+
     def test_reentrant_detach_during_read(self):
         # gh-157363, gh-157364: The buffer must stay alive until its active
         # read operation returns.
diff --git 
a/Misc/NEWS.d/next/Library/2026-10-22-16-18-42.gh-issue-159739.TioR3a.rst 
b/Misc/NEWS.d/next/Library/2026-10-22-16-18-42.gh-issue-159739.TioR3a.rst
new file mode 100644
index 00000000000000..69f90a8d30e1dd
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-10-22-16-18-42.gh-issue-159739.TioR3a.rst
@@ -0,0 +1,4 @@
+Fix crashes in :class:`io.TextIOWrapper` and
+:class:`io.IncrementalNewlineDecoder` when a callback changes the active
+codec or decoded buffer. :class:`io.StringIO` now raises :exc:`ValueError`
+if converting the size passed to ``truncate()`` closes the stream.
diff --git a/Modules/_io/stringio.c b/Modules/_io/stringio.c
index e9a46167cff596..558fd57aa93c95 100644
--- a/Modules/_io/stringio.c
+++ b/Modules/_io/stringio.c
@@ -499,6 +499,9 @@ _io_StringIO_truncate_impl(stringio *self, PyObject *pos)
                          "negative pos value %zd", size);
             return NULL;
         }
+        /* __index__ may have closed or reinitialized the stream. */
+        CHECK_INITIALIZED(self);
+        CHECK_CLOSED(self);
     }
 
     if (size < self->string_size) {
diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c
index 7523b516f49eb5..4ef5251d451a4c 100644
--- a/Modules/_io/textio.c
+++ b/Modules/_io/textio.c
@@ -336,8 +336,11 @@ _PyIncrementalNewlineDecoder_decode(PyObject *myself,
 
     /* decode input (with the eventual \r from a previous pass) */
     if (self->decoder != Py_None) {
-        output = PyObject_CallMethodObjArgs(self->decoder,
+        /* The wrapper can be reinitialized by a decoding callback. */
+        PyObject *decoder = Py_NewRef(self->decoder);
+        output = PyObject_CallMethodObjArgs(decoder,
             &_Py_ID(decode), input, final ? Py_True : Py_False, NULL);
+        Py_DECREF(decoder);
     }
     else {
         output = Py_NewRef(input);
@@ -1006,11 +1009,17 @@ _textiowrapper_decode(_PyIO_State *state, PyObject 
*decoder, PyObject *bytes,
 {
     PyObject *chars;
 
+    if (decoder == NULL) {
+        return _unsupported(state, "not readable");
+    }
+    /* A codec callback may replace the wrapper's decoder. */
+    Py_INCREF(decoder);
     if (Py_IS_TYPE(decoder, state->PyIncrementalNewlineDecoder_Type))
         chars = _PyIncrementalNewlineDecoder_decode(decoder, bytes, eof);
     else
         chars = PyObject_CallMethodObjArgs(decoder, &_Py_ID(decode), bytes,
                                            eof ? Py_True : Py_False, NULL);
+    Py_DECREF(decoder);
 
     if (check_decoded(chars) < 0)
         // check_decoded already decreases refcount
@@ -1813,7 +1822,10 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject 
*text)
         self->encoding_start_of_stream = 0;
     }
     else {
-        b = PyObject_CallMethodOneArg(self->encoder, &_Py_ID(encode), text);
+        /* A codec callback may reconfigure the stream. */
+        PyObject *encoder = Py_NewRef(self->encoder);
+        b = PyObject_CallMethodOneArg(encoder, &_Py_ID(encode), text);
+        Py_DECREF(encoder);
     }
 
     Py_DECREF(text);
@@ -2053,8 +2065,9 @@ textiowrapper_read_chunk(textio *self, Py_ssize_t 
size_hint)
     if (decoded_chars == NULL)
         goto fail;
 
-    textiowrapper_set_decoded_chars(self, decoded_chars);
+    /* The old chunk's finalizer can clear the new chunk. */
     nchars = PyUnicode_GET_LENGTH(decoded_chars);
+    textiowrapper_set_decoded_chars(self, decoded_chars);
     if (nchars > 0)
         self->b2cratio = (double) nbytes / nchars;
     else
@@ -2126,15 +2139,9 @@ _io_TextIOWrapper_read_impl(textio *self, Py_ssize_t n)
             return NULL;
         }
 
-        _PyIO_State *state = self->state;
-        if (Py_IS_TYPE(self->decoder, state->PyIncrementalNewlineDecoder_Type))
-            decoded = _PyIncrementalNewlineDecoder_decode(self->decoder,
-                                                          bytes, 1);
-        else
-            decoded = PyObject_CallMethodObjArgs(
-                self->decoder, &_Py_ID(decode), bytes, Py_True, NULL);
+        decoded = _textiowrapper_decode(self->state, self->decoder, bytes, 1);
         Py_DECREF(bytes);
-        if (check_decoded(decoded) < 0)
+        if (decoded == NULL)
             goto fail;
 
         result = textiowrapper_get_decoded_chars(self, -1);

_______________________________________________
Python-checkins mailing list -- [email protected]
To unsubscribe send an email to [email protected]
https://mail.python.org/mailman3//lists/python-checkins.python.org
Member address: [email protected]

Reply via email to