Skip to content

fix(downloader): bound the wait for response headers so a wedged origin cannot hang an install forever#11053

Merged
mudler merged 1 commit into
masterfrom
fix/download-response-header-timeout
Jul 22, 2026
Merged

fix(downloader): bound the wait for response headers so a wedged origin cannot hang an install forever#11053
mudler merged 1 commit into
masterfrom
fix/download-response-header-timeout

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

What happened

A gallery model install (materializing a HuggingFace repo) hung for 94 minutes with zero bytes transferred, no error, no retry and no abort. The partial tree stayed frozen at 18G. The last log line was the download starting, then complete silence:

14:06:19 INFO Downloading url=".../LongCat-Video-Avatar-1.5/resolve/<rev>/base_model/diffusion_pytorch_model-000..."
(then nothing; partial tree still 18G when checked again at 15:40)

The retry machinery from #10985 was working - two retries fired just before, at 14:05:01 and 14:06:14 ("download failed, retrying"). The third attempt started at 14:06:19 and wedged. The install never completed, the model config was never written, and the operator sees only an install that silently never finishes.

Root cause

The stall watchdog added earlier wraps the response body:

if DownloadStallTimeout > 0 {
    source = newIdleTimeoutReader(resp.Body, DownloadStallTimeout)
}

That only starts guarding once downloadClient.Do(req) has returned. The transport built by httpclient.New bounds dial (30s), TLS handshake (10s), idle pooled connections (90s) and expect-continue (1s) - but 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.

Two call sites were unguarded, not one: the body request, and the HEAD that probes for Range support before a resume.

The fix

http.Transport.ResponseHeaderTimeout, set at the transport rather than as a client-level Timeout - a client Timeout also bounds the body and would truncate multi-tens-of-GB downloads, reintroducing a worse bug. The deliberate "no body deadline" property is preserved and covered by a test.

120 seconds. It 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 existing 60s stall window. Worst case becomes roughly 6 minutes across the 3-attempt budget instead of unbounded. Overridable via DownloadResponseHeaderTimeout, mirroring DownloadStallTimeout.

The knob is opt-in (httpclient.WithResponseHeaderTimeout) rather than a new default in HardenedTransport. httpclient.New is a shared constructor with 28 call sites, several of which carry streaming traffic - the cloud-proxy backend proxying chat completions, and the MCP SSE clients. A streaming endpoint may legitimately withhold headers until it has something to say (a queued or slow-to-first-token completion), so capping that by default would risk breaking exactly the traffic the constructor's doc comment says it protects. Only the downloader opts in; no other caller's behaviour changes.

A classification trap this exposed

net/http reports a ResponseHeaderTimeout as an error satisfying errors.Is(err, context.DeadlineExceeded) (verified empirically, not assumed). IsRetryable excluded context.DeadlineExceeded outright, so it read our own transport guard firing on a wedged peer as "the caller gave up" and refused to retry - the guard would have fired and then aborted the install permanently.

An explicit ErrTransientDownload marking now outranks the cancellation sentinels. A caller who genuinely gave up is still caught by the ctx.Err() check above it, so nothing that should stop is let through. The resume probe's error is likewise marked transient (it only ever fails on transport trouble - the status is not consulted), so a momentarily wedged origin no longer turns a resumable download into a hard install failure.

Tests

TDD, Ginkgo/Gomega. The new downloader specs use a server that accepts the connection, reads the request, and never writes a response. Before the fix all three hung the full Eventually window; the fourth spec (body not truncated) passed throughout, confirming the suite was not trivially red.

[FAILED] Timed out after 5.000s.   aborts a request whose response headers never arrive instead of hanging forever
[FAILED] Timed out after 5.000s.   classifies a response header timeout as retryable so the plan resumes
[FAILED] Timed out after 5.001s.   aborts a resume probe whose response headers never arrive
FAIL! -- 1 Passed | 3 Failed | 0 Pending | 49 Skipped

After the fix the wedge is detected in ~0.3s. Coverage added:

  • request whose headers never arrive aborts in bounded time
  • the error is classified retryable, so the plan resumes from the .partial
  • the resume HEAD probe is guarded too
  • a slow body after prompt headers is not truncated
  • httpclient: opt-in bounds headers, does not bound the body, and the default transport stays unbounded so streaming is unaffected
  • IsRetryable: transient outranks a deadline-reporting error, still refuses a done context and a deliberate user abort

Verification

command exit
go build ./core/... ./pkg/... 0
go test ./pkg/downloader/ ./pkg/httpclient/ 0
make lint 0

Related

Third defect found in this download path, and they form a set:

🤖 Generated with Claude Code

https://claude.ai/code/session_0156CbKpDh8qbAYzJZJDZ2yk

…in cannot hang an install forever

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).

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
@mudler
mudler merged commit 16033d5 into master Jul 22, 2026
1 of 22 checks passed
@mudler
mudler deleted the fix/download-response-header-timeout branch July 22, 2026 16:28
pull Bot pushed a commit to ACay047/TacticalAI that referenced this pull request Jul 23, 2026
… from scratch (mudler#11071)

materializeLocked built a download task for every file in the resolved
snapshot unconditionally. A completed file is promoted from
.downloads/<hash> into snapshot/<path> and its blob deleted, so on any
re-entry (a controller pod roll, a resubmit, a crash) the new pass built a
task whose .downloads blob no longer existed, re-downloaded the whole file
from Hugging Face, and its AfterDownload even removed the already-complete
snapshot copy first. The only resume that worked was the downloader's
per-file .partial resume for a file caught mid-transfer; completed files
were never skipped.

Production consequence: installing longcat-video-avatar-1.5 (~35 GB after
allow_patterns) on a cluster whose controller rolls hourly (Flux image
automation) never converged across ~14 hours. Each roll restarted from the
first shard; the completed bytes on disk were repeatedly deleted and
re-fetched, and the artifact never promoted. curl of the same files from
inside the pod ran fine, proving the loss was the materializer re-fetching,
not the network.

Before building a task, check whether the file is already materialized and
verified in this staging tree's snapshot/ and, if so, keep it and count it
complete instead of downloading. "Materialized" means a regular file of the
expected size that passes the same verifyDownloadedFile check the download
path uses, so the kept manifest entry is byte-for-byte identical to a fresh
one and integrity is re-checked. The manifest requires a SHA-256 for every
file and non-LFS files carry none to borrow, so a hash is unavoidable for
the manifest anyway; a full re-hash of local disk is still orders of
magnitude cheaper than re-downloading, and the downloader re-verifies any
file it does fetch. Manifest entries are now written at their snapshot index
rather than appended in completion order, so a mix of skipped and downloaded
files keeps the resolved order that committedResult and staging read. The
unconditional root.Remove(destination) now runs only on the fresh-download
path; a kept file survives. Skips are logged at INFO with count and bytes so
an operator can see resume working.

This is the resume-side counterpart to the sibling defects on this path:
read/write error conflation and transient retry (mudler#10985), hash-verify
progress accounting and silent success on an expired deadline (mudler#11026), and
the response-header hang (mudler#11053). The download machinery resumed a single
in-flight file; the materializer above it still threw away every completed
file on restart. It also makes orphan-partial adoption worth its cost:
an adopted tree's completed files were re-downloaded anyway until now.


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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants