mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 18:09:05 -04:00
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>
152 lines
4.7 KiB
Go
152 lines
4.7 KiB
Go
package downloader_test
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"time"
|
|
|
|
. "github.com/mudler/LocalAI/pkg/downloader"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("Download response header timeout", func() {
|
|
var filePath string
|
|
var savedHeaderTimeout time.Duration
|
|
|
|
BeforeEach(func() {
|
|
dir, err := os.Getwd()
|
|
Expect(err).ToNot(HaveOccurred())
|
|
filePath = dir + "/respheader_model"
|
|
savedHeaderTimeout = DownloadResponseHeaderTimeout
|
|
})
|
|
|
|
AfterEach(func() {
|
|
DownloadResponseHeaderTimeout = savedHeaderTimeout
|
|
_ = os.Remove(filePath)
|
|
_ = os.Remove(filePath + ".partial")
|
|
})
|
|
|
|
It("aborts a request whose response headers never arrive instead of hanging forever", func() {
|
|
// The server accepts the connection and reads the request, then never
|
|
// writes a status line. The stall watchdog cannot help here: it wraps
|
|
// the response body, and there is no response yet. Without a
|
|
// ResponseHeaderTimeout on the transport, Do() parks forever and the
|
|
// whole install freezes with zero bytes transferred.
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-release // never write headers
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
|
|
DownloadResponseHeaderTimeout = 300 * time.Millisecond
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- URI(server.URL).DownloadFileWithContext(
|
|
context.Background(), filePath, "", 1, 1,
|
|
func(s1, s2, s3 string, f float64) {})
|
|
}()
|
|
|
|
var err error
|
|
Eventually(done, "5s").Should(Receive(&err))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("timeout awaiting response headers"))
|
|
})
|
|
|
|
It("classifies a response header timeout as retryable so the plan resumes", func() {
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-release
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
|
|
DownloadResponseHeaderTimeout = 300 * time.Millisecond
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- URI(server.URL).DownloadFileWithContext(
|
|
context.Background(), filePath, "", 1, 1,
|
|
func(s1, s2, s3 string, f float64) {})
|
|
}()
|
|
|
|
var err error
|
|
Eventually(done, "5s").Should(Receive(&err))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(IsRetryable(context.Background(), err)).To(BeTrue(),
|
|
"a wedged origin must be retried, not treated as a permanent failure")
|
|
})
|
|
|
|
It("aborts a resume probe whose response headers never arrive", func() {
|
|
// The HEAD that probes for Range support runs before the body request
|
|
// and uses the same client, so it is a second unguarded wait. A
|
|
// leftover .partial is what puts that probe on the path.
|
|
release := make(chan struct{})
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
<-release
|
|
}))
|
|
defer server.Close()
|
|
defer close(release)
|
|
|
|
Expect(os.WriteFile(filePath+".partial", make([]byte, 128), 0600)).To(Succeed())
|
|
|
|
DownloadResponseHeaderTimeout = 300 * time.Millisecond
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- URI(server.URL).DownloadFileWithContext(
|
|
context.Background(), filePath, "", 1, 1,
|
|
func(s1, s2, s3 string, f float64) {})
|
|
}()
|
|
|
|
var err error
|
|
Eventually(done, "5s").Should(Receive(&err))
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).To(ContainSubstring("timeout awaiting response headers"))
|
|
Expect(IsRetryable(context.Background(), err)).To(BeTrue(),
|
|
"a wedged resume probe must be retried, not aborted permanently")
|
|
})
|
|
|
|
It("does not bound the body once headers have arrived, so long downloads are not truncated", func() {
|
|
// The deliberate "no body deadline" property must survive: headers come
|
|
// back promptly, then the body trickles for longer than the header
|
|
// timeout. This must succeed.
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == "HEAD" {
|
|
w.WriteHeader(http.StatusOK)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Length", "4")
|
|
w.WriteHeader(http.StatusOK)
|
|
for i := 0; i < 4; i++ {
|
|
_, _ = w.Write([]byte{byte('a' + i)})
|
|
if f, ok := w.(http.Flusher); ok {
|
|
f.Flush()
|
|
}
|
|
time.Sleep(150 * time.Millisecond)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
DownloadResponseHeaderTimeout = 300 * time.Millisecond
|
|
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
done <- URI(server.URL).DownloadFileWithContext(
|
|
context.Background(), filePath, "", 1, 1,
|
|
func(s1, s2, s3 string, f float64) {})
|
|
}()
|
|
|
|
var err error
|
|
Eventually(done, "10s").Should(Receive(&err))
|
|
Expect(err).ToNot(HaveOccurred())
|
|
content, readErr := os.ReadFile(filePath)
|
|
Expect(readErr).ToNot(HaveOccurred())
|
|
Expect(string(content)).To(Equal("abcd"))
|
|
})
|
|
})
|