Skip to content

The no-cut watchdog is inline in the loop it watches, so a starved origin leaves it unable to decide rather than late #406

Description

@superuser404notfound

Split out of #405, where it was the fourth of four findings and the only one I measured instead of
building. The other three shipped in 6.36.0 and are confirmed by the reporter against a shipping
client; this one is a separate round because it moves a decision off the read thread.

The defect

HLSSegmentProducer's no-cut stall watchdog is evaluated inline in the read loop it watches. It
ticks between av_read_frame calls, and av_read_frame does not return until a whole packet is
assembled. There is no interrupt_callback on the format context, so that call has no upper bound
at all. An origin too slow to complete a packet inside the watchdog window does not make the
watchdog late, it makes it unable to run.

Structurally the same defect #309 fixed on the reader side, where the precondition that had to go
was "a consumer must be blocked on it".

Measurement

Loopback trickle origin, 300 s MPEG-TS fixture: 200 KB/s, then 100 bytes once per second for 45 s
on the connection it already holds
, then normal. One connection for the whole run, Range: bytes=0-, no reconnects, which is the shape of the reporter's trace (gen=1->1, reconnects=0,
every wait signalled with nothing to show for it).

Arm A, 6.36.0 as shipped:

[AVIOReader] slow read: 46874ms at offset=4194304 stallWaits=71(46874ms,71signaled) tailWaits=0 reconnects=0 backoff=0ms iters=143 gen=1->1
[HLSSegmentProducer] no-cut stall: no segment finalized for 48s (packetsRead=1473, sinceFinalize=110, rate=2.3pkt/s, source starvation); videoPtsAdvance=1.6s; exiting for host retune

Those two lines are 30 ms apart. liveSourceStarvationTimeoutSeconds is 35 s and it fired at
48 s: the 13 s of overrun are exactly the time the read was blocked.

Arm B, short read. readPersistent patched to return what is in hand rather than hold out for
requestSize (live only, totalRead > 0). The slow-read line disappeared, so the patch did its
job, and the watchdog fired at 47 s. One second. The short read moves the block up one layer, it
does not remove it. Not the lever, do not re-attempt it as one.

Fix shape

Lift the watchdog onto a timer that evaluates the existing classifier (noCutStallAction) off the
read thread, and abort the parked read when it decides .exitForRetune.

The abort does not need building. Demuxer.markClosed() already does exactly this: the AVIO read
callback returns -1 and av_read_frame returns at once, no resources freed. It is what the #79
reopen path uses to unblock a wedged read, and HLSVideoEngine's session teardown already pairs it
with the producer's own stop(), because a cancel flag alone cannot reach a thread parked in a
read. The no-cut watchdog is the one decision on that path that sits inside the loop with no such
pairing, and its exit is a teardown anyway (exitReason = .segmentStall, break, host retune), so it
has nothing to preserve across the abort.

What makes it its own round

  • The Three live-path fixes from the #168 investigation (high-bitrate live HLS on tvOS) #177 slow-delivery hold re-arms the window from the read thread, and its anchor
    (noCutHoldRearmedAt, consecutiveNoCutHolds) plus the per-window packet counters are locals of
    the read loop
    . Moving the decision off that thread means moving that state under stateLock,
    and the read thread's re-arm then races the timer's evaluation. The hold exists so a slow but
    live source is not retuned, so getting that race wrong turns a hold into a retune.
  • The timer must not fire against a producer that has not started its pump or is being stopped, and
    it stays live-only.

Explicitly out of scope

Reported and traced by @tschuegy in #405.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions