fix(spf): keep the live reload loop alive across transient fetch failures

The reload loop's try/catch wrapped the while, so a single fetch/parse failure
(e.g. a transient "TypeError: Failed to fetch", a CDN blip) ended the loop —
the live playlist would stop refreshing and playback would eventually stall at
the last-known window. Move the try inside the loop: on a non-abort error, log
and retry on the next cadence; only abort (source change / destroy) exits.

Adds a fake-timer test asserting the loop retries after a rejected fetch and
resolves on the next attempt. Verified against a live Mux CMAF stream (window
keeps advancing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:22 -07:00
co-authored by Claude Opus 4.8
parent b4c7db2653
commit fb5e6cbf95
2 changed files with 50 additions and 6 deletions
@@ -115,8 +115,8 @@ function setupTrackReload<K extends SelectedTrackKey>({
const trackId = state[selectedKey].get()!;
void (async () => {
try {
while (!ac.signal.aborted) {
while (!ac.signal.aborted) {
try {
const presentation = peek(state.presentation);
if (!isResolvedPresentation(presentation)) break;
// The track currently in the presentation is the prior snapshot
@@ -143,11 +143,19 @@ function setupTrackReload<K extends SelectedTrackKey>({
const target = meta?.targetDuration || FALLBACK_TARGET_DURATION;
// Spec: reload ~target duration; half that when the playlist was unchanged.
await sleep((changed ? target : target / 2) * 1000, ac.signal);
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
// A transient fetch/parse failure must not kill the loop — a live
// playlist has to keep refreshing. Log and retry on the next
// cadence (the `while` re-checks `aborted`).
// TODO(error-management): route to a state-error slot once one exists.
console.error(`[reload:${type}] media-playlist reload failed; retrying:`, error);
try {
await sleep(FALLBACK_TARGET_DURATION * 1000, ac.signal);
} catch {
return; // aborted during the retry wait
}
}
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') return;
// TODO(error-management): route to a state-error slot once one exists.
console.error(`[reload:${type}] media-playlist reload failed:`, error);
}
})();
@@ -120,3 +120,39 @@ describe('reloadAudioTrack', () => {
reactor.destroy();
});
});
describe('reload resilience', () => {
it('survives a transient fetch failure and retries (does not kill the loop)', async () => {
vi.useFakeTimers();
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const state = {
presentation: signal<MaybeResolvedPresentation | undefined>(makePresentation()),
selectedVideoTrackId: signal<string | undefined>('v-1'),
};
let calls = 0;
const fetchResolvableText = vi.fn(() => {
calls += 1;
return calls === 1 ? Promise.reject(new TypeError('Failed to fetch')) : Promise.resolve(MEDIA_PLAYLIST);
});
const reactor = reloadVideoTrack.setup({ state, config: { fetchResolvableText } });
// First attempt rejects: logged, but the loop is still alive (track not yet resolved).
await vi.advanceTimersByTimeAsync(0);
expect(calls).toBe(1);
expect(errorSpy).toHaveBeenCalled();
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(false);
// Retry cadence elapses → second attempt succeeds (would never happen if the
// loop had died on the first failure).
await vi.advanceTimersByTimeAsync(6000);
expect(calls).toBe(2);
expect(isResolvedTrack(findTrack(state.presentation.get()!, 'video', 'v-1')!)).toBe(true);
reactor.destroy();
} finally {
vi.useRealTimers();
}
});
});