Skip to content

Commit 5e54037

Browse files
Fix memory consumption on empty/small messages (aio-libs#13393)
Co-authored-by: J. Nick Koston <nick@koston.org>
1 parent 5fa8584 commit 5e54037

11 files changed

Lines changed: 871 additions & 73 deletions

‎CHANGES/13393.breaking.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
The WebSocket receive queue now only holds a weak reference to the ``WebSocketReader`` while parsing is stalled; code constructing a reader directly and passing it to ``set_parser()`` must keep its own strong reference to it, or frames the reader stopped short of parsing are lost with it -- by :user:`Dreamsorcerer`.

‎CHANGES/13393.bugfix.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed excessive memory consumption with small WebSocket messages -- by :user:`Dreamsorcerer`.

‎THREAT_MODEL.md‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -582,6 +582,21 @@ client-side, the writer adds masks to outgoing frames.
582582
fragment count (`max(1024, max_msg_size // 256)`) and pauses reading for
583583
backpressure once exceeded, mirroring the HTTP chunk-splits limit in
584584
`StreamReader` (PR #11894).
585+
- **PR #13393** — queue accounting only counted payload bytes, so a flood
586+
of empty/tiny frames could pin unbounded per-message object overhead in
587+
`WebSocketDataQueue` before the `_limit` high-water mark fired. Each
588+
queued message is now charged a 128-byte accounting overhead
589+
(`MSG_SIZE_OVERHEAD`) and the reader stalls at a frame boundary once the
590+
queue is over the mark: the unparsed remainder stays compressed in
591+
`_tail`, the queue keeps only a weakref to the stalled reader, and
592+
drains re-drive it at a `_limit // 2` low-water mark (each resume
593+
re-slices the tail, so per-pop resumes would make draining a tiny-frame
594+
burst quadratic CPU), and the transport stays paused until the stash is
595+
exhausted (resuming earlier would admit a fresh socket read into `_tail`
596+
per couple of drained messages, relocating the memory bound there).
597+
Callers constructing a `WebSocketReader` for
598+
`set_parser()` must hold a strong reference to it (both in-tree response
599+
classes do, via `_parser`).
585600

586601
---
587602

‎aiohttp/_websocket/reader_c.pxd‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,21 @@ cdef set MESSAGE_TYPES_WITH_CONTENT
4444
cdef tuple EMPTY_FRAME
4545
cdef tuple EMPTY_FRAME_ERROR
4646

47+
cdef unsigned int MSG_SIZE_OVERHEAD
48+
4749
cdef class WebSocketDataQueue:
4850

49-
cdef unsigned int _size
51+
cdef readonly unsigned int _size
5052
cdef public object _protocol
51-
cdef unsigned int _limit
53+
cdef readonly unsigned int _limit
5254
cdef object _loop
5355
cdef bint _eof
5456
cdef object _waiter
5557
cdef object _exception
5658
cdef public object _buffer
5759
cdef object _get_buffer
5860
cdef object _put_buffer
61+
cdef readonly object _stalled_reader
5962

6063
cdef void _release_waiter(self)
6164

@@ -67,6 +70,11 @@ cdef class WebSocketDataQueue:
6770

6871
cdef class WebSocketReader:
6972

73+
# cdef classes are not weak-referenceable without this; the queue parks a
74+
# weakref here while parsing is stalled.
75+
cdef object __weakref__
76+
cdef object _weak_self
77+
7078
cdef WebSocketDataQueue queue
7179
cdef unsigned int _max_msg_size
7280
cdef bint _decode_text

‎aiohttp/_websocket/reader_py.py‎

Lines changed: 55 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,14 @@
33
import asyncio
44
import builtins
55
import sys
6+
import weakref
67
from collections import deque
8+
from typing import Final
79

810
from ..base_protocol import BaseProtocol
911
from ..compression_utils import TooManyMembersError, ZLibDecompressor
1012
from ..helpers import _EXC_SENTINEL, set_exception
13+
from ..log import ws_logger
1114
from ..streams import EofStream
1215
from .helpers import UNPACK_CLOSE_CODE, UNPACK_LEN3, websocket_mask
1316
from .models import (
@@ -60,6 +63,15 @@
6063

6164
TUPLE_NEW = tuple.__new__
6265

66+
# Overhead added to each message to ensure that tiny messages can't use
67+
# unreasonable amounts of memory.
68+
MSG_SIZE_OVERHEAD: Final[int] = 128
69+
70+
STALLED_READER_COLLECTED: Final[str] = (
71+
"WebSocketReader was garbage collected while stalled; "
72+
"callers of set_parser() must hold a strong reference"
73+
)
74+
6375
cython_int = int # Typed to int in Python, but cython with use a signed int in the pxd
6476

6577

@@ -82,6 +94,7 @@ def __init__(
8294
self._buffer: deque[WSMessage] = deque()
8395
self._get_buffer = self._buffer.popleft
8496
self._put_buffer = self._buffer.append
97+
self._stalled_reader: "weakref.ref[WebSocketReader] | None" = None
8598

8699
def is_eof(self) -> bool:
87100
return self._eof
@@ -113,8 +126,10 @@ def feed_eof(self) -> None:
113126
self._exception = None # Break cyclic references
114127

115128
def feed_data(self, data: "WSMessage") -> None:
129+
# Unbox into the typed local before adding, so Cython keeps the sum in
130+
# C instead of boxing MSG_SIZE_OVERHEAD for a Python-level add.
116131
size = data.size
117-
self._size += size
132+
self._size += size + MSG_SIZE_OVERHEAD
118133
self._put_buffer(data)
119134
self._release_waiter()
120135
if self._size > self._limit and not self._protocol._reading_paused:
@@ -135,8 +150,33 @@ def _read_from_buffer(self) -> WSMessage:
135150
if self._buffer:
136151
data = self._get_buffer()
137152
size = data.size
138-
self._size -= size
139-
if self._size < self._limit and self._protocol._reading_paused:
153+
self._size -= size + MSG_SIZE_OVERHEAD
154+
if self._stalled_reader is not None and self._size <= self._limit // 2:
155+
# Resume parsing once the queue drains to the low-water mark.
156+
# Each resume re-slices the parser's unparsed tail, so waiting
157+
# for headroom makes a drain cost one copy per batch of
158+
# messages instead of one per message.
159+
if (reader := self._stalled_reader()) is not None:
160+
reader.feed_data(b"")
161+
else:
162+
# The stash died with the reader. Deliver what was already
163+
# queued, then surface the contract violation on the next
164+
# read instead of hanging. Log as well, since a caller that
165+
# stops reading early never sees the deferred exception. A
166+
# real failure that was already recorded stays the reported
167+
# cause.
168+
self._stalled_reader = None
169+
ws_logger.warning(STALLED_READER_COLLECTED)
170+
if self._exception is None:
171+
self.set_exception(RuntimeError(STALLED_READER_COLLECTED))
172+
# Resuming the transport while a stash remains would admit
173+
# another socket read into the tail for every couple of messages
174+
# drained, moving the memory bound from the queue into the tail.
175+
if (
176+
self._stalled_reader is None
177+
and self._size < self._limit
178+
and self._protocol._reading_paused
179+
):
140180
self._protocol.resume_reading()
141181
return data
142182
if self._exception is not None:
@@ -155,6 +195,9 @@ def __init__(
155195
self.queue = queue
156196
self._max_msg_size = max_msg_size
157197
self._decode_text = decode_text
198+
# Parked on the queue while parsing is stalled; created once so
199+
# stalling does not allocate.
200+
self._weak_self = weakref.ref(self)
158201

159202
self._exc: Exception | None = None
160203
self._partial = bytearray()
@@ -349,6 +392,7 @@ def _handle_frame(
349392

350393
def _feed_data(self, data: bytes) -> None:
351394
"""Return the next frame from the socket."""
395+
self.queue._stalled_reader = None
352396
if self._tail:
353397
data, self._tail = self._tail + data, b""
354398

@@ -357,6 +401,14 @@ def _feed_data(self, data: bytes) -> None:
357401
data_cstr = data
358402

359403
while True:
404+
if start_pos < data_len and self.queue._size > self.queue._limit:
405+
# Over the high-water mark with unparsed bytes left: stash the
406+
# remainder and stall. Gating on unparsed bytes keeps a read
407+
# that ended on a frame boundary from arming an empty stall,
408+
# which would hold the transport paused with nothing to drain.
409+
self.queue._stalled_reader = self._weak_self
410+
break
411+
360412
# read header
361413
if self._state == READ_HEADER:
362414
if data_len - start_pos < 2:

‎aiohttp/client.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1210,14 +1210,14 @@ async def _ws_connect(
12101210
compress=compress,
12111211
client_notakeover=notakeover,
12121212
)
1213-
parser = WebSocketReader(
1213+
ws_resp._parser = WebSocketReader(
12141214
reader,
12151215
max_msg_size,
12161216
compress=bool(compress),
12171217
decode_text=decode_text,
12181218
)
12191219
cb = None if heartbeat is None else ws_resp._on_data_received
1220-
conn_proto.set_parser(parser, reader, data_received_cb=cb)
1220+
conn_proto.set_parser(ws_resp._parser, reader, data_received_cb=cb)
12211221
return ws_resp
12221222

12231223
def _prepare_headers(self, headers: LooseHeaders | None) -> "CIMultiDict[str]":

‎aiohttp/client_ws.py‎

Lines changed: 36 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from types import TracebackType
88
from typing import Any, Final, Generic, Literal, overload
99

10-
from ._websocket.reader import WebSocketDataQueue
10+
from ._websocket.reader import WebSocketDataQueue, WebSocketReader
1111
from .client_exceptions import ClientError, ServerTimeoutError, WSMessageTypeError
1212
from .client_reqrep import ClientResponse
1313
from .helpers import calculate_timeout_when, frozen_dataclass_decorator, set_result
@@ -79,6 +79,10 @@ def __init__(
7979

8080
self._writer = writer
8181
self._reader = reader
82+
# Set by ClientSession._ws_connect; owns the parser so a stalled
83+
# reader parked on the queue by weakref stays alive while this
84+
# response can still be drained.
85+
self._parser: WebSocketReader | None = None
8286
self._protocol = protocol
8387
self._closed = False
8488
self._closing = False
@@ -220,6 +224,8 @@ def _handle_ping_pong_exception(self, exc: BaseException) -> None:
220224
if self._closed:
221225
return
222226
self._set_closed()
227+
# close() is never reached after this; release the parser here.
228+
self._parser = None
223229
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
224230
self._exception = exc
225231
self._response.close()
@@ -337,25 +343,8 @@ async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bo
337343

338344
self._set_closed()
339345
try:
340-
await self._writer.close(code, message)
341-
except asyncio.CancelledError:
342-
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
343-
self._response.close()
344-
raise
345-
except Exception as exc:
346-
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
347-
self._exception = exc
348-
self._response.close()
349-
return True
350-
351-
if self._close_code:
352-
self._response.close()
353-
return True
354-
355-
while True:
356346
try:
357-
async with async_timeout.timeout(self._timeout.ws_close):
358-
msg = await self._reader.read()
347+
await self._writer.close(code, message)
359348
except asyncio.CancelledError:
360349
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
361350
self._response.close()
@@ -366,11 +355,33 @@ async def close(self, *, code: int = WSCloseCode.OK, message: bytes = b"") -> bo
366355
self._response.close()
367356
return True
368357

369-
if msg.type is WSMsgType.CLOSE:
370-
self._close_code = msg.data
358+
if self._close_code:
371359
self._response.close()
372360
return True
373361

362+
while True:
363+
try:
364+
async with async_timeout.timeout(self._timeout.ws_close):
365+
msg = await self._reader.read()
366+
except asyncio.CancelledError:
367+
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
368+
self._response.close()
369+
raise
370+
except Exception as exc:
371+
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
372+
self._exception = exc
373+
self._response.close()
374+
return True
375+
376+
if msg.type is WSMsgType.CLOSE:
377+
self._close_code = msg.data
378+
self._response.close()
379+
return True
380+
finally:
381+
# Once closed the response can no longer be drained; release the
382+
# parser and the stash it retains.
383+
self._parser = None
384+
374385
@overload
375386
async def receive(
376387
self: "ClientWebSocketResponse[Literal[True]]", timeout: float | None = None
@@ -425,7 +436,10 @@ async def receive(
425436
await self.close()
426437
return WS_CLOSED_MESSAGE
427438
except ClientError:
428-
# Likely ServerDisconnectedError when connection is lost
439+
# Likely ServerDisconnectedError when connection is lost.
440+
# close() is not called on this path, so release the parser
441+
# and the stash it retains here.
442+
self._parser = None
429443
self._set_closed()
430444
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
431445
return WS_CLOSED_MESSAGE

0 commit comments

Comments
 (0)