fix(http): cap request bodies, resume on progress, keep a rewind cache - #137
1824239290 wants to merge 9 commits into
Conversation
The read-ahead window doubled as the on-the-wire request size, and ureq's `timeout_recv_response` (15 s) also bounds the body phase, so a single request for N MiB demanded a sustained N/15 MiB/s from the origin: fetching a 32 MiB window as one request failed on anything below ~18 Mbps with `timeout: receive response` -> EIO -> terminal playback error (a 16 MiB window below ~9 Mbps, which the 2 MiB default hid). * source.rs: cap every request body at `HTTP_REQUEST_MAX_BYTES` (4 MiB, a ~2.2 Mbps floor). The window is still filled to `read_ahead_bytes`, but by successive capped pieces: a finished prefetch is folded into the cache and the next piece starts at `cache_end`, so the chain advances across reads. * source.rs: a failed background prefetch is no longer fatal. It is recorded, the chain parks after `HTTP_PREFETCH_MAX_FAILURES` consecutive failures, and the read falls back to a synchronous fetch (whose success re-arms it). * source.rs: replace the flat 20 s wall-clock retry budget with an `HttpRetryGate`: three attempts without progress still fail fast (the read_range caller blocks the demuxer thread), while a fetch that keeps moving its resume point may continue -- up to eight attempts and a 120 s ceiling. A capped 4 MiB request on a slow origin legitimately needs several 15 s attempts, and giving up ends playback instead of stalling it. * source.rs: keep a bounded tail of already-played data (`HTTP_CACHE_RETAIN_BYTES`, 16 MiB -- between VLC's 3x4 MiB reusable ring set and mpv's 50 MiB `--demuxer-max-back-bytes` default) so a small rewind is a cache hit. Fetches now only ever append at `cache_end` or re-anchor the window, which also stops a rewind from throwing away the in-flight prefetch and the buffered future it still needs. * ffmpeg.rs: carry the media source's own error into the terminal demux error, so a read failure reports `...; custom AVIO: http error: timeout: receive response` instead of a bare `Input/output error (-5)`. Tests: gate (three new cases + ceiling), request cap, prefetch chain, retained tail hit, re-anchor past the tail, failing prefetch degrading to a synchronous fetch; the short-prefetch case now asserts the kept piece plus a gap fill. The 591-test lib suite passes.
Two cases that fail on the old shape and pass on the new one: * `http_deep_window_against_a_slow_origin_still_serves_the_read` drives the real client against a drip server (900 KB/s): a 32 MiB window must still serve its first read, with the request capped at 4 MiB. The old shape asked for the whole window in one body and died on the 15 s body deadline. * `http_resumes_more_than_three_times_while_bytes_arrive` truncates the body three times, always after delivering a piece, and expects a fourth attempt: progress must license resuming, which the flat three-attempt rule forbade.
…eam_to_eof Two defects found reviewing the previous commit: * a read that starts inside the cache and ends past the cache end no longer joined the in-flight prefetch (only a fully covered read did), so `fetch_missing` downloaded the same range again while the piece was still on the wire -- a duplicate capped request per chunk boundary in the worst case. Joining now also happens when the read needs bytes at/past the cache end, which is exactly where the piece starts. * `stream_to_eof` (open-ended reads such as danmaku/subtitle sidecars) inherited the per-read attempt cap and silently truncated anything past 8 x 4 MiB = 32 MiB. It now loops until EOF -- a short answer, or the resource total when it is known -- with a 256 MiB hard stop, matching the kernel's other sidecar ceilings. Both are covered by tests that fail on the previous commit: `http_read_straddling_the_cache_end_joins_the_inflight_piece` (duplicate request) and `http_stream_to_eof_reads_past_eight_pieces` (32 MiB truncation, asserted as 37748736 != 33554432). Assertions that depend on loopback timing were given ~10x headroom after one flake under load.
…ad as EOF Findings from reviewing the previous two commits (independently and by hand): * `fetch_length` ended with `max(requested_length)`, so a caller asking for 32 MiB still produced a 32 MiB body -- the cap only bounded the look-ahead it added. The cap is now an invariant: a caller asking for more gets a short read and re-issues (the read loops do), and the request is never sized past the resource total either. * `trim_cache` measured the retention budget from the end of the current read, so a read larger than the budget had its own start trimmed away and fell back to a re-anchor with one uncapped body. It is now measured from -- and clamped to -- the start of the read. * a read the cache never reached came back as an empty read, which the AVIO layer turns into EOF, so playback could end silently when an origin answered short bodies while `range.start` was still inside the resource. `fetch_missing` now keeps walking until the read is covered (bounded by `HTTP_FETCH_MAX_PIECES_PER_READ`), and an uncovered read below the resource total is an error rather than an EOF. * the prefetch gate stopped at half the window (`read_ahead / 2`), so a 32 MiB setting settled around 20 MiB, contradicting what the setting says. It now refills towards `read_ahead_bytes`. * `HTTP_FETCH_TOTAL_BUDGET` was only consulted after a failed attempt, letting the last attempt overshoot the ceiling by its own timeouts; it is checked before an attempt starts now. * `CustomAvio.last_error` was never cleared, so a tolerated earlier failure could be appended to a later, unrelated terminal error; it is cleared on a successful read and seek. Tests: capped caller requests, the trim clamp, short-piece coverage (a range-aware mock origin replaces the fixed response list, which a dropped prefetch used to desynchronise), and the refill depth. 599 lib tests pass.
- stream_to_eof checked the 256 MiB hard stop before the declared-total EOF, so a resource whose total is the limit plus one full piece (e.g. exactly 260 MiB) was misreported as a limit violation after the final piece landed on the total. The EOF check now wins; the hard stop only fires while bytes are still owed. Regression test: probe_stream_to_eof_known_total_just_past_the_limit_ends_cleanly. - The HEAD-probe retry loop broke the budget-exhausted branch out with SourceError but the non-retryable branch with ureq::Error, so that code never compiled; both now break with SourceError(Http(..)). - reanchor_window reset cache_start/cache_bytes a second time in the empty-body error path; dropped the duplicated assignments. - trim_cache bound a length it never used; the binding is gone. - probe_small_piece_cap_triggers_piece_limit asserted nothing (println only) and never exercised the cap: replaced with read_that_needs_more_than_the_piece_cap_must_fail_loudly.
… gone Google retired the SDK repository's legacy `tools` package on 2026-09-15 (android-actions/setup-android#537). The action's default package list still names it, so every job that leaves `packages` unset fails during the setup step with "Failed to find package 'tools'" before compiling anything. This workflow only needs cmdline-tools/platform-tools plus the NDK (installed by the next step), so the default list is narrowed rather than kept.
| } | ||
| let short = (fetched.len() as u64) < HTTP_REQUEST_MAX_BYTES; | ||
| self.cache_bytes.extend_from_slice(&fetched); | ||
| if short { |
There was a problem hiding this comment.
[P2] 短 206 响应不能直接当作整份文件的 EOF
服务器可以限制每次 Range 响应的大小,此时 fetched.len() < 4 MiB,但 Content-Range 已表明后面还有数据。这里直接 break,会让 read_uri_to_end 成功返回被截断的外挂字幕或弹幕。对照复现:同一个 1 MiB SRT,服务端对有结束位置的 Range 每次最多返回 256 KiB;旧版的 bytes=0- 读到全部 16,384 条字幕,新版只读到 4,096 条,仍返回成功。普通有长度的读取已经在 fetch_missing 中处理了短分段,这里也需要根据资源总长继续读取,不能仅凭本次响应短就结束。
| self.cache_end(), | ||
| ))); | ||
| } | ||
| let fetched = self.fetch_range(ByteRange { |
There was a problem hiding this comment.
[P2] 完整的 chunked 200 响应读完后不应继续请求下一段
服务器忽略 Range、返回 200 + Transfer-Encoding: chunked 时,没有 Content-Length,所以 content_length 一直是 None。如果完整 body 至少有 4 MiB,后面的短响应判断也不会退出,循环会再从文件末尾发 GET,然后因非零 offset 收到 200 而报错。实测同一个 8 MiB chunked 响应,旧版整份读取成功;新版先完整收下 8 MiB,再请求 bytes=8388608-12582911,最终返回 server ignored Range request (status 200)。这会使原本可加载的 HTTP 弹幕等外挂资源失败。需要把完整 200 响应已经结束的信息保留到这一层,作为整份读取完成的依据。
Problem
With a large read-ahead window the HTTP source turned the whole window into a
single request body.
ureq'stimeout_recv_response(15 s) also bounds thebody phase, so one request for N MiB demands a sustained N/15 MiB/s from the
origin:
timeout: receive responseterminal: playback stops mid-stream on any origin slower than the window
demands, even though the origin is still delivering data
A 16 MiB window fails the same way below ~9 Mbps; the 2 MiB default merely hid
the cliff. A real-world report hit it on a ~1-2 MiB/s Emby link with the window
set to 32 MiB (two stalls at prime time, reproducible against a throttled
local server).
Fix
Cap every request body at
HTTP_REQUEST_MAX_BYTES(4 MiB, a ~2.2 Mbpsfloor). The window is still filled to
read_ahead_bytes, but by successivecapped pieces: a finished background prefetch is folded into the cache and the
next piece starts at
cache_end, so the chain advances across reads.Failed prefetches are no longer fatal. A failure is recorded and the chain
parks after 3 consecutive failures; the read falls back to a synchronous fetch
whose success re-arms the chain.
Retry policy distinguishes progress from stalls (
HttpRetryGate): threeattempts without progress still fail fast (the caller blocks the demuxer
thread), but a fetch that keeps moving its resume point may continue -- up to
eight attempts and a 120 s ceiling. A capped 4 MiB request on a slow origin
legitimately needs several 15 s attempts; giving up ends playback instead of
stalling it.
Keep a bounded 16 MiB tail of already-played data (
HTTP_CACHE_RETAIN_BYTES-- between VLC's 3x4 MiB reusable ring and mpv's 50 MiB
--demuxer-max-back-bytesdefault) so a small rewind is a cache hit. Fetchesnow only ever append at
cache_endor re-anchor the window, which also stops arewind from throwing away the in-flight prefetch and the buffered future.
Carry the source's error into the terminal demux error (ffmpeg.rs): a read
failure now reports
...; custom AVIO: http error: timeout: receive responseinstead of a bare
Input/output error (-5), which is why the original reportneeded a reproduction instead of a log line.
No public API or ABI changes;
erika.his untouched.Tests
The lib suite passes (519 tests). New cases include:
http_deep_window_against_a_slow_origin_still_serves_the_read- real clientagainst a 900 KB/s drip server: a 32 MiB window must still serve its first
read, with every request capped at 4 MiB (fails on the old shape, which asked
for the whole window in one body)
http_resumes_more_than_three_times_while_bytes_arrive- truncation afterevery delivered piece must still license a fourth attempt
absorb, rewind-tail hit vs re-anchor past the tail, straddling read joining
the in-flight piece, unbounded
stream_to_eoffor sidecar files (256 MiBhard stop, error not silent truncation), uncovered reads erroring instead of
looking like EOF
flake under load
End-to-end validated against a throttled server at 700 KB/s with a 32 MiB
window: open completes in 6.3 s (first 4 MiB piece) and the clip plays to the
end; at 250 KB/s the first piece is cut by the 15 s deadline, the retry resumes
from the last delivered byte and
opensucceeds in 17 s; a 10 s rewind insidethe retained tail issues zero new requests.