Skip to content

Commit dfdfa9d

Browse files
authored
[PR aio-libs#12830/93a2b1c3 backport][3.14] Bound pipelined request queue per connection (aio-libs#12854)
1 parent 0e9cedd commit dfdfa9d

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
@@ -323,6 +323,8 @@ cdef class HttpParser:
323323
list _messages
324324
bint _more_data_available
325325
bint _paused
326+
Py_ssize_t _msg_in_flight
327+
Py_ssize_t _max_msg_queue_size
326328
bint _eof_pending
327329
object _payload
328330
unsigned long long _content_length_expected
@@ -359,6 +361,7 @@ cdef class HttpParser:
359361
size_t max_field_size=8190, payload_exception=None,
360362
bint response_with_body=True, bint read_until_eof=False,
361363
bint auto_decompress=True,
364+
Py_ssize_t max_msg_queue_size=0,
362365
):
363366
cparser.llhttp_settings_init(self._csettings)
364367
cparser.llhttp_init(self._cparser, mode, self._csettings)
@@ -373,6 +376,8 @@ cdef class HttpParser:
373376
self._buf = bytearray()
374377
self._more_data_available = False
375378
self._paused = False
379+
self._msg_in_flight = 0
380+
self._max_msg_queue_size = max_msg_queue_size
376381
self._eof_pending = False
377382
self._payload = None
378383
self._payload_error = 0
@@ -556,6 +561,11 @@ cdef class HttpParser:
556561
assert self._payload is not None
557562
self._paused = True
558563

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

@@ -678,12 +688,12 @@ cdef class HttpRequestParser(HttpParser):
678688
size_t max_line_size=8190, size_t max_headers=128,
679689
size_t max_field_size=8190, payload_exception=None,
680690
bint response_with_body=True, bint read_until_eof=False,
681-
bint auto_decompress=True,
691+
bint auto_decompress=True, Py_ssize_t max_msg_queue_size=0,
682692
):
683693
self._init(cparser.HTTP_REQUEST, protocol, loop, limit, timer,
684694
max_line_size, max_headers, max_field_size,
685695
payload_exception, response_with_body, read_until_eof,
686-
auto_decompress)
696+
auto_decompress, max_msg_queue_size)
687697

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

898914

