mirror of
https://github.com/zoriya/v10.git
synced 2026-08-16 02:45:09 +00:00
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:
co-authored by
Claude Opus 4.8
parent
0cb33bb532
commit
3983195661
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user