Skip to content

Handle the replayed tail the way data_received does - #13358

Draft
rodrigobnogueira wants to merge 8 commits into
aio-libs:masterfrom
rodrigobnogueira:fix-finish-response-tail-handling
Draft

Handle the replayed tail the way data_received does#13358
rodrigobnogueira wants to merge 8 commits into
aio-libs:masterfrom
rodrigobnogueira:fix-finish-response-tail-handling

Conversation

@rodrigobnogueira

@rodrigobnogueira rodrigobnogueira commented Aug 9, 2026

Copy link
Copy Markdown
Member

Stacked on #13356, which touches the same block in finish_response(). Review that one first; this branch contains its two commits. Happy to rebase onto master if #13356 lands or is dropped.

What do these changes do?

finish_response() replays the bytes buffered behind an upgrade request the handler declined (#12796). It feeds them to the parser without the two things data_received() does around the same call, and both are reachable from the network.

A parse error escapes. data_received() wraps feed_data() and turns HttpProcessingError into a queued 400. finish_response() does not, and it replays before resp.prepare() runs — so a malformed request pipelined behind the declined upgrade discards the response already built for the upgrade, force-closes the connection, and logs a routine client error as an unhandled server exception:

GET /upgrade  (Upgrade: websocket, handler declines)
POST /bad     (Transfer-Encoding: chunked + Content-Length: 5)

before:  response bytes b''            connection closed, "Unhandled exception" logged
after:   HTTP/1.1 200 ... then 400     both answered, connection intact

The identical malformed request on the ordinary path already gets a clean 400.

The upgraded flag is discarded. When the replayed bytes contain a second upgrade, the parser switches to upgraded while self._upgraded stays False. The next data_received() therefore takes the parser branch and assigns _message_tail instead of appending to it, dropping whatever was buffered there:

pipelined behind two declined upgrades before after
pure-Python parser /pipelined dropped, silently served
C parser /pipelined dropped and the next request corrupted both served

The C parser's corruption is worth spelling out, because it is the sharper symptom. nb is declared at _http_parser.pyx:631 and assigned only inside the HPE_PAUSED_UPGRADE branch at :675, but it is read at :711:

if self._upgraded:
    return messages, True, data[nb:]

Entering feed_data() already upgraded, without llhttp returning HPE_PAUSED_UPGRADE on that call, therefore slices data with an uninitialised size_t. In the reproducer it lands on 1, and the next request line arrives as b"ET /later HTTP/1.1" — the leading G eaten. Keeping the flag stops the protocol from ever handing the parser that state, so this is a hardening of an invariant the C parser already assumes rather than only a bookkeeping fix.

Are there changes in behavior for the user?

A malformed request pipelined behind a declined upgrade is answered with 400 instead of killing the connection and losing the preceding response. Requests buffered behind a second declined upgrade are dispatched instead of dropped. No API change.

Is it a substantial burden for the maintainers to support this?

No. It makes the replay path mirror data_received(), which is where the same three concerns are already handled.

Related issue number

None. Both defects predate #13356 — verified by running each reproducer against 288cf469b and against this branch's parent and getting identical results — and both are on the path added in #12796.

Checklist

  • I think the code is well written
  • Unit tests for the changes exist
  • Documentation reflects the changes — N/A, no user-facing API change
  • If you provide code modification, please add yourself to CONTRIBUTORS.txt — already listed
  • Add a new news fragment into the CHANGES/ folder
Test run

Both parser backends, C extensions built:

$ pytest tests/test_web_websocket_functional.py tests/test_web_protocol.py \
         tests/test_web_functional.py tests/test_http_parser.py tests/test_web_server.py
1072 passed, 6 deselected, 3 xfailed        # C extensions
656 passed, 35 skipped, 4 deselected        # AIOHTTP_NO_EXTENSIONS=1

Both new tests fail with only web_protocol.py reverted, on both backends:

FAILED tests/test_web_websocket_functional.py::test_bad_pipelined_request_after_failed_websocket_upgrade
FAILED tests/test_web_websocket_functional.py::test_second_upgrade_pipelined_after_failed_websocket_upgrade

mypy aiohttp/web_protocol.py reports the same count as the parent commit, with no errors in the file itself.

rodrigobnogueira and others added 5 commits August 8, 2026 22:47
When the message queue fills, the pure-Python parser stores the unparsed
remainder in self._tail and also returns it. The caller gets a second
copy of bytes the parser is already holding, and the next feed_data()
prepends the retained copy to the returned one.

data_received() drops the returned tail unless the request upgraded, so
ordinary pipelining is unaffected. finish_response() keeps it
unconditionally, though, which is the path taken when a handler answers
an upgrade request normally. Pipelining more requests than the queue
holds behind such a request then serves them repeatedly, or grows the
buffer by a copy of itself per request until memory runs out. The C
parser never returned the tail, so only the pure-Python one is affected.

Clear the returned buffer, matching what the incomplete-request-line
branch already does.

finish_response() also appended straight to the queue without the pause
bookkeeping data_received() does, so nothing later fed the parser the
remainder it kept: the requests past the queue limit went unanswered.
Pause there too and let start() resume as it drains.
finish_response() replays the bytes buffered behind an upgrade request the
handler declined. It feeds them to the parser without the two things
data_received() does around the same call.

A parse error escapes. It is raised before the declined upgrade's own
response is written, so that response is lost, the connection is force
closed, and a client error is logged as an unhandled exception. The same
malformed request on the ordinary path is answered with 400. Queue the
error message instead, exactly as data_received does.

The upgraded flag from the replay is discarded. When the replayed bytes
contain a second upgrade the parser switches to upgraded while this stays
False, so the next data_received() takes the parser branch and assigns
_message_tail rather than appending to it. The requests already buffered
there are dropped, silently with the pure-Python parser and together with
the following request under the C one. Keep the flag.
@psf-chronographer psf-chronographer Bot added the bot:chronographer:provided There is a change note present in this PR label Aug 9, 2026
@rodrigobnogueira rodrigobnogueira added backport-3.14 Trigger automatic backporting to the 3.14 release branch by Patchback robot backport-3.15 Trigger automatic backporting to the 3.15 release branch by Patchback robot labels Aug 9, 2026
@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 98.99%. Comparing base (288cf46) to head (ea5cbb2).
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@           Coverage Diff           @@
##           master   #13358   +/-   ##
=======================================
  Coverage   98.99%   98.99%           
=======================================
  Files         132      132           
  Lines       49156    49246   +90     
  Branches     2560     2563    +3     
=======================================
+ Hits        48661    48753   +92     
+ Misses        371      370    -1     
+ Partials      124      123    -1     
Flag Coverage Δ
Autobahn 22.07% <4.39%> (-0.04%) ⬇️
CI-GHA 98.91% <100.00%> (+<0.01%) ⬆️
OS-Linux 98.68% <100.00%> (+<0.01%) ⬆️
OS-Windows 97.03% <100.00%> (+<0.01%) ⬆️
OS-macOS 97.93% <100.00%> (+<0.01%) ⬆️
Py-3.10 98.13% <100.00%> (+<0.01%) ⬆️
Py-3.11 98.38% <100.00%> (+<0.01%) ⬆️
Py-3.12 98.47% <100.00%> (+<0.01%) ⬆️
Py-3.13 98.45% <100.00%> (+<0.01%) ⬆️
Py-3.14 98.47% <100.00%> (+<0.01%) ⬆️
Py-3.14t 97.56% <100.00%> (+<0.01%) ⬆️
Py-pypy-3.11 97.39% <100.00%> (-0.02%) ⬇️
VM-macos 97.93% <100.00%> (+<0.01%) ⬆️
VM-ubuntu 98.68% <100.00%> (+<0.01%) ⬆️
VM-windows 97.03% <100.00%> (+<0.01%) ⬆️
cython-coverage 38.07% <39.56%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@codspeed-hq

codspeed-hq Bot commented Aug 9, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 84 untouched benchmarks
⏩ 83 skipped benchmarks1


Comparing rodrigobnogueira:fix-finish-response-tail-handling (ea5cbb2) with master (288cf46)

Open in CodSpeed

Footnotes

  1. 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.

The 400 closes the connection, so a single read to EOF collects both
responses. The loop needed an end-of-stream break that never ran while
the test passed, and tests are measured for coverage.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backport-3.14 Trigger automatic backporting to the 3.14 release branch by Patchback robot backport-3.15 Trigger automatic backporting to the 3.15 release branch by Patchback robot bot:chronographer:provided There is a change note present in this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant