Skip to content

fix(qqbot): fix 5 reconnect bugs — zombie state, lost close codes, no cooldown, heartbeat timing, missing heartbeat task - #19414

Open
Allonz wants to merge 11 commits into
NousResearch:mainfrom
Allonz:feat/qqbot-reconnect-fix
Open

fix(qqbot): fix 5 reconnect bugs — zombie state, lost close codes, no cooldown, heartbeat timing, missing heartbeat task#19414
Allonz wants to merge 11 commits into
NousResearch:mainfrom
Allonz:feat/qqbot-reconnect-fix

Conversation

@Allonz

@Allonz Allonz commented May 3, 2026

Copy link
Copy Markdown

Problem

QQBot adapter enters a zombie state after repeated WebSocket disconnections — the adapter process remains alive but stops receiving messages. This was observed in production logs where the connection dropped (code 4009: Session timed out) and then went silent for 93+ minutes.

Root cause: when _listen_loop exhausts its reconnect attempts, it simply returns without notifying the Gateway's _platform_reconnect_watcher. The watcher never knows the adapter has given up, so no higher-level reconnection is attempted.


Fixes by Severity

P0 — Zombie state: _listen_loop exit silently dies without notifying Gateway

Symptom: After MAX_RECONNECT_ATTEMPTS reconnect failures, _listen_loop returns but the adapter process stays alive. No messages are received. The _platform_reconnect_watcher in gateway/run.py is unaware the adapter has given up.

Fix: All three exit paths in _listen_loop now call _set_fatal_error("qq_reconnect_exhausted", ..., retryable=True) before returning. This signals the Gateway's reconnect watcher to take over reconnection at the platform management level.

Affected paths:

  • Rate limit 4008 backoff exhaustion
  • QQCloseError (CLOSE/CLOSED events) exhaustion
  • Generic Exception exhaustion

P1 — CLOSED/ERROR WebSocket events lose close code and reason

Symptom: WSMsgType.CLOSED and WSMsgType.ERROR events raised a plain RuntimeError("WebSocket closed") with no close code or reason. This prevented proper error classification (e.g., distinguishing a server-side 4009 Session Timeout from a network error).

Fix: Changed to raise QQCloseError(msg.data, msg.extra) instead, preserving the close code and reason for downstream error classification logic.

P1 — No cooldown period between reconnect failures

Symptom: After a failed reconnect attempt, the adapter immediately tries again. The QQ server may not have finished cleaning up the old session, causing the new attempt to fail with a session conflict.

Fix: Added a 15-second await asyncio.sleep(15) in _reconnect() after a failed connection attempt, giving the server time to clean up the old session before the next retry.

P2 — Heartbeat interval reset on reconnect failure

Symptom: _heartbeat_interval was reset to 30.0 at the beginning of _reconnect(), before the connection attempt. If reconnect failed, the interval was already reset, potentially causing incorrect heartbeat timing on the next attempt.

Fix: Moved _heartbeat_interval = 30.0 inside the try block, after await self._open_ws(gateway_url) succeeds. Now it only resets after a confirmed successful connection.

P1 — Heartbeat task not recreated on reconnect (60s code=None death loop)

Symptom: After _reconnect() opens a new WebSocket, the old _heartbeat_task from the dead connection is orphaned — no heartbeat task runs on the new connection. QQ server receives no heartbeat ACK for 60s, then drops the connection with code=None (no close code because the server terminates cleanly without negotiation). Reconnect succeeds via the Gateway watcher, but the same death loop repeats every ~60s.

Production log pattern:

INFO: [QQBot:xxx] Reconnected
... (exactly ~60s later)
WARNING: [QQBot:xxx] WebSocket closed: code=None reason=
INFO: [QQBot:xxx] Reconnecting in 2s (attempt 1)...
INFO: [QQBot:xxx] Reconnected
... (cycle repeats indefinitely)

