refactor(spf): drop the vestigial reschedule retry-on-error path

The RecurringRunner propagates a rejected run as the recurrence's failure
(Promise.all short-circuits before any retry verdict lands), so the
retry-on-error affordance in delayedReschedule and mediaPlaylistReloadDelay
was dead code. Remove it: delayedReschedule awaits the run directly (a
rejection now rejects the reschedule), and mediaPlaylistReloadDelay takes a
non-optional current track. Transient-fetch-failure recovery belongs at the
fetch layer, not in the cadence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 09:59:25 -07:00
co-authored by Claude Opus 4.8
parent 2520373f2f
commit 5aa99674e9
4 changed files with 26 additions and 47 deletions
@@ -12,21 +12,17 @@ import type { Reschedule } from './task';
* RFC 8216 §6.3.4's "measured from the last time the client began loading"). If * RFC 8216 §6.3.4's "measured from the last time the client began loading"). If
* the run takes longer than the cadence, the next run starts immediately. * the run takes longer than the cadence, the next run starts immediately.
* *
* A `null` cadence stops the recurrence. An errored run passes `current` as * A `null` cadence stops the recurrence. A rejected run rejects this reschedule,
* `undefined`, so the cadence function can choose to retry (return a delay) or * which the `RecurringRunner` propagates as the recurrence's failure — error
* stop (return `null`). * recovery (e.g. retrying transient fetch failures) belongs below, at the fetch
* layer, not in the cadence.
*/ */
export function delayedReschedule<TValue>( export function delayedReschedule<TValue>(
cadence: (current: TValue | undefined, previous: TValue | undefined) => number | null cadence: (current: TValue, previous: TValue | undefined) => number | null
): Reschedule<TValue> { ): Reschedule<TValue> {
return async (task) => { return async (task) => {
const startedAt = Date.now(); const startedAt = Date.now();
let current: TValue | undefined; const current = await task.run();
try {
current = await task.run();
} catch {
current = undefined;
}
// `task.previous` is the prior successful value (carried by the runner's // `task.previous` is the prior successful value (carried by the runner's
// clone); read-only for the cadence, hence the cast off `DeepReadonly`. // clone); read-only for the cadence, hence the cast off `DeepReadonly`.
const ms = cadence(current, task.previous as TValue | undefined); const ms = cadence(current, task.previous as TValue | undefined);
@@ -82,26 +82,18 @@ describe('delayedReschedule', () => {
await expect(reschedule(fakeTask(async () => 1))).resolves.toBe(false); await expect(reschedule(fakeTask(async () => 1))).resolves.toBe(false);
}); });
it('passes undefined to the cadence when the run errors (so it can retry)', async () => { it('rejects (propagating the run failure) without consulting the cadence', async () => {
vi.useFakeTimers(); const cadence = vi.fn(() => 50);
const cadence = vi.fn(() => 50); // retry on error
const reschedule = delayedReschedule<number>(cadence); const reschedule = delayedReschedule<number>(cadence);
let resolved: boolean | undefined; await expect(
const done = reschedule( reschedule(
fakeTask(async () => { fakeTask(async () => {
throw new Error('boom'); throw new Error('boom');
}) })
).then((v) => { )
resolved = v; ).rejects.toThrow('boom');
}); expect(cadence).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(0);
expect(cadence).toHaveBeenCalledWith(undefined, undefined);
await vi.advanceTimersByTimeAsync(50);
await done;
expect(resolved).toBe(true);
}); });
it('rejects when aborted during the wait', async () => { it('rejects when aborted during the wait', async () => {
+8 -12
View File
@@ -8,34 +8,30 @@ function snapshotSignature(track: ResolvedTrack): string {
return `${getMediaPlaylistMetadata(track)?.mediaSequence ?? 0}:${track.segments.length}`; return `${getMediaPlaylistMetadata(track)?.mediaSequence ?? 0}:${track.segments.length}`;
} }
function targetDurationOf(track: ResolvedTrack | undefined): number { function targetDurationOf(track: ResolvedTrack): number {
return (track && getMediaPlaylistMetadata(track)?.targetDuration) || FALLBACK_TARGET_DURATION; return getMediaPlaylistMetadata(track)?.targetDuration || FALLBACK_TARGET_DURATION;
} }
/** /**
* Live media-playlist reload cadence, per RFC 8216bis §6.3.4 — a * Live media-playlist reload cadence, per RFC 8216bis §6.3.4 — a
* {@link RecurrencePolicy} for a `RecurringRunner` re-resolving the selected * {@link RecurrencePolicy} for a `RecurringRunner` re-resolving the selected
* track. Structurally matches `RecurrencePolicy<ResolvedTrack>` without * track. Structurally matches `RecurrencePolicy<ResolvedTrack>` without
* importing it (media stays core-free): `current` is the freshly resolved track * importing it (media stays core-free): `current` is the freshly resolved track,
* (`undefined` if the reload errored), `previous` the prior resolved snapshot. * `previous` the prior resolved snapshot.
* *
* - Complete playlist (VoD, or live that hit `#EXT-X-ENDLIST`) → `null`: stop. * - Complete playlist (VoD, or live that hit `#EXT-X-ENDLIST`) → `null`: stop.
* Keys off `Track.duration` (finite once complete), the single completeness * Keys off `Track.duration` (finite once complete), the single completeness
* source of truth. * source of truth.
* - Errored reload (`current` undefined) → retry at the last-known target-
* duration cadence (fallback when unknown), keeping the loop alive across
* transient fetch failures.
* - Unchanged window (same media sequence + segment count as `previous`) → poll * - Unchanged window (same media sequence + segment count as `previous`) → poll
* at half the target duration; a moved/grown window (or the first reload) → * at half the target duration; a moved/grown window (or the first reload) →
* full target duration. * full target duration.
* *
* A failed reload doesn't reach here — the rejection propagates through the
* `RecurringRunner`; transient-failure recovery belongs at the fetch layer.
*
* Returned delays are milliseconds. * Returned delays are milliseconds.
*/ */
export function mediaPlaylistReloadDelay( export function mediaPlaylistReloadDelay(current: ResolvedTrack, previous: ResolvedTrack | undefined): number | null {
current: ResolvedTrack | undefined,
previous: ResolvedTrack | undefined
): number | null {
if (!current) return targetDurationOf(previous) * 1000;
if (Number.isFinite(current.duration)) return null; if (Number.isFinite(current.duration)) return null;
const target = targetDurationOf(current); const target = targetDurationOf(current);
@@ -43,12 +43,7 @@ describe('mediaPlaylistReloadDelay', () => {
expect(mediaPlaylistReloadDelay(slid, prev)).toBe(4000); expect(mediaPlaylistReloadDelay(slid, prev)).toBe(4000);
}); });
it('retries at the last-known cadence when a reload errors (current undefined)', () => { it('falls back to 6s when the playlist carries no usable target duration', () => {
const prev = track({ targetDuration: 8 }); expect(mediaPlaylistReloadDelay(track({ targetDuration: 0 }), undefined)).toBe(6000);
expect(mediaPlaylistReloadDelay(undefined, prev)).toBe(8000);
});
it('falls back to 6s when no target duration / prior snapshot is available', () => {
expect(mediaPlaylistReloadDelay(undefined, undefined)).toBe(6000);
}); });
}); });