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

  1. Wrap listen() in a reconnect supervisor with exponential backoff — each iteration re-registers via register_connection and reconnects
  2. Check the preceding WARN logs ("WebSocket error", "failed to send pong") for the real cause
  3. If proxies kill idle sockets, keep the ping/pong path responsive or raise the proxy idle timeout
  4. 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

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


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