zeroclaw-labs/zeroclaw · error · anyhow::Error

QQ WebSocket connection closed: internal message channel clo

Error message

QQ WebSocket connection closed: internal message channel closed

What it means

Raised by QQChannel::listen when the internal broadcast channel that carries parsed ChannelMessages to the consumer is closed (ExitReason::ChannelClosed) — tx.send(...) failed at qq.rs:1753 because the receiver was dropped. This is not a network or QQ problem: the downstream consumer (orchestrator/supervisor event loop) shut down while the gateway connection was still healthy. Unlike other exits, there is no WARN log, only the bail.

Source

Thrown at crates/zeroclaw-channels/src/qq.rs:1825

                        .with_attrs(::serde_json::json!({"MAX_MISSED_ACKS": MAX_MISSED_ACKS})),
                    "heartbeat timeout after consecutive missed ACKs; resume will be attempted on reconnect"
                );
                anyhow::bail!(
                    "QQ WebSocket connection closed: heartbeat ACK timeout \
                     ({MAX_MISSED_ACKS} consecutive missed ACKs)"
                )
            }
            ExitReason::WriteFailed => {
                ::zeroclaw_log::record!(
                    WARN,
                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
                    "WebSocket write failed; resume will be attempted on reconnect"
                );
                anyhow::bail!("QQ WebSocket connection closed: write failed")
            }
            ExitReason::ChannelClosed => {
                anyhow::bail!("QQ WebSocket connection closed: internal message channel closed")
            }
        }
    }

    async fn health_check(&self) -> bool {
        self.fetch_access_token_with_retry().await.is_ok()
    }

    async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
        // No typing-indicator API on the QQ Bot Open Platform.
        Ok(())
    }

    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
        Ok(())
    }
}

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Treat it as a shutdown signal, not a failure — do not reconnect-loop on it; propagate so the caller can finish terminating
  2. On graceful shutdown, drop/cancel the listener task at the same time as the consumer so this path is expected and quiet
  3. If it appears unexpectedly mid-run, find which task owned the receiver and why it exited (check its logs/panics)
Defensive patterns

Strategy: try-catch

Try / catch

if let Err(err) = channel.listen(&tx).await {
    if format!("{err:#}").contains("internal message channel closed") {
        break; // consumer is gone: treat as shutdown, do NOT reconnect-loop
    }
    return Err(err);
}

Prevention

When it happens

Trigger: listen()'s select arm forwards a composed ChannelMessage into tx; the receiving end of the mpsc channel has already been dropped (listener task cancelled, orchestrator shutting down, consumer erroring out), so send returns Err and the loop breaks with ExitReason::ChannelClosed.

Common situations: Graceful bot shutdown where the supervisor stops consuming before the channel task finishes; a consumer task that panicked or was cancelled; reconnect logic dropping the old listener's receiver without draining it.

Understand the failure class

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/9bf16e53c8beaeca. Report an issue: GitHub.