Skip to content

fix(qqbot): avoid closed websocket busy loop - #31333

Closed
danbao wants to merge 1 commit into
NousResearch:mainfrom
danbao:fix/qqbot-closed-ws-busy-loop
Closed

fix(qqbot): avoid closed websocket busy loop#31333
danbao wants to merge 1 commit into
NousResearch:mainfrom
danbao:fix/qqbot-closed-ws-busy-loop

Conversation

@danbao

@danbao danbao commented May 24, 2026

Copy link
Copy Markdown

Summary

Fixes a QQBot gateway edge case where a closed WebSocket can make the gateway spin in a tight reconnect/read loop instead of backing off.

Changes:

  • make QQAdapter._read_events() raise RuntimeError("WebSocket closed") when the receive loop exits while the adapter is still running
  • prevent _listen_loop from treating an already-closed websocket as a clean read-loop return
  • add a regression test that verifies a pre-closed websocket raises and does not call/await receive()

How this was encountered

I hit this on a long-running local Hermes gateway with Discord, Weixin, and QQBot enabled. After a transient network/DNS disruption, QQBot reported WebSocket close/reconnect failures. The gateway process stayed alive under launchd, but it stopped responding normally:

  • gateway process was still running
  • CPU stayed around ~100%
  • Discord started reporting heartbeat blocked for many hours
  • QQBot appeared connected/reconnecting in logs, but platform traffic was not handled reliably
  • a normal hermes gateway restart could not cleanly recover the wedged process; launchd SIGKILL was needed to let the service relaunch

Root cause: if _read_events() is entered while self._ws.closed is already true, this condition is false immediately:

while self._running and self._ws and not self._ws.closed:
    msg = await self._ws.receive()

Before this PR, that meant _read_events() returned silently. _listen_loop() then treated the return as a clean read-loop exit and immediately started the next loop iteration, which can create a no-sleep/no-backoff busy loop around the closed websocket state.

How to reproduce

The new regression test captures the minimal reproducer:

  1. create a QQAdapter
  2. set adapter._running = True
  3. assign adapter._ws to an object where closed == True
  4. call await adapter._read_events()

Before this fix:

  • _read_events() returned silently
  • the test failed with DID NOT RAISE RuntimeError
  • the outer listen loop could continue spinning

After this fix:

  • _read_events() raises RuntimeError("WebSocket closed")
  • receive() is not awaited for an already-closed websocket
  • _listen_loop() takes the existing exception/reconnect path instead of resetting disconnect counters as if the read loop ended cleanly

Sanitized logs from the incident

Gateway log samples:

WARNING gateway.platforms.qqbot.adapter: [QQBot:<app_id>] WebSocket closed: code=4009 reason=Session timed out
WARNING gateway.platforms.qqbot.adapter: [QQBot:<app_id>] Reconnect failed: Failed to get QQ Bot gateway URL: [Errno 8] nodename nor servname provided, or not known
INFO gateway.run: ✓ qqbot connected
INFO gateway.run: Gateway running with 3 platform(s)

gateway.error.log showed the cross-platform symptom: Discord heartbeat warnings while the event loop traceback pointed into the QQBot adapter read loop:

WARNING discord.gateway: Shard ID None heartbeat blocked for more than 25320 seconds.
Loop thread traceback (most recent call last):
  File "/Users/<user>/.hermes/hermes-agent/gateway/platforms/qqbot/adapter.py", line 492, in _listen_loop
    await self._read_events()
  File "/Users/<user>/.hermes/hermes-agent/gateway/platforms/qqbot/adapter.py", line 654, in _read_events
    async def _read_events(self) -> None:

WARNING discord.gateway: Shard ID None heartbeat blocked for more than 25330 seconds.
Loop thread traceback (most recent call last):
  File "/Users/<user>/.hermes/hermes-agent/gateway/platforms/qqbot/adapter.py", line 492, in _listen_loop
    await self._read_events()
  File "/Users/<user>/.hermes/hermes-agent/gateway/platforms/qqbot/adapter.py", line 659, in _read_events
  File "/Users/<user>/.hermes/hermes-agent/venv/lib/python3.11/site-packages/aiohttp/client_ws.py", line 197, in closed

A later warning showed the same wedged gateway had been blocked for much longer:

WARNING discord.gateway: Shard ID None heartbeat blocked for more than 74060 seconds.

All user paths and long identifiers above are redacted.

Fix result

With this PR, the already-closed websocket path now raises and flows into the existing _listen_loop() exception handler/backoff logic. This avoids the observed busy-loop failure mode and allows QQBot reconnect handling to behave like other websocket-close errors.

