docs(spf): reconcile live-stream-support to the reactor guard

seek-to-live-edge is now a reactor (inactive ↔ live): entry does the one-time
seek, effects runs the window-exit guard (window-update re-fire + a single
`play` listener — `play`, not `playing`, since a slid-past playhead is at an
unseekable position where `playing` never fires). The repositionPolicy seam
was removed once the guard became reload-driven (nothing consumed it); the
live-edge-only use-case defines its own reposition behavior when built.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Pillsbury
2026-06-25 10:00:22 -07:00
co-authored by Claude Opus 4.8
parent c60fbb5606
commit fb4b6b05fb
@@ -26,8 +26,9 @@ without it, live HLS sources don't play correctly.
reload loop, sliding-window tracking, `Infinity` duration semantics,
live-edge seek + `setLiveSeekableRange`, and termination-on-`#EXT-X-ENDLIST`
all land. The **live-window playhead guard** (reposition-on-window-exit) is
now implemented in `seek-to-live-edge`. **Not yet implemented:** the edge-only
`on-resume` reposition policy (a future use-case). Termination is
implemented as a reactor in `seek-to-live-edge` (window-update re-fire + a
`play` listener). **Not yet implemented:** an edge-only live mode (a future
use-case). Termination is
`#EXT-X-ENDLIST`-based (the naive tier —
sufficient for conformant content); the miss-counter fallback and reload
jitter are deferred full-depth.
@@ -52,7 +53,7 @@ below is part of "live works (and terminates) at all"; richer live variants
| Sliding-window segment tracking | ✅ Implemented | `placeOnPreviousTimeline` (`parse-media-playlist.ts`) carries the new window onto the established timeline via media-sequence overlap (PDT bridge on full turnover). Segment-loader `planTasks` re-evaluates the mutating `track.segments` on each load dispatch; back-buffer keeps last 2 segments. No explicit "no-longer-in-playlist" eviction signal — the keep-count heuristic + list shrink handle roll-off |
| Live duration semantics | ✅ Implemented | Parser sets `Track.duration = Infinity` for unended live; `calculatePresentationDuration` (default resolver `getResolvedSelectedTrackDuration`) writes it; `updateMediaSourceDuration` propagates `mediaSource.duration = Infinity` once MS is open and buffers idle |
| Live edge tracking + `setLiveSeekableRange` | ✅ Implemented (option **b**) | The live window (or null for VOD/ended) is derived once by the pure `liveWindowFor` (`media/live-window.ts`), consumed by two separate behaviors: `sync-live-seekable-range.ts` declares `setLiveSeekableRange(start, end)` reactively on each window slide (runs while paused too), and `seek-to-live-edge.ts` does the one-time seek to `liveEdgeStart` (the live latency behind the edge, via the `getLiveEdge` primitive). Separate from the reload loop (option b). Inert for VOD via `liveWindowFor` returning null. No `clearLiveSeekableRange()` on termination — unnecessary: the UA consults the live range only while `duration === Infinity`, so once `endOfStream` sets a finite duration it's ignored |
| Live-window playhead guard | ✅ Implemented (`window-exit`) | While playing (`!paused && !seeking && readyState > 0`), reposition `currentTime` to `liveEdgeStart = max(windowStart, windowEnd liveLatency)` (the latency from the injected `resolveLiveLatency` seam — HLS `HOLD-BACK`, 3× target duration) when the playhead falls **outside** the sliding window — covering *paused-too-long* (Scenario A; fires on the `playing` resume) and *fell-behind-on-poor-network* (Scenario B; fires on the reload / window-update re-fire of the effect, since `timeupdate` stops during a stall). Within-window pause and scrub-back are left untouched (DVR model). Lives in `seek-to-live-edge` so the live playhead position has a single owner (the one-time initial seek + the ongoing guard); secondary triggers are `playing` / `timeupdate` / `seeked` listeners. Reposition policy is a seam (`repositionPolicy`, default `'window-exit'`); the edge-only `'on-resume'` branch is not yet implemented. **Deferred:** playback-rate latency catch-up, MSE gap-jumping |
| Live-window playhead guard | ✅ Implemented | `seek-to-live-edge` is a reactor (`inactive ↔ live`); the guard is the `live` state's `effects`. While playing (`!paused && !seeking`), it repositions `currentTime` to `liveEdgeStart` when the playhead has fallen behind the window start. Two triggers: the **window-update re-fire** (the effect reads the live edge, so each reload re-runs it — this catches *fell-behind-on-poor-network* (Scenario B), where `timeupdate` is silent during a stall) and a single **`play` listener** for immediate reposition on *resume* (Scenario A; the reload interval can be seconds) — `play`, not `playing`, since after a long pause the playhead sits behind the window at an unseekable position where `playing` never fires. Within-window pause / DVR scrub-back are left untouched. **Deferred:** playback-rate latency catch-up, MSE gap-jumping |
| Reload jitter / backoff | ✅ Naive only | Target-duration cadence with unchanged-window throttle (half target). No thundering-herd jitter, no backoff on repeated identical-playlist responses (full depth, not implemented) |
| Per-type reload coordination | ✅ Independent | Audio / video / text each own their `RecurringRunner` and reload on their own `#EXT-X-TARGETDURATION`. Resolved as **extended `resolveXTrack`** (same `setupTrackResolution` handles one-shot VOD and recurring live via the `RecurringRunner` abstraction) — no separate `reloadXTrack` family |
| Termination detection | ✅ Naive depth | `#EXT-X-ENDLIST` recognized and surfaced (`MediaPlaylistMetadata.endList`); parser flips `Track.duration` finite (on `ENDLIST` or `PLAYLIST-TYPE:VOD`), which stops the reload loop. ENDLIST-only is the sanctioned naive tier (per [clusters.md](./clusters.md#naive-vs-full-implementation-depth)) and sufficient for conformant content (Mux always emits `ENDLIST`); the miss-counter fallback for non-conformant servers is deferred full-depth |
@@ -62,10 +63,12 @@ below is part of "live works (and terminates) at all"; richer live variants
**Within this feature:**
- **Edge-only `on-resume` reposition policy** — the `repositionPolicy` seam
exists (default `'window-exit'`, implemented), but the `'on-resume'`
(always-snap-to-edge / no-DVR) branch is inert. It lands as the
`[live-edge-only-mode]` use-case, not here.
- **Edge-only live mode** — always snap to the live edge / no DVR (stop fetching
while paused, narrowed seekable, snap to edge on resume). Lands as the
`[live-edge-only-mode]` use-case, which introduces its own reposition behavior.
(An earlier `repositionPolicy` seam anticipating it was removed when the guard
became reload-driven — nothing consumed it, and "always-snap-on-resume" isn't
expressible without resume-detection the window-exit guard doesn't carry.)
**Naive depth (sufficient for conformant content; full depth deferred):**
@@ -85,36 +88,31 @@ below is part of "live works (and terminates) at all"; richer live variants
- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — growing
(non-sliding) window, back-seek through history. Extension with different
windowing semantics; shares the reload loop and the `setLiveSeekableRange`
writer shape. Sits at the maximal-back-seek end of the same seekable
spectrum the guard's `repositionPolicy` exposes (edge-only ↔ in-window
scrub-back ↔ full-history back-seek).
writer shape. Sits at the maximal-back-seek end of the seekable spectrum
(edge-only ↔ in-window scrub-back ↔ full-history back-seek).
- **[non-zero-pts-support](./non-zero-pts-support.md)** — live PTS advances
from stream start, far from zero; the time-mapping primitive live consumes is
a separate cluster B feature.
- **[buffer-stall-recovery](./buffer-stall-recovery.md)** — mid-stream stall
detection + unstick-in-place recovery (nudge → flush → reset). Coordinates
with this feature's planned
guard: an in-window stall is buffer-stall-recovery's territory; the
window-*exit* reposition is the live-specific terminal action.
with this feature's guard: an in-window stall is buffer-stall-recovery's
territory; the window-*exit* reposition is the live-specific terminal action.
## Likely cross-cutting impact
The foundation is implemented, so most prior cross-cutting concerns are now
realized. The remaining forward-looking impact is concentrated in the planned
guard:
realized. What remains is forward-looking:
- **`repositionPolicy` composition seam.** The guard's reposition condition is
a policy point: `'window-exit'` (default — DVR model; reposition only when
the playhead is outside the window, in-window scrub-back preserved) vs
`'on-resume'` (edge-only — always snap to the live edge on resume). Per
- **Edge-only mode is a future composition, not a runtime branch.** The guard
ships one behavior: window-exit (the DVR model reposition only when the
playhead is outside the window, in-window scrub-back preserved). The edge-only
variant (stop fetching while paused + always-snap on resume + narrowed
seekable) is a future **Composition** (alternative-default-config + add +
alternative-impl) tracked as the `[live-edge-only-mode]` use-case. Per
[clusters.md § Composition vs Policy vs middle pattern](./clusters.md#composition-vs-policy-vs-middle-pattern),
`'window-exit'` ships as the default config consumed by the guard behavior;
the **edge-only variant** (stop fetching segments while paused + always-snap
on resume + narrowed seekable) is a future **Composition**
(alternative-default-config + add + alternative-impl), tracked as a
`[live-edge-only-mode]` use-case rather than a runtime branch. Leaving the
seam in place costs only the policy indirection now and keeps the edge-only
mode a composition rather than a rewrite later.
it composes a variant rather than branching at runtime, and introduces its own
reposition behavior. (The `repositionPolicy` config seam that once anticipated
it was removed when the guard became reload-driven — see "What's not implemented".)
- **Single owner of the live playhead position.** The guard writes
`currentTime` (a seek). `seek-to-live-edge` already does the one-time initial
seek; folding the guard into it keeps one writer of "where the live playhead
@@ -124,9 +122,10 @@ guard:
plus the `currentTime` mirror.
- **Primary trigger is the window-update signal, not `timeupdate`.** Scenario
B's fall-behind happens *during a stall*, when `timeupdate` has stopped but
the window keeps sliding via reloads — so the guard must re-evaluate on the
reload / window-update signal. `playing` / `timeupdate` / `seeked` are
secondary triggers (resume case, prompt in-playback detection, overran-edge).
the window keeps sliding via reloads — so the guard re-evaluates on the
reload / window-update signal (the effect's tracked read of the live edge). A
single `play` listener is the secondary trigger, for immediate reposition on
resume (the reload interval can be seconds). `timeupdate` / `seeked` are unused.
## Implementation surface
@@ -137,7 +136,7 @@ guard:
| `liveWindowFor` *(pure helper)* | `media/live-window.ts` | Derive the live window `{start,end}` from the track with the given id (type-agnostic via `findTrackById`), or `null` (VOD/ended/unresolved). Purely geometric — no delivery-format metadata. Centralizes all inertness so consumers don't re-derive the window. |
| `liveWindowFromState` / `getLiveEdge` *(primitives)* | `playback/primitives/live-window.ts` | The state-reading call sites the live behaviors use. `liveWindowFromState` picks the timeline-bearing track — `selectedVideoTrackId ?? selectedAudioTrackId` (video positions both A/V; audio-only falls back to audio) — and calls `liveWindowFor`. `getLiveEdge({state,config})` adds the target playhead position (`liveEdgeStart = end live latency`, clamped to start), bundling window geometry with the format-specific `config.resolveLiveLatency` policy so the behavior consumes one edge. Reads signals lazily (call inside an effect). |
| `syncLiveSeekableRange` | `behaviors/dom/sync-live-seekable-range.ts` | Consume `liveWindowFromState`; `setLiveSeekableRange(start, end)` reactively on each window slide, including while paused. Duration is owned solely by `updateMediaSourceDuration`. Composed before `seekToLiveEdge`. |
| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | Consume `getLiveEdge`; one-time seek to `liveEdgeStart` + the live-window playhead guard (reposition-on-window-exit, `repositionPolicy` seam). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. Gates on `mediaSource` open so the seek lands in the declared range. |
| `seekToLiveEdge` | `behaviors/dom/seek-to-live-edge.ts` | A reactor (`inactive ↔ live`) consuming `getLiveEdge`. `live` `entry` does the one-time seek to `liveEdgeStart`; `live` `effects` runs the window-exit guard (window-update re-fire + `play` listener). Format-neutral — the live latency comes from the injected `resolveLiveLatency` seam, never read here. The `mediaSource`-open precondition orders the entry seek after `sync-live-seekable-range` declares the range, so the seek lands in-window. |
| `anchorLiveTracks` | `behaviors/anchor-live-tracks.ts` | Pin live track timelines to the SourceBuffer's native-PTS ground truth (first appended segment) or manifest estimate; re-pin per reload as the window slides |
| `resolveVideoTrack` / `resolveAudioTrack` / `resolveTextTrack` | `behaviors/resolve-track.ts` | Own the reload loop via `RecurringRunner`; reschedule defaults to `mediaPlaylistReloadDelay`; per-type independent |
| `calculatePresentationDuration` | `behaviors/calculate-presentation-duration.ts` | Populate `presentation.duration` via the config resolver (`Infinity` for unended live) |
@@ -155,8 +154,8 @@ for the `endOfStream` gate.
`seek-to-live-edge` / `endOfStream` — distinct decision domains, not a
multi-writer conflict). `presentation.streamType` (`'live'`). Per-track
`segments` (live-mutating; written by reload parse, read by segment-loader).
Live edge and the planned `liveEdgeStart` are **derived** from
`track.segments`, not state slots.
Live edge and `liveEdgeStart` are **derived** (via `getLiveEdge`) from
`track.segments` + the injected latency, not state slots.
**Engine composition:** `engines/hls/engine.ts` composes the live behaviors
unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`,
@@ -174,7 +173,6 @@ unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`,
| `resolveLiveLatency` | `seekToLiveEdge` config (engine-injected) | `liveLatencyFor` (HLS) | Format-neutral seam for the seconds the playhead trails the live edge. HLS injects `HOLD-BACK` (3× target duration); a DASH engine would inject `suggestedPresentationDelay`. Keeps `seek-to-live-edge` free of delivery-format specifics |
| `DEFAULT_FORWARD_BUFFER_CONFIG.bufferDuration` | `media/buffer/forward-buffer.ts` | `30` | Seconds ahead of playhead to load |
| `DEFAULT_BACK_BUFFER_CONFIG.keepSegments` | `media/buffer/back-buffer.ts` | `2` | Segments kept behind playhead |
| `repositionPolicy` | `seek-to-live-edge` config (behavior-scoped) | `'window-exit'` | Reposition condition seam. `'window-exit'` implemented; `'on-resume'` (edge-only) is inert pending the `[live-edge-only-mode]` use-case, which adds the public engine-config surface |
## Verification
@@ -204,13 +202,12 @@ unconditionally (`anchorLiveTracks`, `calculatePresentationDuration`,
**Live-window playhead guard**`behaviors/dom/tests/seek-to-live-edge.test.ts`
(`describe('live-window playhead guard')`, event-capable fake media element):
playing-inside-window → no seek; `currentTime < windowStart` (playing) → seek to
`liveEdgeStart`; `currentTime > windowEnd` (playing) → seek; paused-out-of-window
→ no seek, then seek on `playing` resume; DVR mid-window scrub-back → **no** yank;
`seeking` in flight → defer to `seeked`; sub-tolerance boundary → no jitter seek,
beyond-tolerance → seek; window slides past frozen paused playhead across reloads
→ no seek while paused, snaps in on resume; stalled-behind-window → repositions on
the window-update re-fire; `on-resume` policy → inert (not yet implemented).
playing-inside-window → no seek; behind-window-start on resume (`play`) → seek to
`liveEdgeStart`; paused while the window slides → no seek, then seek on `play`
resume; DVR mid-window scrub-back across a window update **no** yank; `seeking`
in flight → no seek, then seek once it settles; sub-tolerance boundary → no
jitter seek, beyond-tolerance → seek; stalled-behind-window (playing, frozen
`currentTime`) → repositions on the window-update re-fire.
**Out of scope / deferred:** E2E (Chromium) — a local synthetic sliding-window
stream (short target-duration) driving pause-beyond-window → resume-snaps-near-edge
@@ -231,13 +228,15 @@ covers the guard logic deterministically.
**Resolved during guard implementation:**
- **`repositionPolicy` seam shape** → behavior-scoped config on
`seek-to-live-edge` (default `'window-exit'`), **not** a public
`SimpleHlsEngineConfig` field. Avoids a speculative engine-config surface with
no current consumer (per `conventions/config.md`); the `[live-edge-only-mode]`
use-case adds the public field when it implements `'on-resume'`.
- **Guard placement** → extends `seek-to-live-edge` (single owner of the live
playhead position), not a sibling behavior.
- **`repositionPolicy` seam** → introduced as behavior-scoped config in the
initial guard, then **removed** when the guard became reload-driven
(window-update re-fire + `play` listener): nothing consumed it, and the
edge-only `'on-resume'` behavior isn't expressible without resume-detection the
window-exit guard doesn't carry. The `[live-edge-only-mode]` use-case defines
its own reposition behavior when built (per `conventions/config.md`
anti-speculative-config).
- **Guard placement** → the `live` state's `effects` in the `seek-to-live-edge`
reactor (single owner of the live playhead position), not a sibling behavior.
## Related features
@@ -245,11 +244,10 @@ covers the guard logic deterministically.
loop; adds blocking reload, partial segments, delta playlists, preload hints.
- **[dvr-event-stream-support](./dvr-event-stream-support.md)** — growing
window + full-history back-seek on the same reload loop; the maximal-back-seek
end of the seekable spectrum the guard's `repositionPolicy` sits on (edge-only
↔ in-window scrub-back ↔ full history).
end of the seekable spectrum (edge-only ↔ in-window scrub-back ↔ full history).
- **[buffer-stall-recovery](./buffer-stall-recovery.md)** — in-window stall
detection + unstick-in-place recovery; coordinates with this feature's
planned guard (window-exit reposition is the live-specific terminal action).
detection + unstick-in-place recovery; coordinates with this feature's guard
(window-exit reposition is the live-specific terminal action).
- **[non-zero-pts-support](./non-zero-pts-support.md)** — live PTS starts far
from zero; cluster B foundation live consumes for correct `currentTime` /
`seekable`.
@@ -263,14 +261,14 @@ covers the guard logic deterministically.
- **source-replacement** — orthogonal; live → live source change tears down and
rebuilds the reload loop via the same in-place cascade.
- **`[live-edge-only-mode]`** *(future use-case)* — the edge-only composition
the `repositionPolicy: 'on-resume'` seam enables.
(always snap to the live edge / no DVR); introduces its own reposition behavior.
## See also
- [clusters.md § Manifest reload loop](./clusters.md#manifest-reload-loop) —
cluster A description; this feature is the foundation.
- [clusters.md § Composition vs Policy vs middle pattern](./clusters.md#composition-vs-policy-vs-middle-pattern)
— the `repositionPolicy` seam / edge-only composition framing.
— the edge-only composition framing.
- [live-timeline-anchoring](../../../decisions/live-timeline-anchoring.md) — PDT
anchor that places the sliding-window timeline `anchorLiveTracks` consumes.
- [mse-timestamp-offset](../../../decisions/mse-timestamp-offset.md) — native-PTS