31 KiB
KiCad WASM Coroutine Deep Dive
Why Our Use Case Is Special
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 callsrenderFrame()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:
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:
- Were designed for desktop with coroutine-based control flow
- Are now being ported to the browser via Emscripten
- 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:
make_fcontext(stack, size, entry_func); // Create a new context
jump_fcontext(&old, new, value); // Switch contexts
release_fcontext(ctx); // Free a context
On native: ~20 lines of assembly per platform (x86, ARM, etc.) that saves/loads CPU registers. On WASM: impossible natively, must be emulated.
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.
The Full Stack of Abstractions
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.
Two globals drive everything:
__asyncify_state: 0 = Normal, 1 = Unwinding, 2 = Rewinding__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:
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):
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:
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:
- Unwinding the entire call stack (every frame saves state and returns)
- Rewinding the entire call stack (every frame re-enters, restores state, skips ahead)
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 indexptrwith one intarg, returning void"
They existed because calling a WASM function pointer from JavaScript requires:
- Looking up the function in the
WebAssembly.Tableby index - 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:
// 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:
// 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:
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 |
|---|---|---|
Fibers.finishContextSwitch |
dynCall_vi(entryPoint, userData) |
Fiber entry function never called — coroutines are dead |
_emscripten_set_main_loop |
dynCall_v(callback) |
Main loop callback is a no-op |
_emscripten_async_call |
dynCall_vi(callback, arg) |
Timer callbacks never fire |
___call_sighandler |
dynCall_vi(handler, sig) |
Signal handlers are no-ops |
invokeEntryPoint (pthreads) |
dynCall_ii(entry, arg) |
Thread entry never called |
| HTML5 event callbacks | dynCall_iiii(callback, ...) |
Mouse/keyboard events ignored |
Why finishContextSwitch Matters Most For Us
This is the function that runs when a fiber is being entered for the first time. The flow:
finishContextSwitch(newFiber) {
// ... restore C stack ...
var entryPoint = /* read from fiber struct */;
if (entryPoint !== 0) {
// FIRST TIME entering this fiber: call the entry function
var userData = /* read from fiber struct */;
// THIS LINE is what's broken:
{{{ makeDynCall('vi', 'entryPoint') }}}(userData);
//
// 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:
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:
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:
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:
- Written by someone who clearly understood the Emscripten fiber API constraints
- Reviewed by the QEMU maintainers
- Accepted into the official QEMU repository
- Has been running in production
It's only 127 lines. It's the cleanest, most proven reference for "how to do coroutines in Emscripten."
QEMU's Full Implementation
Source: github.com/qemu/qemu/blob/master/util/coroutine-wasm.c
The struct:
typedef struct {
Coroutine base; // QEMU's base coroutine type
void *stack; // C stack buffer (heap-allocated)
size_t stack_size;
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):
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:
- A new QEMU coroutine is created for some I/O operation.
emscripten_fiber_init()is called withcoroutine_trampolineas the entry function.- When the coroutine is first entered (someone swaps to it),
coroutine_trampolinestarts running. - It calls
co->entry(co->entry_arg)— this is the actual I/O handler. - The I/O handler might pause (yield) many times while waiting for data. Each yield does a
fiber_swapback to the caller, and each resume does afiber_swapback to this coroutine. But throughout all of that,coroutine_trampolineis still on the stack — we're inside theco->entry()call. - Eventually the I/O handler finishes and returns.
coroutine_trampolineresumes after theco->entry()line.- It calls
qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)— this swaps back to the caller with a "I'm done" flag. - 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). - The entry function never returns. The
while(true)guarantees it.
Creating a coroutine:
Coroutine *qemu_coroutine_new(void)
{
CoroutineEmscripten *co = g_malloc0(sizeof(*co));
co->stack_size = COROUTINE_STACK_SIZE;
co->stack = qemu_alloc_stack(&co->stack_size);
co->asyncify_stack_size = COROUTINE_STACK_SIZE;
co->asyncify_stack = g_malloc0(co->asyncify_stack_size);
emscripten_fiber_init(
&co->fiber,
coroutine_trampoline, // the infinite-loop entry
&co->base, // user_data
co->stack, co->stack_size,
co->asyncify_stack, co->asyncify_stack_size
);
return &co->base;
}
Both stacks are heap-allocated and persist for the coroutine's entire lifetime.
Context switch:
CoroutineAction qemu_coroutine_switch(Coroutine *from_, Coroutine *to_,
CoroutineAction action)
{
CoroutineEmscripten *from = DO_UPCAST(CoroutineEmscripten, base, from_);
CoroutineEmscripten *to = DO_UPCAST(CoroutineEmscripten, base, to_);
set_current(to_);
to->action = action; // Tell the target why
emscripten_fiber_swap(&from->fiber, &to->fiber); // Swap!
return from->action; // Read what caller set
}
Communication between coroutines uses the action field: one side sets it before swapping, the other reads it after resuming.
Main thread bootstrap (lazy init):
Coroutine *qemu_coroutine_self(void)
{
Coroutine *self = get_current();
if (!self) {
// First call: capture the main thread as a fiber
CoroutineEmscripten *leaderp = g_malloc0(sizeof(*leaderp));
leaderp->asyncify_stack = g_malloc0(leader_asyncify_stack_size);
leaderp->asyncify_stack_size = leader_asyncify_stack_size;
emscripten_fiber_init_from_current_context(
&leaderp->fiber,
leaderp->asyncify_stack,
leaderp->asyncify_stack_size
);
set_leader(leaderp);
self = &leaderp->base;
set_current(self);
}
return self;
}
Cleanup:
void qemu_coroutine_delete(Coroutine *co_)
{
CoroutineEmscripten *co = DO_UPCAST(CoroutineEmscripten, base, co_);
qemu_free_stack(co->stack, co->stack_size);
g_free(co->asyncify_stack);
g_free(co);
}
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.
How Our Implementation Compares to QEMU
Our Code (kicad/thirdparty/libcontext/libcontext.cpp)
[[noreturn]] void wasm_fcontext_entry(void* aArg)
{
auto* ctx = static_cast<wasm_fcontext*>(aArg);
// Step 1: Run the coroutine body
ctx->entry(ctx->transfer_value);
// Step 2: The coroutine body returned. We're in trouble.
ctx->running = false;
// Step 3: Try to swap back to the caller
if (ctx->return_to)
{
// Create a TEMPORARY fiber just so we have something to swap FROM
emscripten_fiber_t finished_ctx {};
alignas(16) char finished_asyncify_stack[64*1024] {};
emscripten_fiber_init_from_current_context(&finished_ctx, ...);
// Swap to the caller. We'll never come back.
emscripten_fiber_swap(&finished_ctx, &ctx->return_to->fiber);
}
// Step 4: If we get here, kill everything
emscripten_unwind_to_js_event_loop();
}
The problems:
-
Step 2 is dangerous. The entry function returned. According to Emscripten docs, this should terminate the program. We're in undefined territory.
-
Step 3 creates stack-local buffers.
finished_ctxandfinished_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 tofinished_ctx(becauseemscripten_fiber_swapsaves the asyncify state into it). If Asyncify ever tries to do anything with those pointers, it's reading garbage memory. -
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:
[[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.
ctx->running = false;
if (ctx->return_to) {
ctx->return_to->transfer_value = 0;
ctx->return_to->running = true;
g_current_context = ctx->return_to;
emscripten_fiber_swap(&ctx->fiber, &ctx->return_to->fiber);
// If we're swapped back to (unlikely), the while(true) loops
}
}
// We never reach here
}
Key differences:
while(true)ensures we never return from the entry function- We swap using
ctx->fiber(the coroutine's own, heap-allocated fiber) instead of creating a stack-local temporary - 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:
- Rebuild the WASM module (
docker/build.sh) - Run the PCBnew E2E test (
cd tests && npm run test:kicad -- --grep "select draw lines") - Check if toolbars now fully appear (the
tools: []should become non-empty) - 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 returnedintptr_tis a pointer toINVOCATION_ARGSthat 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_fcontextstruct design - The
make_fcontext/jump_fcontextAPI mapping to fibers - The
inject-dyncall-shims.shJS patching - The
apply-asyncify.shimport configuration - The main context lazy initialization
What needs fixing
wasm_fcontext_entry: addwhile(true), remove stack-local buffers, removeemscripten_unwind_to_js_event_loop()- Dead code cleanup: delete
wasm/libcontext/directory
Alternative approaches considered
| Approach | Viability | Notes |
|---|---|---|
| Fix current Asyncify + fibers | HIGH | Adopt QEMU's trampoline pattern |
| JSPI (JS Promise Integration) | MEDIUM | Future Asyncify replacement, limited browser support |
| Event-driven state machines | NOT VIABLE | Rewrites every KiCad tool |
| C++20 stackless coroutines | NOT COMPATIBLE | KiCad needs stackful suspension |
| WASM Stack Switching proposal | FUTURE | Not standardized yet |
References
- QEMU coroutine-wasm.c — gold standard implementation
- Emscripten fiber.h docs — API reference
- Asyncify blog post — deep technical dive
- Binaryen Asyncify.cpp — the compiler pass source
- Fiber PR #9859 — design discussion
- minicoro — single-header coroutine lib with WASM support
- Issue #13302 — fiber swap return value bug
- Issue #12733 — dynCall removal discussion