Skip to content

Latest commit

 

History

History
232 lines (179 loc) · 7.57 KB

File metadata and controls

232 lines (179 loc) · 7.57 KB

Brio examples — common pitfalls

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.


1. Re-rendering inside an event handler that forks fibers

The wrong shape

(* 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.

The right shape

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 -> ()
  done

See pitfall 2 below for why raise Exit is needed at the end of the body.


2. Eio.Switch.run waits for child fibers; it does NOT auto-cancel them on body return

This is the gotcha that turns the "right shape" above into a still-broken shape if you're not careful.

The trap

(* 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 ())
done

When 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.

The fix: force the switch to fail

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 -> ()
done

The 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.


3. The mount-then-instantly-unmount trap

The trap

(* 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.)

The fix

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.


4. Eio synchronization primitives are not safe inside JS callbacks

The trap

(* 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;
  ...; s

It 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.

The fix: bridge through Run.await (or Brio.Stream)

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.


Why Brio's component layer is the answer

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.