Skip to content

Commit 8bdc7c7

Browse files
authored
Raise ConnectionResetError if transport is None (aio-libs#11761) (aio-libs#12283)
Backport cherry picked from commit b26c9ae
1 parent a8ff7b5 commit 8bdc7c7

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
@@ -141,7 +141,8 @@ async def _sendfile(
141141

142142
loop = request._loop
143143
transport = request.transport
144-
assert transport is not None
144+
if transport is None:
145+
raise ConnectionResetError("Connection lost")
145146

146147
try:
147148
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
@@ -358,7 +358,8 @@ def _pre_start(self, request: BaseRequest) -> tuple[str | None, WebSocketWriter]
358358
self.force_close()
359359
self._compress = compress
360360
transport = request._protocol.transport
361-
assert transport is not None
361+
if transport is None:
362+
raise ConnectionResetError("Connection lost")
362363
writer = WebSocketWriter(
363364
request._protocol,
364365
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
@@ -13,6 +14,7 @@
1314
from aiohttp import web
1415
from aiohttp.compression_utils import ZLibBackend
1516
from aiohttp.pytest_plugin import AiohttpClient
17+
from aiohttp.web_fileresponse import NOSENDFILE
1618

1719
try:
1820
import brotlicffi as brotli
@@ -1151,3 +1153,64 @@ async def handler(request):
11511153

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

@@ -1608,3 +1608,58 @@ async def websocket_handler(
16081608
assert msg.type is aiohttp.WSMsgType.TEXT
16091609
assert msg.data == "success"
16101610
await ws.close()
1611+
1612+
1613+
async def test_prepare_after_client_disconnect(aiohttp_client: AiohttpClient) -> None:
1614+
"""Test ConnectionResetError when client disconnects before ws.prepare().
1615+
1616+
Reproduces the race condition where:
1617+
- Client connects and sends a WebSocket upgrade request
1618+
- Handler starts async work (e.g. authentication) before calling ws.prepare()
1619+
- Client disconnects while the handler is busy
1620+
- Handler then calls ws.prepare() → ConnectionResetError (not AssertionError)
1621+
"""
1622+
handler_started = asyncio.Event()
1623+
captured_protocol = None
1624+
1625+
async def handler(request: web.Request) -> web.Response:
1626+
nonlocal captured_protocol
1627+
ws = web.WebSocketResponse()
1628+
captured_protocol = request._protocol
1629+
handler_started.set()
1630+
# Simulate async work (e.g., auth check) during which client disconnects.
1631+
await asyncio.sleep(0)
1632+
with pytest.raises(ConnectionResetError, match="Connection lost"):
1633+
await ws.prepare(request)
1634+
return web.Response(status=503)
1635+
1636+
app = web.Application()
1637+
app.router.add_route("GET", "/", handler)
1638+
client = await aiohttp_client(app)
1639+
1640+
request_task = asyncio.create_task(
1641+
client.session.get(
1642+
client.make_url("/"),
1643+
headers={
1644+
hdrs.UPGRADE: "websocket",
1645+
hdrs.CONNECTION: "Upgrade",
1646+
hdrs.SEC_WEBSOCKET_KEY: "dGhlIHNhbXBsZSBub25jZQ==",
1647+
hdrs.SEC_WEBSOCKET_VERSION: "13",
1648+
},
1649+
)
1650+
)
1651+
1652+
# Wait until the handler is running but has not yet called ws.prepare().
1653+
await handler_started.wait()
1654+
assert captured_protocol is not None
1655+
1656+
# Simulate the client disconnecting abruptly.
1657+
captured_protocol.force_close()
1658+
1659+
# Yield so the handler can resume and hit the ConnectionResetError.
1660+
await asyncio.sleep(0)
1661+
1662+
with contextlib.suppress(
1663+
aiohttp.ServerDisconnectedError, aiohttp.ClientConnectionResetError
1664+
):
1665+
await request_task

0 commit comments

Comments
 (0)