After applying the local patch and restarting the gateway, the service recovered:

INFO gateway.run: ✓ qqbot connected
INFO gateway.run: Gateway running with 3 platform(s)

The local gateway CPU dropped from ~100% back to idle after relaunch.

Test Plan

venv/bin/python -m pytest tests/gateway/test_qqbot.py tests/gateway/test_platform_http_client_limits.py -q -o 'addopts='

Result:

166 passed, 4 warnings in 3.34s

The warnings are pre-existing coroutine warnings from unrelated QQBot tests; the new regression test passes.

@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists comp/gateway Gateway runner, session dispatch, delivery platform/qqbot QQ Bot adapter labels May 24, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Competing fix for root issue #17703 (QQBot closed WebSocket busy loop). Related open PRs targeting the same bug: #27821, #29057, #20994, #30431. See also #27810 and #31193 (duplicate issue reports).

@Bartok9

Bartok9 commented May 24, 2026

Copy link
Copy Markdown
Contributor

I rechecked this against current origin/main (b207dc28b). The busy-loop path is still present.

In adapter.py:676-693, when _read_events() is entered with self._ws.closed == True, the while loop condition (not self._ws.closed) is already False so the function returns cleanly. _listen_loop then treats that as a normal read-loop exit and immediately starts the next iteration — no sleep, no backoff — spinning indefinitely.

The fix (raise RuntimeError("WebSocket closed") at the top of _read_events() when the WS is pre-closed) correctly breaks the cycle: _listen_loop catches the RuntimeError, logs it, and applies the configured backoff before reconnecting. Regression test looks tight and covers exactly the described sequence.

@Autsunset

Copy link
Copy Markdown

+1, hit this on my production gateway (v2026.5.29-858). Same root cause: _read_events() guard
at line 682 only checks if not self._ws: but not self._ws.closed, so after a failed reconnect
self._ws still references the server-closed socket and the while-loop at line 685 falls through
instantly. _listen_loop then resets backoff_idx = 0 and spins at ~78% CPU with zero log
output.

Repro path (confirmed via logs + ss -tnp):

  1. QQ gateway sends op 7 / code 4009 (session timeout) — normal, happens every ~30min
  2. _reconnect() fails (e.g. api.sgroup.qq.com/gateway returns 500)
  3. self._ws points to the closed-but-not-None socket; a CLOSE-WAIT fd leaks
  4. _read_events() enters while-loop → condition self._ws and not self._ws.closed is False →
    returns immediately without raising
  5. _listen_loop treats this as a clean return → backoff_idx = 0 → instant re-enter → busy-loop

Local fix I applied (identical approach to this PR):

adapter.py:682

  •    if not self._ws:
    
  •    if not self._ws or self._ws.closed:
           raise RuntimeError("WebSocket not connected")
    

This makes the guard consistent with line 685 and forces _read_events to raise on a closed
socket, so _listen_loop enters the except branch and respects backoff. Verified with a targeted
test: closed-ws now raises RuntimeError instead of silently returning; normal QQCloseError
path is unaffected.

Would love to see this merged — the bug is easy to hit and hard to diagnose (zero log output,
looks like the gateway is healthy from memory_monitor while burning a full core).

@teknium1

Copy link
Copy Markdown
Contributor

This has been implemented on current main by a later QQBot fix. Thanks for the detailed report and reproduction — the prior discussion here helped identify the exact closed-WebSocket path.

Automated hermes-sweeper review evidence:

  • gateway/platforms/qqbot/adapter.py:684 now checks self._ws.closed before entering the receive loop and raises RuntimeError("WebSocket closed"), preventing the clean-return busy loop described in this PR.
  • gateway/platforms/qqbot/adapter.py:639 routes that exception through the existing reconnect/backoff path instead of resetting backoff after a normal _read_events() return.
  • tests/gateway/test_qqbot.py:2201 adds TestReadEventsClosedWsGuard, including the closed-on-entry regression case.
  • The implementing commit is 3eeca4613d618618093db416b564a2b9ef8dbe6a (fix(qqbot): stop 100% CPU spin when WebSocket is closed but not None (#31193, #31771) (#40574)), contained in v2026.6.19.

@teknium1 teknium1 closed this Jun 21, 2026
@teknium1 teknium1 added the sweeper:implemented-on-main Sweeper: behavior already present on current main label Jun 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/gateway Gateway runner, session dispatch, delivery P2 Medium — degraded but workaround exists platform/qqbot QQ Bot adapter sweeper:implemented-on-main Sweeper: behavior already present on current main type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants