gh-111331: Allow closing BytesIO with exported buffers (GH-155383)

serhiy-storchaka <[email protected]>
Newsgroups gmane.comp.python.cvs
Message-ID <[email protected]>
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]
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.