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