pcbjam/docs/research/threading_1.md

446 lines
23 KiB
Markdown
Raw Normal View History

wip: nested-asyncify fix, wxAuiToolBar registration, tests, research docs 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>
2026-04-21 13:58:16 +02:00
# 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](https://github.com/emscripten-core/emscripten/issues/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, JavaScript `async`/`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:
```cpp
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_MANAGER` pushes 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
1. **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.
2. **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 because `callerStub` always swaps via `jumpOut`. The fix addresses a problem that doesn't occur.
3. **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 the `Asyncify.currData` overwrite without save/restore.
4. **Didn't search Emscripten issues.** Issue #9153 is a wontfix matching our failure exactly. A two-minute search would have located it.
5. **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:
1. Looks up the function in the WASM table
2. Pushes a per-call key (`__dyn_SIG_<funcPtr>`) onto `Asyncify.exportCallStack`
3. Calls the function
4. 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 forced
- `Fibers.finishContextSwitch(newFiber)` — on first entry, registers `__fiber_entry_<fiber>` as a synthetic wasmExport
- `_emscripten_fiber_swap(oldFiber, newFiber)`**this is where `Asyncify.currData` gets 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**: `finally` blocks skip `pop()` when `ABORT` is 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:
```javascript
// 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`:
1. Replace `wasm_fcontext_entry` with QEMU-style `while(true)` trampoline — never returns, safer if the entry function's return path were ever accidentally reached
2. Remove `emscripten_unwind_to_js_event_loop()` from `wasm_fcontext_entry` and `jump_fcontext` — these would terminate all WASM execution but are never called in practice
3. 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`, starts `wxTimer::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**:
```cpp
#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`:
```makefile
$(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`:
1. "loads and reports case inventory" — all 8 `CASE` lines present
2. "reports zero failures" — SUMMARY parseable, total=8, failed=0, no pageerror
3. "per-scenario status (diagnostic)" — `expect.soft` per case for triage view
---
## Implementation Sequence
1. Scaffold: dir, stub `.cpp`, Makefile rule, verify build succeeds
2. Implement Baselines 1 and 2 — confirm test infra works
3. Implement Scenario 3 — expect crash (successful reproduction)
4. Implement Scenarios 48 — fill diagnostic matrix
5. Apply `handleSleep` save/restore fix
6. Re-run nested suite — all 8 pass
7. Re-run existing `coroutine` suite — no regression
8. Full KiCad E2E — wizard closes cleanly, toolbars populate, Draw Line works
9. 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](https://github.com/emscripten-core/emscripten/issues/9153) — "Asyncify: Nested pause function calls does not work" (WONTFIX). Matches our failure exactly.
- [Emscripten Issue #13302](https://github.com/emscripten-core/emscripten/issues/13302) — fiber swap return value bug (same single-slot design root)
- [Emscripten Issue #12270](https://github.com/emscripten-core/emscripten/issues/12270) — fibers + embind return undefined (WONTFIX)
- [Emscripten Issue #12239](https://github.com/emscripten-core/emscripten/issues/12239) — `start is not a function` in doRewind (empty exportCallStack variant)
- [Emscripten PR #9859](https://github.com/emscripten-core/emscripten/pull/9859) — fiber API introduction, design discussion
- [Asyncify blog post](https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html) — the transformation explained
- [QEMU coroutine-wasm.c](https://github.com/qemu/qemu/blob/master/util/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