zeroclaw-labs/zeroclaw · warning · anyhow::Error
WebSocket stream ended
Error message
WebSocket stream ended
What it means
DingTalkChannel::listen ends every WebSocket read-loop exit with this bail: a Close frame from the server, a protocol/IO error, a failed pong send, or the internal mpsc sender closing all break the loop and fall through to this line. It is a lifecycle signal ("stream is gone, reconnect"), not the fault description — the underlying WebSocket error is logged separately at WARN just before. DingTalk's stream gateway routinely closes long-lived connections, so a supervisor should expect this error periodically.
Source
Thrown at crates/zeroclaw-channels/src/dingtalk.rs:400
if tx.send(channel_msg).await.is_err() {
::zeroclaw_log::record!(
WARN,
::zeroclaw_log::Event::new(
module_path!(),
::zeroclaw_log::Action::Note
)
.with_outcome(::zeroclaw_log::EventOutcome::Unknown),
"message channel closed"
);
break;
}
}
_ => {}
}
}
anyhow::bail!("WebSocket stream ended")
}
async fn health_check(&self) -> bool {
self.register_connection().await.is_ok()
}
async fn start_typing(&self, _recipient: &str) -> anyhow::Result<()> {
// No typing-indicator API in the DingTalk Open Platform.
Ok(())
}
async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
Ok(())
}
}
#[cfg(test)]
mod tests {View on GitHub (pinned to 88bb9c8533)
Solutions
- Wrap listen() in a reconnect supervisor with exponential backoff — each iteration re-registers via register_connection and reconnects
- Check the preceding WARN logs ("WebSocket error", "failed to send pong") for the real cause
- If proxies kill idle sockets, keep the ping/pong path responsive or raise the proxy idle timeout
- Distinguish shutdown (tx closed) from server disconnect so exit does not trigger reconnect storms
Example fix
// before
channel.listen(tx).await?;
// after: reconnect loop with backoff
let mut backoff = backoff::ExponentialBackoff::default();
loop {
if let Err(e) = channel.listen(tx.clone()).await {
tracing::warn!("dingtalk stream ended: {e}; reconnecting");
}
tokio::time::sleep(backoff.next()).await;
} Defensive patterns
Strategy: retry
Try / catch
let mut delay = Duration::from_secs(1);
loop {
let _ = channel.listen(tx.clone()).await; // ends with "WebSocket stream ended"
tokio::time::sleep(delay).await;
delay = (delay * 2).min(Duration::from_secs(60)); // capped backoff, then re-listen
} Prevention
- Assume the stream will end: build a reconnect supervisor, not a one-shot listen
- Read the WARN WebSocket logs preceding the bail — they carry the real cause
- Cap and jitter backoff to avoid hammering the gateway after outages
When it happens
Trigger: Server-side close after a missed SYSTEM ping/pong exchange (GC pause, blocked network); TCP reset from network drops or NAT idle timeouts; proxy killing idle connections; failed pong write breaking the loop; process shutdown closing the mpsc receiver.
Common situations: Bots behind proxies with short idle timeouts; event bursts stalling the pong loop; laptop-sleep interruptions; deployments that called listen() once with no reconnect handling.
Related errors
- QQ WebSocket connection closed: server requested reconnect (
- gateway registration failed ({status}): {err}
- initial ping failed
- QQ WebSocket connection closed: invalid session (fresh auth
- QQ WebSocket connection closed: close_code={code}, reason="{
AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23).
Data as JSON: /api/errors/bd18e2d3b1be2085.
Report an issue: GitHub.