### Most WASM projects don't need coroutines at all
When you think of "compile C/C++ to WebAssembly with Emscripten," the typical projects are:
- **Games** (Unity, Unreal, etc.): They have a main loop that renders frames. The game engine calls `emscripten_set_main_loop(renderFrame, 60, 0)` and Emscripten calls `renderFrame()` 60 times per second. No coroutines needed — everything is event-driven already.
- **Command-line tools** (ffmpeg, SQLite, etc.): They run, produce output, and exit. Linear execution. No coroutines.
- **Simple GUI apps**: They handle events through callbacks. Button clicked → run handler. No need to pause mid-function.
**KiCad is unusual** because its interactive tools use a **synchronous programming model** inside a coroutine:
```cpp
void PCB_TOOL::DrawLine(TOOL_EVENT& evt) {
Point p1 = WaitForClick(); // ← PAUSES HERE, waits for user
Point p2 = WaitForClick(); // ← PAUSES HERE again
CreateLine(p1, p2);
}
```
This code looks simple and linear, but `WaitForClick()` can't actually block in a browser. Instead, KiCad uses a coroutine to pause the function, return control to the browser event loop, and resume later when the click arrives. This requires the ability to **save and restore the entire call stack** — which is what libcontext and fibers do.
### Very few projects need this
The number of large C/C++ applications that:
1. Were designed for desktop with coroutine-based control flow
2. Are now being ported to the browser via Emscripten
3. Need those coroutines to actually work
...is very small. QEMU is one. KiCad is another. Maybe a handful of others.
Because so few people need this, the Emscripten support for it is:
- **Functional** (the fiber API exists and works)
- **But rough around the edges** (bugs in internal code, poor documentation, edge cases not handled)
- **And under-tested** (most users never exercise these code paths)
### Emscripten's priorities
Emscripten's main user base is game engines and simple tools. Their effort goes into:
- Compilation speed
- WASM binary size
- Performance of simple programs
- SIMD, threading, memory64
The fiber/coroutine path is a niche feature. The `makeDynCall` bug persists because almost nobody hits it — the people who do (like us) work around it.
---
## Concepts From the Ground Up
### What is "The Stack"?
Every time you call a function, a new "frame" is pushed onto the call stack holding local variables, return address, and arguments.
```
main() calls drawLine() calls calculatePoint()
Stack (grows downward):
┌──────────────────┐
│ main() │ ← local vars of main
├──────────────────┤
│ drawLine() │ ← local vars of drawLine
├──────────────────┤
│ calculatePoint() │ ← TOP: local vars of calculatePoint
└──────────────────┘
```
**"Stack-local"** = a variable that lives in a frame. When that function returns, the frame is popped and the variable's memory becomes garbage.
### What is a Coroutine?
A function that can **pause** mid-execution and **resume** later. KiCad needs this because in a browser you can't block waiting for a mouse click (the page freezes). So a drawing tool must pause after requesting a click, let the browser run, then resume when the click arrives.
### Stackful vs Stackless
**Stackless** (C++20 `co_await`): Can only pause at the top level. If `waitForClick()` is 5 calls deep, you can't pause.
**Stackful** (KiCad): Can pause from **anywhere** in the call stack. The entire stack is saved and restored. KiCad needs this.
### What is libcontext?
A small C library (from Boost.Context) that performs context switching via three functions:
```cpp
make_fcontext(stack, size, entry_func); // Create a new context
**libcontext is NOT a separate repo.** It's a directory inside KiCad (`kicad/thirdparty/libcontext/`). Our kicad submodule points to our fork (`emergence-engineering/kicad-source-mirror.git`), so we already own it. No additional forking needed.
KiCad COROUTINE class (kicad/include/tool/coroutine.h)
↓ calls
libcontext API (make_fcontext / jump_fcontext)
↓ implemented with (on WASM)
Emscripten Fibers (emscripten_fiber_swap)
↓ built on
Asyncify (wasm-opt binary transformation)
↓ manipulates
WebAssembly call stack (in the browser's WASM runtime)
```
---
## How Asyncify Actually Works
### The Core Idea
WASM doesn't let you save/restore call stacks like native assembly does. Asyncify works around this with a completely different approach: **it rewrites your WASM bytecode** so that every function can cooperatively save its state and return, then later be re-called and skip ahead to where it left off.
-`__asyncify_data`: pointer to a buffer that holds saved state
### The Asyncify Data Buffer
Each fiber/coroutine has its own buffer (the "asyncify stack"). Layout:
```
[ptr+0] i32: current stack position (grows upward as data is pushed)
[ptr+4] i32: stack end (upper bound)
[ptr+8] i32: rewind_id (which WASM export to re-enter during rewind)
[ptr+12] ... actual saved data (call indices + serialized local variables)
```
### What the Binary Transformation Does
Asyncify (via `wasm-opt --asyncify`) rewrites every function in the WASM module. Here's a before/after:
**Before transformation:**
```c
void foo(int x) {
x = x + 1;
x = x / 2;
bar(x); // ← this call might trigger a pause
while (x & 7) {
x = x + 1;
}
}
```
**After transformation (pseudocode of the generated WASM):**
```c
void foo(int x) {
// PRELUDE: if we're rewinding, restore our saved locals
if (__asyncify_state == REWINDING) {
x = pop_from_asyncify_stack(); // restore x
call_index = pop_from_asyncify_stack(); // which call site to skip to
}
// Normal code: skip during rewind
if (__asyncify_state == NORMAL) {
x = x + 1;
x = x / 2;
}
// The call site: execute if normal, OR if rewinding to this specific call
if (__asyncify_state == NORMAL || call_index == 0) {
bar(x);
// After the call returns: are we unwinding?
if (__asyncify_state == UNWINDING) {
push_to_asyncify_stack(0); // save call index (we were at bar())
push_to_asyncify_stack(x); // save local variable x
return; // cooperatively return up the chain
}
}
// Rest of function: skip during rewind
if (__asyncify_state == NORMAL) {
while (x & 7) {
x = x + 1;
}
}
}
```
**Key insight:** Every function in the call chain gets this treatment. During unwind, each frame saves its state and returns normally. During rewind, each frame skips ahead to the right call site and dives deeper.
### The Complete Unwind Sequence (Pause)
When something wants to pause (e.g., `emscripten_fiber_swap`):
```
1. JS sets __asyncify_state = UNWINDING (1)
2. JS sets __asyncify_data = pointer to this fiber's buffer
3. The call to emscripten_fiber_swap returns to its caller in WASM
4. The caller checks: state == UNWINDING? Yes.
→ Pushes its call index + locals to asyncify_data buffer
→ Returns to ITS caller
5. That caller checks: state == UNWINDING? Yes.
→ Same thing: push call index + locals, return
6. This cascades all the way up until the WASM export returns to JS
7. JS: all WASM frames have returned. Call asyncify_stop_unwind().
→ __asyncify_state = 0 (Normal)
8. The entire WASM call stack is gone. State is saved in the buffer.
```
### The Complete Rewind Sequence (Resume)
When something wants to resume a paused fiber:
```
1. JS sets __asyncify_state = REWINDING (2)
2. JS sets __asyncify_data = pointer to the saved fiber's buffer
3. JS calls the same WASM export function that was running before (e.g., main)
4. main() enters. Sees state == REWINDING.
→ Pops its locals from asyncify_data buffer
→ Pops its call index → skips ahead to that call site
→ Calls the function at that call site
5. That function enters. Sees state == REWINDING.
→ Same thing: pop locals, pop call index, skip ahead, call deeper
6. This continues until we reach the DEEPEST frame (the one that paused)
7. The deepest frame calls asyncify_stop_rewind()
→ __asyncify_state = 0 (Normal)
8. Execution continues normally from exactly where it paused.
```
### How `emscripten_fiber_swap` Coordinates Two Fibers
Each `emscripten_fiber_t` struct contains:
```c
typedef struct {
void* stack_base; // C stack top
void* stack_limit; // C stack bottom
void* stack_ptr; // current C stack pointer (saved on swap)
void (*entry)(void*); // entry function (NULL after first call)
void* user_data; // argument for entry function
asyncify_data_t asyncify_data; // this fiber's own asyncify buffer
} emscripten_fiber_t;
```
The swap sequence for switching from Fiber A to Fiber B:
```
Fiber A is running (state = Normal)
A calls emscripten_fiber_swap(&A, &B):
JS side:
1. state = Unwinding
2. currData = A.asyncify_data (save into A's buffer)
3. asyncify_start_unwind(A.asyncify_data)
4. Save A's C stack pointer into A.stack_ptr
5. Set Fibers.nextFiber = B
6. Return (emscripten_fiber_swap returns to caller)
WASM side:
7. A's call chain unwinds: each frame saves state into A.asyncify_data
8. All WASM frames return to JS
JS side (maybeStopUnwind):
9. asyncify_stop_unwind() → state = Normal
10. Fibers.trampoline() → finishContextSwitch(B)
finishContextSwitch(B):
11. Restore B's C stack pointer + limits
12. Is B.entry != NULL? (first time entering B)
YES → call B.entry(B.user_data) ← this is where dynCall_vi matters!
NO → (B was previously paused)
asyncify_start_rewind(B.asyncify_data)
doRewind() → calls the saved export, which replays B's call chain
B is now running.
Later, B calls emscripten_fiber_swap(&B, &A):
Same process in reverse:
- B's state is saved into B.asyncify_data
- finishContextSwitch(A):
A.entry == NULL → rewind into A.asyncify_data
A's call chain replays until emscripten_fiber_swap
emscripten_fiber_swap's "else" branch runs:
state = Normal
asyncify_stop_rewind()
A continues exactly where it left off.
```
### Why This Is Slow
Every context switch involves:
1. Unwinding the entire call stack (every frame saves state and returns)
Native libcontext: save ~15 CPU registers, change stack pointer. Done in nanoseconds.
Asyncify: serialize/deserialize every frame. Documented overhead: 20-100% slowdown.
---
## The dynCall Problem
### What dynCall Functions Were
`dynCall_vi`, `dynCall_ii`, etc. were JavaScript wrapper functions for calling WASM **function pointers** from JS. Naming convention:
-`v` = void, `i` = int, `f` = float, `d` = double
- First letter = return type, rest = argument types
-`dynCall_vi(ptr, arg)` = "call the WASM function at table index `ptr` with one int `arg`, returning void"
They existed because calling a WASM function pointer from JavaScript requires:
1. Looking up the function in the `WebAssembly.Table` by index
2. Calling it with the right types
Before the WebAssembly.Table API stabilized, Emscripten generated one typed wrapper per signature used in the program.
### Why They Were Removed
Starting Emscripten 2.0.2 (August 2020), removed for performance:
The replacement is `getWasmTableEntry(index)` which directly looks up the function in the table:
```javascript
// Old way:
dynCall_vi(funcPtr, arg1);
// New way:
getWasmTableEntry(funcPtr)(arg1);
```
Benchmarks showed the new way is **60-80% faster** and produces smaller JS output.
### How Emscripten's Internal Code Uses dynCall
Emscripten's own JS library files (the runtime glue) need to call WASM function pointers too. They use a preprocessor macro called `makeDynCall`:
```javascript
// Inside Emscripten's library_async.js, library_html5.js, etc.
// This is a BUILD-TIME macro, expanded by Emscripten's preprocessor
// Old syntax (pre-2.0.9):
{{{ makeDynCall('vi') }}}(funcPtr, arg1)
// New syntax (2.0.9+):
{{{ makeDynCall('vi', 'funcPtr') }}}(arg1)
```
The difference: the old syntax doesn't tell the macro which variable holds the function pointer. The new syntax does.
### The Silent Degradation Bug
Here's what happens when the macro expands. Inside Emscripten's `parseTools.mjs`:
```javascript
function makeDynCall(sig, funcPtr) {
if (funcPtr === undefined) {
// OLD SYNTAX: funcPtr not provided
if (DYNCALLS) {
// -sDYNCALLS=1 is set: use the generated dynCall_vi function
return `dynCall_${sig}`;
}
// DYNCALLS is false (default since ~2.0.3)
// Try to find an exported dynCall_vi... it doesn't exist
// Fall through to:
return `((args) => {} /* a dynamic function call to signature ${sig},
but there are no exported function pointers with that signature,
so this path should never be taken. */)`;
}
// NEW SYNTAX: funcPtr provided → use getWasmTableEntry
return `getWasmTableEntry(${funcPtr})`;
}
```
**The critical problem:** Emscripten's **own internal library files** still use the old syntax in many places. When `DYNCALLS=false` (the default), the macro generates an empty arrow function `(a1 => {})` instead of actually calling the function.
The generated comment even says *"this path should never be taken"* — but it IS taken, because the internal library files trigger it.
### What Breaks
| Location in Emscripten JS | What the no-op replaces | Effect |
// With old-syntax makeDynCall and DYNCALLS=false, this becomes:
// (a1 => {})(userData);
//
// The entry function is NEVER CALLED.
// The fiber "starts" but its body never runs.
} else {
// Subsequent entry: rewind via asyncify
// This path works fine
}
}
```
So: every fiber's FIRST entry goes through `dynCall_vi(entryPoint, userData)`. If that's a no-op, the coroutine body never starts. The fiber appears to start (the swap succeeds) but nothing actually happens inside it.
### What Our Fix Does
`inject-dyncall-shims.sh` does two things:
**1. Generates Asyncify-aware dynCall shims:**
```javascript
function dynCall_vi(funcPtr, arg1) {
var func = getWasmTableEntry(funcPtr);
// Track in Asyncify's export call stack so unwind/rewind works
Asyncify.exportCallStack.push('dynCall_vi');
try {
func(arg1);
} finally {
if (Asyncify.currData) {
// We're mid-unwind: set the rewind function
Asyncify.setDataRewindFunc(Asyncify.currData);
}
Asyncify.exportCallStack.pop();
}
}
```
**2. Patches the six empty arrow function patterns** to call the real shims.
### Why Not Just Use `-sDYNCALLS=1`?
You could. Unity does. But there's a subtlety:
Plain `dynCall_vi` from `-sDYNCALLS=1` is just:
```javascript
function dynCall_vi(index, a1) { getWasmTableEntry(index)(a1); }
```
It does **not** push/pop `Asyncify.exportCallStack`. That tracking is needed for Asyncify to know which WASM export to re-enter during rewind. Without it, if a fiber swap happens inside an indirect call (function pointer), Asyncify loses track of the call chain and the rewind fails.
Our shims add this tracking. That's the extra value over `-sDYNCALLS=1`.
### Is This an Emscripten Bug?
**Yes, arguably.** The problem is that Emscripten's own internal library files (`library_async.js`, `library_html5.js`, etc.) use the deprecated `makeDynCall` syntax, which silently degrades to no-ops when `DYNCALLS=false`. The generated comment says "this path should never be taken" but it's taken constantly. Worth filing as a bug.
---
## QEMU: The Gold Standard Reference
### What QEMU actually is
QEMU is a **machine emulator and virtualizer**. It lets you:
- Run an ARM Linux system on your x86 laptop
- Run Windows inside a virtual machine on Linux
- Emulate hardware for embedded development
It's one of the most important open-source infrastructure projects — it powers much of cloud computing (via KVM/QEMU).
### Why QEMU uses coroutines
QEMU's disk I/O layer uses coroutines for the same reason KiCad uses them: to write **synchronous-looking code** that actually runs asynchronously.
When QEMU needs to read from a virtual disk:
```c
void handle_disk_read(Request *req) {
Buffer data = read_from_disk(req->sector); // ← this might take time
send_data_to_guest(req, data);
}
```
`read_from_disk()` might need to wait for actual I/O. Instead of blocking (which would freeze the emulator), QEMU pauses the coroutine, processes other events, and resumes when the data is ready. Exactly the same pattern as KiCad's `WaitForClick()`.
### Why QEMU was recently ported to WASM
People want to run QEMU in the browser — to provide virtual machines in web-based development environments, education tools, etc. The QEMU project accepted patches to build with Emscripten, and part of that work was making coroutines work in WASM.
### Why QEMU's solution is relevant to us
QEMU and KiCad have the **exact same problem**:
- Both are large C/C++ codebases
- Both use stackful coroutines internally
- Both need those coroutines to work when compiled to WASM
- Both use Emscripten's fiber API as the backend
QEMU's solution (`util/coroutine-wasm.c`) was:
1. Written by someone who clearly understood the Emscripten fiber API constraints
2. Reviewed by the QEMU maintainers
3. Accepted into the official QEMU repository
4. Has been running in production
It's only 127 lines. It's the cleanest, most proven reference for "how to do coroutines in Emscripten."
void *asyncify_stack; // Asyncify data buffer (heap-allocated)
size_t asyncify_stack_size;
CoroutineAction action; // Communication channel (YIELD, TERMINATE, etc.)
emscripten_fiber_t fiber; // The Emscripten fiber handle
} CoroutineEmscripten;
```
Each coroutine owns **two** heap-allocated buffers: a C stack and an asyncify stack. Both persist for the coroutine's lifetime.
**The trampoline (most important part):**
```c
static void coroutine_trampoline(void *co_)
{
Coroutine *co = co_;
while (true) { // ← NEVER returns
co->entry(co->entry_arg); // Run the coroutine body
qemu_coroutine_switch(co, co->caller,
COROUTINE_TERMINATE); // Swap back to caller
}
}
```
Walk-through:
1. A new QEMU coroutine is created for some I/O operation.
2.`emscripten_fiber_init()` is called with `coroutine_trampoline` as the entry function.
3. When the coroutine is first entered (someone swaps to it), `coroutine_trampoline` starts running.
4. It calls `co->entry(co->entry_arg)` — this is the actual I/O handler.
5. The I/O handler might pause (yield) many times while waiting for data. Each yield does a `fiber_swap` back to the caller, and each resume does a `fiber_swap` back to this coroutine. But throughout all of that, `coroutine_trampoline` is still on the stack — we're inside the `co->entry()` call.
6. Eventually the I/O handler finishes and returns.
7.`coroutine_trampoline` resumes after the `co->entry()` line.
8. It calls `qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)` — this swaps back to the caller with a "I'm done" flag.
9. The `while(true)` loops back to the top. If nobody ever swaps back to this coroutine, it just stays suspended here forever (which is fine — the fiber is deallocated later).
10. The entry function **never returns**. The `while(true)` guarantees it.
Both stacks freed when coroutine destroyed. No stack-local temporaries, no abandoned frames.
### The Key Insight: Entry Function Must NEVER Return
Emscripten's fiber API has a rule: **if the fiber's entry function returns, the program terminates**. This is documented in `fiber.h`:
> "If entry_func returns, the entire program will end, as if main had returned."
Why? Because when the entry function returns, control goes... nowhere. The fiber's stack is done. There's no caller to return to (the fiber was started from a swap, not a regular function call). Emscripten handles this by treating it as program exit.
1.**Step 2 is dangerous.** The entry function returned. According to Emscripten docs, this should terminate the program. We're in undefined territory.
2.**Step 3 creates stack-local buffers.**`finished_ctx` and `finished_asyncify_stack` (64KB!) are on this function's stack. When we swap away, this stack frame is abandoned. But Asyncify's bookkeeping still holds pointers to `finished_ctx` (because `emscripten_fiber_swap` saves the asyncify state into it). If Asyncify ever tries to do anything with those pointers, it's reading garbage memory.
3.**Step 4 uses `emscripten_unwind_to_js_event_loop()`**. This function says "I'm done with all WASM execution, return to the browser event loop." It tears down the ENTIRE WASM call stack — not just this fiber, but everything. If this happens during KiCad's startup sequence, the startup dies.
### The Three Differences
| Issue | Our Code | QEMU |
|-------|----------|------|
| Entry function returns? | Yes, then handles it | Never - `while(true)` |
| Stack-local asyncify buffers? | Yes (64KB on stack) | No - all heap-allocated |
| `emscripten_unwind_to_js_event_loop`? | Yes, as fallback | Not used |
---
## How Would We Adopt QEMU's Pattern?
### The change is small
The fix is to replace our `wasm_fcontext_entry` with a QEMU-style trampoline:
**QEMU-style replacement:**
```cpp
[[noreturn]] void wasm_fcontext_entry(void* aArg)
{
auto* ctx = static_cast<wasm_fcontext*>(aArg);
while (true) {
// Run the coroutine body
ctx->entry(ctx->transfer_value);
// Coroutine finished. Swap back to whoever started us.
// If we're swapped back to (unlikely), the while(true) loops
}
}
// We never reach here
}
```
Key differences:
1.`while(true)` ensures we never return from the entry function
2. We swap using `ctx->fiber` (the coroutine's own, heap-allocated fiber) instead of creating a stack-local temporary
3. No `emscripten_unwind_to_js_event_loop()` — we just stay in the loop
### How hard is the change?
**Maybe 15-20 lines changed** in one file (`kicad/thirdparty/libcontext/libcontext.cpp`). The architecture is already right — we use Emscripten fibers, we have a `wasm_fcontext` struct with proper fields, we have `g_current_context` tracking. The only wrong part is the entry-return handling.
The change is small but **the testing is critical**. After making it:
1. Rebuild the WASM module (`docker/build.sh`)
2. Run the PCBnew E2E test (`cd tests && npm run test:kicad -- --grep "select draw lines"`)
3. Check if toolbars now fully appear (the `tools: []` should become non-empty)
4. Check if drawing actually works
### What could go wrong?
The biggest risk is that `jump_fcontext`'s semantics don't perfectly match what KiCad's COROUTINE class expects. Specifically:
- KiCad's COROUTINE uses `jump_fcontext(&old_ctx, new_ctx, value)` where the returned `intptr_t` is a pointer to `INVOCATION_ARGS` that tells the coroutine why it was resumed (FROM_ROOT, FROM_ROUTINE, CONTINUE_AFTER_ROOT).
- If the trampoline loop doesn't correctly set the transfer value before swapping back, the caller might misinterpret why the coroutine stopped.
But this is testable — the E2E test will catch it.
---
## Verification of the Investigation Document
The original investigation document (`0001-kicad-wasm-tool-activation-investigation.md`) was reviewed and verified:
| Claim | Verdict |
|-------|---------|
| Fiber entry callback was a no-op | TRUE |
| inject-dyncall-shims.sh fixes it | TRUE |
| emscripten_fiber_swap needed in ASYNCIFY_IMPORTS | TRUE |
| Tools array is empty / startup stalls | TRUE |
| Problem is in WASM coroutine layer, not KiCad UI | TRUE (well-supported) |
| libcontext impl is "experimental / not clean" | FALSE - it's well-structured production code |
| Coroutine return is the remaining issue | PLAUSIBLE but not proven |
| KiCad investigative changes exist in files | NOT FOUND (likely already reverted) |
---
## Summary
### What's already correct
- The `wasm_fcontext` struct design
- The `make_fcontext` / `jump_fcontext` API mapping to fibers