Structural traps that show up when wiring Eio against the DOM.
Pitfalls 1–3 are consequences of how Eio.Switch.run waits for
forked fibers; pitfall 4 is a consequence of how Eio's
synchronization primitives expect a fiber context. None is a Brio
runtime bug; all are mismatches between Eio's model and the JS
event loop. The forthcoming component layer (Stage 6) packages
the right shapes so application code doesn't have to navigate this
manually.
(* DON'T DO THIS *)
Ev.iter ~sw Ev.hashchange (Window.target ()) (fun _ ->
Switch.run (fun render_sw -> render ~sw:render_sw ()))It looks fine: hashchange fires, we open a fresh sub-switch, render
into it. When render returns, the sub-switch tears down and we're
ready for the next event.
The trap: every Ev.iter registration forks a fiber, and render
typically calls Ev.iter to register child-button handlers. Those
forked fibers run indefinitely. Eio.Switch.run does not return
until all its child fibers exit. So Switch.run blocks, the iter
handler never returns, iter never re-arms Ev.next, and
subsequent navigation events are silently dropped.
Drive navigation from a top-level loop instead of from inside an event handler:
let next_route_change () =
let win = Window.target () in
Fiber.first
(fun () -> ignore (Ev.next History.Ev.popstate win))
(fun () -> ignore (Ev.next Ev.hashchange win))
let () =
Brio.Run.start @@ fun () ->
while true do
try
Switch.run (fun render_sw ->
render ~sw:render_sw ();
next_route_change ();
raise Exit)
with Exit -> ()
doneSee pitfall 2 below for why raise Exit is needed at the end of the
body.
This is the gotcha that turns the "right shape" above into a still-broken shape if you're not careful.
(* STILL BROKEN — Switch.run waits forever for the iter fibers *)
while true do
Switch.run (fun render_sw ->
render ~sw:render_sw (); (* registers via Ev.iter, FORKS *)
next_route_change ())
doneWhen next_route_change returns, the body has returned. But
Switch.run then waits for the click-handler fibers forked by
Ev.iter inside render — those fibers run forever. So
Switch.run never returns, the loop stalls, and the second
navigation event silently has no effect.
This is a property of Eio.Switch.run's contract: a switch that
finishes its body normally waits for all spawned fibers to
finish. Body returning is not the same as the switch closing.
Raise a sentinel exception to fail the switch — Switch.run's
exception-handling path cancels all fibers in the switch (propagating
cancellation through their Run.await cancel callbacks — DOM
listeners are removed, timers cleared, fetches aborted), then
re-raises. Catch it outside and iterate.
while true do
try
Switch.run (fun render_sw ->
render ~sw:render_sw ();
next_route_change ();
raise Exit)
with Exit -> ()
doneThe exception is for control flow, not error reporting; pick a
distinguishable exception (Exit, a fresh local one) and catch it
narrowly.
This is the canonical "switch dies on signal X" pattern in Brio.
(* DON'T DO THIS — the component renders, then immediately disappears *)
Run.start @@ fun () ->
Switch.run @@ fun sw ->
Component.mount_into ~sw ~parent:(Document.body ()) (new shell ())mount_into returns immediately; Switch.run's body returns;
Switch.run waits for child fibers — but if shell#mount happens
not to register any listeners (e.g. a pure display component), there
are no child fibers. Switch.run runs its on_release callbacks
(including mount_into's c#el#remove ()), then returns. The
component is detached from the DOM before the user sees it.
(In practice, most components register at least one Ev.iter on
mount, which forks a fiber that keeps the switch alive. But not
all — and "your component happens to register no listeners today"
is a fragile invariant for a happy path.)
Use Brio.Component.run for component-based apps:
let () = Component.run (new my_app ())It enters the runtime, mounts into Document.body (or a parent you
pass via ?parent), and blocks forever via
Eio.Fiber.await_cancel. The trailing block is what keeps the
switch open for the page's lifetime regardless of whether the
component registers anything.
If you're not using the component layer (e.g. an SPA that drives a
top-level while true loop instead, like examples/spa/spa.ml),
you don't need Component.run — your loop is what keeps the switch
alive, and pitfall 2's raise Exit is what tears each iteration
down.
(* DON'T DO THIS — fails on the second event *)
let stream ~sw typ target =
let s = Eio.Stream.create Int.max_int in
Brr.Ev.listen typ (fun ev -> Eio.Stream.add s ev) target;
...; sIt compiles, the first event fires correctly, then the second crashes the page with:
Fatal error: exception Failure("Mutex.lock: mutex already locked. Cannot wait.")
Eio.Stream.add performs the Eio.Mutex effect to coordinate with
takers. That effect is dispatched by the fiber-scheduler effect
handler installed inside Run.start, but a DOM event-dispatch
callback runs outside any fiber — JS calls it directly when an
event is dispatched. There is no current fiber and no effect handler
in the dynamic stack, so the effect falls through to Eio's default
which prints the mutex-error and gives up.
The same trap will hit any code that calls Eio.Mutex.lock,
Eio.Condition.broadcast, Eio.Stream.add, Eio.Promise.resolve
on a Promise.t created with Eio.Promise.create, or any other
fiber-context-requiring primitive from inside a Brr.Ev.listen
callback, a setTimeout callback registered without going through
Brio.Time, or a Promise.then_ callback registered without going
through Brio.Promise.
Run.await is purpose-built to be called from JS callbacks. Its
resolve argument touches only the scheduler's run-queue (no
effects), so it works from anywhere. Build queueing/streaming
primitives on top of it.
Brio.Stream is the streaming bridge — same shape as Eio.Stream
but add performs no effect, just enqueues or hands off to a
waiter via a callback that came from Run.await. It's what
Brio.Ev.stream returns:
let clicks = Brio.Ev.stream ~sw Brio.Ev.click button in
let ev = Brio.Stream.take clicks (* fiber-context: fine *)The shape to remember:
| In a fiber | In a JS callback |
|---|---|
Eio.Stream.{add,take}, Eio.Mutex, Eio.Promise (from create) |
Run.await, Brio.Stream.add, plain mutable refs / queues |
If you find yourself needing a synchronization primitive Brio doesn't
yet wrap, build it on Run.await rather than reaching for the Eio
version — the Eio one will work in the fiber path and silently break
on the JS-callback path.
A component owns a switch. Re-rendering = swap the switch. Mounting = open it; unmounting = fail it. The patterns above are encoded once in the component implementation; consumers don't see them. That's the structural payoff of going OO + structured-concurrent.