https://github.com/python/cpython/commit/7385f48f2257b6328925d89c709b72a2875c7c51
commit: 7385f48f2257b6328925d89c709b72a2875c7c51
branch: main
author: Serhiy Storchaka <[email protected]>
committer: serhiy-storchaka <[email protected]>
date: 2026-08-14T08:18:46+03:00
summary:
gh-111331: Allow closing BytesIO with exported buffers (GH-155383)
close() no longer fails with BufferError if there are exported buffers.
Every exported buffer keeps a reference to the internal buffer, so it
outlives closing of the BytesIO object. As a result, destroying or garbage
collecting such object no longer emits an unraisable exception.
files:
A Misc/NEWS.d/next/Library/2026-08-08-18-30-00.gh-issue-111331.bioclose.rst
M Doc/library/io.rst
M Lib/test/test_io/test_memoryio.py
M Modules/_io/bytesio.c
diff --git a/Doc/library/io.rst b/Doc/library/io.rst
index c0d7ee877536ad5..ecaa053b4e18b9e 100644
--- a/Doc/library/io.rst
+++ b/Doc/library/io.rst
@@ -763,10 +763,13 @@ than raw I/O does.
.. note::
As long as the view exists, the :class:`BytesIO` object cannot be
- resized or closed.
+ resized. Closing it does not invalidate the view.
.. versionadded:: 3.2
+ .. versionchanged:: next
+ The :class:`BytesIO` object can now be closed while the view exists.
+
.. method:: getvalue()
Return :class:`bytes` containing the entire contents of the buffer.
diff --git a/Lib/test/test_io/test_memoryio.py
b/Lib/test/test_io/test_memoryio.py
index 026dff23fe7ce24..0037fdc2fd67c1a 100644
--- a/Lib/test/test_io/test_memoryio.py
+++ b/Lib/test/test_io/test_memoryio.py
@@ -457,9 +457,6 @@ def test_getbuffer(self):
# raises a BufferError.
self.assertRaises(BufferError, memio.write, b'x' * 100)
self.assertRaises(BufferError, memio.truncate)
- # gh-111049: _io.BytesIO detach on close would lead to corruption.
- if self.ioclass is io.BytesIO:
- self.assertRaises(BufferError, memio.close)
self.assertFalse(memio.closed)
# Mutating the buffer updates the BytesIO
buf[3:6] = b"abc"
@@ -474,12 +471,7 @@ def test_getbuffer(self):
self.assertRaises(ValueError, memio.getbuffer)
def test_getbuffer_delete(self):
- # gh-111330: _pyio .close() works and the buffer stays working
- if self.ioclass is io.BytesIO:
- # gh-111049: _io.BytesIO detach on close would lead to corruption.
- # gh-111331: It would be nice to support this.
- self.skipTest("io.BytesIO does not support, gh-111049")
-
+ # gh-111330, gh-111331: .close() works and the buffer stays working
memio = self.ioclass(b"1234567890")
buf = memio.getbuffer()
self.assertEqual(bytes(buf), b"1234567890")
@@ -489,6 +481,21 @@ def test_getbuffer_delete(self):
buf[3:6] = b"abc"
self.assertEqual(bytes(buf), b"123abc7890")
self.assertRaises(ValueError, memio.getbuffer)
+ self.assertRaises(ValueError, memio.getvalue)
+ del buf
+ support.gc_collect()
+ memio.close()
+
+ def test_getbuffer_del(self):
+ # gh-111330, gh-111331: deleting the BytesIO which has an exported
+ # buffer does not emit an unraisable exception.
+ memio = self.ioclass(b"1234567890")
+ buf = memio.getbuffer()
+ with support.catch_unraisable_exception() as cm:
+ del memio
+ support.gc_collect()
+ self.assertIsNone(cm.unraisable)
+ self.assertEqual(bytes(buf), b"1234567890")
def test_getbuffer_empty(self):
memio = self.ioclass()
@@ -513,15 +520,13 @@ def test_getbuffer_gc_collect(self):
a = [buf]
a.append(a)
- # gh-111330: _pyio GC with exports should pass.
+ # gh-111330, gh-111331: no unraisable exception is emitted.
with support.catch_unraisable_exception() as cm:
del memio
- self.assertIsNone(cm.unraisable)
- del buf
- del a
- # The C implementation emits an unraisable exception.
- with support.catch_unraisable_exception():
+ del buf
+ del a
gc.collect()
+ self.assertIsNone(cm.unraisable)
self.assertIsNone(memiowr())
self.assertIsNone(bufwr())
diff --git
a/Misc/NEWS.d/next/Library/2026-08-08-18-30-00.gh-issue-111331.bioclose.rst
b/Misc/NEWS.d/next/Library/2026-08-08-18-30-00.gh-issue-111331.bioclose.rst
new file mode 100644
index 000000000000000..9e9f3b4bd71e876
--- /dev/null
+++ b/Misc/NEWS.d/next/Library/2026-08-08-18-30-00.gh-issue-111331.bioclose.rst
@@ -0,0 +1,4 @@
+Closing a :class:`io.BytesIO` object which has exported buffers no longer
+fails with :exc:`BufferError`. The exported buffers keep the data alive and
+stay usable. As a result, destroying or garbage collecting such object no
+longer emits an unraisable exception.
diff --git a/Modules/_io/bytesio.c b/Modules/_io/bytesio.c
index 7d6053d85cd9e4a..f7ba68bc637b888 100644
--- a/Modules/_io/bytesio.c
+++ b/Modules/_io/bytesio.c
@@ -40,7 +40,8 @@ typedef struct {
* Py_REFCNT(buf) == 1, exports == 0.
* Py_REFCNT(buf) > 1. exports == 0,
first modification or export causes the internal buffer copying.
- * exports > 0. Py_REFCNT(buf) == 1, any modifications are forbidden.
+ * exports > 0. Any modifications are forbidden. Every exported buffer
+ keeps a reference to buf, so it outlives closing of the bytesio object.
*/
static int
@@ -925,7 +926,7 @@ static PyObject *
_io_BytesIO_close_impl(bytesio *self)
/*[clinic end generated code: output=1471bb9411af84a0 input=34ce76d8bd17a23b]*/
{
- CHECK_EXPORTS(self);
+ /* The exported buffers keep the internal buffer alive. */
Py_CLEAR(self->buf);
Py_RETURN_NONE;
}
@@ -1281,6 +1282,9 @@ bytesiobuf_getbuffer_lock_held(PyObject *op, Py_buffer
*view, int flags)
_Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(b);
+ if (check_closed(b)) {
+ return -1;
+ }
if (FT_ATOMIC_LOAD_SSIZE_RELAXED(b->exports) == 0 && SHARED_BUF(b)) {
if (unshare_buffer_lock_held(b, b->string_size) < 0)
return -1;
@@ -1290,6 +1294,9 @@ bytesiobuf_getbuffer_lock_held(PyObject *op, Py_buffer
*view, int flags)
(void)PyBuffer_FillInfo(view, op,
PyBytes_AS_STRING(b->buf), b->string_size,
0, flags);
+ /* Keep the internal buffer alive: the bytesio object can be closed
+ while the buffer is exported. */
+ view->internal = Py_NewRef(b->buf);
FT_ATOMIC_ADD_SSIZE(b->exports, 1);
return 0;
}
@@ -1311,11 +1318,12 @@ bytesiobuf_getbuffer(PyObject *op, Py_buffer *view, int
flags)
}
static void
-bytesiobuf_releasebuffer(PyObject *op, Py_buffer *Py_UNUSED(view))
+bytesiobuf_releasebuffer(PyObject *op, Py_buffer *view)
{
bytesiobuf *obj = bytesiobuf_CAST(op);
bytesio *b = bytesio_CAST(obj->source);
FT_ATOMIC_ADD_SSIZE(b->exports, -1);
+ Py_CLEAR(view->internal);
}
static int
_______________________________________________
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]