Skip to content

fix(http): cap request bodies, resume on progress, keep a rewind cache - #137

Closed
1824239290 wants to merge 9 commits into
AimesSoft:mainfrom
1824239290:pr/http-request-cap-and-rewind-cache
Closed

1824239290 wants to merge 9 commits into
AimesSoft:mainfrom
1824239290:pr/http-request-cap-and-rewind-cache

Conversation

@1824239290

Copy link
Copy Markdown
Contributor

Problem

With a large read-ahead window the HTTP source turned the whole window into a
single request body. ureq's timeout_recv_response (15 s) also bounds the
body phase, so one request for N MiB demands a sustained N/15 MiB/s from the
origin:

  • 32 MiB window -> needs ~2.13 MiB/s (~18 Mbps) or the request dies with
    timeout: receive response
  • that error surfaces as EIO from the custom AVIO, and the demuxer treats it as
    terminal: 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 Mbps
floor).
The window is still filled to read_ahead_bytes, but by successive
capped 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): three
attempts 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-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.

Carry the source's error into the terminal demux error (ffmpeg.rs): a read
failure now reports ...; custom AVIO: http error: timeout: receive response
instead of a bare Input/output error (-5), which is why the original report
needed a reproduction instead of a log line.

No public API or ABI changes; erika.h is untouched.

Tests

The lib suite passes (519 tests). New cases include:

  • http_deep_window_against_a_slow_origin_still_serves_the_read - real client
    against 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 after
    every delivered piece must still license a fourth attempt
  • request-cap invariants (caller-requested sizes included), prefetch chain and
    absorb, rewind-tail hit vs re-anchor past the tail, straddling read joining
    the in-flight piece, unbounded stream_to_eof for sidecar files (256 MiB
    hard stop, error not silent truncation), uncovered reads erroring instead of
    looking like EOF
  • assertions that depend on loopback timing were given ~10x headroom after one
    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 open succeeds in 17 s; a 10 s rewind inside
the retained tail issues zero new requests.

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.
@1824239290 1824239290 closed this Sep 19, 2026
}
let short = (fetched.len() as u64) < HTTP_REQUEST_MAX_BYTES;
self.cache_bytes.extend_from_slice(&fetched);
if short {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 响应已经结束的信息保留到这一层,作为整份读取完成的依据。

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