Skip to content

fix(subtitles): give the video path priority on the source link (#240) - #242

Merged
superuser404notfound merged 3 commits into
mainfrom
fix/issue240-side-reader-link-priority
Jul 28, 2026
Merged

fix(subtitles): give the video path priority on the source link (#240)#242
superuser404notfound merged 3 commits into
mainfrom
fix/issue240-side-reader-link-priority

Conversation

@superuser404notfound

Copy link
Copy Markdown
Owner

What

On Matroska a subtitle-only side reader is a second full copy of the stream: matroska_parse_cluster reads every block off the wire and only matroska_parse_block then 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 and markCloseds 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.

  1. A seek in flight owns the link. Nothing else may spend the landing budget.
  2. Inside a bounded grace window after anchoring (8 s), the side reader fetches anyway, so a freshly selected track is not left with an empty store on a busy link.
  3. An actively fetching pump wins.
  4. Otherwise the side reader fetches, which on a link with headroom is nearly always, since the pump parks as soon as its forward buffer is full.
  5. A 60 s continuous yield takes the link back for 10 s, so a pump that never parks (wedged, or a host that reports none) cannot silently disable lookahead for the session.

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 AVIOReader carries a label (pump, prefetch, nativesubs, extract), the connection-start line names it and reports its range length and is no longer DEBUG-only, and the memprobe reports pumpFetchedMB next to prefFetchedMB plus the prefetcher's cumulative yield.

Measured

New bench affordances, both needed and both here: aetherctl play --seek-pattern a,b,c walks absolute far-seek targets and prints how long each seek(to:) took (the existing 6 s backward hop lands in the segment cache and never restarts the producer), and throttle-origin.py --shared shapes 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:

before after
seek landing, median 23.5 s / 23.7 s 12.7 s / 13.8 s
source connections 35 21
side reader share of bytes ~47% ~24%

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=complete clean, tvOS Simulator build green.

Reported by @cmcpherson274 (#240). Retest still open, so no closing keyword.

#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.
@superuser404notfound
superuser404notfound merged commit 4553d8a into main Jul 28, 2026
3 checks passed
@superuser404notfound
superuser404notfound deleted the fix/issue240-side-reader-link-priority branch July 28, 2026 21:10
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.

1 participant