refactor(spf): gate track reload on completeness, not a serviced-epoch counter

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) <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 e5d98649ff
commit 6125c93216
2 changed files with 61 additions and 27 deletions
@@ -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<K extends SelectedTrackKey, E extends ReloadEpochKey>({
state,
config: { selectedKey, reloadEpochKey, findTrackToResolve, fetchResolvableText = defaultFetchResolvableText },
@@ -94,13 +114,6 @@ function setupTrackResolution<K extends SelectedTrackKey, E extends ReloadEpochK
// to e.g. run concurrently (like we currently are), serially with a queue, or abort the previous task and replace it with the newly scheduled one. (CJP).
const runner = new ConcurrentRunner();
// Highest reload epoch already serviced for the selected track. A resolved
// track only re-fetches once the scheduler bumps past this; an unresolved
// track always loads (the initial resolve / a retry of a failed one). Init 0
// so a freshly-mounted already-resolved track isn't re-fetched (matches the
// pre-reload one-shot resolve behavior).
let lastLoadedEpoch = 0;
// Reactor states model the FSM the previous effect-based body was
// hand-rolling. 'presentation-resolved' is entered when the
// presentation is fully parsed (has a Ham id + selectionSets); leaving
@@ -135,23 +148,23 @@ function setupTrackResolution<K extends SelectedTrackKey, E extends ReloadEpochK
// changes (presentation-resolved ↔ presentation-unresolved);
// within 'presentation-resolved' we peek (untracked read) so
// internal updates (segments added by sibling tasks) don't
// re-fire the effect. Tracked reads — `selectedKey` and the reload
// epoch — are taken up front, before any early return, so a later
// epoch bump re-fires this effect.
// re-fire the effect. The tracked reads — `selectedKey` and the
// reload-epoch ping — are taken up front, before any early return,
// so a selection change or a scheduler bump re-fires this effect.
const trackId = state[selectedKey].get();
const epoch = state[reloadEpochKey].get() ?? 0;
// Subscribe to the scheduler's reload-epoch slot purely to re-fire
// on each bump — the value is unused; `shouldResolveTrack` decides
// from the snapshot, not the epoch. (A signal used as an event
// channel; see the header comment.)
state[reloadEpochKey].get();
const presentation = peek(state.presentation);
if (!presentation || !trackId) return;
const track = findTrackToResolve(presentation, trackId);
if (!track) return;
// Resolved track: skip unless the scheduler requested a reload.
// Unresolved track: always load (initial resolve / failed-resolve retry).
if (isResolvedTrack(track) && epoch <= lastLoadedEpoch) return;
// Mark this epoch serviced synchronously: a same-id task already in
// flight is deduped by the runner, and we don't want to re-schedule
// it on the next effect run.
lastLoadedEpoch = epoch;
// Unresolved → initial resolve / retry; resolved-but-incomplete →
// live reload; resolved + complete → reuse. A same-id task already
// in flight is deduped by the runner (drop-if-busy).
if (!track || !shouldResolveTrack(track)) return;
runner.schedule(
// NOTE: This can/maybe will be pulled into a per-use case factory (e.g. something like createResolveTrackTask(track, context, config)),
@@ -451,11 +451,13 @@ http://example.com/seg0.m4s`;
await vi.waitFor(() => 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;