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