Main-repo side of a multi-part WIP covering the KiCad WASM tool-selection and nested-Asyncify work. Submodule commits are in kicad@f6e9239aaa (libcontext hygiene) and wxwidgets@bb80f91e8b (auibar registration + dialog diagnostics). ## scripts/common/inject-dyncall-shims.sh Wrap Asyncify.handleSleep / allocateData to save-and-restore Asyncify.currData around each EM_ASYNC_JS sleep. This fixes the nested Asyncify collision where a fiber swap that fired during a modal's event loop clobbered currData, and the modal's later doRewind used the fiber's buffer and hit "RuntimeError: index out of bounds". Root cause documented as Emscripten Issue #9153 (wontfix upstream). Diagnostic-rewind logging (forcedBottomOfCallStack, callStack traces) is retained to help future debugging of Asyncify state corruption. ## tests/ - tests/playwright-kicad.config.ts: add `channel: 'chrome'` for the chromium project so --project=chromium --headed uses system Chrome (real GPU) instead of SwiftShader on ARM Mac. Also switch trace to retain-on-failure + screenshot on-failure for easier E2E debugging. - tests/kicad/pcbnew.spec.ts: replace `tool.checked` assertions with a label-suffix check (`[checked]`) since our auibar registration encodes checked state in the label (no schema change to the registry). - tests/apps/Makefile.wasm: add `coroutine-nested` build target + include it in the all: list. - tests/apps/standalone/coroutine/: kicad_coroutine_harness.h + test app reproducing KiCad COROUTINE semantics against real libcontext. - tests/apps/standalone/coroutine-nested/: nested_test.cpp reproduces the EM_ASYNC_JS-modal + fiber-swap nesting bug in isolation. 8 scenarios from baseline_modal_alone through nested_fibers_inside_modal. - tests/e2e/coroutine.spec.ts + coroutine-nested.spec.ts: Playwright specs that load the standalone apps and assert all case cases pass via [COROUTINE_TEST] SUMMARY log parsing. ## research/ and features/browser-tools/ Three background docs capturing the investigation trajectory: - features/browser-tools/0001-kicad-wasm-tool-activation-investigation.md Early investigation: why tools don't activate; initial dynCall-empty- callback hypothesis. - features/browser-tools/0002-wasm-coroutine-deep-dive.md Deep dive on Asyncify internals, fiber API, QEMU's coroutine-wasm reference implementation. - features/browser-tools/0003-wxauitoolbar-registration-fix.md The narrow fix: why wxAuiToolBar needs a registration block, where to add it, what the fallback plan is. - research/threading_1.md: corrected root-cause analysis after reading runtime logs — nested-Asyncify currData collision, Emscripten #9153. - research/threading_2.md: extended research on alternative approaches (JSPI/WasmFX/state-machines) and why they don't help here. ## Submodule pointer updates kicad: f6e9239aaa (wip: libcontext WASM hygiene cleanup) wxwidgets: bb80f91e8b (wip: wxAuiToolBar element-registry registration + dialog diagnostics) ## Open threads not yet in scope - Firefox/Chrome divergent behavior: "indirect call signature mismatch" traps in Firefox vs renderer crash in system Chrome (tracked in plans/peaceful-hugging-pnueli.md and the research docs). - E2E pixel-diff for Draw Lines fails because the test's diff region does not cover where the line is actually drawn; tool activation works, the line is visible in test-results/pcbnew-draw-lines-02-after-drawing.png. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
23 KiB
Research: KiCad WASM Coroutine Architecture & Nested Asyncify Bug
TL;DR
The bug: RuntimeError: index out of bounds when a startup wizard modal closes. Happens because Asyncify.currData is a single-slot global. Our fiber-swap shim overwrites it when a tool activates inside the modal's event loop. The modal's subsequent rewind then follows a stale/mismatched call chain and crashes.
The fix: ~10 lines in scripts/common/inject-dyncall-shims.sh to wrap Asyncify.handleSleep so it saves its own asyncifyData before unwind and restores it before doRewind.
Upstream status: Documented as Emscripten Issue #9153, marked wontfix. We must work around it.
Conceptual Foundations
Coroutine vs subroutine
A subroutine has a single entry and a single exit — runs to completion. A coroutine is a subroutine that can be paused at arbitrary points and resumed later.
Two flavors:
- Stackless: the compiler transforms the function into a state machine. Saves only locals at designated suspension points (
co_await,await). Can only pause at those points, not inside arbitrary callees. C++20 coroutines, JavaScriptasync/await. - Stackful: the coroutine owns a separate call stack. Can pause from ANY depth — even from inside library functions. Python greenlets, Boost.Context, Lua coroutines, KiCad's
COROUTINE.
KiCad needs stackful because WaitForClick() is called many frames deep inside tool logic; stackless would require rewriting every tool.
Fiber
A runtime primitive for stackful coroutines: owns its own stack, cooperatively scheduled (unlike threads which are preemptively scheduled by the OS). Native fibers swap CPU registers and the stack pointer — ~20 assembly instructions per platform.
WASM has no register access and no direct call-stack manipulation. Emscripten provides emscripten_fiber_t emulated on top of Asyncify.
Asyncify
A binary transformation pass (Binaryen's wasm-opt --asyncify). It rewrites every WASM function in the module to add:
- A prelude:
if (state == REWINDING) { pop_locals(); jump to saved call site } - Wrapped call sites:
normal_call(); if (state == UNWINDING) { push_locals(); save_call_index(); return; }
Three globals drive everything:
__asyncify_state: 0=Normal, 1=Unwinding, 2=Rewinding__asyncify_data: pointer to current buffer- JS-side
Asyncify.currData: a single-slot pointer to the currently-active async operation's buffer
The structural fault: Asyncify.currData is a global. Emscripten assumes ONE async operation active at a time. When two overlap (EM_ASYNC_JS modal + fiber swap during its event loop), they fight over this slot.
Layering in KiCad WASM
KiCad tool code (C++)
└─ uses COROUTINE<int, TOOL_EVENT&> [kicad/include/tool/coroutine.h]
└─ uses libcontext::jump_fcontext [kicad/thirdparty/libcontext/libcontext.cpp]
└─ WASM backend: emscripten_fiber_swap
└─ emscripten/fiber.h [tools/emsdk/.../fiber.h]
└─ uses Asyncify [wasm-opt transform]
wxWidgets uses EM_ASYNC_JS (parallel Asyncify channel):
wxDialog::ShowModal
└─ startModal() EM_ASYNC_JS [wxwidgets/src/wasm/dialog.cpp]
└─ Asyncify.handleSleep
└─ uses the SAME Asyncify.currData
Two independent channels share one global. Collision guaranteed.
All 8 Suspension Patterns
Pattern 1 — Tool First Activation
Trigger: TOOL_MANAGER::dispatchInternal() finds a matching Go() transition; calls cofunc->Call(event).
Mechanism: make_fcontext(callerStub) → jump_fcontext → emscripten_fiber_swap → (first time) dynCall_vi(entryPoint, userData) → wasm_fcontext_entry(ctx) → callerStub → tool method.
Status per runtime logs: working (multiple successful first-entries).
Pattern 2 — RunMainStack (dialog from tool)
Tool coroutine calls RunMainStack([&]() { dlg.ShowModal(); }) → CALL_CONTEXT::RunMainStack → jump_fcontext back to main with CONTINUE_AFTER_ROOT → main runs the lambda → lambda's ShowModal uses EM_ASYNC_JS → returns → main calls doResume() with FROM_ROOT → coroutine resumes.
The canonical "nested Asyncify" pattern at the tool level. Not yet reached in current test runs (blocked by Pattern 4 failure first).
Pattern 3 — Tool Wait/Resume cycle
Inside a tool method:
while (TOOL_EVENT* evt = Wait()) { process(evt); }
Wait() → TOOL_MANAGER::ScheduleWait() → sets pendingWait, calls cofunc->KiYield() → jumpOut() → jump_fcontext → fiber swap to main. Later, matching event → cofunc->Resume() → swap back. Each Wait/Resume is 2 fiber swaps (4 asyncify operations).
Status per logs: working.
Pattern 4 — Standalone Modal (EM_ASYNC_JS)
wxDialog::ShowModal() on main stack → startModal() EM_ASYNC_JS → Asyncify unwinds main into global currData buffer → setTimeout event loop polls ProcessEvents every 17ms → EndModal(code) resolves Promise → Asyncify rewinds main → result returned.
Status: works alone; fails when fibers run during its event loop. This is where the current bug manifests.
Pattern 5 — Clipboard (EM_ASYNC_JS)
js_writeTextToClipboard, js_readTextFromClipboard, etc. Async browser APIs wrapped in EM_ASYNC_JS. Same single-slot collision hazard as Pattern 4 if called while a fiber is mid-suspension.
Pattern 6 — Font enumeration (EM_ASYNC_JS)
js_enumerateFonts() using Local Font Access API. Fires once at app init, typically before fibers exist. Probably safe.
Pattern 7 — Nested/stacked tools
Two mechanisms:
- Push/Pop:
TOOL_MANAGERpushes old coroutine onto stack when a new tool activates; pops back when new tool finishes. - FROM_ROUTINE calls:
child.Call(parentCoroutine, value)— no CALL_CONTEXT, no root bounce; parent resumes child, child yields back to parent directly.
Status per logs: working.
Pattern 8 — Selection tool at startup
Not the blocker I originally claimed. PCB_SELECTION_TOOL is the first coroutine, but it Call/Yield/Resume cycles correctly per logs. The startup DOES progress through this pattern without stalling.
The Actual Bug — Full Trace Against Source
Step-by-step (verified against tools/emsdk/upstream/emscripten/src/lib/libasync.js):
1. JS calls wasmExports["_ZN8wxDialog9ShowModalEv"]()
│ exportCallStack = ["_ZN8wxDialog9ShowModalEv"]
│ Asyncify.state = Normal, currData = null
↓
2. WASM: startModal() is EM_ASYNC_JS
│ compiles to: Asyncify.handleAsync(startAsync)
│ which calls: Asyncify.handleSleep((wakeUp) => startAsync().then(wakeUp))
│
│ handleSleep:
│ • allocateData() → malloc's BLOCK_A (~12 byte header + stack space)
│ • setDataRewindFunc(BLOCK_A):
│ bottomOfCallStack = exportCallStack[0] = "_ZN8wxDialog9ShowModalEv"
│ rewindId = Asyncify.getCallStackId(bottomOfCallStack)
│ HEAP32[(BLOCK_A + 8) >> 2] = rewindId ← modal's re-entry pinned
│ • Asyncify.currData = BLOCK_A ← the MODAL's buffer
│ • _asyncify_start_unwind(BLOCK_A)
│ • WASM unwinds fully. exportCallStack → []
│ • Asyncify.state = Normal (unwind complete, awaiting Promise)
↓
3. JS event loop. setTimeout(runEventLoop, 17ms) fires.
│ ccall('ProcessEvents') pushes "ProcessEvents" to exportCallStack.
│ WASM: ProcessEvents dispatches queued events.
│ One of them: tool activation → cofunc->Call(event).
│ That calls jump_fcontext → our _emscripten_fiber_swap override fires.
↓
4. ★★★ THE FAULT ★★★ — inject-dyncall-shims.sh line ~215:
│
│ if (Asyncify.state === Asyncify.State.Normal) {
│ Asyncify.state = Asyncify.State.Unwinding;
│ var asyncifyData = oldFiber + 20; ← fiber's embedded asyncify_data
│ // ... sets up __fiber_rewind_<oldFiber> stable rewind target ...
│ Asyncify.setDataRewindFunc(asyncifyData, "__fiber_rewind_<oldFiber>");
│ Asyncify.currData = asyncifyData; ◄◄◄ OVERWRITES BLOCK_A
│ _asyncify_start_unwind(asyncifyData);
│ ...
│ }
│
│ At this moment: BLOCK_A's pointer is LOST from Asyncify's view.
│ BLOCK_A is still malloc'd; the fiber just changed the "current" slot.
↓
5. Fiber runs tool body, eventually swaps back. Each fiber swap again writes
Asyncify.currData = some_fiber_buffer. Multiple fiber swaps may occur
during the modal's event loop.
│
│ Asyncify.currData is now ANY of these fiber buffers, NEVER restored to BLOCK_A.
↓
6. User action in modal resolves it. wxDialog::EndModal(5100) is called,
which invokes Module._endModal(5100).
│ The Promise stored by startModal's setTimeout resolves with 5100.
│
│ .then(wakeUp) runs from pure JS:
│ handleSleep's wakeUp(5100):
│ runtimeKeepalivePop();
│ handleSleepReturnValue = 5100;
│ Asyncify.state = Rewinding;
│ _asyncify_start_rewind(Asyncify.currData); ← NOT BLOCK_A!
│ Asyncify.doRewind(Asyncify.currData); ← rewinds wrong buffer
↓
7. ★★★ THE CRASH ★★★
│ Asyncify.currData is some fiber's buffer.
│ rewind_id at (that fiber + 20 + 8) → name "__fiber_rewind_<fiber>"
│ doRewind calls wasmExports["__fiber_rewind_<fiber>"]()
│ That wrapper calls wasmExports[entryKey] = __fiber_entry_<fiber>
│ __fiber_entry_<fiber> calls dynCall_vi(entryPoint, userData)
│ entryPoint was set to 0 when fiber first entered (Emscripten clears it)
│ dynCall_vi(0, ...) → getWasmTableEntry(0) → wasmTable.get(0)
│ Binaryen's rewind then tries to replay a saved call-index chain
│ serialized during the fiber's last unwind — but we're now inside the
│ modal's expected context. Call indices point to wrong table entries.
│ → RuntimeError: index out of bounds
Matching evidence in log files
From tests/logs/kicad/pcbnew/pcbnew-spec-ts-pcbnew-wasm-select-draw-lines-and-draw-on-the-board.log:
[DIAG_MODAL] Modal started (Module._endModal appeared) asyncifyState=0
... (many successful fiber operations inside the modal event loop) ...
[DIAG_REWIND_FUNC] ... modalActive=true callStack=["ProcessEvents",...]
...
EndModal: 5100
[DIAG_MODAL] EndModal called with code=5100 asyncifyState=0
[DIAG_STARTMODAL] endModal called, code=5100
[DIAG_STARTMODAL] promise resolved, result=5100
🔥 RuntimeError: index out of bounds at pcbnew.wasm:144634078
at dynCall_vi (pcbnew.js:6525) ← our shim
at dynCall_vi (pcbnew.js:27816) ← our fiber entry wrapper
at wrapper (pcbnew.js:17788) ← Emscripten callUserCallback
at safeSetTimeout ← modal's event loop
The double dynCall_vi at lines 6525 and 27816 is explained:
- Line 27816 = injected shim (bottom of
pcbnew.js) - Line 6525 =
Fibers.entryWrapperByFiber[fiber]=function() { return dynCall_vi(entryPoint, userData); }(a fiber-specific wrapper)
Rewind enters the fiber wrapper → calls dynCall_vi with entryPoint=0 → crash.
Why The First Pass Missed This
-
Didn't read runtime logs. The investigation docs described "startup stalls / toolbars empty" — outdated. Current logs show the app progresses through the wizard and crashes on close. Ground truth was one file away.
-
Treated QEMU's pattern as universal. QEMU's while(true) trampoline fixes entry-function-returns — but
wasm_fcontext_entry's return path is never reached in KiCad becausecallerStubalways swaps viajumpOut. The fix addresses a problem that doesn't occur. -
Didn't audit
inject-dyncall-shims.sh. The 350-line script contains the actual bug site (~80 lines of fiber stabilization). Without reading it carefully, couldn't see theAsyncify.currDataoverwrite without save/restore. -
Didn't search Emscripten issues. Issue #9153 is a wontfix matching our failure exactly. A two-minute search would have located it.
-
Confused historical symptom with current symptom. Investigation docs were written before dynCall shim fixes existed. Those fixes changed the symptom from "startup stall" to "crash on modal close". Docs weren't updated.
The Shim's Fiber Stabilization Layer (auditor's notes)
Part 1: Per-signature dynCall shims
For each dynCall_* signature, generate a JS wrapper that:
- Looks up the function in the WASM table
- Pushes a per-call key (
__dyn_SIG_<funcPtr>) ontoAsyncify.exportCallStack - Calls the function
- On return, pops the key and calls
maybeStopUnwind
Part 2: Empty-callback patches (6 patterns)
Emscripten 4.x generates (a1 => {}) no-op stubs when DYNCALLS=0. Six of these are actually called:
- HTML5 event callbacks
- pthread entry
- Signal handlers
- Timer callbacks
- Main loop iterator
- Fiber entry callback ← critical
Each is replaced with the appropriate dynCall_* invocation.
Part 3: Fiber rewind stabilization (THE BUG SITE)
Overrides three functions:
Asyncify.setDataRewindFunc(ptr, forcedBottomOfCallStack)— writes a specific fiber-owned rewind ID when forcedFibers.finishContextSwitch(newFiber)— on first entry, registers__fiber_entry_<fiber>as a synthetic wasmExport_emscripten_fiber_swap(oldFiber, newFiber)— this is whereAsyncify.currDatagets clobbered
Data structures created but never cleaned up:
Fibers.rewindTargetByFiber[fiberPtr]Fibers.rewindWrapperByFiber[fiberPtr]Fibers.entryWrapperByFiber[fiberPtr]Fibers.entryKeyByFiber[fiberPtr]wasmExports["__fiber_entry_<fiberPtr>"],wasmExports["__fiber_rewind_<fiberPtr>"]
Plus Asyncify.callStackNameToId / callStackIdToName grow by one entry per unique fiber pointer.
Secondary hazards (not causing the current crash but loaded footguns)
- Pointer reuse collisions: When a C++ fiber is freed and memory reused for a new fiber, the new fiber's synthetic exports overwrite the old ones at the same key. Stale references become dangling.
- exportCallStack imbalance on ABORT:
finallyblocks skippop()whenABORTis set. Recoverable errors leave the stack corrupted. - ID map unbounded growth: one ID per unique string forever. Long session = thousands of entries.
The Fix
Root-cause fix: wrap Asyncify.handleSleep
Add to scripts/common/inject-dyncall-shims.sh, within the fiber stabilization block:
// Save/restore Asyncify.currData around handleSleep to survive
// fiber swaps that happen during the sleep's Promise await.
// Fixes Emscripten Issue #9153 (wontfix).
if (typeof Asyncify !== "undefined" && Asyncify.handleSleep) {
var __originalHandleSleep = Asyncify.handleSleep.bind(Asyncify);
Asyncify.handleSleep = function(startAsync) {
return __originalHandleSleep(function(wakeUp) {
// This function runs inside handleSleep AFTER allocateData and
// setDataRewindFunc have set Asyncify.currData to THIS sleep's buffer.
var myAsyncifyData = Asyncify.currData;
return startAsync(function(result) {
// wakeUp runs from pure JS with an empty exportCallStack.
// Asyncify.currData may have been clobbered by fiber swaps that
// ran during our Promise await. Restore our own data before
// handleSleep proceeds to doRewind.
Asyncify.currData = myAsyncifyData;
return wakeUp(result);
});
});
};
}
Why this works: handleSleep pairs allocateData (sets currData) with doRewind (reads currData). Between those, the Promise awaits. If anything overwrites currData during the await, the rewind uses the wrong buffer. By capturing our own data pointer after allocateData and restoring it before wakeUp triggers doRewind, we guarantee the rewind uses the correct buffer regardless of what fiber swaps did.
Why it's safe: The capture happens synchronously after handleSleep sets currData for this sleep; the restore happens in the Promise resolution path before handleSleep's rewind logic runs. Never runs concurrently with the sleep's own rewind.
Alternative considered: save/restore in fiber swap
Could alternatively push/pop Asyncify.currData inside _emscripten_fiber_swap, but this requires knowing when the outer context is "done" using its currData — handleSleep already knows this (at wakeUp time), so wrapping handleSleep is simpler and more robust.
Workaround (if fix is delayed): block tool activation during modal
In TOOL_MANAGER::dispatchInternal, check wxTheApp->GetTopWindow()->IsModal() or similar. Queue events; drain on modal close. Doesn't fix the architectural issue but unblocks startup.
Hygiene cleanups (separate follow-up PR)
Independent of the bug fix, these improve kicad/thirdparty/libcontext/libcontext.cpp:
- Replace
wasm_fcontext_entrywith QEMU-stylewhile(true)trampoline — never returns, safer if the entry function's return path were ever accidentally reached - Remove
emscripten_unwind_to_js_event_loop()fromwasm_fcontext_entryandjump_fcontext— these would terminate all WASM execution but are never called in practice - Remove
parking_fiber/active_fiber()/ensure_parking_context()— dead code paths related to the entry-returns problem
Additional shim cleanups:
4. Add Fibers.destroyFiber(fiberPtr) called from release_fcontext via EM_ASM; clears rewindTargetByFiber, rewindWrapperByFiber, entryWrapperByFiber, entryKeyByFiber, and delete wasmExports["__fiber_*_<fiberPtr>"]
5. Balance exportCallStack even on ABORT (pop if top matches expected key)
These don't fix the bug but eliminate several loaded footguns.
Test Plan
New standalone: tests/apps/standalone/coroutine-nested/nested_test.cpp
Same pattern as coroutine_test.cpp. Reuses kicad_coroutine_harness.h.
Shared helper AutoClosingDialog:
- On
wxEVT_SHOW, startswxTimer::StartOnce(delayMs)→EndModal(wxID_OK)on fire - Optional external-close hook for scenarios that need to interleave fiber ops before closing
Asyncify state logging macro:
#define LOG_ASYNCIFY(tag) EM_ASM({
console.log('[COROUTINE_TEST] ASYNCIFY ' + UTF8ToString($0) +
' state=' + Asyncify.state +
' stackLen=' + Asyncify.exportCallStack.length +
' currData=' + (Asyncify.currData || 'null') +
' tableLen=' + wasmTable.length);
}, tag)
The currData value in these traces is the smoking gun.
The 8 scenarios
| # | Name | Proves |
|---|---|---|
| 1 | baseline_modal_alone |
Build and EM_ASYNC_JS work |
| 2 | baseline_fiber_alone |
Fiber swap works |
| 3 | fiber_create_run_destroy_inside_modal |
TARGET REPRODUCER |
| 4 | fiber_multi_swap_inside_modal |
Multiple swaps under modal |
| 5 | fiber_yield_across_modal_close |
Dormant fiber across modal boundary |
| 6 | fiber_deep_yield_loop_inside_modal |
Deep stack + many yields under modal |
| 7 | modal_fiber_modal_sequence |
Modal A → fiber → Modal B |
| 8 | nested_fibers_inside_modal |
Fiber-to-fiber (FROM_ROUTINE) under modal |
Diagnostic matrix
| Hypothesis | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Build/infra broken | FAIL | — | — | — | — | — | — | — |
| Fiber port broken | — | FAIL | — | — | — | — | — | — |
| Any fiber-under-modal | pass | pass | FAIL | FAIL | FAIL | FAIL | FAIL | FAIL |
| Multi-swap only | pass | pass | pass | FAIL | pass | FAIL | maybe | FAIL |
| Dormant fiber pin | pass | pass | pass | pass | FAIL | pass | pass | pass |
| Consecutive-modal leak | pass | pass | pass | pass | pass | pass | FAIL | pass |
| Nested fiber-to-fiber | pass | pass | pass | pass | pass | pass | pass | FAIL |
Current build expectation: Baselines pass; Scenario 3 crashes with RuntimeError: index out of bounds.
After fix expectation: All 8 pass.
Build integration
Add to tests/apps/Makefile.wasm:
$(S)/coroutine-nested/nested_test.o: $(S)/coroutine-nested/nested_test.cpp \
$(S)/coroutine/kicad_coroutine_harness.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext \
-I$(S)/coroutine $< -o $@
$(S)/coroutine-nested/nested_test.html: \
$(S)/coroutine-nested/nested_test.o \
$(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
coroutine-nested: $(S)/coroutine-nested/nested_test.html
Reuses LDFLAGS_COROUTINE (already has startModal and emscripten_fiber_swap in ASYNCIFY_IMPORTS).
E2E spec: tests/e2e/coroutine-nested.spec.ts
Three tests mirroring coroutine.spec.ts:
- "loads and reports case inventory" — all 8
CASElines present - "reports zero failures" — SUMMARY parseable, total=8, failed=0, no pageerror
- "per-scenario status (diagnostic)" —
expect.softper case for triage view
Implementation Sequence
- Scaffold: dir, stub
.cpp, Makefile rule, verify build succeeds - Implement Baselines 1 and 2 — confirm test infra works
- Implement Scenario 3 — expect crash (successful reproduction)
- Implement Scenarios 4–8 — fill diagnostic matrix
- Apply
handleSleepsave/restore fix - Re-run nested suite — all 8 pass
- Re-run existing
coroutinesuite — no regression - Full KiCad E2E — wizard closes cleanly, toolbars populate, Draw Line works
- Separate PR: libcontext.cpp hygiene (while(true), remove unwind, remove parking)
File Inventory
| File | Role |
|---|---|
tests/apps/standalone/coroutine-nested/nested_test.cpp |
NEW — 8-case reproducer |
tests/apps/Makefile.wasm |
Add coroutine-nested target |
tests/e2e/coroutine-nested.spec.ts |
NEW — E2E for reproducer |
scripts/common/inject-dyncall-shims.sh |
FIX — add handleSleep wrapper |
kicad/thirdparty/libcontext/libcontext.cpp |
Hygiene follow-up — while(true), remove unwind |
kicad/include/tool/coroutine.h |
Reference only |
kicad/common/tool/tool_manager.cpp |
Reference only |
wxwidgets/src/wasm/dialog.cpp |
Reference only — site of EM_ASYNC_JS startModal |
References
- Emscripten Issue #9153 — "Asyncify: Nested pause function calls does not work" (WONTFIX). Matches our failure exactly.
- Emscripten Issue #13302 — fiber swap return value bug (same single-slot design root)
- Emscripten Issue #12270 — fibers + embind return undefined (WONTFIX)
- Emscripten Issue #12239 —
start is not a functionin doRewind (empty exportCallStack variant) - Emscripten PR #9859 — fiber API introduction, design discussion
- Asyncify blog post — the transformation explained
- QEMU coroutine-wasm.c — reference implementation (solves a different subset of problems)
- Local:
tools/emsdk/upstream/emscripten/src/lib/libasync.js— ground truth for Asyncify JS runtime - Local:
tools/emsdk/upstream/emscripten/system/include/emscripten/fiber.h— fiber API