‎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
@@ -275,6 +275,7 @@ def __init__(
275275
response_with_body: bool = True,
276276
read_until_eof: bool = False,
277277
auto_decompress: bool = True,
278+
max_msg_queue_size: int = 0,
278279
) -> None:
279280
self.protocol = protocol
280281
self.loop = loop
@@ -300,6 +301,9 @@ def __init__(
300301
self._headers_parser = HeadersParser(
301302
max_line_size, max_headers, max_field_size, self.lax
302303
)
304+
# Stop emitting messages once this many are queued unconsumed (0 = off).
305+
self._max_msg_queue_size = max_msg_queue_size
306+
self._msg_in_flight = 0
303307

304308
@abc.abstractmethod
305309
def parse_message(self, lines: list[bytes]) -> _MsgT: ...
@@ -311,6 +315,11 @@ def pause_reading(self) -> None:
311315
assert self._payload_parser is not None
312316
self._payload_parser.pause_reading()
313317

318+
def message_consumed(self) -> None:
319+
"""Protocol drained a queued message; free a slot for parsing."""
320+
if self._msg_in_flight > 0:
321+
self._msg_in_flight -= 1
322+
314323
def feed_eof(self) -> _MsgT | None:
315324
if self._payload_parser is not None:
316325
self._payload_parser.feed_eof()
@@ -353,6 +362,15 @@ def feed_data(
353362
# read HTTP message (request/response line + headers), \r\n\r\n
354363
# and split by lines
355364
if self._payload_parser is None and not self._upgraded:
365+
if (
366+
self._max_msg_queue_size
367+
and self._msg_in_flight >= self._max_msg_queue_size
368+
):
369+
# Queue full: buffer the rest and stop. Safe pause point;
370+
# any preceding body is consumed before the next request
371+
# line. Resumes via feed_data(b"") when the queue drains.
372+
self._tail = data[start_pos:]
373+
break
356374
pos = data.find(SEP, start_pos)
357375
# consume \r\n
358376
if pos == start_pos and not self._lines:
@@ -497,6 +515,8 @@ def get_content_length() -> int | None:
497515
payload = EMPTY_PAYLOAD
498516

499517
messages.append((msg, payload))
518+
if self._max_msg_queue_size:
519+
self._msg_in_flight += 1
500520
should_close = msg.should_close
501521
else:
502522
self._tail = data[start_pos:]

‎aiohttp/web_protocol.py‎

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from propcache import under_cached_property
1717

1818
from .abc import AbstractAccessLogger, AbstractStreamWriter
19-
from .base_protocol import BaseProtocol
19+
from .base_protocol import PAUSE_RESUME_READING_ERRORS, BaseProtocol
2020
from .helpers import DEFAULT_CHUNK_SIZE, ceil_timeout
2121
from .http import (
2222
HttpProcessingError,
@@ -37,6 +37,11 @@
3737

3838
__all__ = ("RequestHandler", "RequestPayloadError", "PayloadAccessError")
3939

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

@@ -146,6 +151,9 @@ class RequestHandler(BaseProtocol):
146151
"_keepalive_timeout",
147152
"_lingering_time",
148153
"_messages",
154+
"_max_msg_queue_size",
155+
"_msg_queue_resume_size",
156+
"_msg_queue_paused",
149157
"_message_tail",
150158
"_handler_waiter",
151159
"_waiter",
@@ -186,6 +194,13 @@ def __init__(
186194
auto_decompress: bool = True,
187195
timeout_ceil_threshold: float = 5,
188196
):
197+
self._max_msg_queue_size = MAX_MSG_QUEUE_SIZE
198+
# Low-water mark: resume reading once the queue drains to half the limit
199+
# so we refill in batches instead of churning pause/resume per request.
200+
self._msg_queue_resume_size = MAX_MSG_QUEUE_SIZE // 2
201+
# Set before super().__init__ so _reading_paused_for_msg_queue() is safe
202+
# if BaseProtocol ever triggers a resume during init.
203+
self._msg_queue_paused = False
189204
parser = HttpRequestParser(
190205
self,
191206
loop,
@@ -195,6 +210,7 @@ def __init__(
195210
max_headers=max_headers,
196211
payload_exception=RequestPayloadError,
197212
auto_decompress=auto_decompress,
213+
max_msg_queue_size=MAX_MSG_QUEUE_SIZE,
198214
)
199215
super().__init__(loop, parser)
200216

@@ -431,6 +447,14 @@ def data_received(self, data: bytes) -> None:
431447
# don't set result twice
432448
waiter.set_result(None)
433449

450+
# Queue full: pause the transport (the parser already stopped
451+
# emitting). start() resumes as it drains the queue.
452+
if (
453+
not self._msg_queue_paused
454+
and len(self._messages) >= self._max_msg_queue_size
455+
):
456+
self._pause_msg_queue_reading()
457+
434458
self._upgraded = upgraded
435459
if upgraded and tail:
436460
self._message_tail = tail
@@ -447,6 +471,36 @@ def data_received(self, data: bytes) -> None:
447471
if eof:
448472
self.close()
449473

474+
def _reading_paused_for_msg_queue(self) -> bool:
475+
return self._msg_queue_paused
476+
477+
def _pause_msg_queue_reading(self) -> None:
478+
self._msg_queue_paused = True
479+
if self.transport is not None:
480+
try:
481+
self.transport.pause_reading()
482+
except PAUSE_RESUME_READING_ERRORS:
483+
# Transport lacks flow control; nothing to pause. Intentionally
484+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
485+
pass
486+
487+
def _resume_msg_queue_reading(self) -> None:
488+
if not self._upgraded:
489+
# Reparse buffered pipelined requests while still marked paused so
490+
# a refill past the limit does not re-pause an already-paused
491+
# transport; only resume below once it stayed under the limit.
492+
self.data_received(b"")
493+
if len(self._messages) >= self._max_msg_queue_size:
494+
return
495+
self._msg_queue_paused = False
496+
if not self._reading_paused and self.transport is not None:
497+
try:
498+
self.transport.resume_reading()
499+
except PAUSE_RESUME_READING_ERRORS:
500+
# Transport lacks flow control; nothing to resume. Intentionally
501+
# ignored (see PAUSE_RESUME_READING_ERRORS; do not use suppress).
502+
pass
503+
450504
def keep_alive(self, val: bool) -> None:
451505
"""Set keep-alive connection mode.
452506
@@ -579,6 +633,18 @@ async def start(self) -> None:
579633

580634
message, payload = self._messages.popleft()
581635

636+
# Free a parser slot; resume reading once drained to low water so
637+
# pipelining keeps flowing while this request is handled.
638+
# no branch: _parser is only None after connection_lost, whose path
639+
# exits this loop, so the None case is not reachably exercisable.
640+
if self._parser is not None: # pragma: no branch
641+
self._parser.message_consumed()
642+
if (
643+
self._msg_queue_paused
644+
and len(self._messages) <= self._msg_queue_resume_size
645+
):
646+
self._resume_msg_queue_reading()
647+
582648
# time is only fetched if logging is enabled as otherwise
583649
# its thrown away and never used.
584650
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
@@ -250,6 +250,7 @@ peername
250250
performant
251251
pickleable
252252
ping
253+
pipelined
253254
pipelining
254255
pluggable
255256
plugin

‎tests/test_http_parser.py‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,78 @@ def test_c_parser_loaded():
159159
assert "RawResponseMessageC" in dir(aiohttp.http_parser)
160160

161161

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

0 commit comments

Comments
 (0)