|
1 | 1 | import asyncio |
2 | 2 | import bz2 |
| 3 | +import contextlib |
3 | 4 | import gzip |
4 | 5 | import pathlib |
5 | 6 | import socket |
|
15 | 16 | from aiohttp.compression_utils import ZLibBackend |
16 | 17 | from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer |
17 | 18 | from aiohttp.typedefs import PathLike |
| 19 | +from aiohttp.web_fileresponse import NOSENDFILE |
18 | 20 |
|
19 | 21 | try: |
20 | 22 | import brotlicffi as brotli |
@@ -1156,3 +1158,64 @@ async def handler(request: web.Request) -> web.FileResponse: |
1156 | 1158 |
|
1157 | 1159 | resp.release() |
1158 | 1160 | await client.close() |
| 1161 | + |
| 1162 | + |
| 1163 | +@pytest.mark.skipif(NOSENDFILE, reason="OS sendfile not available") |
| 1164 | +async def test_sendfile_after_client_disconnect( |
| 1165 | + aiohttp_client: AiohttpClient, tmp_path: pathlib.Path |
| 1166 | +) -> None: |
| 1167 | + """Test ConnectionResetError when client disconnects before sendfile. |
| 1168 | +
|
| 1169 | + Reproduces the race condition where: |
| 1170 | + - Client sends a GET request for a file |
| 1171 | + - Handler does async work (e.g. auth check) before returning a FileResponse |
| 1172 | + - Client disconnects while the handler is busy |
| 1173 | + - Server then calls sendfile() → ConnectionResetError (not AssertionError) |
| 1174 | +
|
| 1175 | + _send_headers_immediately is set to False so that super().prepare() |
| 1176 | + only buffers the headers without writing to the transport. Otherwise |
| 1177 | + _write() raises ClientConnectionResetError first and _sendfile()'s own |
| 1178 | + transport check is never reached. |
| 1179 | + """ |
| 1180 | + filepath = tmp_path / "test.txt" |
| 1181 | + filepath.write_bytes(b"x" * 1024) |
| 1182 | + |
| 1183 | + handler_started = asyncio.Event() |
| 1184 | + prepare_done = asyncio.Event() |
| 1185 | + captured_protocol = None |
| 1186 | + |
| 1187 | + async def handler(request: web.Request) -> web.Response: |
| 1188 | + nonlocal captured_protocol |
| 1189 | + resp = web.FileResponse(filepath) |
| 1190 | + resp._send_headers_immediately = False |
| 1191 | + captured_protocol = request._protocol |
| 1192 | + handler_started.set() |
| 1193 | + # Simulate async work (e.g., auth check) during which client disconnects. |
| 1194 | + await asyncio.sleep(0) |
| 1195 | + with pytest.raises(ConnectionResetError, match="Connection lost"): |
| 1196 | + await resp.prepare(request) |
| 1197 | + prepare_done.set() |
| 1198 | + return web.Response(status=503) |
| 1199 | + |
| 1200 | + app = web.Application() |
| 1201 | + app.router.add_get("/", handler) |
| 1202 | + client = await aiohttp_client(app) |
| 1203 | + |
| 1204 | + request_task = asyncio.create_task(client.get("/")) |
| 1205 | + |
| 1206 | + # Wait until the handler is running but has not yet returned the response. |
| 1207 | + await handler_started.wait() |
| 1208 | + assert captured_protocol is not None |
| 1209 | + |
| 1210 | + # Simulate the client disconnecting by setting transport to None directly. |
| 1211 | + # We cannot use force_close() because closing the TCP transport triggers |
| 1212 | + # connection_lost() which cancels the handler task before it can call |
| 1213 | + # prepare() and hit the ConnectionResetError in _sendfile(). |
| 1214 | + captured_protocol.transport = None |
| 1215 | + |
| 1216 | + # Wait for the handler to resume, call prepare(), and hit ConnectionResetError. |
| 1217 | + await asyncio.wait_for(prepare_done.wait(), timeout=1) |
| 1218 | + |
| 1219 | + request_task.cancel() |
| 1220 | + with contextlib.suppress(asyncio.CancelledError): |
| 1221 | + await request_task |
0 commit comments