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]