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]

Reply via email to