Current section
Files
Jump to
Current section
Files
docs/stewardship/concurrency-shape.md
# Concurrency shape
How plushie-elixir's runtime is structured under OTP, why the
parts split the way they do, and the supervision discipline that
holds them together. Other host SDKs have their own concurrency
shape; plushie-elixir's is downstream of Elixir and BEAM idioms
rather than cross-SDK convergence.
## The Bridge/Runtime split
Two GenServers, one supervisor:
- **Bridge** owns the Port (the OS process or the `{:iostream, pid}`
transport adapter). It owns wire framing, transport I/O,
renderer process lifecycle, and the restart-on-crash behavior.
It knows nothing about apps, models, views, or commands.
- **Runtime** owns the app's `init/update/view` loop, the
current model and tree, the subscription set, the widget
handler registry, and command execution. It knows nothing
about the wire format or the renderer process.
Bridge speaks wire, Runtime speaks Elm. They communicate
through Erlang messages: Runtime `cast`s settings, snapshots,
patches, and effects to Bridge; Bridge sends parsed events
back to Runtime.
This split exists because the two responsibilities have
different lifetimes and different failure modes. Bridge
crashes are renderer crashes; the recovery is restart the
binary and replay state. Runtime crashes are app code
crashes; the recovery is revert to the last good model.
Mixing the two would couple recovery paths that should be
independent.
## Supervision: rest_for_one
The top-level supervisor uses `:rest_for_one`:
```
Plushie.Supervisor (rest_for_one, auto_shutdown: :any_significant)
├── Task.Supervisor (started first)
├── Bridge (significant, transient)
├── Runtime (significant, transient)
└── DevServer (dev mode only, transient)
```
The order matters:
- **Task.Supervisor** starts first so `:async` and `:stream`
commands always have a parent. Runtime starts processing
events before its first command, but the supervisor for those
commands has to exist before the first command runs.
- **Bridge** starts before Runtime. Runtime's
`handle_continue(:initial_render)` fires immediately and
casts Settings + Snapshot to Bridge. If Bridge has not
registered yet, those casts are lost and the renderer hangs
on stdin.
- **Runtime** starts last. By the time it begins, both Task
supervisor and Bridge are ready.
Supervision behavior:
- **Bridge crashes**, Runtime restarts too (rest_for_one).
Fresh start: a new bridge spawns the renderer, Runtime sends
Settings and a fresh Snapshot.
- **Runtime crashes alone**, only Runtime restarts. The bridge
and renderer keep running; Runtime re-sends settings and
snapshot to bring state back in sync.
- `:transient` means clean exits do not restart. A user app
that closes its last window (via `Command.exit/0` or
`:exit_on_close_request`) shuts down the supervisor cleanly.
- `:auto_shutdown: :any_significant` means a clean exit in a
significant child tears down the tree.
## GenServer over Task for the runtime loop
The runtime is a GenServer, not a Task or a recursive process
loop. This is deliberate:
- Synchronous reads (`get_model`, `get_tree`, `health`) are the
test framework's bedrock. A GenServer call is the right tool
for synchronous reads from outside the process.
- Sync barriers (`Plushie.Runtime.sync/1`) are GenServer calls
that flush the mailbox up to the call point. Tests and
external callers need this guarantee.
- Mailbox handling for events, command results, timers, and
subscription sources is uniform under `handle_info/2` /
`handle_cast/2` / `handle_call/3`. A bespoke loop would have
to reinvent the same shapes.
The runtime has substantial internal complexity (handler
registries, scope chains, view error tracking, dev overlay,
coalescing) but the GenServer shape is the same shape every
Elixir engineer expects.
## Transport behaviour
`Plushie.Transport` defines callbacks for `init/1`,
`send_data/2`, `close/1`, `handle_info/2`. Two implementations:
- `Transport.Port` - Erlang Port for `:spawn` (fork the binary)
and `:stdio` (use the current process's stdin/stdout) modes.
The standard production transport.
- `Transport.Iostream` - message-based adapter for
`{:iostream, pid}`. Used by the test session pool to
multiplex multiple test sessions over a single shared
renderer process.
Bridge delegates all transport I/O through the behaviour. New
transport modes (a future TLS transport, a future named-pipe
transport) implement the behaviour without touching Bridge
internals.
This is the kind of small, well-justified abstraction that
earns its place: two real implementations from day one, the
shared shape (init, send, close, handle messages) is genuinely
the same concept, and the Bridge code reads cleanly against
the behaviour.
## SessionPool architecture
The test framework runs sessions through a pool:
- **Multiplexed pool** (`SessionPool.Multiplexed`) for `:mock`
and `:headless` backends. One renderer process started with
`--max-sessions N`; each test gets a session ID. Wire
messages are tagged with the session ID; the renderer routes
to per-session state internally. Session startup is
microseconds; renderer startup is amortized across the
suite.
- **Windowed pool** (`SessionPool.Windowed`) for the
`:windowed` backend. One renderer process per session
because real iced windows do not multiplex cleanly. Slower;
used when window lifecycle matters.
The pool wraps the production `Plushie.Bridge` and `Plushie.Runtime`;
it is not a separate runtime. Tests use `Plushie.Test.Case`
which sets up a session and gives the test a runtime PID to
talk to. The runtime itself is the production runtime.
## What's not used
- **No `:sys` debug functions.** `:sys.get_state/1`,
`:sys.replace_state/2`, `:sys.get_status/1`, etc. are debug
tools, not production APIs. Tests use `Runtime.sync/1`,
`Runtime.get_model/1`, `Runtime.get_tree/1`. See
`test-discipline.md`.
- **No registry-based dynamic supervision for runtimes.** The
runtime is registered by name (`Plushie.Runtime` or a custom
name); a project that wants multiple Plushie instances passes
`:name` and gets `<name>.Runtime`, `<name>.Bridge`, etc. There
is no use case for hundreds of dynamically-spawned plushie
instances; if one appears, the shape can be revisited.
- **No GenStage/Flow/Broadway in the event path.** The runtime
processes events serially. Event coalescing for high-frequency
sources happens at message arrival, not through a stage
pipeline. Streams are simpler and the runtime's mailbox is
not a bottleneck at the rates we target.
- **No Phoenix.PubSub or distributed messaging.** plushie-elixir
is a desktop runtime. Distribution is not in scope.
- **No `Application.start/0` / `application :mod` registration.**
The user starts plushie under their own supervisor (or directly
via `Plushie.start_link/2`); we are a library, not an OTP
application. Apps are application code; libraries that
auto-start runtimes are a known anti-pattern.
## Process registration
Names are derived from the `:name` opt:
- `<name>.Supervisor` - the top-level supervisor.
- `<name>.Bridge` - the bridge GenServer.
- `<name>.Runtime` - the runtime GenServer.
- `<name>.TaskSupervisor` - the Task supervisor.
- `<name>.DevServer` - the dev file watcher (dev mode only).
`Plushie.runtime_for/1` and `Plushie.bridge_for/1` resolve
names for callers that need the registered atom.
## Implications
- A change that introduces a new long-lived process gets a
supervision-level question first: where does it fit in the
rest_for_one chain, what is its restart strategy, what is
its transient-or-permanent posture, what does its crash mean
for the rest of the tree.
- A change that makes Bridge or Runtime do something the other
one already does is suspect. Wire framing in Runtime is wrong;
app state in Bridge is wrong.
- A change that calls `:sys` functions in production code or
tests is rewritten to use the public Runtime API.
- Tests that rely on internal process structure (assuming a
specific name, peeking at supervisor children) are brittle
and get rewritten to use the public test framework.