Skip to content

Commit b26c9ae

Browse files
authored
Raise ConnectionResetError if transport is None (#11761)
1 parent 9ee2c3a commit b26c9ae

5 files changed

Lines changed: 127 additions & 3 deletions

File tree

CHANGES/11761.bugfix.rst

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Fixed ``AssertionError`` when the transport is ``None`` during WebSocket
2+
preparation or file response sending (e.g. when a client disconnects
3+
immediately after connecting). A ``ConnectionResetError`` is now raised
4+
instead -- by :user:`agners`.

aiohttp/web_fileresponse.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,8 @@ async def _sendfile(
128128

129129
loop = request._loop
130130
transport = request.transport
131-
assert transport is not None
131+
if transport is None:
132+
raise ConnectionResetError("Connection lost")
132133

133134
try:
134135
await loop.sendfile(transport, fobj, offset, count)

aiohttp/web_ws.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -361,7 +361,8 @@ def _pre_start(self, request: BaseRequest) -> tuple[str | None, WebSocketWriter]
361361
self.force_close()
362362
self._compress = compress
363363
transport = request._protocol.transport
364-
assert transport is not None
364+
if transport is None:
365+
raise ConnectionResetError("Connection lost")
365366
writer = WebSocketWriter(
366367
request._protocol,
367368
transport,

tests/test_web_sendfile_functional.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import bz2
3+
import contextlib
34
import gzip
45
import pathlib
56
import socket
@@ -15,6 +16,7 @@
1516
from aiohttp.compression_utils import ZLibBackend
1617
from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer
1718
from aiohttp.typedefs import PathLike
19+
from aiohttp.web_fileresponse import NOSENDFILE
1820

1921
try:
2022
import brotlicffi as brotli
@@ -1156,3 +1158,64 @@ async def handler(request: web.Request) -> web.FileResponse:
11561158

11571159
resp.release()
11581160
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

tests/test_web_websocket_functional.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import pytest
1212

1313
import aiohttp
14-
from aiohttp import WSServerHandshakeError, web
14+
from aiohttp import WSServerHandshakeError, hdrs, web
1515
from aiohttp.http import WSCloseCode, WSMsgType
1616
from aiohttp.pytest_plugin import AiohttpClient, AiohttpServer
1717

@@ -1661,3 +1661,58 @@ async def websocket_handler(
16611661
assert msg.type is aiohttp.WSMsgType.TEXT
16621662
assert msg.data == "success"
16631663
await ws.close()
1664+
1665+
1666+
async def test_prepare_after_client_disconnect(aiohttp_client: AiohttpClient) -> None:
1667+
"""Test ConnectionResetError when client disconnects before ws.prepare().
1668+
1669+
Reproduces the race condition where:
1670+
- Client connects and sends a WebSocket upgrade request
1671+
- Handler starts async work (e.g. authentication) before calling ws.prepare()
1672+
- Client disconnects while the handler is busy
1673+
- Handler then calls ws.prepare() → ConnectionResetError (not AssertionError)
1674+
"""
1675+
handler_started = asyncio.Event()
1676+
captured_protocol = None
1677+
1678+
async def handler(request: web.Request) -> web.Response:
1679+
nonlocal captured_protocol
1680+
ws = web.WebSocketResponse()
1681+
captured_protocol = request._protocol
1682+
handler_started.set()
1683+
# Simulate async work (e.g., auth check) during which client disconnects.
1684+
await asyncio.sleep(0)
1685+
with pytest.raises(ConnectionResetError, match="Connection lost"):
1686+
await ws.prepare(request)
1687+
return web.Response(status=503)
1688+
1689+
app = web.Application()
1690+
app.router.add_route("GET", "/", handler)
1691+
client = await aiohttp_client(app)
1692+
1693+
request_task = asyncio.create_task(
1694+
client.session.get(
1695+
client.make_url("/"),
1696+
headers={
1697+
hdrs.UPGRADE: "websocket",
1698+
hdrs.CONNECTION: "Upgrade",
1699+
hdrs.SEC_WEBSOCKET_KEY: "dGhlIHNhbXBsZSBub25jZQ==",
1700+
hdrs.SEC_WEBSOCKET_VERSION: "13",
1701+
},
1702+
)
1703+
)
1704+
1705+
# Wait until the handler is running but has not yet called ws.prepare().
1706+
await handler_started.wait()
1707+
assert captured_protocol is not None
1708+
1709+
# Simulate the client disconnecting abruptly.
1710+
captured_protocol.force_close()
1711+
1712+
# Yield so the handler can resume and hit the ConnectionResetError.
1713+
await asyncio.sleep(0)
1714+
1715+
with contextlib.suppress(
1716+
aiohttp.ServerDisconnectedError, aiohttp.ClientConnectionResetError
1717+
):
1718+
await request_task

0 commit comments

Comments
 (0)