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

688 lines
20 KiB
Markdown

# 05 - Design A: JS Asyncify arbiter
> **STATUS (2026-06-23):** the throw-based top loop assumed here **is gone** (de-park; [`../wasm-exceptions/09`](../wasm-exceptions/09-event-loop-deparking-plan.md)). 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`](12-design-b-asyncify-implementation-plan.md) + [`13`](13-design-b-engineering-spec.md); 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:
```text
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:
```cpp
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:
```text
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:
```js
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:
```text
call -> run to completion -> return
```
Coroutine:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```js
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:
```js
{
id,
kind: "sleep" | "fiber" | "nested-loop" | "main-loop",
dataPtr,
status: "running" | "unwinding" | "parked" | "ready" | "rewinding" | "done",
result,
cancel
}
```
`Asyncify.currData` becomes a derived value:
```text
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:
```text
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:
```text
oldFiber data = oldFiber + 20
newFiber data = newFiber + 20
```
On swap from old to new:
```text
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:
```text
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:
```js
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:
```text
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:
```text
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:
```cpp
emscripten_set_main_loop(ProcessEvents, 0, 1);
```
to:
```cpp
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:
```text
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:
```js
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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:
```text
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.