|
14 | 14 | import aiohttp |
15 | 15 | from aiohttp import WSServerHandshakeError, hdrs, web |
16 | 16 | from aiohttp.http import WSCloseCode, WSMsgType |
| 17 | +from aiohttp.web_protocol import MAX_MSG_QUEUE_SIZE |
17 | 18 |
|
18 | 19 |
|
19 | 20 | async def test_websocket_can_prepare(aiohttp_client: AiohttpClient) -> None: |
@@ -127,6 +128,160 @@ async def second_handler(request: web.Request) -> web.Response: |
127 | 128 | await writer.wait_closed() |
128 | 129 |
|
129 | 130 |
|
| 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 | + |
130 | 285 | async def test_handshake_connection_header_substring_not_a_token( |
131 | 286 | aiohttp_client: AiohttpClient, |
132 | 287 | ) -> None: |
|
0 commit comments