Fix memory consumption on empty/small messages - #13393
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #13393 +/- ##
========================================
Coverage 98.99% 98.99%
========================================
Files 132 132
Lines 49454 49575 +121
Branches 2572 2581 +9
========================================
+ Hits 48959 49079 +120
Misses 371 371
- Partials 124 125 +1
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. |
|
@aiolibsbot review |
Merging this PR will improve performance by 9.53%
|
| Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|
| ⚡ | test_simple_web_file_response[ssl-large] |
307.7 ms | 280.9 ms | +9.53% |
Tip
Curious why this is faster? Comment @codspeedbot explain why this is faster on this PR, or directly use the CodSpeed MCP with your agent.
Comparing fix-ws-queue (3d7f5f1) with master (d041d4d)2
Footnotes
-
83 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩
-
No successful run was found on
master(77c5108) during the generation of this report, so d041d4d was used instead as the comparison base. There might be some changes unrelated to this pull request in this report. ↩
Reviewed. No blockers. Verified locally, not just by reading. Cythonized cleanly. Built Also probed three failure modes. Protocol error in stashed tail still surfaces correctly, after delivering queued messages. Partial-frame tail leaves no stall. Suspected quadratic re-slice on drain: measured 0.40s vs 0.34s on master for 43,690 frames per read. Not a concern. Five nits. Two worth a look: new queue↔reader cycle needs breaking (pair survives |
|
|
|
@aiolibsbot review |
PR Review — Fix memory consumption on empty/small messagesBoth findings from my previous pass are resolved, and the fix holds up under a compiled Cython build. Merge-ready; five non-blocking nits. The overshoot fix is the right shape. Moving the limit check to the top of the parse loop — before any frame is touched, rather than after one has been handed to the queue — means a read that lands while the queue is still over the mark now inflates nothing at all. I measured 10 successive over-limit reads: the queue stays at exactly 1 buffered message and The headline number, 50 over-limit reads of a 32-message deflate burst: What I verified rather than assumed, on the patched tree:
✅ Resolved since last review (2)Previously-flagged issues verified fixed
🟢 Suggestions
1. Residual reader ↔ queue cycle when a stalled connection is abandoned
|
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| aiohttp/_websocket/reader_py.py | Adds per-message queue accounting and drain-driven parser backpressure without an eligible follow-up defect. |
| aiohttp/_websocket/reader_c.pxd | Updates Cython declarations for queue inspection, stalled-reader state, and weak-reference support. |
| aiohttp/client.py | Retains the client WebSocket parser on the response so stalled parsing can resume. |
| aiohttp/client_ws.py | Adds typed client-side ownership for the WebSocket parser. |
| aiohttp/web_ws.py | Adds server-side parser ownership and installs the retained parser into the request protocol. |
| tests/test_websocket_parser.py | Adds focused regression coverage for queue limits, compressed bursts, stalled input, EOF, errors, and weak-reference behavior. |
Reviews (2): Last reviewed commit: "Fix test" | Re-trigger Greptile
|
@aiolibsbot gh_request Do a scope analysis on the everything that holds a |
Reference-scope analysis: who holds the reader, and where data can go missingScope: every reference to Who holds the
|
| Holder | Strength | Set in | Released in |
|---|---|---|---|
RequestHandler._payload_parser |
strong | set_parser() from WebSocketResponse._post_start |
connection_lost(), after feed_eof(); data_received() → close() on parser eof |
ResponseHandler._payload_parser |
strong | set_parser() from ClientSession._ws_connect |
connection_lost(), after feed_eof(); data_received() on parser eof |
WebSocketResponse._parser (new) |
strong | _post_start() |
never |
ClientWebSocketResponse._parser (new) |
strong | _ws_connect() |
never |
WebSocketDataQueue._stalled_reader |
weak | _feed_data() when over the mark |
top of _feed_data(), set_exception() |
Both protocols drop their reference at connection loss, so from that moment the two new
_parser assignments are the only strong reference to a reader that may still hold a
stash. They are load-bearing, and they were the last hole: the reader is constructed in
exactly two places in-tree, both now owned.
Who holds the WebSocketDataQueue
WebSocketReader.queue, WebSocketResponse._reader / ClientWebSocketResponse._reader
(never cleared), and ResponseHandler._payload (cleared in close()/abort()/
connection_lost(), harmless because the response owns it). No weak references — the
queue cannot vanish under a live response. queue._protocol is strong, and
protocol._data_received_cb is a bound method of the response, so the response outlives
the protocol's own teardown.
Finding 1 — the ownership was untested
Reverting either _parser assignment loses 3903 of 8000 already-received frames, with
no exception and a normal CLOSED: the queue drains what it has, the weak reference is
dead, nothing drives the parser again. Nothing in the suite failed. #13408 adds one test
per side, each feeding a single oversized read, dropping the connection synchronously, and
asserting all 8000 arrive (assert 4097 == 8000 without the fix).
Finding 2 — set_exception() drops the stash, and that is a behaviour change
set_exception() clears _stalled_reader ("Nothing more will be parsed"), but
_read_from_buffer() raises only once the buffer is empty, so buffered messages are still
delivered first. Frames the parser stopped short of are not:
this branch: queued before stall: 4097 → delivered: 4097, then ConnectionResetError
master: queued: 8000 → delivered: 8000, then ConnectionResetError
(8000 empty TEXT frames in one read, then queue.set_exception(ConnectionResetError()).)
Correct for a parse error, where the reader is poisoned via self._exc anyway. Less
obviously correct for WebSocketResponse._cancel(), which is the transport-died hook and
now silently discards complete frames that master delivered. Worth a deliberate decision
either way — dropping the clear in set_exception() would keep the stash reachable, since
the terminal states that matter for the cycle are already covered by feed_eof() plus the
weak reference.
Side note found while tracing it: _cancel()'s comment says web_protocol calls it from
connection_lost or at shutdown, but the only in-tree callers of _cancel() are
BaseRequest._cancel (line 376/432 of web_protocol.py, a different method) and one
test. Either the comment is stale or a wiring regression is hiding there — either way, WS
handlers currently learn about connection loss via feed_eof(), not _cancel().
Finding 3 — the new rule is invisible to third parties
WebSocketReader is exported in aiohttp.http.__all__ and
aiohttp.http_websocket.__all__. Anyone constructing one and handing it to set_parser()
(proxies, ASGI bridges) must now keep their own strong reference, and the failure mode is
silent: a short stream, or a hang if EOF never arrives. Cheap to mention in the changelog
fragment.
Verified, not assumed
- Post-EOF drain calls
pause_reading()/resume_reading()withtransport is None; both
are guarded, andpause_reading()'sassert self._parser is not Noneis skipped only
because_upgradedstaysTrueafter connection loss. Safe, but that is the coupling
holding it up. - Driving the parser from
_read_from_buffer()is not re-entrant:_release_waiter()
only sets a future result. - Cython:
cdef object __weakref__cythonizes clean andweakref.ref()works on the
compiledWebSocketReader; 98 parser tests pass against the built extension. (Only
_websocket/*was compiled — the full build needs a Node.js build of vendored llhttp.)
Analysis by Kōan (Claude Opus 5) at @bdraco's request.
No description provided.