Files
LocalAI/pkg/downloader/stall.go
mudler's LocalAI [bot] 16033d562a fix(downloader): bound the wait for response headers so a wedged origin cannot hang an install forever (#11053)
A gallery model install hung for 94 minutes with zero bytes transferred, no
error, no retry and no abort, leaving a partial tree frozen at 18G. The last
log line was the download starting, then silence:

    14:06:19 INFO Downloading url=".../LongCat-Video-Avatar-1.5/resolve/<rev>/base_model/diffusion_pytorch_model-000..."

The retry machinery from #10985 was working (two retries fired at 14:05:01 and
14:06:14); the third attempt simply never returned. The install never
completed, the model config was never written, and nothing surfaced the
failure.

The stall watchdog added earlier wraps the response *body*, so it only starts
guarding once downloadClient.Do() has returned. The transport had no
ResponseHeaderTimeout, so a peer that completes the dial and TLS handshake,
reads the request, and then never sends a status line parks Do() for the
process lifetime. IdleConnTimeout governs pooled idle connections, not an
in-flight request. Both the body request and the HEAD that probes for Range
support were unguarded.

Bound the header wait at the transport, not the client: a client-level Timeout
would also bound the body and truncate multi-tens-of-GB downloads. The knob is
opt-in (WithResponseHeaderTimeout) rather than a default in HardenedTransport,
because a streaming endpoint may legitimately withhold headers until it has
something to say, and capping that would break the streaming clients that share
this constructor.

Also fix a classification trap this exposed: net/http reports a
ResponseHeaderTimeout as an error satisfying errors.Is(err,
context.DeadlineExceeded), which IsRetryable read as "the caller gave up" and
refused to retry. An explicit transient marking now outranks the cancellation
sentinels; a caller who genuinely gave up is still caught by the ctx.Err()
check. The resume probe's error is likewise marked transient, so a momentarily
wedged origin no longer turns a resumable download into a hard install failure.

Third defect found in this download path, after #10985 (read vs write errors
conflated) and #11026 (hash verification emitted no progress and an expired
deadline returned success).


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-22 18:28:51 +02:00

94 lines
3.2 KiB
Go

package downloader
import (
"fmt"
"io"
"sync"
"time"
)
// DownloadStallTimeout bounds how long an in-flight download may receive no
// data before it is aborted. A silently-dropped TCP connection (no FIN/RST)
// would otherwise block the body read forever, freezing an install at N bytes
// until an external reaper kills it. Overridable (tests set it small); a value
// <= 0 disables the guard.
var DownloadStallTimeout = 60 * time.Second
// DownloadResponseHeaderTimeout bounds how long a download request may wait for
// the server's response headers. DownloadStallTimeout cannot cover this window:
// it wraps the response body, which does not exist until the client has a
// response. A peer that accepts the connection, reads the request and then
// never answers leaves http.Client.Do parked forever, freezing an install at
// zero bytes for the process lifetime.
//
// The value has to tolerate an origin legitimately slow to *start* answering (a
// CDN under load, cold object storage, a redirect chain) while still bounding a
// wedge. Two minutes is far above any plausible legitimate time-to-first-byte
// and twice the stall window, so a genuine wedge fails in bounded time and the
// retry machinery resumes from the .partial. Deliberately NOT a client-level
// Timeout: that would also bound the body and truncate multi-tens-of-GB
// downloads. Overridable (tests set it small); a value <= 0 disables the guard.
var DownloadResponseHeaderTimeout = 120 * time.Second
// idleTimeoutReader wraps a streaming ReadCloser and aborts reads that make no
// progress within timeout. A standard io.Copy blocks indefinitely on a Read
// against a dead-but-unclosed socket; nothing in the copy loop can interrupt a
// blocked syscall. The watchdog timer closes the underlying reader on expiry,
// which unblocks the in-flight Read with an error. Each read that returns data
// resets the idle clock, so a slow-but-steady transfer never trips the guard.
type idleTimeoutReader struct {
rc io.ReadCloser
timeout time.Duration
mu sync.Mutex
timer *time.Timer
fired bool
done bool
}
func newIdleTimeoutReader(rc io.ReadCloser, timeout time.Duration) *idleTimeoutReader {
r := &idleTimeoutReader{rc: rc, timeout: timeout}
r.timer = time.AfterFunc(timeout, r.onStall)
return r
}
// onStall fires when no data has arrived within the timeout. Closing the
// underlying reader is what unblocks a Read parked in the kernel.
func (r *idleTimeoutReader) onStall() {
r.mu.Lock()
if r.done {
r.mu.Unlock()
return
}
r.fired = true
r.mu.Unlock()
_ = r.rc.Close()
}
func (r *idleTimeoutReader) Read(p []byte) (int, error) {
n, err := r.rc.Read(p)
if n > 0 {
r.timer.Reset(r.timeout)
}
if err != nil {
r.mu.Lock()
fired := r.fired
r.mu.Unlock()
if fired {
// Translate the "use of closed connection" the watchdog induced
// into an actionable stall error. This is not context.Canceled,
// so the caller keeps the .partial file for a later resume.
return n, fmt.Errorf("download stalled: no data received for %s", r.timeout)
}
}
return n, err
}
func (r *idleTimeoutReader) Close() error {
r.mu.Lock()
r.done = true
r.mu.Unlock()
r.timer.Stop()
return r.rc.Close()
}