cjpais/Handy · error · anyhow::Error

transfer stalled: no data for

Error message

transfer stalled: no data for {}s

What it means

Raised when the response body stream yields no chunk for DOWNLOAD_STALL_TIMEOUT (60 s) while the download is otherwise in progress. The connection is alive at the HTTP level but data stopped flowing — a mid-transfer stall. Critically, the partial file is kept on purpose, so the next download attempt resumes from where the data stopped rather than restarting a multi-GB fetch.

Solutions

  1. Retry the download — resume picks up from the preserved partial.
  2. Prevent the machine from sleeping during large downloads (or use a wired connection).
  3. If stalls repeat at the same point, capture the URL and test with curl -C - <url> to distinguish network from server issues.
  4. Move off unreliable hotspots/VPNs for multi-GB models.
Defensive patterns

Strategy: retry

Try / catch

match manager.download_model(model_id, None).await {
    Ok(_) => Ok(()),
    Err(e) if e.to_string().contains("transfer stalled") => {
        // partial preserved on stall: the retry resumes from where data stopped
        manager.download_model(model_id, None).await
    }
    Err(e) => Err(e),
}

Prevention

When it happens

Trigger: stream.next() times out: server/CDN stops sending mid-body, connection half-open after a NAT/firewall drop, path MTU blackhole, or severe bandwidth starvation (not zero, but under the event loop's progress cadence — practically, nothing arrives for 60 s).

Common situations: Laptop sleep/resume mid-download; flaky hotel/airport Wi-Fi; mobile tether switch; CDN node failure mid-stream; power-saving NICs dropping long transfers.

Related errors


AI-assisted analysis of cjpais/Handy@c89b7bf389 (2026-08-17). Data as JSON: /api/errors/540f74dc560ec733. Report an issue: GitHub.

Appendix: source

Thrown at src-tauri/src/managers/model/download.rs:327

                total: total_size,
                percentage: if total_size > 0 {
                    (downloaded as f64 / total_size as f64) * 100.0
                } else {
                    0.0
                },
            }));
        };
        emit_progress(downloaded);

        // Throttle progress events to max 10/sec (100ms intervals)
        let mut last_emit = Instant::now();
        let throttle = Duration::from_millis(100);
        let mut stream = response.bytes_stream();
        loop {
            let chunk = tokio::select! {
                c = tokio::time::timeout(DOWNLOAD_STALL_TIMEOUT, stream.next()) => match c {
                    // Stalled mid-body: keep the partial for resume.
                    Err(_) => return Err(anyhow::anyhow!(
                        "transfer stalled: no data for {}s",
                        DOWNLOAD_STALL_TIMEOUT.as_secs()
                    )),
                    Ok(None) => break,
                    Ok(Some(chunk)) => chunk?,
                },
                _ = cancel_token.cancelled() => {
                    // Keep the partial for resume; caller handles state cleanup.
                    return Ok(HttpDownloadOutcome::Cancelled);
                }
            };
            // An untrusted server must not be able to fill the disk: cut the
            // transfer at the first byte past the known total instead of
            // trusting it to eventually close the stream. Everything written
            // so far is tainted by a provably-misbehaving server — clear it.
            if let Some(cap) = known_total {
                if downloaded + chunk.len() as u64 > cap {
                    drop(file);

View on GitHub (pinned to c89b7bf389)