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.
This commit is contained in:
teknium1
2026-09-15 15:37:10 -07:00
committed by Teknium
parent f529986abf
commit 7c5296ce1c
2 changed files with 44 additions and 2 deletions
+4 -2
View File
@@ -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()
+40
View File
@@ -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