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>
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. See12+13; this doc's arbiter design is reused there.
Goal: fix the current system with the smallest architectural move. Keep
EM_ASYNC_JSmodal/clipboard/font calls and Emscripten fibers, but introduce one JS-side authority that ownsAsyncify.currData,Asyncify.statetransitions, 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:
startModalinwxwidgets/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:
- Only the arbiter writes
Asyncify.currDataduring managed transitions. - Every parked context has a durable
dataPtr. - A context may be parked without being active.
- At most one context is
unwindingorrewinding. - Promise resolution never directly calls
doRewind()if another transition is active. Fibers.trampolineRunningis reset even if re-entered Wasm unwinds.- A context's buffer is not freed until that context reaches
doneorcancelled. Asyncify.currDatamay 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.jsfrom "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.stateis 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_swapmodal_pump_runs_fiber_then_modal_resolvesfiber_swap_after_top_level_main_loop_parknested_quasi_modal_loop_exit_resumes_DoRunnested_quasi_modal_pump_error_does_not_hangclipboard_has_text_timeout_does_not_crashout_of_order_sleep_resolutionfiber_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()andShowQuasiModal()synchronous from KiCad's perspective. - Can be built incrementally from the current
handlesleep.jsshim. - 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
currDatabehind 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.