Skip to content

Commit 93a2b1c

Browse files
authored
Bound pipelined request queue per connection (aio-libs#12830)
1 parent 5e89842 commit 93a2b1c

9 files changed

Lines changed: 425 additions & 7 deletions

CHANGES/12830.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Bounded the number of parsed-but-unhandled pipelined HTTP/1 requests buffered per connection on the server; once the queue reaches an internal limit the parser stops emitting and the transport is paused, resuming as the request handler drains the queue, so a client keeping one handler busy can no longer accumulate an unbounded backlog of pipelined requests -- by :user:`bdraco`.

aiohttp/_http_parser.pyx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,8 @@ cdef class HttpParser:
325325
list _messages
326326
bint _more_data_available
327327
bint _paused
328+
Py_ssize_t _msg_in_flight
329+
Py_ssize_t _max_msg_queue_size
328330
bint _eof_pending
329331
object _payload
330332
unsigned long long _content_length_expected
@@ -361,6 +363,7 @@ cdef class HttpParser:
361363
size_t max_field_size=8190, payload_exception=None,
362364
bint response_with_body=True, bint read_until_eof=False,
363365
bint auto_decompress=True,
366+
Py_ssize_t max_msg_queue_size=0,
364367
):
365368
cparser.llhttp_settings_init(self._csettings)
366369
cparser.llhttp_init(self._cparser, mode, self._csettings)
@@ -375,6 +378,8 @@ cdef class HttpParser:
375378
self._buf = bytearray()
376379
self._more_data_available = False
377380
self._paused = False
381+
self._msg_in_flight = 0
382+
self._max_msg_queue_size = max_msg_queue_size
378383
self._eof_pending = False
379384
self._payload = None
380385
self._payload_error = 0
@@ -558,6 +563,11 @@ cdef class HttpParser:
558563
assert self._payload is not None
559564
self._paused = True
560565

566+
def message_consumed(self):
567+
# Protocol drained a queued message; free a slot for parsing.
568+
if self._msg_in_flight > 0:
569+
self._msg_in_flight -= 1
570+
561571
def feed_eof(self):
562572
cdef bytes desc
563573

@@ -680,12 +690,12 @@ cdef class HttpRequestParser(HttpParser):
680690
size_t max_line_size=8190, size_t max_headers=128,
681691
size_t max_field_size=8190, payload_exception=None,
682692
bint response_with_body=True, bint read_until_eof=False,
683-
bint auto_decompress=True,
693+
bint auto_decompress=True, Py_ssize_t max_msg_queue_size=0,
684694
):
685695
self._init(cparser.HTTP_REQUEST, protocol, loop, limit, timer,
686696
max_line_size, max_headers, max_field_size,
687697
payload_exception, response_with_body, read_until_eof,
688-
auto_decompress)
698+
auto_decompress, max_msg_queue_size)
689699

690700
cdef object _on_status_complete(self):
691701
cdef int idx1, idx2
@@ -894,6 +904,12 @@ cdef int cb_on_message_complete(cparser.llhttp_t* parser) except -1:
894904
pyparser._last_error = exc
895905
return -1
896906
else:
907+
if pyparser._max_msg_queue_size:
908+
pyparser._msg_in_flight += 1
909+
if pyparser._msg_in_flight >= pyparser._max_msg_queue_size:
910+
# Queue full: pause llhttp between messages. feed_data() buffers
911+
# the remainder as tail; resumes once the queue drains.
912+
return cparser.HPE_PAUSED
897913
return 0
898914

899915

aiohttp/base_protocol.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@
88
if TYPE_CHECKING:
99
from .http_parser import HttpParser
1010

11+
# Raised by transport.pause_reading()/resume_reading() when the transport
12+
# does not support flow control; safe to ignore.
13+
# NOTE: Catch these with a plain try/except/pass, never contextlib.suppress():
14+
# pause/resume run on the hot read path and suppress() is ~6x slower than
15+
# try/except here (it builds a context manager and unpacks this tuple per call).
16+
PAUSE_RESUME_READING_ERRORS = (AttributeError, NotImplementedError, RuntimeError)
17+
1118

