pcbjam/docs/features/async/06-design-b-fiber-first-runtime.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

17 KiB

06 - Design B: fiber-first async runtime

STATUS (2026-06-23): Design B is now being implemented on Asyncify — see 12 (plan/phases/test-matrix) and 13 (engineering spec/work log). The de-park (../wasm-exceptions/09) replaced the top-level throw with an Asyncify park and regressed the coroutine suite — the red scenario this design fixes. External research (Ruby-WASM, Julia-WASM, Qt-for-WASM) confirms the fiber-scheduler is the proven path.

Goal: make the architecture conceptually cleaner by reducing the number of suspension primitives. Instead of having tool coroutines use fibers while modal/clipboard/font/nested loops use EM_ASYNC_JS sleeps, put every blocking-looking operation onto a fiber-like runtime and let one scheduler decide which stack runs next.

Status

This is a design note, not an implementation. It is more invasive than Design A, but it is also cleaner.

Design A says:

Keep both suspension systems.
Put an arbiter in front of them.

Design B says:

Stop having two suspension systems.
Represent every suspendable C++ activity as a scheduler context with its own stack/buffer.

In other words: modals, clipboard waits, font waits, quasi-modal loops, and tool coroutines all become the same kind of thing from the runtime's point of view.

Concepts

Coroutine

A coroutine is code that can yield and later resume.

KiCad tools already have this shape:

tool starts
  -> waits for user event
  -> yields
  -> resumes when event arrives
  -> waits again

The useful property is that the tool can be written in direct style. It does not have to become a chain of callbacks.

Stackless coroutine vs. stackful coroutine

A stackless coroutine can only suspend at explicit points in that function. JavaScript async/await is stackless from the JS perspective: every caller must also understand Promise control flow.

A stackful coroutine can suspend deep inside normal calls:

tool()
  -> helper()
    -> dialog.ShowModal()
      -> suspend here

When it resumes, it continues from inside ShowModal() and returns normally to helper() and then to tool().

KiCad wants stackful behavior because a large C++ application assumes blocking APIs like ShowModal() and synchronous predicates.

Fiber

A fiber is a stackful coroutine with its own stack.

Native platforms switch fibers by saving CPU registers and the stack pointer. In WebAssembly we cannot directly manipulate the real engine stack, so Emscripten implements fibers using Asyncify.

The important thing: a fiber has an identity and storage.

fiber A:
  C stack memory
  asyncify_data buffer
  status

fiber B:
  C stack memory
  asyncify_data buffer
  status

This is exactly the model we want for every independently parked operation.

Asyncify buffer

An Asyncify buffer is where the saved Wasm stack goes when a context parks.

Think of it like a suitcase for a suspended stack:

before suspend:
  live call stack is inside Wasm engine

during suspend:
  saved stack is copied into context.dataPtr

after resume:
  stack is rebuilt from context.dataPtr

Many suitcases may exist. Only one suitcase is being packed or unpacked at any instant.

Trampoline

A trampoline is code that transfers control from one context to another after the old context has fully yielded.

For fibers:

old fiber asks to switch to new fiber
old fiber unwinds
JS boundary is reached
trampoline rewinds new fiber

It is called a trampoline because control "bounces" through it. The old stack cannot directly jump into the new stack while it is still being unwound. The trampoline is the neutral place where the runtime can say, "old is saved, now start new."

Scheduler

A scheduler decides which context runs next.

In a cooperative scheduler, contexts yield voluntarily. There is no preemption. That matches browser main-thread WebAssembly well: only one thing runs at a time, and control returns to the browser between slices.

Design B wants a scheduler like:

ready queue:
  tool fiber
  modal continuation
  nested loop continuation
  clipboard continuation

running:
  exactly one context

Current architecture

Today KiCad-WASM has two broad suspension families.

Family 1: fibers.

KiCad tool coroutine
  -> libcontext
  -> emscripten_fiber_swap
  -> fiber-owned asyncify buffer

Family 2: EM_ASYNC_JS sleeps.

wxDialog::ShowModal
clipboard read/write/hasText/clear
font enumeration
nested event loop if c27 is adopted
  -> Asyncify.handleSleep
  -> malloc-owned asyncify buffer

Both families eventually manipulate the same Asyncify.currData and Asyncify.state.

Design B removes that split.

Core design

Make all blocking-looking APIs suspend the current scheduler context instead of directly using EM_ASYNC_JS as an independent stack owner.

Conceptual API:

int wxWasmAwaitModal();
char* wxWasmAwaitClipboardRead();
int wxWasmAwaitClipboardHasText();
int wxWasmAwaitNestedLoopExit();

Internally:

current fiber/context calls await operation
  -> runtime records what JS Promise/event will wake it
  -> current context yields to scheduler/main context
  -> JS continues pumping browser events
  -> Promise/event resolves
  -> scheduler marks context ready
  -> scheduler later resumes that same context

From C++'s perspective, this still looks blocking:

int result = wxWasmAwaitModal();

From the runtime's perspective, there is no separate handleSleep stack competing with fibers. There are only scheduler contexts.

