fix(qqbot): prevent listener busy-loop after closed-WebSocket reconnect failure (#31771) - #31774
Closed
xxxigm wants to merge 3 commits into
Closed
fix(qqbot): prevent listener busy-loop after closed-WebSocket reconnect failure (#31771)#31774xxxigm wants to merge 3 commits into
xxxigm wants to merge 3 commits into
Conversation
…earch#31771) After a reconnect attempt failed (e.g. ``_get_gateway_url`` raised on a transient DNS glitch before ``_open_ws`` could clear ``self._ws``), the next ``_listen_loop`` iteration re-entered ``_read_events`` with a stale closed socket reference. The ``while not self._ws.closed:`` body never ran, the function returned ``None`` immediately, and ``_listen_loop`` reset the backoff and looped again — pegging the process at 99-100% CPU with no further reconnect logs. Make ``_read_events`` raise on entry when the socket is missing / already-closed, and again at the bottom if the read loop exited silently while the listener still wants to run. ``_listen_loop`` now sees a ``RuntimeError`` and applies its reconnect/backoff path instead of treating the silent return as a successful read cycle.
…31771) Defense in depth for the busy-loop scenario in NousResearch#31771: when ``_ensure_token`` or ``_get_gateway_url`` raises inside ``_reconnect`` before ``_open_ws`` could clear the previous session's ``self._ws``, the closed reference would survive and leak into the next listen-loop iteration. Drop the reference on the failure path (only when it's actually closed — never null out a still-live socket) so a subsequent ``_read_events`` call surfaces ``WebSocket not connected`` rather than the closed socket silently short-circuiting the read loop. Pairs with the ``_read_events`` pre-flight raise from the previous commit; either defense alone would already prevent the hot loop, but together they make the contract explicit at both ends.
…ousResearch#31771) Add 11 regression tests pinning both the ``_read_events`` and ``_reconnect`` defenses against the NousResearch#31771 hot-loop: - ``TestReadEventsRaisesOnClosedSocket`` (4 cases) — covers entry with a stale closed socket, entry with ``self._ws=None``, the socket-disappears-mid-read post-loop guard, and the clean-shutdown case where silent return is correct. - ``TestReconnectClearsStaleClosedSocket`` (3 cases) — closes the stale-state path: failed reconnect drops a closed reference, a successful reconnect leaves the new socket set, and a still-open socket is never killed by the cleanup branch. - ``TestListenLoopNoBusyLoopAfterReconnectFailure`` (2 cases) — the end-to-end repro: ``_listen_loop`` now ticks ``backoff_idx`` after each silent failure and gives up at ``MAX_RECONNECT_ATTEMPTS`` instead of looping forever. Wrapped in ``asyncio.wait_for`` so the test runner's timeout catches a regression even when the loop body is actually hot. - ``TestReadEventsSourceGuards`` (2 cases) — pin the defensive checks in source via ``inspect.getsource`` so an accidental refactor removing them is loud during code review.
Collaborator
2 tasks
Contributor
|
Thanks for the detailed reproduction and regression coverage. This is already implemented on current Automated hermes-sweeper review evidence:
The linked competing-fix discussion also points to the same salvage path, so this PR is redundant with main. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does this PR do?
Stops the QQBot adapter from pegging a Hermes Gateway process at 99-100% CPU after the WebSocket closes and the next reconnect attempt fails.
When
_ensure_tokenor_get_gateway_urlraised inside_reconnect(e.g. the transientFailed to get QQ Bot gateway URL: [Errno 8] nodename nor servname providedfrom the issue trace), the previous session'sself._wswas never cleared because_open_ws— the only place that nulls the reference — never ran. The next_listen_loopiteration re-entered_read_events, where thewhile not self._ws.closed:loop body never ran for a closed socket, the function returnedNoneimmediately, and_listen_loopresetbackoff_idx = 0and looped again. Result: a hot busy-loop with no further reconnect logs,platforms.qqbot.statestuck atdisconnected, and one Python core fully pinned.This PR adds two layers of defense:
_read_eventsraises on entry when the socket is missing / already-closed, and again at the bottom if the read loop exited silently while the listener still wants to run, so a closed-socket return is never mistaken for a successful read cycle._reconnectclearsself._wson its exception path (only when the socket is actually closed — never killing a still-live one) so the next iteration sees a missing socket rather than a closed one.Either fix on its own breaks the loop; together they make the contract explicit at both ends.
Related Issue
Closes #31771.
Type of Change
Changes Made
gateway/platforms/qqbot/adapter.py—_read_eventsraisesRuntimeError(\"WebSocket closed before read\")on entry with a closed socket andRuntimeError(\"WebSocket closed during read\")after the loop ifself._runningis still True;_reconnectdrops a stale closedself._wsreference on the exception path.tests/gateway/test_qqbot_busy_loop_31771.py— 11 new regression tests:TestReadEventsRaisesOnClosedSocket(4 cases) — entry with stale closed socket /self._ws=None, the post-loop guard for socket-disappears-mid-read, and clean-shutdown silent return.TestReconnectClearsStaleClosedSocket(3 cases) — failed reconnect drops a closed reference, a successful reconnect leaves the new socket set, a still-open socket is never killed.TestListenLoopNoBusyLoopAfterReconnectFailure(2 cases) — end-to-end repro pinned withasyncio.wait_forso a regression that re-introduces the hot loop will trip the test runner's timeout.TestReadEventsSourceGuards(2 cases) —inspect.getsource-based guards so an accidental refactor removing the defensive checks is loud during code review.How to Test
.venvis set up:python3 -m venv .venv && source .venv/bin/activate && pip install -e \".[all,dev]\"```
scripts/run_tests.sh tests/gateway/test_qqbot_busy_loop_31771.py
```
Expected:
11 passed.```
scripts/run_tests.sh tests/gateway/test_qqbot.py tests/gateway/test_qqbot_busy_loop_31771.py
```
Expected:
170 passed(159 pre-existing + 11 new).Checklist
Code
fix(qqbot):/test(qqbot):)scripts/run_tests.sh tests/gateway/test_qqbot_busy_loop_31771.pyand all 11 tests passDocumentation & Housekeeping
docs/, docstrings) — N/A (defensive guard, no public-API change; rationale captured in code comments + commit bodies)cli-config.yaml.exampleif I added/changed config keys — N/ACONTRIBUTING.mdorAGENTS.mdif I changed architecture or workflows — N/AScreenshots / Logs
```
$ scripts/run_tests.sh tests/gateway/test_qqbot_busy_loop_31771.py
24 workers [11 items]
============================== 11 passed in 0.5s ==============================
$ scripts/run_tests.sh tests/gateway/test_qqbot.py tests/gateway/test_qqbot_busy_loop_31771.py
=== Summary: 2 files, 170 tests passed, 0 failed (100% complete) in 2.6s ===
```
After applying this PR, the exact failure trace from #31771:
```
2026-05-25 02:30:33,484 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903695542] WebSocket error: WebSocket closed
2026-05-25 02:30:33,488 INFO gateway.platforms.qqbot.adapter: [QQBot:1903695542] Reconnecting in 2s (attempt 1)...
2026-05-25 02:30:35,522 WARNING gateway.platforms.qqbot.adapter: [QQBot:1903695542] Reconnect failed: Failed to get QQ Bot gateway URL: ...
```
continues on through the normal backoff/retry path (
WebSocket closed before readfrom_read_events→except Exception→ next_reconnectwith bumpedbackoff_idx) instead of looping silently at 100% CPU.