← All reports

Twitch.tv may stall at ad transition during live streaming

Component: WebCore Media Source Extensions | 93e7a14

MSE Coded Frame Processing is the normatively specified algorithm determining how incoming encoded frames interact with already-buffered content; steps 1.14/1.15 are its overlap-removal phase, erasing buffered content that a new frame's presentation range overlaps. B-frames (bidirectional predictive) have PTS > DTS because they are decoded before display, and at the tail of an fMP4 segment the last-in-decode-order sample's trun.sample_duration is a decode-grid placeholder rather than a real presentation duration.

LayoutTests/media/media-source/media-source-append-b-frame-tail-overshoot.html

+// Segment 2 triggers the B-frame tail path:
+// I-frame: dts=0 pts=0 dur=50 isSync=true
+// B-frame: dts=50 pts=80 dur=30 isSync=false => frameEnd=110
+// overshoots existing content sync (pts=100) by 10 ms.
+segment2 = MP4.mediaSegment({
+ sequenceNumber: 2,
+ tracks: [{ id: 1, type: 'video', baseDecodeTime: 0,
+ samples: [
+ { duration: 50, compositionTimeOffset: 0, isSync: true },
+ { duration: 30, compositionTimeOffset: 30, isSync: false },
+ ],
+ }],
+});
+// Without fix: buffered.end(0) == 0.110 (content sync erased → gap)
+// With fix: buffered.end(0) == 0.200 (content sync shifted to pts=110, preserved)

Because that tail duration is a placeholder, frameEndTime = pts + duration can slightly exceed the next buffered sample's PTS without representing a true editorial overlap — and the spec-literal removal then erases legitimate content, producing a buffered-range gap that stalls playback at Twitch ad splice points. The fix adds a per-track "B-frame tail + within-timeFudgeFactor" heuristic that redirects to a forward-shift path instead, introducing MediaSample::createCopyWithAdjustedStartTime, TrackBuffer::adjustSampleStartTime, and SampleMap::replaceSample to shift the overlapped sample forward by the overshoot. Three coupled structures mutate together: the SampleMap's presentation-order submap, its decode-order submap, and TrackBuffer::m_decodeQueue.

fMP4 tail B-frame: dts=50 pts=80 dur=30  =>  frameEnd=110
Next buffered content sync: pts=100

BEFORE (spec-literal 1.14):
  Erase range [50, 110) from buffered
    content sync @ pts=100 ∈ [50,110)  →  REMOVED
    step 1.15 cascades dependents
    buffered: [0, 0.110)   ← gap, playback stalls

AFTER (new heuristic path):
  overshoot = frameEnd(110) − nextSample.pts(100) = 10 ms
  10ms < timeFudgeFactor()  AND  B-frame tail?
    YES  →  adjustSampleStartTime: shift sync pts 100→110
              SampleMap::replaceSample (erase+hint-insert, both submaps)
              m_decodeQueue entry updated to match
    1.14 erase range [50,110) no longer contains the sync
    buffered: [0, 0.200)   ← continuous, no stall

The fix adds timing-mutation code paths that atomically rewrite three coupled MSE bookkeeping structures — both SampleMap submaps and the decode queue — on a path driven directly by attacker-supplied fMP4 bytes. Live-stream stalls at ad splice points are resolved, at the cost of new state and lifetime surface in tightly coupled sample bookkeeping.

Narrow: SampleMap::replaceSample performs erase+hint-insert in both the presentation-order and decode-order submaps without validating that the adjusted sample's key is strictly ordered relative to its neighbors. A zero-duration sample clamping the offset to zero makes the "new" key identical to the old, so the erase+insert is a no-op while m_buffered is still mutated — the two structures desynchronize. The review tell is an erase-then-insert pair where the new key is derived arithmetically from the old without an ordering assertion.

Wider: range bookkeeping symmetry. TrackBuffer::adjustSampleStartTime subtracts the original sample's [pts, presentationEndTime) from m_buffered and re-adds the adjusted range. If the original range is absent at call time — the sample not yet fully committed, or a concurrent step-1.15 cascade already having removed it — the subtraction is a no-op and the adjusted range is inserted without a matching removal, producing a spurious buffered range that shifts the media element's seekable range. Audit every other m_buffered mutation in TrackBuffer for the same subtract-then-add shape and check whether each verifies the subtraction actually removed something. Adjacent: the per-track isPresentationTail flag is computed in processPendingMediaSamples before the append loop, so multi-track segments with interleaved audio/video samples could flag the wrong sample and let the shift path fire on a non-tail video sample the spec mandates be removed.

Widest: platform/engine split-brain. MediaSampleAVFObjC::createCopyWithAdjustedStartTime uses CMSampleBufferCreateCopyWithNewTiming and applies the offset to each sub-sample of a multi-sample CMSampleBuffer. If any adjusted start time goes negative or a sub-sample duration underflows, AVFoundation may accept the buffer while WebCore's TimeRanges bookkeeping computes a different range — the platform decoder and the MSE buffered attribute then disagree. This generalizes to every place WebCore mirrors platform-media timing state; wherever both sides compute a range independently from the same mutation, the divergence case needs a test. Fuzzing crafted fMP4 segments that hit the B-frame tail path across changeType, appendWindowStart, and timestampOffset boundaries is the most productive entry point.