{"repo":"NousResearch/hermes-agent","public_id":"17703","display_label":"QQBot closed WebSocket causes busy-loop, stalled reconnects","summary":"Members consistently concern the QQBot adapter's `_read_events`/`_listen_loop` misbehaving when the WebSocket is closed but not None after a network outage or an unsuccessful reconnect, reported as a 100% CPU busy-loop or reconnection stopping. Many are proposed fixes, such as raising an error or adding guards, and some issues mention a similar WeCom pattern.","subsystem":"gateway","counts":{"members":18,"open_issues":1,"open_issues_distinct":1,"open_prs":1,"closed_issues":6,"merged_prs":1,"attempt_prs":10},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":40574,"merged_at":"2026-06-07T01:44:45+00:00"},"resolution":{"state":"partially_resolved","by":"pr-40574"},"freshness":{"state":"verified","last_verified_at":"2026-06-07T01:44:45+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.13568627450980392},"fix_edges":[{"issue_number":17703,"pr_number":40574,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-07T01:44:45+00:00"},{"issue_number":27810,"pr_number":40574,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-07T01:44:45+00:00"},{"issue_number":31193,"pr_number":40574,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true,"pr_merged_at":"2026-06-07T01:44:45+00:00"},{"issue_number":31771,"pr_number":40574,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false,"pr_merged_at":"2026-06-07T01:44:45+00:00"}],"members":[{"kind":"issue","number":17703,"title":"QQBot stops reconnecting after failed reconnect leaves websocket closed","url":null,"state":"closed"},{"kind":"issue","number":27810,"title":"[Bug]: QQBot closed WebSocket 导致无限空循环，单线程 CPU 100%","url":null,"state":"closed"},{"kind":"issue","number":31101,"title":"QQ Bot adapter: _read_events() silent loop after reconnect failure prevents further retries","url":null,"state":"closed"},{"kind":"issue","number":31193,"title":"QQ Bot Reconnect Busy Loop Causes 100% CPU Spin","url":null,"state":"closed"},{"kind":"issue","number":31771,"title":"[Bug]: QQBot adapter busy-loops after WebSocket reconnect failure, causing 100% CPU","url":null,"state":"closed"},{"kind":"issue","number":42271,"title":"fix(qqbot): listen_loop enters permanent dead state after network outage — _read_events() bypasses reconnect logic","url":null,"state":"closed"},{"kind":"issue","number":55492,"title":"QQBot _read_events has same silent-return pattern as WeCom busy-loop (mitigated but inconsistent)","url":null,"state":"open"},{"kind":"pr","number":17704,"title":"fix(qqbot): keep reconnecting after closed websocket","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3eeca4613d618618093db416b564a2b9ef8dbe6a","number":0},{"kind":"pr","value":"#17704","number":17704},{"kind":"pr","value":"#31193","number":31193},{"kind":"pr","value":"#31771","number":31771},{"kind":"pr","value":"#40574","number":40574}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":27821,"title":"fix(qqbot): prevent busy-loop when WebSocket closes without exception","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3eeca4613d618618093db416b564a2b9ef8dbe6a","number":0},{"kind":"pr","value":"#31193","number":31193},{"kind":"pr","value":"#31771","number":31771},{"kind":"pr","value":"#40574","number":40574}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false},{"kind":"pr","number":31151,"title":"fix(qqbot): raise RuntimeError in _read_events when WebSocket is closed","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#17704","number":17704},{"kind":"pr","value":"#17703","number":17703},{"kind":"pr","value":"#25070","number":25070},{"kind":"pr","value":"#20994","number":20994}],"category":"duplicate_of","implication":"duplicate"},"merged":false},{"kind":"pr","number":31774,"title":"fix(qqbot): prevent listener busy-loop after closed-WebSocket reconnect failure (#31771)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3eeca4613d618618093db416b564a2b9ef8dbe6a","number":0},{"kind":"pr","value":"#31771","number":31771},{"kind":"pr","value":"#40574","number":40574}],"category":"merged_via","implication":"superseded"},"merged":false},{"kind":"pr","number":32885,"title":"fix(qqbot): prevent reconnect stall after closed websocket","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3eeca4613d618618093db416b564a2b9ef8dbe6a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":39430,"title":"fix(qqbot): prevent CPU-spinning tight loop after WebSocket reconnect failure (#17703)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#41773","number":41773}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40198,"title":"fix(gateway): keep QQBot reconnect loop alive after stale websocket","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"3eeca4613d618618093db416b564a2b9ef8dbe6a","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40497,"title":"fix(qqbot): prevent busy-loop when WebSocket is closed but not None","url":null,"state":"closed","close_reason":{"refs":[{"kind":"commit","value":"8dca28775","number":0}],"category":"implemented_elsewhere","implication":"superseded"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":40574,"title":"fix(qqbot): stop 100% CPU spin when WebSocket is closed but not None (#31193, #31771)","url":null,"state":"merged","close_reason":{"category":"merged"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["gateway/platforms/qqbot/adapter.py","tests/gateway/test_qqbot.py"],"fix_hunks":[{"file":"gateway/platforms/qqbot/adapter.py","hunks":["@@ -681,6 +681,12 @@ async def _read_events(self) -> None:\n         \"\"\"Read WebSocket frames until connection closes.\"\"\"\n         if not self._ws:\n             raise RuntimeError(\"WebSocket not connected\")\n+        if self._ws.closed:\n+            # A closed-but-non-None ws makes the while-condition false on entry,\n+            # so this would return normally — which _listen_loop treats as a\n+            # clean read and immediately retries with backoff reset to 0,\n+            # producing a 100% CPU spin. Raise so the reconnect/backoff path runs.\n+            raise RuntimeError(\"WebSocket closed\")\n \n         while self._running and self._ws and not self._ws.closed:\n             msg = await self._ws.receive()"]},{"file":"tests/gateway/test_qqbot.py","hunks":["@@ -2196,3 +2196,27 @@ def test_fatal_codes_include_intent_errors(self):\n         assert 4014 in fatal_codes\n         assert 4001 in fatal_codes\n         assert 4915 in fatal_codes\n+\n+\n+class TestReadEventsClosedWsGuard:\n+    \"\"\"Regression: a closed-but-non-None ws must raise on entry, not return\n+    normally, so _listen_loop goes through reconnect/backoff instead of\n+    busy-looping at 100% CPU (issues #31193 / #31771).\"\"\"\n+\n+    def _make_adapter(self, **extra):\n+        from gateway.platforms.qqbot import QQAdapter\n+        return QQAdapter(_make_config(app_id=\"a\", client_secret=\"b\", **extra))\n+\n+    def test_read_events_raises_when_ws_closed_on_entry(self):\n+        adapter = self._make_adapter()\n+        adapter._running = True\n+        adapter._ws = SimpleNamespace(closed=True)\n+        with pytest.raises(RuntimeError):\n+            asyncio.run(adapter._read_events())\n+\n+    def test_read_events_raises_when_ws_none(self):\n+        adapter = self._make_adapter()\n+        adapter._running = True\n+        adapter._ws = None\n+        with pytest.raises(RuntimeError):\n+            asyncio.run(adapter._read_events())\n"]}]},{"kind":"pr","number":55524,"title":"fix(qqbot): add post-loop guard in _read_events to prevent backoff reset","url":null,"state":"open","close_reason":{"category":"uncategorized","implication":"needs_llm"},"merged":false},{"kind":"pr","number":55601,"title":"fix(qqbot): raise on silent loop exit to keep _listen_loop consistent","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#55524","number":55524},{"kind":"pr","value":"#55492","number":55492},{"kind":"pr","value":"#55486","number":55486}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}