diff --git a/.claude/plans/spf/actor-migration-assessment.md b/.claude/plans/spf/actor-migration-assessment.md new file mode 100644 index 000000000..6cad806ef --- /dev/null +++ b/.claude/plans/spf/actor-migration-assessment.md @@ -0,0 +1,68 @@ +# Actor Migration Assessment + +Assessment of migrating SPF's segment-related actors to the `createMachineActor` factory. +Written after completing the text track Actor/Reactor spike. **Partially stale** — see +inline notes for what has been completed. + +--- + +## Background + +The text track spike produced reference implementations using three actor patterns: + +- `TextTracksActor` — uses `createTransitionActor` (reducer-style, no FSM) +- `TextTrackSegmentLoaderActor` — manual `CallbackActor` (lightweight, no framework) +- Reactors (`syncTextTracks`, `loadTextTrackCues`) — use `createMachineReactor` + +The segment-loading layer had three actors to consider: +`SegmentLoaderActor`, `SourceBufferActor`, and `loadSegments` (Reactor). + +--- + +## SegmentLoaderActor ✅ (Completed) + +**File:** `packages/spf/src/dom/features/segment-loader-actor.ts` + +Migrated to `createMachineActor` with `idle`/`loading` states, `onSettled: 'idle'`, +and continue/preempt logic. The continue case uses `SerialRunner.abortPending()` (added +during migration) — drops queued tasks without touching the in-flight task. In-flight +tracking (`inFlightInitTrackId`, `inFlightSegmentId`) lives in actor context via +`setContext`/`getContext`. + +--- + +## SourceBufferActor ✅ (Completed) + +**File:** `packages/spf/src/dom/media/source-buffer-actor.ts` + +Migrated to `createMachineActor` with `idle`/`updating` states, `onSettled: 'idle'`, +and a `cancel` message in the `updating` state. The four originally-identified blockers +were resolved: + +1. **Awaitable send** — `SegmentLoaderActor` now observes completion via + `waitForIdle(snapshot, signal)` rather than awaiting `send()` directly. +2. **Context as task output** — tasks return new context; `.then(setContext)` commits it. + `getContext` threading ensures each task reads the context committed by the previous task. +3. **`batch()`** — implemented as a message type; handler iterates and schedules all tasks. +4. **Partial updates** — `setContext()` called mid-task for streaming segment progress. + +--- + +## loadSegments (Reactor) + +**File:** `packages/spf/src/dom/features/load-segments.ts` + +Currently a function with signals/effects inline — it is the Reactor layer that +bridges state → `SegmentLoaderActor` messages. Not itself an actor. + +Could be rewritten as a class-based Reactor if a `createMachineReactor` pattern is established +(per the primitives.md design). Lower priority than the actor migrations; natural +follow-on after `SegmentLoaderActor` is migrated. + +--- + +## Status + +- ✅ `SegmentLoaderActor` — migrated to `createMachineActor` (with `abortPending()` instead of `replaceQueue()`) +- ✅ `SourceBufferActor` — migrated to `createMachineActor` +- ⬜ `loadSegments` — still function-based; natural follow-on as a `createMachineReactor` migration diff --git a/internal/design/spf/actor-reactor-factories.md b/internal/design/spf/actor-reactor-factories.md new file mode 100644 index 000000000..291535a42 --- /dev/null +++ b/internal/design/spf/actor-reactor-factories.md @@ -0,0 +1,671 @@ +--- +status: decided +date: 2026-04-03 +--- + +# Actor and Reactor Factories + +Design for `createMachineActor` and `createMachineReactor` — the declarative factory functions that replace +bespoke Actor classes and function-based Reactors in SPF. + +Motivated by the text track architecture spike (videojs/v10#1158), which produced the first +`createMachineActor` / `createMachineReactor`-based implementations in SPF and surfaced the need for +shared, principled primitives. See [text-track-architecture.md](text-track-architecture.md) +for the reference implementation and spike assessment. + +--- + +## Decision + +Actors and Reactors are defined via a **declarative definition object** passed to a factory +function. The factory constructs the live instance — managing the state signal, runner +lifecycle, and `'destroyed'` terminal state. Consumers define behavior; the framework handles +mechanics. + +Two separate factories: + +```typescript +const actor = createMachineActor(actorDefinition); +const reactor = createMachineReactor(reactorDefinition); +``` + +Both return instances that implement `SignalActor` and expose `snapshot` and `destroy()`. + +A third factory, `createTransitionActor`, handles actors with reactive context but no +FSM. Lightweight callback actors implement the `CallbackActor` interface directly. + +### Actor and Reactor Types + +| Factory | States | Reactive? | Runner | Use when | +|---|---|---|---|---| +| `createMachineActor` | User-defined FSM | Yes (`snapshot`) | Optional | Per-state message dispatch, `onSettled`, async work | +| `createTransitionActor` | `active` / `destroyed` | Yes (`snapshot`) | No | Reactive context via reducer, no FSM needed | +| `CallbackActor` (manual) | None | No | Manual | Fire-and-forget messages, minimal overhead | +| `createMachineReactor` | User-defined FSM | Yes (`snapshot`) | No | Signal-driven transitions, per-state effects | + +**Actors** (message-driven): +- **`MessageActor`** — returned by `createMachineActor`. Has finite states, per-state + handlers, optional runner, and reactive `snapshot` with `value` + `context`. + Used by: `SourceBufferActor`, `SegmentLoaderActor`. +- **`TransitionActor`** — returned by `createTransitionActor`. Pure reducer model: + `(context, message) => context`. No finite states — `snapshot.value` is always + `'active' | 'destroyed'`. Reactive context for downstream consumers. + Used by: `TextTracksActor`. +- **`CallbackActor`** — manual implementation. `send()` + `destroy()`, no snapshot. + Used when the actor needs no reactive state and the overhead of a factory isn't + warranted. Used by: `TextTrackSegmentLoaderActor`. + +**Reactors** (signal-driven): +- **`Reactor`** — returned by `createMachineReactor`. Has finite states, `monitor` for + state derivation, and `entry`/`effects` per-state effects. No `send()` — driven + entirely by signal observation. + Used by: `syncTextTracks`, `loadTextTrackCues`, `resolvePresentation`, `trackPlaybackInitiated`. + +--- + +## Actor Definition + +### Shape + +```typescript +type ActorDefinition< + UserState extends string, + Context extends object, + Message extends { type: string }, + RunnerFactory extends (() => RunnerLike) | undefined = undefined, +> = { + runner?: RunnerFactory; // factory — called once at createMachineActor() time + initial: UserState; + context: Context; + states: Partial, + ctx: HandlerContext + ) => void; + }; + }>>; +}; + +// runner is present and typed as the exact runner only when runner: is declared. +// When omitted, runner is absent from the type entirely (not undefined). +type HandlerContext = { + transition: (to: UserState) => void; + context: Context; // snapshot at dispatch time — stale after any setContext call + getContext: () => Context; // live untracked read — always current + setContext: (next: Context) => void; +} & (RunnerFactory extends () => infer R ? { runner: R } : object); +``` + +### Example — `SourceBufferActor` + +Serializes SourceBuffer operations. Shows `onSettled` for auto-return, an `onMessage` +helper to deduplicate handlers, `batch` for atomic multi-message dispatch, and `cancel` +in the work state. Tasks return the next context — `getContext` threading ensures each +task reads the context committed by the previous task. + +```typescript +const onMessage = (msg: IndividualSourceBufferMessage, { transition, setContext, getContext, runner }: Ctx): void => { + transition('updating'); + const task = messageToTask(msg, { getContext, sourceBuffer, setContext }); + runner.schedule(task).then(setContext, handleError); +}; + +return createMachineActor SerialRunner>({ + runner: () => new SerialRunner(), + initial: 'idle', + context: { segments: [], bufferedRanges: [], initTrackId: undefined }, + states: { + idle: { + on: { + 'append-init': onMessage, + 'append-segment': onMessage, + remove: onMessage, + batch: (msg, { transition, setContext, getContext, runner }) => { + if (msg.messages.length === 0) return; + transition('updating'); + msg.messages.forEach((m) => { + const task = messageToTask(m, { getContext, sourceBuffer, setContext }); + runner.schedule(task).then(setContext, handleError); + }); + }, + }, + }, + updating: { + onSettled: 'idle', + on: { + cancel: (_, { runner }) => { runner.abortAll(); }, + }, + }, + }, +}); +``` + +### Example — `SegmentLoaderActor` + +Plans and executes segment fetches. Shows context threading (`inFlightInitTrackId`, +`inFlightSegmentId`), continue/preempt decision in the `loading` handler, and +`abortPending()` vs `abortAll()` for fine-grained runner control. + +```typescript +return createMachineActor SerialRunner>({ + runner: () => new SerialRunner(), + initial: 'idle', + context: { inFlightInitTrackId: null, inFlightSegmentId: null }, + states: { + idle: { + on: { + load: (msg, ctx) => { + const allTasks = planTasks(msg); + if (allTasks.length === 0) return; + ctx.transition('loading'); + scheduleAll(allTasks, ctx); + }, + }, + }, + loading: { + onSettled: 'idle', + on: { + load: (msg, ctx) => { + const { context, runner } = ctx; + const allTasks = planTasks(msg); + const inFlightStillNeeded = /* check context against new plan */; + + if (inFlightStillNeeded) { + runner.abortPending(); // continue in-flight + scheduleAll(excludeInFlight(allTasks), ctx); // schedule remainder + } else { + runner.abortAll(); // preempt everything + sourceBufferActor.send({ type: 'cancel' }); + scheduleAll(allTasks, ctx); + } + }, + }, + }, + }, +}); +``` + +--- + +## Reactor Definition + +### Shape + +```typescript +type ReactorDefinition = { + initial: State; + /** + * Cross-cutting monitor — returns the target state. The framework compares + * to the current state and drives the transition. Registered before per-state + * effects — see the monitor-before-state ordering guarantee below. + * + * Accepts a single function or an array of functions. + */ + monitor?: ReactorDeriveFn | ReactorDeriveFn[]; + /** + * Per-state definitions. States with no effects use `{}`. + */ + states: Record; +}; + +/** Returns the target state. Framework drives the transition. */ +type ReactorDeriveFn = () => State; + +type ReactorStateDefinition = { + /** + * Entry effects — run once on state entry, automatically untracked. + * No untrack() needed inside the fn body. Return a cleanup function or + * AbortController to run on state exit. + */ + entry?: ReactorEffectFn | ReactorEffectFn[]; + /** + * Reactive effects — re-run whenever a tracked signal changes while + * this state is active. Return a cleanup to run before each re-run + * and on state exit. + */ + effects?: ReactorEffectFn | ReactorEffectFn[]; +}; + +type ReactorEffectFn = () => (() => void) | { abort(): void } | void; +``` + +### Example — `syncTextTracks` + +Two states (`preconditions-unmet` ↔ `set-up`), one `monitor`, and one `entry` + one +`effects` effect in `set-up` with independent tracking and cleanup. + +```typescript +const reactor = createMachineReactor<'preconditions-unmet' | 'set-up'>({ + initial: 'preconditions-unmet', + // monitor returns the target state; framework drives the transition. + monitor: () => preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet', + states: { + 'preconditions-unmet': {}, // no effects — monitor handles exit + + 'set-up': { + // entry: automatically untracked — runs once on state entry. + // Reading mediaElement and modelTextTracks here does NOT create dependencies. + entry: () => { + const el = mediaElementSignal.get() as HTMLMediaElement; + const tracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[]; + tracks.forEach(t => el.appendChild(createTrackElement(t))); + return () => { + el.querySelectorAll('track[data-src-track]').forEach(t => t.remove()); + update(state, { selectedTextTrackId: undefined }); + }; + }, + + // effects: re-runs when selectedId changes. el is read with untrack() + // since element changes go through the monitor (preconditions-unmet path). + effects: () => { + const el = untrack(() => mediaElementSignal.get() as HTMLMediaElement); + const selectedId = selectedIdSignal.get(); // tracked — re-run on change + syncModes(el.textTracks, selectedId); + const unlisten = listen(el.textTracks, 'change', onChange); + return () => unlisten(); + }, + }, + }, +}); +``` + +### Example — `loadTextTrackCues` + +Four states with actor lifecycle managed across states, the `deriveState` pattern for +complex multi-condition transitions, and `untrack()` for non-reactive owner reads. + +```typescript +// Hoist computeds outside the reactor — computed() inside an effect body +// creates a new Computed node on every re-run with no memoization. +const derivedStateSignal = computed(() => deriveState(state.get(), owners.get())); +const currentTimeSignal = computed(() => state.get().currentTime ?? 0); +const selectedTrackSignal = computed(() => findSelectedTrack(state.get())); + +const reactor = createMachineReactor({ + initial: 'preconditions-unmet', + monitor: () => derivedStateSignal.get(), + states: { + 'preconditions-unmet': { + // entry: defensive actor reset on state entry (no-op if already undefined). + // Handles all paths back from active states. + entry: () => { teardownActors(owners); }, + }, + + 'setting-up': { + entry: () => { + teardownActors(owners); // defensive — same as preconditions-unmet + const mediaElement = owners.get().mediaElement as HTMLMediaElement; + const textTracksActor = createTextTracksActor(mediaElement); + const segmentLoaderActor = createTextTrackSegmentLoaderActor(textTracksActor); + update(owners, { textTracksActor, segmentLoaderActor }); + // No return — deriveState drives the onward transition automatically. + }, + }, + + pending: {}, // neutral waiting state — no effects + + 'monitoring-for-loads': { + // effects: re-runs whenever currentTime or selectedTrack changes. + // owners is read with untrack() — actor presence is guaranteed by + // deriveState when in this state; actor snapshot changes must not + // re-trigger this effect. + effects: () => { + const currentTime = currentTimeSignal.get(); // tracked + const track = selectedTrackSignal.get()!; // tracked + const { segmentLoaderActor } = untrack(() => owners.get()); + segmentLoaderActor!.send({ type: 'load', track, currentTime }); + }, + }, + }, +}); +``` + +--- + +## Key Design Decisions + +### Factory functions, not base classes + +**Decision:** `createMachineActor(def)` and `createMachineReactor(def)` rather than `extends BaseActor` / +`extends Reactor`. + +**Alternatives considered:** +- **Base class + subclass** — `class TextTracksActor extends BaseActor<...>`. Familiar OO pattern, + explicit contract. But inheritance couples the consumer to the framework's class hierarchy, + limits composition, and makes the definition implicit (spread across the constructor body). +- **Interface only** — each Actor/Reactor implements `SignalActor` directly. No boilerplate + reduction; every implementation reimplements the same snapshot/signal/destroy mechanics. + +**Rationale:** A definition object is pure data — inspectable, serializable, testable in isolation +without instantiation. The factory owns all mechanics (snapshot signal, runner lifecycle, +`'destroyed'` guard); the definition owns behavior. Aligns with the XState model and keeps the +door open for a future definition-vs-implementation separation (see below). + +--- + +### Separate `createMachineActor` and `createMachineReactor` + +**Decision:** Two distinct factories with distinct definition shapes. + +**Alternatives considered:** +- **Unified `createMachine`** — one factory for both, distinguishing by definition shape (Actors + have `on`/`runner`; Reactors have effect arrays). XState does this. + +**Rationale:** Actors and Reactors have genuinely different input shapes and internal mechanics. +A unified factory would produce a definition type with optional properties for both cases, +losing type-level guarantees (e.g., a Reactor definition shouldn't have `runner` or `on`). +The shared core — state signal, `'destroyed'` terminal, `destroy()` — is thin enough to +extract as an internal `createMachineCore` without a unified public API. XState unifies because +its actors ARE the reactive graph; in SPF, the separation between reactive observation (Reactor) +and message dispatch (Actor) is intentional and worth preserving in the API surface. + +--- + +### `'destroyed'` is implicit and always enforced + +**Decision:** User-defined state types never include `'destroyed'`. The framework always adds it +as the terminal state. `destroy()` on any Actor or Reactor always transitions to `'destroyed'` +and calls exit cleanup for the currently active state. + +```typescript +// User defines: +type LoaderUserState = 'idle' | 'loading'; +// Framework produces: +type LoaderState = 'idle' | 'loading' | 'destroyed'; +``` + +**Rationale:** `'destroyed'` is universal — every Actor and Reactor has it. Making it implicit +ensures it can't be accidentally omitted or given a custom behavior that breaks framework +guarantees (e.g., `send()` being a no-op in the destroyed state). Users only define their +domain-meaningful states. + +--- + +### Runner as a factory function, actor-lifetime scope + +**Decision:** `runner: () => new SerialRunner()` — a factory function called once when +`createMachineActor()` is called. The runner lives for the actor's full lifetime and is destroyed +when the actor is destroyed. + +**Alternatives considered:** +- **Magic strings** (`runner: 'serial'`) — requires a string-to-class registry and introduces an + extra import layer. Deferred to a possible future XState-style definition-vs-implementation + split. +- **Constructor reference** (`runner: SerialRunner`) — `new def.runner()`. Slightly less explicit + than a factory; doesn't compose as naturally when construction needs configuration. +- **State-lifetime runners** — runner created on state entry, destroyed on state exit. Naturally + eliminates the generation-token problem (`onSettled` always refers to the fresh chain), and + aligns with XState's `invoke` model where async work is tied to the state that started it. + Not adopted as the default because `TextTrackSegmentLoaderActor` intentionally persists runner + state across idle/loading cycles. But this is worth revisiting per-actor — see + [Open Questions](#state-scoped-runner). + +**Rationale:** Actor-lifetime scope matches the current pattern and is the most flexible default. +A factory function (`() => new X(options)`) handles configured runners without changing the +framework. The generation-token problem (`onSettled` must refer to the latest chain, not a +stale one) is handled by the framework internally rather than by runner scope. + +--- + +### `monitor`-before-state ordering guarantee + +**Decision:** `monitor` effects are registered before per-state effects in `createMachineReactor`. +This ordering is **load-bearing**: per-state effects can rely on invariants established by +`monitor` having already run. + +**How it works:** The effect scheduler drains pending computeds into an insertion-ordered +`Set` before executing them. Because `monitor` effects are registered first, they are +guaranteed to execute before per-state effects in every flush. + +**What this enables:** When a `monitor` fn returns a new state, `createMachineReactor` calls +`transition()` immediately and updates the snapshot signal. By the time per-state effects run, +the reactor is already in the new state — so a per-state effect gated on +`snapshot.value !== state` correctly no-ops without needing to re-check conditions that the +`monitor` just resolved. + +**Important caveat:** This guarantee is specific to `createMachineReactor`'s registration order. +It is not a formal guarantee of the TC39 Signals proposal — it depends on the polyfill's +`Watcher` implementation preserving insertion order in `getPending()`. Do not assume this +ordering holds outside of `createMachineReactor`. See [signals.md § Effect Execution Order](signals.md) +for the general principle. + +--- + +### Per-state `on` handlers + +**Decision:** Message handlers are declared per state. The same message type can appear in +multiple states with different behavior. + +```typescript +states: { + idle: { on: { load: (msg, ctx) => { /* plan + schedule; transition → loading */ } } }, + loading: { on: { load: (msg, ctx) => { /* abort + replan; stay loading */ } } } +} +``` + +**Alternatives considered:** +- **Top-level `on`** with internal state guard — one handler per message type, branches on + `context.state` internally. More compact for simple cases, but hides state-dependent + behavior in imperative branches rather than making it explicit in the definition. + +**Rationale:** Matches XState's model. State-scoped handlers make valid message/state combinations +explicit and inspectable from the definition alone — no need to trace imperative branches. + +--- + +### `onSettled` at the state level + +**Decision:** Each state can declare `onSettled: 'targetState'`. When the actor's runner settles +(all scheduled tasks have completed) while the actor is in that state, the framework automatically +transitions to `targetState`. + +**Rationale:** The framework owns the generation-token logic — re-subscribing to +`runner.whenSettled()` each time the handler returns so that `abortAll()` + reschedule +correctly supersedes the previous settled callback. Both `SourceBufferActor` and +`SegmentLoaderActor` use `onSettled: 'idle'` to auto-return from their work states. + +--- + +### `entry` vs `effects` per-state effects + +Per-state effects fall into two distinct categories, each with its own key in the state definition: + +- **`entry`** — run once on state entry, **automatically untracked**. No `untrack()` needed inside + the fn body. Use for one-time setup: creating DOM elements, reading `owners`, starting a fetch. + Return a cleanup function or `AbortController` to run on state exit (or re-entry if the effect + runs again). +- **`effects`** — intentionally re-run when a tracked signal changes while the state is active. + Use for effects that must stay in sync with reactive data: mode sync, message dispatch. + +Signals that should not trigger re-runs in a `effects` effect must be wrapped with `untrack()`. +Signal reads inside `entry` are automatically untracked — the fn body runs inside `untrack()`. + +**Inline computed anti-pattern:** `computed()` inside an effect body creates a new `Computed` +node on every re-run with no memoization. `Computed`s that gate effect re-runs must be hoisted +*outside* the effect body (typically at the factory function scope, before `createMachineReactor()`). + +--- + +## XState Comparison + +### Definition vs. Implementation + +The current design uses a single definition object that contains both structure (states, runner +type, initial state) and behavior (handler functions). XState v5 separates these: + +```typescript +// Definition — pure structure, no runtime dependencies +const def = setup({ actors: { fetcher: fetchActor } }).createMachine({ ... }); + +// Implementation — runtime wiring +const actor = createMachineActor(def, { input: { ... } }); +``` + +This separation enables serialization, visualization, and testing the definition without +instantiation. SPF's current factory approach is compatible with this future direction: +`runner: () => new SerialRunner()` today becomes a named reference resolved against a provided +implementation map later. The migration path is additive — no existing definitions need to change. + +#### Handler context API + +The second argument to Actor message handlers is: +```typescript +{ + transition: (to: UserState) => void; + context: Context; // snapshot at dispatch time — stale after any setContext call + getContext: () => Context; // live untracked read — always current + setContext: (next: Context) => void; +} + & (RunnerFactory extends () => infer R ? { runner: R } : {}) +``` + +`runner` is present and typed as the exact runner instance *only* when the definition +declares a `runner` factory. When no runner is declared, `runner` is absent from the type +entirely (not `undefined` — it simply doesn't exist). This is enforced at the type level via +conditional intersection. + +`context` vs `getContext`: use `context` for synchronous logic that runs in the handler body +itself (dispatch time). Use `getContext` when passing it to tasks scheduled on the runner — +async tasks execute after the handler returns, by which point `context` may be stale (e.g. a +previous task in a batch has already called `setContext`). Passing `getContext` ensures each +task reads the context committed by the task before it, making `workingCtx` threading +unnecessary for sequential operations. + +--- + +### Async Work Model: Where Does Work "Belong"? + +This is the most significant behavioral divergence from XState, with real tradeoffs in both +directions. + +#### The SPF pattern + +In SPF, when an actor like `SourceBufferActor` receives an `append-init` message while `idle`, +the `idle` handler does three things: transitions to `updating`, schedules the work on the +runner, and registers callbacks to update context and settle back to `idle` via `onSettled`. + +```typescript +idle: { + on: { + 'append-init': (msg, { transition, setContext, runner }) => { + transition('updating'); // 1. route + const task = makeTask(msg); + runner.schedule(task).then(setContext); // 2. start work + // 3. onSettled: 'idle' in updating handles the return + } + } +}, +updating: { onSettled: 'idle' } +``` + +The work starts in the `idle` handler and finishes in `updating` via `onSettled`. Two things +happen in separate microtasks: `setContext` (from the task's `.then()`), then `transition('idle')` +(from `onSettled`). Observers see two emissions: `{ value: 'updating', context: newCtx }` followed +by `{ value: 'idle', context: newCtx }`. + +#### The XState pattern + +In XState, `idle` *only routes* — the work belongs to the state that is doing it: + +```typescript +idle: { + on: { 'append-init': { target: 'updating' } } // just routing +}, +updating: { + invoke: { + src: 'executeMessage', + input: ({ event }) => event, // the triggering event travels with the transition + onDone: { + target: 'idle', + actions: assign(({ event }) => event.output) // context + state update, atomically + } + } +} +``` + +`updating` invokes the work on entry, using the event that caused the transition as input. +When the work completes, `onDone` updates context and transitions state in one atomic step — +one emission: `{ value: 'idle', context: newCtx }`. + +#### Consequences + +**Atomicity.** XState's `onDone` updates context and state together; SPF does it in two +microtasks. Currently harmless — all consumers wait for `idle` before reading context — but +it's load-bearing discipline rather than a model guarantee. + +**Lifecycle scoping.** In XState, when the machine leaves `updating` for any reason (a +`cancel` event, `destroy()`, etc.), the invoked service is cancelled automatically. In SPF, the +runner outlives any particular state. Cancellation is handled explicitly — via +`runner.abortAll()` / `runner.abortPending()` in message handlers and a first-class `cancel` +message on `SourceBufferActor`. `SegmentLoaderActor`'s `loading.on.load` handler encodes the +preempt/continue decision explicitly rather than through automatic state-exit cleanup. + +**Partial / streaming updates.** SPF calls `setContext` — a closure callback that writes +context directly from inside the task, bypassing the event system. XState's equivalent is the +invoked service sending intermediate events back to the machine +(`sendBack({ type: 'CHUNK', data })`), which trigger context-updating transitions while the +machine stays in `updating`. More ceremony, but each intermediate state is a proper +event-driven transition — observable, testable, guarded. + +**State graph scalability.** With two states the differences are manageable. If the actor grew +to handle `errored`, `draining`, or `quota-exceeded` states, the XState model scales cleanly — +each state owns its behavior, and leaving any state cancels its work. The SPF model requires +increasingly careful manual management as the graph grows. + +#### Tradeoffs of adopting the XState model + +The XState approach is not strictly better. The costs: + +- **The runner doesn't go away.** `SerialRunner`'s serial queuing and abort semantics don't + exist in XState's `invoke` primitive. The runner would move inside the invoked service rather + than being eliminated. The benefit is lifecycle scoping, not simplification. +- **The dispatch table is the same either way.** Whether messages are routed in the `idle` + handler or via `input: ({ event }) => event` in `updating`'s invoke, the `messageToTask` + dispatch exists in both models. Location changes, not complexity. +- **Partial updates as events adds ceremony for a narrow case.** `setContext` fires once + per streaming segment when the first chunk lands. Modeling it as machine events means the + `updating` state handles task-internal events alongside external messages, with every chunk + going through the full dispatch loop. Heavy machinery for one operation type. +- **TypeScript complexity.** With multiple message types all targeting `updating`, the invoke + `input` type is a union and the service must discriminate on `event.type`. + +#### Middle ground: state-scoped runner + +The most targeted improvement would be making the runner *state-scoped* — created on entry to +`updating`, destroyed on exit — without adopting the full `invoke` model. This was explored +and deferred; see [Open Questions](#state-scoped-runner). + +--- + +## Open Questions + +### State-scoped runner {#state-scoped-runner} + +The current actor-lifetime runner means work scheduled in `updating` completes regardless of +subsequent state transitions. For `SourceBufferActor`, this is intentional (a physical +SourceBuffer write must be reflected in the model even if a signal fires mid-operation). For +other actors, it's accidental — there's no mechanism to say "if the actor leaves this state, +abandon in-flight work." + +A state-scoped runner would close this gap, but the investigation concluded it requires more +than convention: + +- **Scheduling happens in `idle`, not `updating`.** `idle` handlers transition to `updating` + and then schedule tasks — in the same function body, on the same runner reference. For the + runner to be state-scoped, either `transition()` must return the new state's runner (magic, + rejected), or task inputs must travel through context so that an `onEnter` hook on `updating` + can drain them and do the scheduling there. +- **`onEnter` is a real API addition.** The "entry hook drains context" model is essentially + a lightweight `invoke` — it requires `onEnter` in `ActorStateDefinition`, a per-state + runner factory, and the framework to wire them up on state entry and exit. That's a meaningful + framework change, not a convention. +- **Context as side channel is awkward.** Task inputs (message payloads) traveling through + reactive actor context leaks internal scheduling details into the public snapshot. + +**Decision:** Keep actor-lifetime runners for now. The generation-token problem is already +handled by the framework. The cancellation gap (work outliving its state) is real but not +currently exploited — no actor today has a non-settle path out of its work state. Revisit if +a new actor needs explicit cancellation on state exit, or if the framework grows `onEnter` +support for other reasons (at which point state-scoped runners become straightforward). diff --git a/internal/design/spf/decisions.md b/internal/design/spf/decisions.md index 864cbfc88..e6b82521c 100644 --- a/internal/design/spf/decisions.md +++ b/internal/design/spf/decisions.md @@ -11,6 +11,69 @@ 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](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](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 @@ -227,7 +290,7 @@ Rationale behind SPF's key choices. **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 throughput observable rather than going through the global state. The bridge exists to decouple the refactor from the feature work. +**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](#abr-throughput). @@ -262,7 +325,7 @@ This lets the UI show "currently manual at 720p, ABR would choose 1080p" without ### ABR Throughput Direct Read {#abr-throughput} -The bandwidth bridge (`loadSegments` → `state.bandwidthState` → `switchQuality`) introduces a round-trip through global state. ABR should eventually read from a throughput observable owned by the network layer, removing the bridge. +The bandwidth bridge (`loadSegments` → `state.bandwidthState` → `switchQuality`) 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/`. diff --git a/internal/design/spf/index.md b/internal/design/spf/index.md index 19122bb57..b21888293 100644 --- a/internal/design/spf/index.md +++ b/internal/design/spf/index.md @@ -6,17 +6,36 @@ date: 2026-03-11 # SPF — Streaming Playback Framework > **This is a living design document for a highly tentative codebase.** The current implementation captures useful early lessons but is expected to undergo significant architectural change in the near term. [architecture.md](architecture.md) and [decisions.md](decisions.md) document the current state; [primitives.md](primitives.md) is the forward-looking design. +> +> **Structure note:** These docs don't follow the standard [design doc template](../README.md) (`Decision → Context → Alternatives → Rationale`). SPF's scope — a multi-layered streaming framework with several interacting primitives — warrants a different structure: an index with glossary, per-primitive deep dives, and explicit "Open questions" sections for areas still in flux. -A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. +A lean, actor-based framework for HLS playback over MSE. Handles manifest parsing, quality selection, segment buffering, and end-of-stream coordination — without a monolithic player. Actors and Reactors are defined via declarative factory functions (`createMachineActor`, `createMachineReactor`) backed by TC39 Signals. ## Contents -| Document | Purpose | -| ---------------------------------- | ------------------------------------------------------------- | -| [index.md](index.md) | Overview, problem, quick start, surface API | -| [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | -| [architecture.md](architecture.md) | Current implementation: layers, components, data flow | -| [decisions.md](decisions.md) | Decided and open design decisions | +| Document | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------- | +| [index.md](index.md) | Overview, problem, quick start, surface API | +| [primitives.md](primitives.md) | Foundational building blocks (Tasks, Actors, Reactors, State) | +| [signals.md](signals.md) | Signals as the reactive primitive — decision, tradeoffs, friction | +| [actor-reactor-factories.md](actor-reactor-factories.md) | Decided design for `createMachineActor` / `createMachineReactor` factories | +| [text-track-architecture.md](text-track-architecture.md) | Reference Actor/Reactor implementation + spike assessment | +| [architecture.md](architecture.md) | Current implementation: layers, components, data flow | +| [decisions.md](decisions.md) | Decided and open design decisions | + +## Glossary + +| Term | Definition | +| ---- | ---------- | +| **Actor** | Long-lived stateful worker that processes messages serially via a queue. Owns a context snapshot and a Runner. Key examples: `SourceBufferActor`, `SegmentLoaderActor`. | +| **Reactor** | Thin subscriber that observes state changes and translates them into actor messages. Contains no business logic beyond "should I send a message, and what should it say?" | +| **Task** | Ephemeral async work unit with status tracking (`pending`, `active`, `complete`, `error`) and abort support. | +| **Runner** | Task scheduler that controls execution ordering. `SerialRunner` runs one task at a time; `ConcurrentRunner` runs tasks in parallel. | +| **Snapshot** | Reactive read-only state of an Actor or Reactor, exposed for external consumption. | +| **Signal** | Reactive primitive from the [TC39 Signals proposal](https://github.com/tc39/proposal-signals). The layer underneath Actors and Reactors — see [signals.md](signals.md). | +| **MSE** | [Media Source Extensions](https://developer.mozilla.org/en-US/docs/Web/API/Media_Source_Extensions_API) — browser API for programmatically feeding media data to a `