fix(spf): re-read track timeline after fetch to avoid clobbering the live anchor

A track reload read its `previous` (the prior window it carries the timeline
forward from) before the playlist fetch await, then parsed against that stale
snapshot after. When anchor-live-tracks established the shared live anchor during
the fetch — stamping every track's timeline via positionAllTracksToAnchor — the
in-flight reload wrote a window built on the pre-stamp (un-anchored) snapshot,
clobbering the stamp. Anchoring is pin-once, so the track was never re-corrected:
its model timeline sat seconds off the anchor and the loader stopped fetching it
near currentTime. Observed live on Mux LL-HLS as the selected video track
stranded ~hundreds of seconds off the anchor while its un-reloaded ABR-shell
siblings stayed anchored — video never buffered, playback stalled.

Re-read `previous` from a fresh peek after the fetch and parse against that. With
no yield between the re-read and the write, no concurrent writer can interleave,
so the stamp is carried forward instead of lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:46 -07:00
co-authored by Claude Opus 4.8
parent 0cb33bb532
commit 3983195661
2 changed files with 93 additions and 8 deletions
@@ -132,16 +132,27 @@ function setupTrackResolution<K extends SelectedTrackKey>({
// `fetchResolvableText` is the behavior's failover-decorated
// fetch: it trips the CDN on a failed fetch (network error or
// non-OK status). A parse failure is a content issue, not a
// CDN-availability one, so it doesn't trip.
// CDN-availability one, so it doesn't trip. The gate-time `track`
// supplies only the playlist URL (stable across the fetch).
const text = await fetchResolvableText(track, { signal });
const mediaTrack = parseMediaPlaylist(text, track);
// Updater handles undefined inputs by returning current
// unchanged; isResolvedPresentation narrows for the patch.
// State-exit on resolving→unresolved fires runner.abortAll
// before any URL change settles, and per the Fetch spec the
// signal abort cancels in-flight body reads — so by the
// time we reach this point the presentation we resolved
// Re-read `previous` *after* the fetch: a concurrent write during
// the await — notably anchor-live-tracks shifting this track onto
// the shared live anchor — must be carried forward, not clobbered.
// Parsing against the pre-fetch snapshot would strand the track
// off the anchor for good (anchoring is pin-once). Correctness
// rests on a run-to-completion invariant: NOTHING may yield
// (await) between this re-read and the write below, so no writer
// can interleave. `parseMediaPlaylist` is synchronous — keep it
// that way, or move the read into the updater.
const live = peek(state.presentation);
const previous = live ? findTrackToResolve(live, trackId) : undefined;
if (!previous) throw new Error('resolve-track: selected track not found');
const mediaTrack = parseMediaPlaylist(text, previous);
// State-exit on resolving→unresolved fires runner.abortAll before
// any URL change settles, and per the Fetch spec the signal abort
// cancels in-flight body reads — so the presentation we resolve
// against is the live one.
update(state.presentation, (current) => {
if (!isResolvedPresentation(current)) return current;
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
import type { StateSignals } from '../../../core/composition/create-composition';
import { signal } from '../../../core/signals/primitives';
import type { TaskLike } from '../../../core/tasks/task';
import { positionAllTracksToAnchor } from '../../../media/presentation-anchor';
import type {
MaybeResolvedPresentation,
PartiallyResolvedAudioTrack,
@@ -532,6 +533,79 @@ http://example.com/seg0.m4s`;
});
});
describe('resolveVideoTrack — concurrent anchor stamp during fetch', () => {
// Regression: anchor-live-tracks establishes the shared live anchor and stamps
// every track's timeline while a track resolution's playlist fetch is in
// flight. The resolution must parse against the track as stamped — not the
// pre-fetch snapshot — or it clobbers the stamp and strands the track off the
// anchor permanently (anchoring is pin-once). Observed live as video never
// buffering: its model timeline sat ~hundreds of seconds off currentTime.
it('honors a startDate stamped onto the shell mid-fetch (parses against the live snapshot)', async () => {
const unresolved: PartiallyResolvedVideoTrack = {
type: 'video',
id: 'track-1',
url: 'http://example.com/variant1.m3u8',
bandwidth: 1_000_000,
mimeType: 'video/mp4',
codecs: [],
};
const presentation: Presentation = {
id: 'pres-1',
url: 'http://example.com/playlist.m3u8',
selectionSets: [
{ id: 'video-set', type: 'video', switchingSets: [{ id: 'sw-1', type: 'video', tracks: [unresolved] }] },
],
startTime: 0,
};
const state = makeState({ presentation, selectedVideoTrackId: 'track-1' });
// Wall clock at media-time 0, 20s before the first segment's PDT — so an
// anchored first segment lands at startTime 20 and the track's startDate
// reads back as the anchor. Without the stamp, the track would resolve at
// local base 0 with startDate = the raw first-segment PDT instead.
const ANCHOR = 1_672_531_200;
const PLAYLIST = `#EXTM3U
#EXT-X-VERSION:7
#EXT-X-TARGETDURATION:4
#EXT-X-MEDIA-SEQUENCE:0
#EXT-X-MAP:URI="http://example.com/init.mp4"
#EXT-X-PROGRAM-DATE-TIME:2023-01-01T00:00:20.000Z
#EXTINF:4.0,
http://example.com/seg0.m4s`;
// Gate the fetch so the stamp lands while the request is in flight.
let releaseFetch!: () => void;
const inFlight = new Promise<void>((resolve) => {
releaseFetch = resolve;
});
let markStarted!: () => void;
const started = new Promise<void>((resolve) => {
markStarted = resolve;
});
vi.spyOn(globalThis, 'fetch').mockImplementation(async () => {
markStarted();
await inFlight;
return new Response(PLAYLIST);
});
const reactor = resolveVideoTrack.setup({ state });
await started;
// Establish + stamp the anchor mid-fetch, exactly as anchor-live-tracks does.
state.presentation.set(positionAllTracksToAnchor(state.presentation.get() as Presentation, ANCHOR));
releaseFetch();
await vi.waitFor(() => {
expect(isResolvedTrack(findTrackById(state.presentation.get()!, 'track-1')!)).toBe(true);
});
const resolved = findTrackById(state.presentation.get()!, 'track-1');
expect(resolved.startDate).toBe(ANCHOR);
reactor.destroy();
});
});
// Helper to find track by ID in presentation
function findTrackById(
presentation: MaybeResolvedPresentation,