33import asyncio
44import builtins
55import sys
6+ import weakref
67from collections import deque
8+ from typing import Final
79
810from ..base_protocol import BaseProtocol
911from ..compression_utils import TooManyMembersError , ZLibDecompressor
1012from ..helpers import _EXC_SENTINEL , set_exception
13+ from ..log import ws_logger
1114from ..streams import EofStream
1215from .helpers import UNPACK_CLOSE_CODE , UNPACK_LEN3 , websocket_mask
1316from .models import (
6063
6164TUPLE_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+
6375cython_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 :
0 commit comments