fix(subtitles): give the video path priority on the source link (#240) - #242
Merged
superuser404notfound merged 3 commits intoJul 28, 2026
Merged
Conversation
#240: on Matroska a subtitle side reader is a second full copy of the stream (matroska_parse_cluster reads every block off the wire, matroska_parse_block only then honours the discard flag), so a subtitled session asks the link for about twice the media rate. Above ~2x headroom nobody notices; at 1.3x to 1.5x, an ordinary Wi-Fi bench in front of a high-bitrate remux, the two readers split the link and the video path misses its deadlines. The reporter measured the same segment serving in 2.2 s alone and 7.5 s alongside the prefetcher, which expired the seek landing budget; the recovery re-anchor then jumped the clock, which rebuilt the prefetch session, which took more link, and landings stacked to 25 s. Arbitration is a strict priority, not a share: playback is load-bearing, subtitle lookahead is not. A side reader fetches while the video path does not need the link, which on a fast link is nearly always (the pump parks as soon as its forward buffer is full). Two escapes keep it from being a mute switch: a 5 s lead floor so cues at the playhead are never gated, and a 60 s continuous-yield cap with a 10 s grant window so a pump that never parks cannot silently disable lookahead for the session. - SideReaderLinkPolicy / SideReaderLinkGate / SideReaderLinkArbiter: the rules, the shared state (a count, so a producer restart cannot drop the claim), and the injectable half the reader loops ask. - HLSSegmentProducer claims the link for its pump loop and hands it back inside both park helpers, so the release is balanced whatever the park returns. - The #151 prefetcher and the native subtitle readers yield on the same rule; a whole-program .vtt pass is exempt (it has no lead to spend). - The prefetch open waits for a seek to land, not just for the restart to settle: the open pays a header read, the cue-index prewarm and a positioning seek, each a bounded range the origin delivers in full. - A playhead jump re-anchors the running session in place instead of tearing it down and rebuilding it, which since the bounded ranges of #220 cost tens of megabytes of link per jump. Positioning is one shared function now, so an in-place move cannot drift from the session-start rules (#234). Diagnostics, because the reporter had to infer a second reader from connection lines that named none: every AVIOReader carries a label (pump/prefetch/nativesubs /extract), the connection-start line is no longer DEBUG-only and reports its range length, and the memprobe reports pumpFetchedMB next to prefFetchedMB plus the prefetcher's cumulative yield. 15 tests in Issue240SideReaderLinkPriorityTests, full suite 1243 green, strict-concurrency=complete clean, tvOS Simulator build green.
…ottle mode #240 needs two things a bench could not do before. `--seek-pattern a,b,c` walks a list of absolute targets, one per `--seek-every` tick, and prints how long each `seek(to:)` took: the existing 6 s backward hop lands in the segment cache and never restarts the producer, so it exercises none of the machinery the report is about. And `throttle-origin.py --shared` shapes the SUM of all connections rather than each one, which is what a real link does; with per-connection shaping two readers each get the full rate and a contention defect cannot reproduce at all.
Measured on a shaped 1.4x-headroom bench, the lead floor from the previous commit did nothing: the rule was "fetch while less than 5 s ahead of the playhead", and on a link that cannot carry two readers the side reader never gets ahead at all, so its lead stayed negative and the floor never expired. It kept 47% of the link and the seek landings did not move. A grace window cannot get stuck that way, and it protects the case that actually needed protecting, which is a freshly selected track with nothing in the store for the new position, not steady-state lookahead: cues at the playhead come from the pump's own tap either way. 8 s after each anchor and after each in-place re-anchor, then the video path wins until it parks. Bench, two runs per build (900 s Matroska at 8 Mbit/s, 4 s GOPs, one SRT track, shared link at 1.43 MB/s = 1.4x media rate, 5 far seeks 25 s apart): landings (median) 23.5 s / 23.7 s -> 12.7 s / 13.8 s source connections 35 -> 21 reader share of bytes ~47% -> ~24% Docs and CHANGELOG for the whole #240 change.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
On Matroska a subtitle-only side reader is a second full copy of the stream:
matroska_parse_clusterreads every block off the wire and onlymatroska_parse_blockthen honours the discard flag. A subtitled session therefore asks the link for roughly twice the media rate (2.6x measured in #220). Above about 2x headroom nobody notices. At 1.3x to 1.5x, an ordinary Wi-Fi bench in front of a high-bitrate remux, the two readers split the link and the video path misses its deadlines.That is #240. The reporter measured the same segment serving in 2.2 s alone and 7.5 s alongside the prefetcher, which expired the seek landing budget; the recovery re-anchored, which jumped the clock, which rebuilt the prefetch session, which took more link, and landings stacked to 25 s. The engine was starving itself in a loop it kept feeding.
Their own reading, that superseded readers keep fetching, does not hold: a producer restart stops and waits for the old pump and
markCloseds a demuxer it has to replace, the coalescer runs one restart at a time, and a prefetch re-anchor cancels the task andmarkCloseds its side demuxer. What was real is the contention around all of that.The rule
A strict priority, not a share. Playback is load-bearing, subtitle lookahead is not.
The grace window is deliberately time-bounded rather than lead-bounded. The first version of this used a lead floor ("fetch while less than 5 s ahead of the playhead") and the bench showed it doing nothing at all: on a link that cannot carry two readers the lead never rises, so the floor never expires. It kept 47% of the link and the landings did not move.
Also here: a playhead jump re-anchors the running prefetch session in place instead of tearing it down and building a new one. A rebuild pays a fresh open, the Matroska cue-index prewarm and a positioning seek, and since the bounded ranges of 5.24.0 each of those seeks takes a full range off the link, once per jump. Positioning is one shared function now, so an in-place move cannot drift from the session-start rules (#234).
Diagnostics
The reporter had to infer a second reader from connection lines that named none, and read one reader's generation counter as several concurrent connections. So: every
AVIOReadercarries a label (pump,prefetch,nativesubs,extract), the connection-start line names it and reports its range length and is no longerDEBUG-only, and the memprobe reportspumpFetchedMBnext toprefFetchedMBplus the prefetcher's cumulative yield.Measured
New bench affordances, both needed and both here:
aetherctl play --seek-pattern a,b,cwalks absolute far-seek targets and prints how long eachseek(to:)took (the existing 6 s backward hop lands in the segment cache and never restarts the producer), andthrottle-origin.py --sharedshapes the sum of all connections rather than each one (per-connection shaping gives two readers the full rate each, so this defect cannot reproduce under it).900 s Matroska, 8 Mbit/s, 4 s GOPs, one SRT track, shared link at 1.43 MB/s (1.4x media rate), five far seeks 25 s apart, two runs per build:
The remaining time is transfer geometry on a link that thin, not arbitration.
Verification
15 tests in
Issue240SideReaderLinkPriorityTests(policy table, gate counting across a restart, both loop paths, the valve and its grant window, in-place re-anchor, shared positioning). Full suite 1244 green,-strict-concurrency=completeclean, tvOS Simulator build green.Reported by @cmcpherson274 (#240). Retest still open, so no closing keyword.