18 KiB
status, date
| status | date |
|---|---|
| draft | 2026-05-18 |
Actors
Actors are message-driven units that own serial async work over a resource they wrap. They receive typed messages, run handlers (often via a runner that serializes async tasks), and expose a reactive
snapshotso downstream observers can read their current state without coupling to the message channel. The canonical factories arecreateMachineActor(FSM + runner + per-state handlers),createTransitionActor(pure reducer over context), and the manualCallbackActorshape. This doc covers when to reach for an Actor and the composition patterns that come with it; for the factory shapes themselves see../actor-reactor-factories.mdand../primitives.md.
When to reach for an Actor
An Actor is the right shape when all of these are true:
- The unit owns a resource (wraps an external API like
SourceBuffer,addTextTrack/addCue; or owns long-lived bookkeeping like a cue cache). - Work on that resource is serial — appends to a
SourceBuffercan't overlap; cue additions need to honor add-order; segment fetches against a single bandwidth-tracker need to interleave correctly. - Inputs arrive as discrete events / decisions ("load this range," "append these bytes," "clear all"), not as a continuous signal-derived value.
- You want a stable identity downstream consumers can reference (the actor's
snapshot, itssend()channel).
If the unit's job is "observe these signals and react when they change," that's a Reactor, not an Actor. The two-line decision rule:
| Driven by | Reach for |
|---|---|
| Messages | Actor |
| Signals | Reactor |
A behavior almost always composes both: the dispatcher reactor reads signals and send()s messages; the actor receives, serializes, executes. Don't try to make one of them do the other's job.
What about a plain effect?
For a mirror behavior with no resource ownership, no async work, and no message channel — read a signal, write a signal — neither Actor nor Reactor is needed; an effect is the right shape (see behaviors.md → "Simple vs primitive-augmented").
The three actor shapes
| Shape | Factory | States | Reactive snapshot | Runner | Reach for |
|---|---|---|---|---|---|
MessageActor |
createMachineActor |
User-defined FSM | Yes | Optional | Per-state message handling, async work serialization, onSettled transitions, continue-vs-preempt logic |
TransitionActor |
createTransitionActor |
'active' / 'destroyed' |
Yes | No | Pure reducer over context; snapshot is the public surface; no async work |
CallbackActor |
manual (no factory) | None | No | Manual | Fire-and-forget dispatch; no observable state; lightest possible shape |
The factory choice follows the work shape, not the message count. Two messages with rich serial async work behind them want MessageActor; ten messages that just mutate a context map want TransitionActor.
Reach for MessageActor when
- Async work needs serialization (a runner) and per-message-type behavior depends on what's currently in-flight.
- States carry meaning —
'idle'vs'updating'for aSourceBuffer;'idle'vs'loading'for a segment loader. The state determines which messages are valid and what they do. - Continue-vs-preempt logic is needed: a new message arrives while work is in flight, and the handler must decide whether to keep, abort, or schedule on top.
SourceBufferActorandSegmentLoaderActorare the canonical worked examples.TextTrackSegmentLoaderActor(post-b3f44efe) is the third — the FSM made room for continue-vs-preempt against a<track>-mounted cue cache.
Reach for TransitionActor when
- The actor's job is bookkeeping: maintain reactive context (a map, a list, a cache) that downstream readers consume via
snapshot. - Messages are pure reducers:
(context, message) => context. No FSM, no async, no runner. - The reactive snapshot is the whole point — consumers read it via
actor.snapshot.get()and react. TextTracksActoris the canonical worked example: maintainscuesByTrackId+segmentsByTrackIdfor duplicate-add detection and load planning. Messages ('add-cues','clear') are reducer updates.
Reach for CallbackActor when
- No reactive snapshot is needed.
- The actor is just a
send()+destroy()shape — fire-and-forget. - Adding a factory's machinery would be ceremony with one state.
Note: a CallbackActor upgraded to a MessageActor is a common migration as continue-vs-preempt logic becomes worth the bytes. TextTrackSegmentLoaderActor's b3f44efe migration is the worked example — went from CallbackActor to MessageActor so the idle/loading FSM could track inFlight* context and decide continue-vs-preempt rather than abort-everything-on-every-send.
One actor or two?
When a pipeline needs both resource serialization (mechanism — append to this SourceBuffer; add cues to this <track>) and policy decisions (which segments to fetch given current time + preload + selected track), prefer two actors over one unified actor.
The split is along the line "what to do" (policy) vs "how to do it" (mechanism):
- Mechanism actor wraps the external resource. Knows how to serialize append/remove/abort operations on it. Doesn't decide what to append.
- Policy actor plans the work. Decides which segments to fetch in what order; handles continue-vs-preempt across overlapping load requests. Doesn't directly touch the external resource — it
send()s into the mechanism actor.
Why two:
- Composition. A
SegmentLoaderActor(policy) needs to talk to someSourceBufferActor(mechanism). The split lets the loader exist with one interface, regardless of whether the buffer wraps an MSESourceBuffer, an in-memory test stub, or a future alternative target. - Independent reasoning. The mechanism actor's correctness is local — it serializes its API correctly. The policy actor's correctness is local — given the mechanism's contract, it plans the right work. A bug in one doesn't require re-reasoning about the other.
- Reactive snapshots at the right grain. Mechanism's snapshot is "what bytes are buffered, am I currently updating"; policy's snapshot is "what segments are in flight, what's queued." Downstream consumers read whichever they need — observers don't have to filter through one combined snapshot.
The two canonical pairs:
| Pipeline | Mechanism actor | Policy actor |
|---|---|---|
| Video / audio MSE | SourceBufferActor (wraps SourceBuffer; serializes appends via SerialRunner) |
SegmentLoaderActor (plans + fetches segments; FSM with continue-vs-preempt) |
| Text tracks | TextTracksActor (TransitionActor over cues-by-track + segments-by-track) |
TextTrackSegmentLoaderActor (MessageActor with idle/loading + inFlightTrackId + inFlightSegmentId) |
When not to split
The split isn't free — two actors means two lifecycles, two snapshots, two destroy contracts. Don't split if:
- The "policy" half is trivially "send whatever message arrives to the mechanism." There's no planning, no continue-vs-preempt, no state worth modeling. A
CallbackActorupstream of aMessageActoris two actors playing one role. - The two halves can't be reasoned about independently — every policy decision needs the mechanism's full internal state, and the mechanism's correctness requires knowing the policy's intent. That's not actually two responsibilities; it's one responsibility that hasn't been factored cleanly.
When to merge that did exist (and shouldn't recur)
Earlier text-track work briefly considered merging TextTracksActor + TextTrackSegmentLoaderActor into one. It was rejected: the cue-cache reducer (TransitionActor shape) and the load planner (MessageActor shape) have different state-machine needs. Merging would have either forced the cache into an FSM it doesn't need, or stripped the planner of the FSM it does need.
Where actors are created: the per-type setup-actor convention
A per-type setup-actor behavior owns the full per-type actor cluster. When two actors form a writer-publisher unit (mechanism + policy bound to the same lifetime), they're created together in one setup behavior's entry body, and destroyed together in that body's exit cleanup. Downstream dispatcher behaviors read the policy actor from context and send() — they don't create either actor themselves.
Canonical worked examples on this branch:
setupVideoBufferActors— entry body createsSourceBuffer→SourceBufferActor→SegmentLoaderActorin one synchronous block; publishesvideoBufferActor+videoSegmentLoaderActorto context. Exit destroys in reverse (loader → buffer-actor) and clears slots.loadVideoSegmentsis pure-consumer: readsvideoSegmentLoaderActor, dispatches.setupAudioBufferActors— mirrors video.loadAudioSegmentsmirrorsloadVideoSegments.setupTextTrackActors— createsTextTracksActor+TextTrackSegmentLoaderActortogether; publishes both.loadTextTrackSegmentsreadstextTrackSegmentLoaderActor, dispatches. (NotetextTracksActoris also published — it has a separate reader,syncTextTracks, which mounts<track>DOM nodes from its snapshot.)
The rule's diagnostic question: which behavior currently calls createXActor? If a dispatcher behavior is calling createXActor inside its own setup (i.e., creates the actor it consumes), that's the "creates and stashes its own consumed actor" sniff — extract the creation to a sibling setup-actor behavior; have the dispatcher read it from context.
Why co-locate the cluster
- Lifetime coupling is structural, not coordinated-through-signals.
SegmentLoaderActorrequires aSourceBufferActorto construct; they have identical lifetimes (both come up when the source buffer is ready; both go down when it tears down). Co-locating their creation in one atomicentrybody encodes the contract via call order, not via two reactors observing the same upstream signal and racing to see which goes up first. - The dispatcher's role becomes clean: pure-consumer. Reading
xSegmentLoaderActorfrom context and callingsend()is the whole job. No "is the loader ready?" guard nested inside an effect that also created the loader. - Destroy order is naturally correct. Reverse-order destroy in a single exit-cleanup closure: policy actor first (aborts in-flight work), then mechanism actor (tears down the underlying resource). Splitting creation across two behaviors makes destroy ordering depend on which behavior tears down first — fragile.
Single-reader context publication is not a sniff
A context slot read by exactly one downstream behavior is the natural integration mechanism between a writer-behavior (the setup-actor) and a reader-behavior (the dispatcher) whose lifetimes coordinate through it. Earlier framing treated single-reader slots as gratuitous publication — the reframe: the slot's role is being the integration channel between two cooperating behaviors, not "broadcasting to N consumers." One reader is enough.
The sniff that does matter is the inverse: a slot published with no readers (dead context entry). Drop those.
When the cluster splits across behaviors (legitimate exceptions)
The co-location rule has principled exceptions when the actor's lifetimes legitimately diverge:
- Different upstream dependencies. If actor A's lifetime is bound to the
MediaSourceattachment and actor B's lifetime is bound to themediaElement(which outlives manyMediaSources on a player that swaps sources), they can't share anentrybody — their setup behaviors have different gating.TextTracksActoris mediaElement-bound (DOM<track>slots can't be removed) and survives source resets; its segment-loader sibling is also mediaElement-bound, so they happen to co-locate; but a per-source actor and a per-element actor would not. - Different consumers want to swap one independently. If a future shape lets engines swap the policy actor (e.g., a different segment-loader strategy) while keeping the mechanism actor, the split into two setup behaviors would be the right shape. Not exercised today.
The convention is "co-locate by default when lifetimes match"; the exceptions are real but rare. Don't split prophylactically — the cost of unnecessary splitting (lifecycle coordination through signals, destroy-order fragility) is the same as the cost of the original anti-pattern, just at a different layer.
Lifecycle binding
An actor's lifetime is bound to the most ephemeral of its dependencies. Encode the binding through the setup-actor behavior's state machine:
| Actor | Lifetime bound to | Encoded in |
|---|---|---|
SourceBufferActor |
mediaSource attached + selected track present with codecs |
setupVideoBufferActors / setupAudioBufferActors 'buffer-ready' state |
SegmentLoaderActor |
Same as SourceBufferActor (constructed from it) |
Same — created and destroyed in the same 'buffer-ready' entry/exit |
TextTracksActor |
mediaElement in scope |
setupTextTrackActors's effect (no FSM needed — single-resource gate) |
TextTrackSegmentLoaderActor |
Same as TextTracksActor |
Same — created/destroyed in the same effect |
The destroy contract:
- Synchronous and idempotent. Calling
destroy()twice is safe; calling it during in-flight work aborts. - Reverse-order in a cluster. Policy actor first, mechanism actor second. Policy's destroy aborts in-flight work and prevents new dispatches; mechanism's destroy then tears down the underlying resource safely.
- State-exit cleanup is the natural site.
entry: () => { /* create */; return () => { /* reverse-destroy */ }; }binds the cluster's lifetime to its setup-actor behavior's state machine. No closure flag, noif (alreadyDestroyed)guard.
Composition: dispatcher reads, doesn't create
The full pipeline for one type:
setupXBufferActors ← actor cluster owner (writer)
└─ entry on `'buffer-ready'`:
├─ creates SourceBuffer
├─ creates SourceBufferActor ─→ context.xBufferActor
└─ creates SegmentLoaderActor ─→ context.xSegmentLoaderActor
loadXSegments ← dispatcher (reader)
└─ reads context.xSegmentLoaderActor
└─ on signal changes: send({ type: 'load', ... })
The dispatcher's setup never calls createSegmentLoaderActor. It reads the slot, encodes its policy modes as reactor states (see reactors.md → "Policy modes as states"), and dispatches. Lifecycle concerns — actor creation, actor destroy, slot publication — belong upstream.
Config threading: actor factories accept a config arg at construction (e.g. createSegmentLoaderActor(actor, fetch, { forwardBuffer, backBuffer })). The setup-actor behavior is the natural place to thread engine config into the actor, because it's where the actor is created. See config.md → "Threading paths" → "Actor factories" for the full pattern.
Anti-patterns
- Actor that reads signals directly. Actors are message-driven, not signal-driven. If an actor needs to react to a signal, the dispatcher behavior should observe the signal and
send()a message. The reverse coupling makes the actor's behavior depend on a reactive context the actor itself doesn't expose, hiding causality from readers. - Dispatcher that creates its own consumed actor. "Creates and stashes" sniff. Pull the creation up into a sibling setup-actor behavior; have the dispatcher read from context. See Where actors are created.
- One actor playing two roles. A
MessageActorwhose handlers do both serial-resource work and high-level planning is two responsibilities behind one snapshot. Split mechanism from policy. - Two actors playing one role. A
CallbackActorupstream of aMessageActorthat just forwards every message is ceremonial decomposition. If the upstream isn't doing planning, continue-vs-preempt, or any state-dependent work, merge it into the downstream — or remove it. - Closure-mutable state in a handler that should be in
context. The factory providesgetContext/setContext/transitionprecisely so handlers don't need closure state.let lastFooparallel to a context field is double-bookkeeping and survives nothing the context doesn't already track. untrack'd-but-actually-tracked snapshot reads from handler bodies invoked inside a tracked dispatcher. When a dispatcher reactor callsactor.send()from inside aneffects:body, the handler runs synchronously inside the reactor's tracking scope. A bareactor.snapshot.get()inside the handler then leaks the handler-actor's snapshot into the dispatcher's dep set, causing the dispatcher to re-fire on every handler-actor state change. Usepeek(actor.snapshot)inside handlers when the dispatcher is the caller. (Worked example:TextTrackSegmentLoaderActor.planTasks'speek(textTracksActor.snapshot)fix inb3f44efe; the same latent issue exists increateSegmentLoaderActor.getBufferedSegmentsand will surface when v/a'sloadingInputsEqdedup is removed.)- Forgetting to destroy in reverse order in a cluster. Mechanism-first destroy can race against the policy actor's in-flight work. Always policy-actor-destroy → mechanism-actor-destroy in cluster exit cleanups.
- Publishing actors with no readers. A
context.fooActorslot that no dispatcher reads is dead weight in the engine context type — drop it. (Sniff that does not apply: a single-reader slot, which is the writer-reader integration channel by design.)