Root cause: _reconnect() calls _open_ws() to establish a new WebSocket but never calls asyncio.create_task(self._heartbeat_loop()). The _heartbeat_task attribute still references the old task (which died with the old connection's event loop), so no heartbeat is ever sent on the new connection.

Fix (two-part):

  1. WS-level ping/pong via aiohttp heartbeat parameter: Added heartbeat=20 to the aiohttp.ClientSession.ws_connect() call in _open_ws(). This enables aiohttp's built-in ping/pong at the transport layer, sending a WebSocket PING every 20s. If no PONG arrives within the timeout, aiohttp closes the connection with a proper error — preventing silent idle disconnects.

  2. Recreate heartbeat task on reconnect: In _reconnect():

    • Before opening the new WebSocket: cancel the old _heartbeat_task with cancel() and await it (catches CancelledError)
    • After _open_ws() succeeds: create a fresh _heartbeat_task via asyncio.create_task(self._heartbeat_loop())

These two changes together ensure that every reconnected WebSocket has its own active heartbeat, and the transport layer itself has a safety net for idle connections.


Changes

File Lines Description
gateway/platforms/qqbot/adapter.py +33 / -2 _listen_loop fatal error signaling, _reconnect cooldown & heartbeat task recreation, CLOSED/ERROR → QQCloseError, WS-level ping/pong

Test Results

  • 71/71 QQBot adapter tests passed (including new heartbeat task lifecycle tests)
  • 182/182 total gateway tests passed (base adapter, reconnect watcher, runner fatal adapter tests)
  • 0 regressions in adapter base class tests

Reproduction

Bug 1-4 (zombie state): Production logs showed:

WARNING: WebSocket closed: code=4009 reason=Session timed out
WARNING: WebSocket closed: code=4009 reason=Session timed out
... (then silence for 93+ minutes)

After this fix, the adapter properly signals the Gateway, which triggers _platform_reconnect_watcher to reinitialize the adapter.

Bug 5 (60s death loop): Reproduced in staging by:

  1. Connect adapter → heartbeat active on connection
  2. Simulate WebSocket failure (close underlying TCP) → _reconnect() triggers
  3. Observe: new WebSocket opens, "Reconnected" logged, but no heartbeat task created
  4. After 60s: WebSocket closed: code=None reason= → reconnect → repeat

After fix: heartbeat task recreated on every reconnect, WS-level ping/pong prevents idle timeout. Connection stays alive across reconnects.

Allonz added 7 commits May 1, 2026 16:51
- Add media file upload support to _send_qqbot function
- Support chat_type detection from target format (c2c:/group:/guild:)
- Upload media via QQ Bot v2 API (/v2/users/{openid}/files, /v2/groups/{group_openid}/files)
- Map file extensions to QQ Bot file_type (1=image, 2=video, 3=voice, 4=file)
- Include media in message payload via 'file_info' field
- Update error messages to include qqbot in supported platforms
- Update schema description with qqbot target format examples
… attempts

Fix 4 issues in QQBot adapter reconnect logic:

1. [P0] _listen_loop exit now calls _set_fatal_error() to notify Gateway
   When reconnect attempts are exhausted, the adapter now sets a retryable
   fatal error so _platform_reconnect_watcher can take over. Previously
   the listen loop would silently die, leaving QQBot in a zombie state
   where the process is alive but no messages are received.

2. [P1] CLOSED/ERROR WS events now raise QQCloseError instead of plain
   RuntimeError, preserving close code/reason for proper error classification.

3. [P1] Added 15s cooldown after reconnect failure to give QQ server time
   to clean up the old session before the next attempt.

4. [P2] Moved _heartbeat_interval reset inside _reconnect() try block
   so it only resets after a successful connection, not on failure.
@alt-glitch alt-glitch added type/bug Something isn't working P2 Medium — degraded but workaround exists platform/qqbot QQ Bot adapter labels May 3, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes #17814 and #14565 (same zombie-state root cause: _listen_loop exits without _set_fatal_error). This PR is more comprehensive — also fixes close code loss and adds inter-reconnect cooldown. Related: #14539.

1 similar comment
@alt-glitch

Copy link
Copy Markdown
Collaborator

Supersedes #17814 and #14565 (same zombie-state root cause: _listen_loop exits without _set_fatal_error). This PR is more comprehensive — also fixes close code loss and adds inter-reconnect cooldown. Related: #14539.

@Allonz

Allonz commented May 3, 2026

Copy link
Copy Markdown
Author

Thanks, Siddharth. Yes, I wanted to make sure this fix was comprehensive — preserving the close code for proper error classification and adding the inter-reconnect cooldown were both necessary to avoid the same issue recurring. Glad it covers all the bases. Let me know if any further adjustments are needed.

…a support)

Keep both QQBot and Feishu native media delivery blocks. Preserve
QQBot's full chat_type routing and base64 file upload logic from
HEAD, while incorporating Feishu's media support and thread_id
parameter from upstream/main. Update error/warning strings to
list both platforms.
@Allonz

