From c6b5163133619febd0fbe8c327e52399b1a54ffd Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sat, 20 Jun 2026 21:16:54 +0100 Subject: [PATCH 1/2] Fix unbounded memory growth from repeated empty writes to io.TextIOWrapper --- Lib/test/test_io/test_textio.py | 12 +++++ ...-06-20-21-15-13.gh-issue-151814.OIbgsO.rst | 2 + Modules/_io/textio.c | 44 +++++++++++-------- 3 files changed, 39 insertions(+), 19 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-06-20-21-15-13.gh-issue-151814.OIbgsO.rst diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index 82096ab09873955..efcfa753e28c50e 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -797,6 +797,18 @@ def test_writelines_error(self): self.assertRaises(TypeError, txt.writelines, None) self.assertRaises(TypeError, txt.writelines, b'abc') + def test_write_empty_stress(self): + # gh-151814: repeatedly writing the empty string shouldn't accumulate + # in the pending-write buffer. + buf = self.BytesIO() + txt = self.TextIOWrapper(buf, encoding="utf-8") + for _ in range(1_000_000): + txt.write('') + self.assertEqual(buf.getvalue(), b'') + txt.write('S') + txt.flush() + self.assertEqual(buf.getvalue(), b'S') + def test_issue1395_1(self): txt = self.TextIOWrapper(self.BytesIO(self.testdata), encoding="ascii") diff --git a/Misc/NEWS.d/next/Library/2026-06-20-21-15-13.gh-issue-151814.OIbgsO.rst b/Misc/NEWS.d/next/Library/2026-06-20-21-15-13.gh-issue-151814.OIbgsO.rst new file mode 100644 index 000000000000000..1365fb4d8edb1d2 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-06-20-21-15-13.gh-issue-151814.OIbgsO.rst @@ -0,0 +1,2 @@ +Fix unbounded memory growth in :class:`io.TextIOWrapper` when repeatedly +writing an empty string. diff --git a/Modules/_io/textio.c b/Modules/_io/textio.c index 24e08cec88f2a3a..5b2a20a30c28cb2 100644 --- a/Modules/_io/textio.c +++ b/Modules/_io/textio.c @@ -1820,32 +1820,38 @@ _io_TextIOWrapper_write_impl(textio *self, PyObject *text) } } - if (self->pending_bytes == NULL) { - assert(self->pending_bytes_count == 0); - self->pending_bytes = b; - } - else if (!PyList_CheckExact(self->pending_bytes)) { - PyObject *list = PyList_New(2); - if (list == NULL) { + if (bytes_len > 0) { + if (self->pending_bytes == NULL) { + assert(self->pending_bytes_count == 0); + self->pending_bytes = b; + } + else if (!PyList_CheckExact(self->pending_bytes)) { + PyObject *list = PyList_New(2); + if (list == NULL) { + Py_DECREF(b); + return NULL; + } + // Since Python 3.12, allocating GC object won't trigger GC and release + // GIL. See https://github.com/python/cpython/issues/97922 + assert(!PyList_CheckExact(self->pending_bytes)); + PyList_SET_ITEM(list, 0, self->pending_bytes); + PyList_SET_ITEM(list, 1, b); + self->pending_bytes = list; + } + else { + if (PyList_Append(self->pending_bytes, b) < 0) { + Py_DECREF(b); + return NULL; + } Py_DECREF(b); - return NULL; } - // Since Python 3.12, allocating GC object won't trigger GC and release - // GIL. See https://github.com/python/cpython/issues/97922 - assert(!PyList_CheckExact(self->pending_bytes)); - PyList_SET_ITEM(list, 0, self->pending_bytes); - PyList_SET_ITEM(list, 1, b); - self->pending_bytes = list; + + self->pending_bytes_count += bytes_len; } else { - if (PyList_Append(self->pending_bytes, b) < 0) { - Py_DECREF(b); - return NULL; - } Py_DECREF(b); } - self->pending_bytes_count += bytes_len; if (self->pending_bytes_count >= self->chunk_size || needflush || text_needflush) { if (_textiowrapper_writeflush(self) < 0) From 0d3fe570f62775bc2cfc0ed9e1b138c27133f879 Mon Sep 17 00:00:00 2001 From: Stan Ulbrych Date: Sun, 21 Jun 2026 10:29:25 +0100 Subject: [PATCH 2/2] Remove test --- Lib/test/test_io/test_textio.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/Lib/test/test_io/test_textio.py b/Lib/test/test_io/test_textio.py index efcfa753e28c50e..82096ab09873955 100644 --- a/Lib/test/test_io/test_textio.py +++ b/Lib/test/test_io/test_textio.py @@ -797,18 +797,6 @@ def test_writelines_error(self): self.assertRaises(TypeError, txt.writelines, None) self.assertRaises(TypeError, txt.writelines, b'abc') - def test_write_empty_stress(self): - # gh-151814: repeatedly writing the empty string shouldn't accumulate - # in the pending-write buffer. - buf = self.BytesIO() - txt = self.TextIOWrapper(buf, encoding="utf-8") - for _ in range(1_000_000): - txt.write('') - self.assertEqual(buf.getvalue(), b'') - txt.write('S') - txt.flush() - self.assertEqual(buf.getvalue(), b'S') - def test_issue1395_1(self): txt = self.TextIOWrapper(self.BytesIO(self.testdata), encoding="ascii")