pcbjam/docs/features/browser-tools/0002-wasm-coroutine-deep-dive.md
2026-06-05 13:36:44 +02:00

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 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:

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:

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:

  1. Unwinding the entire call stack (every frame saves state and returns)
  2. 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 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:

// 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:

  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."

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:

  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.

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:

  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:

[[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:

  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
  • The inject-dyncall-shims.sh JS patching
  • The apply-asyncify.sh import configuration
  • The main context lazy initialization

What needs fixing

  • wasm_fcontext_entry: add while(true), remove stack-local buffers, remove emscripten_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