fix(qqbot): prevent CPU-spinning tight loop after WebSocket reconnect failure (#17703) - #39430
fix(qqbot): prevent CPU-spinning tight loop after WebSocket reconnect failure (#17703)#39430k176060444-lgtm wants to merge 2 commits into
Conversation
… failure Fixes NousResearch#17703 When a QQ Bot WebSocket disconnects and the first reconnect attempt fails (e.g. DNS resolution during a brief network outage), the stale closed WebSocket reference causes _read_events() to return silently. The outer _listen_loop treats this as a clean exit, resets backoff_idx to 0, and re-enters _read_events() immediately — creating a tight loop with no await points that starves the asyncio event loop and spins at 100% CPU. Three independent guards prevent this: 1. _reconnect(): clear self._ws = None on failure so the next _read_events() call hits the None-check entry guard instead of encountering a stale closed socket. 2. _read_events() entry guard: check self._ws.closed alongside the existing None check, rejecting stale sockets up front. 3. _read_events() post-loop fallback: if the while-loop exits without raising while self._running is True, raise RuntimeError so _listen_loop takes the exception/reconnect path instead of resetting backoff and looping back immediately. Any one of these guards alone is sufficient to break the tight loop; together they provide defense-in-depth. Verified on a live Windows gateway: router reboot triggered the exact disconnect, DNS failure, stale-ws path. With the fix, reconnect backoff progressed 2s, 5s, 10s, 30s and recovered automatically after ~50s. Without the fix, the gateway entered a tight CPU spin and had to be force-killed.
|
Verified: this correctly fixes the CPU-spinning bug in Root cause confirmed — when Three-layer defense is correct:
I verified that Docstring and inline comments clearly explain the invariant. Tests are focused. LGTM. |
d3193dc to
35ba4a5
Compare
Local pytest results (Windows, Python 3.11.15)Replaces CI which is not triggered for fork PRs. Test coverage
Note: |
1. _reconnect() now closes the aiohttp session when reconnect fails, preventing resource leaks until the next successful reconnect. 2. Add 5 regression tests for NousResearch#17703 CPU-spinning fix: - _read_events raises when ws is closed (Guard 2) - _read_events raises when ws is None (Guard 2) - _read_events raises on CLOSED message type - _reconnect clears both _ws and _session on failure - _reconnect handles missing session gracefully
35ba4a5 to
dd5b5fb
Compare
Updated pytest results — 6 tests now cover all 3 guardsTest coverage (updated)
The new |
|
Superseded by #41773. The primary closed-WebSocket CPU busy-loop guard has already been merged upstream. The remaining reconnect-failure resource cleanup has been extracted into a focused PR:
Closing this PR to avoid duplicate review. |
Summary
Fixes #17703 — QQBot adapter enters a CPU-spinning tight loop after a failed reconnect, starving the asyncio event loop and making the gateway unresponsive.
Root Cause
When a QQ Bot WebSocket disconnects and the first reconnect attempt fails (e.g. DNS resolution during a brief network outage),
_reconnect()returnsFalsewithout clearingself._ws. The stale closed WebSocket reference causes_read_events()to exit itswhileloop immediately (becauseself._ws.closedis True), returning normally without raising. The outer_listen_looptreats this as a clean read-loop exit, resetsbackoff_idx = 0, and re-enters_read_events()instantly — creating a tight loop with noawaitpoints that starves the event loop and spins at 100% CPU.Fix
Three independent guards, any one of which alone prevents the tight loop:
Guard 1 —
_reconnect()root-cause cleanup (PR #20994 approach):Guard 2 —
_read_events()entry check (PR #29057 approach):Guard 3 —
_read_events()post-loop fallback (PR #31333 approach):Defense-in-depth: each guard is independent. If any future change accidentally removes one, the other two still prevent the tight loop.
Verification
Tested on a live Windows gateway with a manual router reboot:
Before fix: Gateway entered tight CPU spin after first reconnect failure, had to be force-killed.
After fix:
Gateway recovered automatically after ~50 seconds. No manual intervention needed.
Related PRs
This PR consolidates the approaches from five competing PRs (#29057, #27821, #20994, #31333, #30285) into a single defense-in-depth fix.