diff --git a/gateway/platforms/qqbot/adapter.py b/gateway/platforms/qqbot/adapter.py index 5b4a396ed2f..9532662131d 100644 --- a/gateway/platforms/qqbot/adapter.py +++ b/gateway/platforms/qqbot/adapter.py @@ -681,6 +681,12 @@ class QQAdapter(BasePlatformAdapter): """Read WebSocket frames until connection closes.""" if not self._ws: raise RuntimeError("WebSocket not connected") + if self._ws.closed: + # A closed-but-non-None ws makes the while-condition false on entry, + # so this would return normally — which _listen_loop treats as a + # clean read and immediately retries with backoff reset to 0, + # producing a 100% CPU spin. Raise so the reconnect/backoff path runs. + raise RuntimeError("WebSocket closed") while self._running and self._ws and not self._ws.closed: msg = await self._ws.receive() diff --git a/tests/gateway/test_qqbot.py b/tests/gateway/test_qqbot.py index e1f41aeccdc..816bb5f1601 100644 --- a/tests/gateway/test_qqbot.py +++ b/tests/gateway/test_qqbot.py @@ -2196,3 +2196,27 @@ class TestCloseCodeClassification: assert 4014 in fatal_codes assert 4001 in fatal_codes assert 4915 in fatal_codes + + +class TestReadEventsClosedWsGuard: + """Regression: a closed-but-non-None ws must raise on entry, not return + normally, so _listen_loop goes through reconnect/backoff instead of + busy-looping at 100% CPU (issues #31193 / #31771).""" + + def _make_adapter(self, **extra): + from gateway.platforms.qqbot import QQAdapter + return QQAdapter(_make_config(app_id="a", client_secret="b", **extra)) + + def test_read_events_raises_when_ws_closed_on_entry(self): + adapter = self._make_adapter() + adapter._running = True + adapter._ws = SimpleNamespace(closed=True) + with pytest.raises(RuntimeError): + asyncio.run(adapter._read_events()) + + def test_read_events_raises_when_ws_none(self): + adapter = self._make_adapter() + adapter._running = True + adapter._ws = None + with pytest.raises(RuntimeError): + asyncio.run(adapter._read_events())