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>
This commit is contained in:
parent
a49ed49d5d
commit
9a04217788
17 changed files with 5190 additions and 46 deletions
|
|
@ -0,0 +1,329 @@
|
|||
# KiCad WASM Tool Activation Investigation
|
||||
|
||||
## Summary
|
||||
|
||||
This document explains the current investigation into why interactive PCB tools do not work correctly in the browser build of KiCad.
|
||||
|
||||
The visible symptom is simple:
|
||||
|
||||
- In native KiCad, clicking `Draw Lines` in the right toolbar leaves the tool selected, and two clicks on the board create a line.
|
||||
- In the browser build, the tool does not remain selected, and board clicks do not start drawing.
|
||||
|
||||
The important conclusion so far is that this does **not** look like a normal KiCad tool-definition bug. The evidence points much lower in the stack, into the WebAssembly coroutine/runtime path used to emulate KiCad's native coroutine model in the browser.
|
||||
|
||||
## What "Working" Looks Like Natively
|
||||
|
||||
On macOS, Linux, and Windows, the flow for a tool like `Draw Lines` is roughly:
|
||||
|
||||
```text
|
||||
User clicks right toolbar button
|
||||
-> wxAuiToolBar handles mouse up
|
||||
-> ACTION_TOOLBAR emits tool action
|
||||
-> TOOL_MANAGER activates the requested tool
|
||||
-> KiCad starts or resumes the tool coroutine
|
||||
-> User clicks board canvas
|
||||
-> GAL / canvas event is forwarded to the active tool
|
||||
-> Tool consumes the clicks and creates geometry
|
||||
```
|
||||
|
||||
Two details matter here:
|
||||
|
||||
1. KiCad tools are not just plain event handlers. Many of them are coroutine-driven.
|
||||
2. Native builds can rely on real low-level context switching and on the OS windowing system for input routing.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### `wxAuiToolBar`
|
||||
|
||||
KiCad's right-side drawing toolbar is an AUI toolbar, not a plain `wxToolBar`. That matters because its rendering, hit-testing, and state transitions go through a different path than the simpler wxWidgets toolbar tests.
|
||||
|
||||
### `ACTION_TOOLBAR` and `TOOL_MANAGER`
|
||||
|
||||
The toolbar does not draw lines by itself. Clicking a button activates a named KiCad action, and the tool manager is responsible for making the corresponding interactive tool current.
|
||||
|
||||
### KiCad coroutines
|
||||
|
||||
Interactive tools in KiCad depend on coroutine-style control flow. On native platforms this is implemented with `libcontext`, specifically `make_fcontext()` and `jump_fcontext()`.
|
||||
|
||||
### `wxGLCanvas` / `WEBGL_GAL`
|
||||
|
||||
Once a tool is active, board clicks are handled through KiCad's graphics/input path. In the browser build that path includes a DOM canvas, wxWidgets' WASM port, and KiCad's WebGL GAL layer.
|
||||
|
||||
### Emscripten fibers and Asyncify
|
||||
|
||||
WebAssembly in the browser cannot perform the same kind of native stack switching that desktop KiCad uses. Our port therefore has to emulate it with:
|
||||
|
||||
- Emscripten fibers
|
||||
- Asyncify
|
||||
- generated JavaScript glue around fiber switches
|
||||
|
||||
That emulation layer is the main place where the browser build can diverge from the native behavior.
|
||||
|
||||
## Native Data Flow
|
||||
|
||||
The native path is conceptually:
|
||||
|
||||
```text
|
||||
OS mouse event
|
||||
-> wxWidgets window / child-window dispatch
|
||||
-> wxAuiToolBar::OnLeftUp()
|
||||
-> ACTION_TOOLBAR
|
||||
-> TOOL_MANAGER
|
||||
-> libcontext coroutine switch
|
||||
-> active KiCad tool
|
||||
-> board clicks routed to the tool
|
||||
-> drawing result appears
|
||||
```
|
||||
|
||||
The key point is that native `libcontext` performs real context switching, and the OS owns the final event routing between the toolbar area and the graphics canvas.
|
||||
|
||||
## Browser/WASM Data Flow
|
||||
|
||||
The browser path has more moving parts:
|
||||
|
||||
```text
|
||||
Browser pointer event
|
||||
-> generated wx.js / WASM event bridge
|
||||
-> wxWidgets WASM windowing layer
|
||||
-> wxAuiToolBar or wxGLCanvas target
|
||||
-> KiCad action dispatch
|
||||
-> Emscripten fiber switch
|
||||
-> Asyncify suspend/resume bookkeeping
|
||||
-> KiCad tool coroutine body
|
||||
-> WebGL canvas input handling
|
||||
-> drawing result appears
|
||||
```
|
||||
|
||||
This means the browser build must get all of the following correct at the same time:
|
||||
|
||||
- AUI toolbar hit-testing
|
||||
- tool activation
|
||||
- coroutine entry
|
||||
- coroutine completion / return
|
||||
- WebGL canvas event forwarding
|
||||
|
||||
If any one of those layers is wrong, the tool appears to "not work".
|
||||
|
||||
## What We Observed
|
||||
|
||||
At first glance the problem looked like a toolbar-state problem:
|
||||
|
||||
- the `Draw Lines` button did not stay selected
|
||||
- clicking the board did nothing
|
||||
|
||||
However, once we added a proper E2E test with logging and delayed screenshots, the picture became clearer:
|
||||
|
||||
1. The button state alone was not enough to diagnose the problem.
|
||||
An immediate screenshot can capture hover or pressed state, not true persistent selection.
|
||||
2. After we added explicit `checked`-state tracking and a short delay, it became clear that the tool was still not truly active.
|
||||
3. The deeper failure was not simply "the toolbar forgot its checked state".
|
||||
|
||||
## The First Real Root Cause We Found
|
||||
|
||||
The first concrete bug was in generated JavaScript around Asyncify fiber switching.
|
||||
|
||||
In the generated KiCad JS, the fiber entry callback in `Fibers.finishContextSwitch()` had effectively become a no-op:
|
||||
|
||||
```text
|
||||
(a1 => {})(userData);
|
||||
```
|
||||
|
||||
That means the code switched into the new fiber, but then did not actually call the tool coroutine entry function.
|
||||
|
||||
### Why this matters
|
||||
|
||||
If the entry callback is replaced by an empty function, the tool activation path can appear to run, but the coroutine body never really starts. From the outside, that looks like:
|
||||
|
||||
- the toolbar click "does something"
|
||||
- but the tool never becomes truly active
|
||||
- board clicks are ignored because there is no running interactive tool waiting for them
|
||||
|
||||
## Change 1: Fix the generated fiber entry callback
|
||||
|
||||
We added a new fix in [inject-dyncall-shims.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/inject-dyncall-shims.sh) so the generated JS is patched to call the real entry function:
|
||||
|
||||
```text
|
||||
dynCall_vi(entryPoint, userData);
|
||||
```
|
||||
|
||||
### Reasoning
|
||||
|
||||
This is a good change because it fixes an objectively broken generated code path at the WASM/JS boundary. It is not a KiCad workaround.
|
||||
|
||||
## Change 2: Tell Asyncify that fiber swaps are suspension points
|
||||
|
||||
We updated [apply-asyncify.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/apply-asyncify.sh) so `env.emscripten_fiber_swap` is included in `ASYNCIFY_IMPORTS`.
|
||||
|
||||
### Reasoning
|
||||
|
||||
Asyncify needs to know which imports can suspend or unwind control flow. Fiber swaps are exactly that kind of boundary. If Asyncify does not model them correctly, the call stack bookkeeping around coroutine switches becomes unreliable.
|
||||
|
||||
This is also a WASM-layer fix, not a KiCad UI workaround.
|
||||
|
||||
## What Happened After Those Changes
|
||||
|
||||
Those two changes moved the investigation forward, but they did not finish the problem.
|
||||
|
||||
After the JS callback fix, the logs started showing that the fiber entry code was actually being reached:
|
||||
|
||||
```text
|
||||
[WASM_FCONTEXT] make
|
||||
[WASM_FCONTEXT] swap
|
||||
[WASM_FCONTEXT] entry
|
||||
```
|
||||
|
||||
That is an important result. It means:
|
||||
|
||||
- the original "entry callback is a no-op" bug was real
|
||||
- we did fix it
|
||||
- but another problem exists after fiber entry
|
||||
|
||||
## The Current Deeper Problem
|
||||
|
||||
After the fiber starts, the startup sequence still stalls before PCBnew fully finishes bringing up the toolbars.
|
||||
|
||||
The clearest evidence comes from the E2E log at:
|
||||
|
||||
[pcbnew-spec-ts-pcbnew-wasm-select-draw-lines-and-draw-on-the-board.log](/Users/V/IdeaProjects/kicad-wasm/tests/logs/kicad/pcbnew/pcbnew-spec-ts-pcbnew-wasm-select-draw-lines-and-draw-on-the-board.log)
|
||||
|
||||
The rendered-element summary currently ends up as:
|
||||
|
||||
```text
|
||||
{"count":13,"byType":{"sash":2,"searchctrl":3,"searchbutton":4,"auipart":4},"tools":[]}
|
||||
```
|
||||
|
||||
That means the browser-side registry can see some UI pieces, but **no rendered toolbar tools at all**.
|
||||
|
||||
So the current failure is no longer best described as "the Draw Lines tool unchecks itself". The stronger diagnosis is:
|
||||
|
||||
- PCBnew startup is being interrupted
|
||||
- the AUI toolbars never fully come online
|
||||
- the tool registry is therefore empty
|
||||
- the test cannot even reach a stable active-tool state
|
||||
|
||||
## Why Native KiCad Works But Browser KiCad Does Not
|
||||
|
||||
Native KiCad works because two hard problems are already solved by the native platform stack:
|
||||
|
||||
1. `libcontext` can use real native context switching semantics.
|
||||
2. The operating system handles input routing across the real window hierarchy.
|
||||
|
||||
The browser build does not get either of those for free.
|
||||
|
||||
Instead, it must emulate them with:
|
||||
|
||||
- generated JS glue
|
||||
- Asyncify instrumentation
|
||||
- Emscripten fibers
|
||||
- a DOM canvas and WebGL canvas bridge
|
||||
|
||||
So the browser failure is not evidence that KiCad's tool logic is broken. It is evidence that our WASM adaptation layer is still incomplete.
|
||||
|
||||
## Why Existing wxWidgets Tests Can Still Look Fine
|
||||
|
||||
This issue can exist even if many wxWidgets tests look correct.
|
||||
|
||||
The reason is that the failing KiCad path is more complex than a normal wx control interaction:
|
||||
|
||||
- KiCad uses `wxAuiToolBar`, not only basic controls
|
||||
- KiCad tool activation goes through `ACTION_TOOLBAR` and `TOOL_MANAGER`
|
||||
- KiCad drawing tools depend on coroutine switching
|
||||
- the board uses a separate WebGL-backed canvas/input path
|
||||
|
||||
Most simple wxWidgets tests do not exercise that exact combination.
|
||||
|
||||
## What We Changed During The Investigation
|
||||
|
||||
The current working tree contains a mix of real fixes, testing support, and experiments.
|
||||
|
||||
### Changes that look fundamentally correct
|
||||
|
||||
| Layer | File | Purpose | Why it makes sense |
|
||||
|------|------|---------|--------------------|
|
||||
| Build/WASM glue | [scripts/common/inject-dyncall-shims.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/inject-dyncall-shims.sh) | Fix empty fiber entry callback in generated JS | Repairs objectively broken generated code |
|
||||
| Build/WASM glue | [scripts/common/apply-asyncify.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/apply-asyncify.sh) | Add `env.emscripten_fiber_swap` to Asyncify imports | Makes Asyncify aware of fiber-switch suspension points |
|
||||
| Test support | [tests/e2e/utils/element-tracker.ts](/Users/V/IdeaProjects/kicad-wasm/tests/e2e/utils/element-tracker.ts) | Track `checked` state | Lets the test distinguish hover/pressed from real selection |
|
||||
| Test support | [tests/kicad/pcbnew.spec.ts](/Users/V/IdeaProjects/kicad-wasm/tests/kicad/pcbnew.spec.ts) | Add tool-selection/drawing regression and log capture | Reproduces the bug through the real KiCad flow |
|
||||
| Test observability | [wxwidgets/src/aui/auibar.cpp](/Users/V/IdeaProjects/kicad-wasm/wxwidgets/src/aui/auibar.cpp) | Export rendered AUI tool items to the browser registry | Gives Playwright a reliable way to see KiCad AUI tools |
|
||||
|
||||
### Changes that are investigative, not final
|
||||
|
||||
| Layer | File | Purpose | Current assessment |
|
||||
|------|------|---------|--------------------|
|
||||
| KiCad WebGL input | [kicad/common/gal/webgl/webgl_gal.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/common/gal/webgl/webgl_gal.cpp) | Forward mouse events immediately on WASM instead of posting them | Useful experiment, but not yet proven to be the main fix |
|
||||
| KiCad startup | [kicad/pcbnew/pcb_edit_frame.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/pcbnew/pcb_edit_frame.cpp) | Skip auto-invoking the selection tool on WASM | Pure debugging aid to see whether startup tool activation was the blocker |
|
||||
| KiCad third-party porting layer | [kicad/thirdparty/libcontext/libcontext.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/thirdparty/libcontext/libcontext.cpp) | Experimental Emscripten-fiber implementation of `libcontext` | Likely the right conceptual layer, but the current implementation is not clean/final |
|
||||
|
||||
### Changes that are not meaningful
|
||||
|
||||
| File | Note |
|
||||
|------|------|
|
||||
| [kicad/common/tool/action_toolbar.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/common/tool/action_toolbar.cpp) | Trailing newline / formatting-only diff |
|
||||
| [kicad/common/tool/tools_holder.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/common/tool/tools_holder.cpp) | Trailing newline / formatting-only diff |
|
||||
| [wxwidgets/build/wasm/wx.js](/Users/V/IdeaProjects/kicad-wasm/wxwidgets/build/wasm/wx.js) | Generated build artifact, not a source-level design change |
|
||||
|
||||
## Why the Current `libcontext` Work Is Still Not "Done"
|
||||
|
||||
The current experiments in [libcontext.cpp](/Users/V/IdeaProjects/kicad-wasm/kicad/thirdparty/libcontext/libcontext.cpp) show that we can start a fiber, but not yet return from it with semantics that match what KiCad expects from native `jump_fcontext()`.
|
||||
|
||||
The native contract is subtle:
|
||||
|
||||
- one context transfers control to another
|
||||
- control can later resume into the previous context
|
||||
- returned values and ownership of "who resumes whom" must remain consistent
|
||||
- cleanup of a finished coroutine must not break the surrounding frame startup
|
||||
|
||||
In the browser build, once the first startup coroutine finishes, that handoff is still wrong. The result is not necessarily an immediate crash anymore, but the UI startup is interrupted before the toolbars are fully present.
|
||||
|
||||
## Current Best Explanation
|
||||
|
||||
The current best explanation is:
|
||||
|
||||
1. A toolbar click is not the primary problem.
|
||||
2. The browser port originally had a broken fiber entry callback, which prevented tool coroutines from starting at all.
|
||||
3. After fixing that, the browser port still mishandles coroutine completion / return.
|
||||
4. That deeper runtime mismatch interrupts PCBnew startup before the AUI tools fully appear.
|
||||
5. Because the tools are not fully rendered and the interactive-tool runtime is not stable, the right-side drawing tools do not remain active and board clicks do not draw.
|
||||
|
||||
## Clean Direction From Here
|
||||
|
||||
The cleanest direction is to keep the fix low in the WASM/runtime layer and avoid papering over the issue in KiCad UI code.
|
||||
|
||||
Recommended direction:
|
||||
|
||||
1. Keep the JS fiber-entry fix in [inject-dyncall-shims.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/inject-dyncall-shims.sh).
|
||||
2. Keep the Asyncify import fix in [apply-asyncify.sh](/Users/V/IdeaProjects/kicad-wasm/scripts/common/apply-asyncify.sh).
|
||||
3. Move the `libcontext` solution toward the dedicated WASM layer under [wasm/libcontext](/Users/V/IdeaProjects/kicad-wasm/wasm/libcontext) instead of continuing to patch KiCad's bundled third-party copy in ad hoc ways.
|
||||
4. Remove temporary KiCad startup and input experiments once the lower-layer coroutine behavior is correct.
|
||||
5. Keep the AUI rendered-tool export only if we still want browser E2E tests to target KiCad tools by tooltip and checked state.
|
||||
|
||||
## Reproduction and Evidence
|
||||
|
||||
### Build and run
|
||||
|
||||
```bash
|
||||
./docker/build.sh
|
||||
cd tests
|
||||
npm run test:kicad -- --grep "select draw lines"
|
||||
```
|
||||
|
||||
### Where to look
|
||||
|
||||
- E2E spec:
|
||||
[pcbnew.spec.ts](/Users/V/IdeaProjects/kicad-wasm/tests/kicad/pcbnew.spec.ts)
|
||||
- E2E logs:
|
||||
[tests/logs/kicad/pcbnew](/Users/V/IdeaProjects/kicad-wasm/tests/logs/kicad/pcbnew)
|
||||
- Current startup screenshot:
|
||||
[wizard-00-initial.png](/Users/V/IdeaProjects/kicad-wasm/tests/test-results/wizard-00-initial.png)
|
||||
|
||||
## Bottom Line
|
||||
|
||||
The current evidence says the KiCad browser tool failure is fundamentally a WebAssembly coroutine/runtime problem.
|
||||
|
||||
The right fix direction is:
|
||||
|
||||
- not "change KiCad tool logic"
|
||||
- not "hack the toolbar state"
|
||||
- but "make the WASM coroutine and fiber handoff behave like native KiCad expects"
|
||||
|
||||
The two most defensible changes so far are the generated-JS fiber-entry fix and the Asyncify import update. Everything else should be treated as either observability support or investigation scaffolding until the underlying `libcontext` behavior is corrected.
|
||||
795
features/browser-tools/0002-wasm-coroutine-deep-dive.md
Normal file
795
features/browser-tools/0002-wasm-coroutine-deep-dive.md
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
# 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:
|
||||
|
||||
```cpp
|
||||
void PCB_TOOL::DrawLine(TOOL_EVENT& evt) {
|
||||
Point p1 = WaitForClick(); // ← PAUSES HERE, waits for user
|
||||
Point p2 = WaitForClick(); // ← PAUSES HERE again
|
||||
CreateLine(p1, p2);
|
||||
}
|
||||
```
|
||||
|
||||
This code looks simple and linear, but `WaitForClick()` can't actually block in a browser. Instead, KiCad uses a coroutine to pause the function, return control to the browser event loop, and resume later when the click arrives. This requires the ability to **save and restore the entire call stack** — which is what libcontext and fibers do.
|
||||
|
||||
### Very few projects need this
|
||||
|
||||
The number of large C/C++ applications that:
|
||||
1. Were designed for desktop with coroutine-based control flow
|
||||
2. Are now being ported to the browser via Emscripten
|
||||
3. Need those coroutines to actually work
|
||||
|
||||
...is very small. QEMU is one. KiCad is another. Maybe a handful of others.
|
||||
|
||||
Because so few people need this, the Emscripten support for it is:
|
||||
- **Functional** (the fiber API exists and works)
|
||||
- **But rough around the edges** (bugs in internal code, poor documentation, edge cases not handled)
|
||||
- **And under-tested** (most users never exercise these code paths)
|
||||
|
||||
### Emscripten's priorities
|
||||
|
||||
Emscripten's main user base is game engines and simple tools. Their effort goes into:
|
||||
- Compilation speed
|
||||
- WASM binary size
|
||||
- Performance of simple programs
|
||||
- SIMD, threading, memory64
|
||||
|
||||
The fiber/coroutine path is a niche feature. The `makeDynCall` bug persists because almost nobody hits it — the people who do (like us) work around it.
|
||||
|
||||
---
|
||||
|
||||
## Concepts From the Ground Up
|
||||
|
||||
### What is "The Stack"?
|
||||
|
||||
Every time you call a function, a new "frame" is pushed onto the call stack holding local variables, return address, and arguments.
|
||||
|
||||
```
|
||||
main() calls drawLine() calls calculatePoint()
|
||||
|
||||
Stack (grows downward):
|
||||
┌──────────────────┐
|
||||
│ main() │ ← local vars of main
|
||||
├──────────────────┤
|
||||
│ drawLine() │ ← local vars of drawLine
|
||||
├──────────────────┤
|
||||
│ calculatePoint() │ ← TOP: local vars of calculatePoint
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**"Stack-local"** = a variable that lives in a frame. When that function returns, the frame is popped and the variable's memory becomes garbage.
|
||||
|
||||
### What is a Coroutine?
|
||||
|
||||
A function that can **pause** mid-execution and **resume** later. KiCad needs this because in a browser you can't block waiting for a mouse click (the page freezes). So a drawing tool must pause after requesting a click, let the browser run, then resume when the click arrives.
|
||||
|
||||
### Stackful vs Stackless
|
||||
|
||||
**Stackless** (C++20 `co_await`): Can only pause at the top level. If `waitForClick()` is 5 calls deep, you can't pause.
|
||||
|
||||
**Stackful** (KiCad): Can pause from **anywhere** in the call stack. The entire stack is saved and restored. KiCad needs this.
|
||||
|
||||
### What is libcontext?
|
||||
|
||||
A small C library (from Boost.Context) that performs context switching via three functions:
|
||||
|
||||
```cpp
|
||||
make_fcontext(stack, size, entry_func); // Create a new context
|
||||
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 (`VV-EE/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:**
|
||||
```c
|
||||
void foo(int x) {
|
||||
x = x + 1;
|
||||
x = x / 2;
|
||||
bar(x); // ← this call might trigger a pause
|
||||
while (x & 7) {
|
||||
x = x + 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**After transformation (pseudocode of the generated WASM):**
|
||||
```c
|
||||
void foo(int x) {
|
||||
// PRELUDE: if we're rewinding, restore our saved locals
|
||||
if (__asyncify_state == REWINDING) {
|
||||
x = pop_from_asyncify_stack(); // restore x
|
||||
call_index = pop_from_asyncify_stack(); // which call site to skip to
|
||||
}
|
||||
|
||||
// Normal code: skip during rewind
|
||||
if (__asyncify_state == NORMAL) {
|
||||
x = x + 1;
|
||||
x = x / 2;
|
||||
}
|
||||
|
||||
// The call site: execute if normal, OR if rewinding to this specific call
|
||||
if (__asyncify_state == NORMAL || call_index == 0) {
|
||||
bar(x);
|
||||
|
||||
// After the call returns: are we unwinding?
|
||||
if (__asyncify_state == UNWINDING) {
|
||||
push_to_asyncify_stack(0); // save call index (we were at bar())
|
||||
push_to_asyncify_stack(x); // save local variable x
|
||||
return; // cooperatively return up the chain
|
||||
}
|
||||
}
|
||||
|
||||
// Rest of function: skip during rewind
|
||||
if (__asyncify_state == NORMAL) {
|
||||
while (x & 7) {
|
||||
x = x + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** Every function in the call chain gets this treatment. During unwind, each frame saves its state and returns normally. During rewind, each frame skips ahead to the right call site and dives deeper.
|
||||
|
||||
### The Complete Unwind Sequence (Pause)
|
||||
|
||||
When something wants to pause (e.g., `emscripten_fiber_swap`):
|
||||
|
||||
```
|
||||
1. JS sets __asyncify_state = UNWINDING (1)
|
||||
2. JS sets __asyncify_data = pointer to this fiber's buffer
|
||||
3. The call to emscripten_fiber_swap returns to its caller in WASM
|
||||
4. The caller checks: state == UNWINDING? Yes.
|
||||
→ Pushes its call index + locals to asyncify_data buffer
|
||||
→ Returns to ITS caller
|
||||
5. That caller checks: state == UNWINDING? Yes.
|
||||
→ Same thing: push call index + locals, return
|
||||
6. This cascades all the way up until the WASM export returns to JS
|
||||
7. JS: all WASM frames have returned. Call asyncify_stop_unwind().
|
||||
→ __asyncify_state = 0 (Normal)
|
||||
8. The entire WASM call stack is gone. State is saved in the buffer.
|
||||
```
|
||||
|
||||
### The Complete Rewind Sequence (Resume)
|
||||
|
||||
When something wants to resume a paused fiber:
|
||||
|
||||
```
|
||||
1. JS sets __asyncify_state = REWINDING (2)
|
||||
2. JS sets __asyncify_data = pointer to the saved fiber's buffer
|
||||
3. JS calls the same WASM export function that was running before (e.g., main)
|
||||
4. main() enters. Sees state == REWINDING.
|
||||
→ Pops its locals from asyncify_data buffer
|
||||
→ Pops its call index → skips ahead to that call site
|
||||
→ Calls the function at that call site
|
||||
5. That function enters. Sees state == REWINDING.
|
||||
→ Same thing: pop locals, pop call index, skip ahead, call deeper
|
||||
6. This continues until we reach the DEEPEST frame (the one that paused)
|
||||
7. The deepest frame calls asyncify_stop_rewind()
|
||||
→ __asyncify_state = 0 (Normal)
|
||||
8. Execution continues normally from exactly where it paused.
|
||||
```
|
||||
|
||||
### How `emscripten_fiber_swap` Coordinates Two Fibers
|
||||
|
||||
Each `emscripten_fiber_t` struct contains:
|
||||
```c
|
||||
typedef struct {
|
||||
void* stack_base; // C stack top
|
||||
void* stack_limit; // C stack bottom
|
||||
void* stack_ptr; // current C stack pointer (saved on swap)
|
||||
void (*entry)(void*); // entry function (NULL after first call)
|
||||
void* user_data; // argument for entry function
|
||||
asyncify_data_t asyncify_data; // this fiber's own asyncify buffer
|
||||
} emscripten_fiber_t;
|
||||
```
|
||||
|
||||
The swap sequence for switching from Fiber A to Fiber B:
|
||||
|
||||
```
|
||||
Fiber A is running (state = Normal)
|
||||
|
||||
A calls emscripten_fiber_swap(&A, &B):
|
||||
JS side:
|
||||
1. state = Unwinding
|
||||
2. currData = A.asyncify_data (save into A's buffer)
|
||||
3. asyncify_start_unwind(A.asyncify_data)
|
||||
4. Save A's C stack pointer into A.stack_ptr
|
||||
5. Set Fibers.nextFiber = B
|
||||
6. Return (emscripten_fiber_swap returns to caller)
|
||||
|
||||
WASM side:
|
||||
7. A's call chain unwinds: each frame saves state into A.asyncify_data
|
||||
8. All WASM frames return to JS
|
||||
|
||||
JS side (maybeStopUnwind):
|
||||
9. asyncify_stop_unwind() → state = Normal
|
||||
10. Fibers.trampoline() → finishContextSwitch(B)
|
||||
|
||||
finishContextSwitch(B):
|
||||
11. Restore B's C stack pointer + limits
|
||||
12. Is B.entry != NULL? (first time entering B)
|
||||
YES → call B.entry(B.user_data) ← this is where dynCall_vi matters!
|
||||
NO → (B was previously paused)
|
||||
asyncify_start_rewind(B.asyncify_data)
|
||||
doRewind() → calls the saved export, which replays B's call chain
|
||||
|
||||
B is now running.
|
||||
|
||||
Later, B calls emscripten_fiber_swap(&B, &A):
|
||||
Same process in reverse:
|
||||
- B's state is saved into B.asyncify_data
|
||||
- finishContextSwitch(A):
|
||||
A.entry == NULL → rewind into A.asyncify_data
|
||||
A's call chain replays until emscripten_fiber_swap
|
||||
emscripten_fiber_swap's "else" branch runs:
|
||||
state = Normal
|
||||
asyncify_stop_rewind()
|
||||
|
||||
A continues exactly where it left off.
|
||||
```
|
||||
|
||||
### Why This Is Slow
|
||||
|
||||
Every context switch involves:
|
||||
1. Unwinding the entire call stack (every frame saves state and returns)
|
||||
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:
|
||||
```javascript
|
||||
// Old way:
|
||||
dynCall_vi(funcPtr, arg1);
|
||||
|
||||
// New way:
|
||||
getWasmTableEntry(funcPtr)(arg1);
|
||||
```
|
||||
|
||||
Benchmarks showed the new way is **60-80% faster** and produces smaller JS output.
|
||||
|
||||
### How Emscripten's Internal Code Uses dynCall
|
||||
|
||||
Emscripten's own JS library files (the runtime glue) need to call WASM function pointers too. They use a preprocessor macro called `makeDynCall`:
|
||||
|
||||
```javascript
|
||||
// Inside Emscripten's library_async.js, library_html5.js, etc.
|
||||
// This is a BUILD-TIME macro, expanded by Emscripten's preprocessor
|
||||
|
||||
// Old syntax (pre-2.0.9):
|
||||
{{{ makeDynCall('vi') }}}(funcPtr, arg1)
|
||||
|
||||
// New syntax (2.0.9+):
|
||||
{{{ makeDynCall('vi', 'funcPtr') }}}(arg1)
|
||||
```
|
||||
|
||||
The difference: the old syntax doesn't tell the macro which variable holds the function pointer. The new syntax does.
|
||||
|
||||
### The Silent Degradation Bug
|
||||
|
||||
Here's what happens when the macro expands. Inside Emscripten's `parseTools.mjs`:
|
||||
|
||||
```javascript
|
||||
function makeDynCall(sig, funcPtr) {
|
||||
if (funcPtr === undefined) {
|
||||
// OLD SYNTAX: funcPtr not provided
|
||||
if (DYNCALLS) {
|
||||
// -sDYNCALLS=1 is set: use the generated dynCall_vi function
|
||||
return `dynCall_${sig}`;
|
||||
}
|
||||
// DYNCALLS is false (default since ~2.0.3)
|
||||
// Try to find an exported dynCall_vi... it doesn't exist
|
||||
// Fall through to:
|
||||
return `((args) => {} /* a dynamic function call to signature ${sig},
|
||||
but there are no exported function pointers with that signature,
|
||||
so this path should never be taken. */)`;
|
||||
}
|
||||
// NEW SYNTAX: funcPtr provided → use getWasmTableEntry
|
||||
return `getWasmTableEntry(${funcPtr})`;
|
||||
}
|
||||
```
|
||||
|
||||
**The critical problem:** Emscripten's **own internal library files** still use the old syntax in many places. When `DYNCALLS=false` (the default), the macro generates an empty arrow function `(a1 => {})` instead of actually calling the function.
|
||||
|
||||
The generated comment even says *"this path should never be taken"* — but it IS taken, because the internal library files trigger it.
|
||||
|
||||
### What Breaks
|
||||
|
||||
| Location in Emscripten JS | What the no-op replaces | Effect |
|
||||
|---------------------------|------------------------|--------|
|
||||
| `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:
|
||||
|
||||
```javascript
|
||||
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:**
|
||||
```javascript
|
||||
function dynCall_vi(funcPtr, arg1) {
|
||||
var func = getWasmTableEntry(funcPtr);
|
||||
// Track in Asyncify's export call stack so unwind/rewind works
|
||||
Asyncify.exportCallStack.push('dynCall_vi');
|
||||
try {
|
||||
func(arg1);
|
||||
} finally {
|
||||
if (Asyncify.currData) {
|
||||
// We're mid-unwind: set the rewind function
|
||||
Asyncify.setDataRewindFunc(Asyncify.currData);
|
||||
}
|
||||
Asyncify.exportCallStack.pop();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. Patches the six empty arrow function patterns** to call the real shims.
|
||||
|
||||
### Why Not Just Use `-sDYNCALLS=1`?
|
||||
|
||||
You could. Unity does. But there's a subtlety:
|
||||
|
||||
Plain `dynCall_vi` from `-sDYNCALLS=1` is just:
|
||||
```javascript
|
||||
function dynCall_vi(index, a1) { getWasmTableEntry(index)(a1); }
|
||||
```
|
||||
|
||||
It does **not** push/pop `Asyncify.exportCallStack`. That tracking is needed for Asyncify to know which WASM export to re-enter during rewind. Without it, if a fiber swap happens inside an indirect call (function pointer), Asyncify loses track of the call chain and the rewind fails.
|
||||
|
||||
Our shims add this tracking. That's the extra value over `-sDYNCALLS=1`.
|
||||
|
||||
### Is This an Emscripten Bug?
|
||||
|
||||
**Yes, arguably.** The problem is that Emscripten's own internal library files (`library_async.js`, `library_html5.js`, etc.) use the deprecated `makeDynCall` syntax, which silently degrades to no-ops when `DYNCALLS=false`. The generated comment says "this path should never be taken" but it's taken constantly. Worth filing as a bug.
|
||||
|
||||
---
|
||||
|
||||
## QEMU: The Gold Standard Reference
|
||||
|
||||
### What QEMU actually is
|
||||
|
||||
QEMU is a **machine emulator and virtualizer**. It lets you:
|
||||
- Run an ARM Linux system on your x86 laptop
|
||||
- Run Windows inside a virtual machine on Linux
|
||||
- Emulate hardware for embedded development
|
||||
|
||||
It's one of the most important open-source infrastructure projects — it powers much of cloud computing (via KVM/QEMU).
|
||||
|
||||
### Why QEMU uses coroutines
|
||||
|
||||
QEMU's disk I/O layer uses coroutines for the same reason KiCad uses them: to write **synchronous-looking code** that actually runs asynchronously.
|
||||
|
||||
When QEMU needs to read from a virtual disk:
|
||||
```c
|
||||
void handle_disk_read(Request *req) {
|
||||
Buffer data = read_from_disk(req->sector); // ← this might take time
|
||||
send_data_to_guest(req, data);
|
||||
}
|
||||
```
|
||||
|
||||
`read_from_disk()` might need to wait for actual I/O. Instead of blocking (which would freeze the emulator), QEMU pauses the coroutine, processes other events, and resumes when the data is ready. Exactly the same pattern as KiCad's `WaitForClick()`.
|
||||
|
||||
### Why QEMU was recently ported to WASM
|
||||
|
||||
People want to run QEMU in the browser — to provide virtual machines in web-based development environments, education tools, etc. The QEMU project accepted patches to build with Emscripten, and part of that work was making coroutines work in WASM.
|
||||
|
||||
### Why QEMU's solution is relevant to us
|
||||
|
||||
QEMU and KiCad have the **exact same problem**:
|
||||
- Both are large C/C++ codebases
|
||||
- Both use stackful coroutines internally
|
||||
- Both need those coroutines to work when compiled to WASM
|
||||
- Both use Emscripten's fiber API as the backend
|
||||
|
||||
QEMU's solution (`util/coroutine-wasm.c`) was:
|
||||
1. Written by someone who clearly understood the Emscripten fiber API constraints
|
||||
2. Reviewed by the QEMU maintainers
|
||||
3. Accepted into the official QEMU repository
|
||||
4. Has been running in production
|
||||
|
||||
It's only 127 lines. It's the cleanest, most proven reference for "how to do coroutines in Emscripten."
|
||||
|
||||
### QEMU's Full Implementation
|
||||
|
||||
Source: [github.com/qemu/qemu/blob/master/util/coroutine-wasm.c](https://github.com/qemu/qemu/blob/master/util/coroutine-wasm.c)
|
||||
|
||||
**The struct:**
|
||||
```c
|
||||
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):**
|
||||
```c
|
||||
static void coroutine_trampoline(void *co_)
|
||||
{
|
||||
Coroutine *co = co_;
|
||||
|
||||
while (true) { // ← NEVER returns
|
||||
co->entry(co->entry_arg); // Run the coroutine body
|
||||
qemu_coroutine_switch(co, co->caller,
|
||||
COROUTINE_TERMINATE); // Swap back to caller
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Walk-through:
|
||||
|
||||
1. A new QEMU coroutine is created for some I/O operation.
|
||||
2. `emscripten_fiber_init()` is called with `coroutine_trampoline` as the entry function.
|
||||
3. When the coroutine is first entered (someone swaps to it), `coroutine_trampoline` starts running.
|
||||
4. It calls `co->entry(co->entry_arg)` — this is the actual I/O handler.
|
||||
5. The I/O handler might pause (yield) many times while waiting for data. Each yield does a `fiber_swap` back to the caller, and each resume does a `fiber_swap` back to this coroutine. But throughout all of that, `coroutine_trampoline` is still on the stack — we're inside the `co->entry()` call.
|
||||
6. Eventually the I/O handler finishes and returns.
|
||||
7. `coroutine_trampoline` resumes after the `co->entry()` line.
|
||||
8. It calls `qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)` — this swaps back to the caller with a "I'm done" flag.
|
||||
9. The `while(true)` loops back to the top. If nobody ever swaps back to this coroutine, it just stays suspended here forever (which is fine — the fiber is deallocated later).
|
||||
10. The entry function **never returns**. The `while(true)` guarantees it.
|
||||
|
||||
**Creating a coroutine:**
|
||||
```c
|
||||
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:**
|
||||
```c
|
||||
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):**
|
||||
```c
|
||||
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:**
|
||||
```c
|
||||
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`)
|
||||
|
||||
```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:**
|
||||
```cpp
|
||||
[[noreturn]] void wasm_fcontext_entry(void* aArg)
|
||||
{
|
||||
auto* ctx = static_cast<wasm_fcontext*>(aArg);
|
||||
|
||||
while (true) {
|
||||
// Run the coroutine body
|
||||
ctx->entry(ctx->transfer_value);
|
||||
|
||||
// Coroutine finished. Swap back to whoever started us.
|
||||
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
|
||||
|
||||
- [QEMU coroutine-wasm.c](https://github.com/qemu/qemu/blob/master/util/coroutine-wasm.c) — gold standard implementation
|
||||
- [Emscripten fiber.h docs](https://emscripten.org/docs/api_reference/fiber.h.html) — API reference
|
||||
- [Asyncify blog post](https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html) — deep technical dive
|
||||
- [Binaryen Asyncify.cpp](https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp) — the compiler pass source
|
||||
- [Fiber PR #9859](https://github.com/emscripten-core/emscripten/pull/9859) — design discussion
|
||||
- [minicoro](https://github.com/edubart/minicoro) — single-header coroutine lib with WASM support
|
||||
- [Issue #13302](https://github.com/emscripten-core/emscripten/issues/13302) — fiber swap return value bug
|
||||
- [Issue #12733](https://github.com/emscripten-core/emscripten/issues/12733) — dynCall removal discussion
|
||||
304
features/browser-tools/0003-wxauitoolbar-registration-fix.md
Normal file
304
features/browser-tools/0003-wxauitoolbar-registration-fix.md
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
# wxAuiToolBar Registration — Tool Selection Fix
|
||||
|
||||
## Context
|
||||
|
||||
The nested Asyncify collision bug (see `0002-wasm-coroutine-deep-dive.md` and `research/threading_1.md`) is fixed. KiCad WASM now loads through the startup wizard without crashing, and the full PCBnew UI renders — menus, left drawing-tool sidebar with Line/Circle/Rectangle icons, layer panel, PCB canvas — all visible.
|
||||
|
||||
But tools still don't work end-to-end:
|
||||
|
||||
- **User observation**: clicking the Draw Lines tool in the browser doesn't visibly select it or make it function.
|
||||
- **E2E test**: `select draw lines and draw on the board` can't even attempt a click — it fails earlier at `wxElementRegistry.findAllRendered({ elementType: 'tool' })` because the returned array is empty.
|
||||
|
||||
Both signals point at the same gap: **wxAuiToolBar never registers its tools with the rendered-element registry**. The intended outcome of this fix is to make all wxAuiToolBar buttons (Draw Lines and siblings) clickable, selectable, and functional in the browser build.
|
||||
|
||||
---
|
||||
|
||||
## Root Cause
|
||||
|
||||
### The registry and how it gets populated
|
||||
|
||||
`window.wxElementRegistry` is a JS-side registry of UI elements used by Playwright tests to find controls by type/label/tooltip. For elements that are not standalone wxWindow instances (e.g., toolbar buttons rendered as pixels on a parent canvas), wxWidgets calls a C++ bridge `WasmRegisterRenderedElement()` which invokes the JS helper `wxRenderedElementRegister()`.
|
||||
|
||||
Canonical definition: `wxwidgets/src/wasm/window.cpp:182-214`:
|
||||
```cpp
|
||||
void WasmRegisterRenderedElement(
|
||||
wxWindow* parent,
|
||||
const char* elementType, // "tool", "menuitem", "sash", "auipart", ...
|
||||
const char* subType,
|
||||
int index,
|
||||
const wxString& label,
|
||||
const wxString& tooltip,
|
||||
int screenX, int screenY,
|
||||
int width, int height,
|
||||
bool enabled);
|
||||
```
|
||||
|
||||
### Where it's currently called
|
||||
|
||||
Grepping `wxwidgets/src/` for `WasmRegisterRenderedElement`:
|
||||
|
||||
| File | What it registers |
|
||||
|---|---|
|
||||
| `src/univ/toolbar.cpp:557-607` | Regular wxToolBar items (in `RecalcToolBitmapCache`) |
|
||||
| `src/univ/menu.cpp` | Menu bar items, popup menu items |
|
||||
| `src/aui/framemanager.cpp:2687-2787` | AUI pane captions, close/pin/maximize buttons |
|
||||
| `src/aui/tabart.cpp:392,1137` | Tab headers |
|
||||
| `src/univ/textctrl.cpp:4304-4319` | Text control segments |
|
||||
| `src/propgrid/propgrid.cpp:2509-2537` | Property grid rows |
|
||||
| `src/stc/stc.cpp:5203-5213` | Styled text cells |
|
||||
|
||||
### The gap
|
||||
|
||||
`wxwidgets/src/aui/auibar.cpp` has **zero** `__EMSCRIPTEN__` blocks and **zero** calls to `WasmRegisterRenderedElement`. Verified:
|
||||
```
|
||||
$ grep -nE "__EMSCRIPTEN__|WasmRegister" wxwidgets/src/aui/auibar.cpp
|
||||
(no matches)
|
||||
|
||||
$ git log --oneline -n 10 src/aui/auibar.cpp
|
||||
# Only upstream wxWidgets commits — our fork hasn't modified this file.
|
||||
```
|
||||
|
||||
KiCad's left drawing-tool sidebar is a `wxAuiToolBar` (not `wxToolBar`), which is why its tools are invisible to the registry.
|
||||
|
||||
### Test log evidence
|
||||
|
||||
From `tests/logs/kicad/pcbnew/pcbnew-spec-ts-pcbnew-wasm-select-draw-lines-and-draw-on-the-board.log`:
|
||||
|
||||
```
|
||||
[TEST] rendered summary {"count":42,"byType":{
|
||||
"searchctrl":3,"searchbutton":4,"sash":2,"auipart":4,
|
||||
"combobutton":8,"combotextarea":8,"textctrl":1,"tab":3,"menuitem":9
|
||||
},"tools":[]}
|
||||
```
|
||||
|
||||
Everything else registers. `tools` is the only empty bucket.
|
||||
|
||||
### The log is otherwise clean
|
||||
|
||||
Filtering out diagnostic output (`WASM_FCONTEXT`, `DIAG_*`, `wxLog DEBUG`) leaves only three substantive lines, all informational:
|
||||
```
|
||||
[DIAG_SHOWMODAL] About to call startModal()
|
||||
Debug: EndModal: 5100
|
||||
[DIAG_SHOWMODAL] startModal() returned 5100
|
||||
```
|
||||
|
||||
No exceptions, no crashes, no fiber errors, no `jump-ghost`, `main_refresh=1` stable. The nested Asyncify fix is holding. **The only remaining issue between "UI loads" and "tool works" is this registration gap.**
|
||||
|
||||
### Separating the two signals
|
||||
|
||||
The registry gap directly explains the **test failure**. It does NOT directly explain the **user's manual observation** — registry population is test-only infrastructure and has no effect on in-browser interactivity.
|
||||
|
||||
Hypothesis: once tools are registered, the test will click the tool via coordinates from the registry and produce a log that either shows the click succeeded (no bug — user's "doesn't select" was a display misreading because state wasn't exposed) or shows concrete failure evidence (real activation bug, e.g., another variant of coroutine/asyncify interaction). Either way, the registration fix is strictly additive and unblocks diagnosis.
|
||||
|
||||
---
|
||||
|
||||
## The Fix
|
||||
|
||||
### Summary
|
||||
|
||||
Two small changes, one new block:
|
||||
|
||||
1. **Extend the registry signature** to include `checked` state (needed so the test can verify selection).
|
||||
2. **Add a registration block** to `wxAuiToolBar::OnPaint()` following the `univ/toolbar.cpp` pattern.
|
||||
3. **Update existing callers** to pass a `checked` value (`false` for non-toggleable, real state for `wxItemCheck/wxItemRadio`).
|
||||
|
||||
### Change 1 — extend `WasmRegisterRenderedElement` signature
|
||||
|
||||
**File: `wxwidgets/src/wasm/window.cpp`** (function at line 182)
|
||||
|
||||
Add a `bool checked` parameter and pass it through to the JS helper:
|
||||
|
||||
```cpp
|
||||
void WasmRegisterRenderedElement(
|
||||
wxWindow* parent,
|
||||
const char* elementType,
|
||||
const char* subType,
|
||||
int index,
|
||||
const wxString& label,
|
||||
const wxString& tooltip,
|
||||
int screenX, int screenY,
|
||||
int width, int height,
|
||||
bool enabled,
|
||||
bool checked) // ← NEW
|
||||
{
|
||||
if (!parent) return;
|
||||
uintptr_t parentId = reinterpret_cast<uintptr_t>(parent);
|
||||
|
||||
EM_ASM({
|
||||
var id = $0.toString() + ':' + UTF8ToString($1) + ':' + $2;
|
||||
wxRenderedElementRegister(
|
||||
id,
|
||||
$0.toString(),
|
||||
UTF8ToString($1), // elementType
|
||||
UTF8ToString($3), // subType
|
||||
UTF8ToString($4), // label
|
||||
UTF8ToString($5), // tooltip
|
||||
$6, $7, $8, $9, // x, y, w, h
|
||||
$10 ? true : false, // enabled
|
||||
$2, // index
|
||||
$11 ? true : false // ← checked
|
||||
);
|
||||
},
|
||||
parentId, elementType, index, subType,
|
||||
label.utf8_str().data(), tooltip.utf8_str().data(),
|
||||
screenX, screenY, width, height,
|
||||
enabled, checked);
|
||||
}
|
||||
```
|
||||
|
||||
**File: `wxwidgets/build/wasm/wx.js`** (helper at line 297)
|
||||
|
||||
```javascript
|
||||
function wxRenderedElementRegister(
|
||||
id, parentId, elementType, subType,
|
||||
label, tooltip, screenX, screenY, width, height,
|
||||
enabled, index, checked) // ← NEW
|
||||
{
|
||||
if (window.wxElementRegistry) {
|
||||
window.wxElementRegistry.registerRendered(id, {
|
||||
id, parentId, elementType, subType,
|
||||
label, tooltip,
|
||||
screenX, screenY, width, height,
|
||||
centerX: screenX + Math.floor(width / 2),
|
||||
centerY: screenY + Math.floor(height / 2),
|
||||
enabled,
|
||||
index,
|
||||
checked: !!checked, // ← NEW
|
||||
lastUpdated: Date.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Also update `wxRenderedElementUpdate` (same file, around line 321) similarly, so subsequent updates can change `checked`.
|
||||
|
||||
### Change 2 — register wxAuiToolBar tools
|
||||
|
||||
**File: `wxwidgets/src/aui/auibar.cpp`** (inside `OnPaint`, after the main item-paint loop, before the overflow paint at line ~2501)
|
||||
|
||||
```cpp
|
||||
#ifdef __EMSCRIPTEN__
|
||||
// Update element registry with toolbar tools (for E2E test automation).
|
||||
// Runs after every paint so state (enabled/checked, layout) stays current.
|
||||
extern void WasmRegisterRenderedElement(
|
||||
wxWindow* parent, const char* elementType, const char* subType,
|
||||
int index, const wxString& label, const wxString& tooltip,
|
||||
int screenX, int screenY, int width, int height,
|
||||
bool enabled, bool checked);
|
||||
extern void WasmUnregisterRenderedElementsByParent(wxWindow* parent);
|
||||
|
||||
WasmUnregisterRenderedElementsByParent(this);
|
||||
|
||||
wxPoint screenPos = GetScreenPosition();
|
||||
for (size_t j = 0, itemCount = m_items.GetCount(); j < itemCount; ++j)
|
||||
{
|
||||
wxAuiToolBarItem& item = m_items.Item(j);
|
||||
|
||||
if (!item.m_sizerItem)
|
||||
continue;
|
||||
if (item.m_kind == wxITEM_SEPARATOR)
|
||||
continue;
|
||||
|
||||
wxRect itemRect = item.m_sizerItem->GetRect();
|
||||
|
||||
// Skip items scrolled off the end (match the paint loop's cutoff)
|
||||
if ((horizontal && itemRect.x + itemRect.width >= last_extent) ||
|
||||
(!horizontal && itemRect.y + itemRect.height >= last_extent))
|
||||
continue;
|
||||
|
||||
const char* subType = (item.m_kind == wxITEM_CONTROL) ? "control" : "button";
|
||||
bool isEnabled = !(item.m_state & wxAUI_BUTTON_STATE_DISABLED);
|
||||
bool isChecked = (item.m_state & wxAUI_BUTTON_STATE_CHECKED) != 0;
|
||||
|
||||
WasmRegisterRenderedElement(
|
||||
this,
|
||||
"tool",
|
||||
subType,
|
||||
static_cast<int>(j),
|
||||
item.m_label,
|
||||
item.m_shortHelp,
|
||||
screenPos.x + itemRect.x,
|
||||
screenPos.y + itemRect.y,
|
||||
itemRect.width,
|
||||
itemRect.height,
|
||||
isEnabled,
|
||||
isChecked
|
||||
);
|
||||
}
|
||||
#endif
|
||||
```
|
||||
|
||||
Notes:
|
||||
- `item.m_label` / `item.m_shortHelp` are the verified field names (`wxwidgets/include/wx/aui/auibar.h:231,235`).
|
||||
- `wxAUI_BUTTON_STATE_CHECKED` is already how `wxAuiToolBar::OnLeftUp` tracks toggle state (see line 2676 `m_actionItem->m_state & wxAUI_BUTTON_STATE_CHECKED`).
|
||||
- Placing the block inside OnPaint means every repaint refreshes the registry, which keeps `checked`/`enabled` state synchronized with visible state without needing a separate update path.
|
||||
|
||||
### Change 3 — update existing callers to pass `checked`
|
||||
|
||||
Every existing `WasmRegisterRenderedElement` call must pass a new final arg. Most don't have meaningful checked state:
|
||||
|
||||
- `src/univ/menu.cpp` — pass `false` (or `menuItem->IsChecked()` for check-menu-items, already available)
|
||||
- `src/aui/framemanager.cpp` (pane parts) — pass `false`
|
||||
- `src/aui/tabart.cpp` — pass `false` for non-selected tabs, `true` for the active tab (`page.active`)
|
||||
- `src/univ/textctrl.cpp`, `src/propgrid/propgrid.cpp`, `src/stc/stc.cpp` — pass `false`
|
||||
- `src/univ/toolbar.cpp` — pass `tool->IsToggled()` (real value for the regular wxToolBar path)
|
||||
|
||||
This is a small mechanical change: add `, false` (or the appropriate value) to each existing call site.
|
||||
|
||||
---
|
||||
|
||||
## Verification
|
||||
|
||||
### Build
|
||||
|
||||
1. **wxWidgets standalone build** (fast): `./scripts/build-wxuniversal-wasm.sh`
|
||||
2. **KiCad rebuild** (needed because KiCad statically links wxWidgets; this is the slow step): `./docker/build.sh`
|
||||
3. **Setup test artifacts**: handled automatically by `npm run test:kicad`'s `setup:kicad` step.
|
||||
|
||||
### Run
|
||||
|
||||
```bash
|
||||
cd tests
|
||||
npm run test:kicad
|
||||
```
|
||||
|
||||
Expectations for `pcbnew.spec.ts`:
|
||||
|
||||
- **Test 1** (`click through setup wizard to load PCBnew`): still passes (already passing, unaffected by this change).
|
||||
- **Test 2** (`select draw lines and draw on the board`): now proceeds past the `findAllRendered` poll. Three possible outcomes:
|
||||
1. **Passes fully** — tools were simply invisible to the test before; the user's "doesn't select" manual report was a display misreading (likely state changed but they didn't see the visual update, or they tested a stale build).
|
||||
2. **Fails at the `checked` poll (5s)** — tool renders, click reaches it, but activation path (ACTION_TOOLBAR → TOOL_MANAGER → coroutine) has a real functional bug. Follow up using the log.
|
||||
3. **Fails at the initial `findAllRendered` poll still** — registration isn't firing; something wrong with the build/binding. Debug by inspecting the generated `pcbnew.js` for the new signature.
|
||||
|
||||
### Diagnostic signals in the log
|
||||
|
||||
After the click, watch for these patterns:
|
||||
|
||||
- `[WASM_FCONTEXT] entry-call ctx=…` new fiber created after click → activation coroutine started. Any subsequent failure is in tool logic, not plumbing.
|
||||
- No fiber activity at all after the click → click didn't route to ACTION_TOOLBAR. Suspect event routing through the canvas (`wxwidgets/src/wasm/window.cpp` mouse handlers, possibly `kicad/common/gal/webgl/webgl_gal.cpp` which has a WASM-specific uncommitted change).
|
||||
- Fiber starts but never yields / doesn't hit the tool's `Wait()` loop → similar class of coroutine bug to the Asyncify fix, but different trigger.
|
||||
|
||||
### Follow-up scenarios
|
||||
|
||||
If the test still fails after this fix, use the above signals to narrow to:
|
||||
|
||||
- **Rendering-only**: `Refresh(false); Update()` already runs in `wxAuiToolBar::OnLeftUp` at line 2683–2684, so this is unlikely; but if the registry updates yet the canvas visibly doesn't, something is suppressing paint.
|
||||
- **Coroutine activation**: new variant of nested-asyncify (maybe menu → tool → dialog nesting). Extend `coroutine-nested` standalone test with the matching scenario.
|
||||
- **Event routing**: audit the DOM-event → wxWidgets-event bridge. If clicks on coordinates in the canvas aren't reaching wxAuiToolBar, the bridge has regressed.
|
||||
|
||||
---
|
||||
|
||||
## Files Touched
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `wxwidgets/src/wasm/window.cpp` | Add `bool checked` param to `WasmRegisterRenderedElement` signature |
|
||||
| `wxwidgets/build/wasm/wx.js` | Add `checked` arg to `wxRenderedElementRegister` and `wxRenderedElementUpdate` JS helpers |
|
||||
| `wxwidgets/src/aui/auibar.cpp` | **NEW** registration block in `OnPaint()` (~30 lines in `#ifdef __EMSCRIPTEN__`) |
|
||||
| `wxwidgets/src/univ/toolbar.cpp` | Pass `tool->IsToggled()` as new final arg |
|
||||
| `wxwidgets/src/univ/menu.cpp` | Pass `false` (or `IsChecked()` for check items) |
|
||||
| `wxwidgets/src/aui/framemanager.cpp` | Pass `false` |
|
||||
| `wxwidgets/src/aui/tabart.cpp` | Pass `page.active` where appropriate, else `false` |
|
||||
| `wxwidgets/src/univ/textctrl.cpp`, `propgrid/propgrid.cpp`, `stc/stc.cpp` | Pass `false` |
|
||||
|
||||
Net: +~50 lines of new code, ~8 files touched. The wxWidgets fork drift grows by one localized patch — no protocol or architectural change.
|
||||
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 9b008c10559e6ca36226880580a34a2b3ab1e565
|
||||
Subproject commit f6e9239aaa61700e1d434dc7602afc2f7f7b7f7e
|
||||
446
research/threading_1.md
Normal file
446
research/threading_1.md
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
# 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 4–8 — 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
|
||||
489
research/threading_2.md
Normal file
489
research/threading_2.md
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
# Research: Asyncify + Coroutines — External Solutions, QEMU Deep Dive, and Why Alternatives Fail
|
||||
|
||||
This document extends `threading_1.md` with external research: how other projects solved (or failed to solve) the exact same problem, the technical details of why JSPI/WasmFX/state-machines don't help, and a deeper analysis of QEMU's working implementation vs our code.
|
||||
|
||||
---
|
||||
|
||||
## How Asyncify Works at the Instruction Level
|
||||
|
||||
Understanding why coroutines break Asyncify requires knowing exactly what the binary transformation does.
|
||||
|
||||
### The Binaryen Pass
|
||||
|
||||
`wasm-opt --asyncify` (implemented in `binaryen/src/passes/Asyncify.cpp`) rewrites every WASM function that can transitively reach an "async import" (a function that might suspend). It transforms each function into a three-state machine:
|
||||
|
||||
- **State 0 (Normal)**: Code runs as-is.
|
||||
- **State 1 (Unwinding)**: Functions return immediately, saving their local variables and a call-site index into a contiguous "asyncify stack" region in linear memory.
|
||||
- **State 2 (Rewinding)**: Functions are re-entered from the top. They read saved call indices to skip forward to the correct inner call, restoring locals along the way.
|
||||
|
||||
Two globals drive everything:
|
||||
```
|
||||
__asyncify_state: 0 = Normal, 1 = Unwinding, 2 = Rewinding
|
||||
__asyncify_data: pointer to the asyncify buffer for the current operation
|
||||
```
|
||||
|
||||
### Asyncify Data Buffer Layout
|
||||
|
||||
Each fiber/coroutine has its own buffer (the "asyncify stack"):
|
||||
```
|
||||
[ptr+0] i32: current stack position (grows upward as data is pushed)
|
||||
[ptr+4] i32: stack end (upper bound — overflow → wasm trap)
|
||||
[ptr+8] i32: rewind_id (which WASM export to re-enter during rewind)
|
||||
[ptr+12] ... actual saved data: alternating call indices + serialized locals
|
||||
```
|
||||
|
||||
### Before/After Transformation
|
||||
|
||||
**Before:**
|
||||
```c
|
||||
void foo(int x) {
|
||||
x = x + 1;
|
||||
x = x / 2;
|
||||
bar(x); // ← might trigger a pause
|
||||
while (x & 7) x = x + 1;
|
||||
}
|
||||
```
|
||||
|
||||
**After (pseudocode of the generated WASM):**
|
||||
```c
|
||||
void foo(int x) {
|
||||
if (__asyncify_state == REWINDING) {
|
||||
x = pop_from_asyncify_stack();
|
||||
call_index = pop_from_asyncify_stack();
|
||||
}
|
||||
|
||||
if (__asyncify_state == NORMAL) {
|
||||
x = x + 1;
|
||||
x = x / 2;
|
||||
}
|
||||
|
||||
if (__asyncify_state == NORMAL || call_index == 0) {
|
||||
bar(x);
|
||||
if (__asyncify_state == UNWINDING) {
|
||||
push_to_asyncify_stack(0); // call index
|
||||
push_to_asyncify_stack(x); // local
|
||||
return; // cooperative return
|
||||
}
|
||||
}
|
||||
|
||||
if (__asyncify_state == NORMAL) {
|
||||
while (x & 7) x = x + 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Every function in the call chain gets this treatment. During unwind, each frame saves state and returns. During rewind, each frame skips ahead to the saved call site and dives deeper.
|
||||
|
||||
### The Fundamental Assumption
|
||||
|
||||
**Asyncify assumes a single linear call stack.** The rewind mechanism works by re-entering the outermost export and replaying the call chain from the top down. This requires that the call stack at rewind time is identical to the one at unwind time.
|
||||
|
||||
When `jump_fcontext()` or `emscripten_fiber_swap()` switches the C stack pointer to a different region of memory, the entire call chain changes. This is fine **if and only if** the Asyncify machinery knows about it — which is what `emscripten_fiber_swap` does. Each fiber has its own asyncify buffer, so unwind saves into fiber A's buffer and rewind uses fiber B's buffer. The JS glue orchestrates which buffer is active.
|
||||
|
||||
**What breaks**: If code uses raw stack manipulation (like native boost.context assembly) that bypasses Asyncify entirely. Then Asyncify's bookkeeping points to a call chain that no longer exists.
|
||||
|
||||
### Indirect Calls Compound the Problem
|
||||
|
||||
Because `jump_fcontext` in non-WASM code is called through a function pointer (table call), Asyncify by default conservatively assumes any indirect call may reach an async import. This causes ALL indirect call sites to be instrumented, massively inflating code size. The workaround (`ASYNCIFY_IGNORE_INDIRECT`) skips indirect call analysis but is dangerous if any indirect call IS on the active stack during unwind.
|
||||
|
||||
**Sources**: [Binaryen Asyncify.cpp](https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp), [Alon Zakai's blog post](https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html), [emscripten #8979](https://github.com/emscripten-core/emscripten/issues/8979)
|
||||
|
||||
---
|
||||
|
||||
## emscripten_fiber_t: The Correct Abstraction
|
||||
|
||||
Emscripten's fiber API (`<emscripten/fiber.h>`) is specifically designed to solve the problem of multiple execution stacks on top of Asyncify. Our code already uses it — the question is whether it's used correctly.
|
||||
|
||||
### How It Works
|
||||
|
||||
Each `emscripten_fiber_t` contains:
|
||||
```c
|
||||
typedef struct {
|
||||
void* stack_base; // C stack top
|
||||
void* stack_limit; // C stack bottom
|
||||
void* stack_ptr; // current C stack pointer (saved on swap)
|
||||
void (*entry)(void*); // entry function (NULL after first call)
|
||||
void* user_data; // argument for entry function
|
||||
asyncify_data_t asyncify_data; // THIS fiber's own asyncify buffer
|
||||
} emscripten_fiber_t;
|
||||
```
|
||||
|
||||
`emscripten_fiber_swap(old_fiber, new_fiber)`:
|
||||
1. Triggers Asyncify unwind of the current call stack into `old_fiber->asyncify_data`
|
||||
2. Switches the C stack pointer to `new_fiber->stack_ptr`
|
||||
3. Either calls `new_fiber->entry` (if first entry) or triggers Asyncify rewind using `new_fiber->asyncify_data`
|
||||
|
||||
**Key**: Because each fiber has its own `asyncify_data`, Asyncify tracks each fiber's call stack independently. Context switches tell Asyncify "save state here, restore state there."
|
||||
|
||||
### The Critical Constraint
|
||||
|
||||
From `fiber.h` documentation:
|
||||
|
||||
> "If entry_func returns, the entire program will end, as if main had returned."
|
||||
|
||||
When the entry function returns, there's no caller to return to — the fiber was started from a swap, not a regular function call. Emscripten treats this as program exit.
|
||||
|
||||
### What This Means For Our Code
|
||||
|
||||
Our `wasm_fcontext_entry()` (libcontext.cpp:249-272) violates this constraint. After `ctx->entry(ctx->transfer_value)` returns, the function tries to swap back using a stack-local parking fiber. This is undefined behavior per the Emscripten docs.
|
||||
|
||||
**Sources**: [fiber.h docs](https://emscripten.org/docs/api_reference/fiber.h.html), [PR #9859](https://github.com/emscripten-core/emscripten/pull/9859), [boost.context #109](https://github.com/boostorg/context/issues/109)
|
||||
|
||||
---
|
||||
|
||||
## The "Cannot Have Multiple Async Operations in Flight" Rule
|
||||
|
||||
Asyncify enforces a hard invariant: only one unwind/rewind cycle can be active at any moment. The global `Asyncify.state` variable tracks this. The assertion "Cannot have multiple async operations in flight at once" fires when:
|
||||
|
||||
- WASM is suspended (state = unwinding or rewinding)
|
||||
- A second call tries to enter the WASM module (e.g., from a JS event handler)
|
||||
|
||||
### Why This Matters for KiCad
|
||||
|
||||
When an interactive tool coroutine is suspended in `Wait()`, the fiber has been unwound and the main fiber rewound. Main is now in Normal state. A browser event fires, `ProcessEvents()` runs, finds a matching event, and resumes the coroutine via `cofunc->Resume()` — this triggers a new fiber swap. This is fine because the previous operation completed.
|
||||
|
||||
But consider RunMainStack: coroutine fiber → main fiber → ShowModal() → EM_ASYNC_JS suspends main. Now main's asyncify state is being managed by the EM_ASYNC_JS mechanism, AND the fiber system has its own asyncify buffers. These are separate paths but share `__asyncify_state`.
|
||||
|
||||
The `emscripten_fiber_swap` mechanism handles this correctly because the fiber JS glue and EM_ASYNC_JS use different code paths. But bugs in the glue code (like the dynCall no-ops) can cause state confusion.
|
||||
|
||||
### The `setTimeout(wakeUp, 0)` Pattern
|
||||
|
||||
When Asyncify.wakeUp() is called while compiled code is still on the JS call stack, it corrupts state. The fix is always to defer: `setTimeout(wakeUp, 0)` ensures the previous operation has fully unwound before starting the next rewind. Our modal dialog code uses `setTimeout(0)` twice (double-deferred) for this reason — documented in `learning.md`.
|
||||
|
||||
**Sources**: [emscripten #16291](https://github.com/emscripten-core/emscripten/issues/16291), [emscripten #18412](https://github.com/emscripten-core/emscripten/issues/18412), [emscripten #10515](https://github.com/emscripten-core/emscripten/issues/10515)
|
||||
|
||||
---
|
||||
|
||||
## QEMU WASM: The Gold Standard (Deep Technical Analysis)
|
||||
|
||||
QEMU was compiled to WASM with working coroutines. The patch series "Enable QEMU to run on browsers" (Kohei Tokunaga, April 2025, merged upstream) is the canonical reference for this problem.
|
||||
|
||||
### QEMU's Coroutine Problem
|
||||
|
||||
QEMU's async I/O uses coroutines everywhere — disk reads, network operations, etc. On native systems, QEMU uses `coroutine-ucontext.c` (`ucontext_t` + `sigsetjmp`/`siglongjmp`). Emscripten doesn't support ucontext, so they wrote a new backend.
|
||||
|
||||
### The Implementation: `util/coroutine-wasm.c`
|
||||
|
||||
127 lines. Three functions.
|
||||
|
||||
**The struct:**
|
||||
```c
|
||||
typedef struct {
|
||||
Coroutine base;
|
||||
void *stack; // C stack buffer (heap-allocated, persists)
|
||||
size_t stack_size;
|
||||
void *asyncify_stack; // Asyncify data buffer (heap-allocated, persists)
|
||||
size_t asyncify_stack_size;
|
||||
CoroutineAction action; // Communication: YIELD, TERMINATE, etc.
|
||||
emscripten_fiber_t fiber;
|
||||
} CoroutineEmscripten;
|
||||
```
|
||||
|
||||
Both stacks are heap-allocated and persist for the coroutine's entire lifetime. No stack-local temporaries.
|
||||
|
||||
**The trampoline (most important part):**
|
||||
```c
|
||||
static void coroutine_trampoline(void *co_)
|
||||
{
|
||||
Coroutine *co = co_;
|
||||
|
||||
while (true) { // ← NEVER returns
|
||||
co->entry(co->entry_arg); // Run the coroutine body
|
||||
qemu_coroutine_switch(co, co->caller,
|
||||
COROUTINE_TERMINATE); // Swap back to caller
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Walk-through:
|
||||
1. `emscripten_fiber_init()` is called with `coroutine_trampoline` as entry
|
||||
2. When first swapped to, `coroutine_trampoline` starts running
|
||||
3. Calls `co->entry(co->entry_arg)` — the actual I/O handler
|
||||
4. Handler may yield many times (each yield does fiber_swap back to caller, resume does fiber_swap back)
|
||||
5. Handler finishes and returns
|
||||
6. `coroutine_trampoline` resumes after the `co->entry()` line
|
||||
7. Calls `qemu_coroutine_switch(co, co->caller, COROUTINE_TERMINATE)` — swaps back with "done" flag
|
||||
8. `while(true)` loops. If nobody swaps back, stays suspended forever (fiber freed later)
|
||||
9. **Entry function never returns.**
|
||||
|
||||
**Context switch:**
|
||||
```c
|
||||
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;
|
||||
emscripten_fiber_swap(&from->fiber, &to->fiber);
|
||||
return from->action;
|
||||
}
|
||||
```
|
||||
|
||||
Simple two-party swap. Communication via the `action` field.
|
||||
|
||||
**Main thread bootstrap (lazy init):**
|
||||
```c
|
||||
Coroutine *qemu_coroutine_self(void)
|
||||
{
|
||||
Coroutine *self = get_current();
|
||||
if (!self) {
|
||||
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;
|
||||
}
|
||||
```
|
||||
|
||||
The main execution context is captured lazily as a fiber. Its asyncify stack is heap-allocated.
|
||||
|
||||
**Cleanup:**
|
||||
```c
|
||||
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. No dangling pointers because the while(true) loop means the fiber is either suspended (waiting inside the loop) or never entered again.
|
||||
|
||||
### QEMU Limitation We Share
|
||||
|
||||
From the patch notes: "Fiber does not support submitting coroutines to other threads." QEMU disabled cross-thread coroutine operations in 9pfs for Emscripten builds. KiCad's tool coroutines are single-threaded by design, so this is not a concern.
|
||||
|
||||
### What QEMU Does NOT Need (That We Do)
|
||||
|
||||
QEMU's coroutine model is simpler than KiCad's:
|
||||
|
||||
| Feature | QEMU | KiCad |
|
||||
|---------|------|-------|
|
||||
| Context switch parties | Always 2: coroutine ↔ caller | 3 types: FROM_ROOT, FROM_ROUTINE, CONTINUE_AFTER_ROOT |
|
||||
| Communication | Simple `action` enum | `INVOCATION_ARGS*` struct via `intptr_t` |
|
||||
| RunMainStack (execute on main from coroutine) | Not needed | Essential for ShowModal from tools |
|
||||
| Nested coroutines | Not used | Parent→child tool invocation |
|
||||
| EM_ASYNC_JS nested inside fiber context | Not applicable | ShowModal inside RunMainStack |
|
||||
|
||||
These differences mean we can't copy QEMU verbatim. We adopt the **trampoline pattern** and **heap-only buffers**, but keep KiCad's richer invocation protocol.
|
||||
|
||||
**Sources**: [ktock/qemu-wasm](https://github.com/ktock/qemu-wasm), [QEMU coroutine-fiber.c patch](https://www.mail-archive.com/qemu-block@nongnu.org/msg119137.html), [PATCH 00/10](https://patchew.org/QEMU/cover.1744032780.git.ktokunaga.mail@gmail.com/)
|
||||
|
||||
---
|
||||
|
||||
## Why JSPI Does NOT Solve Our Problem
|
||||
|
||||
JSPI (JavaScript Promise Integration) is a WebAssembly standard (Phase 4 W3C, Chrome 137+, Firefox 139+) that works at the VM level: the JS engine intercepts Promise returns from WASM-to-JS calls and natively suspends the WASM stack. No binary transformation needed.
|
||||
|
||||
### How JSPI Differs from Asyncify
|
||||
|
||||
| | Asyncify | JSPI |
|
||||
|---|---|---|
|
||||
| Mechanism | Binaryen rewrites WASM binary as state machine | VM natively suspends/resumes WASM stack |
|
||||
| Code size overhead | ~50% | Zero |
|
||||
| Suspension speed | Serialize/deserialize all frames | ~1 microsecond |
|
||||
| Where suspension happens | Inside WASM (any instrumented call) | At WASM→JS boundary only |
|
||||
|
||||
### Why JSPI Cannot Replace Coroutines
|
||||
|
||||
**1. JSPI only suspends at JS-WASM boundaries.**
|
||||
|
||||
A JSPI suspension happens when a WASM function calls a JS function that returns a Promise. Coroutine switches from one C++ coroutine to another (both inside WASM) do not cross a JS boundary. JSPI cannot mediate them.
|
||||
|
||||
```
|
||||
Coroutine A ←→ Coroutine B (intra-WASM, no JS boundary → JSPI can't help)
|
||||
Main stack → JS API (WASM→JS boundary → JSPI works here)
|
||||
```
|
||||
|
||||
**2. No JS frames can be suspended.**
|
||||
|
||||
V8 enforces: JSPI cannot capture JS frames on the stack. When WASM calls JS (which calls back into WASM), only the inner WASM stack can be suspended. This means callback-heavy patterns (like our ProcessEvents loop) need careful architecture.
|
||||
|
||||
The error is: "trying to suspend without a WebAssembly.promising export" — which Qt also hits.
|
||||
|
||||
**3. Emscripten main loop incompatibility.**
|
||||
|
||||
`emscripten_set_main_loop()`, `emscripten_request_animation_frame_loop()`, and `emscripten_set_timeout()` invoke WASM callbacks WITHOUT wrapping them in `WebAssembly.promising()`. Those callbacks cannot be suspended by JSPI.
|
||||
|
||||
Qt's investigation of JSPI (`QT_EMSCRIPTEN_ASYNCIFY=2`) confirmed they hit this exact error for dialog operations. As of early 2026, Qt's JSPI support is still incomplete.
|
||||
|
||||
**4. Each JSPI export runs on a separate stack.**
|
||||
|
||||
JSPI allocates a new stack per suspended export call. Multiple outstanding JSPI suspensions (one per tool coroutine) each get their own stack. But the "switch between" semantics of cooperative coroutines (yield to scheduler → scheduler resumes specific other coroutine) doesn't map onto JSPI's Promise-based model.
|
||||
|
||||
### What JSPI IS Good For
|
||||
|
||||
Async operations that cross JS boundaries: file I/O, network requests, `sleep()`, dialog results. If KiCad's tools could be restructured to yield to JS rather than to another C++ coroutine, JSPI becomes applicable. But the current architecture — `COROUTINE::yield()` switches directly via `jump_fcontext` — has no JS boundary.
|
||||
|
||||
**Verdict**: JSPI could potentially replace EM_ASYNC_JS for modal dialogs (Pattern 4 in threading_1.md). It cannot replace emscripten_fiber_swap for tool coroutines (Patterns 1/2/3/7/8).
|
||||
|
||||
**Sources**: [V8 JSPI blog](https://v8.dev/blog/jspi), [V8 JSPI new API](https://v8.dev/blog/jspi-newapi), [emscripten #22493](https://github.com/emscripten-core/emscripten/issues/22493), [emscripten #22469](https://github.com/emscripten-core/emscripten/issues/22469), [wasm/stack-switching #49](https://github.com/WebAssembly/stack-switching/issues/49)
|
||||
|
||||
---
|
||||
|
||||
## Why WasmFX / Typed Continuations Won't Help (Yet)
|
||||
|
||||
WasmFX is a formal WebAssembly proposal adding native stack-switching instructions: `cont.new`, `resume`, `suspend`, `switch`, `cont.bind`. These would allow efficient, type-safe coroutine/fiber switching entirely within WASM — the "correct long-term solution."
|
||||
|
||||
### Status (April 2026)
|
||||
|
||||
**Not shipped in any browser.** Not enabled in Chrome, Firefox, or Safari. Wasmtime has partial x64-Linux-only experimental support (tracking [issue #10248](https://github.com/bytecodealliance/wasmtime/issues/10248)). The proposal has been under discussion since 2021. A reference interpreter exists, but browser shipping is not imminent.
|
||||
|
||||
**Verdict**: Do not plan around this. If it ships in 2027+, we can revisit. For now, Asyncify + emscripten_fiber_t is the only viable path.
|
||||
|
||||
**Sources**: [WasmFX site](http://wasmfx.dev/), [Stack Switching Explainer](https://github.com/WebAssembly/stack-switching/blob/main/proposals/stack-switching/Explainer.md)
|
||||
|
||||
---
|
||||
|
||||
## Qt for WebAssembly: The Closest GUI Framework Comparison
|
||||
|
||||
Qt is the closest analogy: large C++ GUI framework with blocking modal dialogs (`QDialog::exec()`), nested event loops (`QEventLoop::exec()`), and tools that assume synchronous behavior.
|
||||
|
||||
### Qt's Evolution
|
||||
|
||||
**Pre-6.3 (no Asyncify)**: No support for `exec()`. Forced API change to `show()` + signal/slot callbacks. Broke all sync dialog patterns.
|
||||
|
||||
**Qt 6.3+ with Asyncify**: Added `--enable-asyncify` build option. `QEventLoop::exec()` works by Asyncify-suspending the entire WASM module. The Qt event loop spins inside the Asyncify unwind, JS processes browser events, then Asyncify rewinds when ready.
|
||||
|
||||
**JSPI exploration (ongoing)**: Qt has `-feature-wasm-jspi` but as of early 2026 still hits "attempting to suspend without a WebAssembly.promising export" for dialog operations.
|
||||
|
||||
### Qt's Core Insight
|
||||
|
||||
Qt uses Asyncify not to implement cooperative C++ coroutines, but to make the **main** execution context suspendable at arbitrary call depth. The browser JS event loop becomes the "scheduler." There is no explicit coroutine switching between multiple C++ contexts.
|
||||
|
||||
### What Qt Has NOT Solved
|
||||
|
||||
Interactive tools that use cooperative coroutines (like KiCad's tool framework) are NOT something Qt needs to handle. Qt's model is signal/slot, not coroutine-based. This means Qt's experience validates Asyncify for modal dialogs but tells us nothing about the multi-fiber case.
|
||||
|
||||
**Sources**: [Qt WASM docs](https://doc.qt.io/qt-6/wasm.html), [Qt exec() on WASM](http://qtandeverything.blogspot.com/2019/05/exec-on-qt-webassembly.html), [QTBUG-102827](https://bugreports.qt.io/browse/QTBUG-102827)
|
||||
|
||||
---
|
||||
|
||||
## Python/Pyodide/Greenlet: The Conceptual Match
|
||||
|
||||
Python greenlets are stackful coroutines using `slp_switch` (similar to `jump_fcontext`) for stack switching. The Pyodide and Wasmer teams hit exactly our problem.
|
||||
|
||||
### Pyodide Finding
|
||||
|
||||
From [issue #2664](https://github.com/pyodide/pyodide/issues/2664): Hood Chatham identified a fundamental incompatibility — greenlet's `slp_switch` duplicates call stacks (like `fork()`). JSPI explicitly cannot duplicate stacks. Therefore JSPI alone is insufficient for greenlets. A `continulet` abstraction on top of WASM stack switching was needed.
|
||||
|
||||
### Wasmer's Greenlet Solution (2025)
|
||||
|
||||
Wasmer exposed runtime-level system calls (`wasix_context_create/switch/destroy`) implementing cooperative stack switching. This is specific to the Wasmer runtime, not applicable to browser WASM.
|
||||
|
||||
### Pyodide's `syncify()` / `runPythonSyncifying()`
|
||||
|
||||
Works in Chrome with JSPI or Node.js with `--experimental-wasm-stack-switching`. But requires the outer call to be wrapped in `WebAssembly.promising()` — same limitation.
|
||||
|
||||
### Lesson for Us
|
||||
|
||||
If you need N concurrently-suspended C++ coroutines that switch between each other inside WASM, neither raw Asyncify nor JSPI is sufficient alone. The `emscripten_fiber_t` API (managing one `asyncify_data` per fiber) is the correct and currently only tool. QEMU proved it works. Our implementation just has bugs.
|
||||
|
||||
**Sources**: [Pyodide #2664](https://github.com/pyodide/pyodide/issues/2664), [Wasmer greenlet post](https://wasmer.io/posts/greenlet-support-python-wasm)
|
||||
|
||||
---
|
||||
|
||||
## Other Real-World Examples
|
||||
|
||||
### WordPress Playground (PHP in WASM)
|
||||
|
||||
Uses Asyncify for synchronous PHP networking code. Works because PHP has a single-threaded, single-stack model. No cooperative coroutine switching inside PHP. Not comparable.
|
||||
|
||||
### minicoro (Single-header coroutine library)
|
||||
|
||||
[github.com/edubart/minicoro](https://github.com/edubart/minicoro) — a minimal C coroutine library that explicitly supports Emscripten/WASM via the fiber API. Its WASM backend is essentially a cleaner version of what we're doing in libcontext.cpp. Worth studying for patterns but doesn't add capabilities beyond what emscripten_fiber_t provides.
|
||||
|
||||
---
|
||||
|
||||
## Summary of All Approaches Evaluated
|
||||
|
||||
| Approach | Viability | Handles Intra-WASM Coroutines? | Handles Modal Dialogs? | Browser Support |
|
||||
|----------|-----------|-------------------------------|----------------------|----------------|
|
||||
| **Asyncify + emscripten_fiber_t** (current, with fixes) | **HIGH** | Yes | Yes (via EM_ASYNC_JS) | All browsers |
|
||||
| JSPI | Medium | **No** — only at JS boundary | Yes | Chrome 137+, Firefox 139+ |
|
||||
| WasmFX / Typed Continuations | Future | Yes (native) | Yes | **No browser support** |
|
||||
| C++20 stackless coroutines | Not viable | No — only top-level suspension | N/A | N/A |
|
||||
| Rewrite tools as state machines | Not viable | N/A (eliminates coroutines) | N/A | N/A |
|
||||
| Raw Asyncify handleSleep | Not viable | No — can't recurse | Partial | All browsers |
|
||||
| boost.context assembly for wasm32 | Impossible | N/A | N/A | N/A |
|
||||
|
||||
**Conclusion**: Fix the emscripten_fiber_t usage in libcontext.cpp. Everything else is either not ready, not applicable, or not viable.
|
||||
|
||||
---
|
||||
|
||||
## Concrete Bugs In Our Code (Updated From threading_1.md)
|
||||
|
||||
### Bug 1: Entry Function Returns (libcontext.cpp:249-272)
|
||||
|
||||
The `wasm_fcontext_entry()` function violates the Emscripten rule that fiber entry functions must never return. After the coroutine body finishes:
|
||||
- Creates a `parking_fiber` with `emscripten_fiber_init_from_current_context()` — but the asyncify stack is stack-local (64KB on the C stack)
|
||||
- Swaps from parking_fiber to return_to — the parking_fiber's asyncify state now points to stack memory that's garbage
|
||||
- Falls through to `emscripten_unwind_to_js_event_loop()` — terminates ALL WASM execution
|
||||
|
||||
**Fix**: QEMU-style `while(true)` trampoline. Swap back using `&ctx->fiber` (heap-allocated) instead of parking fiber.
|
||||
|
||||
### Bug 2: Detached Epoch Kills Everything (libcontext.cpp:329-335)
|
||||
|
||||
In `jump_fcontext()`, after `emscripten_fiber_swap()` returns, if `old_ctx->resume_epoch == expected_resume_epoch`, the code calls `emscripten_unwind_to_js_event_loop()`. This is the "ghost resume" detection, but the response (kill everything) is disproportionate.
|
||||
|
||||
**Fix**: Log the ghost and return 0 instead of killing. Let the caller handle the null INVOCATION_ARGS.
|
||||
|
||||
### Bug 3: Parking Fiber Complexity (libcontext.cpp:152-175)
|
||||
|
||||
The entire parking fiber mechanism — `ensure_parking_context()`, `active_fiber()` indirection, `parking_initialized` flag, `parking_asyncify_stack` — exists to handle the case where the entry function returns. With the while(true) trampoline, this case doesn't exist. The complexity can be removed entirely.
|
||||
|
||||
**Fix**: Delete parking infrastructure. Always use `&ctx->fiber`.
|
||||
|
||||
---
|
||||
|
||||
## Key External References
|
||||
|
||||
### Emscripten Issues
|
||||
- [#8979](https://github.com/emscripten-core/emscripten/issues/8979) — Coroutines broken with Asyncify (root issue)
|
||||
- [#9859](https://github.com/emscripten-core/emscripten/pull/9859) — Fiber API implementation PR
|
||||
- [#10515](https://github.com/emscripten-core/emscripten/issues/10515) — Asyncify repeated yield fails
|
||||
- [#13302](https://github.com/emscripten-core/emscripten/issues/13302) — Bad return value with Asyncify and fibers
|
||||
- [#16291](https://github.com/emscripten-core/emscripten/issues/16291) — Cannot have multiple async operations in flight
|
||||
- [#20413](https://github.com/emscripten-core/emscripten/issues/20413) — C++20 coroutines + JSPI
|
||||
- [#22469](https://github.com/emscripten-core/emscripten/issues/22469) — Trying to suspend JS frames with JSPI
|
||||
- [#22493](https://github.com/emscripten-core/emscripten/issues/22493) — Main loop incompatible with JSPI
|
||||
|
||||
### Documentation
|
||||
- [fiber.h docs](https://emscripten.org/docs/api_reference/fiber.h.html) — Emscripten fiber API reference
|
||||
- [Asyncify docs](https://emscripten.org/docs/porting/asyncify.html) — Asyncify porting guide
|
||||
- [Binaryen Asyncify.cpp](https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp) — Compiler pass source
|
||||
- [Alon Zakai's Asyncify blog](https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html) — Technical deep dive
|
||||
|
||||
### QEMU
|
||||
- [ktock/qemu-wasm](https://github.com/ktock/qemu-wasm) — QEMU WASM repo
|
||||
- [PATCH 00/10](https://patchew.org/QEMU/cover.1744032780.git.ktokunaga.mail@gmail.com/) — Patch series: Enable QEMU to run on browsers
|
||||
- [coroutine-fiber.c patch](https://www.mail-archive.com/qemu-block@nongnu.org/msg119137.html) — The fiber backend
|
||||
|
||||
### JSPI / Stack Switching
|
||||
- [V8 JSPI blog](https://v8.dev/blog/jspi) — Introduction
|
||||
- [V8 JSPI new API](https://v8.dev/blog/jspi-newapi) — Updated API
|
||||
- [WasmFX Explainer](https://github.com/WebAssembly/stack-switching/blob/main/proposals/stack-switching/Explainer.md)
|
||||
- [Wasmtime #10248](https://github.com/bytecodealliance/wasmtime/issues/10248) — Stack switching tracking
|
||||
- [JS frames constraint](https://github.com/WebAssembly/stack-switching/issues/49)
|
||||
|
||||
### Other Projects
|
||||
- [Pyodide #2664](https://github.com/pyodide/pyodide/issues/2664) — Greenlet/stackful coroutines in WASM
|
||||
- [Wasmer greenlet](https://wasmer.io/posts/greenlet-support-python-wasm) — Runtime-level solution
|
||||
- [boost.context #109](https://github.com/boostorg/context/issues/109) — WASM support (not possible)
|
||||
- [minicoro](https://github.com/edubart/minicoro) — Minimal C coroutine lib with WASM support
|
||||
- [Qt WASM docs](https://doc.qt.io/qt-6/wasm.html)
|
||||
- [Qt QTBUG-102827](https://bugreports.qt.io/browse/QTBUG-102827) — Asyncify crash
|
||||
- [WordPress Playground](https://wordpress.github.io/wordpress-playground/developers/architecture/wasm-asyncify/) — PHP WASM asyncify
|
||||
|
|
@ -30,11 +30,10 @@ echo " Tool: ${WASM_OPT}"
|
|||
# Asyncify import patterns (functions that trigger async suspension)
|
||||
# - env.invoke_* : Exception handling trampolines
|
||||
# - env.__asyncjs__* : EM_ASYNC_JS functions (like startModal())
|
||||
ASYNCIFY_IMPORTS="env.invoke_*,env.__asyncjs__*"
|
||||
ASYNCIFY_IMPORTS="env.invoke_*,env.__asyncjs__*,env.emscripten_fiber_swap"
|
||||
|
||||
# Functions to exclude from asyncify instrumentation
|
||||
# These are large functions that inflate beyond V8's 7.65MB function limit
|
||||
# The names must match exactly as they appear in the WASM (C++ mangled names)
|
||||
# These are large functions that inflate beyond V8's local-count limits.
|
||||
ASYNCIFY_REMOVE=$(cat << 'REMOVELIST'
|
||||
COLOR_SETTINGS::COLOR_SETTINGS(wxString const&, bool)
|
||||
BuildBitmapInfo(std::__2::unordered_map<BITMAPS, std::__2::vector<BITMAP_INFO, std::__2::allocator<BITMAP_INFO>>, std::__2::hash<BITMAPS>, std::__2::equal_to<BITMAPS>, std::__2::allocator<std::__2::pair<BITMAPS const, std::__2::vector<BITMAP_INFO, std::__2::allocator<BITMAP_INFO>>>>>&)
|
||||
|
|
@ -49,7 +48,6 @@ ShapeFix_Wire::FixGap2d(int, bool)
|
|||
REMOVELIST
|
||||
)
|
||||
|
||||
# Convert newlines to commas for the removelist
|
||||
ASYNCIFY_REMOVE_ARG=$(echo "${ASYNCIFY_REMOVE}" | tr '\n' ',' | sed 's/,$//')
|
||||
|
||||
echo ""
|
||||
|
|
|
|||
|
|
@ -137,6 +137,200 @@ rm "$SHIM_FILE"
|
|||
|
||||
echo "Successfully injected $SIG_COUNT dynCall shims into $JS_FILE"
|
||||
|
||||
# Stabilize Asyncify rewind targets for Emscripten fibers.
|
||||
# When a fiber resumes via rewind, the outer JS frame can collapse to the
|
||||
# innermost dynCall wrapper on the next yield. Giving each fiber its own stable
|
||||
# rewind wrapper preserves the original re-entry target across later yields.
|
||||
if grep -q 'Fiber rewind stabilization for Asyncify fibers' "$JS_FILE"; then
|
||||
echo "Fiber rewind stabilization already present - skipping"
|
||||
else
|
||||
FIBER_PATCH_FILE=$(mktemp)
|
||||
cat > "$FIBER_PATCH_FILE" << 'FIBERPATCH'
|
||||
|
||||
// === Fiber rewind stabilization for Asyncify fibers ===
|
||||
if (typeof Asyncify !== "undefined" && typeof Fibers !== "undefined" && typeof _emscripten_fiber_swap !== "undefined") {
|
||||
var __originalAsyncifySetDataRewindFunc = Asyncify.setDataRewindFunc.bind(Asyncify);
|
||||
Asyncify.setDataRewindFunc = function(ptr, forcedBottomOfCallStack) {
|
||||
if (forcedBottomOfCallStack) {
|
||||
var rewindId = Asyncify.getCallStackId(forcedBottomOfCallStack);
|
||||
GROWABLE_HEAP_I32()[(((ptr) + (8)) >> 2)] = rewindId;
|
||||
return;
|
||||
}
|
||||
return __originalAsyncifySetDataRewindFunc(ptr);
|
||||
};
|
||||
|
||||
Fibers.rewindTargetByFiber ||= {};
|
||||
Fibers.rewindWrapperByFiber ||= {};
|
||||
Fibers.entryWrapperByFiber ||= {};
|
||||
Fibers.entryKeyByFiber ||= {};
|
||||
Fibers.shouldUseStableRewindTarget ||= function(fiber) {
|
||||
return GROWABLE_HEAP_U32()[(((fiber) + (16)) >> 2)] !== 0;
|
||||
};
|
||||
Fibers.ensureRewindWrapper ||= function(fiber) {
|
||||
var key = "__fiber_rewind_" + fiber;
|
||||
|
||||
if (!Fibers.rewindWrapperByFiber[fiber]) {
|
||||
var target = Fibers.rewindTargetByFiber[fiber];
|
||||
|
||||
Fibers.rewindWrapperByFiber[fiber] = function() {
|
||||
Asyncify.exportCallStack.push(key);
|
||||
try {
|
||||
return wasmExports[target]();
|
||||
} finally {
|
||||
if (!ABORT) {
|
||||
Asyncify.exportCallStack.pop();
|
||||
Asyncify.maybeStopUnwind();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
wasmExports[key] = Fibers.rewindWrapperByFiber[fiber];
|
||||
}
|
||||
|
||||
return key;
|
||||
};
|
||||
|
||||
var __originalFinishContextSwitch = Fibers.finishContextSwitch;
|
||||
Fibers.finishContextSwitch = function(newFiber) {
|
||||
var entryPoint = GROWABLE_HEAP_U32()[(((newFiber) + (12)) >> 2)];
|
||||
|
||||
if (entryPoint !== 0 && !Fibers.entryWrapperByFiber[newFiber]) {
|
||||
var userData = GROWABLE_HEAP_U32()[(((newFiber) + (16)) >> 2)];
|
||||
var entryKey = "__fiber_entry_" + newFiber;
|
||||
|
||||
Fibers.entryWrapperByFiber[newFiber] = function() {
|
||||
return dynCall_vi(entryPoint, userData);
|
||||
};
|
||||
Fibers.entryKeyByFiber[newFiber] = entryKey;
|
||||
wasmExports[entryKey] = Fibers.entryWrapperByFiber[newFiber];
|
||||
}
|
||||
|
||||
return __originalFinishContextSwitch(newFiber);
|
||||
};
|
||||
|
||||
var __originalEmscriptenFiberSwap = _emscripten_fiber_swap;
|
||||
_emscripten_fiber_swap = (oldFiber, newFiber) => {
|
||||
if (ABORT) return;
|
||||
|
||||
if (Asyncify.state === Asyncify.State.Normal) {
|
||||
Asyncify.state = Asyncify.State.Unwinding;
|
||||
|
||||
var asyncifyData = oldFiber + 20;
|
||||
if (Fibers.shouldUseStableRewindTarget(oldFiber)) {
|
||||
if (!Fibers.rewindTargetByFiber[oldFiber]) {
|
||||
Fibers.rewindTargetByFiber[oldFiber] = Fibers.entryKeyByFiber[oldFiber] || Asyncify.exportCallStack[0];
|
||||
}
|
||||
|
||||
var rewindKey = Fibers.ensureRewindWrapper(oldFiber);
|
||||
Asyncify.setDataRewindFunc(asyncifyData, rewindKey);
|
||||
} else {
|
||||
Asyncify.setDataRewindFunc(asyncifyData);
|
||||
}
|
||||
Asyncify.currData = asyncifyData;
|
||||
_asyncify_start_unwind(asyncifyData);
|
||||
|
||||
var stackTop = stackSave();
|
||||
GROWABLE_HEAP_U32()[(((oldFiber) + (8)) >> 2)] = stackTop;
|
||||
|
||||
Fibers.nextFiber = newFiber;
|
||||
return;
|
||||
}
|
||||
|
||||
return __originalEmscriptenFiberSwap(oldFiber, newFiber);
|
||||
};
|
||||
|
||||
_emscripten_fiber_swap.isAsync = true;
|
||||
|
||||
// --- handleSleep save/restore for nested Asyncify ---
|
||||
// Asyncify.currData is a single-slot global. When a fiber swap runs inside an
|
||||
// EM_ASYNC_JS Promise await (e.g., wxDialog::ShowModal via startModal), the
|
||||
// fiber swap overwrites currData with the fiber's asyncify_data, losing the
|
||||
// sleep's own buffer. On Promise resolution, handleSleep's doRewind then uses
|
||||
// the wrong buffer and crashes with "index out of bounds" or "unreachable".
|
||||
//
|
||||
// Workaround: intercept Asyncify.allocateData to record which pointer belongs
|
||||
// to the active handleSleep; restore it to Asyncify.currData inside the wakeUp
|
||||
// callback before handleSleep proceeds to _asyncify_start_rewind + doRewind.
|
||||
// Documented upstream as Emscripten Issue #9153 (wontfix).
|
||||
if (typeof Asyncify.handleSleep === "function"
|
||||
&& typeof Asyncify.allocateData === "function"
|
||||
&& !Asyncify.__nestedHandleSleepInstalled) {
|
||||
// Stack of handleSleep contexts awaiting their allocateData association.
|
||||
// Each context learns its data pointer when allocateData fires.
|
||||
Asyncify.__pendingSleepContexts = [];
|
||||
|
||||
var __originalAllocateData = Asyncify.allocateData.bind(Asyncify);
|
||||
Asyncify.allocateData = function() {
|
||||
var ptr = __originalAllocateData();
|
||||
// Associate with the innermost pending handleSleep that hasn't been
|
||||
// linked yet. allocateData is called once per sleep's unwind, so the
|
||||
// innermost un-linked context is ours.
|
||||
for (var i = Asyncify.__pendingSleepContexts.length - 1; i >= 0; --i) {
|
||||
var ctx = Asyncify.__pendingSleepContexts[i];
|
||||
if (!ctx.capturedData) {
|
||||
ctx.capturedData = ptr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return ptr;
|
||||
};
|
||||
|
||||
var __originalHandleSleep = Asyncify.handleSleep.bind(Asyncify);
|
||||
Asyncify.handleSleep = function(startAsync) {
|
||||
var sleepCtx = { capturedData: null, cleanedUp: false };
|
||||
Asyncify.__pendingSleepContexts.push(sleepCtx);
|
||||
|
||||
var cleanup = function() {
|
||||
if (sleepCtx.cleanedUp) return;
|
||||
sleepCtx.cleanedUp = true;
|
||||
var idx = Asyncify.__pendingSleepContexts.indexOf(sleepCtx);
|
||||
if (idx !== -1) Asyncify.__pendingSleepContexts.splice(idx, 1);
|
||||
};
|
||||
|
||||
try {
|
||||
return __originalHandleSleep(function(wakeUp) {
|
||||
return startAsync(function(result) {
|
||||
// wakeUp runs from pure JS on Promise resolution. Fiber swaps
|
||||
// during the await may have overwritten Asyncify.currData with
|
||||
// a fiber's buffer pointer. Restore OUR buffer so handleSleep's
|
||||
// _asyncify_start_rewind and doRewind use the right data.
|
||||
if (sleepCtx.capturedData) {
|
||||
Asyncify.currData = sleepCtx.capturedData;
|
||||
}
|
||||
cleanup();
|
||||
return wakeUp(result);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
cleanup();
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
Asyncify.__nestedHandleSleepInstalled = true;
|
||||
}
|
||||
}
|
||||
// === End fiber rewind stabilization ===
|
||||
FIBERPATCH
|
||||
|
||||
FIBER_INSERT_LINE=$(grep -n '^_emscripten_fiber_swap\.isAsync = true;$' "$JS_FILE" | head -1 | cut -d: -f1)
|
||||
|
||||
if [ -z "$FIBER_INSERT_LINE" ]; then
|
||||
echo "Error: Could not find _emscripten_fiber_swap.isAsync marker in $JS_FILE"
|
||||
rm "$FIBER_PATCH_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Injecting fiber rewind stabilization after line $FIBER_INSERT_LINE..."
|
||||
|
||||
head -n "$FIBER_INSERT_LINE" "$JS_FILE" > "${JS_FILE}.tmp"
|
||||
cat "$FIBER_PATCH_FILE" >> "${JS_FILE}.tmp"
|
||||
tail -n +$((FIBER_INSERT_LINE + 1)) "$JS_FILE" >> "${JS_FILE}.tmp"
|
||||
|
||||
mv "${JS_FILE}.tmp" "$JS_FILE"
|
||||
rm "$FIBER_PATCH_FILE"
|
||||
fi
|
||||
|
||||
# Fix empty callback arrow functions generated by Emscripten with pthreads
|
||||
# When pthreads is enabled, Emscripten generates empty {} for some direct-call paths
|
||||
# because it assumes they won't be used. But they ARE used in certain cases.
|
||||
|
|
@ -205,8 +399,123 @@ if [ "$COUNT_BEFORE" -gt 0 ]; then
|
|||
TOTAL_FIXED=$((TOTAL_FIXED + FIXED))
|
||||
fi
|
||||
|
||||
# Fix 6: Asyncify fiber entry callback (1 arg) - signature vi (void return)
|
||||
# Pattern in Fibers.finishContextSwitch: (a1 => {})(userData);
|
||||
# Function pointer is 'entryPoint'
|
||||
COUNT_BEFORE=$(grep -c '(a1 => {})(userData);' "$JS_FILE" || true)
|
||||
if [ "$COUNT_BEFORE" -gt 0 ]; then
|
||||
sed -i '' 's/(a1 => {})(userData);/dynCall_vi(entryPoint, userData);/g' "$JS_FILE"
|
||||
COUNT_AFTER=$(grep -c '(a1 => {})(userData);' "$JS_FILE" || true)
|
||||
FIXED=$((COUNT_BEFORE - COUNT_AFTER))
|
||||
echo " Fixed $FIXED fiber entry callback(s) (dynCall_vi)"
|
||||
TOTAL_FIXED=$((TOTAL_FIXED + FIXED))
|
||||
fi
|
||||
|
||||
if [ "$TOTAL_FIXED" -gt 0 ]; then
|
||||
echo "Total: Fixed $TOTAL_FIXED empty callback(s)"
|
||||
else
|
||||
echo "No empty callbacks found - nothing to fix"
|
||||
fi
|
||||
|
||||
# === Diagnostic logging for modal/asyncify/fiber interactions ===
|
||||
# This instruments key JS functions to trace "index out of bounds" crashes
|
||||
# that occur after EndModal during Asyncify rewind.
|
||||
echo "Injecting diagnostic logging for modal/asyncify interactions..."
|
||||
|
||||
DIAG_PATCH_FILE=$(mktemp)
|
||||
cat > "$DIAG_PATCH_FILE" << 'DIAGPATCH'
|
||||
|
||||
// === Diagnostic: modal/asyncify/fiber interaction logging ===
|
||||
(function() {
|
||||
var _diagTimerId = 0;
|
||||
var _diagModalActive = false;
|
||||
var _diagScheduledTimers = {};
|
||||
|
||||
// 1. Instrument _emscripten_async_call to log function pointer validity
|
||||
if (typeof _emscripten_async_call !== "undefined") {
|
||||
var __diag_orig_async_call = _emscripten_async_call;
|
||||
_emscripten_async_call = function(func, arg, millis) {
|
||||
var tableSize = wasmTable ? wasmTable.length : -1;
|
||||
var inBounds = func < tableSize;
|
||||
var id = ++_diagTimerId;
|
||||
console.warn('[DIAG_ASYNC_CALL] id=' + id + ' func=' + func + ' arg=' + arg +
|
||||
' millis=' + millis + ' tableSize=' + tableSize +
|
||||
' inBounds=' + inBounds + ' modalActive=' + _diagModalActive +
|
||||
' asyncifyState=' + (typeof Asyncify !== 'undefined' ? Asyncify.state : 'N/A'));
|
||||
if (!inBounds) {
|
||||
console.error('[DIAG_ASYNC_CALL] ALREADY OUT OF BOUNDS at schedule time! func=' + func);
|
||||
console.trace();
|
||||
}
|
||||
_diagScheduledTimers[id] = { func: func, arg: arg, millis: millis, scheduledDuringModal: _diagModalActive };
|
||||
return __diag_orig_async_call(func, arg, millis);
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Instrument Asyncify.setDataRewindFunc to log rewind target changes
|
||||
if (typeof Asyncify !== "undefined" && Asyncify.setDataRewindFunc) {
|
||||
var __diag_orig_setRewind = Asyncify.setDataRewindFunc.bind(Asyncify);
|
||||
Asyncify.setDataRewindFunc = function(ptr, forcedBottomOfCallStack) {
|
||||
console.warn('[DIAG_REWIND_FUNC] ptr=' + ptr + ' forced=' + forcedBottomOfCallStack +
|
||||
' state=' + Asyncify.state + ' modalActive=' + _diagModalActive +
|
||||
' callStack=' + JSON.stringify(Asyncify.exportCallStack));
|
||||
return __diag_orig_setRewind(ptr, forcedBottomOfCallStack);
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Track modal lifecycle via _endModal
|
||||
// startModal sets Module._endModal; we wrap that setter to detect modal start/end.
|
||||
var __diag_origDefineProperty = Object.defineProperty;
|
||||
var __diag_endModalSet = false;
|
||||
// Instead of defineProperty (complex), poll-wrap: patch the endModal assignment
|
||||
// by wrapping startModal's promise resolution. We detect modal start when
|
||||
// _endModal appears on Module, and modal end when it's deleted.
|
||||
if (typeof Module !== "undefined") {
|
||||
var __diag_checkInterval = setInterval(function() {
|
||||
if (Module._endModal && !__diag_endModalSet) {
|
||||
__diag_endModalSet = true;
|
||||
_diagModalActive = true;
|
||||
console.warn('[DIAG_MODAL] Modal started (Module._endModal appeared)' +
|
||||
' asyncifyState=' + (typeof Asyncify !== 'undefined' ? Asyncify.state : 'N/A'));
|
||||
|
||||
var __diag_origEndModal = Module._endModal;
|
||||
Module._endModal = function(code) {
|
||||
console.warn('[DIAG_MODAL] EndModal called with code=' + code +
|
||||
' asyncifyState=' + (typeof Asyncify !== 'undefined' ? Asyncify.state : 'N/A'));
|
||||
_diagModalActive = false;
|
||||
return __diag_origEndModal(code);
|
||||
};
|
||||
} else if (!Module._endModal && __diag_endModalSet) {
|
||||
__diag_endModalSet = false;
|
||||
console.warn('[DIAG_MODAL] Modal cleanup complete (Module._endModal deleted)' +
|
||||
' asyncifyState=' + (typeof Asyncify !== 'undefined' ? Asyncify.state : 'N/A'));
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
// 4. Instrument dynCall_vi to catch the exact out-of-bounds call
|
||||
if (typeof dynCall_vi !== "undefined") {
|
||||
var __diag_orig_dynCall_vi = dynCall_vi;
|
||||
dynCall_vi = function(funcPtr, arg1) {
|
||||
var tableSize = wasmTable ? wasmTable.length : -1;
|
||||
if (funcPtr >= tableSize || funcPtr < 0) {
|
||||
console.error('[DIAG_DYNCALL_VI] OUT OF BOUNDS! funcPtr=' + funcPtr +
|
||||
' tableSize=' + tableSize + ' arg1=' + arg1 +
|
||||
' modalActive=' + _diagModalActive +
|
||||
' asyncifyState=' + (typeof Asyncify !== 'undefined' ? Asyncify.state : 'N/A'));
|
||||
console.trace();
|
||||
return; // Skip the call to prevent crash, let execution continue
|
||||
}
|
||||
return __diag_orig_dynCall_vi(funcPtr, arg1);
|
||||
};
|
||||
dynCall_vi.isAsync = true;
|
||||
}
|
||||
|
||||
console.warn('[DIAG] Modal/asyncify diagnostic logging installed');
|
||||
})();
|
||||
// === End diagnostic logging ===
|
||||
DIAGPATCH
|
||||
|
||||
# Append diagnostic patch at the end of the JS file
|
||||
cat "$DIAG_PATCH_FILE" >> "$JS_FILE"
|
||||
rm "$DIAG_PATCH_FILE"
|
||||
echo "Diagnostic logging injected"
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
WXCONFIG = ../../build-wasm/wxwidgets-universal/wx-config
|
||||
TOOLS_ROOT = ../../wxwidgets/build/wasm
|
||||
KICAD_ROOT = ../../kicad
|
||||
|
||||
CXX = em++
|
||||
WX_CXXFLAGS := $(shell $(WXCONFIG) --cxxflags)
|
||||
|
|
@ -93,6 +94,14 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
|
|||
-sPTHREAD_POOL_SIZE_STRICT=0 \
|
||||
$(WX_LDFLAGS_NOGL)
|
||||
|
||||
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
|
||||
COROUTINE_BASE_LDFLAGS = -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['startModal','js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
|
||||
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
|
||||
|
||||
JS = $(TOOLS_ROOT)/wx.js
|
||||
HTML = $(TOOLS_ROOT)/template.html
|
||||
|
||||
|
|
@ -147,7 +156,9 @@ all: minimal_test.html \
|
|||
$(S)/earlysize/earlysize_test.html \
|
||||
$(S)/threadpool/threadpool_test.html \
|
||||
$(S)/logerror/logerror_test.html \
|
||||
$(S)/retinascale/retinascale_test.html
|
||||
$(S)/retinascale/retinascale_test.html \
|
||||
$(S)/coroutine/coroutine_test.html \
|
||||
$(S)/coroutine-nested/nested_test.html
|
||||
|
||||
# Main test app (uses GL)
|
||||
minimal_test.o: minimal_test.cpp
|
||||
|
|
@ -450,6 +461,26 @@ $(S)/logerror/logerror_test.o: $(S)/logerror/logerror_test.cpp
|
|||
$(S)/logerror/logerror_test.html: $(S)/logerror/logerror_test.o $(WX_CORE_LIB)
|
||||
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
|
||||
# Coroutine stress harness - mirrors KiCad coroutine semantics against real libcontext
|
||||
$(S)/coroutine/coroutine_test.o: $(S)/coroutine/coroutine_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
|
||||
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
|
||||
|
||||
$(S)/coroutine/libcontext.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp $(KICAD_ROOT)/thirdparty/libcontext/libcontext.h
|
||||
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
|
||||
|
||||
$(S)/coroutine/coroutine_test.html: $(S)/coroutine/coroutine_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
|
||||
|
||||
# Nested coroutine+modal interaction harness - reproduces Asyncify rewind corruption
|
||||
# when fiber swaps happen inside a wxDialog::ShowModal event loop (Issue #9153).
|
||||
$(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
|
||||
|
||||
# Convenience targets
|
||||
menu: $(S)/menu/menu_test.html
|
||||
clipboard: $(S)/clipboard/clipboard_test.html
|
||||
|
|
@ -502,9 +533,11 @@ $(S)/retinascale/retinascale_test.html: $(S)/retinascale/retinascale_test.o $(WX
|
|||
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
|
||||
retinascale: $(S)/retinascale/retinascale_test.html
|
||||
coroutine: $(S)/coroutine/coroutine_test.html
|
||||
coroutine-nested: $(S)/coroutine-nested/nested_test.html
|
||||
|
||||
clean:
|
||||
rm -f minimal_test.o minimal_test.html minimal_test.js minimal_test.wasm
|
||||
rm -f $(S)/*/*.o $(S)/*/*.html $(S)/*/*.js $(S)/*/*.wasm
|
||||
|
||||
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale
|
||||
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested
|
||||
|
|
|
|||
753
tests/apps/standalone/coroutine-nested/nested_test.cpp
Normal file
753
tests/apps/standalone/coroutine-nested/nested_test.cpp
Normal file
|
|
@ -0,0 +1,753 @@
|
|||
#include "wx/wx.h"
|
||||
#include "wx/textctrl.h"
|
||||
#include "wx/timer.h"
|
||||
#include "wx/dialog.h"
|
||||
|
||||
#include "kicad_coroutine_harness.h"
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten/emscripten.h>
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using coroutine_test::TestCoroutine;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr int ID_SCENARIO_TIMER = wxID_HIGHEST + 550;
|
||||
constexpr int ID_MODAL_CLOSE_TIMER = wxID_HIGHEST + 551;
|
||||
|
||||
struct CaseContext
|
||||
{
|
||||
bool passed = true;
|
||||
std::vector<std::string> failures;
|
||||
|
||||
void Expect( bool aCondition, const std::string& aMessage )
|
||||
{
|
||||
if( !aCondition )
|
||||
{
|
||||
passed = false;
|
||||
failures.push_back( aMessage );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
struct CaseResult
|
||||
{
|
||||
std::string name;
|
||||
bool passed = true;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
|
||||
std::string JoinFailures( const std::vector<std::string>& aFailures )
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
for( std::size_t i = 0; i < aFailures.size(); ++i )
|
||||
{
|
||||
if( i > 0 )
|
||||
oss << " | ";
|
||||
|
||||
oss << aFailures[i];
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
template <typename T>
|
||||
std::string JoinVector( const std::vector<T>& aValues )
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
for( std::size_t i = 0; i < aValues.size(); ++i )
|
||||
{
|
||||
if( i > 0 )
|
||||
oss << ",";
|
||||
|
||||
oss << aValues[i];
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
void LogLine( const std::string& aLine )
|
||||
{
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, aLine.c_str() );
|
||||
#else
|
||||
std::printf( "%s\n", aLine.c_str() );
|
||||
#endif
|
||||
}
|
||||
|
||||
|
||||
void LogAsyncifyState( const char* aTag )
|
||||
{
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM( {
|
||||
try {
|
||||
var tag = UTF8ToString( $0 );
|
||||
var state = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'N/A';
|
||||
var stackLen = ( typeof Asyncify !== 'undefined' && Asyncify.exportCallStack )
|
||||
? Asyncify.exportCallStack.length : 'N/A';
|
||||
var currData = ( typeof Asyncify !== 'undefined' && Asyncify.currData )
|
||||
? Asyncify.currData : 'null';
|
||||
var tableLen = ( typeof wasmTable !== 'undefined' && wasmTable )
|
||||
? wasmTable.length : 'N/A';
|
||||
console.log( '[COROUTINE_TEST] ASYNCIFY ' + tag +
|
||||
' state=' + state +
|
||||
' stackLen=' + stackLen +
|
||||
' currData=' + currData +
|
||||
' tableLen=' + tableLen );
|
||||
} catch (e) {
|
||||
console.log( '[COROUTINE_TEST] ASYNCIFY ' + UTF8ToString( $0 ) + ' error=' + e );
|
||||
}
|
||||
}, aTag );
|
||||
#else
|
||||
(void) aTag;
|
||||
#endif
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
/**
|
||||
* AutoClosingDialog - a wxDialog that closes itself after a delay.
|
||||
* Used to simulate user interaction in automated tests.
|
||||
*/
|
||||
class AutoClosingDialog : public wxDialog
|
||||
{
|
||||
public:
|
||||
AutoClosingDialog( wxWindow* aParent, const wxString& aTag, int aDelayMs ) :
|
||||
wxDialog( aParent, wxID_ANY, aTag, wxDefaultPosition, wxSize( 300, 150 ) ),
|
||||
m_tag( aTag.ToStdString() ),
|
||||
m_delayMs( aDelayMs ),
|
||||
m_timer( this, ID_MODAL_CLOSE_TIMER ),
|
||||
m_externalClose( false )
|
||||
{
|
||||
Bind( wxEVT_SHOW, &AutoClosingDialog::OnShow, this );
|
||||
Bind( wxEVT_TIMER, &AutoClosingDialog::OnTimer, this, ID_MODAL_CLOSE_TIMER );
|
||||
}
|
||||
|
||||
// If set, the dialog will not self-close; an external caller must call EndModalExternal.
|
||||
void UseExternalClose() { m_externalClose = true; }
|
||||
|
||||
void EndModalExternal( int aCode )
|
||||
{
|
||||
LogLine( "[COROUTINE_TEST] MODAL-END-EXT " + m_tag );
|
||||
EndModal( aCode );
|
||||
}
|
||||
|
||||
private:
|
||||
void OnShow( wxShowEvent& aEvent )
|
||||
{
|
||||
if( aEvent.IsShown() )
|
||||
{
|
||||
LogLine( "[COROUTINE_TEST] MODAL-SHOW " + m_tag );
|
||||
LogAsyncifyState( ( "modal-shown-" + m_tag ).c_str() );
|
||||
|
||||
if( !m_externalClose )
|
||||
m_timer.StartOnce( m_delayMs );
|
||||
}
|
||||
|
||||
aEvent.Skip();
|
||||
}
|
||||
|
||||
void OnTimer( wxTimerEvent& aEvent )
|
||||
{
|
||||
(void) aEvent;
|
||||
LogLine( "[COROUTINE_TEST] MODAL-END-AUTO " + m_tag );
|
||||
EndModal( wxID_OK );
|
||||
}
|
||||
|
||||
std::string m_tag;
|
||||
int m_delayMs;
|
||||
wxTimer m_timer;
|
||||
bool m_externalClose;
|
||||
};
|
||||
|
||||
|
||||
class NestedTestFrame : public wxFrame
|
||||
{
|
||||
public:
|
||||
NestedTestFrame() :
|
||||
wxFrame( nullptr, wxID_ANY, "Nested Coroutine+Modal Test",
|
||||
wxDefaultPosition, wxSize( 1000, 760 ) ),
|
||||
m_scenarioTimer( this, ID_SCENARIO_TIMER )
|
||||
{
|
||||
wxPanel* panel = new wxPanel( this );
|
||||
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
|
||||
|
||||
wxStaticText* description = new wxStaticText(
|
||||
panel,
|
||||
wxID_ANY,
|
||||
"Tests the interaction between wxDialog::ShowModal (EM_ASYNC_JS / startModal) and\n"
|
||||
"libcontext fibers (emscripten_fiber_swap). Reproduces nested Asyncify crashes.\n"
|
||||
"The suite runs automatically on startup and reports PASS/FAIL per scenario."
|
||||
);
|
||||
sizer->Add( description, 0, wxEXPAND | wxALL, 8 );
|
||||
|
||||
m_summary = new wxStaticText( panel, wxID_ANY, "Running nested coroutine+modal suite..." );
|
||||
sizer->Add( m_summary, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8 );
|
||||
|
||||
m_log = new wxTextCtrl(
|
||||
panel,
|
||||
wxID_ANY,
|
||||
"",
|
||||
wxDefaultPosition,
|
||||
wxDefaultSize,
|
||||
wxTE_MULTILINE | wxTE_READONLY
|
||||
);
|
||||
m_log->SetFont( wxFontInfo( 10 ).Family( wxFONTFAMILY_TELETYPE ) );
|
||||
sizer->Add( m_log, 1, wxEXPAND | wxALL, 8 );
|
||||
|
||||
panel->SetSizer( sizer );
|
||||
CreateStatusBar();
|
||||
SetStatusText( "Nested coroutine+modal test harness starting" );
|
||||
|
||||
Bind( wxEVT_TIMER, &NestedTestFrame::OnScenarioTimer, this, ID_SCENARIO_TIMER );
|
||||
|
||||
CallAfter( [this]() { RunSuite(); } );
|
||||
}
|
||||
|
||||
private:
|
||||
void Log( const wxString& aMessage )
|
||||
{
|
||||
if( m_log )
|
||||
{
|
||||
m_log->AppendText( aMessage );
|
||||
m_log->AppendText( "\n" );
|
||||
}
|
||||
|
||||
LogLine( aMessage.ToStdString() );
|
||||
}
|
||||
|
||||
void FinalizeCase( const std::string& aName, CaseContext&& aCtx )
|
||||
{
|
||||
CaseResult result;
|
||||
result.name = aName;
|
||||
result.passed = aCtx.passed;
|
||||
result.detail = JoinFailures( aCtx.failures );
|
||||
|
||||
if( result.passed )
|
||||
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
|
||||
else
|
||||
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
|
||||
|
||||
m_results.push_back( std::move( result ) );
|
||||
}
|
||||
|
||||
void FinalizeSuite()
|
||||
{
|
||||
int passed = 0;
|
||||
|
||||
for( const CaseResult& result : m_results )
|
||||
{
|
||||
if( result.passed )
|
||||
++passed;
|
||||
}
|
||||
|
||||
int failed = static_cast<int>( m_results.size() ) - passed;
|
||||
wxString summary = wxString::Format( "Nested suite complete: %d passed, %d failed, %zu total",
|
||||
passed, failed, m_results.size() );
|
||||
m_summary->SetLabel( summary );
|
||||
SetStatusText( summary );
|
||||
Log( wxString::Format( "[COROUTINE_TEST] SUMMARY total=%zu passed=%d failed=%d",
|
||||
m_results.size(), passed, failed ) );
|
||||
}
|
||||
|
||||
// --- Case 1: baseline_modal_alone ---
|
||||
// Proves that the modal mechanism works in isolation (EM_ASYNC_JS/startModal).
|
||||
// If this fails, the test infrastructure is broken.
|
||||
void RunCase_BaselineModalAlone()
|
||||
{
|
||||
const std::string caseName = "baseline_modal_alone";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
CaseContext ctx;
|
||||
LogAsyncifyState( "A-pre-modal" );
|
||||
|
||||
{
|
||||
AutoClosingDialog dlg( this, "baselineA", 50 );
|
||||
int result = dlg.ShowModal();
|
||||
ctx.Expect( result == wxID_OK, "modal should return wxID_OK" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "A-post-modal" );
|
||||
FinalizeCase( caseName, std::move( ctx ) );
|
||||
}
|
||||
|
||||
// --- Case 2: baseline_fiber_alone ---
|
||||
// Proves that TestCoroutine works without any modal involvement.
|
||||
void RunCase_BaselineFiberAlone()
|
||||
{
|
||||
const std::string caseName = "baseline_fiber_alone";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
CaseContext ctx;
|
||||
LogAsyncifyState( "B-pre-fiber" );
|
||||
|
||||
TestCoroutine coroutine( []( TestCoroutine& self ) {
|
||||
self.Yield( 42 );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 1 );
|
||||
ctx.Expect( running, "fiber should yield on first call" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 42, "yield value should be 42" );
|
||||
|
||||
running = coroutine.Resume( 2 );
|
||||
ctx.Expect( !running, "fiber should finish on resume" );
|
||||
|
||||
LogAsyncifyState( "B-post-fiber" );
|
||||
FinalizeCase( caseName, std::move( ctx ) );
|
||||
}
|
||||
|
||||
// --- Case 3: fiber_create_run_destroy_inside_modal (THE TARGET REPRODUCER) ---
|
||||
// A fiber is created, run to completion, and destroyed during a modal's event loop.
|
||||
// In the broken state, the modal's rewind after EndModal crashes with index out of bounds.
|
||||
void StartCase_FiberInsideModal()
|
||||
{
|
||||
const std::string caseName = "fiber_create_run_destroy_inside_modal";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_currentCaseName = caseName;
|
||||
m_currentCtx = std::make_unique<CaseContext>();
|
||||
LogAsyncifyState( "S3-pre-modal" );
|
||||
|
||||
auto dlg = std::make_unique<AutoClosingDialog>( this, "S3", 0 );
|
||||
dlg->UseExternalClose();
|
||||
|
||||
// Arm a scenario timer that will fire INSIDE the modal's event loop
|
||||
m_pendingScenario = [this]() { RunScenario3FiberWork(); };
|
||||
m_scenarioTimer.StartOnce( 30 );
|
||||
|
||||
// Show the modal (this blocks under EM_ASYNC_JS)
|
||||
m_activeDialog = dlg.get();
|
||||
int result = dlg->ShowModal();
|
||||
m_activeDialog = nullptr;
|
||||
|
||||
LogAsyncifyState( "S3-post-modal" );
|
||||
m_currentCtx->Expect( result == wxID_OK,
|
||||
"modal should return wxID_OK (actual: " + std::to_string( result ) + ")" );
|
||||
|
||||
FinalizeCase( caseName, std::move( *m_currentCtx ) );
|
||||
m_currentCtx.reset();
|
||||
|
||||
// Chain to next scenario
|
||||
CallAfter( [this]() { StartCase_FiberMultiSwapInsideModal(); } );
|
||||
}
|
||||
|
||||
void RunScenario3FiberWork()
|
||||
{
|
||||
LogAsyncifyState( "S3-timer-enter" );
|
||||
|
||||
{
|
||||
TestCoroutine co( []( TestCoroutine& self ) {
|
||||
self.Yield( 100 );
|
||||
} );
|
||||
|
||||
bool running = co.Call( 1 );
|
||||
m_currentCtx->Expect( running, "S3: fiber should yield on first call" );
|
||||
m_currentCtx->Expect( co.LastReturnValue() == 100, "S3: yield value should be 100" );
|
||||
|
||||
LogAsyncifyState( "S3-after-call" );
|
||||
|
||||
running = co.Resume( 2 );
|
||||
m_currentCtx->Expect( !running, "S3: fiber should finish on resume" );
|
||||
|
||||
LogAsyncifyState( "S3-after-resume" );
|
||||
}
|
||||
// Fiber destroyed here
|
||||
|
||||
LogAsyncifyState( "S3-after-destroy" );
|
||||
|
||||
if( m_activeDialog )
|
||||
m_activeDialog->EndModalExternal( wxID_OK );
|
||||
}
|
||||
|
||||
// --- Case 4: fiber_multi_swap_inside_modal ---
|
||||
// Multiple fiber yield/resume cycles inside a modal. Tests if the bug needs >=2 swaps.
|
||||
void StartCase_FiberMultiSwapInsideModal()
|
||||
{
|
||||
const std::string caseName = "fiber_multi_swap_inside_modal";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_currentCaseName = caseName;
|
||||
m_currentCtx = std::make_unique<CaseContext>();
|
||||
LogAsyncifyState( "S4-pre-modal" );
|
||||
|
||||
auto dlg = std::make_unique<AutoClosingDialog>( this, "S4", 0 );
|
||||
dlg->UseExternalClose();
|
||||
|
||||
m_pendingScenario = [this]() { RunScenario4MultiSwap(); };
|
||||
m_scenarioTimer.StartOnce( 30 );
|
||||
|
||||
m_activeDialog = dlg.get();
|
||||
int result = dlg->ShowModal();
|
||||
m_activeDialog = nullptr;
|
||||
|
||||
LogAsyncifyState( "S4-post-modal" );
|
||||
m_currentCtx->Expect( result == wxID_OK, "S4: modal should return wxID_OK" );
|
||||
|
||||
FinalizeCase( caseName, std::move( *m_currentCtx ) );
|
||||
m_currentCtx.reset();
|
||||
|
||||
CallAfter( [this]() { StartCase_FiberYieldAcrossModalClose(); } );
|
||||
}
|
||||
|
||||
void RunScenario4MultiSwap()
|
||||
{
|
||||
LogAsyncifyState( "S4-timer-enter" );
|
||||
|
||||
{
|
||||
TestCoroutine co( []( TestCoroutine& self ) {
|
||||
self.Yield( 1 );
|
||||
self.Yield( 2 );
|
||||
self.Yield( 3 );
|
||||
} );
|
||||
|
||||
bool running = co.Call( 10 );
|
||||
m_currentCtx->Expect( running, "S4: first yield" );
|
||||
m_currentCtx->Expect( co.LastReturnValue() == 1, "S4: yield value 1" );
|
||||
|
||||
running = co.Resume( 20 );
|
||||
m_currentCtx->Expect( running, "S4: second yield" );
|
||||
m_currentCtx->Expect( co.LastReturnValue() == 2, "S4: yield value 2" );
|
||||
|
||||
running = co.Resume( 30 );
|
||||
m_currentCtx->Expect( running, "S4: third yield" );
|
||||
m_currentCtx->Expect( co.LastReturnValue() == 3, "S4: yield value 3" );
|
||||
|
||||
running = co.Resume( 40 );
|
||||
m_currentCtx->Expect( !running, "S4: fiber should finish" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S4-after-fiber" );
|
||||
|
||||
if( m_activeDialog )
|
||||
m_activeDialog->EndModalExternal( wxID_OK );
|
||||
}
|
||||
|
||||
// --- Case 5: fiber_yield_across_modal_close ---
|
||||
// Fiber is Call()'d and yields, then modal closes WITHOUT resuming the fiber.
|
||||
// After modal, we resume the still-suspended fiber. Tests dormant fiber buffer impact.
|
||||
void StartCase_FiberYieldAcrossModalClose()
|
||||
{
|
||||
const std::string caseName = "fiber_yield_across_modal_close";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_currentCaseName = caseName;
|
||||
m_currentCtx = std::make_unique<CaseContext>();
|
||||
LogAsyncifyState( "S5-pre-modal" );
|
||||
|
||||
m_s5Fiber = std::make_unique<TestCoroutine>( []( TestCoroutine& self ) {
|
||||
self.Yield( 501 );
|
||||
self.Yield( 502 );
|
||||
} );
|
||||
|
||||
auto dlg = std::make_unique<AutoClosingDialog>( this, "S5", 0 );
|
||||
dlg->UseExternalClose();
|
||||
|
||||
m_pendingScenario = [this]() { RunScenario5Yield(); };
|
||||
m_scenarioTimer.StartOnce( 30 );
|
||||
|
||||
m_activeDialog = dlg.get();
|
||||
int result = dlg->ShowModal();
|
||||
m_activeDialog = nullptr;
|
||||
|
||||
LogAsyncifyState( "S5-post-modal" );
|
||||
|
||||
// After modal, resume the fiber
|
||||
if( m_s5Fiber && m_s5Fiber->Running() )
|
||||
{
|
||||
bool running = m_s5Fiber->Resume( 99 );
|
||||
m_currentCtx->Expect( running, "S5: fiber should yield again after modal close" );
|
||||
|
||||
running = m_s5Fiber->Resume( 100 );
|
||||
m_currentCtx->Expect( !running, "S5: fiber should finish after second resume" );
|
||||
}
|
||||
|
||||
m_s5Fiber.reset();
|
||||
m_currentCtx->Expect( result == wxID_OK, "S5: modal should return wxID_OK" );
|
||||
|
||||
FinalizeCase( caseName, std::move( *m_currentCtx ) );
|
||||
m_currentCtx.reset();
|
||||
|
||||
CallAfter( [this]() { StartCase_FiberDeepYieldLoop(); } );
|
||||
}
|
||||
|
||||
void RunScenario5Yield()
|
||||
{
|
||||
LogAsyncifyState( "S5-timer-enter" );
|
||||
|
||||
bool running = m_s5Fiber->Call( 1 );
|
||||
m_currentCtx->Expect( running, "S5: fiber should yield in modal" );
|
||||
m_currentCtx->Expect( m_s5Fiber->LastReturnValue() == 501, "S5: yield 501" );
|
||||
|
||||
LogAsyncifyState( "S5-fiber-yielded" );
|
||||
|
||||
// Do NOT resume; leave the fiber suspended across the modal close.
|
||||
|
||||
if( m_activeDialog )
|
||||
m_activeDialog->EndModalExternal( wxID_OK );
|
||||
}
|
||||
|
||||
// --- Case 6: fiber_deep_yield_loop_inside_modal ---
|
||||
// Deep recursive stack with many yields inside a modal. Stresses asyncify buffers.
|
||||
void StartCase_FiberDeepYieldLoop()
|
||||
{
|
||||
const std::string caseName = "fiber_deep_yield_loop_inside_modal";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_currentCaseName = caseName;
|
||||
m_currentCtx = std::make_unique<CaseContext>();
|
||||
LogAsyncifyState( "S6-pre-modal" );
|
||||
|
||||
auto dlg = std::make_unique<AutoClosingDialog>( this, "S6", 0 );
|
||||
dlg->UseExternalClose();
|
||||
|
||||
m_pendingScenario = [this]() { RunScenario6DeepYield(); };
|
||||
m_scenarioTimer.StartOnce( 30 );
|
||||
|
||||
m_activeDialog = dlg.get();
|
||||
int result = dlg->ShowModal();
|
||||
m_activeDialog = nullptr;
|
||||
|
||||
LogAsyncifyState( "S6-post-modal" );
|
||||
m_currentCtx->Expect( result == wxID_OK, "S6: modal should return wxID_OK" );
|
||||
|
||||
FinalizeCase( caseName, std::move( *m_currentCtx ) );
|
||||
m_currentCtx.reset();
|
||||
|
||||
CallAfter( [this]() { StartCase_ModalFiberModalSequence(); } );
|
||||
}
|
||||
|
||||
void RunScenario6DeepYield()
|
||||
{
|
||||
LogAsyncifyState( "S6-timer-enter" );
|
||||
|
||||
{
|
||||
TestCoroutine co( [ctx = m_currentCtx.get()]( TestCoroutine& self ) {
|
||||
std::function<void( int )> dive = [&]( int depth ) {
|
||||
std::array<int, 8> locals {};
|
||||
|
||||
for( std::size_t i = 0; i < locals.size(); ++i )
|
||||
locals[i] = depth * 10 + static_cast<int>( i );
|
||||
|
||||
int expected = std::accumulate( locals.begin(), locals.end(), 0 );
|
||||
|
||||
if( depth == 0 )
|
||||
{
|
||||
self.Yield( 600 );
|
||||
ctx->Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
|
||||
"S6: deepest frame locals survive resume" );
|
||||
return;
|
||||
}
|
||||
|
||||
dive( depth - 1 );
|
||||
ctx->Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
|
||||
"S6: frame locals survive at depth " + std::to_string( depth ) );
|
||||
};
|
||||
|
||||
dive( 4 );
|
||||
} );
|
||||
|
||||
bool running = co.Call( 1 );
|
||||
m_currentCtx->Expect( running, "S6: deep fiber should yield" );
|
||||
m_currentCtx->Expect( co.LastReturnValue() == 600, "S6: deep yield value" );
|
||||
|
||||
running = co.Resume( 2 );
|
||||
m_currentCtx->Expect( !running, "S6: deep fiber should finish" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S6-after-fiber" );
|
||||
|
||||
if( m_activeDialog )
|
||||
m_activeDialog->EndModalExternal( wxID_OK );
|
||||
}
|
||||
|
||||
// --- Case 7: modal_fiber_modal_sequence ---
|
||||
// Modal A -> fiber work between -> Modal B. Tests state leak across modal boundaries.
|
||||
void StartCase_ModalFiberModalSequence()
|
||||
{
|
||||
const std::string caseName = "modal_fiber_modal_sequence";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
CaseContext ctx;
|
||||
LogAsyncifyState( "S7-pre-modal-A" );
|
||||
|
||||
// Modal A (auto-close)
|
||||
{
|
||||
AutoClosingDialog dlgA( this, "S7A", 50 );
|
||||
int resultA = dlgA.ShowModal();
|
||||
ctx.Expect( resultA == wxID_OK, "S7: modal A should return wxID_OK" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S7-post-modal-A" );
|
||||
|
||||
// Fiber work between modals
|
||||
{
|
||||
TestCoroutine co( []( TestCoroutine& self ) {
|
||||
self.Yield( 700 );
|
||||
} );
|
||||
|
||||
bool running = co.Call( 1 );
|
||||
ctx.Expect( running, "S7: inter-modal fiber should yield" );
|
||||
|
||||
running = co.Resume( 2 );
|
||||
ctx.Expect( !running, "S7: inter-modal fiber should finish" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S7-mid" );
|
||||
|
||||
// Modal B (auto-close)
|
||||
{
|
||||
AutoClosingDialog dlgB( this, "S7B", 50 );
|
||||
int resultB = dlgB.ShowModal();
|
||||
ctx.Expect( resultB == wxID_OK, "S7: modal B should return wxID_OK" );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S7-post-modal-B" );
|
||||
|
||||
FinalizeCase( "modal_fiber_modal_sequence", std::move( ctx ) );
|
||||
|
||||
CallAfter( [this]() { StartCase_NestedFibersInsideModal(); } );
|
||||
}
|
||||
|
||||
// --- Case 8: nested_fibers_inside_modal ---
|
||||
// Parent fiber calls child fiber (FROM_ROUTINE) inside modal.
|
||||
void StartCase_NestedFibersInsideModal()
|
||||
{
|
||||
const std::string caseName = "nested_fibers_inside_modal";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_currentCaseName = caseName;
|
||||
m_currentCtx = std::make_unique<CaseContext>();
|
||||
LogAsyncifyState( "S8-pre-modal" );
|
||||
|
||||
auto dlg = std::make_unique<AutoClosingDialog>( this, "S8", 0 );
|
||||
dlg->UseExternalClose();
|
||||
|
||||
m_pendingScenario = [this]() { RunScenario8NestedFibers(); };
|
||||
m_scenarioTimer.StartOnce( 30 );
|
||||
|
||||
m_activeDialog = dlg.get();
|
||||
int result = dlg->ShowModal();
|
||||
m_activeDialog = nullptr;
|
||||
|
||||
LogAsyncifyState( "S8-post-modal" );
|
||||
m_currentCtx->Expect( result == wxID_OK, "S8: modal should return wxID_OK" );
|
||||
|
||||
FinalizeCase( caseName, std::move( *m_currentCtx ) );
|
||||
m_currentCtx.reset();
|
||||
|
||||
// Done with all cases
|
||||
CallAfter( [this]() { FinalizeSuite(); } );
|
||||
}
|
||||
|
||||
void RunScenario8NestedFibers()
|
||||
{
|
||||
LogAsyncifyState( "S8-timer-enter" );
|
||||
|
||||
{
|
||||
auto ctx = m_currentCtx.get();
|
||||
std::vector<std::string> sequence;
|
||||
|
||||
TestCoroutine child( [&sequence]( TestCoroutine& self ) {
|
||||
sequence.push_back( "child-start" );
|
||||
self.Yield( 801 );
|
||||
sequence.push_back( "child-end" );
|
||||
} );
|
||||
|
||||
TestCoroutine parent( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "parent-start" );
|
||||
bool childRunning = child.Call( self, 100 );
|
||||
ctx->Expect( childRunning, "S8: child should yield to parent" );
|
||||
ctx->Expect( child.LastReturnValue() == 801, "S8: child yield value" );
|
||||
sequence.push_back( "parent-after-child-yield" );
|
||||
|
||||
childRunning = child.Resume( self, 200 );
|
||||
ctx->Expect( !childRunning, "S8: child should finish on resume" );
|
||||
sequence.push_back( "parent-end" );
|
||||
} );
|
||||
|
||||
bool running = parent.Call( 1 );
|
||||
ctx->Expect( !running, "S8: parent should complete" );
|
||||
|
||||
const std::vector<std::string> expected = {
|
||||
"parent-start", "child-start", "parent-after-child-yield", "child-end", "parent-end"
|
||||
};
|
||||
ctx->Expect( sequence == expected,
|
||||
"S8: unexpected sequence: " + JoinVector( sequence ) );
|
||||
}
|
||||
|
||||
LogAsyncifyState( "S8-after-fiber" );
|
||||
|
||||
if( m_activeDialog )
|
||||
m_activeDialog->EndModalExternal( wxID_OK );
|
||||
}
|
||||
|
||||
// --- Scenario timer handler (runs inside modal event loops) ---
|
||||
void OnScenarioTimer( wxTimerEvent& aEvent )
|
||||
{
|
||||
if( aEvent.GetId() != ID_SCENARIO_TIMER )
|
||||
return;
|
||||
|
||||
if( m_pendingScenario )
|
||||
{
|
||||
auto scenario = std::move( m_pendingScenario );
|
||||
m_pendingScenario = nullptr;
|
||||
scenario();
|
||||
}
|
||||
}
|
||||
|
||||
// --- RunSuite: kicks off the synchronous cases, then chains async ones ---
|
||||
void RunSuite()
|
||||
{
|
||||
m_results.clear();
|
||||
|
||||
// Synchronous baselines first
|
||||
RunCase_BaselineModalAlone();
|
||||
RunCase_BaselineFiberAlone();
|
||||
|
||||
// Chain async modal+fiber scenarios 3..8
|
||||
CallAfter( [this]() { StartCase_FiberInsideModal(); } );
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<CaseResult> m_results;
|
||||
wxTimer m_scenarioTimer;
|
||||
std::function<void()> m_pendingScenario;
|
||||
std::string m_currentCaseName;
|
||||
std::unique_ptr<CaseContext> m_currentCtx;
|
||||
AutoClosingDialog* m_activeDialog = nullptr;
|
||||
std::unique_ptr<TestCoroutine> m_s5Fiber;
|
||||
wxStaticText* m_summary = nullptr;
|
||||
wxTextCtrl* m_log = nullptr;
|
||||
};
|
||||
|
||||
|
||||
class NestedTestApp : public wxApp
|
||||
{
|
||||
public:
|
||||
bool OnInit() override
|
||||
{
|
||||
NestedTestFrame* frame = new NestedTestFrame();
|
||||
frame->Show();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
wxIMPLEMENT_APP( NestedTestApp );
|
||||
975
tests/apps/standalone/coroutine/coroutine_test.cpp
Normal file
975
tests/apps/standalone/coroutine/coroutine_test.cpp
Normal file
|
|
@ -0,0 +1,975 @@
|
|||
#include "wx/wx.h"
|
||||
#include "wx/textctrl.h"
|
||||
#include "wx/timer.h"
|
||||
|
||||
#include "kicad_coroutine_harness.h"
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
#include <emscripten/emscripten.h>
|
||||
#endif
|
||||
|
||||
#include <array>
|
||||
#include <functional>
|
||||
#include <numeric>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using coroutine_test::TestCoroutine;
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr int ID_ASYNC_CASE_TIMER = wxID_HIGHEST + 450;
|
||||
|
||||
struct CaseContext
|
||||
{
|
||||
bool passed = true;
|
||||
std::vector<std::string> failures;
|
||||
|
||||
void Expect( bool aCondition, const std::string& aMessage )
|
||||
{
|
||||
if( !aCondition )
|
||||
{
|
||||
passed = false;
|
||||
failures.push_back( aMessage );
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
struct CaseResult
|
||||
{
|
||||
std::string name;
|
||||
bool passed = true;
|
||||
std::string detail;
|
||||
};
|
||||
|
||||
|
||||
std::string JoinFailures( const std::vector<std::string>& aFailures )
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
for( std::size_t i = 0; i < aFailures.size(); ++i )
|
||||
{
|
||||
if( i > 0 )
|
||||
oss << " | ";
|
||||
|
||||
oss << aFailures[i];
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
|
||||
template <typename T>
|
||||
std::string JoinVector( const std::vector<T>& aValues )
|
||||
{
|
||||
std::ostringstream oss;
|
||||
|
||||
for( std::size_t i = 0; i < aValues.size(); ++i )
|
||||
{
|
||||
if( i > 0 )
|
||||
oss << ",";
|
||||
|
||||
oss << aValues[i];
|
||||
}
|
||||
|
||||
return oss.str();
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
|
||||
class CoroutineTestFrame : public wxFrame
|
||||
{
|
||||
public:
|
||||
CoroutineTestFrame() :
|
||||
wxFrame( nullptr, wxID_ANY, "Coroutine Stress Test",
|
||||
wxDefaultPosition, wxSize( 1000, 760 ) ),
|
||||
m_asyncCaseTimer( this, ID_ASYNC_CASE_TIMER )
|
||||
{
|
||||
wxPanel* panel = new wxPanel( this );
|
||||
wxBoxSizer* sizer = new wxBoxSizer( wxVERTICAL );
|
||||
|
||||
wxStaticText* description = new wxStaticText(
|
||||
panel,
|
||||
wxID_ANY,
|
||||
"Stress-tests KiCad-style coroutine semantics on top of the real libcontext WASM port.\n"
|
||||
"The suite runs automatically on startup and reports PASS/FAIL per scenario."
|
||||
);
|
||||
sizer->Add( description, 0, wxEXPAND | wxALL, 8 );
|
||||
|
||||
m_summary = new wxStaticText( panel, wxID_ANY, "Running coroutine stress suite..." );
|
||||
sizer->Add( m_summary, 0, wxEXPAND | wxLEFT | wxRIGHT | wxBOTTOM, 8 );
|
||||
|
||||
m_log = new wxTextCtrl(
|
||||
panel,
|
||||
wxID_ANY,
|
||||
"",
|
||||
wxDefaultPosition,
|
||||
wxDefaultSize,
|
||||
wxTE_MULTILINE | wxTE_READONLY
|
||||
);
|
||||
m_log->SetFont( wxFontInfo( 10 ).Family( wxFONTFAMILY_TELETYPE ) );
|
||||
sizer->Add( m_log, 1, wxEXPAND | wxALL, 8 );
|
||||
|
||||
panel->SetSizer( sizer );
|
||||
CreateStatusBar();
|
||||
SetStatusText( "Coroutine test harness starting" );
|
||||
Bind( wxEVT_TIMER, &CoroutineTestFrame::OnAsyncCaseTimer, this, ID_ASYNC_CASE_TIMER );
|
||||
|
||||
CallAfter( [this]() { RunSuite(); } );
|
||||
}
|
||||
|
||||
private:
|
||||
struct AsyncWaitLoopCaseState
|
||||
{
|
||||
struct Event
|
||||
{
|
||||
std::string name;
|
||||
};
|
||||
|
||||
CaseContext ctx;
|
||||
std::vector<std::string> sequence;
|
||||
bool pendingWait = false;
|
||||
bool shutdown = false;
|
||||
Event wakeupEvent;
|
||||
std::unique_ptr<TestCoroutine> tool;
|
||||
int phase = 0;
|
||||
};
|
||||
|
||||
struct AsyncNestedResumeCaseState
|
||||
{
|
||||
struct Event
|
||||
{
|
||||
std::string name;
|
||||
};
|
||||
|
||||
CaseContext ctx;
|
||||
std::vector<std::string> sequence;
|
||||
bool selectionPendingWait = false;
|
||||
bool selectionShutdown = false;
|
||||
Event selectionWakeupEvent;
|
||||
std::unique_ptr<TestCoroutine> selection;
|
||||
std::unique_ptr<TestCoroutine> control;
|
||||
int phase = 0;
|
||||
};
|
||||
|
||||
void Log( const wxString& aMessage )
|
||||
{
|
||||
if( m_log )
|
||||
{
|
||||
m_log->AppendText( aMessage );
|
||||
m_log->AppendText( "\n" );
|
||||
}
|
||||
|
||||
std::string utf8 = aMessage.ToStdString();
|
||||
|
||||
#ifdef __EMSCRIPTEN__
|
||||
EM_ASM( {
|
||||
console.log( UTF8ToString( $0 ) );
|
||||
}, utf8.c_str() );
|
||||
#else
|
||||
printf( "%s\n", utf8.c_str() );
|
||||
#endif
|
||||
}
|
||||
|
||||
CaseResult RunCase( const std::string& aName, const std::function<void( CaseContext& )>& aFn )
|
||||
{
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", aName ) );
|
||||
|
||||
CaseContext ctx;
|
||||
aFn( ctx );
|
||||
|
||||
CaseResult result;
|
||||
result.name = aName;
|
||||
result.passed = ctx.passed;
|
||||
result.detail = JoinFailures( ctx.failures );
|
||||
|
||||
if( result.passed )
|
||||
{
|
||||
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
void FinishCase( CaseResult aResult )
|
||||
{
|
||||
m_results.push_back( std::move( aResult ) );
|
||||
|
||||
if( m_pendingAsyncCases == 0 )
|
||||
FinalizeSuite();
|
||||
}
|
||||
|
||||
void FinalizeCase( const std::string& aName, CaseContext&& aCtx )
|
||||
{
|
||||
CaseResult result;
|
||||
result.name = aName;
|
||||
result.passed = aCtx.passed;
|
||||
result.detail = JoinFailures( aCtx.failures );
|
||||
|
||||
if( result.passed )
|
||||
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
|
||||
else
|
||||
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
|
||||
|
||||
FinishCase( std::move( result ) );
|
||||
}
|
||||
|
||||
void FinalizeSuite()
|
||||
{
|
||||
int passed = 0;
|
||||
|
||||
for( const CaseResult& result : m_results )
|
||||
{
|
||||
if( result.passed )
|
||||
++passed;
|
||||
}
|
||||
|
||||
int failed = static_cast<int>( m_results.size() ) - passed;
|
||||
wxString summary = wxString::Format( "Coroutine suite complete: %d passed, %d failed, %zu total",
|
||||
passed, failed, m_results.size() );
|
||||
m_summary->SetLabel( summary );
|
||||
SetStatusText( summary );
|
||||
Log( wxString::Format( "[COROUTINE_TEST] SUMMARY total=%zu passed=%d failed=%d",
|
||||
m_results.size(), passed, failed ) );
|
||||
}
|
||||
|
||||
void StartAsyncWaitLoopCase()
|
||||
{
|
||||
constexpr const char* caseName = "async_wait_loop_stays_suspended";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_pendingAsyncCases = 1;
|
||||
m_asyncCaseName = caseName;
|
||||
m_asyncState = std::make_unique<AsyncWaitLoopCaseState>();
|
||||
|
||||
AsyncWaitLoopCaseState* state = m_asyncState.get();
|
||||
|
||||
state->tool = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
|
||||
while( true )
|
||||
{
|
||||
state->ctx.Expect( !state->pendingWait, "tool should not enter Wait twice in a row" );
|
||||
state->pendingWait = true;
|
||||
state->sequence.push_back( "wait-enter" );
|
||||
self.Yield( 700 );
|
||||
state->sequence.push_back( "wait-return" );
|
||||
|
||||
if( state->shutdown )
|
||||
break;
|
||||
|
||||
state->ctx.Expect( !state->wakeupEvent.name.empty(),
|
||||
"wakeup event should be populated before resume" );
|
||||
state->sequence.push_back( "event:" + state->wakeupEvent.name );
|
||||
}
|
||||
|
||||
state->sequence.push_back( "tool-end" );
|
||||
} );
|
||||
|
||||
bool running = state->tool->Call( 1 );
|
||||
state->ctx.Expect( running, "tool wait loop should yield on initial call" );
|
||||
state->ctx.Expect( state->tool->LastReturnValue() == 700,
|
||||
"initial wait yield should reach the root" );
|
||||
state->ctx.Expect( state->pendingWait, "tool should be pending wait after initial yield" );
|
||||
|
||||
const std::vector<std::string> expectedBeforeDispatch = {
|
||||
"wait-enter"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedBeforeDispatch,
|
||||
"unexpected async sequence before dispatch: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
|
||||
state->phase = 1;
|
||||
m_asyncCaseTimer.StartOnce( 10 );
|
||||
}
|
||||
|
||||
void CompleteAsyncWaitLoopCase()
|
||||
{
|
||||
if( !m_asyncState )
|
||||
return;
|
||||
|
||||
RecordAsyncCase( m_asyncCaseName, std::move( m_asyncState->ctx ) );
|
||||
m_asyncState.reset();
|
||||
StartAsyncNestedResumeCase();
|
||||
}
|
||||
|
||||
void RecordAsyncCase( const std::string& aName, CaseContext&& aCtx )
|
||||
{
|
||||
CaseResult result;
|
||||
result.name = aName;
|
||||
result.passed = aCtx.passed;
|
||||
result.detail = JoinFailures( aCtx.failures );
|
||||
|
||||
if( result.passed )
|
||||
Log( wxString::Format( "[COROUTINE_TEST] PASS %s", aName ) );
|
||||
else
|
||||
Log( wxString::Format( "[COROUTINE_TEST] FAIL %s :: %s", aName, result.detail ) );
|
||||
|
||||
m_results.push_back( std::move( result ) );
|
||||
}
|
||||
|
||||
void StartAsyncNestedResumeCase()
|
||||
{
|
||||
constexpr const char* caseName = "async_nested_resume_from_child_tool";
|
||||
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
|
||||
|
||||
m_asyncCaseName = caseName;
|
||||
m_nestedAsyncState = std::make_unique<AsyncNestedResumeCaseState>();
|
||||
|
||||
AsyncNestedResumeCaseState* state = m_nestedAsyncState.get();
|
||||
|
||||
state->selection = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
|
||||
while( true )
|
||||
{
|
||||
state->ctx.Expect( !state->selectionPendingWait,
|
||||
"selection should not enter Wait twice in a row" );
|
||||
state->selectionPendingWait = true;
|
||||
state->sequence.push_back( "selection:wait-enter" );
|
||||
self.Yield( 700 );
|
||||
state->sequence.push_back( "selection:wait-return" );
|
||||
|
||||
if( state->selectionShutdown )
|
||||
break;
|
||||
|
||||
state->ctx.Expect( !state->selectionWakeupEvent.name.empty(),
|
||||
"selection wakeup event should be populated before nested resume" );
|
||||
state->sequence.push_back( "selection:event:" + state->selectionWakeupEvent.name );
|
||||
}
|
||||
|
||||
state->sequence.push_back( "selection:tool-end" );
|
||||
} );
|
||||
|
||||
state->control = std::make_unique<TestCoroutine>( [state]( TestCoroutine& self ) {
|
||||
(void) self;
|
||||
state->sequence.push_back( "control:start" );
|
||||
state->ctx.Expect( state->selectionPendingWait,
|
||||
"selection should be pending when child tool resumes it" );
|
||||
state->selectionPendingWait = false;
|
||||
state->selectionWakeupEvent = { "metricUnits" };
|
||||
|
||||
bool selectionRunning = state->selection->Resume( self, 2 );
|
||||
state->ctx.Expect( selectionRunning,
|
||||
"selection should yield back to child tool after nested resume" );
|
||||
state->ctx.Expect( state->selection->LastReturnValue() == 700,
|
||||
"nested selection yield should reach the child tool" );
|
||||
state->ctx.Expect( state->selectionPendingWait,
|
||||
"selection should be waiting again after nested resume" );
|
||||
|
||||
state->sequence.push_back( "control:after-selection" );
|
||||
} );
|
||||
|
||||
bool selectionRunning = state->selection->Call( 1 );
|
||||
state->ctx.Expect( selectionRunning, "selection should yield on initial call" );
|
||||
state->ctx.Expect( state->selection->LastReturnValue() == 700,
|
||||
"initial selection yield should reach the root" );
|
||||
state->ctx.Expect( state->selectionPendingWait,
|
||||
"selection should be pending wait after initial yield" );
|
||||
|
||||
const std::vector<std::string> expectedBeforeNestedResume = {
|
||||
"selection:wait-enter"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedBeforeNestedResume,
|
||||
"unexpected nested sequence before child tool dispatch: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
|
||||
state->phase = 1;
|
||||
m_asyncCaseTimer.StartOnce( 10 );
|
||||
}
|
||||
|
||||
void CompleteAsyncNestedResumeCase()
|
||||
{
|
||||
if( !m_nestedAsyncState )
|
||||
return;
|
||||
|
||||
m_pendingAsyncCases = 0;
|
||||
FinalizeCase( m_asyncCaseName, std::move( m_nestedAsyncState->ctx ) );
|
||||
m_nestedAsyncState.reset();
|
||||
}
|
||||
|
||||
void OnAsyncCaseTimer( wxTimerEvent& aEvent )
|
||||
{
|
||||
if( aEvent.GetId() != ID_ASYNC_CASE_TIMER )
|
||||
return;
|
||||
|
||||
if( m_asyncState )
|
||||
{
|
||||
AsyncWaitLoopCaseState* state = m_asyncState.get();
|
||||
|
||||
if( state->phase == 1 )
|
||||
{
|
||||
state->ctx.Expect( state->pendingWait,
|
||||
"tool should still be pending when the browser callback resumes it" );
|
||||
state->pendingWait = false;
|
||||
state->wakeupEvent = { "metricUnits" };
|
||||
|
||||
bool running = state->tool->Resume( 2 );
|
||||
state->ctx.Expect( running, "tool should yield again after the first callback resume" );
|
||||
state->ctx.Expect( state->tool->LastReturnValue() == 700,
|
||||
"second wait yield should reach the root" );
|
||||
state->ctx.Expect( state->pendingWait,
|
||||
"tool should be pending wait again after second yield" );
|
||||
state->ctx.Expect( state->tool->Running(),
|
||||
"tool should still be suspended after second yield" );
|
||||
|
||||
const std::vector<std::string> expectedAfterFirstResume = {
|
||||
"wait-enter",
|
||||
"wait-return",
|
||||
"event:metricUnits",
|
||||
"wait-enter"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedAfterFirstResume,
|
||||
"unexpected wait-loop sequence after first callback resume: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
|
||||
state->phase = 2;
|
||||
m_asyncCaseTimer.StartOnce( 10 );
|
||||
return;
|
||||
}
|
||||
|
||||
if( state->phase == 2 )
|
||||
{
|
||||
const std::vector<std::string> expectedStillSuspended = {
|
||||
"wait-enter",
|
||||
"wait-return",
|
||||
"event:metricUnits",
|
||||
"wait-enter"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedStillSuspended,
|
||||
"tool should remain suspended until the next explicit dispatch: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
state->ctx.Expect( state->pendingWait,
|
||||
"tool should still be pending wait before the next dispatch" );
|
||||
state->ctx.Expect( state->tool->Running(),
|
||||
"tool should still be running before the next dispatch" );
|
||||
|
||||
state->phase = 3;
|
||||
m_asyncCaseTimer.StartOnce( 10 );
|
||||
return;
|
||||
}
|
||||
|
||||
if( state->phase == 3 )
|
||||
{
|
||||
state->ctx.Expect( state->pendingWait,
|
||||
"tool should still be pending before the shutdown dispatch" );
|
||||
state->pendingWait = false;
|
||||
state->shutdown = true;
|
||||
state->wakeupEvent = { "shutdown" };
|
||||
|
||||
bool running = state->tool->Resume( 3 );
|
||||
state->ctx.Expect( !running, "tool should finish after the explicit shutdown dispatch" );
|
||||
|
||||
const std::vector<std::string> expectedAfterFinalResume = {
|
||||
"wait-enter",
|
||||
"wait-return",
|
||||
"event:metricUnits",
|
||||
"wait-enter",
|
||||
"wait-return",
|
||||
"tool-end"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedAfterFinalResume,
|
||||
"unexpected wait-loop sequence after shutdown dispatch: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
|
||||
CompleteAsyncWaitLoopCase();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if( !m_nestedAsyncState )
|
||||
return;
|
||||
|
||||
AsyncNestedResumeCaseState* state = m_nestedAsyncState.get();
|
||||
|
||||
if( state->phase == 1 )
|
||||
{
|
||||
bool controlRunning = state->control->Call( 11 );
|
||||
state->ctx.Expect( !controlRunning,
|
||||
"child tool should finish after resuming selection once" );
|
||||
|
||||
const std::vector<std::string> expectedAfterNestedResume = {
|
||||
"selection:wait-enter",
|
||||
"control:start",
|
||||
"selection:wait-return",
|
||||
"selection:event:metricUnits",
|
||||
"selection:wait-enter",
|
||||
"control:after-selection"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedAfterNestedResume,
|
||||
"unexpected nested sequence after child tool resume: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
state->ctx.Expect( state->selectionPendingWait,
|
||||
"selection should be waiting again after the child tool finishes" );
|
||||
state->ctx.Expect( state->selection->Running(),
|
||||
"selection should still be suspended after nested resume" );
|
||||
|
||||
state->phase = 2;
|
||||
m_asyncCaseTimer.StartOnce( 10 );
|
||||
return;
|
||||
}
|
||||
|
||||
if( state->phase == 2 )
|
||||
{
|
||||
const std::vector<std::string> expectedStillSuspended = {
|
||||
"selection:wait-enter",
|
||||
"control:start",
|
||||
"selection:wait-return",
|
||||
"selection:event:metricUnits",
|
||||
"selection:wait-enter",
|
||||
"control:after-selection"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedStillSuspended,
|
||||
"selection should remain suspended after the child tool returns: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
state->ctx.Expect( state->selectionPendingWait,
|
||||
"selection should still be pending before the shutdown dispatch" );
|
||||
state->ctx.Expect( state->selection->Running(),
|
||||
"selection should still be running before the shutdown dispatch" );
|
||||
|
||||
state->selectionPendingWait = false;
|
||||
state->selectionShutdown = true;
|
||||
state->selectionWakeupEvent = { "shutdown" };
|
||||
|
||||
bool selectionRunning = state->selection->Resume( 3 );
|
||||
state->ctx.Expect( !selectionRunning,
|
||||
"selection should finish after the explicit shutdown dispatch" );
|
||||
|
||||
const std::vector<std::string> expectedAfterFinalResume = {
|
||||
"selection:wait-enter",
|
||||
"control:start",
|
||||
"selection:wait-return",
|
||||
"selection:event:metricUnits",
|
||||
"selection:wait-enter",
|
||||
"control:after-selection",
|
||||
"selection:wait-return",
|
||||
"selection:tool-end"
|
||||
};
|
||||
state->ctx.Expect( state->sequence == expectedAfterFinalResume,
|
||||
"unexpected nested sequence after final selection shutdown: "
|
||||
+ JoinVector( state->sequence ) );
|
||||
|
||||
CompleteAsyncNestedResumeCase();
|
||||
}
|
||||
}
|
||||
|
||||
void RunSuite()
|
||||
{
|
||||
m_results.clear();
|
||||
|
||||
m_results.push_back( RunCase( "first_entry_runs_once", [this]( CaseContext& ctx ) {
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
ctx.Expect( self.EntryCount() == 1, "entry count should be 1 on first entry" );
|
||||
self.Yield( 11 );
|
||||
ctx.Expect( self.EntryCount() == 1, "entry count should still be 1 after resume" );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 1 );
|
||||
ctx.Expect( running, "coroutine should yield on first call" );
|
||||
ctx.Expect( coroutine.EntryCount() == 1, "entry count should be 1 after call" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 11, "yield value should be 11" );
|
||||
|
||||
running = coroutine.Resume( 2 );
|
||||
ctx.Expect( !running, "coroutine should finish after resume" );
|
||||
ctx.Expect( coroutine.EntryCount() == 1, "entry count should remain 1" );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "yield_resume_preserves_state", [this]( CaseContext& ctx ) {
|
||||
int entryRuns = 0;
|
||||
int afterResume = 0;
|
||||
intptr_t resumedValue = -1;
|
||||
bool localPreserved = false;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
++entryRuns;
|
||||
int localGuard = 41;
|
||||
self.Yield( 111 );
|
||||
++afterResume;
|
||||
resumedValue = self.CurrentValue();
|
||||
localPreserved = ( localGuard == 41 );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 7 );
|
||||
ctx.Expect( running, "coroutine should yield on initial call" );
|
||||
ctx.Expect( entryRuns == 1, "entry should run once" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 111, "yield should reach caller" );
|
||||
|
||||
running = coroutine.Resume( 222 );
|
||||
ctx.Expect( !running, "coroutine should finish after resume" );
|
||||
ctx.Expect( afterResume == 1, "post-resume code should run once" );
|
||||
ctx.Expect( resumedValue == 222, "resume value should reach coroutine" );
|
||||
ctx.Expect( localPreserved, "stack-local state should survive yield/resume" );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "deep_stack_preserved_across_yield", [this]( CaseContext& ctx ) {
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
std::function<void( int )> dive = [&]( int depth ) {
|
||||
std::array<int, 16> locals {};
|
||||
|
||||
for( std::size_t i = 0; i < locals.size(); ++i )
|
||||
locals[i] = depth * 100 + static_cast<int>( i );
|
||||
|
||||
int expected = std::accumulate( locals.begin(), locals.end(), 0 );
|
||||
|
||||
if( depth == 0 )
|
||||
{
|
||||
self.Yield( 500 );
|
||||
ctx.Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
|
||||
"deepest frame locals should survive resume" );
|
||||
return;
|
||||
}
|
||||
|
||||
dive( depth - 1 );
|
||||
ctx.Expect( std::accumulate( locals.begin(), locals.end(), 0 ) == expected,
|
||||
"frame locals should survive unwind/rewind at depth " + std::to_string( depth ) );
|
||||
};
|
||||
|
||||
dive( 6 );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 3 );
|
||||
ctx.Expect( running, "deep stack coroutine should yield" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 500, "deep stack yield value should propagate" );
|
||||
|
||||
running = coroutine.Resume( 4 );
|
||||
ctx.Expect( !running, "deep stack coroutine should finish after resume" );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "nested_coroutine_call_and_resume", [this]( CaseContext& ctx ) {
|
||||
std::vector<std::string> sequence;
|
||||
intptr_t childResumeValue = 0;
|
||||
|
||||
TestCoroutine child( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "child-start" );
|
||||
self.Yield( 33 );
|
||||
childResumeValue = self.CurrentValue();
|
||||
sequence.push_back( "child-end" );
|
||||
} );
|
||||
|
||||
TestCoroutine parent( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "parent-start" );
|
||||
|
||||
bool childRunning = child.Call( self, 10 );
|
||||
ctx.Expect( childRunning, "child should yield to parent" );
|
||||
ctx.Expect( child.LastReturnValue() == 33, "child yield value should reach parent" );
|
||||
sequence.push_back( "after-child-yield" );
|
||||
|
||||
childRunning = child.Resume( self, 44 );
|
||||
ctx.Expect( !childRunning, "child should finish after resume" );
|
||||
ctx.Expect( childResumeValue == 44, "resume value should reach child" );
|
||||
sequence.push_back( "after-child-finish" );
|
||||
|
||||
self.Yield( 55 );
|
||||
sequence.push_back( "parent-end" );
|
||||
} );
|
||||
|
||||
bool running = parent.Call( 1 );
|
||||
ctx.Expect( running, "parent should yield to root" );
|
||||
ctx.Expect( parent.LastReturnValue() == 55, "parent yield should reach root" );
|
||||
|
||||
const std::vector<std::string> expectedBeforeResume = {
|
||||
"parent-start",
|
||||
"child-start",
|
||||
"after-child-yield",
|
||||
"child-end",
|
||||
"after-child-finish"
|
||||
};
|
||||
ctx.Expect( sequence == expectedBeforeResume,
|
||||
"unexpected nested sequence before parent resume: " + JoinVector( sequence ) );
|
||||
|
||||
running = parent.Resume( 2 );
|
||||
ctx.Expect( !running, "parent should finish after resume" );
|
||||
|
||||
const std::vector<std::string> expectedAfterResume = {
|
||||
"parent-start",
|
||||
"child-start",
|
||||
"after-child-yield",
|
||||
"child-end",
|
||||
"after-child-finish",
|
||||
"parent-end"
|
||||
};
|
||||
ctx.Expect( sequence == expectedAfterResume,
|
||||
"unexpected nested sequence after parent resume: " + JoinVector( sequence ) );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "nested_parent_yield_preserves_suspend", [this]( CaseContext& ctx ) {
|
||||
std::vector<std::string> sequence;
|
||||
intptr_t childResumeValue = 0;
|
||||
intptr_t childFinalValue = 0;
|
||||
|
||||
TestCoroutine child( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "child-start" );
|
||||
self.Yield( 301 );
|
||||
childResumeValue = self.CurrentValue();
|
||||
sequence.push_back( "child-after-parent-resume" );
|
||||
self.Yield( 302 );
|
||||
childFinalValue = self.CurrentValue();
|
||||
sequence.push_back( "child-end" );
|
||||
} );
|
||||
|
||||
TestCoroutine parent( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "parent-start" );
|
||||
|
||||
bool childRunning = child.Call( self, 111 );
|
||||
ctx.Expect( childRunning, "child should yield to parent before parent yields to root" );
|
||||
ctx.Expect( child.LastReturnValue() == 301, "child first yield should reach parent" );
|
||||
sequence.push_back( "parent-after-child-yield" );
|
||||
|
||||
self.Yield( 401 );
|
||||
|
||||
sequence.push_back( "parent-after-root-resume" );
|
||||
ctx.Expect( child.Running(), "child should still be suspended when parent resumes from root" );
|
||||
|
||||
childRunning = child.Resume( self, 222 );
|
||||
ctx.Expect( childRunning, "child should yield a second time after parent resumes" );
|
||||
ctx.Expect( child.LastReturnValue() == 302, "child second yield should reach parent" );
|
||||
sequence.push_back( "parent-after-child-second-yield" );
|
||||
|
||||
self.Yield( 402 );
|
||||
|
||||
sequence.push_back( "parent-final-resume" );
|
||||
childRunning = child.Resume( self, 333 );
|
||||
ctx.Expect( !childRunning, "child should finish on final resume" );
|
||||
sequence.push_back( "parent-end" );
|
||||
} );
|
||||
|
||||
bool running = parent.Call( 1 );
|
||||
ctx.Expect( running, "parent should yield to root after child yields to parent" );
|
||||
ctx.Expect( parent.LastReturnValue() == 401, "parent first yield should reach root" );
|
||||
ctx.Expect( child.Running(), "child should remain suspended after parent yields to root" );
|
||||
|
||||
const std::vector<std::string> expectedBeforeResume = {
|
||||
"parent-start",
|
||||
"child-start",
|
||||
"parent-after-child-yield"
|
||||
};
|
||||
ctx.Expect( sequence == expectedBeforeResume,
|
||||
"parent should remain suspended after yielding to root: " + JoinVector( sequence ) );
|
||||
|
||||
running = parent.Resume( 2 );
|
||||
ctx.Expect( running, "parent should yield a second time after explicit root resume" );
|
||||
ctx.Expect( parent.LastReturnValue() == 402, "parent second yield should reach root" );
|
||||
ctx.Expect( childResumeValue == 222, "child should observe the value from the parent resume" );
|
||||
|
||||
const std::vector<std::string> expectedAfterFirstResume = {
|
||||
"parent-start",
|
||||
"child-start",
|
||||
"parent-after-child-yield",
|
||||
"parent-after-root-resume",
|
||||
"child-after-parent-resume",
|
||||
"parent-after-child-second-yield"
|
||||
};
|
||||
ctx.Expect( sequence == expectedAfterFirstResume,
|
||||
"unexpected sequence after first explicit parent resume: " + JoinVector( sequence ) );
|
||||
|
||||
running = parent.Resume( 3 );
|
||||
ctx.Expect( !running, "parent should finish after the final explicit root resume" );
|
||||
ctx.Expect( childFinalValue == 333, "child should observe the final resume value" );
|
||||
|
||||
const std::vector<std::string> expectedAfterFinalResume = {
|
||||
"parent-start",
|
||||
"child-start",
|
||||
"parent-after-child-yield",
|
||||
"parent-after-root-resume",
|
||||
"child-after-parent-resume",
|
||||
"parent-after-child-second-yield",
|
||||
"parent-final-resume",
|
||||
"child-end",
|
||||
"parent-end"
|
||||
};
|
||||
ctx.Expect( sequence == expectedAfterFinalResume,
|
||||
"unexpected sequence after final parent resume: " + JoinVector( sequence ) );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "root_bounce_continue_after_root", [this]( CaseContext& ctx ) {
|
||||
std::vector<std::string> events;
|
||||
int rootRuns = 0;
|
||||
intptr_t afterRootValue = 0;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
events.push_back( "before-root" );
|
||||
self.RunMainStack( [&]() {
|
||||
++rootRuns;
|
||||
events.push_back( "on-root" );
|
||||
}, 77 );
|
||||
afterRootValue = self.CurrentValue();
|
||||
events.push_back( "after-root" );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 5 );
|
||||
ctx.Expect( !running, "root bounce case should finish in one root call" );
|
||||
ctx.Expect( rootRuns == 1, "root callback should run exactly once" );
|
||||
ctx.Expect( afterRootValue == 77, "resume from root bounce should keep value" );
|
||||
|
||||
const std::vector<std::string> expected = { "before-root", "on-root", "after-root" };
|
||||
ctx.Expect( events == expected, "unexpected root bounce order: " + JoinVector( events ) );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "completion_returns_control_without_exit", [this]( CaseContext& ctx ) {
|
||||
int entryRuns = 0;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
++entryRuns;
|
||||
(void) self;
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 9 );
|
||||
ctx.Expect( !running, "completed coroutine should return false from Call" );
|
||||
ctx.Expect( entryRuns == 1, "completion case should run exactly once" );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "resume_after_finish_does_not_reenter", [this]( CaseContext& ctx ) {
|
||||
int entryRuns = 0;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
++entryRuns;
|
||||
(void) self;
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 0 );
|
||||
ctx.Expect( !running, "coroutine should finish immediately" );
|
||||
|
||||
running = coroutine.Resume( 123 );
|
||||
ctx.Expect( !running, "resume on finished coroutine should stay false" );
|
||||
ctx.Expect( entryRuns == 1, "finished coroutine must not re-enter" );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "interleaving_multiple_coroutines", [this]( CaseContext& ctx ) {
|
||||
std::vector<std::string> sequence;
|
||||
|
||||
TestCoroutine a( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "a1" );
|
||||
self.Yield( 1 );
|
||||
sequence.push_back( "a2" );
|
||||
self.Yield( 2 );
|
||||
sequence.push_back( "a3" );
|
||||
} );
|
||||
|
||||
TestCoroutine b( [&]( TestCoroutine& self ) {
|
||||
sequence.push_back( "b1" );
|
||||
self.Yield( 10 );
|
||||
sequence.push_back( "b2" );
|
||||
} );
|
||||
|
||||
bool runningA = a.Call( 1 );
|
||||
bool runningB = b.Call( 2 );
|
||||
ctx.Expect( runningA, "coroutine A should yield on first call" );
|
||||
ctx.Expect( runningB, "coroutine B should yield on first call" );
|
||||
ctx.Expect( a.LastReturnValue() == 1, "A first yield should be 1" );
|
||||
ctx.Expect( b.LastReturnValue() == 10, "B first yield should be 10" );
|
||||
|
||||
runningA = a.Resume( 3 );
|
||||
runningB = b.Resume( 4 );
|
||||
ctx.Expect( runningA, "coroutine A should yield on second resume" );
|
||||
ctx.Expect( !runningB, "coroutine B should finish on resume" );
|
||||
ctx.Expect( a.LastReturnValue() == 2, "A second yield should be 2" );
|
||||
|
||||
runningA = a.Resume( 5 );
|
||||
ctx.Expect( !runningA, "coroutine A should finish on final resume" );
|
||||
|
||||
const std::vector<std::string> expected = { "a1", "b1", "a2", "b2", "a3" };
|
||||
ctx.Expect( sequence == expected, "unexpected interleave order: " + JoinVector( sequence ) );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "stress_many_round_trips", [this]( CaseContext& ctx ) {
|
||||
constexpr int rounds = 96;
|
||||
int total = 0;
|
||||
int iterations = 0;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
for( int i = 0; i < rounds; ++i )
|
||||
{
|
||||
total += static_cast<int>( self.CurrentValue() );
|
||||
++iterations;
|
||||
|
||||
if( i + 1 < rounds )
|
||||
self.Yield( i + 1 );
|
||||
}
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 1 );
|
||||
ctx.Expect( running, "stress coroutine should yield on first iteration" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 1, "first stress yield should be 1" );
|
||||
|
||||
for( int value = 2; value <= rounds; ++value )
|
||||
{
|
||||
running = coroutine.Resume( value );
|
||||
|
||||
if( value < rounds )
|
||||
{
|
||||
ctx.Expect( running, "stress coroutine should still be running at value " + std::to_string( value ) );
|
||||
ctx.Expect( coroutine.LastReturnValue() == value,
|
||||
"stress yield mismatch at value " + std::to_string( value ) );
|
||||
}
|
||||
else
|
||||
{
|
||||
ctx.Expect( !running, "stress coroutine should finish on final resume" );
|
||||
}
|
||||
}
|
||||
|
||||
int expectedTotal = rounds * ( rounds + 1 ) / 2;
|
||||
ctx.Expect( iterations == rounds, "stress coroutine should run all iterations" );
|
||||
ctx.Expect( total == expectedTotal,
|
||||
"stress accumulated total mismatch, got " + std::to_string( total ) +
|
||||
" expected " + std::to_string( expectedTotal ) );
|
||||
} ) );
|
||||
|
||||
m_results.push_back( RunCase( "transfer_values_round_trip", [this]( CaseContext& ctx ) {
|
||||
std::vector<intptr_t> observed;
|
||||
|
||||
TestCoroutine coroutine( [&]( TestCoroutine& self ) {
|
||||
observed.push_back( self.CurrentValue() );
|
||||
self.Yield( 31 );
|
||||
observed.push_back( self.CurrentValue() );
|
||||
self.Yield( 63 );
|
||||
observed.push_back( self.CurrentValue() );
|
||||
} );
|
||||
|
||||
bool running = coroutine.Call( 17 );
|
||||
ctx.Expect( running, "transfer test should yield on first call" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 31, "first transfer yield should be 31" );
|
||||
|
||||
running = coroutine.Resume( 47 );
|
||||
ctx.Expect( running, "transfer test should yield on second step" );
|
||||
ctx.Expect( coroutine.LastReturnValue() == 63, "second transfer yield should be 63" );
|
||||
|
||||
running = coroutine.Resume( 79 );
|
||||
ctx.Expect( !running, "transfer test should finish on final resume" );
|
||||
|
||||
const std::vector<intptr_t> expected = { 17, 47, 79 };
|
||||
ctx.Expect( observed == expected, "unexpected transfer sequence: " + JoinVector( observed ) );
|
||||
} ) );
|
||||
|
||||
m_pendingAsyncCases = 1;
|
||||
StartAsyncWaitLoopCase();
|
||||
}
|
||||
|
||||
private:
|
||||
std::vector<CaseResult> m_results;
|
||||
wxTimer m_asyncCaseTimer;
|
||||
std::unique_ptr<AsyncWaitLoopCaseState> m_asyncState;
|
||||
std::unique_ptr<AsyncNestedResumeCaseState> m_nestedAsyncState;
|
||||
std::string m_asyncCaseName;
|
||||
int m_pendingAsyncCases = 0;
|
||||
wxStaticText* m_summary = nullptr;
|
||||
wxTextCtrl* m_log = nullptr;
|
||||
};
|
||||
|
||||
|
||||
class CoroutineTestApp : public wxApp
|
||||
{
|
||||
public:
|
||||
bool OnInit() override
|
||||
{
|
||||
CoroutineTestFrame* frame = new CoroutineTestFrame();
|
||||
frame->Show();
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
wxIMPLEMENT_APP( CoroutineTestApp );
|
||||
252
tests/apps/standalone/coroutine/kicad_coroutine_harness.h
Normal file
252
tests/apps/standalone/coroutine/kicad_coroutine_harness.h
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
#pragma once
|
||||
|
||||
#include <libcontext.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <functional>
|
||||
#include <memory>
|
||||
#include <utility>
|
||||
|
||||
namespace coroutine_test
|
||||
{
|
||||
|
||||
class TestCoroutine
|
||||
{
|
||||
public:
|
||||
enum class InvocationType
|
||||
{
|
||||
FromRoot,
|
||||
FromRoutine,
|
||||
ContinueAfterRoot
|
||||
};
|
||||
|
||||
struct Invocation;
|
||||
|
||||
private:
|
||||
struct Context
|
||||
{
|
||||
libcontext::fcontext_t ctx = nullptr;
|
||||
};
|
||||
|
||||
class CallContext
|
||||
{
|
||||
public:
|
||||
void SetMainStack( Context* aStack )
|
||||
{
|
||||
m_mainStackContext = aStack;
|
||||
}
|
||||
|
||||
Invocation* RunMainStack( TestCoroutine* aCoroutine, std::function<void()> aFunc,
|
||||
intptr_t aValue )
|
||||
{
|
||||
m_mainStackFunction = std::move( aFunc );
|
||||
Invocation args{ InvocationType::ContinueAfterRoot, aCoroutine, this, aValue };
|
||||
|
||||
return reinterpret_cast<Invocation*>(
|
||||
libcontext::jump_fcontext( &( aCoroutine->m_callee.ctx ), m_mainStackContext->ctx,
|
||||
reinterpret_cast<intptr_t>( &args ) ) );
|
||||
}
|
||||
|
||||
Invocation* Continue( Invocation* aArgs )
|
||||
{
|
||||
while( aArgs && aArgs->type == InvocationType::ContinueAfterRoot )
|
||||
{
|
||||
m_mainStackFunction();
|
||||
aArgs->type = InvocationType::FromRoot;
|
||||
aArgs = aArgs->destination->doResume( aArgs );
|
||||
}
|
||||
|
||||
return aArgs;
|
||||
}
|
||||
|
||||
private:
|
||||
Context* m_mainStackContext = nullptr;
|
||||
std::function<void()> m_mainStackFunction;
|
||||
};
|
||||
|
||||
public:
|
||||
struct Invocation
|
||||
{
|
||||
InvocationType type;
|
||||
TestCoroutine* destination;
|
||||
CallContext* context;
|
||||
intptr_t value;
|
||||
};
|
||||
|
||||
using EntryFn = std::function<void( TestCoroutine& )>;
|
||||
|
||||
explicit TestCoroutine( EntryFn aEntry, std::size_t aStackSize = 256 * 1024 ) :
|
||||
m_stackSize( aStackSize ),
|
||||
m_entry( std::move( aEntry ) )
|
||||
{
|
||||
}
|
||||
|
||||
~TestCoroutine()
|
||||
{
|
||||
if( m_caller.ctx )
|
||||
libcontext::release_fcontext( m_caller.ctx );
|
||||
|
||||
if( m_callee.ctx )
|
||||
libcontext::release_fcontext( m_callee.ctx );
|
||||
}
|
||||
|
||||
bool Call( intptr_t aValue = 0 )
|
||||
{
|
||||
if( m_callee.ctx || !m_entry )
|
||||
return false;
|
||||
|
||||
CallContext ctx;
|
||||
Invocation args{ InvocationType::FromRoot, this, &ctx, aValue };
|
||||
Invocation* ret = ctx.Continue( doCall( &args ) );
|
||||
m_lastReturnValue = ret ? ret->value : 0;
|
||||
return Running();
|
||||
}
|
||||
|
||||
bool Call( const TestCoroutine& aCoroutine, intptr_t aValue )
|
||||
{
|
||||
if( m_callee.ctx || !m_entry )
|
||||
return false;
|
||||
|
||||
Invocation args{ InvocationType::FromRoutine, this, aCoroutine.m_callContext, aValue };
|
||||
Invocation* ret = doCall( &args );
|
||||
m_lastReturnValue = ret ? ret->value : 0;
|
||||
return Running();
|
||||
}
|
||||
|
||||
bool Resume( intptr_t aValue = 0 )
|
||||
{
|
||||
if( !m_running )
|
||||
return false;
|
||||
|
||||
CallContext ctx;
|
||||
Invocation args{ InvocationType::FromRoot, this, &ctx, aValue };
|
||||
Invocation* ret = ctx.Continue( doResume( &args ) );
|
||||
m_lastReturnValue = ret ? ret->value : 0;
|
||||
return Running();
|
||||
}
|
||||
|
||||
bool Resume( const TestCoroutine& aCoroutine, intptr_t aValue )
|
||||
{
|
||||
if( !m_running )
|
||||
return false;
|
||||
|
||||
Invocation args{ InvocationType::FromRoutine, this, aCoroutine.m_callContext, aValue };
|
||||
Invocation* ret = doResume( &args );
|
||||
m_lastReturnValue = ret ? ret->value : 0;
|
||||
return Running();
|
||||
}
|
||||
|
||||
void Yield( intptr_t aValue = 0 )
|
||||
{
|
||||
jumpOut( InvocationType::FromRoutine, aValue );
|
||||
}
|
||||
|
||||
void RunMainStack( std::function<void()> aFunc, intptr_t aValue = 0 )
|
||||
{
|
||||
if( !m_callContext )
|
||||
return;
|
||||
|
||||
Invocation* ret = m_callContext->RunMainStack( this, std::move( aFunc ), aValue );
|
||||
updateIncomingInvocation( ret );
|
||||
}
|
||||
|
||||
bool Running() const
|
||||
{
|
||||
return m_running;
|
||||
}
|
||||
|
||||
intptr_t CurrentValue() const
|
||||
{
|
||||
return m_currentInvocation ? m_currentInvocation->value : 0;
|
||||
}
|
||||
|
||||
intptr_t LastReturnValue() const
|
||||
{
|
||||
return m_lastReturnValue;
|
||||
}
|
||||
|
||||
std::size_t EntryCount() const
|
||||
{
|
||||
return m_entryCount;
|
||||
}
|
||||
|
||||
private:
|
||||
static void callerStub( intptr_t aData )
|
||||
{
|
||||
Invocation& args = *reinterpret_cast<Invocation*>( aData );
|
||||
|
||||
TestCoroutine* coroutine = args.destination;
|
||||
coroutine->m_callContext = args.context;
|
||||
coroutine->m_currentInvocation = &args;
|
||||
coroutine->m_entryCount += 1;
|
||||
|
||||
if( args.type == InvocationType::FromRoot )
|
||||
coroutine->m_callContext->SetMainStack( &coroutine->m_caller );
|
||||
|
||||
coroutine->m_entry( *coroutine );
|
||||
coroutine->m_running = false;
|
||||
coroutine->jumpOut( InvocationType::FromRoutine, 0 );
|
||||
}
|
||||
|
||||
Invocation* doCall( Invocation* aInvocation )
|
||||
{
|
||||
m_stack = std::make_unique<char[]>( m_stackSize );
|
||||
void* stackTop = m_stack.get() + m_stackSize;
|
||||
|
||||
m_callee.ctx = libcontext::make_fcontext( stackTop, m_stackSize, callerStub );
|
||||
m_running = true;
|
||||
|
||||
return jumpIn( aInvocation );
|
||||
}
|
||||
|
||||
Invocation* doResume( Invocation* aInvocation )
|
||||
{
|
||||
return jumpIn( aInvocation );
|
||||
}
|
||||
|
||||
Invocation* jumpIn( Invocation* aInvocation )
|
||||
{
|
||||
m_currentInvocation = aInvocation;
|
||||
|
||||
return reinterpret_cast<Invocation*>(
|
||||
libcontext::jump_fcontext( &( m_caller.ctx ), m_callee.ctx,
|
||||
reinterpret_cast<intptr_t>( aInvocation ) ) );
|
||||
}
|
||||
|
||||
void jumpOut( InvocationType aType, intptr_t aValue )
|
||||
{
|
||||
Invocation args{ aType, nullptr, nullptr, aValue };
|
||||
Invocation* ret = reinterpret_cast<Invocation*>(
|
||||
libcontext::jump_fcontext( &( m_callee.ctx ), m_caller.ctx,
|
||||
reinterpret_cast<intptr_t>( &args ) ) );
|
||||
updateIncomingInvocation( ret );
|
||||
}
|
||||
|
||||
void updateIncomingInvocation( Invocation* aInvocation )
|
||||
{
|
||||
m_currentInvocation = aInvocation;
|
||||
|
||||
if( !aInvocation )
|
||||
return;
|
||||
|
||||
m_callContext = aInvocation->context;
|
||||
|
||||
if( aInvocation->type == InvocationType::FromRoot && m_callContext )
|
||||
m_callContext->SetMainStack( &m_caller );
|
||||
}
|
||||
|
||||
private:
|
||||
std::size_t m_stackSize;
|
||||
EntryFn m_entry;
|
||||
bool m_running = false;
|
||||
std::unique_ptr<char[]> m_stack;
|
||||
Context m_caller;
|
||||
Context m_callee;
|
||||
CallContext* m_callContext = nullptr;
|
||||
Invocation* m_currentInvocation = nullptr;
|
||||
intptr_t m_lastReturnValue = 0;
|
||||
std::size_t m_entryCount = 0;
|
||||
};
|
||||
|
||||
} // namespace coroutine_test
|
||||
105
tests/e2e/coroutine-nested.spec.ts
Normal file
105
tests/e2e/coroutine-nested.spec.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { test, expect, tryLoadApp } from './utils/fixtures';
|
||||
|
||||
const EXPECTED_CASES = [
|
||||
'baseline_modal_alone',
|
||||
'baseline_fiber_alone',
|
||||
'fiber_create_run_destroy_inside_modal',
|
||||
'fiber_multi_swap_inside_modal',
|
||||
'fiber_yield_across_modal_close',
|
||||
'fiber_deep_yield_loop_inside_modal',
|
||||
'modal_fiber_modal_sequence',
|
||||
'nested_fibers_inside_modal',
|
||||
];
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((log) => log.includes('[COROUTINE_TEST] SUMMARY'));
|
||||
}
|
||||
|
||||
test.describe('Nested Coroutine+Modal Tests', () => {
|
||||
test('nested harness loads and reports its case inventory', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() => testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE ')).length,
|
||||
{ timeout: 45000 }
|
||||
)
|
||||
.toBe(EXPECTED_CASES.length);
|
||||
|
||||
const caseLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE '));
|
||||
|
||||
for (const caseName of EXPECTED_CASES) {
|
||||
expect(
|
||||
caseLogs.some((log) => log.includes(`[COROUTINE_TEST] CASE ${caseName}`)),
|
||||
`case ${caseName} should appear in logs`
|
||||
).toBe(true);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-nested-01-loaded.png', fullPage: true });
|
||||
|
||||
expect(loaded, 'Nested harness should load').toBe(true);
|
||||
});
|
||||
|
||||
test('nested suite reports zero failures', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
expect(loaded, 'Nested harness should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 45000,
|
||||
message: 'Nested suite should emit a final summary line',
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const summary = findSummary(testLogger.consoleLogs)!;
|
||||
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(match, 'Nested summary should be parseable').not.toBeNull();
|
||||
|
||||
const total = Number(match![1]);
|
||||
const passed = Number(match![2]);
|
||||
const failed = Number(match![3]);
|
||||
|
||||
const failLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] FAIL '));
|
||||
const passLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] PASS '));
|
||||
|
||||
expect(total).toBe(EXPECTED_CASES.length);
|
||||
expect(passed).toBe(EXPECTED_CASES.length);
|
||||
expect(failed).toBe(0);
|
||||
expect(failLogs).toHaveLength(0);
|
||||
expect(passLogs).toHaveLength(EXPECTED_CASES.length);
|
||||
|
||||
// Critical: catch the nested-asyncify crash
|
||||
const indexOobErrors = testLogger.errors.filter((e) =>
|
||||
e.toLowerCase().includes('index out of bounds')
|
||||
);
|
||||
expect(indexOobErrors, 'no index out of bounds errors').toHaveLength(0);
|
||||
|
||||
expect(
|
||||
testLogger.errors.filter((error) => !error.includes('favicon')),
|
||||
'no unexpected page errors'
|
||||
).toHaveLength(0);
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-nested-02-summary.png', fullPage: true });
|
||||
});
|
||||
|
||||
test('per-scenario status (diagnostic)', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine-nested/nested_test.html');
|
||||
await tryLoadApp(page, 30000);
|
||||
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 45000,
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
// Use soft assertions so we see the full failure map instead of stopping at the first FAIL.
|
||||
for (const name of EXPECTED_CASES) {
|
||||
const passed = testLogger.consoleLogs.some((log) =>
|
||||
log.includes(`[COROUTINE_TEST] PASS ${name}`)
|
||||
);
|
||||
expect.soft(passed, `scenario ${name} should PASS`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
74
tests/e2e/coroutine.spec.ts
Normal file
74
tests/e2e/coroutine.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect, tryLoadApp } from './utils/fixtures';
|
||||
|
||||
const EXPECTED_CASES = [
|
||||
'first_entry_runs_once',
|
||||
'yield_resume_preserves_state',
|
||||
'deep_stack_preserved_across_yield',
|
||||
'nested_coroutine_call_and_resume',
|
||||
'nested_parent_yield_preserves_suspend',
|
||||
'async_wait_loop_stays_suspended',
|
||||
'async_nested_resume_from_child_tool',
|
||||
'root_bounce_continue_after_root',
|
||||
'completion_returns_control_without_exit',
|
||||
'resume_after_finish_does_not_reenter',
|
||||
'interleaving_multiple_coroutines',
|
||||
'stress_many_round_trips',
|
||||
'transfer_values_round_trip',
|
||||
];
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((log) => log.includes('[COROUTINE_TEST] SUMMARY'));
|
||||
}
|
||||
|
||||
test.describe('Coroutine Harness Tests', () => {
|
||||
test('coroutine harness loads and reports its case inventory', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine/coroutine_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
|
||||
await expect.poll(
|
||||
() => testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE ')).length,
|
||||
{ timeout: 15000 }
|
||||
).toBe(EXPECTED_CASES.length);
|
||||
|
||||
const caseLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] CASE '));
|
||||
|
||||
for (const caseName of EXPECTED_CASES) {
|
||||
expect(caseLogs.some((log) => log.includes(`[COROUTINE_TEST] CASE ${caseName}`))).toBe(true);
|
||||
}
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-01-loaded.png', fullPage: true });
|
||||
|
||||
expect(loaded, 'Coroutine harness should load').toBe(true);
|
||||
});
|
||||
|
||||
test('coroutine stress suite reports zero failures', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/coroutine/coroutine_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
expect(loaded, 'Coroutine harness should load').toBe(true);
|
||||
|
||||
await expect.poll(
|
||||
() => findSummary(testLogger.consoleLogs) ?? null,
|
||||
{ timeout: 20000, message: 'Coroutine suite should emit a final summary line' }
|
||||
).not.toBeNull();
|
||||
|
||||
const summary = findSummary(testLogger.consoleLogs)!;
|
||||
const match = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(match, 'Coroutine summary should be parseable').not.toBeNull();
|
||||
|
||||
const total = Number(match![1]);
|
||||
const passed = Number(match![2]);
|
||||
const failed = Number(match![3]);
|
||||
|
||||
const failLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] FAIL '));
|
||||
const passLogs = testLogger.consoleLogs.filter((log) => log.includes('[COROUTINE_TEST] PASS '));
|
||||
|
||||
expect(total).toBe(EXPECTED_CASES.length);
|
||||
expect(passed).toBe(EXPECTED_CASES.length);
|
||||
expect(failed).toBe(0);
|
||||
expect(failLogs).toHaveLength(0);
|
||||
expect(passLogs).toHaveLength(EXPECTED_CASES.length);
|
||||
expect(testLogger.errors.filter((error) => !error.includes('favicon'))).toHaveLength(0);
|
||||
|
||||
await page.screenshot({ path: 'test-results/coroutine-02-summary.png', fullPage: true });
|
||||
});
|
||||
});
|
||||
|
|
@ -2,7 +2,7 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { test, expect } from './fixtures';
|
||||
import { clickByLabel } from '../e2e/utils/element-tracker';
|
||||
import { clickByLabel, clickByTooltip, findByTooltip } from '../e2e/utils/element-tracker';
|
||||
|
||||
/**
|
||||
* PCBnew WASM E2E Tests
|
||||
|
|
@ -74,6 +74,21 @@ type ReferenceComparison = {
|
|||
|
||||
type ReferenceRegion = typeof REFERENCE_REGIONS[number];
|
||||
|
||||
type DiffRegion = {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ScreenshotDifference = {
|
||||
actualWidth: number;
|
||||
actualHeight: number;
|
||||
diffPixels: number;
|
||||
diffRatio: number;
|
||||
meanChannelDiff: number;
|
||||
};
|
||||
|
||||
async function compareToReference(
|
||||
page: Page,
|
||||
actualPng: Buffer,
|
||||
|
|
@ -159,6 +174,83 @@ async function compareToReference(
|
|||
});
|
||||
}
|
||||
|
||||
async function compareScreenshots(
|
||||
page: Page,
|
||||
beforePng: Buffer,
|
||||
afterPng: Buffer,
|
||||
region: DiffRegion
|
||||
): Promise<ScreenshotDifference> {
|
||||
return page.evaluate(async ({ beforeBase64, afterBase64, crop }) => {
|
||||
const loadImage = async (base64: string): Promise<HTMLImageElement> => {
|
||||
const image = new Image();
|
||||
image.src = `data:image/png;base64,${base64}`;
|
||||
await image.decode();
|
||||
return image;
|
||||
};
|
||||
|
||||
const [before, after] = await Promise.all([
|
||||
loadImage(beforeBase64),
|
||||
loadImage(afterBase64),
|
||||
]);
|
||||
|
||||
if (before.width !== after.width || before.height !== after.height) {
|
||||
return {
|
||||
actualWidth: after.width,
|
||||
actualHeight: after.height,
|
||||
diffPixels: Number.POSITIVE_INFINITY,
|
||||
diffRatio: Number.POSITIVE_INFINITY,
|
||||
meanChannelDiff: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = crop.width;
|
||||
canvas.height = crop.height;
|
||||
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
|
||||
if (!context) {
|
||||
throw new Error('2D canvas context unavailable for screenshot comparison');
|
||||
}
|
||||
|
||||
context.drawImage(before, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
|
||||
const beforeData = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(after, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height);
|
||||
const afterData = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
|
||||
let diffPixels = 0;
|
||||
let totalChannelDiff = 0;
|
||||
|
||||
for (let i = 0; i < beforeData.length; i += 4) {
|
||||
const dr = Math.abs(beforeData[i] - afterData[i]);
|
||||
const dg = Math.abs(beforeData[i + 1] - afterData[i + 1]);
|
||||
const db = Math.abs(beforeData[i + 2] - afterData[i + 2]);
|
||||
const da = Math.abs(beforeData[i + 3] - afterData[i + 3]);
|
||||
const maxDiff = Math.max(dr, dg, db, da);
|
||||
|
||||
totalChannelDiff += dr + dg + db + da;
|
||||
|
||||
if (maxDiff > 16) {
|
||||
diffPixels += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
actualWidth: after.width,
|
||||
actualHeight: after.height,
|
||||
diffPixels,
|
||||
diffRatio: diffPixels / (canvas.width * canvas.height),
|
||||
meanChannelDiff: totalChannelDiff / beforeData.length,
|
||||
};
|
||||
}, {
|
||||
beforeBase64: beforePng.toString('base64'),
|
||||
afterBase64: afterPng.toString('base64'),
|
||||
crop: region,
|
||||
});
|
||||
}
|
||||
|
||||
async function getCanvasMetrics(page: Page): Promise<CanvasMetrics> {
|
||||
return page.evaluate(() => {
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
|
@ -249,43 +341,54 @@ async function getRegistryMetrics(page: Page): Promise<RegistryMetrics> {
|
|||
});
|
||||
}
|
||||
|
||||
async function completeWizard(page: Page): Promise<void> {
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.screenshot({ path: 'test-results/wizard-00-initial.png', scale: 'device' });
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
let clicked = await clickByLabel(page, 'Next >');
|
||||
|
||||
if (!clicked) {
|
||||
clicked = await clickByLabel(page, 'Finish');
|
||||
|
||||
if (clicked) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({
|
||||
path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||
scale: 'device'
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({
|
||||
path: `test-results/wizard-${String(i).padStart(2, '0')}.png`,
|
||||
scale: 'device'
|
||||
});
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
}
|
||||
|
||||
async function hideCursor(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.cursor = 'none';
|
||||
document.body.style.cursor = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
test.describe('PCBnew WASM', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/kicad/pcbnew.html');
|
||||
});
|
||||
|
||||
test('click through setup wizard to load PCBnew', async ({ page }) => {
|
||||
await expect(page.locator('#canvas')).toBeVisible({ timeout: 90000 });
|
||||
await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.screenshot({ path: 'test-results/wizard-00-initial.png', scale: 'device' });
|
||||
|
||||
for (let i = 1; i <= 10; i++) {
|
||||
let clicked = await clickByLabel(page, 'Next >');
|
||||
|
||||
if (!clicked) {
|
||||
clicked = await clickByLabel(page, 'Finish');
|
||||
|
||||
if (clicked) {
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({
|
||||
path: `test-results/wizard-${String(i).padStart(2, '0')}-finish.png`,
|
||||
scale: 'device'
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
await page.waitForTimeout(500);
|
||||
await page.screenshot({
|
||||
path: `test-results/wizard-${String(i).padStart(2, '0')}.png`,
|
||||
scale: 'device'
|
||||
});
|
||||
}
|
||||
|
||||
await page.waitForTimeout(2000);
|
||||
await completeWizard(page);
|
||||
const metrics = await getCanvasMetrics(page);
|
||||
const registryMetrics = await getRegistryMetrics(page);
|
||||
|
||||
|
|
@ -334,10 +437,7 @@ test.describe('PCBnew WASM', () => {
|
|||
expect(appearancePane.width).toBeGreaterThanOrEqual(200);
|
||||
expect(appearancePane.width).toBeLessThanOrEqual(240);
|
||||
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.style.cursor = 'none';
|
||||
document.body.style.cursor = 'none';
|
||||
});
|
||||
await hideCursor(page);
|
||||
|
||||
const cssScreenshot = await page.screenshot({
|
||||
path: 'test-results/pcbnew-loaded-css.png',
|
||||
|
|
@ -357,4 +457,182 @@ test.describe('PCBnew WASM', () => {
|
|||
const canvasCount = await page.locator('canvas').count();
|
||||
expect(canvasCount).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('select draw lines and draw on the board', async ({ page, testLogger }) => {
|
||||
await completeWizard(page);
|
||||
await hideCursor(page);
|
||||
|
||||
await page.evaluate(() => {
|
||||
const canvases = Array.from(document.querySelectorAll('canvas')).map((canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(canvas);
|
||||
return {
|
||||
id: canvas.id,
|
||||
className: canvas.className,
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
width: canvas.width,
|
||||
height: canvas.height,
|
||||
rectX: rect.x,
|
||||
rectY: rect.y,
|
||||
rectWidth: rect.width,
|
||||
rectHeight: rect.height,
|
||||
shouldBeVisible: (canvas as HTMLCanvasElement).dataset?.shouldBeVisible ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`[TEST] canvas summary ${JSON.stringify(canvases)}`);
|
||||
|
||||
const registry = window.wxElementRegistry;
|
||||
const topLevels = (registry?.findAll?.({}) ?? [])
|
||||
.filter((item) => /Frame|Dialog|Wizard/.test(item.typeName))
|
||||
.slice(0, 20)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
typeName: item.typeName,
|
||||
label: item.label,
|
||||
name: item.name,
|
||||
visible: item.visible,
|
||||
enabled: item.enabled,
|
||||
screenX: item.screenX,
|
||||
screenY: item.screenY,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
}));
|
||||
const rendered = registry?.findAllRendered?.({}) ?? [];
|
||||
const byType = rendered.reduce<Record<string, number>>((acc, item) => {
|
||||
acc[item.elementType] = (acc[item.elementType] ?? 0) + 1;
|
||||
return acc;
|
||||
}, {});
|
||||
const tools = rendered
|
||||
.filter((item) => item.elementType === 'tool')
|
||||
.slice(0, 20)
|
||||
.map((item) => ({
|
||||
id: item.id,
|
||||
label: item.label,
|
||||
tooltip: item.tooltip,
|
||||
checked: item.checked,
|
||||
enabled: item.enabled,
|
||||
}));
|
||||
|
||||
console.log(`[TEST] top-level summary ${JSON.stringify(topLevels)}`);
|
||||
console.log(`[TEST] rendered summary ${JSON.stringify({ count: rendered.length, byType, tools })}`);
|
||||
});
|
||||
|
||||
await page.waitForFunction(() => {
|
||||
const registry = window.wxElementRegistry;
|
||||
if (!registry?.findAllRendered) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return registry.findAllRendered({ elementType: 'tool' })
|
||||
.some((tool) => tool.tooltip?.includes('Draw Lines'));
|
||||
}, null, { timeout: 15000 });
|
||||
|
||||
const drawLinesTool = await findByTooltip(page, 'Draw Lines', { elementType: 'tool' });
|
||||
expect(drawLinesTool).not.toBeNull();
|
||||
|
||||
if (!drawLinesTool) {
|
||||
throw new Error('Draw Lines tool not found in rendered element registry');
|
||||
}
|
||||
|
||||
// The registry carries checked state via a " [checked]" label suffix
|
||||
// appended by wxAuiToolBar::OnPaint on Emscripten — no schema change.
|
||||
const isToolChecked = (t: { label?: string } | null | undefined) =>
|
||||
(t?.label ?? '').includes('[checked]');
|
||||
|
||||
expect(drawLinesTool.enabled).toBe(true);
|
||||
expect(isToolChecked(drawLinesTool)).toBe(false);
|
||||
const baselineErrorCount = testLogger.errors.length;
|
||||
|
||||
const beforeToolClick = await page.screenshot({
|
||||
path: 'test-results/pcbnew-draw-lines-00-before-tool-click.png',
|
||||
scale: 'device'
|
||||
});
|
||||
|
||||
expect(await clickByTooltip(page, 'Draw Lines', { elementType: 'tool' })).toBe(true);
|
||||
|
||||
await expect.poll(async () => {
|
||||
const tool = await findByTooltip(page, 'Draw Lines', { elementType: 'tool' });
|
||||
return isToolChecked(tool);
|
||||
}, {
|
||||
message: 'Draw Lines tool should stay selected after the click',
|
||||
timeout: 5000,
|
||||
}).toBe(true);
|
||||
|
||||
await page.mouse.move(640, 360);
|
||||
await page.waitForTimeout(600);
|
||||
|
||||
const selectedDrawLinesTool = await findByTooltip(page, 'Draw Lines', { elementType: 'tool' });
|
||||
expect(isToolChecked(selectedDrawLinesTool)).toBe(true);
|
||||
|
||||
const afterToolClick = await page.screenshot({
|
||||
path: 'test-results/pcbnew-draw-lines-01-after-click.png',
|
||||
scale: 'device'
|
||||
});
|
||||
|
||||
const glCanvasId = await page.evaluate(() => {
|
||||
const glCanvas =
|
||||
Array.from(document.querySelectorAll('[id^="glcanvas-"]'))
|
||||
.map((canvas) => canvas as HTMLCanvasElement)
|
||||
.find((canvas) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(canvas);
|
||||
return style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
}) ??
|
||||
document.querySelector('[id^="glcanvas-"]') as HTMLCanvasElement | null;
|
||||
|
||||
return glCanvas?.id ?? null;
|
||||
});
|
||||
|
||||
expect(glCanvasId).not.toBeNull();
|
||||
|
||||
if (!glCanvasId) {
|
||||
throw new Error('Visible GL canvas not found');
|
||||
}
|
||||
|
||||
const glCanvasBox = await page.locator(`#${glCanvasId}`).boundingBox();
|
||||
expect(glCanvasBox).not.toBeNull();
|
||||
|
||||
if (!glCanvasBox) {
|
||||
throw new Error('GL canvas bounding box unavailable');
|
||||
}
|
||||
|
||||
const startPoint = {
|
||||
x: Math.round(glCanvasBox.x + glCanvasBox.width * 0.28),
|
||||
y: Math.round(glCanvasBox.y + glCanvasBox.height * 0.36),
|
||||
};
|
||||
const endPoint = {
|
||||
x: Math.round(glCanvasBox.x + glCanvasBox.width * 0.48),
|
||||
y: Math.round(glCanvasBox.y + glCanvasBox.height * 0.47),
|
||||
};
|
||||
|
||||
await page.mouse.click(startPoint.x, startPoint.y);
|
||||
await page.waitForTimeout(250);
|
||||
await page.mouse.click(endPoint.x, endPoint.y);
|
||||
await page.waitForTimeout(750);
|
||||
|
||||
const afterDrawing = await page.screenshot({
|
||||
path: 'test-results/pcbnew-draw-lines-02-after-drawing.png',
|
||||
scale: 'device'
|
||||
});
|
||||
|
||||
const diffRegion: DiffRegion = {
|
||||
x: Math.max(0, Math.min(startPoint.x, endPoint.x) - 24),
|
||||
y: Math.max(0, Math.min(startPoint.y, endPoint.y) - 24),
|
||||
width: Math.abs(endPoint.x - startPoint.x) + 48,
|
||||
height: Math.abs(endPoint.y - startPoint.y) + 48,
|
||||
};
|
||||
|
||||
const drawingDiff = await compareScreenshots(page, afterToolClick, afterDrawing, diffRegion);
|
||||
|
||||
expect(drawingDiff.diffPixels).toBeGreaterThan(120);
|
||||
expect(drawingDiff.diffRatio).toBeGreaterThan(0.01);
|
||||
expect(drawingDiff.meanChannelDiff).toBeGreaterThan(1);
|
||||
|
||||
const realErrors = testLogger.errors
|
||||
.slice(baselineErrorCount)
|
||||
.filter((error) => !error.includes('favicon') && !error.includes('uncaught exception: unwind'));
|
||||
expect(realErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -55,7 +55,8 @@ export default defineConfig({
|
|||
|
||||
use: {
|
||||
baseURL: `http://localhost:${port}`,
|
||||
trace: 'on-first-retry',
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
|
|
@ -68,11 +69,14 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
{
|
||||
// Chrome for headed debugging only (headless crashes on ARM Mac)
|
||||
// Use --headed flag when running: npm run test:kicad:headed
|
||||
// Uses the SYSTEM-installed Google Chrome (not the Playwright-bundled
|
||||
// Chromium) so WebGL runs on the real GPU instead of SwiftShader.
|
||||
// The bundled Chromium fails with canvas hidden on ARM Mac because of
|
||||
// Chromium issues #1416283, #338414704 (SwiftShader WebGL bug).
|
||||
// Run via: npm run test:kicad:headed
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
channel: 'chrome',
|
||||
viewport: { width: 1280, height: 720 },
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit e01f29abc429fc899c8bb09953b527dcb0652a87
|
||||
Subproject commit bb80f91e8b1f4db8af24f215fc6f77e0d9590794
|
||||
Loading…
Reference in a new issue