Skip to content

Commit 0f1a008

Browse files
[PR #8685/e7c02ca4 backport][3.10] Fix exceptions from WebSocket ping task not being consumed (#8729)
Co-authored-by: J. Nick Koston <nick@koston.org> closes #7238 fixes #5182 fixes #4153 fixes #2309
1 parent 635ae62 commit 0f1a008

5 files changed

Lines changed: 136 additions & 13 deletions

File tree

CHANGES/8685.bugfix.rst

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fixed unconsumed exceptions raised by the WebSocket heartbeat -- by :user:`bdraco`.
2+
3+
If the heartbeat ping raised an exception, it would not be consumed and would be logged as an warning.

aiohttp/client_ws.py

Lines changed: 16 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -141,21 +141,28 @@ def _send_heartbeat(self) -> None:
141141
if not ping_task.done():
142142
self._ping_task = ping_task
143143
ping_task.add_done_callback(self._ping_task_done)
144+
else:
145+
self._ping_task_done(ping_task)
144146

145147
def _ping_task_done(self, task: "asyncio.Task[None]") -> None:
146148
"""Callback for when the ping task completes."""
149+
if not task.cancelled() and (exc := task.exception()):
150+
self._handle_ping_pong_exception(exc)
147151
self._ping_task = None
148152

149153
def _pong_not_received(self) -> None:
150-
if not self._closed:
151-
self._set_closed()
152-
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
153-
self._exception = ServerTimeoutError()
154-
self._response.close()
155-
if self._waiting and not self._closing:
156-
self._reader.feed_data(
157-
WSMessage(WSMsgType.ERROR, self._exception, None)
158-
)
154+
self._handle_ping_pong_exception(ServerTimeoutError())
155+
156+
def _handle_ping_pong_exception(self, exc: BaseException) -> None:
157+
"""Handle exceptions raised during ping/pong processing."""
158+
if self._closed:
159+
return
160+
self._set_closed()
161+
self._close_code = WSCloseCode.ABNORMAL_CLOSURE
162+
self._exception = exc
163+
self._response.close()
164+
if self._waiting and not self._closing:
165+
self._reader.feed_data(WSMessage(WSMsgType.ERROR, exc, None))
159166

160167
def _set_closed(self) -> None:
161168
"""Set the connection to closed.

aiohttp/web_ws.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -164,16 +164,28 @@ def _send_heartbeat(self) -> None:
164164
if not ping_task.done():
165165
self._ping_task = ping_task
166166
ping_task.add_done_callback(self._ping_task_done)
167+
else:
168+
self._ping_task_done(ping_task)
167169

168170
def _ping_task_done(self, task: "asyncio.Task[None]") -> None:
169171
"""Callback for when the ping task completes."""
172+
if not task.cancelled() and (exc := task.exception()):
173+
self._handle_ping_pong_exception(exc)
170174
self._ping_task = None
171175

172176
def _pong_not_received(self) -> None:
173177
if self._req is not None and self._req.transport is not None:
174-
self._set_closed()
175-
self._set_code_close_transport(WSCloseCode.ABNORMAL_CLOSURE)
176-
self._exception = asyncio.TimeoutError()
178+
self._handle_ping_pong_exception(asyncio.TimeoutError())
179+
180+
def _handle_ping_pong_exception(self, exc: BaseException) -> None:
181+
"""Handle exceptions raised during ping/pong processing."""
182+
if self._closed:
183+
return
184+
self._set_closed()
185+
self._set_code_close_transport(WSCloseCode.ABNORMAL_CLOSURE)
186+
self._exception = exc
187+
if self._waiting and not self._closing and self._reader is not None:
188+
self._reader.feed_data(WSMessage(WSMsgType.ERROR, exc, None))
177189

178190
def _set_closed(self) -> None:
179191
"""Set the connection to closed.

tests/test_client_ws_functional.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,36 @@ async def handler(request):
600600
assert ping_received
601601

602602

603+
async def test_heartbeat_connection_closed(aiohttp_client: AiohttpClient) -> None:
604+
"""Test that the connection is closed while ping is in progress."""
605+
606+
async def handler(request: web.Request) -> NoReturn:
607+
ws = web.WebSocketResponse(autoping=False)
608+
await ws.prepare(request)
609+
await ws.receive()
610+
assert False
611+
612+
app = web.Application()
613+
app.router.add_route("GET", "/", handler)
614+
615+
client = await aiohttp_client(app)
616+
resp = await client.ws_connect("/", heartbeat=0.1)
617+
ping_count = 0
618+
# We patch write here to simulate a connection reset error
619+
# since if we closed the connection normally, the client would
620+
# would cancel the heartbeat task and we wouldn't get a ping
621+
assert resp._conn is not None
622+
with mock.patch.object(
623+
resp._conn.transport, "write", side_effect=ConnectionResetError
624+
), mock.patch.object(resp._writer, "ping", wraps=resp._writer.ping) as ping:
625+
await resp.receive()
626+
ping_count = ping.call_count
627+
# Connection should be closed roughly after 1.5x heartbeat.
628+
await asyncio.sleep(0.2)
629+
assert ping_count == 1
630+
assert resp.close_code is WSCloseCode.ABNORMAL_CLOSURE
631+
632+
603633
async def test_heartbeat_no_pong(aiohttp_client: AiohttpClient) -> None:
604634
"""Test that the connection is closed if no pong is received without sending messages."""
605635
ping_received = False

tests/test_web_websocket_functional.py

Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
import contextlib
55
import sys
66
import weakref
7-
from typing import Any, Optional
7+
from typing import Any, NoReturn, Optional
8+
from unittest import mock
89

910
import pytest
1011

@@ -724,6 +725,76 @@ async def handler(request):
724725
await ws.close()
725726

726727

728+
async def test_heartbeat_connection_closed(
729+
loop: asyncio.AbstractEventLoop, aiohttp_client: AiohttpClient
730+
) -> None:
731+
"""Test that the connection is closed while ping is in progress."""
732+
ping_count = 0
733+
734+
async def handler(request: web.Request) -> NoReturn:
735+
nonlocal ping_count
736+
ws_server = web.WebSocketResponse(heartbeat=0.05)
737+
await ws_server.prepare(request)
738+
# We patch write here to simulate a connection reset error
739+
# since if we closed the connection normally, the server would
740+
# would cancel the heartbeat task and we wouldn't get a ping
741+
with mock.patch.object(
742+
ws_server._req.transport, "write", side_effect=ConnectionResetError
743+
), mock.patch.object(
744+
ws_server._writer, "ping", wraps=ws_server._writer.ping
745+
) as ping:
746+
try:
747+
await ws_server.receive()
748+
finally:
749+
ping_count = ping.call_count
750+
assert False
751+
752+
app = web.Application()
753+
app.router.add_get("/", handler)
754+
755+
client = await aiohttp_client(app)
756+
ws = await client.ws_connect("/", autoping=False)
757+
msg = await ws.receive()
758+
assert msg.type is aiohttp.WSMsgType.CLOSED
759+
assert msg.extra is None
760+
assert ws.close_code == WSCloseCode.ABNORMAL_CLOSURE
761+
assert ping_count == 1
762+
await ws.close()
763+
764+
765+
async def test_heartbeat_failure_ends_receive(
766+
loop: asyncio.AbstractEventLoop, aiohttp_client: AiohttpClient
767+
) -> None:
768+
"""Test that no heartbeat response to the server ends the receive call."""
769+
ws_server_close_code = None
770+
ws_server_exception = None
771+
772+
async def handler(request: web.Request) -> NoReturn:
773+
nonlocal ws_server_close_code, ws_server_exception
774+
ws_server = web.WebSocketResponse(heartbeat=0.05)
775+
await ws_server.prepare(request)
776+
try:
777+
await ws_server.receive()
778+
finally:
779+
ws_server_close_code = ws_server.close_code
780+
ws_server_exception = ws_server.exception()
781+
assert False
782+
783+
app = web.Application()
784+
app.router.add_get("/", handler)
785+
786+
client = await aiohttp_client(app)
787+
ws = await client.ws_connect("/", autoping=False)
788+
msg = await ws.receive()
789+
assert msg.type is aiohttp.WSMsgType.PING
790+
msg = await ws.receive()
791+
assert msg.type is aiohttp.WSMsgType.CLOSED
792+
assert ws.close_code == WSCloseCode.ABNORMAL_CLOSURE
793+
assert ws_server_close_code == WSCloseCode.ABNORMAL_CLOSURE
794+
assert isinstance(ws_server_exception, asyncio.TimeoutError)
795+
await ws.close()
796+
797+
727798
async def test_heartbeat_no_pong_send_many_messages(
728799
loop: Any, aiohttp_client: Any
729800
) -> None:

0 commit comments

Comments
 (0)