1219
class BaseProtocol(asyncio.Protocol):
1320
__slots__ = (
@@ -65,9 +72,15 @@ def pause_reading(self) -> None:
6572
if self.transport is not None:
6673
try:
6774
self.transport.pause_reading()
68-
except (AttributeError, NotImplementedError, RuntimeError):
75+
except PAUSE_RESUME_READING_ERRORS:
76+
# Transport lacks flow control; nothing to pause. Intentionally
77+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
6978
pass
7079

80+
def _reading_paused_for_msg_queue(self) -> bool:
81+
"""Keep the transport paused for protocol-specific reasons (overridden)."""
82+
return False
83+
7184
def resume_reading(self, resume_parser: bool = True) -> None:
7285
self._reading_paused = False
7386

@@ -77,10 +90,16 @@ def resume_reading(self, resume_parser: bool = True) -> None:
7790

7891
# Reading may have been paused again in the above call if there was a lot of
7992
# compressed data still pending.
80-
if not self._reading_paused and self.transport is not None:
93+
if (
94+
not self._reading_paused
95+
and not self._reading_paused_for_msg_queue()
96+
and self.transport is not None
97+
):
8198
try:
8299
self.transport.resume_reading()
83-
except (AttributeError, NotImplementedError, RuntimeError):
100+
except PAUSE_RESUME_READING_ERRORS:
101+
# Transport lacks flow control; nothing to resume. Intentionally
102+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
84103
pass
85104
self._reading_paused = False
86105

aiohttp/http_parser.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ def __init__(
266266
response_with_body: bool = True,
267267
read_until_eof: bool = False,
268268
auto_decompress: bool = True,
269+
max_msg_queue_size: int = 0,
269270
) -> None:
270271
self.protocol = protocol
271272
self.loop = loop
@@ -288,6 +289,9 @@ def __init__(
288289
self._auto_decompress = auto_decompress
289290
self._limit = limit
290291
self._headers_parser = HeadersParser(max_field_size, self.lax)
292+
# Stop emitting messages once this many are queued unconsumed (0 = off).
293+
self._max_msg_queue_size = max_msg_queue_size
294+
self._msg_in_flight = 0
291295

292296
@abc.abstractmethod
293297
def parse_message(self, lines: list[bytes]) -> _MsgT: ...
@@ -299,6 +303,11 @@ def pause_reading(self) -> None:
299303
assert self._payload_parser is not None
300304
self._payload_parser.pause_reading()
301305

306+
def message_consumed(self) -> None:
307+
"""Protocol drained a queued message; free a slot for parsing."""
308+
if self._msg_in_flight > 0:
309+
self._msg_in_flight -= 1
310+
302311
def feed_eof(self) -> _MsgT | None:
303312
if self._payload_parser is not None:
304313
self._payload_parser.feed_eof()
@@ -340,6 +349,15 @@ def feed_data(
340349
# read HTTP message (request/response line + headers), \r\n\r\n
341350
# and split by lines
342351
if self._payload_parser is None and not self._upgraded:
352+
if (
353+
self._max_msg_queue_size
354+
and self._msg_in_flight >= self._max_msg_queue_size
355+
):
356+
# Queue full: buffer the rest and stop. Safe pause point;
357+
# any preceding body is consumed before the next request
358+
# line. Resumes via feed_data(b"") when the queue drains.
359+
self._tail = data[start_pos:]
360+
break
343361
pos = data.find(SEP, start_pos)
344362
# consume \r\n
345363
if pos == start_pos and not self._lines:
@@ -484,6 +502,8 @@ def get_content_length() -> int | None:
484502
payload = EMPTY_PAYLOAD
485503

486504
messages.append((msg, payload))
505+
if self._max_msg_queue_size:
506+
self._msg_in_flight += 1
487507
should_close = msg.should_close
488508
else:
489509
self._tail = data[start_pos:]

aiohttp/web_protocol.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
from propcache import under_cached_property
1515

1616
from .abc import AbstractAccessLogger, AbstractAsyncAccessLogger, AbstractStreamWriter
17-
from .base_protocol import BaseProtocol
17+
from .base_protocol import PAUSE_RESUME_READING_ERRORS, BaseProtocol
1818
from .helpers import DEFAULT_CHUNK_SIZE, ceil_timeout, frozen_dataclass_decorator
1919
from .http import (
2020
HttpProcessingError,
@@ -35,6 +35,11 @@
3535

3636
__all__ = ("RequestHandler", "RequestPayloadError", "PayloadAccessError")
3737

38+
# Max parsed-but-unhandled pipelined requests buffered per connection before
39+
# reading is paused. Bounds memory a client can pin by keeping one handler busy
40+
# and pipelining behind it; reading resumes as the queue drains.
41+
MAX_MSG_QUEUE_SIZE = 32
42+
3843
if TYPE_CHECKING:
3944
import ssl
4045

@@ -168,6 +173,9 @@ class RequestHandler(BaseProtocol, Generic[_Request]):
168173
"_keepalive_timeout",
169174
"_lingering_time",
170175
"_messages",
176+
"_max_msg_queue_size",
177+
"_msg_queue_resume_size",
178+
"_msg_queue_paused",
171179
"_message_tail",
172180
"_handler_waiter",
173181
"_waiter",
@@ -206,6 +214,13 @@ def __init__(
206214
auto_decompress: bool = True,
207215
timeout_ceil_threshold: float = 5,
208216
):
217+
self._max_msg_queue_size = MAX_MSG_QUEUE_SIZE
218+
# Low-water mark: resume reading once the queue drains to half the limit
219+
# so we refill in batches instead of churning pause/resume per request.
220+
self._msg_queue_resume_size = MAX_MSG_QUEUE_SIZE // 2
221+
# Set before super().__init__ so _reading_paused_for_msg_queue() is safe
222+
# if BaseProtocol ever triggers a resume during init.
223+
self._msg_queue_paused = False
209224
parser = HttpRequestParser(
210225
self,
211226
loop,
@@ -215,6 +230,7 @@ def __init__(
215230
max_headers=max_headers,
216231
payload_exception=RequestPayloadError,
217232
auto_decompress=auto_decompress,
233+
max_msg_queue_size=MAX_MSG_QUEUE_SIZE,
218234
)
219235
super().__init__(loop, parser)
220236

@@ -461,6 +477,14 @@ def data_received(self, data: bytes) -> None:
461477
# don't set result twice
462478
waiter.set_result(None)
463479

480+
# Queue full: pause the transport (the parser already stopped
481+
# emitting). start() resumes as it drains the queue.
482+
if (
483+
not self._msg_queue_paused
484+
and len(self._messages) >= self._max_msg_queue_size
485+
):
486+
self._pause_msg_queue_reading()
487+
464488
self._upgraded = upgraded
465489
if upgraded and tail:
466490
self._message_tail = tail
@@ -477,6 +501,36 @@ def data_received(self, data: bytes) -> None:
477501
if eof:
478502
self.close()
479503

504+
def _reading_paused_for_msg_queue(self) -> bool:
505+
return self._msg_queue_paused
506+
507+
def _pause_msg_queue_reading(self) -> None:
508+
self._msg_queue_paused = True
509+
if self.transport is not None:
510+
try:
511+
self.transport.pause_reading()
512+
except PAUSE_RESUME_READING_ERRORS:
513+
# Transport lacks flow control; nothing to pause. Intentionally
514+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
515+
pass
516+
517+
def _resume_msg_queue_reading(self) -> None:
518+
if not self._upgraded:
519+
# Reparse buffered pipelined requests while still marked paused so
520+
# a refill past the limit does not re-pause an already-paused
521+
# transport; only resume below once it stayed under the limit.
522+
self.data_received(b"")
523+
if len(self._messages) >= self._max_msg_queue_size:
524+
return
525+
self._msg_queue_paused = False
526+
if not self._reading_paused and self.transport is not None:
527+
try:
528+
self.transport.resume_reading()
529+
except PAUSE_RESUME_READING_ERRORS:
530+
# Transport lacks flow control; nothing to resume. Intentionally
531+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
532+
pass
533+
480534
def keep_alive(self, val: bool) -> None:
481535
"""Set keep-alive connection mode.
482536
@@ -606,6 +660,18 @@ async def start(self) -> None:
606660

607661
message, payload = self._messages.popleft()
608662

663+
# Free a parser slot; resume reading once drained to low water so
664+
# pipelining keeps flowing while this request is handled.
665+
# no branch: _parser is only None after connection_lost, whose path
666+
# exits this loop, so the None case is not reachably exercisable.
667+
if self._parser is not None: # pragma: no branch
668+
self._parser.message_consumed()
669+
if (
670+
self._msg_queue_paused
671+
and len(self._messages) <= self._msg_queue_resume_size
672+
):
673+
self._resume_msg_queue_reading()
674+
609675
# time is only fetched if logging is enabled as otherwise
610676
# its thrown away and never used.
611677
start = loop.time() if self._logging_enabled else None

docs/spelling_wordlist.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ peername
251251
performant
252252
pickleable
253253
ping
254+
pipelined
254255
pipelining
255256
pluggable
256257
plugin

tests/test_http_parser.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,78 @@ def test_c_parser_loaded() -> None:
153153
assert "RawResponseMessageC" in dir(aiohttp.http_parser)
154154

155155

156+
_PIPELINED_GET = b"GET / HTTP/1.1\r\nHost: a\r\n\r\n"
157+
158+
159+
def _build_request_parser(
160+
request_cls: type[HttpRequestParser],
161+
protocol: BaseProtocol,
162+
loop: asyncio.AbstractEventLoop,
163+
max_msg_queue_size: int,
164+
) -> HttpRequestParser:
165+
return request_cls(
166+
protocol,
167+
loop,
168+
DEFAULT_CHUNK_SIZE,
169+
max_line_size=8190,
170+
max_headers=128,
171+
max_field_size=8190,
172+
max_msg_queue_size=max_msg_queue_size,
173+
)
174+
175+
176+
def test_max_msg_queue_size_caps_emitted_messages(
177+
request_cls: type[HttpRequestParser],
178+
protocol: BaseProtocol,
179+
event_loop: asyncio.AbstractEventLoop,
180+
) -> None:
181+
parser = _build_request_parser(request_cls, protocol, event_loop, 4)
182+
messages, upgraded, _tail = parser.feed_data(_PIPELINED_GET * 10)
183+
assert len(messages) == 4
184+
assert not upgraded
185+
186+
187+
def test_max_msg_queue_size_resumes_after_consume(
188+
request_cls: type[HttpRequestParser],
189+
protocol: BaseProtocol,
190+
event_loop: asyncio.AbstractEventLoop,
191+
) -> None:
192+
limit = 4
193+
total = 10
194+
parser = _build_request_parser(request_cls, protocol, event_loop, limit)
195+
messages, _upgraded, _tail = parser.feed_data(_PIPELINED_GET * total)
196+
seen = 0
197+
while messages:
198+
assert len(messages) <= limit
199+
seen += len(messages)
200+
for _msg, _payload in messages:
201+
parser.message_consumed()
202+
messages, _upgraded, _tail = parser.feed_data(b"")
203+
assert seen == total
204+
205+
206+
def test_max_msg_queue_size_zero_is_unbounded(
207+
request_cls: type[HttpRequestParser],
208+
protocol: BaseProtocol,
209+
event_loop: asyncio.AbstractEventLoop,
210+
) -> None:
211+
parser = _build_request_parser(request_cls, protocol, event_loop, 0)
212+
messages, _upgraded, _tail = parser.feed_data(_PIPELINED_GET * 50)
213+
assert len(messages) == 50
214+
215+
216+
def test_message_consumed_underflow_is_ignored(
217+
request_cls: type[HttpRequestParser],
218+
protocol: BaseProtocol,
219+
event_loop: asyncio.AbstractEventLoop,
220+
) -> None:
221+
parser = _build_request_parser(request_cls, protocol, event_loop, 4)
222+
# No message is in flight; consuming must not underflow the counter.
223+
parser.message_consumed()
224+
messages, _upgraded, _tail = parser.feed_data(_PIPELINED_GET * 4)
225+
assert len(messages) == 4
226+
227+
156228
def test_parse_headers(parser: HttpRequestParser) -> None:
157229
text = b"""GET /test HTTP/1.1\r
158230
Host: a\r

0 commit comments

Comments
 (0)