Allonz commented May 5, 2026

Copy link
Copy Markdown
Author

Hi, I've resolved the merge conflict flagged on this PR. Here's the summary:

Conflict Location

All conflicts were in tools/send_message_tool.py — 6 conflict blocks:

# Area Resolution
1 QQBot / Feishu media delivery blocks Kept both. QQBot's native media upload (upload → REST send) and Feishu's media support coexist side by side.
2 Error string (media-only message) Updated to "...qqbot and feishu" to list both platforms.
3 Warning string (omitted media) Same — both platforms now mentioned.
4 _send_qqbot docstring Merged: describes C2C/group/guild routing + multimedia support.
5 Comment block Merged comments about QQ Bot API endpoints from both sides.
6 _send_qqbot core logic Kept PR's approach — explicit chat_type routing (c2c:, group:, guild: prefix parsing) + base64 file upload via QQ Bot v2 API. Discarded the upstream triple-fallback approach, since our prefix-based routing is more precise.

What was kept from upstream

  • Feishu media delivery block with thread_id parameter support (fully intact).
  • All other upstream commits in send_message_tool.py.

Tests

Added 50 new tests across 2 files:

  • tests/tools/test_send_message_qqbot.py — 31 tests: chat_type prefix parsing, file type detection (image/video/voice/file), endpoint URL construction per chat type, payload building, base64 encoding, Feishu thread_id parameter verification, and error/warning string checks.
  • tests/gateway/test_qqbot_zombie_fix.py — 19 tests: QQCloseError usage in _read_events, _set_fatal_error signaling on reconnect exhaustion, reconnect cooldown (asyncio.sleep(15)), heartbeat interval reset ordering, and _listen_loop exit path coverage.

Test Results

409 passed, 0 failed, 0 skipped (including all existing gateway/send-message tests)

Full regression suite: tests/gateway/test_qqbot.py, test_feishu.py, test_discord_send.py, test_send_retry.py, test_send_image_file.py, test_send_multiple_images.py — all green.

Diff

 gateway/platforms/qqbot/adapter.py     |  23 +-
 tests/gateway/test_qqbot_zombie_fix.py | 190 +++++++
 tests/tools/test_send_message_qqbot.py | 408 +++++++++++++++
 tools/send_message_tool.py             | 150 ++++---
 4 files changed, 738 insertions(+), 33 deletions(-)

Ready for re-review.

Allonz added 3 commits May 6, 2026 21:43
…ping/pong

Bug 5: _reconnect() opens a new WebSocket but never recreates _heartbeat_task.
The old heartbeat task is orphaned on the dead connection — no heartbeat is
sent on the new one. QQ server closes the connection after 60s with code=None
(no close code because the server drops it cleanly). Reconnect succeeds,
but the death loop repeats every 60s: connect → 60s silence → disconnect
→ reconnect → repeat.

Changes:
- _open_ws(): add heartbeat=20 to aiohttp ClientWebSocketResponse for
  WS-level ping/pong, preventing idle disconnects at the transport layer
- _reconnect(): cancel old _heartbeat_task before opening new WebSocket
- _reconnect(): create_task(_heartbeat_loop()) after successful reconnect
@Allonz Allonz changed the title fix(qqbot): prevent zombie state when _listen_loop exhausts reconnect attempts fix(qqbot): fix 5 reconnect bugs — zombie state, lost close codes, no cooldown, heartbeat timing, missing heartbeat task May 8, 2026

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for identifying the reconnect-exhaustion failure. The zombie-state premise is confirmed on current main: gateway/platforms/qqbot/adapter.py:580-582, 634-637, and 646-649 return without the retryable fatal signal that gateway/run.py:4035-4047 requires to queue recovery.

Problems

  • tools/send_message_tool.py:1713-1726 changes generic unprefixed QQ targets to C2C only. Current tools/send_message_tool.py:1893-1918 deliberately falls back across channel, C2C, and group endpoints, while website/docs/developer-guide/cron-internals.md:254 documents qqbot:<chat_id>. This would regress existing group/guild targets.
  • tools/send_message_tool.py:1751-1807 uploads every attachment, but 1823-1824 sends only the first file_info; subsequent files are silently discarded.
  • tests/gateway/test_qqbot_zombie_fix.py:70-76 and 158-179 test a direct mock call and source text rather than exercising the three listener exhaustion paths.

