fix(spf): keep the live anchor sticky per source

The anchor reactor's monitor re-derived buffer ground truth on every reload, so a
transient loss of it — a buffer underrun, flush, or seek that momentarily empties
both SourceBuffers — flipped the reactor back to `unanchored` and cleared
`liveAnchor`. The anchor value itself is idempotent, but `seekToLiveEdge` gates
its `live` state on `liveAnchor` being set: clearing it drove `live → inactive →
live` and re-fired the one-time live-edge seek, jumping the playhead forward.

Make the anchor sticky per source: once `liveAnchor` is published, stay
`anchored` while the presentation stays resolved; only a source change (the
presentation reset to an unresolved value) reverts it. This matches the
"established once per source" intent in live-presentation-anchor.md.

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 3983195661
commit 6bbbc23961
2 changed files with 73 additions and 9 deletions
@@ -125,24 +125,32 @@ function anchorLiveTracksSetup<Context extends object>({
return createMachineReactor<AnchorFsmState>({
initial: 'unanchored',
// Re-checks buffer availability on each reload (the resolver is read untracked
// by the engine, so reloads — not buffer ticks — drive the transition). An
// unresolved presentation drops back to idle.
// Checks buffer availability on each reload until anchored (the resolver is
// read untracked by the engine, so reloads — not buffer ticks — drive the
// transition). An unresolved presentation drops back to idle.
monitor: () => {
const presentation = state.presentation.get();
if (!isResolvedPresentation(presentation)) return 'unanchored';
// Sticky per source: once the anchor is published, stay `anchored` for the
// lifetime of this resolved presentation. Only a source change — which
// resets the presentation to an unresolved value (handled above) — reverts.
// A *transient* loss of buffer ground truth (underrun, flush, seek) must NOT
// drop the established anchor: doing so re-opens the seekToLiveEdge gate and
// re-fires its one-time live-edge seek, jumping the playhead. "Pin-once"
// means pin once per source — see live-presentation-anchor.md.
if (state.liveAnchor.get() !== undefined) return 'anchored';
return isUndefined(deriveBufferAnchor(presentation)) ? 'unanchored' : 'anchored';
},
states: {
// Reset the published anchor per source so a new source re-gates the seek.
unanchored: { entry: () => state.liveAnchor.set(undefined) },
anchored: {
// Establish the shared anchor once and stamp it onto every track. Runs
// once per entry; a source change exits to `unanchored`, so the next
// source re-establishes. Re-deriving the same buffer anchor is idempotent
// (segment PDT and native-PTS start are stable), and
// `positionAllTracksToAnchor` writes no new reference when nothing moved —
// so a transient re-entry is a no-op.
// Establish the shared anchor once and stamp it onto every track. The
// sticky monitor keeps us `anchored` for the source once `liveAnchor` is
// published, so this runs exactly once per source; only a source change
// (exit to `unanchored`) re-arms it. (Were it to re-enter, re-deriving the
// same buffer anchor is idempotent and `positionAllTracksToAnchor` writes
// no new reference when nothing moved — so it would still be a no-op.)
entry: () => {
const presentation = state.presentation.get();
if (!isResolvedPresentation(presentation)) return;
@@ -148,6 +148,62 @@ describe('anchorLiveTracks', () => {
cleanup();
});
it('keeps the established anchor through a transient loss of buffer ground truth (sticky per source)', async () => {
let hasBuffer = true;
const { cleanup, state } = run({
presentation: makePresentation([makeVideoTrack()]),
config: {
resolveBufferedAnchor: () =>
hasBuffer ? { trackId: 'v-1', segmentId: 'segment-85', actualStart: 500 } : undefined,
},
});
expect(state.liveAnchor.get()).toBe(500);
// Buffer ground truth momentarily vanishes (underrun / flush / seek), then a
// reload fires. The established anchor must persist — dropping it re-opens the
// seekToLiveEdge gate and re-fires its one-time live-edge seek. Only a source
// change (unresolved presentation) reverts.
hasBuffer = false;
const carried = makeVideoTrack();
carried.startTime = 500;
carried.startDate = 500;
carried.segments = [{ ...carried.segments[0]!, startTime: 500, startDate: 1000 }];
state.presentation.set(makePresentation([carried]));
await flush();
await flush();
expect(state.liveAnchor.get()).toBe(500);
cleanup();
});
it('reverts the anchor on a source change, then re-establishes for the new source', async () => {
let actualStart = 500;
const { cleanup, state } = run({
presentation: makePresentation([makeVideoTrack()]),
config: { resolveBufferedAnchor: () => ({ trackId: 'v-1', segmentId: 'segment-85', actualStart }) },
});
expect(state.liveAnchor.get()).toBe(500);
// Source change → presentation reset to an unresolved value: the anchor clears
// so the new source re-gates the seek.
state.presentation.set({ url: 'https://example.com/new.m3u8' });
await flush();
await flush();
expect(state.liveAnchor.get()).toBeUndefined();
// New source resolves with its own buffer truth → re-establishes.
actualStart = 700;
state.presentation.set(makePresentation([makeVideoTrack()]));
await flush();
await flush();
expect(state.liveAnchor.get()).toBe(300); // video seg PDT 1000 actualStart 700
cleanup();
});
it('establishes once — a later reload is left to the parser (no re-establish even if buffer drifts)', async () => {
let actualStart = 500;
const { cleanup, state } = run({