fix(spf): gate end-of-stream on playlist completeness so live duration stays Infinity

endOfStream's deriveState treated "the last currently-known segment is appended"
as "the stream ended", with no completeness check. For VoD that's correct, but
for live the last segment is only the rolling edge — so once it was appended the
behavior called mediaSource.endOfStream() and set duration to the buffered
(live-edge) end, the next reload's appends flipped the MediaSource back to
'open', and it re-fired on a loop. The visible symptom was mediaSource.duration
flipping from Infinity to a finite, growing value (and a stream that could stall
once the window slid past it).

Add a completeness guard: a track only reaches 'eos-ready' when its playlist is
complete (#EXT-X-ENDLIST) and its last segment is appended. Ongoing live (no
endList) stays inert, so duration remains Infinity; a live stream that genuinely
ends appends #EXT-X-ENDLIST, which opens the guard and ends it gracefully. Keys
off completeness, consistent with resolveSelectedTrackDuration and seekToLiveEdge.

Verified live playback through createSimpleHlsEngine: duration now holds at
Infinity. Full spf suite green (1073 passed); two new tests cover the
live-no-fire and graceful-endList-fire cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:23 -07:00
co-authored by Claude Opus 4.8
parent bfb5ef7aae
commit 54f126bd9f
2 changed files with 66 additions and 7 deletions
@@ -1,9 +1,11 @@
/**
* **Drive each `open → ended` transition of the MediaSource.** Calls
* `MediaSource.endOfStream()` once the temporally last segments of every
* active buffer actor's currently-loading track are fully appended and
* the user has reached them — letting the browser finalize duration and
* fire `ended` on the media element.
* `MediaSource.endOfStream()` once every active buffer actor's
* currently-loading track is *complete* (`#EXT-X-ENDLIST`), its temporally
* last segments are fully appended, and the user has reached them — letting
* the browser finalize duration and fire `ended` on the media element. The
* completeness gate keeps it inert for ongoing live, whose "last segment" is
* only the rolling edge; a live stream opts in by appending `#EXT-X-ENDLIST`.
*
* Re-fires on every subsequent `open → ended → open` cycle. Per the MSE
* spec, `appendBuffer()` after `endOfStream()` transitions the MediaSource
@@ -99,7 +101,7 @@ import { getMaxBufferedEnd, waitForSourceBuffersReady } from '../../../media/dom
import { isLastSegmentAppended } from '../../../media/dom/mse/end-of-stream';
import { onMediaSourceReadyStateChange } from '../../../media/dom/mse/mediasource-setup';
import type { MaybeResolvedPresentation } from '../../../media/types';
import { isResolvedTrack } from '../../../media/types';
import { getMediaPlaylistMetadata, isResolvedTrack } from '../../../media/types';
import { findTrackById } from '../../../media/utils/tracks';
import type { SourceBufferActor } from '../../actors/dom/source-buffer';
@@ -144,6 +146,13 @@ function deriveState(
const track = findTrackById(presentation, initTrackId);
if (!track || !isResolvedTrack(track)) return 'preconditions-unmet';
// Only a complete playlist (#EXT-X-ENDLIST) has a true last segment. For an
// ongoing live playlist the last segment is just the current rolling edge,
// so end-of-stream must not fire — calling `endOfStream()` there would pin a
// finite (live-edge) duration and end the stream, only to be reopened by the
// next reload's appends and re-fire on a loop. When a live stream genuinely
// ends it appends #EXT-X-ENDLIST, which opens this guard.
if (!getMediaPlaylistMetadata(track)?.endList) return 'preconditions-unmet';
if (!isLastSegmentAppended(track.segments, appended)) return 'preconditions-unmet';
if (track.segments.length > 0) {
@@ -1,7 +1,13 @@
import { describe, expect, it, vi } from 'vitest';
import type { ContextSignals, StateSignals } from '../../../../core/composition/create-composition';
import { signal } from '../../../../core/signals/primitives';
import type { MaybeResolvedPresentation, Presentation, Segment, VideoTrack } from '../../../../media/types';
import {
type MaybeResolvedPresentation,
MEDIA_PLAYLIST_METADATA_KEY,
type Presentation,
type Segment,
type VideoTrack,
} from '../../../../media/types';
import { createSourceBufferActor, type SourceBufferActor } from '../../../actors/dom/source-buffer';
import { type EndOfStreamContext, type EndOfStreamState, endOfStream } from '../end-of-stream';
@@ -110,7 +116,7 @@ function makeSegments(count: number): Segment[] {
}));
}
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrack {
function makeResolvedVideoTrack(segmentCount: number, id = 'video-1', endList = true): VideoTrack {
return {
id,
type: 'video' as const,
@@ -122,6 +128,7 @@ function makeResolvedVideoTrack(segmentCount: number, id = 'video-1'): VideoTrac
startTime: 0,
duration: segmentCount * 2.5,
codecs: 'avc1.64001f',
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList } },
} as unknown as VideoTrack;
}
@@ -309,6 +316,48 @@ describe('endOfStream', () => {
await cleanup();
});
it('does not call endOfStream() for a live (incomplete) playlist even with the last segment appended', async () => {
// Live: no #EXT-X-ENDLIST. The "last segment" is only the rolling edge, so
// appending it must NOT end the stream — doing so would pin a finite
// (live-edge) duration and reopen/re-fire on every reload.
const track = makeResolvedVideoTrack(2, 'video-1', false);
const mockMs = makeMediaSource();
const actor = makeActorWithSegments(['seg-0', 'seg-1']);
const { cleanup } = setupEndOfStream(
{ presentation: makePresentation(track) },
{ mediaSource: mockMs, videoBufferActor: actor }
);
await new Promise((resolve) => setTimeout(resolve, 30));
expect(mockMs.endOfStream).not.toHaveBeenCalled();
await cleanup();
});
it('calls endOfStream() once a live playlist appends #EXT-X-ENDLIST (graceful end)', async () => {
// A live stream that ends appends #EXT-X-ENDLIST; the guard then opens and
// end-of-stream fires normally.
const liveTrack = makeResolvedVideoTrack(2, 'video-1', false);
const mockMs = makeMediaSource();
const actor = makeActorWithSegments(['seg-0', 'seg-1']);
const { state, cleanup } = setupEndOfStream(
{ presentation: makePresentation(liveTrack) },
{ mediaSource: mockMs, videoBufferActor: actor }
);
await new Promise((resolve) => setTimeout(resolve, 20));
expect(mockMs.endOfStream).not.toHaveBeenCalled();
// Stream ends: a reload patches in the same window now marked complete.
state.presentation.set(makePresentation(makeResolvedVideoTrack(2, 'video-1', true)));
await vi.waitFor(() => {
expect(mockMs.endOfStream).toHaveBeenCalledTimes(1);
});
await cleanup();
});
it('calls endOfStream() only once while MediaSource stays ended', async () => {
const track = makeResolvedVideoTrack(4);
const mockMs = makeMediaSource();
@@ -409,6 +458,7 @@ describe('endOfStream', () => {
url: 'https://example.com/audio.m3u8',
mimeType: 'audio/mp4',
segments: makeSegments(4).map((s) => ({ ...s, id: `audio-${s.id}` })),
metadata: { [MEDIA_PLAYLIST_METADATA_KEY]: { mediaSequence: 0, targetDuration: 3, endList: true } },
} as unknown as VideoTrack;
const presentation = {
id: 'pres-1',