pcbjam/docs/features/async/05-design-a-js-asyncify-arbiter.md
Viktor Vaczi c1ef489cfa feat(wasm-eh): migrate the WASM build to native wasm exceptions (+ 3D viewer default-on)
Replace the legacy Emscripten JS-exceptions model with native wasm-EH (legacy
encoding) across the whole build, keeping Asyncify coroutines working via a
from-source Binaryen --hoist-cpp-catches pre-pass. Net result: native-EH is the
only build mode, the 3D viewer is on by default, and pcbnew shrinks substantially.

Highlights:
- Binaryen submodule everywhere + --hoist-cpp-catches integration in apply-asyncify;
  post-link Asyncify covers every app wasm (not just standalone test wasm).
- Build deps (incl. OpenCASCADE without OCC_CONVERT_SIGNALS) and all KiCad apps
  with -fwasm-exceptions; emscripten_sleep added to the post-link asyncify-imports.
- libcontext fiber entry wired under native exceptions; while-loop main loop +
  currData shim injected into all wx apps.
- Native-EH collab apply fixed: DEBUG-define the embind TU + match all out-of-CMake
  C++ TUs' ABI flags to the core, fixing the vtable-layout skew / mis-dispatch.
- 3D viewer enabled by default (real raytracer linked, not the stub).
- Retire the EH-spike scaffolding; flip the asyncify-races ablation pins to
  shim-redundancy pins (native-EH stays clean with the legacy shims ablated).
- Fix the asyncify-races quiescence check to not require Asyncify.currData==0:
  under the native-EH per-frame-yield top loop the main stack is asyncify-suspended
  every frame, so currData legitimately churns (a freed-but-not-yet-nulled buffer,
  not a leak). Refresh the pcbnew toolbar screenshot baseline for the new kicad.
