fix(spf): rescue a playhead stranded by an out-of-window seek

The window-exit guard in seekToLiveEdge bailed whenever
`mediaElement.seeking` was true, to avoid fighting an in-flight DVR
scrub-back. But a seek whose target sits behind the window start can
never complete — the data has slid out of the window and been evicted —
so `seeking` stays true forever and the playhead strands permanently,
the very stall the guard exists to rescue.

Drop the `seeking` bail. The `currentTime < windowStart` test is itself
the precise discriminator: an in-window scrub-back lands at
`currentTime >= windowStart` and never trips it, so only a stuck
out-of-window seek is repositioned to the live edge.

Verified live (Mux sliding-window LL-HLS): repeated seeks to the window
start now snap back to the edge within ~1.5s instead of stalling, while
mid-window DVR scrub-back still holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 11:38:28 -07:00
co-authored by Claude Opus 4.8
parent 6888b83abe
commit 88edc78cb2
2 changed files with 38 additions and 10 deletions
@@ -17,8 +17,10 @@
* `entry`, it fires once per entry into `live`; a source change exits to
* `inactive`, so the next source re-seeks (no closure latch to reset).
*
* Window-exit guard: while playing (not paused, not mid-seek), reposition to the
* live edge when the playhead has fallen behind the window start. Two triggers:
* Window-exit guard: while playing (not paused), reposition to the live edge
* when the playhead has fallen behind the window start — including when a seek
* to a now-evicted position has stranded the playhead (such a seek can never
* settle, so we rescue rather than wait on it). Two triggers:
* the **window-update re-fire** (the guard reads the live edge, so each reload /
* slide re-runs it — this catches a stall, where `timeupdate` stops but the
* playlist keeps reloading) and a **`play` listener** for immediate reactivity on
@@ -171,8 +173,16 @@ function seekToLiveEdgeSetup({
const { start: windowStart, liveEdgeStart } = getLiveEdge({ state, config })!;
const mediaElement = peek(context.mediaElement)!;
const reposition = () => {
// Don't yank a paused viewer or fight an in-flight seek (DVR scrub-back).
if (mediaElement.paused || mediaElement.seeking) return;
// Don't yank a paused viewer. We deliberately DON'T bail on
// `mediaElement.seeking`: a seek whose target sits behind the window
// start can never complete (the data has slid out of the window and
// been evicted), so `seeking` would stay true forever and strand the
// playhead — the very stall this guard exists to rescue. The
// `currentTime < windowStart` test is itself the precise
// discriminator: an in-window DVR scrub-back lands at
// `currentTime >= windowStart` and never trips it, so only a stuck
// out-of-window seek is repositioned.
if (mediaElement.paused) return;
if (mediaElement.currentTime < windowStart - REPOSITION_TOLERANCE) {
mediaElement.currentTime = liveEdgeStart;
}
@@ -300,17 +300,35 @@ describe('seekToLiveEdge', () => {
cleanup();
});
it('does not reposition while a seek is in progress; repositions once it settles', () => {
it('does not yank an in-flight seek to an in-window position (DVR scrub-back)', () => {
const { el, cleanup } = started();
el.paused = false;
el.currentTime = 90;
el.currentTime = 102; // scrubbing back to a valid position within [100, 110]
el.seeking = true;
el.dispatchEvent(new Event('play'));
expect(el.currentTime).toBe(90); // seek in flight → untouched
// In-window → never trips the `< windowStart` guard, so the seek proceeds
// untouched. The guard discriminates on position, not the `seeking` flag.
expect(el.currentTime).toBe(102);
cleanup();
});
el.seeking = false;
el.dispatchEvent(new Event('play'));
expect(el.currentTime).toBe(104);
it('rescues a seek stranded behind the window start, even while still seeking', async () => {
const { el, state, cleanup } = started();
el.paused = false;
// A scrub toward the back of the window lands on data that has since slid
// out and been evicted: the seek hangs (readyState drops, `seeking` stays
// true) and `currentTime` is frozen behind the window start.
el.seeking = true;
el.readyState = 1; // HAVE_METADATA — stalled, no data at the target
el.currentTime = 90;
// The window-update re-fire (a playlist reload — `timeupdate` is silent
// during the stall) catches it: 90 < new windowStart 200. Previously the
// `seeking` bail blocked this and the playhead stranded permanently.
state.presentation.set(makePresentation(200, 100));
await flush();
expect(el.currentTime).toBe(204); // snapped to the new live edge despite `seeking`
cleanup();
});