Files
v10/internal/design/spf/decisions.md
T
2026-04-08 13:38:24 -07:00

17 KiB
Raw Blame History

status, date
status date
draft 2026-03-11

Design Decisions

This document reflects decisions made in the current SPF codebase — not settled long-term choices. The implementation is highly tentative and expected to undergo significant architectural change. The decisions recorded here capture lessons learned from the initial pass (source buffer creation order, end-of-stream gating, streaming response bodies, etc.) and are worth preserving as context, but many will be revisited as the underlying primitives and architecture evolve. See primitives.md for the forward-looking design and its open questions.

Rationale behind SPF's key choices.


Actor/Reactor Pattern (from text track spike)

These decisions were made or confirmed during the text track architecture spike (videojs/v10#1158). See text-track-architecture.md for the full reference implementation and assessment.


monitor-before-state ordering as a load-bearing guarantee

Decision: monitor effects in createMachineReactor always run before per-state effects. This ordering guarantee is documented in createMachineReactor's source and must be preserved.

Rationale: Per-state effects rely on invariants established by monitor functions. When a monitor function returns a new state, the framework calls transition() and the snapshot updates before any per-state effect fires — so per-state effects that no-op when snapshot.value !== expectedState do so correctly without needing to re-check conditions themselves.

Caveat: The guarantee is specific to createMachineReactor's registration order. It depends on the TC39 signal-polyfill's Watcher preserving insertion order in getPending() — not a formal guarantee of the TC39 Signals proposal.


deriveState pattern for transition logic

Decision: Transition conditions live in a pure deriveState function, wrapped in a computed() signal outside any effect body, consumed by the monitor field to drive transitions. The monitor function returns the target state; the framework handles the comparison and transition.

Rationale: Keeps the monitor function minimal and machine-readable; makes transition conditions independently testable as a plain function; prevents the inline computed anti-pattern (see actor-reactor-factories.md).


Actors in owners as the lifecycle contract

Decision: Actors created by a reactor are written to the shared owners signal. The engine's destroy() generically destroys any value in owners with a destroy() method. The reactor does not destroy its own actors.

Rationale: Keeps reactor cleanup simple — no tracking of which actors were created, no custom destroy logic. Gives the engine a single, uniform cleanup point. The tradeoff is an implicit contract: callers using a reactor outside the engine must destroy actors from owners before destroying the reactor.


Entry-reset as a defensive pattern for actor-creating states

Decision: States that create actors ('setting-up') and states that are reset points ('preconditions-unmet') both call teardownActors() on entry. teardownActors is a guarded no-op when actors are already undefined, preventing spurious signal writes.

Rationale: Any transition to a reset state may arrive from a state where actors were alive. Defensive teardown on both states eliminates the need to track "did I come from an actor-alive state?" — the entry effect is always safe to run.


Architecture

Reactor / Actor Separation

Decision: Feature files are split into thin reactors (observe state → send messages) and stateful actors (execute work, own context). Reactors contain no async logic; actors contain no subscription logic.

Alternatives:

  • Monolithic feature classes — common in traditional players; merge observation and execution in one class. Harder to test, harder to reason about what's in-flight.
  • Pure state machines — encode all transitions as state; no actors. Eliminates side effects but makes async work (fetch, SourceBuffer) awkward to represent.

Rationale: MSE operations have inherent ordering constraints (one appendBuffer at a time). Actors model this naturally as a serial queue. Reactors stay simple because they do nothing async — if the actor is busy, the message waits in the queue.


Actor Model for SourceBuffer

Decision: Each SourceBuffer is wrapped by a SourceBufferActor that serializes all operations through a SerialRunner. The actor owns a context snapshot (buffered segments, status) that other features can read synchronously.

Alternatives:

  • Direct SourceBuffer calls — simpler initially, but requires callers to gate on SourceBuffer.updating everywhere. Spreads the serialization concern across multiple features.
  • Promise chain — chain .then() calls on each operation. Loses the ability to inspect queue state or abort mid-chain.

Rationale: Centralizing serialization in the actor makes every caller simpler. The context snapshot — especially status: 'idle' | 'updating' and the segments list — is read by endOfStream and loadSegments without needing to query MSE directly.


Single Event Stream Across Features

Decision: All features in PlaybackEngine share one typed event stream. Each feature casts its events via @ts-expect-error to fit the union type.

Alternatives:

  • Per-feature event streams — cleaner types, but requires each feature to wire its own stream and increases object allocation.
  • No event stream — features communicate only through state patches. Loses the ability to fire point-in-time events (e.g., "segment appended") without polluting state.

Rationale: Shared stream simplifies the wiring in PlaybackEngine without meaningful runtime cost. The type cast is localized to one line per feature.


Segment Loading

Three-Case Load Planning

Decision: SegmentLoaderActor.planTasks() runs in exactly three passes: removes first, then init, then media segments. The order is fixed.

Alternatives:

  • Interleaved planning — decide removes and appends together. Harder to follow; removes must always precede appends for the same time range.
  • Single-pass with branching — one loop that handles all cases. Conflates concerns; harder to test each case independently.

Rationale: The three cases map cleanly to the three things that can happen at any segment boundary: clean up stale buffer, switch to a new track's init, load new content. Fixed order prevents ordering bugs.


In-Flight Preemption vs. Continuation

Decision: When a new load message arrives while work is in progress, the actor checks whether the in-flight task is still needed. If yes, it completes and queues remaining tasks (continue). If no, it aborts the in-flight task and replans (preempt).

Alternatives:

  • Always abort — simpler logic; always replan from scratch on any new message. Wastes work when the in-flight segment is still needed (e.g., minor currentTime advance).
  • Always complete — never abort in-flight work. Causes stale segments to be appended after a quality switch; requires later cleanup.

Rationale: Continue/preempt minimizes wasted network bytes while ensuring the buffer always reflects the current intent. Most currentTime advances continue; track switches preempt.


Init Segment Atomicity

Decision: Init segments use minChunkSize: Infinity — the full response body is accumulated before appending.

Alternatives:

  • Stream init segments — possible in theory, but init data must be complete before media segments can be decoded. A partial init append would likely cause a decode error.

Rationale: Init segments are small (typically < 1 KB). Atomicity avoids ordering issues with no meaningful cost.


Streaming Body for Media Segments

Decision: Video media segments are streamed incrementally via ChunkedStreamIterable (default 128 KB chunks). Audio media segments currently use minChunkSize: Infinity (atomic, equivalent to arrayBuffer()).

Alternatives:

  • Full arrayBuffer() for all segments — was the original approach; simpler abort semantics, no partial-segment state needed. But delays bandwidth sampling until the entire segment downloads and prevents mid-download abort.
  • Streaming for both video and audio — the intended final state. Audio segments are short; streaming them would complicate the code for minimal benefit, but it would enable consistent abort semantics.

Status: in flux. Audio streaming is pending. The current asymmetry (video streams, audio does not) is a migration step. See Open Questions.


Partial Segment Tracking

Decision: When the first chunk of a segment is appended, the actor marks it partial: true in its context. This flag is cleared when the final chunk appends.

Alternatives:

  • Optimistic completion — treat the segment as "done" once queued. Risks calling endOfStream while a segment is still in-flight.
  • Track by bytes — compare totalBytes to ContentLength. Requires reliable Content-Length headers (not guaranteed with HLS).

Rationale: partial is a simple boolean derived from the actor's own execution state — no external dependencies. endOfStream checks !partial for the last segment, preventing premature stream end.


ABR

EWMA Bandwidth Estimation

Decision: Bandwidth estimation uses a fast/slow EWMA pair. The exported estimate is the minimum of both.

Alternatives:

  • Simple moving average — easy to compute but slow to react to drops and prone to noise.
  • Percentile-based — more robust to outliers, but requires keeping a sample window in memory.
  • Single EWMA — one decay factor. Choosing fast vs. slow is a trade-off; the dual approach hedges.

Rationale: Conservative minimum of fast/slow is the standard approach (used in hls.js, Shaka, etc.). The fast EMA reacts quickly to drops; the slow EMA prevents overreaction to spikes. Taking the minimum biases toward caution, reducing stalls.

Zero-Factor Correction

Decision: Displayed bandwidth estimates apply estimate / (1 - α^totalWeight) to correct for EWMA initialization bias.

Context: A freshly created EWMA has a near-zero estimate even before any samples arrive, because the accumulated weight starts at zero. Raw values are misleading in the UI and can cause ABR to under-select on the first quality decision.

Rationale: This is a standard EWMA correction. Without it, the first quality selection is always the lowest rendition regardless of actual network conditions.


Upgrade Throttle

Decision: Quality upgrades are gated by minUpgradeInterval (default 8 s). Downgrades are immediate.

Alternatives:

  • Symmetric throttle — gate both upgrades and downgrades. But slow downgrades during network drops cause buffer stalls.
  • No throttle — react to every bandwidth sample. Causes oscillation when bandwidth fluctuates around a rendition threshold.

Rationale: Asymmetry matches the asymmetry in consequence: a missed upgrade just means slightly lower quality; a missed downgrade can cause a rebuffer. Immediate downgrades prevent stalls; gated upgrades prevent thrashing.


abrDisabled Flag

Decision: Setting state.abrDisabled = true prevents switchQuality from updating selectedVideoTrackId.

Alternatives:

  • Separate manualVideoTrackId field — explicit field for user-selected quality, with ABR writing to a different field. Cleaner separation; the player can show which track is "manually" vs. "automatically" selected.

Status: provisional. abrDisabled is a blunt instrument. The long-term design separates manualVideoTrackId from abrVideoTrackId so both can be tracked independently. See Open Questions.


MSE Coordination

SourceBuffer Creation Order

Decision: Both the video and audio SourceBuffer are created in the same synchronous execution context (step 5 in PlaybackEngine), even if only one is needed at first.

Context: Firefox has a bug where mozHasAudio remains false if the video SourceBuffer is created before the audio SourceBuffer in a different task. This causes Firefox to believe the stream has no audio and mute/skip it.

Alternatives:

  • Lazy creation — create each buffer only when the first segment for that track is ready. Cleaner conceptually but triggers the Firefox bug.

Rationale: Creating both buffers together is a workaround for a browser bug. The cost (one extra SourceBuffer created slightly early) is negligible. The fix is permanent until Firefox patches the underlying bug.


endOfStream Actor-Idle Gate

Decision: endOfStream waits for both SourceBufferActor instances to report status: 'idle' before calling mediaSource.endOfStream().

Context: MediaSource.endOfStream() must not be called while any SourceBuffer.updating is true — it throws a DOMException. The actors expose their status synchronously via their snapshot.

Alternatives:

  • Poll SourceBuffer.updating directly — bypasses the actor abstraction. Introduces a direct DOM dependency in a feature that otherwise reads only from actor snapshots.
  • setTimeout/rAF delay — unreliable; race condition if the buffer update finishes after the timer fires.

Rationale: Actor idle is the correct signal: it means all queued tasks have completed, not just the currently executing one. Subscribing to actor snapshots is instantaneous — no polling.


remove() Re-opens MediaSource

Decision: Code that could call endOfStream must guard against spurious re-entry when SourceBuffer.remove() is called near end-of-stream.

Context: Calling SourceBuffer.remove() (and appendBuffer()) automatically transitions a 'ended' MediaSource back to 'open'. If endOfStream is watching actor state and immediately re-fires when actors go idle, a remove() operation after endOfStream() creates an infinite loop.

Implementation: endOfStream checks currentTime >= lastSegment.startTime before firing. Back-buffer cleanup removes segments behind currentTime, so this guard ensures we don't re-end after cleaning up the back buffer near end-of-stream.


State Management

Batched patch() with Explicit flush()

Decision: state.patch() defers updates via queueMicrotask. state.flush() is provided for cases where subscribers must react synchronously.

Alternatives:

  • Synchronous patch() — subscribers fire immediately on every patch. Risks re-entrant subscription loops and makes it impossible to batch multiple simultaneous updates.
  • Manual batching only — no automatic deferral; callers always batch explicitly. More control but more boilerplate.

Rationale: Automatic batching eliminates most accidental re-entrancy. flush() is the escape hatch for the cases (ABR bandwidth sampling) where timing matters.


Bandwidth Bridge

Decision: loadSegments maintains local throughput state per track and syncs it to state.bandwidthState after each sample.

Context: This is a migration artifact. The long-term design has ABR read directly from a reactive throughput source rather than going through the global state. The bridge exists to decouple the refactor from the feature work.

Status: temporary. Remove once ABR reads from throughput directly. See Open Questions.


Open Questions

Audio Streaming

Audio segments currently use minChunkSize: Infinity (full atomic download). This prevents mid-download abort and delays bandwidth sampling for audio fetches.

Options:

  • Stream audio like video (consistent abort semantics, better sampling)
  • Keep atomic (audio segments are short; streaming buys little)

Open: Streaming audio would eliminate the asymmetry. The main blocker is that streaming requires partial-segment tracking, which is already in place. Worth revisiting once the video streaming path stabilizes.


ABR Track Fields

abrDisabled is a boolean that suppresses all ABR. The desired model separates:

  • abrVideoTrackId — the track ABR would choose
  • manualVideoTrackId — the track the user explicitly selected

This lets the UI show "currently manual at 720p, ABR would choose 1080p" without having two separate play modes.

Open: Needs a state shape decision and migration path from abrDisabled.


ABR Throughput Direct Read

The bandwidth bridge (loadSegmentsstate.bandwidthStateswitchQuality) introduces a round-trip through global state. ABR should eventually read from a reactive throughput source owned by the network layer, removing the bridge.

Open: Requires defining the throughput API in core/ and wiring it through dom/.


SegmentLoaderActor / LoadTask Naming

The current naming (LoadTask, related internals) is provisional. Better candidates: SegmentLoaderOp, LoadOp, or SegmentFetchTask.

Open: Rename before public API stabilizes.


endOfStream Subscription Structure

The endOfStream feature currently subscribes to both actor snapshots and state separately, leading to some duplication in the condition checks. A cleaner approach would combine actor + state into a single derived selector.

Open: Refactor once the actor snapshot API stabilizes.