Skip to content

Commit 0660260

Browse files
Fix pipelining a rejected upgrade (aio-libs#13468)
1 parent e54b79c commit 0660260

2 files changed

Lines changed: 170 additions & 2 deletions

File tree

aiohttp/web_protocol.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -807,11 +807,24 @@ async def finish_response(
807807
prematurely.
808808
"""
809809
request._finish()
810-
if self._parser is not None:
810+
811+
# Handle feeding the message tail following an upgrade request that
812+
# was declined.
813+
# The upgrade request is the last request before the parser paused,
814+
# so wait for self._messages to be empty.
815+
# payload_parser is not None if the upgrade was accepted.
816+
if (
817+
self._upgraded
818+
and not self._messages
819+
and self._payload_parser is None
820+
and self._parser is not None
821+
):
811822
self._parser.set_upgraded(False)
812823
self._upgraded = False
813824
if self._message_tail:
814-
messages, _upgraded, tail = self._parser.feed_data(self._message_tail)
825+
messages, upgraded, tail = self._parser.feed_data(self._message_tail)
826+
# A further upgrade request in the tail buffers its own remainder.
827+
self._upgraded = upgraded
815828
self._message_tail = tail
816829
for msg, payload in messages:
817830
self._request_count += 1

tests/test_web_websocket_functional.py

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import aiohttp
1515
from aiohttp import WSServerHandshakeError, hdrs, web
1616
from aiohttp.http import WSCloseCode, WSMsgType
17+
from aiohttp.web_protocol import MAX_MSG_QUEUE_SIZE
1718

1819

1920
async def test_websocket_can_prepare(aiohttp_client: AiohttpClient) -> None:
@@ -127,6 +128,160 @@ async def second_handler(request: web.Request) -> web.Response:
127128
await writer.wait_closed()
128129

129130

131+
def _raw_get(path: str) -> bytes:
132+
return f"GET {path} HTTP/1.1\r\nHost: localhost\r\n\r\n".encode("ascii")
133+
134+
135+
_RAW_UPGRADE = (
136+
b"GET /ws HTTP/1.1\r\n"
137+
b"Host: localhost\r\n"
138+
b"Upgrade: websocket\r\n"
139+
b"Connection: Upgrade\r\n"
140+
b"Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n"
141+
b"Sec-WebSocket-Version: 13\r\n"
142+
b"\r\n"
143+
)
144+
145+
146+
def _masked_text_frame(payload: bytes) -> bytes:
147+
"""Build a client text frame; frames sent to a server must be masked."""
148+
assert len(payload) < 126
149+
mask = b"\x37\xfa\x21\x3d"
150+
return (
151+
b"\x81"
152+
+ bytes((0x80 | len(payload),))
153+
+ mask
154+
+ bytes(b ^ mask[i % 4] for i, b in enumerate(payload))
155+
)
156+
157+
158+
async def test_websocket_frames_pipelined_behind_request_burst(
159+
aiohttp_server: AiohttpServer,
160+
) -> None:
161+
"""A websocket upgraded from within a request burst still reads frames.
162+
163+
The parser stops at the upgrade request and buffers everything after it,
164+
which is websocket data once the handshake succeeds. Answering the requests
165+
queued ahead of the upgrade must neither consume that buffer as HTTP nor
166+
leave the transport paused by the pipeline queue after switching protocols.
167+
"""
168+
# More than the queue holds, so reading pauses and the parser buffers.
169+
pipelined_requests = MAX_MSG_QUEUE_SIZE + 8
170+
handled: list[str] = []
171+
172+
async def handler(request: web.Request) -> web.Response:
173+
handled.append(request.path)
174+
return web.Response()
175+
176+
async def ws_handler(request: web.Request) -> web.WebSocketResponse:
177+
ws = web.WebSocketResponse()
178+
await ws.prepare(request)
179+
await ws.send_str(await ws.receive_str())
180+
return ws
181+
182+
app = web.Application()
183+
app.router.add_get("/ws", ws_handler)
184+
app.router.add_get("/{tail:.*}", handler)
185+
server = await aiohttp_server(app)
186+
187+
reader, writer = await asyncio.open_connection(server.host, server.port)
188+
try:
189+
writer.write(
190+
b"".join(_raw_get(f"/r{i}") for i in range(pipelined_requests))
191+
+ _RAW_UPGRADE
192+
+ _masked_text_frame(b"frame-ok")
193+
)
194+
await writer.drain()
195+
196+
# Without the fix the frame is eaten by the http parser and the paused
197+
# transport is never resumed, so nothing is echoed back.
198+
await asyncio.wait_for(reader.readuntil(b"\x81\x08frame-ok"), timeout=10)
199+
finally:
200+
writer.close()
201+
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
202+
await writer.wait_closed()
203+
204+
assert handled == [f"/r{i}" for i in range(pipelined_requests)]
205+
206+
207+
async def test_pipelined_request_after_declined_upgrade_behind_burst(
208+
aiohttp_server: AiohttpServer,
209+
) -> None:
210+
"""A declined upgrade replays its tail even when queued behind a request.
211+
212+
Only the upgrade request's own response settles whether the buffered bytes
213+
are websocket data or pipelined HTTP, so an earlier request completing must
214+
leave them alone and the declining response still has to replay them.
215+
"""
216+
handled: list[str] = []
217+
218+
async def handler(request: web.Request) -> web.Response:
219+
handled.append(request.path)
220+
return web.Response(text=f"{request.path[1:]}-ok")
221+
222+
async def ws_handler(request: web.Request) -> NoReturn:
223+
raise web.HTTPUpgradeRequired()
224+
225+
app = web.Application()
226+
app.router.add_get("/ws", ws_handler)
227+
app.router.add_get("/{tail:.*}", handler)
228+
server = await aiohttp_server(app)
229+
230+
reader, writer = await asyncio.open_connection(server.host, server.port)
231+
try:
232+
writer.write(_raw_get("/first") + _RAW_UPGRADE + _raw_get("/second"))
233+
await writer.drain()
234+
235+
# Without the replay the trailing request stalls until keep-alive expires.
236+
data = await asyncio.wait_for(reader.readuntil(b"second-ok"), timeout=10)
237+
finally:
238+
writer.close()
239+
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
240+
await writer.wait_closed()
241+
242+
assert handled == ["/first", "/second"]
243+
assert data.count(b"HTTP/1.1 200 OK") == 2, data
244+
assert b"426" in data, data
245+
246+
247+
async def test_pipelined_request_after_two_declined_upgrades(
248+
aiohttp_server: AiohttpServer,
249+
) -> None:
250+
"""A second upgrade inside a replayed tail buffers its own remainder again.
251+
252+
Replaying a declined upgrade's tail can turn up another upgrade request,
253+
which puts the parser back into upgraded mode. Losing that leaves the bytes
254+
behind the second upgrade buffered with nothing left to replay them.
255+
"""
256+
handled: list[str] = []
257+
258+
async def handler(request: web.Request) -> web.Response:
259+
handled.append(request.path)
260+
return web.Response(text="second-ok")
261+
262+
async def ws_handler(request: web.Request) -> NoReturn:
263+
raise web.HTTPUpgradeRequired()
264+
265+
app = web.Application()
266+
app.router.add_get("/ws", ws_handler)
267+
app.router.add_get("/{tail:.*}", handler)
268+
server = await aiohttp_server(app)
269+
270+
reader, writer = await asyncio.open_connection(server.host, server.port)
271+
try:
272+
writer.write(_RAW_UPGRADE + _RAW_UPGRADE + _raw_get("/second"))
273+
await writer.drain()
274+
275+
data = await asyncio.wait_for(reader.readuntil(b"second-ok"), timeout=10)
276+
finally:
277+
writer.close()
278+
with contextlib.suppress(ConnectionResetError, BrokenPipeError):
279+
await writer.wait_closed()
280+
281+
assert handled == ["/second"]
282+
assert data.count(b"HTTP/1.1 426 ") == 2, data
283+
284+
130285
async def test_handshake_connection_header_substring_not_a_token(
131286
aiohttp_client: AiohttpClient,
132287
) -> None:

0 commit comments

Comments
 (0)