How ShowModal() would work

Current model:

wxDialog::ShowModal()
  -> startModal() EM_ASYNC_JS
  -> handleSleep parks the whole current C stack
  -> JS setTimeout loop calls ProcessEvents
  -> EndModal resolves Promise
  -> handleSleep rewinds the saved C stack

Fiber-first model:

wxDialog::ShowModal()
  -> show dialog
  -> register modal wait on current context
  -> yield current context to scheduler
  -> browser/main loop keeps pumping events
  -> EndModal stores return code and marks context ready
  -> scheduler resumes context
  -> ShowModal returns int

There is still a suspension. The difference is ownership.

In the current model, startModal() creates a separate handleSleep suspension that can overlap with tool fibers.

In Design B, the modal wait is a reason for the current fiber/context to yield. It does not create a second independent suspension mechanism.

How clipboard would work

Current model:

wxClipboard::GetData()
  -> js_readTextFromClipboard() EM_ASYNC_JS
  -> handleSleep
  -> Promise waits
  -> rewind C++ stack

Fiber-first model:

wxClipboard::GetData()
  -> start JS clipboard Promise
  -> current context yields
  -> Promise resolves with text or error
  -> scheduler resumes context
  -> GetData continues synchronously with stored result

This still allows the public wx API to look synchronous. But the wait is represented as "this fiber is blocked on clipboard" instead of "handleSleep owns another Asyncify buffer."

How quasi-modal nested loops would work

The linked wx commit c27fe8bf implements nested wxGUIEventLoop::DoRun() by adding another EM_ASYNC_JS pump. That is reasonable as an incremental fix, but in Design B the nested loop is just another scheduler wait.

Current c27-shaped model:

ShowQuasiModal
  -> wxGUIEventLoop::Run()
  -> nested DoRun
  -> wxWasmRunNestedLoop() EM_ASYNC_JS
  -> setTimeout pump calls ProcessEvents
  -> EndQuasiModal resolves Promise

Fiber-first model:

ShowQuasiModal
  -> wxGUIEventLoop::Run()
  -> nested DoRun registers "wait until this loop exits"
  -> current context yields to scheduler
  -> top-level browser loop continues pumping ProcessEvents
  -> EndQuasiModal calls loop->Exit()
  -> scheduler marks nested-loop context ready
  -> nested DoRun returns

No second emscripten_set_main_loop. No nested handleSleep pump. No independent Promise rewind directly from the nested loop.

The main loop in Design B

This is where de-parking becomes easier to understand.

The top-level Emscripten main loop currently uses:

emscripten_set_main_loop(ProcessEvents, 0, 1);

The 1 means "simulate an infinite loop." Emscripten implements that by throwing "unwind" out of the startup stack. This prevents main() and wxEntryReal() from returning into wx cleanup.

In Design B, the cleaner version is:

main loop is also a scheduler participant
app lifetime is owned by the scheduler/browser loop
wx cleanup runs only on real exit/unload

That probably means eventually replacing the top-level throw with explicit lifetime ownership:

startup initializes wx app
scheduler starts rAF/setTimeout event pump
startup returns without destroying wx app
real cleanup is deferred to page unload or explicit app exit

But this is the risky part. wx's normal contract says:

OnRun returns -> app exits -> cleanup happens

Browser apps want:

OnRun starts event pump -> app stays alive -> cleanup happens later

The existing simulate_infinite_loop=1 throw is Emscripten's shortcut for that mismatch. Design B would replace the shortcut with explicit lifetime rules.

Why this design may need de-parking

Design B tries to make every suspension normal and scheduler-owned. A plain JS throw that abandons the startup stack is not normal and not scheduler-owned.

So the cleanest Design B includes de-parking:

no special top-level throw
no hidden abandoned C++ stack
main loop is represented as a scheduler/lifetime state

But de-parking must be paired with suppressing normal wx cleanup during steady-state operation. Otherwise wxTheApp and top-level windows can be deleted while the browser rAF loop is still calling ProcessEvents().

That is why "de-park main" is not a trivial first patch. It belongs naturally in Design B, but it requires an app-lifetime plan.

Can we have separate Asyncify state for each stack?

Design B's answer is nuanced:

Separate durable state per stack: yes.
Separate active Asyncify.state per stack: no, not needed.

Each scheduler context gets:

own C stack/fiber stack
own asyncify_data buffer
own status/result/wait reason

But the Wasm instance still has one active transition at a time:

Normal -> Unwinding -> Normal
Normal -> Rewinding -> Normal

That is fine. A single-threaded cooperative scheduler only needs one active transition. The important thing is that parked contexts retain their own buffers while they are inactive.

Why not just queue handleSleep calls?

Because a modal dialog must pump events while it is waiting.

A bad queue would say:

modal sleep is active
do not allow any other suspension until modal resolves

That freezes the UI if the modal's own event pump needs to run tool callbacks or nested waits.

A good scheduler says:

modal context is parked
other contexts may run
when modal resolves, resume it later when the transition slot is free

Design B makes that cleaner by representing all waits as scheduler waits, not arbitrary nested handleSleep calls.

