From 6125c93216bf37d578b8d56b577abd6ace4730e7 Mon Sep 17 00:00:00 2001 From: Christian Pillsbury Date: Thu, 18 Jun 2026 07:20:23 -0700 Subject: [PATCH] refactor(spf): gate track reload on completeness, not a serviced-epoch counter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the resolve-track loader's fused reload gate (`isResolvedTrack(track) && epoch <= lastLoadedEpoch`) and its `let lastLoadedEpoch` closure-local with a pure `shouldResolveTrack` predicate keyed off `Track.duration` completeness: unresolved → load, resolved-but-incomplete → reload, resolved + complete → reuse. A switch to a resolved-but-incomplete live track now eagerly re-fetches (its window may have slid past the playhead), which is what makes the last-serviced memory unnecessary — every effect re-fire is a legitimate load, with in-flight dedup and the peeked presentation preventing redundancy. The reload-epoch slot becomes purely a re-fire ping: subscribed to, never compared. Completeness via `Number.isFinite(track.duration)` is the existing single source of truth, so a live stream that hits ENDLIST stops reloading with no engine- or stream-type config. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/playback/behaviors/resolve-track.ts | 63 +++++++++++-------- .../behaviors/tests/resolve-track.test.ts | 25 +++++++- 2 files changed, 61 insertions(+), 27 deletions(-) diff --git a/packages/spf/src/playback/behaviors/resolve-track.ts b/packages/spf/src/playback/behaviors/resolve-track.ts index 091fc9cc..88c8b661 100644 --- a/packages/spf/src/playback/behaviors/resolve-track.ts +++ b/packages/spf/src/playback/behaviors/resolve-track.ts @@ -26,12 +26,18 @@ import { AUDIO_TYPE_CONFIG, TEXT_TYPE_CONFIG, VIDEO_TYPE_CONFIG } from '../primi // [live-presentation-modeling.md](../../../internal/design/spf/live-presentation-modeling.md): // it produces the windowed segment list. *When* to (re)fetch is category [3] // "refetch policy", owned by the sibling `scheduleTrackReload` scheduler, which -// bumps a per-type reload-epoch slot. The loader loads when the track is -// unresolved (the initial resolve — the only trigger for VoD, where no -// scheduler is composed) OR when the reload epoch has advanced past the last -// one it serviced (a live reload). Setting the epoch synchronously at schedule -// time, paired with `ConcurrentRunner`'s id-dedup, coalesces bumps that arrive -// while a fetch is still in flight (drop-if-busy). +// bumps a per-type reload-epoch slot on a cadence. The loader treats that slot +// purely as a re-fire *ping* — it subscribes to the bump but never reads its +// value — and re-runs the gate. Whether a (re)load is actually due is decided +// by `shouldResolveTrack` against the current snapshot: +// - unresolved → load (initial resolve, or a retry of a failed one) +// - resolved, incomplete → reload (a live window may have slid past the +// playhead, so reusing it risks a stall) +// - resolved, complete → reuse (VoD, or live that hit ENDLIST — a complete +// playlist can never go stale) +// A same-id task already in flight is deduped by `ConcurrentRunner` +// (drop-if-busy), and the post-resolve presentation write is read with `peek`, +// so it never re-fires the effect. // ============================================================================ /** @@ -82,6 +88,20 @@ interface ResolveTrackConfig { getCdnId?: GetCdnId; } +/** + * Should the loader (re)resolve this track right now? Unresolved → yes (initial + * resolve, or a retry of a failed one). Resolved-but-incomplete (live, ongoing) + * → yes: the cached window may have slid past the playhead, so reuse risks a + * stall. Resolved and complete (VoD, or live that hit `#EXT-X-ENDLIST`) → no; a + * complete playlist can never go stale. Completeness keys off `Track.duration` + * (Infinity while the playlist can still grow), the single completeness source + * of truth — so a live stream that ends stops reloading the moment it goes + * finite, with no engine- or stream-type config. + */ +function shouldResolveTrack(track: PartiallyResolvedTrack | ResolvedTrack): boolean { + return !isResolvedTrack(track) || !Number.isFinite(track.duration); +} + function setupTrackResolution({ state, config: { selectedKey, reloadEpochKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText }, @@ -94,13 +114,6 @@ function setupTrackResolution expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true)); expect(fetchSpy).toHaveBeenCalledTimes(1); - // A scheduler bump re-fetches the (already-resolved) track. + // A scheduler bump re-fetches the (already-resolved, incomplete) track. state.videoReloadEpoch.set(1); await vi.waitFor(() => expect(fetchSpy).toHaveBeenCalledTimes(2)); - // A stale/duplicate bump (≤ last serviced) does not. + // Re-setting the same epoch value is a signal no-op (no re-fire), so the + // scheduler's monotonic bumps drive reloads one-for-one without the loader + // tracking a last-serviced epoch. state.videoReloadEpoch.set(1); await new Promise((resolve) => setTimeout(resolve, 20)); expect(fetchSpy).toHaveBeenCalledTimes(2); @@ -463,6 +465,25 @@ http://example.com/seg0.m4s`; reactor.destroy(); }); + it('does not reload a complete (VoD/ENDLIST) track on an epoch bump', async () => { + const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' }); + // ENDLIST → complete → finite duration → never reloads, even if a bump arrives. + const fetchSpy = vi + .spyOn(globalThis, 'fetch') + .mockImplementation(async () => new Response(`${LIVE_PLAYLIST}\n#EXT-X-ENDLIST`)); + + const reactor = resolveVideoTrack.setup({ state }); + + await vi.waitFor(() => expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + state.videoReloadEpoch.set(1); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + reactor.destroy(); + }); + it('retries an unresolved track on epoch bump after a transient failure', async () => { const state = makeState({ presentation: liveVideoPresentation(), selectedVideoTrackId: 'track-1' }); let calls = 0;