diff --git a/packages/spf/src/playback/behaviors/reload-track.ts b/packages/spf/src/playback/behaviors/reload-track.ts index 2bc1ccde..772f8f38 100644 --- a/packages/spf/src/playback/behaviors/reload-track.ts +++ b/packages/spf/src/playback/behaviors/reload-track.ts @@ -115,8 +115,8 @@ function setupTrackReload({ 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({ 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); } })(); diff --git a/packages/spf/src/playback/behaviors/tests/reload-track.test.ts b/packages/spf/src/playback/behaviors/tests/reload-track.test.ts index ba0590e7..51a6694c 100644 --- a/packages/spf/src/playback/behaviors/tests/reload-track.test.ts +++ b/packages/spf/src/playback/behaviors/tests/reload-track.test.ts @@ -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(makePresentation()), + selectedVideoTrackId: signal('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(); + } + }); +});