Implementation approaches

There are two possible ways to implement Design B.

B1: Fiberize wx waits on top of current libcontext

Keep the existing Emscripten fiber/libcontext machinery. Add a small runtime API:

using WAKE_TOKEN = int;

WAKE_TOKEN wasm_begin_async_wait(...);
int wasm_yield_until(WAKE_TOKEN token);
void wasm_resolve_wait(WAKE_TOKEN token, int result);

Then implement modal/clipboard/font/nested-loop waits through that API.

Rough flow:

C++ starts async JS operation
JS operation gets token
C++ yields current fiber/context
JS resolves token later
scheduler resumes waiting context

This approach keeps most of KiCad's coroutine system. The risk is making sure every caller is on a scheduler-owned context when it tries to wait. If code on the raw main stack calls a wait, the runtime must either fiberize the main stack first or reject with a clear diagnostic.

B2: Make the whole app run inside a managed root fiber

Instead of only tool coroutines being fibers, start KiCad inside a root managed fiber. Then even "main stack" waits are scheduler-owned.

Conceptual startup:

JS starts runtime
runtime creates root app fiber
root app fiber calls main/wxEntry
root app fiber yields when OnRun starts browser loop
browser loop/scheduler owns future resumes

This is cleaner but more invasive. It makes the top-level main loop, modal waits, tool coroutines, and nested loops all part of one runtime from the beginning.

If we were designing from scratch, this is probably the architecture. In an existing port, it is a bigger migration.

How this compares to Design A

Question Design A: JS arbiter Design B: fiber-first runtime
Keeps current EM_ASYNC_JS waits? Yes Mostly no
Requires de-parking main? Only if diagnostic proves needed Probably yes for the clean version
Touches wx app lifetime? Maybe Likely
JS shim complexity Medium/high Medium
C++ runtime changes Low/medium High
Conceptual cleanliness Good Best
Migration risk Lower Higher
End state Two suspension families under one arbiter One scheduler-owned suspension family

Failure modes

Code waits outside a managed context

If a blocking-looking API is called on a stack the scheduler does not own, it cannot safely yield.

Mitigation:

  • initialize a root app fiber early, or
  • detect unmanaged waits and abort with a diagnostic in development builds.

Lifetime cleanup runs too early

If de-parking lets wxEntryReal() continue into cleanup, the app can be destroyed while the browser loop still runs.

Mitigation:

  • make browser loop own app lifetime,
  • defer OnExit()/wxEntryCleanupReal() to page unload or explicit app exit,
  • ensure emscripten_cancel_main_loop() and cleanup are ordered.

Reentrancy bugs become visible

When waits become scheduler-managed, the browser can continue running other events while a context is parked. That is correct, but C++ code may have assumed certain globals cannot change while a "blocking" call is waiting.

Mitigation:

  • tests for out-of-order resolution,
  • clear "modal blocks this parent/window" rules,
  • keep wx's modal/quasi-modal disabling semantics intact.

Starvation

If the scheduler always resumes newly ready contexts first, an older parked context could wait too long.

Mitigation:

  • FIFO ready queue by default,
  • priority only for paint/input if proven necessary,
  • diagnostics for context age.

Test matrix

Design B should pass everything Design A needs, plus root-context tests:

  • app startup inside managed root fiber
  • top-level main loop starts without orphaning Asyncify state
  • ShowModal() from root context
  • ShowModal() from tool coroutine
  • ShowQuasiModal() from tool coroutine
  • clipboard read from root context
  • clipboard read from tool coroutine
  • font enumeration during startup
  • nested modal inside quasi-modal
  • exit/unload cleanup after parked contexts exist

Each test should assert:

  • no crash
  • no hang
  • correct return value
  • app remains interactive after the wait
  • cleanup does not run during steady-state main-loop pumping

Why this is elegant

Design B is elegant because it makes the runtime model match the actual problem:

KiCad is a synchronous C++ GUI app.
The browser is asynchronous.
Therefore the port needs a stackful cooperative scheduler.

Once we accept that, modal dialogs, clipboard operations, font enumeration, nested event loops, and tool coroutines are not different species. They are all contexts that sometimes wait.

The universal rule becomes:

No API directly owns Asyncify.
APIs ask the scheduler to park or wake contexts.
The scheduler alone performs Asyncify transitions.

That is the clean architecture.

Why this may be too much as the first fix

It asks us to change more than the bug requires:

  • modal implementation,
  • clipboard implementation,
  • font enumeration,
  • nested event loops,
  • possibly app startup and shutdown,
  • root stack ownership.

That is a lot of surface area for a port that already has working pieces.

So the pragmatic path is often:

1. Build Design A arbiter.
2. Use tests to locate remaining architectural pain.
3. Migrate high-risk waits toward Design B over time.
4. De-park/root-fiber the app only when the evidence says it is necessary or worth the cleanup.

Decision point

Choose Design B if we want the clean long-term runtime architecture and are willing to touch wx/KiCad lifetime boundaries.

Choose Design A first if we want to stabilize the current port and learn exactly which suspension overlaps still fail.