- CI: drop the obsolete binaryen_version input/env (the build uses the binaryen
  submodule fork's wasm-opt, not a version download); key the wasm-output cache on
  the binaryen submodule SHA instead.

Bumps the wxwidgets + binaryen submodules to their squashed feature commits.

Validated green: all 7 apps native-EH (real 3D in pcbnew); KiCad e2e 63/63
Firefox + Chromium (3D viewer renders); wx 336; coroutine 34/34 both engines;
asyncify 7/7 both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:40:26 +02:00

20 KiB

05 - Design A: JS Asyncify arbiter

STATUS (2026-06-23): the throw-based top loop assumed here is gone (de-park; ../wasm-exceptions/09). This arbiter is the core of Design B's scheduler, now being implemented — the de-park created the red scenario (coroutine regression) that 07/D3 said this arbiter lacked. See 12 + 13; this doc's arbiter design is reused there.

Goal: fix the current system with the smallest architectural move. Keep EM_ASYNC_JS modal/clipboard/font calls and Emscripten fibers, but introduce one JS-side authority that owns Asyncify.currData, Asyncify.state transitions, the fiber trampoline, and resume queueing.

Status

This is a design note, not an implementation. It explains an incremental universal fix: instead of adding one-off patches for clipboard, modal dialogs, quasi-modal loops, and fibers, we put one scheduler in front of all suspension paths.

The core idea is:

Many contexts may be parked.
Only one context may be actively unwinding or rewinding at a time.
Asyncify.currData is not durable state. It is a temporary register loaded from the current context.

That is exactly the distinction that makes the design work.

Concepts

What is a call stack?

A call stack is the chain of active function calls for one line of execution.

If C++ is here:

main()
  -> wxEntry()
    -> wxDialog::ShowModal()
      -> startModal()

then local variables and return addresses for all of those calls are on the stack. Normally, a function returns by walking back up that chain.

In browser WebAssembly, we cannot block the browser main thread while waiting for a Promise. So if C++ wants to pretend that ShowModal() blocks, the stack must be saved somewhere and resumed later.

What is Asyncify?

Asyncify is Binaryen/Emscripten's way to save and restore a WebAssembly call stack.

At the low level, it is buffer-oriented:

asyncify_start_unwind(dataPtr)
asyncify_stop_unwind()
asyncify_start_rewind(dataPtr)
asyncify_stop_rewind()

dataPtr points at an asyncify_data structure. The important fact is that the Wasm-level mechanism already accepts a buffer pointer. It does not require there to be only one buffer.

Emscripten's JS runtime adds a convenience layer:

Asyncify.state    // Normal, Unwinding, Rewinding
Asyncify.currData // pointer to the buffer currently being used

That convenience layer is where our contention lives. currData is a single slot.

What is a coroutine?

A coroutine is a function that can pause in the middle and later continue from the same place.

Ordinary function:

call -> run to completion -> return

Coroutine:

call -> run a bit -> yield -> later resume -> run more -> yield/return

KiCad's interactive tools are coroutine-shaped. A routing tool, drawing tool, or selection tool often waits for user input, yields to the UI, then resumes with the next event.

What is a fiber?

A fiber is a stackful coroutine. It has its own stack.

That matters because a stackful coroutine can pause deep inside ordinary C++ calls without turning every caller into a callback or Promise. From the C++ side it can still look synchronous.

In this repo, KiCad's libcontext shim maps KiCad coroutines to Emscripten fibers:

KiCad COROUTINE
  -> libcontext jump_fcontext
    -> emscripten_fiber_swap
      -> Asyncify unwind/rewind

Each wasm_fcontext already has its own asyncify_stack buffer in kicad/thirdparty/libcontext/libcontext.cpp. So the fiber half already understands the key pattern: one context, one buffer.

What is a trampoline?

A trampoline is a small piece of code whose job is to bounce control into another execution context.

In Emscripten's fiber implementation, _emscripten_fiber_swap(oldFiber, newFiber) starts by unwinding the old fiber. Once the unwind reaches the bottom of the JS/Wasm call boundary, Emscripten must start the new fiber's rewind. That second half cannot happen directly from the middle of the old fiber. It happens from Fibers.trampoline().

So the rough flow is:

old fiber calls emscripten_fiber_swap(old, new)
  -> old stack unwinds into old.asyncify_data
  -> control reaches JS boundary
  -> Fibers.trampoline() runs
  -> trampoline loads new.asyncify_data
  -> trampoline starts rewind into new fiber

The trampoline is needed because a fiber swap is two operations separated by the unwind reaching the edge of the runtime:

leave old stack now
enter new stack after old stack is fully saved

The bug-prone part is that Fibers.trampoline() has its own guard, Fibers.trampolineRunning. If a rewind from inside the trampoline itself unwinds again before the trampoline function returns, the guard can stay stuck. Then future fiber swaps set Fibers.nextFiber, but the trampoline refuses to run.

What is parking?

Parking means a context is suspended and waiting outside Wasm.

Examples:

  • A modal dialog is parked while waiting for the user to press OK or Cancel.
  • A clipboard read is parked while waiting for navigator.clipboard.readText().
  • A KiCad tool fiber is parked after yielding to another fiber.

Parked does not mean active. Many contexts can be parked at once as long as each has its own saved stack buffer.

What is queueing?

Queueing means "this parked context is ready to resume, but do not resume it immediately if the runtime is in the middle of another unwind/rewind."

The queue is not a mutex that blocks all async work. A naive mutex would deadlock modal UI, because a modal needs events to keep flowing while it is waiting.

The queue should serialize only active transitions:

Allowed:
  sleep A parked
  fiber B parked
  nested loop C parked
  clipboard D parked

Not allowed:
  two calls to asyncify_start_rewind at the same instant
  a Promise wakeup directly calling doRewind while the fiber trampoline is mid-switch

The problem this design solves

Today, several roads write the same global slot:

handleSleep:
  Asyncify.currData = Asyncify.allocateData()

fiber swap:
  Asyncify.currData = oldFiber + 20

fiber finishContextSwitch:
  Asyncify.currData = newFiber + 20

current handleSleep shim:
  restores the sleep buffer before wakeUp

The current handlesleep.js shim is useful, but it is still local. It protects one sleep from fiber swaps by remembering the sleep's buffer. It does not make all suspension producers obey one state machine.

Design A says: make the local patch into a real arbiter.

Core design

Introduce a JS object, conceptually:

AsyncifyArbiter = {
  active: null,
  readyQueue: [],
  contexts: new Map(),
  transitionRunning: false,
  trampolineRunning: false,

  registerSleep(ctx) {},
  registerFiber(ctx) {},
  park(ctx) {},
  markReady(ctx, value) {},
  drain() {},
  beginUnwind(ctx) {},
  beginRewind(ctx) {},
  finishRewind(ctx) {},
  withCurrData(ctx, fn) {}
}

Each context has durable state:

{
  id,
  kind: "sleep" | "fiber" | "nested-loop" | "main-loop",
  dataPtr,
  status: "running" | "unwinding" | "parked" | "ready" | "rewinding" | "done",
  result,
  cancel
}

Asyncify.currData becomes a derived value:

When rewinding context X:
  Asyncify.currData = X.dataPtr

When unwinding context X:
  Asyncify.currData = X.dataPtr

When no transition is active:
  Asyncify.currData may be null

No context is allowed to rely on Asyncify.currData as its long-term storage.

Hooks

Hook 1: Asyncify.handleSleep

This is the road used by EM_ASYNC_JS functions:

  • startModal in wxwidgets/src/wasm/dialog.cpp
  • clipboard read/write/clear/has-text in wxwidgets/src/wasm/clipbrd.cpp
  • font enumeration in wxwidgets/src/wasm/fontenum.cpp
  • the nested event loop added by wx commit c27fe8bf, if adopted

The current shim wraps handleSleep and captures the allocated buffer. The arbiter would keep that, but add explicit lifecycle state:

handleSleep begins
  -> create sleep context
  -> intercept allocateData
  -> associate allocated dataPtr with the context
  -> start unwind
  -> mark context parked

Promise resolves
  -> store result on context
  -> mark context ready
  -> queue drain

drain runs when safe
  -> load context.dataPtr into Asyncify.currData
  -> start rewind
  -> doRewind(context.dataPtr)

Important difference from the current implementation: the Promise resolution path should not directly call wakeUp if another transition is already active. It should enqueue the context and let drain() decide when to resume.

Hook 2: _emscripten_fiber_swap

The fiber path already has per-fiber buffers. The arbiter should not allocate new buffers for fibers. It should track them.

Current idea:

oldFiber data = oldFiber + 20
newFiber data = newFiber + 20

On swap from old to new:

register old fiber context if unknown
register new fiber context if unknown
begin unwind of old
set Fibers.nextFiber = new

When the unwind reaches bottom, the arbiter owns the trampoline step:

finish old unwind
queue new fiber as ready
drain
  -> begin rewind of new

The original Emscripten functions can still do much of the work. The key is to wrap their critical sections so the arbiter knows which data pointer belongs to which context and can reset guards reliably.

Hook 3: Fibers.trampoline

The arbiter must own the trampoline guard.

Minimum invariant:

try {
  Fibers.trampolineRunning = true;
  // process next fiber
} finally {
  Fibers.trampolineRunning = false;
}

But the arbiter design goes further. It treats the trampoline as part of the scheduler:

maybeStopUnwind()
  -> old stack is fully saved
  -> notify arbiter that a transition completed
  -> arbiter queues the next fiber
  -> arbiter drains when safe

The reason this matters: if re-entered Wasm unwinds again while the trampoline is running, the arbiter has to know that it left the trampoline in an incomplete state. A try/finally fixes the guard symptom. A scheduler gives us a place to reason about the whole switch.

Hook 4: main loop diagnostic, not necessarily main loop ownership

This is the part that caused the earlier "do not start by de-parking" comment.

The top-level emscripten_set_main_loop(ProcessEvents, 0, 1) throws "unwind" to stop C++ from returning into wx cleanup. That throw is not Asyncify. It is a special Emscripten lifetime trick.

Design A can leave that alone if the throw happens while no Asyncify context is in flight.

So the first diagnostic is:

At the moment top-level DoRun installs the main loop:
  Asyncify.state == Normal?
  Asyncify.currData == null?
  Fibers.trampolineRunning == false?

If yes, the throw is only a lifetime mechanism. It is not corrupting Asyncify state. Then the arbiter can fix overlapping sleeps/fibers without touching wx app lifetime.

If no, the throw is abandoning a live Asyncify context. Then Design A is incomplete unless it also takes ownership of the main-loop park or we de-park main.

Why not de-park first?

De-parking means changing the top-level main loop from:

emscripten_set_main_loop(ProcessEvents, 0, 1);

to:

emscripten_set_main_loop(ProcessEvents, 0, 0);

The 0 version does not throw. It returns normally. That sounds cleaner for Asyncify.

But it has a cost: after DoRun() returns, wx thinks the app is done. wxEntryReal() continues into cleanup:

OnRun returns
  -> CallOnExit destructor runs wxTheApp->OnExit()
  -> wxEntryCleanupReal deletes windows and wxTheApp
  -> Emscripten rAF main loop is still alive
  -> next tick touches deleted app/window state

So de-parking is not just a one-line Asyncify fix. It is an application lifetime redesign.

That is why Design A starts with the arbiter and the diagnostic. If the top-level throw is not orphaning a live buffer, de-parking is avoidable. If the diagnostic proves the throw is orphaning a live buffer, then de-parking becomes part of the solution, not a speculative first move.

Queueing model

The queue should be cooperative and explicit:

function markReady(ctx, result) {
  ctx.result = result;
  ctx.status = "ready";
  readyQueue.push(ctx);
  scheduleDrain();
}

function scheduleDrain() {
  if (drainScheduled) return;
  drainScheduled = true;
  setTimeout(drain, 0);
}

function drain() {
  drainScheduled = false;

  if (transitionRunning) return;
  if (Asyncify.state !== Asyncify.State.Normal) return;
  if (Fibers.trampolineRunning) return;
  if (!readyQueue.length) return;

  const ctx = readyQueue.shift();
  transitionRunning = true;

  try {
    beginRewind(ctx);
  } finally {
    // This may not run immediately if beginRewind re-enters Wasm and unwinds.
    // Therefore the real implementation must pair this with explicit callbacks
    // from stop_rewind/maybeStopUnwind, not rely only on JS finally.
  }
}

The subtle point: queueing must understand that doRewind() can re-enter Wasm and trigger another unwind before returning. A plain JS queue is not enough unless the arbiter also receives transition completion signals.

Flow: modal dialog plus tool fiber

Current bad flow:

ShowModal starts handleSleep
  -> currData = modalBuffer
  -> modal parked

modal's JS pump calls ProcessEvents
  -> tool fiber swaps
  -> currData = fiberBuffer

modal Promise resolves
  -> handleSleep rewinds currData
  -> currData is wrong
  -> crash or wrong rewind

Arbiter flow:

ShowModal starts handleSleep
  -> modal context dataPtr = modalBuffer
  -> modal context parked

modal's JS pump calls ProcessEvents
  -> fiber context old/new dataPtr tracked
  -> fiber transitions complete
  -> currData may change transiently

modal Promise resolves
  -> modal context marked ready
  -> drain waits until no active transition
  -> currData = modalContext.dataPtr
  -> modal rewinds using modalBuffer

currData can be overwritten during the wait. It no longer matters because the durable pointer lives in the context record.

Flow: long clipboard read plus fiber swap

The clipboard IsSupported() path is unpleasant because it can park for two seconds on a permission-gated readText().

With the arbiter:

clipboard sleep parks with dataPtr C
fiber swap parks/resumes with dataPtr F
clipboard Promise resolves later
arbiter reloads C before clipboard rewind

That makes the path correct, but it does not make it good UX. We may still want a synchronous or cached IsSupported() for performance and browser permission reasons. That is a separate quality fix.

Flow: quasi-modal nested event loop

The linked wx commit c27fe8bf adds a nested event loop implemented with EM_ASYNC_JS.

Conceptually, that creates another sleep context:

DIALOG_SHIM::ShowQuasiModal()
  -> wxGUIEventLoop event_loop
  -> event_loop.Run()
  -> nested DoRun
  -> wxWasmRunNestedLoop()
  -> Asyncify sleep until EndQuasiModal exits it

Design A can support this if wxWasmRunNestedLoop() goes through the same handleSleep arbiter.

The caution: if the nested pump catches an async ProcessEvents rejection and stops pumping without resolving its Promise, the nested DoRun() remains parked forever. The arbiter can make the rewind safe, but the nested loop implementation still needs a policy:

on pump error:
  resolve with an error/exit code
  or reject in a controlled way
  or mark context cancelled and resume C++ through cleanup

It should not silently stop with no resolution.

Invariants

The arbiter should assert these aggressively in diagnostics builds:

  1. Only the arbiter writes Asyncify.currData during managed transitions.
  2. Every parked context has a durable dataPtr.
  3. A context may be parked without being active.
  4. At most one context is unwinding or rewinding.
  5. Promise resolution never directly calls doRewind() if another transition is active.
  6. Fibers.trampolineRunning is reset even if re-entered Wasm unwinds.
  7. A context's buffer is not freed until that context reaches done or cancelled.
  8. Asyncify.currData may be null while contexts are parked. The context records are the truth.

What this design does not solve by itself

It does not remove the top-level throw "unwind"

If the top-level main-loop throw is clean, that is fine. If it abandons a live Asyncify context, the arbiter cannot recover perfectly after the fact because the throw bypasses Asyncify bookkeeping.

That is why the diagnostic matters.

It does not make arbitrary nested C stacks possible

Raw nested handleSleep on the same C stack is still not a thing Asyncify can magically support. If function f() is parked and function g() inside the same live stack tries to park independently, there is only one actual stack. To have independent suspension, f and g must be on separate stacks/fibers or be parked as one combined context.

The arbiter prevents buffer loss. It does not violate stack physics.

It does not remove browser permission latency

A correct two-second clipboard read is still a two-second clipboard read. Correctness and UX are separate.

Implementation sketch

Stage 1: observation only.

  • Add diagnostics around handleSleep, _emscripten_fiber_swap, Fibers.trampoline, maybeStopUnwind, and the first rAF tick.
  • Log context ids, data pointers, states, queue length, and trampoline guard.
  • Confirm whether the top-level main-loop throw happens with clean Asyncify state.

Stage 2: make sleeps context-owned.

  • Promote handlesleep.js from "restore captured pointer" to "register sleep context".
  • Store sleep data pointer in context.
  • Queue Promise wakeups through drain() instead of direct immediate rewind when unsafe.

Stage 3: make fibers context-owned.

  • Wrap _emscripten_fiber_swap.
  • Register fiber buffers by pointer.
  • Move trampoline guard reset into arbiter invariants.
  • Record old/new fiber context state around swaps.

Stage 4: unify drain.

  • One drain path handles ready sleep contexts and ready fiber contexts.
  • Drain only starts a rewind when Asyncify.state is Normal and no transition is active.

Stage 5: decide on de-parking.

  • If diagnostics prove the top-level throw is clean, leave it alone.
  • If not, combine the arbiter with a separate lifetime change. See Design B for the cleaner version of that world.

Tests this design needs

Minimum named tests:

  • long_parked_sleep_clobbered_by_swap
  • modal_pump_runs_fiber_then_modal_resolves
  • fiber_swap_after_top_level_main_loop_park
  • nested_quasi_modal_loop_exit_resumes_DoRun
  • nested_quasi_modal_pump_error_does_not_hang
  • clipboard_has_text_timeout_does_not_crash
  • out_of_order_sleep_resolution
  • fiber_trampoline_unwinds_inside_trampoline_then_next_swap_lives

Each test should assert:

  • no index out of bounds
  • no indirect call to null
  • no uncaught "unwind" rejection
  • completion within a timeout
  • correct returned value or dialog code

Liveness is as important as crash freedom.

Why this is elegant enough

This design is elegant because it changes the ownership model without forcing KiCad or wxWidgets to become async-first.

The main rule becomes:

Every suspension has a context.
Every context owns its buffer.
The arbiter owns the one active transition slot.

That is the same shape as Emscripten fibers, TinyGo's scheduler, and other coroutine runtimes. It respects Asyncify's real constraint: not one buffer total, but one active transition at a time.

Tradeoffs

Pros:

  • Lowest disruption to KiCad and wxWidgets C++ APIs.
  • Keeps ShowModal() and ShowQuasiModal() synchronous from KiCad's perspective.
  • Can be built incrementally from the current handlesleep.js shim.
  • Avoids app-lifetime risk unless diagnostics prove de-parking is required.

Cons:

  • JS shim complexity increases.
  • Still depends on Emscripten-generated runtime shapes (handleSleep, Fibers.trampoline, _emscripten_fiber_swap).
  • A partial arbiter can be worse than no arbiter if some path still writes currData behind its back.
  • It makes unsafe overlaps safe, but it does not automatically improve slow clipboard/font UX.

Decision point

Choose Design A if we want the practical universal fix first.

Before implementing it, answer:

At top-level main-loop installation, is Asyncify clean?

If yes, build the arbiter around sleeps and fibers. If no, either extend Design A to own the main-loop lifetime too, or move toward Design B.