Hmbown/CodeWhale · error · StreamError::Stall

stream_stall

stream_stall

Error message

Stream stalled: no data received for {timeout_secs}s, closing stream

What it means

The engine's stream watchdog closed a model stream because no data arrived within chunk_timeout_secs seconds. It constructs a StreamError::Stall envelope, logs it, and increments stream_errors so the nothing-streamed retry can fire or the turn can fail with the real reason. This prevents turns hanging forever on a frozen connection.

Solutions

  1. Retry the request; the engine treats stalls like any stream error and may retry automatically.
  2. Increase the stall/chunk timeout setting if the provider legitimately has high time-to-first-token.
  3. Check network path (VPN, proxy, firewall) that may silently drop idle SSE connections.
  4. Check provider status pages for ongoing incidents and fail over to another model/provider.

Example fix

// before
stall_timeout = 15s
// after: allow slow time-to-first-token from a warmup-heavy provider
stall_timeout = 60s
Defensive patterns

Strategy: retry

Validate before calling

// preflight: ensure the provider endpoint responds before starting a turn
if tokio::time::timeout(Duration::from_secs(10), health_check(endpoint)).await.is_err() {
    return Err("provider unreachable; skipping turn");
}

Try / catch

match result {
    Err(e) if e.contains("Stream stalled") => warn_and_retry_with_backoff(),
    other => other,
}

Prevention

When it happens

Trigger: While reading stream chunks in run_turn's stream pump, a read times out after chunk_timeout_secs of silence, producing Ok(None)/timeout that maps to StreamError::Stall { timeout_secs }.

Common situations: Slow or overloaded provider APIs; corporate proxies or VPNs dropping idle connections; long server-side model warmups exceeding the stall window; very large prompts with long time-to-first-token.

Understand the failure class

Background: Request timed out: what client-side request timeouts mean across libraries (Request timed out, TIMED_OUT, APITimeoutError) — this error's family across 39 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/dffacd4c0436fae4. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/core/engine/turn_loop.rs:4790

        let max_duration = self.config.stream_max_duration;
        let max_duration_secs = max_duration.as_secs();
        let max_content_bytes = self.config.stream_max_content_bytes;

        // Process stream events
        loop {
            let poll_outcome = tokio::select! {
                biased;
                _ = self.cancel_token.cancelled() => None,
                result = tokio::time::timeout(chunk_timeout, stream.next()) => {
                    match result {
                        Ok(Some(event_result)) => Some(event_result),
                        Ok(None) => None, // stream ended normally
                        Err(_) => {
                            let envelope = StreamError::Stall {
                                timeout_secs: chunk_timeout_secs,
                            }
                            .into_envelope();
                            crate::logging::warn(&envelope.message);
                            // A stall is a stream error like any other:
                            // count it so the nothing-streamed retry can
                            // fire, and record it so an unrecovered stall
                            // fails the turn with the real reason instead
                            // of ending "Completed" over a frozen block.
                            stream_errors = stream_errors.saturating_add(1);
                            stream_error.get_or_insert(envelope.message.clone());
                            let _ = self.tx_event.send(Event::error(envelope)).await;
                            None
                        }
                    }
                }
            };
            let Some(event_result) = poll_outcome else {
                break;
            };
            while let Some(pending) = self.next_turn_steer() {
                if pending.content.trim().is_empty() {

View on GitHub (pinned to 73e0f67d83)