From 24178a395cf97a7d6aa350396bba067c55296676 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 2 Nov 2026 08:05:36 +0700 Subject: [PATCH 1/2] fix(websocket): don't rearm heartbeat after close Closes aio-libs/aiohttp#14125 --- CHANGES/14125.bugfix.rst | 5 +++++ aiohttp/web_ws.py | 9 ++++++++- tests/test_web_websocket.py | 16 ++++++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 CHANGES/14125.bugfix.rst diff --git a/CHANGES/14125.bugfix.rst b/CHANGES/14125.bugfix.rst new file mode 100644 index 00000000000..3110bfcb2c5 --- /dev/null +++ b/CHANGES/14125.bugfix.rst @@ -0,0 +1,5 @@ +Stopped the server WebSocket heartbeat from being rearmed after close: +peer-close data arriving after the close handshake scheduled a deferred +heartbeat reset that re-created the heartbeat timer on the closed connection. +The deferred reset is now dropped when the connection is closing or closed -- +by :user:`rinopatrick`. diff --git a/aiohttp/web_ws.py b/aiohttp/web_ws.py index 1ed0d54b172..56bde270ede 100644 --- a/aiohttp/web_ws.py +++ b/aiohttp/web_ws.py @@ -70,7 +70,6 @@ def __bool__(self) -> bool: class WebSocketResponse(StreamResponse, Generic[_DecodeText]): - _length_check: bool = False _ws_protocol: str | None = None _writer: WebSocketWriter | None = None @@ -153,12 +152,20 @@ def _flush_heartbeat_reset(self) -> None: self._heartbeat_reset_handle = None if not self._need_heartbeat_reset: return + if self._closed or self._closing: + # The connection closed while the reset was deferred (e.g. the + # peer close frame arrived after the close handshake). Do not + # re-arm the heartbeat on a dead connection (see #14125). + self._need_heartbeat_reset = False + return self._reset_heartbeat() self._need_heartbeat_reset = False def _reset_heartbeat(self) -> None: if self._heartbeat is None: return + if self._closed or self._closing: + return self._cancel_pong_response_cb() req = self._req timeout_ceil_threshold = ( diff --git a/tests/test_web_websocket.py b/tests/test_web_websocket.py index ecc4e025b47..d8ac62ac477 100644 --- a/tests/test_web_websocket.py +++ b/tests/test_web_websocket.py @@ -143,6 +143,22 @@ async def test_flush_heartbeat_reset_returns_early_when_not_needed() -> None: reset.assert_not_called() +async def test_flush_heartbeat_reset_does_not_rearm_after_close() -> None: + # Peer-close data arriving after the close handshake must not re-arm + # the heartbeat on the closed connection (see #14125). + ws = web.WebSocketResponse(heartbeat=10) + ws._loop = asyncio.get_running_loop() + ws._set_closed() + + # data (e.g. the peer close frame) arrives after close + ws._on_data_received() + await asyncio.sleep(0) + await asyncio.sleep(0) + + assert ws._heartbeat_cb is None + assert ws._need_heartbeat_reset is False + + async def test_send_heartbeat_returns_early_when_reset_is_pending() -> None: ws = web.WebSocketResponse(heartbeat=0.05) ws._need_heartbeat_reset = True From 51cd05e586ca2069d1f986a4ef8aecc3def312f8 Mon Sep 17 00:00:00 2001 From: Patrick Date: Mon, 2 Nov 2026 08:55:39 +0700 Subject: [PATCH 2/2] fix(changelog): add PR-numbered symlink --- CHANGES/14236.bugfix.rst | 1 + 1 file changed, 1 insertion(+) create mode 120000 CHANGES/14236.bugfix.rst diff --git a/CHANGES/14236.bugfix.rst b/CHANGES/14236.bugfix.rst new file mode 120000 index 00000000000..1932a2135d6 --- /dev/null +++ b/CHANGES/14236.bugfix.rst @@ -0,0 +1 @@ +14125.bugfix.rst \ No newline at end of file