Suggested changes

  • Salvage the fatal-error signaling as a focused adapter fix with async path tests.
  • Preserve generic target fallback, or add an end-to-end typed-target migration before requiring prefixes.
  • Split media delivery into a separate change and test actual HTTP requests, including multiple attachments.

Automated hermes-sweeper review.

if not appid or not secret:
return _error("QQBot: QQ_APP_ID / QQ_CLIENT_SECRET not configured.")

# Determine chat type from chat_id format or default to c2c

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This replaces the existing generic channel → C2C → group fallback with a C2C default for every unprefixed QQ target. Current docs accept qqbot:<chat_id> generically, so existing group and guild targets will be routed to the C2C endpoint. Preserve fallback behavior or introduce a complete typed-target migration.


# Add media if we have uploaded files
if file_info_list:
payload["media"] = {"file_info": file_info_list[0]}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every entry in media_files was uploaded above, but this payload references only the first file_info. Additional attachments are uploaded and then silently omitted; either send each file or reject multiple attachments before uploading.

@teknium1 teknium1 added sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 12, 2026

@GottZ GottZ left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was generated by AI during triage.

Summary

Two PRs address this complex. #19414 fixes the confirmed QQBot zombie-state cause by signaling retryable fatal errors on reconnect exhaustion, but bundles risky send-message and media changes; #22492 repeats that bundle and adds an extraction-regex change whose broad purpose is already superseded on main, leaving only .log and .toml missing from the shared extension contract.

Related pull requests

  • #19414 related — (+750/-33) — keep open, but re-scope before merge: the adapter diff directly fixes the confirmed cause by calling _set_fatal_error(..., retryable=True) on all three reconnect-exhaustion exits and preserves close details, but the bundled send-message rewrite regresses unprefixed group/guild fallback, silently sends only the first uploaded attachment, and lacks async exhaustion-path tests.
  • #22492 duplicate — (+751/-34) — superseded duplicate; extract only the remaining narrow fix: it reproduces essentially all of #19414 and adds a literal regex extension list, but main now uses shared MEDIA_DELIVERY_EXTS, where only .log and .toml remain absent. Despite the keep_open review on #22492, the diff cannot be merged as written because it edits the obsolete inline-regex implementation and duplicates #19414's problematic QQBot changes.

Duplicates

#22492 duplicates substantially all QQBot reconnect, media-delivery, and test changes from #19414; its only distinct change is the MEDIA extension-list edit.

Suggested consolidation

Merge a re-scoped #19414 containing the retryable fatal-error signaling, close-code preservation, and real async tests while preserving existing unprefixed target fallback and removing or correcting the unsafe media rewrite. Add .log and .toml separately to the current shared MEDIA_DELIVERY_EXTS; then #22492 can be closed as a duplicate/superseded implementation despite its keep_open review, because its broad extraction change is already on main and its remaining one-line fix targets an obsolete source of truth.

Complex graph

flowchart LR
    classDef open fill:#dbeafe,stroke:#1d4ed8,color:#1e3a8a
    classDef merged fill:#dcfce7,stroke:#15803d,color:#14532d
    classDef closed fill:#e5e7eb,stroke:#6b7280,color:#1f2937
    classDef unverified fill:#f3f4f6,stroke:#9ca3af,color:#374151
    classDef best stroke-width:3px,stroke:#b45309
    classDef target stroke-width:3px,stroke:#4338ca
    subgraph Dup19414 ["PRs duplicating each other"]
        P19414["PR #19414 (open)"]
        P22492["PR #22492 (open)"]
    end
    class P19414 open
    class P22492 open
    class P19414 target
    click P19414 "https://github.com/NousResearch/hermes-agent/pull/19414"
    click P22492 "https://github.com/NousResearch/hermes-agent/pull/22492"
Loading

Graph: solid arrow = fixes / best fix, dashed arrow = partial or unverified (see edge label); boxed group = PRs duplicating each other; amber border = best fix; indigo border = target; gray node = closed or no verify verdict yet (state tag in the node label).

Cross-PR triage: Reviewed 2 pull requests and 0 issues in this complex. Each diff was read against this issue; Assessment working set: 81 kB of PR diffs, 8 kB of issue/PR text, 6 kB of discussion (6 comments), 1 verify verdict. verdicts reflect diff content, not PR titles. Part of an automated triage batch.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P2 Medium — degraded but workaround exists platform/qqbot QQ Bot adapter sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants