From 23471d36b7775e555fcd67e1997d22fbdaea5b53 Mon Sep 17 00:00:00 2001 From: Mathieu Dupuy Date: Sat, 10 Oct 2026 02:37:57 +0200 Subject: [PATCH] Fix PyPy BufferError on SSL reads in StreamReader StreamReader wraps the socket with stdlib's socket.SocketIO, whose inherited _pyio.RawIOBase.read() allocates a bytearray, fills it via readinto(), then shrinks it in place with 'del b[n:]'. For SSL sockets, readinto() goes through ssl.SSLSocket.recv_into(), which creates a local memoryview over that same buffer. On CPython this view is released immediately via deterministic refcounting, but on PyPy buffer exports stay alive until the next GC run, so the subsequent in-place resize can randomly raise: BufferError: Existing exports of data: object cannot be re-sized Add _SocketIO, a thin socket.SocketIO subclass whose read() returns a sliced copy instead of resizing the buffer in place, avoiding the unsafe code path entirely. Opened by Claude, in a Cursor session, on behalf of @deronnax. --- cheroot/makefile.py | 28 +++++++++++++++++++++++++++- cheroot/test/test_makefile.py | 26 +++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cheroot/makefile.py b/cheroot/makefile.py index f5780a1ede..42655b4b70 100644 --- a/cheroot/makefile.py +++ b/cheroot/makefile.py @@ -35,12 +35,38 @@ def _flush_unlocked(self): del self._write_buf[:n] +class _SocketIO(socket.SocketIO): + """``socket.SocketIO`` with a resize-safe ``read()``. + + ``_pyio.RawIOBase.read()`` (the base class' default implementation, + inherited unchanged by ``socket.SocketIO``) allocates a ``bytearray``, + fills it via ``readinto()``, then shrinks it in place with + ``del b[n:]``. On PyPy, ``ssl.SSLSocket.recv_into()`` creates a local + ``memoryview`` over that same buffer which is not released + deterministically (PyPy keeps buffer exports alive until the next GC + run, unlike CPython's immediate refcounting), so the later in-place + resize can randomly raise ``BufferError: Existing exports of data: + object cannot be re-sized``. Returning a fresh copy via slicing avoids + resizing the original buffer and sidesteps the issue entirely. + """ + + def read(self, size=-1): + """Read and return up to ``size`` bytes, without resizing.""" + if size is None or size < 0: + return self.readall() + b = bytearray(size) + n = self.readinto(b) + if n is None: + return None + return bytes(b[:n]) + + class StreamReader(io.BufferedReader): """Socket stream reader.""" def __init__(self, sock, mode='r', bufsize=io.DEFAULT_BUFFER_SIZE): """Initialize socket stream reader.""" - super().__init__(socket.SocketIO(sock, mode), bufsize) + super().__init__(_SocketIO(sock, mode), bufsize) self.bytes_read = 0 def read(self, *args, **kwargs): diff --git a/cheroot/test/test_makefile.py b/cheroot/test/test_makefile.py index d65d4ea268..ad358d8a6b 100644 --- a/cheroot/test/test_makefile.py +++ b/cheroot/test/test_makefile.py @@ -6,9 +6,11 @@ class MockSocket: """A mock socket.""" - def __init__(self): + def __init__(self, keep_buffer_exported=False): """Initialize :py:class:`MockSocket`.""" self.messages = [] + self.keep_buffer_exported = keep_buffer_exported + self.leaked_view = None def recv_into(self, buf): """Simulate ``recv_into`` for Python 3.""" @@ -17,6 +19,10 @@ def recv_into(self, buf): msg = self.messages.pop(0) for index, byte in enumerate(msg): buf[index] = byte + if self.keep_buffer_exported: + # Simulate ssl.SSLSocket.recv_into() leaking a memoryview over + # ``buf`` past the call, as happens on PyPy (see GH-#XXX). + self.leaked_view = memoryview(buf) return len(msg) def recv(self, size): @@ -51,3 +57,21 @@ def test_bytes_written(): wfile = makefile.MakeFile(sock, 'w') wfile.write(b'bar') assert wfile.bytes_written == 3 + + +def test_socket_io_read_does_not_resize_buffer(): + """``_SocketIO.read()`` must not shrink its buffer in place. + + Regression test for a PyPy-only ``BufferError: Existing exports of + data: object cannot be re-sized``, triggered when the default + ``_pyio.RawIOBase.read()`` implementation (``del b[n:]``) resizes a + buffer still referenced by an unreleased ``memoryview``, e.g. one + created internally by ``ssl.SSLSocket.recv_into()``. On PyPy that + memoryview stays alive until the next GC run instead of being + released immediately, unlike CPython's deterministic refcounting. + """ + sock = MockSocket(keep_buffer_exported=True) + sock.messages.append(b'foo') + raw = makefile._SocketIO(sock, 'r') + assert raw.read(3) == b'foo' + assert sock.leaked_view is not None