https://github.com/python/cpython/commit/fa0ec86ab682a561e2dc6f50127f2a78f76ce311
commit: fa0ec86ab682a561e2dc6f50127f2a78f76ce311
branch: main
author: tonghuaroot (童话) <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-17T21:13:33+03:00
summary:
gh-153539: Fix use-after-free in TextIOWrapper.tell() with a reentrant decoder
(GH-153540)
TextIOWrapper.tell() used a borrowed next_input from the snapshot across the
decoder's getstate/decode/setstate calls, so a decoder that reenters seek()
from getstate could free it and leave tell() reading freed memory. Own the
reference across those calls, matching the sibling textiowrapper_read_chunk.
files:
A Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
M Lib/test/test_io/test_textio.py
M Modules/_io/textio.c
diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py
index 82096ab09873955..07f6b1415d0dbda 100644
--- a/Lib/test/test_io/test_textio.py
+++ b/Lib/test/test_io/test_textio.py
@@ -1610,6 +1610,49 @@ def make_text(buffer):
wrapper.write('x')
self.assertRaisesRegex(ValueError, "detached", wrapper.read)
+ def test_reentrant_seek_during_tell(self):
+ # gh-153539: reading short of _CHUNK_SIZE leaves residual bytes in the
+ # snapshot, so tell() re-decodes and calls the decoder's getstate(); a
+ # reentrant seek() there must not free the snapshot tell() still uses.
+ # C-only: _pyio binds next_input as a strong local and cannot crash.
+ wrapper = None
+ armed = False
+
+ class ReentrantDecoder(codecs.IncrementalDecoder):
+ def decode(self, input, final=False):
+ return bytes(input).decode("latin-1")
+ def getstate(self):
+ nonlocal armed
+ if wrapper is not None and armed:
+ armed = False
+ wrapper.seek(0)
+ return (b"", 0)
+ def setstate(self, state):
+ pass
+
+ def search(name):
+ if name != "reentrant_tell_test":
+ return None
+ return codecs.CodecInfo(
+ name=name,
+ encode=lambda s, e='strict': (s.encode("latin-1"), len(s)),
+ decode=lambda b, e='strict': (bytes(b).decode("latin-1"),
len(b)),
+ incrementaldecoder=ReentrantDecoder)
+
+ codecs.register(search)
+ self.addCleanup(codecs.unregister, search)
+ raw = self.BytesIO(b"abcdefghijklmnop" * 8)
+ wrapper = self.TextIOWrapper(self.BufferedReader(raw),
+ encoding="reentrant_tell_test",
newline="")
+ wrapper._CHUNK_SIZE = 8
+ wrapper.read(5)
+ armed = True
+ self.assertIsInstance(wrapper.tell(), int)
+ # tell() at the snapshot boundary takes the early return that owns and
+ # must release next_input; exercise it too (leak-checked under -R).
+ wrapper.seek(0)
+ self.assertIsInstance(wrapper.tell(), int)
+
class PyTextIOWrapperTest(TextIOWrapperTest, PyTestCase):
shutdown_error = "LookupError: unknown encoding: ascii"
diff --git
a/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
b/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
new file mode 100644
index 000000000000000..241d67949680925
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-07-11-12-21-12.gh-issue-153539.gBs2T5.rst
@@ -0,0 +1,3 @@
+Fix a crash in the C implementation of :meth:`io.TextIOWrapper.tell` when the
+decoder's ``getstate`` method triggers a reentrant seek, or when another thread
+seeks the same stream concurrently. Patch by tonghuaroot.
diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c
index ea8ed2713d8a146..5b3d379e4e75543 100644
--- a/Modules/_io/textio.c
+++ b/Modules/_io/textio.c
@@ -2823,7 +2823,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
PyObject *res;
PyObject *posobj = NULL;
cookie_type cookie = {0,0,0,0,0};
- PyObject *next_input;
+ PyObject *next_input = NULL;
Py_ssize_t chars_to_skip, chars_decoded;
Py_ssize_t skip_bytes, skip_back;
PyObject *saved_state = NULL;
@@ -2875,11 +2875,15 @@ _io_TextIOWrapper_tell_impl(textio *self)
assert (PyBytes_Check(next_input));
+ /* Own next_input: a reentrant or concurrent seek can drop the snapshot. */
+ Py_INCREF(next_input);
+
cookie.start_pos -= PyBytes_GET_SIZE(next_input);
/* How many decoded characters have been used up since the snapshot? */
if (self->decoded_chars_used == 0) {
/* We haven't moved from the snapshot point. */
+ Py_DECREF(next_input);
return textiowrapper_build_cookie(&cookie);
}
@@ -3020,6 +3024,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
}
finally:
+ Py_XDECREF(next_input);
res = PyObject_CallMethodOneArg(
self->decoder, &_Py_ID(setstate), saved_state);
Py_DECREF(saved_state);
@@ -3032,6 +3037,7 @@ _io_TextIOWrapper_tell_impl(textio *self)
return textiowrapper_build_cookie(&cookie);
fail:
+ Py_XDECREF(next_input);
if (saved_state) {
PyObject *exc = PyErr_GetRaisedException();
res = PyObject_CallMethodOneArg(
_______________________________________________
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]