Proxied stream timeout regression
Summary
Zurg used download_timeout_secs as a wall-clock deadline for proxied HTTP
downloads. Its default is 15 seconds, so any single upstream response whose body
took longer than 15 seconds to finish was cut off even while bytes were arriving
normally.
The defect was reported on Discord on 2026-08-21 against
2026.08.21.0151-nightly (fc2a21f). Repeated 2 GB ranged requests through zurg
ended at 15.001 seconds after delivering about 1.26 GB at 84 MB/s. The same range
completed directly from Real-Debrid, and raising download_timeout_secs to 3600
let the request finish through zurg in 25.8 seconds.
This was a zurg regression, not content filtering, a CDN failure, or a regression
in fc2a21f. Commit 6edeaa8c (Optimize client, 2024-11-17) introduced it. The
plain proxied serving path was fixed by 41db7245, and eff972b8 then made the
same mistake difficult to reintroduce at the provider API boundary.
User impact
The affected path was any response body that zurg proxied from an HTTP-backed
debrid provider and that remained open longer than download_timeout_secs.
Real-Debrid was affected from November 2024. TorBox and AllDebrid inherited the
same behavior when those providers were added. Locally served NZB reads never
passed through the HTTP download client and were not affected.
The user-visible symptoms included:
- Infuse or another direct WebDAV client freezing during playback;
- playback failing after a seek opened a new ranged request;
- a download ending early despite receiving HTTP
206 Partial Content; and - client errors that appeared to implicate the media file or CDN even though a direct request completed normally.
The player did not necessarily fail 15 seconds after playback started. At the reported 84 MB/s, zurg transferred roughly 1.26 GB before the deadline. A player could consume that buffer for several minutes before it ran dry, which turned an exact 15-second network cutoff into an apparently variable playback freeze.
Root cause
Go's http.Client.Timeout is a deadline for the entire request lifecycle. It is
not an inactivity or stall timeout. The clock starts when the request begins and
continues while the response body is read, regardless of whether data is flowing.
Before 6edeaa8c, the download client itself had no whole-request timeout:
client: &http.Client{}
download_timeout_secs bounded the connection and response-header phases through
the transport's dialer and ResponseHeaderTimeout. Once the response body arrived,
the serving path could keep reading for as long as the downstream client needed.
6edeaa8c reorganized the HTTP transport and added the configured timeout to the
client itself while removing ResponseHeaderTimeout:
client.client = &http.Client{
Transport: transport,
Timeout: time.Duration(timeoutSecs) * time.Second,
}
That changed the meaning of the existing 15-second value. It still protected the connection phase, but it now also imposed an absolute deadline on the response body. The serving path already fetched media through this client, so the regression became live as soon as this change landed.
The failure was quiet. Zurg sent the successful upstream status and headers before copying the body to the player. When the client's deadline interrupted the body, the copy operation returned an error, but the serving path discarded that error:
n, _ = copyBuffered(resp, body)
By then the 206 response was already committed and could not be replaced with an
error status. With request logging disabled, nothing reported why the body was
short. The downstream client therefore saw a successful partial-content response
whose body ended before its advertised length.
Regression history
The relevant commits form a longer chain than the nightly where the issue was reported:
The February RAR fix is the important near miss. Its commit message explicitly
states that http.Client.Timeout covers the body and that the 15-second default was
killing RAR streams. The diagnosis was correct, but the fix was scoped to the RAR
reader. Keeping separate DownloadFile and DownloadFileStreaming methods allowed
the ordinary serving path to retain the unsafe choice for another six months.
Why it stayed hidden
Several effects concealed the regression for about 21 months:
-
Most media-server deployments read zurg through rclone rather than direct WebDAV. rclone uses bounded ranged requests. The current managed-mount defaults start at 4 MB and grow to a 512 MB limit, so on a fast connection many individual upstream requests finish inside 15 seconds. This reduced exposure; it did not make the path safe, particularly on slower links.
-
Direct WebDAV clients such as Infuse can issue large or open-ended ranges. Those requests keep one upstream response alive long enough to hit the deadline reliably, which is why the Discord reproduction was exact at three different offsets.
-
Playback buffering separated cause from symptom. The upstream request died at 15 seconds, but playback stopped only after the player consumed the bytes already delivered. The visible delay varied with bitrate, buffering, and seeks.
-
The response looked successful. The CDN returned a valid
206, zurg forwarded it, and the body-copy error was ignored after headers were sent. Direct CDN tests stayed healthy and normal logs contained no timeout error. -
Raising the setting appeared to solve the problem. That made the defect look like an undersized tuning value rather than a timeout attached to the wrong abstraction.
-
Existing tests used bodies or ranges that completed inside the configured timeout. No test deliberately kept a response body flowing beyond the deadline and then asserted the complete length and checksum through the serving path.
-
The narrow RAR fix removed the most obvious earlier manifestation without removing the unsafe default from ordinary downloads.
Why a higher timeout appeared to fix it
Changing the setting to 3600 seconds did not change what the timeout covered. It only moved the wall-clock deadline far enough away for the reported 25.8-second transfer to finish:
15 seconds < 25.8-second transfer < 3600 seconds
This was a workaround, not a safe fix. Any finite whole-request timeout can still cut off a sufficiently long body, including a slow client or a paused connection. The setting also feeds the download client's dial timeout and bounded verification requests, so increasing it to an hour can make an unreachable host take far longer to fail. Fixed builds should use the normal configured value; it no longer caps a proxied media body.
Fix and guardrails
The immediate fix in 41db7245 changed streamFileToResponse to fetch through
Provider.DownloadFileStreaming. That client shares the same transport but has no
http.Client.Timeout, so body lifetime is no longer tied to
download_timeout_secs. The request still uses the downstream request context, so
disconnecting the player cancels the upstream read. Connection establishment and
TLS handshaking remain bounded by the transport.
eff972b8 then removed reliance on every caller choosing the method with
"Streaming" in its name:
-
Real-Debrid's
DownloadFilenow usesDoStreaming. -
The shared
debrid.DownloadRangedhelper acceptsStreamingDoer, which exposes onlyDoStreaming; AllDebrid and TorBox use this helper. -
Both provider download methods are therefore safe for a long response body.
-
Bounded non-playback reads, such as the two 64 KiB reads used for an OpenSubtitles hash, receive their own explicit context deadline instead of borrowing an unsafe client-wide body timeout.
The regression tests in internal/universal/stream_timeout_test.go use a local
server that sends a 1.6 MB body in 16 chunks over about 1.6 seconds while the
configured timeout is one second. They assert both full length and checksum:
-
directly through both provider download methods for Real-Debrid, AllDebrid, and TorBox;
-
end to end through
streamFileToResponsefor all three HTTP-backed providers; and -
through an NZB-shaped local provider as an unaffected control.
The invariant is now explicit: a method that returns a download body must never use a whole-request timeout. Short probes and metadata reads should carry a deadline on their own context; streaming bodies should live until completion, downstream cancellation, or a real transport error.
Bounded by progress, not by time
Living until completion left one way for a read never to end: a delivery node
that keeps the response open and stops feeding it, or never answers at all. In
September 2026 a Plex scan sat for eleven hours on one Real-Debrid file whose
read never failed. This client had no bound to give up on it with, and rclone's
--timeout is an idle bound: zurg forwards a body in 256 KiB writes, so a node
delivering 256 KiB every five minutes, under 1 KB/s, resets it for ever. A
slower node does trip it, but rclone then sends the same request to the same
node, logging one error per three timeouts. Measured on zen with rclone 1.75
and the mount's own flags on 2026-10-03, a reader on such a node was still
blocked after 30 minutes.
internal/universal/starved_read.go now cuts a proxied read once the node has
delivered less than 1 MiB across 30 seconds that zurg spent waiting on it. Only
waiting on the node counts: from the moment the request has a connection until
its headers arrive, and the time inside each read of the body. Time spent writing
to the client does not, so the property above still holds and a paused or slow
client can keep a stream open for as long as it likes. A cut file is answered
503 for two minutes so rclone's retries end at once instead of each waiting out
another window. Locally served NZB reads are not watched.
Reads of files inside an archive never reached streamFileToResponse, so they
were left out of that bound until card 204. Their bytes come from spans of each
volume, fetched through the same client by providerDoer.Do and read by the
RAR parser, and a node could hold one of those for as long as it kept the
connection. Each volume fetch is now watched by the same floor, the entry is
refused for two minutes when one is cut, and a cut while the archive is being
listed fails the listing rather than letting the parser leave the volume out as
a damaged one, which on a real five-volume set served the entry's full length
with the wrong bytes after the second volume.
Card 222 found the same thing behind every other way a node can fail a volume
read: a 5xx, a 429, a connection dropped part way through a header. The parser
took each for a damaged volume and listed the archive without it, and that
listing was cached, so on the same set a 503 on volume 3 served the wrong bytes
from offset 16,057 and went on serving them after the node recovered. The
parser now tells a volume whose bytes did not parse from one whose bytes never
arrived (rarstream.ErrVolumeUnavailable, marked by HTTPSrc for the statuses
it sees, by providerDoer for everything a remote read fails with, and by the
NZB body for what its reader classes as the transport). The second fails the
listing, a nested set and a volume-head grouping included, and the failure is
answered 503 with a Retry-After, never cached as an archive failure, never
recorded as a verdict, and never answered on a browse with the raw volumes in
place of the archive's contents. Volumes that are damaged or absent are listed
around as before.
The 256 KiB writes named above went in card 204 too. The copy still fills
its buffer while a node is fast, so a fast body leaves in whole buffers as
before, but it sends the first bytes the moment they arrive and never holds a
byte longer than 250 ms (defaultCopyFlushAfter in
internal/universal/downloader.go), flushing the response with them. A slow
node no longer keeps the client from its headers. Before it, on zen through
rclone and the mount's own flags, an archive entry on a node sending 64 KiB and
then 16 KiB every four minutes had sent rclone nothing at all when rclone gave
up on it at exactly five minutes, three times over: everything the node had
sent was still in the buffer.