From 7c5296ce1c3338bf6b2e94f669732f2b342192d1 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Tue, 15 Sep 2026 15:37:10 -0700 Subject: [PATCH] fix(buzz): a clean relay close backs off and publishes retrying like any other disconnect A relay that accepted, authenticated and subscribed and then closed cleanly made the read loop return without raising, so _websocket_loop reconnected immediately with no backoff and never flipped health to "retrying". The read loop now raises ConnectionError on StopAsyncIteration so the clean close takes the same backoff + degraded path as an idle or send-side disconnect. --- plugins/platforms/buzz/adapter.py | 6 +++-- tests/gateway/test_buzz_websocket.py | 40 ++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/buzz/adapter.py b/plugins/platforms/buzz/adapter.py index a803add75b..2eb5c21087 100644 --- a/plugins/platforms/buzz/adapter.py +++ b/plugins/platforms/buzz/adapter.py @@ -1138,7 +1138,7 @@ class BuzzAdapter(BasePlatformAdapter): backoff = min(backoff * 2, 30.0) async def _ws_read_loop(self, websocket, subscriptions: Dict[str, Optional[str]]) -> None: - """Read frames until the relay closes; an idle read raises ConnectionError to reconnect.""" + """Read frames until the relay closes; a close or an idle read raises ConnectionError to reconnect.""" frame_iter = websocket.__aiter__() while True: read_task = asyncio.ensure_future(frame_iter.__anext__()) @@ -1155,7 +1155,9 @@ class BuzzAdapter(BasePlatformAdapter): ) raw = read_task.result() except StopAsyncIteration: - return + # A clean relay close is still a disconnect: raising sends it through the same + # backoff + "retrying" path instead of reconnecting in a hot loop. + raise ConnectionError("relay closed the WebSocket") from None finally: if not read_task.done(): read_task.cancel() diff --git a/tests/gateway/test_buzz_websocket.py b/tests/gateway/test_buzz_websocket.py index 54b9b12409..56a15e6d58 100644 --- a/tests/gateway/test_buzz_websocket.py +++ b/tests/gateway/test_buzz_websocket.py @@ -254,6 +254,46 @@ async def test_websocket_loop_reconnects_when_discovery_send_sees_closed_socket( assert sockets[0].exited, "the dead connection was not closed before reconnecting" +@pytest.mark.asyncio +async def test_websocket_loop_backs_off_and_publishes_retrying_on_clean_relay_close(monkeypatch): + """A relay that accepts, then cleanly closes after subscribe, is a disconnect like any other. + + StopAsyncIteration used to return from the read loop without raising, so the + loop reconnected in a hot loop with no backoff and health stayed "connected". + """ + adapter = _make_adapter() + states = [] + monkeypatch.setattr(adapter, "_write_runtime_status_safe", lambda status, **kw: states.append(kw["platform_state"])) + + async def clean_close(): + raise StopAsyncIteration + + sockets = [] + + def fake_connect(*args, **kwargs): + ws = _ScriptedWebSocket(clean_close) + sockets.append(ws) + return ws + + import websockets as _ws_mod + + monkeypatch.setattr(_ws_mod, "connect", fake_connect) + + task = asyncio.create_task(adapter._websocket_loop()) + try: + await asyncio.sleep(0.3) + finally: + task.cancel() + try: + await asyncio.wait_for(task, 5.0) + except (asyncio.CancelledError, asyncio.TimeoutError): + pass + + assert len(sockets) == 1, f"clean close must back off before reconnecting, got {len(sockets)} connects in 0.3s" + assert sockets[0].exited, "the closed connection was not exited before backing off" + assert states == ["retrying"], f"a clean relay close must publish retrying, got {states}" + + @pytest.mark.asyncio async def test_websocket_loop_dispatches_frames_and_closes_cleanly(monkeypatch): """The watchdog refactor preserves the healthy path: frames dispatch to