docs: asyncify currData + wasm-exceptions research dossiers

Two research dossiers under docs/features:
- async/: Asyncify currData contention — the clipboard crash and post-idle
  tool hang as one disease (single global suspension slot, three producers),
  asyncify internals walkthroughs, prior art, arbiter designs A/B.
- wasm-exceptions/: -fexceptions vs -fwasm-exceptions — measured invoke-driven
  share of the asyncify tax on pcbnew (59% raw / 64% gzip; download 64.5->36 MB
  if migrated), KiCad dialog-in-catch audit (85 direct / 93 review of 636,
  catch_audit.py included), binaryen toolchain status (partial EH support since
  v125; TryTable unsupported), and a catch-arm-hoisting pre-pass design that
  would obsolete the KiCad-side refactor. Cross-linked with the parked
  KICAD_WASM_EH end-to-end experiment (docs/wasm-exceptions-experiment.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-12 13:53:43 +02:00
commit 66ce367703
16 changed files with 3252 additions and 0 deletions

View file

@ -0,0 +1,107 @@
# 01 — Background: the session, the test, and the three bugs
## How we got here
The work began with: *"load a real PCB design in the PCB editor … use the open menu inside
KiCad and open it … create a test, make screenshots."* That investigation lives in Claude
session **`3301ab3a-c457-4359-be9a-dcf4dcfc9fbc.jsonl`** (started 2026-05-28). It produced:
- **The e2e test:** `tests/kicad/load-pcb.spec.ts` — drives File→Open, injects demo boards into
MEMFS, waits for `LoadBoard`, screenshots. Parametrized over two demos: **`microwave`**
(RF-polygon heavy) and **`pic_programmer`** (~698 KB real layout). Probe variant:
`tests/kicad/load-pcb-probe.spec.ts`.
- **Logs:** `tests/logs/kicad/load-pcb/…microwave….log`.
- **Findings docs:** `docs/docs/features/fix-asyncify-O2-and-modal-promise-rejection/rtree-debug-findings.md`
and `…/clipboard-asyncify-findings.md`.
Three distinct errors appeared along the way. They are easy to conflate but have different
root causes.
---
## Bug 1 — rtree `Classify` assert during `LoadBoard()` — **FIXED**
A WASM-only 32-bit integer overflow in KiCad geometry:
`kicad/libs/kimath/src/geometry/shape_poly_set.cpp:1927` instantiated
`RTree<intptr_t, intptr_t, 2, intptr_t>`. The 4th template arg (`ELEMTYPEREAL`) is the wide
float type for `RectSphericalVolume`'s `sumOfSquares`. Every other RTree in KiCad uses `double`;
this one used `intptr_t`. On native (`intptr_t==int64_t`) the bug is latent; on **wasm32
(`intptr_t==int32_t`)** a microwave polygon's area (~1.79e14) wraps int32 to garbage
(`coverSplitArea = -2099823776`), breaking `PickSeeds``seed0==seed1==0`
`Classify(0,1)` trips `ASSERT(!m_taken[0])` at `rtree.h:1771` → WASM `Aborted()` during load.
**Fix (committed):** `intptr_t``double`.
- kicad `07d8130d` — *"shape_poly_set: use double instead of intptr_t for RTree ELEMTYPEREAL"*
- root `31ff88e`*"tests: load-pcb e2e for microwave + pic_programmer demos"* (bumps submodule)
This bug is **not** part of the async story. Listed only so it isn't re-confused with the
others.
---
## Bug 2 — clipboard `index out of bounds` crash, *after* the board renders
Symptoms (Firefox): `RuntimeError: index out of bounds` (microwave) / `indirect call to null`
(pic_programmer), preceded by `[wxClipboard] Cannot check clipboard content: Clipboard
operation timed out`. The board is already fully painted; this is downstream noise that the
load-pcb test deliberately filtered out.
**Mechanism (verified against current code):** `wxClipboard::IsSupported()`
(`wxwidgets/src/wasm/clipbrd.cpp:288-311`) answers "is text available?" by calling
`js_clipboardHasText()` (`clipbrd.cpp:118-142`), an `EM_ASYNC_JS` that does
`navigator.clipboard.readText()` raced against a **2-second timeout**. The just-loaded headless
page has no clipboard permission, so each call **suspends via Asyncify for the full 2 s**.
`IsSupported` is a synchronous-by-contract predicate that the UI-update / paste-enable path
calls repeatedly, so these suspends stack (`pendingSleeps`→3 in the diagnostics) and, crossed by
a modal teardown's fiber swap, the single `Asyncify.currData` slot is clobbered →
`doRewind(null/garbage)` → crash.
> The `clipboard-asyncify-findings.md` doc labels its fix "Applied," but the **code is still
> buggy** — `wxwidgets` HEAD `6fb2eac257` still calls the async `IsSupported`. The fix was
> designed but never committed.
Full control flow in [`02-asyncify-internals.md`](02-asyncify-internals.md) §"The crash".
---
## Bug 3 — the tool-open **hang**, *after* idle (the real blocker for rendering)
From the live trace (libcontext `KICAD_DIAG_COROUTINE`): the programmatic File→Open hangs
inside the **first tool fiber swap** after startup. Trace ends at a `jump-swap` with no
following `entry-call`/`jump-resume` — the swap unwinds but the target fiber never runs. Key
observations from that session:
1. The **same** coroutine completes many swap cycles during the startup burst, then the **first
swap after startup hangs.**
2. It is **not specific to the open** — on the fileless `/p/mytest/eeschema` route, a `w`
keystroke after the startup burst triggers a fiber resume that hangs identically. So **all
post-idle tool interactivity is affected.**
3. At idle, measured **`Asyncify.state == Normal` but `Asyncify.currData` set** — i.e. a swap
unwound and its rewind was never issued.
The session attributed this to the main loop "parking" `main()` in the single `currData` slot
via `wxwidgets/src/wasm/evtloop.cpp:107`
`emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1)`.
> **Important correction (see §"De-parking" in 02):** `simulate_infinite_loop=1` is **not** an
> Asyncify park — it is a plain `throw "unwind"` (`pcbnew.js:11392`) that *discards* the native
> `main()` stack. The dangling-`currData`-at-idle it produces is the leading suspect for the
> hang, but the precise mechanism (orphaned buffer vs. stuck trampoline guard) is an open
> empirical question answered by a one-line diagnostic.
There is also a related **null-`IsModal` / `windowClosing` close crash**: when `beforeunload`
fires during the suspended-open window, the close path crashes on a null vtable slot. That is a
*lifecycle* bug (teardown firing under the live loop), adjacent to the hang.
---
## The connective insight
Bug 2 (crash) and Bug 3 (hang) are **the same disease**: one global `currData`/`state` register
shared by three producers. Crash vs. hang is just which producer loses the race for the slot:
- **Crash:** a long-parked **sleep** (the 2 s clipboard read) is clobbered by a **fiber swap**.
- **Hang:** a **fiber swap** is stranded because the slot is dirtied/occupied (by the parked
main loop's abnormal teardown), so its rewind is never issued.
Everything else in this dossier follows from that one observation.

View file

@ -0,0 +1,320 @@
# 02 — The machine: Asyncify internals and control flows
This is the legible model: what suspends, who owns the single slot, and exact line-by-line
control flow for the park, the hang, the crash, and **de-parking**.
---
## §0. One-paragraph model
Emscripten Asyncify has **two global registers**: `Asyncify.state`
(`Normal=0 / Unwinding=1 / Rewinding=2`) and `Asyncify.currData` (pointer to *the* save buffer).
They describe **"the single suspension currently in flight."** The runtime assumes **at most one**
suspension is live and that it **fully rewinds before the next begins.** KiCad-WASM breaks that
because **three subsystems drive those same two registers**: tool coroutines (libcontext →
`emscripten_fiber_swap`), modal dialogs + clipboard (`EM_ASYNC_JS``handleSleep`), and the
parked main loop. Overlap on the single slot → one context reads a buffer that is no longer its
own → crash or hang.
---
## §1. What "sleeps" and what does not
"Sleep" = an **Asyncify suspension**: the wasm stack is *unwound* into a buffer, control returns
to JS, JS runs, then the stack is *rewound*. The only wasm exports involved are
`_asyncify_start_unwind / stop_unwind / start_rewind / stop_rewind` (`pcbnew.js:15532-15538`);
**all scheduling is JS glue.** C++ never writes `currData`/`state` — it only spills/restores
locals when the JS-driven state says to.
| Call | Sleeps? | Mechanism | Where |
|---|---|---|---|
| `emscripten_fiber_swap` (tool coroutine swap) | **YES** | unwind source fiber + rewind target fiber | `pcbnew.js:11557` |
| `startModal` (`wxDialog::ShowModal`) | **YES** | `EM_ASYNC_JS``handleSleep`, awaits a Promise | `dialog.cpp:201` |
| `js_clipboardHasText` (old `IsSupported`) | **YES** | `EM_ASYNC_JS`, `readText()` raced vs 2 s timeout | `clipbrd.cpp:118` |
| `js_readTextFromClipboard` (paste) | **YES** | `EM_ASYNC_JS`, only on user gesture | `clipbrd.cpp:76` |
| `js_writeTextToClipboard` / `js_clearClipboard` | **YES** | `EM_ASYNC_JS` | `clipbrd.cpp:38`, `:145` |
| `js_enumerateFonts` | **YES** | `EM_ASYNC_JS` | `fontenum.cpp:33` |
| `js_isClipboardAPIAvailable` / `js_isFontAccessAPIAvailable` | **NO** | synchronous `EM_JS` capability probe | `clipbrd.cpp:29`, `fontenum.cpp:25` |
| `emscripten_async_call`, `emscripten_async_run_in_main_runtime_thread` | **NO** | timer/main-thread dispatch, not a suspend | `timer.cpp:80`, `utils.cpp:162/183` |
| `ProcessEvents` (one main-loop tick) | **NO** by itself | plain C call on a fresh stack; sleeps only if something *inside* does | `evtloop.cpp:19` |
| the rAF main-loop tick | **NO** | `requestAnimationFrame`/`setTimeout` re-enters wasm fresh each frame | `pcbnew.js:11342` |
| `emscripten_set_main_loop(...,1)` "infinite loop" | **NO** (not a sleep!) | `throw "unwind"` — a *plain JS exception*, not Asyncify | `pcbnew.js:11392` |
**The single most important correction:** the main loop's "park" is **not** an Asyncify
suspension. It is a thrown JS string. This matters enormously (§7).
The complete inventory of EM_ASYNC_JS "sleep" sites is **6**, all in `wxwidgets/src/wasm/`
(the KiCad tree has **zero** EM_ASYNC_JS / `emscripten_sleep`): `dialog.cpp:201`,
`clipbrd.cpp:38/76/118/145`, `fontenum.cpp:33`. This is why a fix can stay in the wasm layer.
---
## §2. The three suspension producers
### 2a. Tool coroutines = libcontext = emscripten fibers
KiCad runs interactive tools as coroutines (`COROUTINE` in `kicad/include/tool/coroutine.h`).
`Call`/`Resume`/`KiYield` switch stacks via `libcontext::jump_fcontext` (`coroutine.h:530`,
`:548`). On wasm, libcontext is **not** native assembly — it is a shim over emscripten fibers
(`libcontext.cpp`): `make_fcontext``emscripten_fiber_init` (`:287`); `jump_fcontext`
`emscripten_fiber_swap` (`:320`). So **"a tool fiber swap" is literally `emscripten_fiber_swap`,
which drives `Asyncify.currData`/`state`.** Each `wasm_fcontext` embeds its **own** asyncify
buffer (`libcontext.cpp:98` `char asyncify_stack[64*1024]`, `ASYNCIFY_STACK_SIZE` at `:34`),
bound at `emscripten_fiber_init` (`:287`); the main stack is fiberized via
`emscripten_fiber_init_from_current_context` (`:210-212`). libcontext keeps its own
`g_current_context`, a per-context `resume_epoch` to detect "ghost resumes" (`:323`), and a
`[[noreturn]]` trampoline `wasm_fcontext_entry` (`:228`) that loops forever so a finished
coroutine swaps back instead of returning. `KICAD_DIAG_COROUTINE`
(`kicad/include/kicad_wasm_diag.h`) logs every `jump-enter / save-slot / jump-swap /
jump-resume / jump-ghost / entry-call / trampoline-swap`.
### 2b. Modal dialogs (and the old clipboard) = EM_ASYNC_JS = handleSleep
`wxDialog::ShowModal` (`dialog.cpp:245`) must *block and return an `int`* (native semantics —
hundreds of KiCad sites do `if( dlg.ShowModal()==wxID_OK )`). A browser main thread cannot
block, so `startModal` (`dialog.cpp:201`) is an `EM_ASYNC_JS` that: suspends the C++ stack via
Asyncify, runs a `setTimeout(17ms)` loop calling `ProcessEvents` so the UI stays live, and
resolves when `EndModal` (`dialog.cpp:286`) fires `Module._endModal(code)`. **The modal is,
structurally, a coroutine on `handleSleep`.** The old clipboard `IsSupported` (`clipbrd.cpp:288`)
used the same road.
### 2c. The main loop "park"
`wxGUIEventLoop::DoRun` (`evtloop.cpp:85`) ends with
`emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1)` (`:107`). Dissected
in §4 and §7.
---
## §3. The Asyncify engine (the JS glue)
### handleSleep — the EM_ASYNC_JS / sleep road (`pcbnew.js:10160`)
- First entry, `state==Normal`: call `startAsync(wakeUp)`. If `wakeUp` is not called
synchronously, a real suspend begins (`:10219`):
`state=Unwinding; currData = allocateData(); MainLoop.pause(); start_unwind()`.
- Promise resolves → `wakeUp(result)` (`:10169`):
`state=Rewinding; start_rewind(currData); MainLoop.resume(); doRewind(currData)`.
`doRewind` reads **field #8 of the buffer** to learn *which exported function to re-enter*
(`getDataRewindFuncName`, `:10143`). **If `currData` is wrong or null here → garbage →
`RuntimeError: index out of bounds`.**
- Re-entry at `state==Rewinding` (`:10229`): `state=Normal; stop_rewind(); free(currData);
currData=null`.
> Asymmetry: the sleep road pauses/resumes `MainLoop` (`:10225`, `:10180`). The fiber road does
> **not** touch `MainLoop`.
### fiber swap — the coroutine road (`pcbnew.js:11557`)
```js
function _emscripten_fiber_swap(oldFiber, newFiber) {
if (Asyncify.state === Asyncify.State.Normal) { // leaving a fiber
Asyncify.state = Asyncify.State.Unwinding;
var asyncifyData = oldFiber + 20; // OLD fiber's embedded buffer
Asyncify.setDataRewindFunc(asyncifyData);
Asyncify.currData = asyncifyData; // <-- writes the single slot
_asyncify_start_unwind(asyncifyData);
Fibers.nextFiber = newFiber; // schedule the rewind target
} else { // landing back via rewind
Asyncify.state = Asyncify.State.Normal;
_asyncify_stop_rewind();
Asyncify.currData = null;
}
}
```
The actual rewind of the *target* is deferred to **`Fibers.trampoline`** (`pcbnew.js:11522`),
invoked from **`maybeStopUnwind`** (`:10097`) once the unwind reaches bottom
(`exportCallStack.length===0`) — `maybeStopUnwind` also `runtimeKeepalivePush()`es (`:10105`):
```js
trampoline() {
if (!Fibers.trampolineRunning && Fibers.nextFiber) { // GUARD
Fibers.trampolineRunning = true;
do { var f = Fibers.nextFiber; Fibers.nextFiber = 0;
Fibers.finishContextSwitch(f); } while (Fibers.nextFiber);
Fibers.trampolineRunning = false; // only reached if body returns
}
}
finishContextSwitch(newFiber) { // the rewind half
... restore stack limits/pointer ...
if (entryPoint !== 0) { Asyncify.currData = null; dynCall_vi(entryPoint, userData); } // first run
else { var d = newFiber+20; Asyncify.currData = d; Asyncify.state = Rewinding;
_asyncify_start_rewind(d); Asyncify.doRewind(d); } // resume
}
```
**Two fragilities:**
1. `finishContextSwitch` re-enters wasm (`doRewind`/`dynCall_vi`). If that re-entered code
itself unwinds before returning, the `do/while` is abandoned with `trampolineRunning===true`
(the reset never runs). **Every future `Fibers.trampoline()` then fails the guard** → pending
`nextFiber` never processed → **hang.** (This is the facet the pasted `try/finally`
"self-heal" targets.)
2. fiber buffers come from `emscripten_fiber_init`, **not** `Asyncify.allocateData`, so the
`handlesleep.js` shim is **blind to them.**
### The #9153 shim — what `handlesleep.js` is, and where it comes from
`tests/apps/kicad/pcbnew.js` is **generated** (emscripten link, then post-processed; committed
but overwritten by every build). The handleSleep override is **not** Emscripten's — its source
of truth is **`scripts/common/shims/handlesleep.js`**, injected verbatim into `pcbnew.js` by
**`scripts/common/inject-dyncall-shims.sh`** (`cat "$SHIM_DIR/handlesleep.js" >>`) right after
the `_emscripten_fiber_swap.isAsync = true;` marker (~`pcbnew.js:11579`).
Build order (`docker/build.sh:123-156`): **link → inject-dyncall-shims.sh → apply-finalize.sh →
apply-asyncify.sh.** `inject-dyncall-shims.sh` injects, in order: (1) per-signature `dynCall_*`
bindings, (2) six inline empty-callback fixes, (3) `handlesleep.js`, (4) optional
`diagnostics.js` (only with `SHIM_DIAGNOSTICS=1` — source of the `[CLIP-DIAG]`/`[DIAG_SLEEP]` log
lines). The trampoline self-heal is **not present today.** The asyncify pass itself
(`apply-asyncify.sh`) uses imports `env.invoke_*,env.__asyncjs__*,env.emscripten_fiber_swap`
(`:33`), a large-function removelist (`:37-50`), then a `-O2` shrink pass (`:72`).
What the shim does: tag each `handleSleep` with the buffer it allocated, and in `wakeUp` restore
`Asyncify.currData = thatBuffer` right before `start_rewind`/`doRewind` — so a fiber swap that
clobbered the slot during the await doesn't make the sleep rewind the wrong buffer. **It fixes
exactly one level of nesting, and only for sleeps (not fibers).**
---
## §4. Control flow — startup, and how the loop becomes "parked"
```
run() -> doRun() -> callMain() pcbnew.js:21346
└─ entryFunction(argc,argv) == wasmExports["__main_argc_argv"] (C main)
└─ wxEntry -> wxEntryReal() init.cpp:464
├─ wxTheApp->CallOnInit() (build UI, frames, tools…)
│ └─ [STARTUP BURST: tool coroutines Call/Yield/Resume run here.
│ Each is an emscripten_fiber_swap; currData churns Normal<->set<->null.
│ These WORK because main's real C stack is on exportCallStack,
│ so each unwind reaches bottom, trampoline fires, target rewinds.]
├─ class CallOnExit { ~CallOnExit(){ wxTheApp->OnExit(); } } callOnExit; init.cpp:488
└─ return wxTheApp->OnRun()
└─ MainLoop() -> wxGUIEventLoop::DoRun() evtloop.cpp:85
└─ emscripten_set_main_loop(ProcessEvents,0,1) evtloop.cpp:107
└─ setMainLoop(...) pcbnew.js:11324
├─ _emscripten_set_main_loop_timing(1,1) :11387
│ └─ runtimeKeepalivePush(); MainLoop.running=true :11270
│ (★ runtime now stays alive even if main "exits")
├─ MainLoop.scheduler() -> schedules first rAF tick
└─ if (simulateInfiniteLoop) throw "unwind"; :11392 <-- THE PARK
```
The `throw "unwind"` propagates **as a plain JS exception** out through every wasm frame of
`OnRun/DoRun/...` (abandoned, *not* asyncify-saved, *no* C++ destructors) up to:
```
callMain catch(e) -> handleException(e) pcbnew.js:21362, 1391
└─ e == "unwind" -> return EXITSTATUS (swallowed silently) :1397
```
**Result of the park:**
- The native C stack of `main()` (and `wxEntryReal`/`OnRun`/`DoRun`) is **gone**.
- **`CallOnExit::~CallOnExit()` (→ `OnExit()`) and `wxEntryCleanupReal()` NEVER RUN** — the app,
frames, and tools stay alive. *This is the entire purpose of `simulate_infinite_loop=1`.*
- The runtime stays alive purely via the keepalive counter (★). Each rAF tick re-enters wasm
fresh through `MainLoop.runner → runIter → callUserCallback(ProcessEvents)`
(`pcbnew.js:11342→11452→10003`) on a **brand-new C stack**. `ProcessEvents` (`evtloop.cpp:19`)
pumps `ProcessPendingEvents` + `Paint` + every-third `ProcessIdle`, then returns. **No sleep
in a quiet tick.**
---
## §5. Control flow — the HANG (first tool interaction after startup)
```
rAF tick -> ProcessEvents -> TOOL_MANAGER -> coroutine->Resume()/Call()
└─ libcontext::jump_fcontext -> emscripten_fiber_swap(old,new) currData = old+20; start_unwind
└─ unwind propagates out of ProcessEvents …
└─ maybeStopUnwind (exportCallStack==0?) -> Fibers.trampoline()
└─ finishContextSwitch(new): currData=new+20; start_rewind; doRewind(new)
└─ tool body runs … yields/returns … swaps back to caller …
```
Healthy idle ends with `currData == null`. The measured bug state was **`state==Normal` but
`currData != null`** — an unwind happened, its rewind was never issued; the swap parks forever;
all post-idle tool interactivity dies.
**Why the *first post-startup* swap?** Two credible mechanisms, both rooted in §4's `throw
"unwind"` (not mutually exclusive):
1. **Dangling `currData` from the abnormal teardown.** The throw abandons the C stack **without**
running `stop_unwind/stop_rewind` or resetting the Asyncify globals. If the startup burst left
an in-flight / half-settled fiber context at the moment `DoRun` threw, `currData` stays
non-null into idle. The next `emscripten_fiber_swap` enters the `state==Normal` branch and
**overwrites** `currData` with `old+20`, orphaning the dangling buffer; the orphan can never
be rewound → hang.
2. **Stuck trampoline guard.** If a swap inside `Fibers.trampoline`'s `do/while` unwound and never
returned (§3 fragility #1), `trampolineRunning` is stuck `true`, so the first post-startup
swap's `nextFiber` is scheduled but the trampoline early-returns → hang.
> **Decisive diagnostic (cheap, no rebuild — JS-only):** log `Asyncify.currData`,
> `Asyncify.state`, `Fibers.trampolineRunning` at (a) the last line of `DoRun` *before* the
> throw, (b) the first rAF tick, (c) the entry of the first post-startup `emscripten_fiber_swap`.
> Compare `currData` against `g_main_context`'s buffer and any live coroutine's `fiber+20`. That
> tells you which of #1/#2 (or both) is in play — and whether a `currData` authority alone fixes
> it or de-parking is required.
---
## §6. Control flow — the CRASH (clipboard), for contrast
```
post-load idle -> (wx paste-enable / GetClipboardUTF8) -> wxClipboard::IsSupported(wxDF_TEXT)
└─ js_clipboardHasText (EM_ASYNC_JS) -> handleSleep: currData=bufA; MainLoop.pause(); start_unwind
└─ PARKED up to 2 s awaiting readText() (headless => always full timeout)
├─ during the wait a modal tears down (EndModal:5100) -> emscripten_fiber_swap
│ └─ currData = fiberF+20 <-- clobbers bufA in the single slot
├─ 2nd/3rd clipboard polls stack up (log: pendingSleeps->3, one ENTER at state=2)
└─ bufA's Promise resolves -> handleSleep wakeUp: start_rewind(currData=null/F)
└─ doRewind(null) -> reads garbage field#8 -> RuntimeError: index out of bounds
```
Same slot, opposite victim: a long-parked **sleep** clobbered by a **fiber swap** (crash) vs.
in §5 a **fiber swap** stranded by a dirtied slot (hang).
---
## §7. "De-parking" in finest detail
**What it means:** change `evtloop.cpp:107` to `emscripten_set_main_loop(ProcessEvents, 0,
/*simulate_infinite_loop=*/0)`. Then `setMainLoop` does **not** throw (`pcbnew.js:11391`
skipped); it **returns normally** into `DoRun`, which returns up the C++ stack. Asyncify globals
are left in the clean state ordinary C++ returns produce — removing mechanism §5#1 at the source.
**The lifecycle trap it creates (why `=1` exists):** if `DoRun` returns, the C++ unwind runs the
teardown the park was hiding:
```
DoRun returns -> OnRun returns -> wxEntryReal:
├─ ~CallOnExit() -> wxTheApp->OnExit() init.cpp:488
└─ wxEntry -> wxEntryCleanupReal() init.cpp:433
├─ wxTheApp->CleanUp() (deletes ALL top-level windows, pending objects)
├─ delete app; (destroys wxTheApp) init.cpp:448
└─ DoCommonPostCleanup()
=> then C main returns => callMain: exitJS(ret, implicit=true) pcbnew.js:21360
└─ _proc_exit: keepRuntimeAlive()==true (keepalive ★) => does NOT abort :1383
=> runtime KEEPS RUNNING, rAF keeps firing ProcessEvents …
… but wxTheApp + all windows are already FREED => next tick touches freed memory
(this is also the null-IsModal/windowClosing close crash).
```
So the runtime survives (keepalive), but the app is torn down under the still-firing loop.
**That is precisely the trap `simulate_infinite_loop=1` avoids — not by keeping the runtime
alive (the keepalive counter already does that), but purely by preventing the C++ cleanup from
running.**
**A correct de-park is therefore two coupled changes:**
1. `emscripten_set_main_loop(..., 0)` so `DoRun` returns with clean Asyncify state, **and**
2. **suppress the destructive post-`MainLoop` teardown** so the live app isn't freed. Options:
- **(2a)** a wasm-specific `OnRun`/event-loop path whose return does *not* fall into
`~CallOnExit`/`wxEntryCleanupReal`, with real cleanup driven from **`UnloadCallback`**
(`app.cpp:620`, registered `:694`) on `beforeunload`.
- **(2b)** keep `wxEntryReal` but guard `wxEntryCleanupReal`/`OnExit` to no-op while the rAF
loop is registered (a "main loop owns lifetime" flag), deferring real cleanup to unload.
Either way the rAF loop becomes the sole owner of app lifetime; `ScheduleExit`
(`evtloop.cpp:40`, `emscripten_cancel_main_loop`) is the one teardown path.
**What de-parking fixes / doesn't:**
- **Fixes:** the dangling-`currData`-at-idle mechanism (§5#1) and the null-`IsModal` close crash.
- **Does not, by itself, fix:** the fundamental slot-sharing — two genuinely overlapping
suspensions still contend for one `currData`. De-parking removes the *base occupant*; it does
not make concurrent suspensions compose. The trampoline-guard facet (§5#2) is independent.
**Cost:** `evtloop.cpp` + `app.cpp`/`init.cpp` change → full wx rebuild + relink, touches
shutdown. A prior `CallAfter`-deferral of the call site was tried and reverted — evidence the
problem is the *abandoned-unwind topology*, not the timing of when the loop is installed.
---
## §8. Why we can't just "make the modal not sleep"
`ShowModal()` must hand KiCad a **blocking `int`** (native semantics; rewriting call sites is a
KiCad change, against policy). In a single-threaded browser the only ways to "return later from a
call that hasn't finished" are (a) block the thread — impossible, freezes the tab — or (b)
suspend the stack (Asyncify/fiber). So the modal is **necessarily** a suspension. Re-homing it
onto a fiber buys nothing (fibers are the same `currData` machine). The durable answer is to make
suspensions **compose**, i.e. fix the slot — see [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md).

View file

@ -0,0 +1,123 @@
# 03 — Is there a real solution? Per-context buffers and prior art
## §1. Yes — one data buffer per context
**The single global slot is NOT a WebAssembly or Binaryen law. It is a choice in Emscripten's
high-level JS runtime.** "Store one for each place; each unwind/rewind pair has its own data
slot" is the standard, supported solution.
- **The Binaryen Asyncify pass is multi-buffer by design.** `asyncify_start_unwind(dataPtr)` and
`asyncify_start_rewind(dataPtr)` *take the buffer pointer as an argument* — the pass has no
global "current buffer." Alon Zakai's Asyncify writeup: *"In this example we have just one such
structure … to implement something like coroutines **you would use one data structure for
each.**"*
- **The single slot lives in Emscripten's `libasync.js`**, as global `Asyncify.currData` plus the
guard in `whenDone()`: `assert(!Asyncify.asyncPromiseHandlers, 'cannot have multiple async
operations in flight at once')`. The docs state the rule plainly: *"It is not safe to start an
async operation while another is already running."* That guard, not the wasm, is what we trip.
**We already implement per-context buffers — for fibers.** Each tool coroutine is a
`wasm_fcontext` embedding its **own** asyncify buffer (`libcontext.cpp:98`,
`char asyncify_stack[64*1024]`); `emscripten_fiber_t` itself carries an `asyncify_data` per
fiber. So the coroutine half is *already* "one slot per place." **The bug is not missing buffers
— two subsystems blindly write the one global `currData`:** fiber swaps set `currData = fiber+20`
(`pcbnew.js:11549/11565`); `handleSleep` sets `currData = allocateData()` (a fresh malloc,
`:10223`). When a fiber swap happens *inside* a sleep's await (modal `startModal` pumps
`ProcessEvents`, which yields a tool coroutine), the swap overwrites the sleep's buffer pointer →
crash; or a parked context's pointer is lost → hang. **`handlesleep.js` is a one-level patch of
exactly this.**
**The hard rule that makes the fix tractable:** with Asyncify only **one** context may be
*actively* unwinding/rewinding at any instant (the Normal/Unwinding/Rewinding state machine is
per-module-instance), but **N contexts may be PARKED simultaneously**, each frozen in its own
buffer, resumable in any order. Single-threaded cooperative scheduling means one-active-at-a-time
is all we ever need. The cure is **never losing a parked context's buffer pointer.** Fibers do
that by storing the pointer in the fiber struct; the fix is to give the sleep side the same
discipline under one authority.
**Why raw nested `handleSleep` can never work (Emscripten #9153, WONTFIX):** a `start_unwind`
serializes the *entire* live C stack from that point to the top into *one* buffer. You cannot
unwind inner frame `g` while keeping outer frame `f` alive — one live C stack per instance. To
make `f` and `g` independently suspendable they must live on **separate fibers (separate C stacks
+ separate buffers).** This is the architectural reason the modal (a `handleSleep` over the whole
stack) and the tool coroutine inside it (its own fiber) are the colliding pair.
## §2. Prior art — who has built "many suspended stacks" on Asyncify
- **Emscripten Fibers** (PR #9859, by *Akaricchi*, merged by *kripken*, 2020) — the official
mechanism: one `asyncify_data` per fiber; `Asyncify.currData` is the transient "which fiber is
moving this microsecond" pointer. Docs: fibers "supersede the legacy coroutine API" and are "a
building block for asynchronous control flow constructs, such as coroutines."
- **QEMU wasm port**`CoroutineEmscripten` allocates its own `asyncify_stack` per coroutine and
delegates switching to `emscripten_fiber_swap`. In production for virtio/block I/O.
- **TinyGo** — goroutines on wasm via Asyncify, one buffer per goroutine, a JS scheduler
trampoline that rewinds the next ready goroutine.
- **Pyodide / CPython** — used Asyncify for `run_sync` and hit the single-operation reentrancy
wall (keyboard-interrupt-during-await left the promise pending forever, #2141).
So "store one per place" is the textbook answer, shipped by several serious C/C++ runtimes.
## §3. We are not switching to JSPI
JSPI (the browser-native stack-switching alternative to Asyncify) is **out of scope** — we are
staying on Asyncify. In short, it does not fit this codebase: it cannot replace the *intra-wasm*
`emscripten_fiber_swap` tool coroutines (they cross no JS boundary), it is incompatible with
`emscripten_set_main_loop` (our entire architecture), and combining it with our
pthreads/`PROXY_TO_PTHREAD` build is unsupported. A prior session reached the same conclusion in
`research/threading_2.md`. The fix therefore stays entirely within Asyncify, in the wasm/shim
layer.
## §4. The achievable fix for us — a unified per-context `currData` authority
This generalizes `handlesleep.js`. It fits our fibers + parked-main-loop architecture, is
supported by the current toolchain, and stays entirely in the **wasm/shim layer** (no KiCad
changes).
**Maintain one registry of suspension contexts**, each entry = `{ dataPtr, rewindId, kind:
fiber|sleep, status }`. Make that registry the *single authority* that owns `Asyncify.currData`:
at every unwind/rewind transition, set `currData` from the context being resumed — never let a
raw `fiber_swap` or `handleSleep` write it blindly.
**Two hook points (already identified in our tree):**
- **Fiber-swap path:** `_emscripten_fiber_swap` (`pcbnew.js:11557`, source `libcontext.cpp:321`).
Each coroutine already owns its buffer (`wasm_fcontext::asyncify_stack`), so here the job is to
*register/track*, not allocate.
- **handleSleep path:** `Asyncify.handleSleep`/`allocateData` (`pcbnew.js:10121-10241`). The 6
EM_ASYNC_JS sleep sites that funnel through it: `dialog.cpp:201` (`startModal`),
`clipbrd.cpp:38/76/118/145`, `fontenum.cpp:33`.
**Failure modes the authority must respect (from QEMU/TinyGo/Pyodide experience):**
1. **Wrong buffer/entry on rewind** → always `doRewind` via the buffer's interned `rewind_id`,
never a hardcoded export.
2. **Use-after-free** → never free a context's buffer while it is still parked; only on
completion/cancel. (Today `handleSleep` `_free`s at `pcbnew.js:10233`; a unified owner must not
free a buffer another context still references.)
3. **Buffer too small** → traps with `unreachable`; sizes are 64 KB (fiber) / `StackSize` (sleep)
— keep generous.
4. **Out-of-order resolution + shared globals** → buffers are independent, but the C heap/wasm
globals are shared; resuming in a different order than parked can violate invariants (ordinary
coroutine reentrancy hazard, not asyncify-specific).
5. **`wakeUp` re-entering wasm mid-rewind** → fire resumptions from a clean JS stack
(`setTimeout(wakeUp,0)`) so a Promise `.then` can't start a rewind while another is in flight.
How this composes with de-parking and the clipboard fix is in
[`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md).
---
## Sources
**Asyncify internals / fibers / prior art**
- Kripken: Pause and Resume WebAssembly with Binaryen's Asyncify — https://kripken.github.io/blog/wasm/2019/07/16/asyncify.html
- Binaryen Asyncify.cpp — https://github.com/WebAssembly/binaryen/blob/main/src/passes/Asyncify.cpp
- Emscripten fiber.h — https://github.com/emscripten-core/emscripten/blob/main/system/include/emscripten/fiber.h , docs https://emscripten.org/docs/api_reference/fiber.h.html
- Emscripten PR #9859 (Fibers API) — https://github.com/emscripten-core/emscripten/pull/9859
- Issue #9153 (nested asyncify, WONTFIX) — https://github.com/emscripten-core/emscripten/issues/9153
- Issue #16291 / #18412 ("cannot have multiple async operations in flight") — https://github.com/emscripten-core/emscripten/issues/16291
- QEMU wasm coroutine backend — https://www.mail-archive.com/qemu-devel@nongnu.org/msg1113010.html
- TinyGo goroutines — https://aykevl.nl/2019/02/tinygo-goroutines
- Pyodide issue #2141 (run_sync reentrancy wall) — https://github.com/pyodide/pyodide/issues/2141
- Emscripten async docs — https://emscripten.org/docs/porting/asyncify.html
**In-repo**
- `research/threading_2.md` — prior async/threading research.

View file

@ -0,0 +1,121 @@
# 04 — How the fixes relate, the test matrix, open questions
> The goal is **one universal mechanism**, not patches scattered around. This file classifies the
> candidate fixes by *root cause* so it's clear what is part of the one solution, what is
> subsumed, and what is genuinely separate.
## §1. The candidate levers, by root cause
| Lever | Root cause it addresses | Relationship to the universal fix |
|---|---|---|
| **Sync clipboard `IsSupported`** | A *separate* semantic/UX bug: a cheap predicate doing a 2 s permission-gated async read on the idle path. | **NOT needed for correctness.** A correct per-context authority makes the 2 s sleep *safe* even when overlapped. Keep it only as an independent UX/perf improvement, or drop it. |
| **Trampoline self-heal** (`try/finally` around `Fibers.trampoline`) | A *different register*, `Fibers.trampolineRunning`, getting stuck `true` after a mid-loop unwind. | **Subsumed.** A real authority must own the trampoline anyway; "always reset the guard" becomes one of its invariants, not a standalone bolt-on. |
| **De-park `main()`** (`simulate_infinite_loop=0` + suppress teardown) | The `throw "unwind"` abandoning Asyncify state outside any scheduler's control, plus the null-`IsModal` lifecycle crash. | **Orthogonal** to `currData`. Whether it's required is decided by one measurement (§2). If required, it is *part of* the universal design (the main loop becomes a normal scheduler participant), not a hack. |
| **Per-context `currData` authority** | The single global slot shared by overlapping suspensions. | **The core.** This is the one universal mechanism. |
### Why de-parking doesn't automatically fall out of the authority
The authority governs `currData` during *normal* unwind/rewind transitions. But
`simulate_infinite_loop=1` is a **`throw "unwind"`** (`pcbnew.js:11392`) — a JS exception that
**bypasses all asyncify bookkeeping, including the authority's.** Two orthogonal design axes:
- **D1 — how does `main()` avoid returning into destructive cleanup?** (throw, vs. de-park +
suppress cleanup)
- **D2 — how is `currData` managed across overlaps?** (single slot, vs. unified authority)
The authority is purely D2; the throw is purely D1. They don't subsume each other. You *can*
build the authority while keeping the throw — its safety then hinges entirely on §2.
## §2. The one measurement that sets the scope
> **Is `Asyncify.currData` clean (null, no context parked) at the instant `wxGUIEventLoop::DoRun()`
> executes the `throw "unwind"`?**
- **If yes** → the throw abandons nothing; the **per-context authority alone is the complete
universal solution.** No de-parking needed.
- **If no** (a startup coroutine or the main fiber is still parked at the throw) → the throw
orphans a live buffer *outside the authority's control*. Then the clean answer is to **remove
the throw** (de-park), so the authority never has to recover from an abnormal teardown. (The
alternative — "reconcile/reset `currData` at the next tick" — is exactly the kind of paper-over
we want to avoid.)
**The diagnostic (cheap, JS-only, no full rebuild):** add `console.log`s of
`Asyncify.currData` / `Asyncify.state` / `Fibers.trampolineRunning` at:
1. the last line of `DoRun` *before* `emscripten_set_main_loop(...,1)` (C side, `evtloop.cpp`),
2. the first `MainLoop.runner` rAF tick (`pcbnew.js:11342`),
3. the entry of the first post-startup `_emscripten_fiber_swap` (`pcbnew.js:11557`).
Compare `currData` to `g_main_context`'s buffer (libcontext) and to any live coroutine's
`fiber+20`. This disambiguates §5 mechanism #1 (orphaned buffer) vs. #2 (stuck guard) in
[`02-asyncify-internals.md`](02-asyncify-internals.md), and answers the yes/no above.
**Run this before designing anything.**
## §3. The universal, hack-free framing
The cleanest single design is **one cooperative Asyncify scheduler in which *every* suspendable
thing is a registered context with its own buffer** — tool coroutines, modals, clipboard, fonts,
**and the main loop itself** — where the scheduler is the sole owner of `currData` *and* the
fiber trampoline, and the main loop participates normally instead of being parked via a
state-abandoning `throw`.
In that framing:
- the per-context `currData` authority is the **core** (D2),
- the trampoline self-heal is an **internal invariant** of it,
- making the main loop a normal scheduler participant **is** de-parking (D1) — a consequence of
"no special-case suspension may abandon state," not a bolt-on,
- the clipboard sync change is **out of scope** — a separate UX fix.
The §2 measurement decides whether the scheduler must own the main loop from the start (cleaner)
or can be scoped to coroutines + sleeps and leave the main loop alone.
## §4. Tests — enumerating "every possible case"
A strong harness already exists: `tests/apps/standalone/coroutine*/` (nine probes:
`main/nested/nested_ex/embind/mainloop/gl/gl_pt/vcall/wxpt`) built by `scripts/build-wasm-test.sh`
via `tests/apps/Makefile.wasm` (each links `-sASYNCIFY=1
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']` then the same `inject-dyncall-shims.sh`), plus
`tests/e2e/coroutine-nested.spec.ts` (8 modal×fiber scenarios) and `coroutine-pthread.spec.ts`,
all asserting **no `index out of bounds`** and polling a `SUMMARY total/passed/failed` line.
**Gaps:** no systematic coverage of *out-of-order* and *long-parked* overlaps, and it asserts
crash-freedom but **not liveness** (so it would not catch a hang).
Make it a **generated combinatorial product** and assert three outcomes per cell:
- **Primitives (cells):** `S1`=EM_ASYNC_JS sleep (modal/clipboard/font), `S2`=fiber swap (tool
coroutine), `S3`=parked main loop, `S4`=pthread boundary.
- **Overlap shape:** none / nested-LIFO / **interleaved out-of-order** / **long-parked outer**
(the 2 s clipboard shape). The last two are under-tested.
- **Host context:** direct / rAF main-loop tick / embind dispatch / WebGL2 frame / deep stack /
`-fexceptions` invoke wrappers.
- **Resume target:** continuation / **virtual call** (`invoke_vi→dynCall_vi`, the `vcall_repro`
smoking gun).
- **Assert per cell:** (1) no `index out of bounds` / `indirect call to null` / `unwind`
rejection (no crash); (2) **completes within a timeout** (no hang — the missing assertion
today); (3) returned value correct (no silent wrong-buffer rewind).
- **Two must-add named scenarios** that pin our exact bugs deterministically:
`long_parked_sleep_clobbered_by_swap` (the clipboard crash) and `fiber_swap_after_main_park`
(the §5 hang — the harness must install a `simulate_infinite_loop=1` main loop, then swap a
fiber post-park).
## §5. Open questions to resolve before any implementation
1. **Run the §2 diagnostic.** Is idle `currData` the orphaned-from-park buffer (§5#1 in 02), a
stuck trampoline guard (§5#2), or both? This decides whether the authority alone suffices or
the main loop must be de-parked, and whether the trampoline self-heal is meaningful on its own.
2. **For de-parking, which suppression shape** (2a UnloadCallback-driven cleanup vs. 2b guarded
`wxEntryCleanupReal`) is least invasive given our `wxApp`/`init.cpp` fork delta? Check
`scripts/kicad-diff-stats.sh` and current wx fork divergence first.
3. **Does sync-clipboard alone stop the load-pcb route hanging, or only stop crashing?** If the
open hangs even with clipboard synchronous, the authority (and likely de-park) is required, not
optional.
---
## Provenance
This dossier was reconstructed from: Claude session
`3301ab3a-c457-4359-be9a-dcf4dcfc9fbc.jsonl`; the in-repo
`docs/docs/features/fix-asyncify-O2-and-modal-promise-rejection/{rtree,clipboard}-*-findings.md` and
`research/threading_*.md`; direct reading of the generated `tests/apps/kicad/pcbnew.js` runtime,
the `wxwidgets/src/wasm/` and `kicad/thirdparty/libcontext/` sources, and the
`scripts/common/` build pipeline; plus web research into Emscripten/Binaryen Asyncify and fibers
(sources listed in [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md)).

View file

@ -0,0 +1,686 @@
# 05 - Design A: JS Asyncify arbiter
> Goal: fix the current system with the smallest architectural move. Keep
> `EM_ASYNC_JS` modal/clipboard/font calls and Emscripten fibers, but introduce one JS-side
> authority that owns `Asyncify.currData`, `Asyncify.state` transitions, the fiber trampoline,
> and resume queueing.
## Status
This is a design note, not an implementation. It explains an incremental universal fix:
instead of adding one-off patches for clipboard, modal dialogs, quasi-modal loops, and fibers,
we put one scheduler in front of all suspension paths.
The core idea is:
```text
Many contexts may be parked.
Only one context may be actively unwinding or rewinding at a time.
Asyncify.currData is not durable state. It is a temporary register loaded from the current context.
```
That is exactly the distinction that makes the design work.
## Concepts
### What is a call stack?
A call stack is the chain of active function calls for one line of execution.
If C++ is here:
```cpp
main()
-> wxEntry()
-> wxDialog::ShowModal()
-> startModal()
```
then local variables and return addresses for all of those calls are on the stack. Normally, a
function returns by walking back up that chain.
In browser WebAssembly, we cannot block the browser main thread while waiting for a Promise. So
if C++ wants to pretend that `ShowModal()` blocks, the stack must be saved somewhere and resumed
later.
### What is Asyncify?
Asyncify is Binaryen/Emscripten's way to save and restore a WebAssembly call stack.
At the low level, it is buffer-oriented:
```text
asyncify_start_unwind(dataPtr)
asyncify_stop_unwind()
asyncify_start_rewind(dataPtr)
asyncify_stop_rewind()
```
`dataPtr` points at an `asyncify_data` structure. The important fact is that the Wasm-level
mechanism already accepts a buffer pointer. It does not require there to be only one buffer.
Emscripten's JS runtime adds a convenience layer:
```js
Asyncify.state // Normal, Unwinding, Rewinding
Asyncify.currData // pointer to the buffer currently being used
```
That convenience layer is where our contention lives. `currData` is a single slot.
### What is a coroutine?
A coroutine is a function that can pause in the middle and later continue from the same place.
Ordinary function:
```text
call -> run to completion -> return
```
Coroutine:
```text
call -> run a bit -> yield -> later resume -> run more -> yield/return
```
KiCad's interactive tools are coroutine-shaped. A routing tool, drawing tool, or selection tool
often waits for user input, yields to the UI, then resumes with the next event.
### What is a fiber?
A fiber is a stackful coroutine. It has its own stack.
That matters because a stackful coroutine can pause deep inside ordinary C++ calls without
turning every caller into a callback or Promise. From the C++ side it can still look synchronous.
In this repo, KiCad's `libcontext` shim maps KiCad coroutines to Emscripten fibers:
```text
KiCad COROUTINE
-> libcontext jump_fcontext
-> emscripten_fiber_swap
-> Asyncify unwind/rewind
```
Each `wasm_fcontext` already has its own `asyncify_stack` buffer in
`kicad/thirdparty/libcontext/libcontext.cpp`. So the fiber half already understands the key
pattern: one context, one buffer.
### What is a trampoline?
A trampoline is a small piece of code whose job is to bounce control into another execution
context.
In Emscripten's fiber implementation, `_emscripten_fiber_swap(oldFiber, newFiber)` starts by
unwinding the old fiber. Once the unwind reaches the bottom of the JS/Wasm call boundary,
Emscripten must start the new fiber's rewind. That second half cannot happen directly from the
middle of the old fiber. It happens from `Fibers.trampoline()`.
So the rough flow is:
```text
old fiber calls emscripten_fiber_swap(old, new)
-> old stack unwinds into old.asyncify_data
-> control reaches JS boundary
-> Fibers.trampoline() runs
-> trampoline loads new.asyncify_data
-> trampoline starts rewind into new fiber
```
The trampoline is needed because a fiber swap is two operations separated by the unwind reaching
the edge of the runtime:
```text
leave old stack now
enter new stack after old stack is fully saved
```
The bug-prone part is that `Fibers.trampoline()` has its own guard,
`Fibers.trampolineRunning`. If a rewind from inside the trampoline itself unwinds again before
the trampoline function returns, the guard can stay stuck. Then future fiber swaps set
`Fibers.nextFiber`, but the trampoline refuses to run.
### What is parking?
Parking means a context is suspended and waiting outside Wasm.
Examples:
- A modal dialog is parked while waiting for the user to press OK or Cancel.
- A clipboard read is parked while waiting for `navigator.clipboard.readText()`.
- A KiCad tool fiber is parked after yielding to another fiber.
Parked does not mean active. Many contexts can be parked at once as long as each has its own
saved stack buffer.
### What is queueing?
Queueing means "this parked context is ready to resume, but do not resume it immediately if the
runtime is in the middle of another unwind/rewind."
The queue is not a mutex that blocks all async work. A naive mutex would deadlock modal UI,
because a modal needs events to keep flowing while it is waiting.
The queue should serialize only active transitions:
```text
Allowed:
sleep A parked
fiber B parked
nested loop C parked
clipboard D parked
Not allowed:
two calls to asyncify_start_rewind at the same instant
a Promise wakeup directly calling doRewind while the fiber trampoline is mid-switch
```
## The problem this design solves
Today, several roads write the same global slot:
```text
handleSleep:
Asyncify.currData = Asyncify.allocateData()
fiber swap:
Asyncify.currData = oldFiber + 20
fiber finishContextSwitch:
Asyncify.currData = newFiber + 20
current handleSleep shim:
restores the sleep buffer before wakeUp
```
The current `handlesleep.js` shim is useful, but it is still local. It protects one sleep from
fiber swaps by remembering the sleep's buffer. It does not make all suspension producers obey one
state machine.
Design A says: make the local patch into a real arbiter.
## Core design
Introduce a JS object, conceptually:
```js
AsyncifyArbiter = {
active: null,
readyQueue: [],
contexts: new Map(),
transitionRunning: false,
trampolineRunning: false,
registerSleep(ctx) {},
registerFiber(ctx) {},
park(ctx) {},
markReady(ctx, value) {},
drain() {},
beginUnwind(ctx) {},
beginRewind(ctx) {},
finishRewind(ctx) {},
withCurrData(ctx, fn) {}
}
```
Each context has durable state:
```js
{
id,
kind: "sleep" | "fiber" | "nested-loop" | "main-loop",
dataPtr,
status: "running" | "unwinding" | "parked" | "ready" | "rewinding" | "done",
result,
cancel
}
```
`Asyncify.currData` becomes a derived value:
```text
When rewinding context X:
Asyncify.currData = X.dataPtr
When unwinding context X:
Asyncify.currData = X.dataPtr
When no transition is active:
Asyncify.currData may be null
```
No context is allowed to rely on `Asyncify.currData` as its long-term storage.
## Hooks
### Hook 1: `Asyncify.handleSleep`
This is the road used by `EM_ASYNC_JS` functions:
- `startModal` in `wxwidgets/src/wasm/dialog.cpp`
- clipboard read/write/clear/has-text in `wxwidgets/src/wasm/clipbrd.cpp`
- font enumeration in `wxwidgets/src/wasm/fontenum.cpp`
- the nested event loop added by wx commit `c27fe8bf`, if adopted
The current shim wraps `handleSleep` and captures the allocated buffer. The arbiter would keep
that, but add explicit lifecycle state:
```text
handleSleep begins
-> create sleep context
-> intercept allocateData
-> associate allocated dataPtr with the context
-> start unwind
-> mark context parked
Promise resolves
-> store result on context
-> mark context ready
-> queue drain
drain runs when safe
-> load context.dataPtr into Asyncify.currData
-> start rewind
-> doRewind(context.dataPtr)
```
Important difference from the current implementation: the Promise resolution path should not
directly call `wakeUp` if another transition is already active. It should enqueue the context and
let `drain()` decide when to resume.
### Hook 2: `_emscripten_fiber_swap`
The fiber path already has per-fiber buffers. The arbiter should not allocate new buffers for
fibers. It should track them.
Current idea:
```text
oldFiber data = oldFiber + 20
newFiber data = newFiber + 20
```
On swap from old to new:
```text
register old fiber context if unknown
register new fiber context if unknown
begin unwind of old
set Fibers.nextFiber = new
```
When the unwind reaches bottom, the arbiter owns the trampoline step:
```text
finish old unwind
queue new fiber as ready
drain
-> begin rewind of new
```
The original Emscripten functions can still do much of the work. The key is to wrap their
critical sections so the arbiter knows which data pointer belongs to which context and can reset
guards reliably.
### Hook 3: `Fibers.trampoline`
The arbiter must own the trampoline guard.
Minimum invariant:
```js
try {
Fibers.trampolineRunning = true;
// process next fiber
} finally {
Fibers.trampolineRunning = false;
}
```
But the arbiter design goes further. It treats the trampoline as part of the scheduler:
```text
maybeStopUnwind()
-> old stack is fully saved
-> notify arbiter that a transition completed
-> arbiter queues the next fiber
-> arbiter drains when safe
```
The reason this matters: if re-entered Wasm unwinds again while the trampoline is running, the
arbiter has to know that it left the trampoline in an incomplete state. A `try/finally` fixes the
guard symptom. A scheduler gives us a place to reason about the whole switch.
### Hook 4: main loop diagnostic, not necessarily main loop ownership
This is the part that caused the earlier "do not start by de-parking" comment.
The top-level `emscripten_set_main_loop(ProcessEvents, 0, 1)` throws `"unwind"` to stop C++
from returning into wx cleanup. That throw is not Asyncify. It is a special Emscripten lifetime
trick.
Design A can leave that alone if the throw happens while no Asyncify context is in flight.
So the first diagnostic is:
```text
At the moment top-level DoRun installs the main loop:
Asyncify.state == Normal?
Asyncify.currData == null?
Fibers.trampolineRunning == false?
```
If yes, the throw is only a lifetime mechanism. It is not corrupting Asyncify state. Then the
arbiter can fix overlapping sleeps/fibers without touching wx app lifetime.
If no, the throw is abandoning a live Asyncify context. Then Design A is incomplete unless it
also takes ownership of the main-loop park or we de-park main.
## Why not de-park first?
De-parking means changing the top-level main loop from:
```cpp
emscripten_set_main_loop(ProcessEvents, 0, 1);
```
to:
```cpp
emscripten_set_main_loop(ProcessEvents, 0, 0);
```
The `0` version does not throw. It returns normally. That sounds cleaner for Asyncify.
But it has a cost: after `DoRun()` returns, wx thinks the app is done. `wxEntryReal()` continues
into cleanup:
```text
OnRun returns
-> CallOnExit destructor runs wxTheApp->OnExit()
-> wxEntryCleanupReal deletes windows and wxTheApp
-> Emscripten rAF main loop is still alive
-> next tick touches deleted app/window state
```
So de-parking is not just a one-line Asyncify fix. It is an application lifetime redesign.
That is why Design A starts with the arbiter and the diagnostic. If the top-level throw is not
orphaning a live buffer, de-parking is avoidable. If the diagnostic proves the throw is
orphaning a live buffer, then de-parking becomes part of the solution, not a speculative first
move.
## Queueing model
The queue should be cooperative and explicit:
```js
function markReady(ctx, result) {
ctx.result = result;
ctx.status = "ready";
readyQueue.push(ctx);
scheduleDrain();
}
function scheduleDrain() {
if (drainScheduled) return;
drainScheduled = true;
setTimeout(drain, 0);
}
function drain() {
drainScheduled = false;
if (transitionRunning) return;
if (Asyncify.state !== Asyncify.State.Normal) return;
if (Fibers.trampolineRunning) return;
if (!readyQueue.length) return;
const ctx = readyQueue.shift();
transitionRunning = true;
try {
beginRewind(ctx);
} finally {
// This may not run immediately if beginRewind re-enters Wasm and unwinds.
// Therefore the real implementation must pair this with explicit callbacks
// from stop_rewind/maybeStopUnwind, not rely only on JS finally.
}
}
```
The subtle point: queueing must understand that `doRewind()` can re-enter Wasm and trigger
another unwind before returning. A plain JS queue is not enough unless the arbiter also receives
transition completion signals.
## Flow: modal dialog plus tool fiber
Current bad flow:
```text
ShowModal starts handleSleep
-> currData = modalBuffer
-> modal parked
modal's JS pump calls ProcessEvents
-> tool fiber swaps
-> currData = fiberBuffer
modal Promise resolves
-> handleSleep rewinds currData
-> currData is wrong
-> crash or wrong rewind
```
Arbiter flow:
```text
ShowModal starts handleSleep
-> modal context dataPtr = modalBuffer
-> modal context parked
modal's JS pump calls ProcessEvents
-> fiber context old/new dataPtr tracked
-> fiber transitions complete
-> currData may change transiently
modal Promise resolves
-> modal context marked ready
-> drain waits until no active transition
-> currData = modalContext.dataPtr
-> modal rewinds using modalBuffer
```
`currData` can be overwritten during the wait. It no longer matters because the durable pointer
lives in the context record.
## Flow: long clipboard read plus fiber swap
The clipboard `IsSupported()` path is unpleasant because it can park for two seconds on a
permission-gated `readText()`.
With the arbiter:
```text
clipboard sleep parks with dataPtr C
fiber swap parks/resumes with dataPtr F
clipboard Promise resolves later
arbiter reloads C before clipboard rewind
```
That makes the path correct, but it does not make it good UX. We may still want a synchronous or
cached `IsSupported()` for performance and browser permission reasons. That is a separate
quality fix.
## Flow: quasi-modal nested event loop
The linked wx commit `c27fe8bf` adds a nested event loop implemented with `EM_ASYNC_JS`.
Conceptually, that creates another sleep context:
```text
DIALOG_SHIM::ShowQuasiModal()
-> wxGUIEventLoop event_loop
-> event_loop.Run()
-> nested DoRun
-> wxWasmRunNestedLoop()
-> Asyncify sleep until EndQuasiModal exits it
```
Design A can support this if `wxWasmRunNestedLoop()` goes through the same `handleSleep`
arbiter.
The caution: if the nested pump catches an async `ProcessEvents` rejection and stops pumping
without resolving its Promise, the nested `DoRun()` remains parked forever. The arbiter can
make the rewind safe, but the nested loop implementation still needs a policy:
```text
on pump error:
resolve with an error/exit code
or reject in a controlled way
or mark context cancelled and resume C++ through cleanup
```
It should not silently stop with no resolution.
## Invariants
The arbiter should assert these aggressively in diagnostics builds:
1. Only the arbiter writes `Asyncify.currData` during managed transitions.
2. Every parked context has a durable `dataPtr`.
3. A context may be parked without being active.
4. At most one context is `unwinding` or `rewinding`.
5. Promise resolution never directly calls `doRewind()` if another transition is active.
6. `Fibers.trampolineRunning` is reset even if re-entered Wasm unwinds.
7. A context's buffer is not freed until that context reaches `done` or `cancelled`.
8. `Asyncify.currData` may be null while contexts are parked. The context records are the truth.
## What this design does not solve by itself
### It does not remove the top-level `throw "unwind"`
If the top-level main-loop throw is clean, that is fine. If it abandons a live Asyncify context,
the arbiter cannot recover perfectly after the fact because the throw bypasses Asyncify
bookkeeping.
That is why the diagnostic matters.
### It does not make arbitrary nested C stacks possible
Raw nested `handleSleep` on the same C stack is still not a thing Asyncify can magically support.
If function `f()` is parked and function `g()` inside the same live stack tries to park
independently, there is only one actual stack. To have independent suspension, `f` and `g` must
be on separate stacks/fibers or be parked as one combined context.
The arbiter prevents buffer loss. It does not violate stack physics.
### It does not remove browser permission latency
A correct two-second clipboard read is still a two-second clipboard read. Correctness and UX are
separate.
## Implementation sketch
Stage 1: observation only.
- Add diagnostics around `handleSleep`, `_emscripten_fiber_swap`, `Fibers.trampoline`,
`maybeStopUnwind`, and the first rAF tick.
- Log context ids, data pointers, states, queue length, and trampoline guard.
- Confirm whether the top-level main-loop throw happens with clean Asyncify state.
Stage 2: make sleeps context-owned.
- Promote `handlesleep.js` from "restore captured pointer" to "register sleep context".
- Store sleep data pointer in context.
- Queue Promise wakeups through `drain()` instead of direct immediate rewind when unsafe.
Stage 3: make fibers context-owned.
- Wrap `_emscripten_fiber_swap`.
- Register fiber buffers by pointer.
- Move trampoline guard reset into arbiter invariants.
- Record old/new fiber context state around swaps.
Stage 4: unify drain.
- One drain path handles ready sleep contexts and ready fiber contexts.
- Drain only starts a rewind when `Asyncify.state` is Normal and no transition is active.
Stage 5: decide on de-parking.
- If diagnostics prove the top-level throw is clean, leave it alone.
- If not, combine the arbiter with a separate lifetime change. See Design B for the cleaner
version of that world.
## Tests this design needs
Minimum named tests:
- `long_parked_sleep_clobbered_by_swap`
- `modal_pump_runs_fiber_then_modal_resolves`
- `fiber_swap_after_top_level_main_loop_park`
- `nested_quasi_modal_loop_exit_resumes_DoRun`
- `nested_quasi_modal_pump_error_does_not_hang`
- `clipboard_has_text_timeout_does_not_crash`
- `out_of_order_sleep_resolution`
- `fiber_trampoline_unwinds_inside_trampoline_then_next_swap_lives`
Each test should assert:
- no `index out of bounds`
- no `indirect call to null`
- no uncaught `"unwind"` rejection
- completion within a timeout
- correct returned value or dialog code
Liveness is as important as crash freedom.
## Why this is elegant enough
This design is elegant because it changes the ownership model without forcing KiCad or wxWidgets
to become async-first.
The main rule becomes:
```text
Every suspension has a context.
Every context owns its buffer.
The arbiter owns the one active transition slot.
```
That is the same shape as Emscripten fibers, TinyGo's scheduler, and other coroutine runtimes.
It respects Asyncify's real constraint: not one buffer total, but one active transition at a
time.
## Tradeoffs
Pros:
- Lowest disruption to KiCad and wxWidgets C++ APIs.
- Keeps `ShowModal()` and `ShowQuasiModal()` synchronous from KiCad's perspective.
- Can be built incrementally from the current `handlesleep.js` shim.
- Avoids app-lifetime risk unless diagnostics prove de-parking is required.
Cons:
- JS shim complexity increases.
- Still depends on Emscripten-generated runtime shapes (`handleSleep`, `Fibers.trampoline`,
`_emscripten_fiber_swap`).
- A partial arbiter can be worse than no arbiter if some path still writes `currData` behind its
back.
- It makes unsafe overlaps safe, but it does not automatically improve slow clipboard/font UX.
## Decision point
Choose Design A if we want the practical universal fix first.
Before implementing it, answer:
```text
At top-level main-loop installation, is Asyncify clean?
```
If yes, build the arbiter around sleeps and fibers. If no, either extend Design A to own the
main-loop lifetime too, or move toward Design B.

View file

@ -0,0 +1,609 @@
# 06 - Design B: fiber-first async runtime
> Goal: make the architecture conceptually cleaner by reducing the number of suspension
> primitives. Instead of having tool coroutines use fibers while modal/clipboard/font/nested loops
> use `EM_ASYNC_JS` sleeps, put every blocking-looking operation onto a fiber-like runtime and let
> one scheduler decide which stack runs next.
## Status
This is a design note, not an implementation. It is more invasive than Design A, but it is also
cleaner.
Design A says:
```text
Keep both suspension systems.
Put an arbiter in front of them.
```
Design B says:
```text
Stop having two suspension systems.
Represent every suspendable C++ activity as a scheduler context with its own stack/buffer.
```
In other words: modals, clipboard waits, font waits, quasi-modal loops, and tool coroutines all
become the same kind of thing from the runtime's point of view.
## Concepts
### Coroutine
A coroutine is code that can yield and later resume.
KiCad tools already have this shape:
```text
tool starts
-> waits for user event
-> yields
-> resumes when event arrives
-> waits again
```
The useful property is that the tool can be written in direct style. It does not have to become a
chain of callbacks.
### Stackless coroutine vs. stackful coroutine
A stackless coroutine can only suspend at explicit points in that function. JavaScript
`async`/`await` is stackless from the JS perspective: every caller must also understand Promise
control flow.
A stackful coroutine can suspend deep inside normal calls:
```text
tool()
-> helper()
-> dialog.ShowModal()
-> suspend here
```
When it resumes, it continues from inside `ShowModal()` and returns normally to `helper()` and
then to `tool()`.
KiCad wants stackful behavior because a large C++ application assumes blocking APIs like
`ShowModal()` and synchronous predicates.
### Fiber
A fiber is a stackful coroutine with its own stack.
Native platforms switch fibers by saving CPU registers and the stack pointer. In WebAssembly we
cannot directly manipulate the real engine stack, so Emscripten implements fibers using
Asyncify.
The important thing: a fiber has an identity and storage.
```text
fiber A:
C stack memory
asyncify_data buffer
status
fiber B:
C stack memory
asyncify_data buffer
status
```
This is exactly the model we want for every independently parked operation.
### Asyncify buffer
An Asyncify buffer is where the saved Wasm stack goes when a context parks.
Think of it like a suitcase for a suspended stack:
```text
before suspend:
live call stack is inside Wasm engine
during suspend:
saved stack is copied into context.dataPtr
after resume:
stack is rebuilt from context.dataPtr
```
Many suitcases may exist. Only one suitcase is being packed or unpacked at any instant.
### Trampoline
A trampoline is code that transfers control from one context to another after the old context has
fully yielded.
For fibers:
```text
old fiber asks to switch to new fiber
old fiber unwinds
JS boundary is reached
trampoline rewinds new fiber
```
It is called a trampoline because control "bounces" through it. The old stack cannot directly
jump into the new stack while it is still being unwound. The trampoline is the neutral place
where the runtime can say, "old is saved, now start new."
### Scheduler
A scheduler decides which context runs next.
In a cooperative scheduler, contexts yield voluntarily. There is no preemption. That matches
browser main-thread WebAssembly well: only one thing runs at a time, and control returns to the
browser between slices.
Design B wants a scheduler like:
```text
ready queue:
tool fiber
modal continuation
nested loop continuation
clipboard continuation
running:
exactly one context
```
## Current architecture
Today KiCad-WASM has two broad suspension families.
Family 1: fibers.
```text
KiCad tool coroutine
-> libcontext
-> emscripten_fiber_swap
-> fiber-owned asyncify buffer
```
Family 2: `EM_ASYNC_JS` sleeps.
```text
wxDialog::ShowModal
clipboard read/write/hasText/clear
font enumeration
nested event loop if c27 is adopted
-> Asyncify.handleSleep
-> malloc-owned asyncify buffer
```
Both families eventually manipulate the same `Asyncify.currData` and `Asyncify.state`.
Design B removes that split.
## Core design
Make all blocking-looking APIs suspend the current scheduler context instead of directly using
`EM_ASYNC_JS` as an independent stack owner.
Conceptual API:
```cpp
int wxWasmAwaitModal();
char* wxWasmAwaitClipboardRead();
int wxWasmAwaitClipboardHasText();
int wxWasmAwaitNestedLoopExit();
```
Internally:
```text
current fiber/context calls await operation
-> runtime records what JS Promise/event will wake it
-> current context yields to scheduler/main context
-> JS continues pumping browser events
-> Promise/event resolves
-> scheduler marks context ready
-> scheduler later resumes that same context
```
From C++'s perspective, this still looks blocking:
```cpp
int result = wxWasmAwaitModal();
```
From the runtime's perspective, there is no separate `handleSleep` stack competing with fibers.
There are only scheduler contexts.
## How `ShowModal()` would work
Current model:
```text
wxDialog::ShowModal()
-> startModal() EM_ASYNC_JS
-> handleSleep parks the whole current C stack
-> JS setTimeout loop calls ProcessEvents
-> EndModal resolves Promise
-> handleSleep rewinds the saved C stack
```
Fiber-first model:
```text
wxDialog::ShowModal()
-> show dialog
-> register modal wait on current context
-> yield current context to scheduler
-> browser/main loop keeps pumping events
-> EndModal stores return code and marks context ready
-> scheduler resumes context
-> ShowModal returns int
```
There is still a suspension. The difference is ownership.
In the current model, `startModal()` creates a separate `handleSleep` suspension that can overlap
with tool fibers.
In Design B, the modal wait is a reason for the current fiber/context to yield. It does not
create a second independent suspension mechanism.
## How clipboard would work
Current model:
```text
wxClipboard::GetData()
-> js_readTextFromClipboard() EM_ASYNC_JS
-> handleSleep
-> Promise waits
-> rewind C++ stack
```
Fiber-first model:
```text
wxClipboard::GetData()
-> start JS clipboard Promise
-> current context yields
-> Promise resolves with text or error
-> scheduler resumes context
-> GetData continues synchronously with stored result
```
This still allows the public wx API to look synchronous. But the wait is represented as "this
fiber is blocked on clipboard" instead of "handleSleep owns another Asyncify buffer."
## How quasi-modal nested loops would work
The linked wx commit `c27fe8bf` implements nested `wxGUIEventLoop::DoRun()` by adding another
`EM_ASYNC_JS` pump. That is reasonable as an incremental fix, but in Design B the nested loop is
just another scheduler wait.
Current c27-shaped model:
```text
ShowQuasiModal
-> wxGUIEventLoop::Run()
-> nested DoRun
-> wxWasmRunNestedLoop() EM_ASYNC_JS
-> setTimeout pump calls ProcessEvents
-> EndQuasiModal resolves Promise
```
Fiber-first model:
```text
ShowQuasiModal
-> wxGUIEventLoop::Run()
-> nested DoRun registers "wait until this loop exits"
-> current context yields to scheduler
-> top-level browser loop continues pumping ProcessEvents
-> EndQuasiModal calls loop->Exit()
-> scheduler marks nested-loop context ready
-> nested DoRun returns
```
No second `emscripten_set_main_loop`. No nested `handleSleep` pump. No independent Promise
rewind directly from the nested loop.
## The main loop in Design B
This is where de-parking becomes easier to understand.
The top-level Emscripten main loop currently uses:
```cpp
emscripten_set_main_loop(ProcessEvents, 0, 1);
```
The `1` means "simulate an infinite loop." Emscripten implements that by throwing `"unwind"` out
of the startup stack. This prevents `main()` and `wxEntryReal()` from returning into wx cleanup.
In Design B, the cleaner version is:
```text
main loop is also a scheduler participant
app lifetime is owned by the scheduler/browser loop
wx cleanup runs only on real exit/unload
```
That probably means eventually replacing the top-level throw with explicit lifetime ownership:
```text
startup initializes wx app
scheduler starts rAF/setTimeout event pump
startup returns without destroying wx app
real cleanup is deferred to page unload or explicit app exit
```
But this is the risky part. wx's normal contract says:
```text
OnRun returns -> app exits -> cleanup happens
```
Browser apps want:
```text
OnRun starts event pump -> app stays alive -> cleanup happens later
```
The existing `simulate_infinite_loop=1` throw is Emscripten's shortcut for that mismatch.
Design B would replace the shortcut with explicit lifetime rules.
## Why this design may need de-parking
Design B tries to make every suspension normal and scheduler-owned. A plain JS throw that
abandons the startup stack is not normal and not scheduler-owned.
So the cleanest Design B includes de-parking:
```text
no special top-level throw
no hidden abandoned C++ stack
main loop is represented as a scheduler/lifetime state
```
But de-parking must be paired with suppressing normal wx cleanup during steady-state operation.
Otherwise `wxTheApp` and top-level windows can be deleted while the browser rAF loop is still
calling `ProcessEvents()`.
That is why "de-park main" is not a trivial first patch. It belongs naturally in Design B, but it
requires an app-lifetime plan.
## Can we have separate Asyncify state for each stack?
Design B's answer is nuanced:
```text
Separate durable state per stack: yes.
Separate active Asyncify.state per stack: no, not needed.
```
Each scheduler context gets:
```text
own C stack/fiber stack
own asyncify_data buffer
own status/result/wait reason
```
But the Wasm instance still has one active transition at a time:
```text
Normal -> Unwinding -> Normal
Normal -> Rewinding -> Normal
```
That is fine. A single-threaded cooperative scheduler only needs one active transition. The
important thing is that parked contexts retain their own buffers while they are inactive.
## Why not just queue `handleSleep` calls?
Because a modal dialog must pump events while it is waiting.
A bad queue would say:
```text
modal sleep is active
do not allow any other suspension until modal resolves
```
That freezes the UI if the modal's own event pump needs to run tool callbacks or nested waits.
A good scheduler says:
```text
modal context is parked
other contexts may run
when modal resolves, resume it later when the transition slot is free
```
Design B makes that cleaner by representing all waits as scheduler waits, not arbitrary nested
`handleSleep` calls.
## Implementation approaches
There are two possible ways to implement Design B.
### B1: Fiberize wx waits on top of current libcontext
Keep the existing Emscripten fiber/libcontext machinery. Add a small runtime API:
```cpp
using WAKE_TOKEN = int;
WAKE_TOKEN wasm_begin_async_wait(...);
int wasm_yield_until(WAKE_TOKEN token);
void wasm_resolve_wait(WAKE_TOKEN token, int result);
```
Then implement modal/clipboard/font/nested-loop waits through that API.
Rough flow:
```text
C++ starts async JS operation
JS operation gets token
C++ yields current fiber/context
JS resolves token later
scheduler resumes waiting context
```
This approach keeps most of KiCad's coroutine system. The risk is making sure every caller is on
a scheduler-owned context when it tries to wait. If code on the raw main stack calls a wait, the
runtime must either fiberize the main stack first or reject with a clear diagnostic.
### B2: Make the whole app run inside a managed root fiber
Instead of only tool coroutines being fibers, start KiCad inside a root managed fiber. Then even
"main stack" waits are scheduler-owned.
Conceptual startup:
```text
JS starts runtime
runtime creates root app fiber
root app fiber calls main/wxEntry
root app fiber yields when OnRun starts browser loop
browser loop/scheduler owns future resumes
```
This is cleaner but more invasive. It makes the top-level main loop, modal waits, tool
coroutines, and nested loops all part of one runtime from the beginning.
If we were designing from scratch, this is probably the architecture. In an existing port, it is
a bigger migration.
## How this compares to Design A
| Question | Design A: JS arbiter | Design B: fiber-first runtime |
|---|---|---|
| Keeps current `EM_ASYNC_JS` waits? | Yes | Mostly no |
| Requires de-parking main? | Only if diagnostic proves needed | Probably yes for the clean version |
| Touches wx app lifetime? | Maybe | Likely |
| JS shim complexity | Medium/high | Medium |
| C++ runtime changes | Low/medium | High |
| Conceptual cleanliness | Good | Best |
| Migration risk | Lower | Higher |
| End state | Two suspension families under one arbiter | One scheduler-owned suspension family |
## Failure modes
### Code waits outside a managed context
If a blocking-looking API is called on a stack the scheduler does not own, it cannot safely yield.
Mitigation:
- initialize a root app fiber early, or
- detect unmanaged waits and abort with a diagnostic in development builds.
### Lifetime cleanup runs too early
If de-parking lets `wxEntryReal()` continue into cleanup, the app can be destroyed while the
browser loop still runs.
Mitigation:
- make browser loop own app lifetime,
- defer `OnExit()`/`wxEntryCleanupReal()` to page unload or explicit app exit,
- ensure `emscripten_cancel_main_loop()` and cleanup are ordered.
### Reentrancy bugs become visible
When waits become scheduler-managed, the browser can continue running other events while a
context is parked. That is correct, but C++ code may have assumed certain globals cannot change
while a "blocking" call is waiting.
Mitigation:
- tests for out-of-order resolution,
- clear "modal blocks this parent/window" rules,
- keep wx's modal/quasi-modal disabling semantics intact.
### Starvation
If the scheduler always resumes newly ready contexts first, an older parked context could wait
too long.
Mitigation:
- FIFO ready queue by default,
- priority only for paint/input if proven necessary,
- diagnostics for context age.
## Test matrix
Design B should pass everything Design A needs, plus root-context tests:
- app startup inside managed root fiber
- top-level main loop starts without orphaning Asyncify state
- `ShowModal()` from root context
- `ShowModal()` from tool coroutine
- `ShowQuasiModal()` from tool coroutine
- clipboard read from root context
- clipboard read from tool coroutine
- font enumeration during startup
- nested modal inside quasi-modal
- exit/unload cleanup after parked contexts exist
Each test should assert:
- no crash
- no hang
- correct return value
- app remains interactive after the wait
- cleanup does not run during steady-state main-loop pumping
## Why this is elegant
Design B is elegant because it makes the runtime model match the actual problem:
```text
KiCad is a synchronous C++ GUI app.
The browser is asynchronous.
Therefore the port needs a stackful cooperative scheduler.
```
Once we accept that, modal dialogs, clipboard operations, font enumeration, nested event loops,
and tool coroutines are not different species. They are all contexts that sometimes wait.
The universal rule becomes:
```text
No API directly owns Asyncify.
APIs ask the scheduler to park or wake contexts.
The scheduler alone performs Asyncify transitions.
```
That is the clean architecture.
## Why this may be too much as the first fix
It asks us to change more than the bug requires:
- modal implementation,
- clipboard implementation,
- font enumeration,
- nested event loops,
- possibly app startup and shutdown,
- root stack ownership.
That is a lot of surface area for a port that already has working pieces.
So the pragmatic path is often:
```text
1. Build Design A arbiter.
2. Use tests to locate remaining architectural pain.
3. Migrate high-risk waits toward Design B over time.
4. De-park/root-fiber the app only when the evidence says it is necessary or worth the cleanup.
```
## Decision point
Choose Design B if we want the clean long-term runtime architecture and are willing to touch
wx/KiCad lifetime boundaries.
Choose Design A first if we want to stabilize the current port and learn exactly which
suspension overlaps still fail.

View file

@ -0,0 +1,49 @@
# Asyncify `currData` contention in KiCad-WASM — research dossier
> **Status:** research / understanding only. No implementation has been chosen.
> Authored 2026-06. All line numbers are against the artifacts current at that time
> (`tests/apps/kicad/pcbnew.js`, `wxwidgets/src/wasm/*.cpp`,
> `kicad/thirdparty/libcontext/libcontext.cpp`, `wxwidgets/src/common/init.cpp`).
## Why this exists
While bringing up `tests/kicad/load-pcb.spec.ts` (load a real `.kicad_pcb` through
File→Open), three distinct errors surfaced. One is fixed; two remain and turned out to be
**the same underlying disease**: Emscripten Asyncify has a single global suspension register
(`Asyncify.currData` + `Asyncify.state`), but KiCad-WASM has **three independent subsystems**
that all drive it (tool coroutines, modal/clipboard sleeps, and the parked main loop). When any
two overlap, one reads a buffer that no longer belongs to it → **crash** (`index out of bounds`)
or **hang** (a swap unwinds but is never rewound).
## TL;DR
- **The single slot is not a WebAssembly/Binaryen law — it's a choice in Emscripten's JS
runtime.** The Binaryen Asyncify pass is multi-buffer by design; `asyncify_start_unwind`/
`asyncify_start_rewind` take the buffer pointer as an argument.
- **"Give each context its own buffer" is the standard, supported solution** — it's literally
what Emscripten *fibers* are, and what QEMU/TinyGo/Pyodide do. We already do it for tool
coroutines (each `wasm_fcontext` owns a buffer). The bug is that the **fiber path and the
`handleSleep` path both blindly overwrite the one global `currData` register.**
- **We are not switching to JSPI.** The fix stays within Asyncify, in the wasm/shim layer.
- **The achievable universal fix** is a single cooperative scheduler that owns `currData` (and
the fiber trampoline) and treats every suspendable thing — coroutines, modals, clipboard,
fonts, and the main loop itself — as a registered context with its own buffer.
## Document index
| File | Contents |
|---|---|
| [`01-background-and-findings.md`](01-background-and-findings.md) | The originating session, the e2e test + logs, and the three concrete bugs (rtree=fixed; clipboard crash; tool-open hang). |
| [`02-asyncify-internals.md`](02-asyncify-internals.md) | The machine, in detail: what "sleeps", the single `currData`/`state`, the three producers, `handleSleep`/`fiber_swap`/trampoline, the #9153 shim, and full control-flow walkthroughs (startup→park, the hang, the crash, **de-parking** down to the JS line). |
| [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md) | Is there a real solution? Per-context buffers, who has done it, why we're not switching to JSPI, and the unified-authority recipe with failure modes. Sources/URLs included. |
| [`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md) | How the fix options relate (what's subsumed vs. genuinely separate), the one diagnostic that decides scope, the combinatorial test matrix, and open questions. |
| [`05-design-a-js-asyncify-arbiter.md`](05-design-a-js-asyncify-arbiter.md) | Incremental design: keep current `EM_ASYNC_JS` sleeps and fibers, but put one JS arbiter in charge of `currData`, transition queueing, and the trampoline. Includes concept explanations. |
| [`06-design-b-fiber-first-runtime.md`](06-design-b-fiber-first-runtime.md) | Cleaner long-term design: make modals, clipboard, fonts, nested loops, and tools all scheduler-owned fiber-like contexts. Explains how this relates to de-parking and app lifetime. |
## The single decisive next step
Before designing anything, **measure whether `Asyncify.currData` is clean (null) at the moment
`wxGUIEventLoop::DoRun()` throws `"unwind"`** (and at the first rAF tick, and at the first
post-startup `emscripten_fiber_swap`). That one fact determines whether the universal fix must
also reshape the main loop ("de-parking") or whether a per-context `currData` authority alone
suffices. Details in [`04-decisions-tests-open-questions.md`](04-decisions-tests-open-questions.md).

View file

@ -0,0 +1,401 @@
# Research dossier: Asyncify `currData` contention in KiCad-WASM — the crash *and* the hang
> **This is a research/understanding document, not an implementation plan.** Its job is to
> make the whole machine legible: what suspends ("sleeps"), what doesn't, who owns the one
> global Asyncify slot, why the main loop is "parked," and exactly what "de-parking" would
> change. Fix options are listed at the very end as *options*, not steps.
All line numbers are against the currently-built artifacts:
`tests/apps/kicad/pcbnew.js` (generated JS runtime), `wxwidgets/src/wasm/*.cpp`,
`kicad/thirdparty/libcontext/libcontext.cpp`, `kicad/include/tool/coroutine.h`,
`wxwidgets/src/common/init.cpp`.
---
## 0. The one-paragraph model
Emscripten Asyncify has **two global registers**: `Asyncify.state`
(`Normal=0 / Unwinding=1 / Rewinding=2`) and `Asyncify.currData` (a pointer to *the* save
buffer). They describe **"the single suspension currently in flight."** The runtime assumes
**at most one** suspension is live and that it **fully rewinds before the next begins.**
KiCad-WASM breaks that assumption because **three independent subsystems drive those same
two registers**: tool coroutines (libcontext → `emscripten_fiber_swap`), modal dialogs +
clipboard (`EM_ASYNC_JS``handleSleep`), and the parked main loop. When any two overlap on
the single slot, one of them reads a buffer that no longer belongs to it. Depending on *who
clobbers whom*, you get either a **crash** (`doRewind` on a null/garbage buffer →
`index out of bounds`) or a **hang** (a swap unwinds, but its rewind is never issued, so the
target never runs). The clipboard bug and the tool-open hang are the **same disease**.
---
## 1. Vocabulary: what "sleeps" and what does not
"Sleep" here = an **Asyncify suspension**: the wasm stack is *unwound* into a buffer, control
returns to JS, JS does work, then the stack is *rewound* and execution resumes exactly where
it left off. The three primitives `_asyncify_start_unwind / stop_unwind / start_rewind /
stop_rewind` (`pcbnew.js:15532-15538`) are the only wasm exports involved; **all scheduling
is done by JS glue.** C++ never writes `currData`/`state` — it only spills/restores locals
when the JS-driven state says to.
| Call | Sleeps? | Mechanism | Where |
|---|---|---|---|
| `emscripten_fiber_swap` (tool coroutine swap) | **YES** | unwind source fiber + rewind target fiber | `pcbnew.js:11557` |
| `startModal` (`wxDialog::ShowModal`) | **YES** | `EM_ASYNC_JS``handleSleep`, awaits a Promise | `dialog.cpp:201` |
| `js_clipboardHasText` (old `IsSupported`) | **YES** | `EM_ASYNC_JS`, `readText()` raced vs 2 s timeout | `clipbrd.cpp:118` |
| `js_readTextFromClipboard` (paste) | **YES** | `EM_ASYNC_JS`, only on user gesture | `clipbrd.cpp` GetData |
| `js_enumerateFonts` | **YES** | `EM_ASYNC_JS` | `fontenum.cpp` |
| `js_isClipboardAPIAvailable` | **NO** | synchronous `EM_JS` capability probe | `clipbrd.cpp:29` |
| `ProcessEvents` (one main-loop tick) | **NO** by itself | plain C call on a fresh stack; only sleeps if something *inside* it does | `evtloop.cpp:19` |
| the rAF main-loop tick | **NO** | `requestAnimationFrame`/`setTimeout` re-enters wasm fresh each frame | `pcbnew.js:11342` |
| `emscripten_set_main_loop(...,1)` "infinite loop" | **NO** (not a sleep!) | `throw "unwind"` — a *plain JS exception*, not Asyncify | `pcbnew.js:11392` |
**The single most important correction:** the main loop's "park" is **not** an Asyncify
suspension. It is a thrown JS string. This matters enormously (§5).
---
## 2. The three suspension producers, in detail
### 2a. Tool coroutines = libcontext = emscripten fibers
KiCad runs interactive tools as coroutines (`COROUTINE` in `kicad/include/tool/coroutine.h`).
`Call`/`Resume`/`KiYield` switch stacks via `libcontext::jump_fcontext` (`coroutine.h:530`,
`:548`). On wasm, libcontext is **not** native assembly — it is a shim over emscripten fibers
(`libcontext.cpp`): `make_fcontext``emscripten_fiber_init` (`:271`); `jump_fcontext`
`emscripten_fiber_swap` (`:320`). So **"a tool fiber swap" is literally `emscripten_fiber_swap`,
which drives `Asyncify.currData`/`state`.** libcontext keeps its own `g_current_context`,
per-context `resume_epoch` (to detect "ghost resumes" where a swap returns without anyone
swapping back, `:323`), and a `[[noreturn]]` trampoline `wasm_fcontext_entry` (`:228`) that
loops forever so a finished coroutine swaps back instead of returning (a returning fiber would
end the whole program). `KICAD_DIAG_COROUTINE` (`kicad/include/kicad_wasm_diag.h`) logs every
`jump-enter / save-slot / jump-swap / jump-resume / jump-ghost / entry-call / trampoline-swap`
— this is the trace the prior session read.
### 2b. Modal dialogs (and the old clipboard) = EM_ASYNC_JS = handleSleep
`wxDialog::ShowModal` (`dialog.cpp:245`) must *block and return an `int`* (native semantics —
hundreds of KiCad sites do `if( dlg.ShowModal()==wxID_OK )`). A browser main thread cannot
block, so `startModal` (`dialog.cpp:201`) is an `EM_ASYNC_JS` that: suspends the C++ stack via
Asyncify, runs a `setTimeout(17ms)` loop that calls `ProcessEvents` so the UI stays live, and
resolves when `EndModal` (`dialog.cpp:286`) fires `Module._endModal(code)`. **The modal is,
structurally, a coroutine implemented on `handleSleep`.** The old clipboard
`IsSupported` (`clipbrd.cpp:288`) used the same `handleSleep` road via `js_clipboardHasText`.
### 2c. The main loop "park"
`wxGUIEventLoop::DoRun` (`evtloop.cpp:85`) ends with
`emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/1)` (`:107`).
Dissected fully in §4§5.
---
## 3. The Asyncify engine (the JS glue), exactly
### handleSleep — the EM_ASYNC_JS / sleep road (`pcbnew.js:10160`)
- First entry, `state==Normal`: call `startAsync(wakeUp)`. If `wakeUp` is **not** called
synchronously, a real suspend begins (`:10219`):
`state=Unwinding; currData = allocateData(); MainLoop.pause(); start_unwind()`.
→ the C stack unwinds; locals spill into `currData`'s buffer.
- Promise resolves → `wakeUp(result)` (`:10169`):
`state=Rewinding; start_rewind(currData); MainLoop.resume(); doRewind(currData)`.
`doRewind` reads **field #8 of the buffer** to learn *which exported function to re-enter*
(`getDataRewindFuncName`, `:10143`). **If `currData` is wrong or null here → reads garbage →
`RuntimeError: index out of bounds`.**
- Re-entry at `state==Rewinding` (`:10229`): `state=Normal; stop_rewind(); free(currData);
currData=null`. The slot is released.
> Note the **asymmetry**: the sleep road pauses/resumes `MainLoop` (`:10225`,`:10180`). The
> fiber road (below) does **not** touch `MainLoop`.
### fiber swap — the coroutine road (`pcbnew.js:11557`)
```js
function _emscripten_fiber_swap(oldFiber, newFiber) {
if (Asyncify.state === Asyncify.State.Normal) { // leaving a fiber
Asyncify.state = Asyncify.State.Unwinding;
var asyncifyData = oldFiber + 20; // OLD fiber's embedded buffer
Asyncify.setDataRewindFunc(asyncifyData);
Asyncify.currData = asyncifyData; // <-- writes the single slot
_asyncify_start_unwind(asyncifyData);
Fibers.nextFiber = newFiber; // schedule the rewind target
} else { // landing back via rewind
Asyncify.state = Asyncify.State.Normal;
_asyncify_stop_rewind();
Asyncify.currData = null;
}
}
```
The actual rewind of the *target* is deferred to **`Fibers.trampoline`** (`pcbnew.js:11522`),
which is invoked from **`maybeStopUnwind`** (`:10097`) once the unwind reaches the bottom
(`exportCallStack.length===0`, `:10098`) — note `maybeStopUnwind` also does
`runtimeKeepalivePush()` (`:10105`) "so a rewind can be done later":
```js
trampoline() {
if (!Fibers.trampolineRunning && Fibers.nextFiber) { // GUARD
Fibers.trampolineRunning = true;
do { var f = Fibers.nextFiber; Fibers.nextFiber = 0;
Fibers.finishContextSwitch(f); } while (Fibers.nextFiber);
Fibers.trampolineRunning = false; // only reached if body returns
}
}
finishContextSwitch(newFiber) { // the rewind half
... restore stack limits/pointer ...
if (entryPoint !== 0) { Asyncify.currData = null; dynCall_vi(entryPoint, userData); } // first run
else { var d = newFiber+20; Asyncify.currData = d; Asyncify.state = Rewinding;
_asyncify_start_rewind(d); Asyncify.doRewind(d); } // resume
}
```
**Two fragilities live here:**
1. `finishContextSwitch` re-enters wasm (`doRewind`/`dynCall_vi`). If that re-entered code
itself unwinds before returning, the `do/while` is abandoned with
`Fibers.trampolineRunning === true` (the `:11533` reset never runs). **Every future
`Fibers.trampoline()` then fails the guard and returns immediately** → pending `nextFiber`
never processed → **hang.** (This is the precise facet the pasted `try/finally`
"self-heal" addresses — it forces the guard back to `false`.)
2. fiber buffers come from `emscripten_fiber_init`, **not** `Asyncify.allocateData`, so the
`handlesleep.js` shim (§3a) is **blind to them**.
### The #9153 shim — what `handlesleep.js` actually is (`scripts/common/shims/handlesleep.js`)
`pcbnew.js` is **generated** (emscripten link output, then post-processed; it is committed but
overwritten by every build). The handleSleep override is **not** Emscripten's — it is our
shim, **source of truth `scripts/common/shims/handlesleep.js`**, injected verbatim into
`pcbnew.js` by `scripts/common/inject-dyncall-shims.sh` (`cat "$SHIM_DIR/handlesleep.js" >>`)
right after the `_emscripten_fiber_swap.isAsync = true;` marker (lands ~`pcbnew.js:11579`).
Build order (`docker/build.sh:123-156`): **link → inject-dyncall-shims.sh → apply-finalize.sh
→ apply-asyncify.sh.** `inject-dyncall-shims.sh` injects, in order: (1) per-signature
`dynCall_*` bindings, (2) six inline empty-callback fixes, (3) `handlesleep.js`, (4) optional
`diagnostics.js` (only with `SHIM_DIAGNOSTICS=1` — the source of the `[CLIP-DIAG]`/`[DIAG_SLEEP]`
log lines). **The trampoline self-heal is not present today.**
What the shim does: tag each `handleSleep` with the buffer it allocated, and in `wakeUp`
restore `Asyncify.currData = thatBuffer` right before `start_rewind`/`doRewind` — so a fiber
swap that clobbered the slot during the await doesn't make the sleep rewind the wrong buffer.
**It fixes exactly one level of nesting** and only for sleeps (not fibers).
---
## 4. CONTROL FLOW — startup, and how the loop becomes "parked"
```
run() ─► doRun() ─► callMain() pcbnew.js:21346
└─ entryFunction(argc,argv) == wasmExports["__main_argc_argv"] (C main)
└─ wxEntry ─► wxEntryReal() init.cpp:464
├─ wxTheApp->CallOnInit() (build UI, frames, tools…)
│ └─ [STARTUP BURST: tool coroutines Call/Yield/Resume run here.
│ Each is an emscripten_fiber_swap → currData churns Normal↔set↔null.
│ These WORK because main's real C stack is on exportCallStack,
│ so each unwind reaches bottom, trampoline fires, target rewinds.]
├─ class CallOnExit { ~CallOnExit(){ wxTheApp->OnExit(); } } callOnExit; init.cpp:488
└─ return wxTheApp->OnRun()
└─ MainLoop() ─► wxGUIEventLoop::DoRun() evtloop.cpp:85
└─ emscripten_set_main_loop(ProcessEvents,0,1) evtloop.cpp:107
└─ setMainLoop(...) pcbnew.js:11324
├─ _emscripten_set_main_loop_timing(1,1) :11387
│ └─ runtimeKeepalivePush(); MainLoop.running=true :11270
│ (★ runtime now stays alive even if main "exits")
├─ MainLoop.scheduler() → schedules first rAF tick
└─ if (simulateInfiniteLoop) throw "unwind"; :11392 ◄── THE PARK
```
The `throw "unwind"` propagates **as a plain JS exception** out through every wasm frame of
`OnRun/DoRun/...` (they are abandoned, *not* asyncify-saved, *no* C++ destructors run) up to:
```
callMain catch(e) ─► handleException(e) pcbnew.js:21362,1391
└─ e == "unwind" → return EXITSTATUS (swallowed silently) :1397
```
**Result of the park:**
- The native C stack of `main()` (and `wxEntryReal`, `OnRun`, `DoRun`) is **gone**.
- **`CallOnExit::~CallOnExit()` (→ `OnExit()`) and `wxEntryCleanupReal()` NEVER RUN** — the
app, frames, and tools stay alive. *This is the entire purpose of `simulate_infinite_loop=1`.*
- The runtime stays alive purely via the keepalive counter (★). Each rAF tick now re-enters
wasm fresh through `MainLoop.runner → runIter → callUserCallback(ProcessEvents)`
(`pcbnew.js:11342→11452→10003`) on a **brand-new C stack**. `ProcessEvents` (`evtloop.cpp:19`)
pumps `ProcessPendingEvents` + `Paint` + every-third `ProcessIdle`, then returns; the tick
ends; next rAF scheduled. **No sleep is involved in a quiet tick.**
---
## 5. CONTROL FLOW — the HANG (first tool interaction after startup)
A user gesture (the programmatic File→Open, or a `w` keystroke) makes `ProcessEvents` dispatch
into `TOOL_MANAGER`, which `Resume()`s a tool coroutine:
```
rAF tick ─► ProcessEvents ─► TOOL_MANAGER ─► coroutine->Resume()/Call()
└─ libcontext::jump_fcontext ─► emscripten_fiber_swap(old,new) currData = old+20; start_unwind
└─ unwind propagates out of ProcessEvents …
└─ maybeStopUnwind (exportCallStack==0?) ─► Fibers.trampoline()
└─ finishContextSwitch(new): currData=new+20; start_rewind; doRewind(new)
└─ tool body runs … yields/returns … swaps back to caller …
```
In a *healthy* world this round-trips and `currData` ends `null`. The empirical bug state the
prior session **measured at idle was `Asyncify.state==Normal` but `Asyncify.currData != null`**
— i.e. an unwind happened and **its rewind was never issued.** The swap "parks" forever; the
open never renders; *all* post-idle tool interactivity is dead (reproduced even on the
fileless `/p/mytest/eeschema` route by a `w` keystroke — so it is general, not open-specific).
**Why the *first post-startup* swap, specifically?** The two credible mechanisms (not mutually
exclusive), both rooted in §4's `throw "unwind"`:
1. **Dangling `currData` from the abnormal teardown.** The park abandons the C stack via a JS
throw **without** running `stop_unwind/stop_rewind` or resetting the Asyncify globals. If
the startup burst left an in-flight or half-settled fiber context at the moment `DoRun`
threw, `currData` stays non-null into idle. The next `emscripten_fiber_swap` enters the
`state==Normal` branch and **overwrites** `currData` with `old+20`, orphaning the dangling
buffer; when control later needs the orphaned context, its rewind can never be issued → hang.
2. **Stuck trampoline guard.** If any swap inside `Fibers.trampoline`'s `do/while` unwound and
never returned (§3, fragility #1), `trampolineRunning` is stuck `true`, so the first
post-startup swap's `nextFiber` is scheduled but the trampoline early-returns and never
rewinds it → hang.
> **Diagnostic that would disambiguate (cheap, no rebuild):** log `Asyncify.currData`,
> `Asyncify.state`, and `Fibers.trampolineRunning` (a) at the last line of `DoRun` *before* the
> throw, (b) on the first rAF tick, (c) at the entry of the first post-startup
> `emscripten_fiber_swap`. Compare `currData` against `g_main_context`'s buffer and against any
> live coroutine's `fiber+20`. That tells you which of #1/#2 (or both) is in play.
---
## 6. CONTROL FLOW — the CRASH (clipboard), for contrast
```
post-load idle ─► (wx paste-enable / GetClipboardUTF8) ─► wxClipboard::IsSupported(wxDF_TEXT)
└─ js_clipboardHasText (EM_ASYNC_JS) ─► handleSleep: currData=bufA; MainLoop.pause(); start_unwind
└─ PARKED up to 2 s awaiting readText() (headless ⇒ always full timeout)
├─ during the wait a modal tears down (EndModal:5100) ─► emscripten_fiber_swap
│ └─ currData = fiberF+20 ◄── clobbers bufA in the single slot
├─ 2nd/3rd clipboard polls stack up (log: pendingSleeps→3, one ENTER at state=2)
└─ bufA's Promise resolves ─► handleSleep wakeUp: start_rewind(currData=null/F)
└─ doRewind(null) ─► reads garbage field#8 ─► RuntimeError: index out of bounds
```
Same slot, opposite victim: here a long-parked **sleep** is clobbered by a **fiber swap**
(crash); in §5 a **fiber swap** is stranded by a dangling slot left by the **park** (hang).
---
## 7. "De-parking" in finest detail — the option you hadn't seen
**What "de-park" means:** change `evtloop.cpp:107` to
`emscripten_set_main_loop(ProcessEvents, 0, /*simulate_infinite_loop=*/0)`. Then `setMainLoop`
does **not** throw (`pcbnew.js:11391` skipped); it **returns normally** into `DoRun`, which
returns up the C++ stack. The Asyncify globals are left in the clean state ordinary C++ returns
produce (no abandoned unwind), which removes mechanism §5#1 at the source.
**The lifecycle problem it creates (and why `=1` exists):** if `DoRun` returns, the unwind of
the C++ stack runs the very teardown the park was hiding:
```
DoRun returns ─► OnRun returns ─► wxEntryReal:
├─ ~CallOnExit() ─► wxTheApp->OnExit() init.cpp:488
└─ wxEntry ─► wxEntryCleanupReal() init.cpp:433
├─ wxTheApp->CleanUp() (deletes ALL top-level windows, pending objects)
├─ delete app; (destroys wxTheApp) init.cpp:448
└─ DoCommonPostCleanup()
⇒ then C main returns ⇒ callMain: exitJS(ret, implicit=true) pcbnew.js:21360
└─ _proc_exit: keepRuntimeAlive()==true (keepalive ★) ⇒ does NOT abort :1383
⇒ runtime KEEPS RUNNING, rAF keeps firing ProcessEvents …
… but wxTheApp + all windows are already FREED ⇒ next tick touches freed memory
(this is also the null-`IsModal`/`windowClosing` close-path crash).
```
So the runtime survives (keepalive), but the app is torn down underneath the still-firing main
loop. **That is precisely the trap `simulate_infinite_loop=1` avoids** — not by keeping the
runtime alive (the keepalive counter already does that), but **purely by preventing the C++
cleanup from running.**
**Therefore a correct de-park is two coupled changes, not one:**
1. `emscripten_set_main_loop(..., 0)` so `DoRun` returns with clean Asyncify state, **and**
2. **suppress the destructive post-`MainLoop` teardown** so the live app isn't freed. Options
for (2), in ascending invasiveness:
- **(2a)** In the wasm `wxApp`/event-loop path, make the `OnRun``DoRun` return *not* fall
into `~CallOnExit`/`wxEntryCleanupReal` (e.g. a wasm-specific `OnRun` that returns through
a path which skips cleanup), and instead run cleanup from **`UnloadCallback`**
(`app.cpp:620`, registered `:694`) on `beforeunload`. This keeps teardown for real page
exit and removes it from the steady state.
- **(2b)** Keep `wxEntryReal` as-is but guard `wxEntryCleanupReal` / `OnExit` to be no-ops
while the rAF loop is still registered (a "main loop owns lifetime" flag), deferring the
real cleanup to unload.
- Either way, **the rAF loop must remain the sole owner of app lifetime**; `ScheduleExit`
(`evtloop.cpp:40`, `emscripten_cancel_main_loop`) becomes the one path that tears down.
**What de-parking does and does NOT fix:**
- **Fixes:** the dangling-`currData`-at-idle mechanism (§5#1) and the
null-`IsModal` close crash (the teardown no longer fires under the live loop).
- **Does not, by itself, fix:** the *fundamental* slot-sharing. Two genuinely overlapping
suspensions (a 2 s clipboard sleep crossed by a fiber swap; a modal whose nested
`ProcessEvents` drives a tool coroutine) still contend for one `currData`. De-parking removes
the *base occupant*; it does not make concurrent suspensions compose. The trampoline guard
facet (§5#2) is also independent — addressed by the self-heal, not by de-parking.
**Cost:** `evtloop.cpp` + `app.cpp`/`init.cpp` lifecycle change → **full wx rebuild + relink**
(~1015 min), and it touches app shutdown, so it needs the test matrix (§9) as a safety net.
The prior session tried a `CallAfter`-deferral of the call site first and reverted it — which
is useful evidence: deferring the *call site* didn't help, implying the problem is the
*parked-stack/abandoned-unwind topology*, not the timing of when the loop is installed.
---
## 8. Why we can't just "make the modal not sleep"
The modal must hand KiCad a **blocking `int ShowModal()`** (native semantics; rewriting the
call sites is a KiCad change, against policy). In a single-threaded browser the only ways to
"return later from a call that hasn't finished" are (a) block the thread — impossible, freezes
the tab — or (b) suspend the stack (Asyncify/fiber). So the modal is **necessarily** a
suspension. Re-homing it onto a fiber instead of `handleSleep` buys nothing (fibers are the
same `currData` machine). The only durable answer is to make suspensions **compose**, i.e. fix
the slot, not the modal.
---
## 9. How to enumerate "every possible case" as tests
A strong harness already exists: `tests/apps/standalone/coroutine*/` (nine probes:
`main/nested/nested_ex/embind/mainloop/gl/gl_pt/vcall/wxpt`) built by
`scripts/build-wasm-test.sh` via `tests/apps/Makefile.wasm` (each links with
`-sASYNCIFY=1 -sASYNCIFY_IMPORTS=['emscripten_fiber_swap']` then the same
`inject-dyncall-shims.sh`), plus `tests/e2e/coroutine-nested.spec.ts` (8 modal×fiber
scenarios) and `coroutine-pthread.spec.ts`, all asserting **no `index out of bounds`** and
polling a `SUMMARY total/passed/failed` line. Gaps: it doesn't systematically cover
*out-of-order* and *long-parked* overlaps, and it asserts crash-freedom but **not liveness**
(so it would not catch a hang).
Make it a **generated combinatorial product** and assert three outcomes per cell:
- **Primitives (cells):** `S1`=EM_ASYNC_JS sleep (modal/clipboard/font), `S2`=fiber swap (tool
coroutine), `S3`=parked main loop, `S4`=pthread boundary.
- **Overlap shape:** none / nested-LIFO / **interleaved out-of-order** / **long-parked outer**
(the 2 s clipboard shape). The last two are the under-tested ones.
- **Host context:** direct / rAF main-loop tick / embind dispatch / WebGL2 frame / deep stack /
`-fexceptions` invoke wrappers.
- **Resume target:** continuation / **virtual call** (`invoke_vi→dynCall_vi`, the `vcall_repro`
smoking gun).
- **Assert per cell:** (1) no `index out of bounds` / `indirect call to null` / `unwind`
rejection (no crash); (2) **completes within a timeout** (no hang — the missing assertion
today); (3) returned value correct (no silent wrong-buffer rewind).
- **Two must-add named scenarios** that pin our exact bugs deterministically:
`long_parked_sleep_clobbered_by_swap` (the clipboard crash) and
`fiber_swap_after_main_park` (the §5 hang — requires the harness to actually install a
`simulate_infinite_loop=1` main loop, then swap a fiber post-park).
---
## 10. Fix options (reference only — not a plan)
Same disease, four independent levers; they compose:
- **Stage 0 — synchronous clipboard `IsSupported`** (`clipbrd.cpp`): deletes the longest-lived
sleep (the 2 s idle read). Kills the **crash** class. Low risk, wx-layer.
- **Stage 1 — trampoline self-heal** (`try/finally` in `inject-dyncall-shims.sh`): forces
`Fibers.trampolineRunning=false` even when a swap unwinds mid-loop. De-wedges the **guard
facet** of the hang (§5#2). Cheap, JS-shim-only. Band-aid, not cure.
- **Stage 2 — de-park `main()`** (`evtloop.cpp` + `app.cpp`/`init.cpp`, §7): removes the
dangling-slot base occupant (§5#1) and the close crash. The structural cure for the hang.
Bigger; wx rebuild + lifecycle care.
- **Stage 3 — per-context `currData`** (generalize `handlesleep.js` into a real context stack
that also tracks fiber buffers): makes overlapping suspensions compose regardless of order.
Highest correctness/risk; only if the §9 matrix still shows overlap failures after 02.
- **Stage 4 — the combinatorial matrix (§9):** gates the riskier fixes.
---
## Open questions to resolve before any implementation
1. Run the §5 diagnostic — is the idle `currData` the orphaned-from-park buffer (#1), or is the
trampoline guard stuck (#2), or both? This decides whether Stage 1 alone meaningfully helps.
2. For Stage 2, which suppression shape (2a UnloadCallback-driven cleanup vs 2b guarded
`wxEntryCleanupReal`) is least invasive given our `wxApp`/`init.cpp` fork delta? (Check
`scripts/kicad-diff-stats.sh` and the current wx fork divergence first.)
3. Does Stage 0 (clipboard) by itself make the load-pcb route stop hanging, or only stop
crashing? (If the open hangs even with clipboard synchronous, Stage 2 is required, not
optional.)

View file

@ -0,0 +1,76 @@
# 01 — The two exception-handling models, and where ours bleeds into Asyncify
## What we build with today
Every layer is on **`-fexceptions`** = Emscripten's JavaScript-based EH:
- `scripts/build-wxuniversal-wasm.sh:141-142` (wxWidgets CFLAGS/CXXFLAGS)
- `scripts/kicad/build-kicad-target.sh:203/207/211/214` (KiCad, with the comment at `:196`:
"-fexceptions is required because wxWidgets is built with exceptions enabled")
- `tests/apps/Makefile.wasm` (standalone test apps)
No `SUPPORT_LONGJMP` is set anywhere → default `emscripten` flavor (JS-based setjmp/longjmp
through the same machinery).
## Model 1 — JS-based EH (`-fexceptions`), what actually happens
- A C++ `throw` compiles to a call out to JS (`__cxa_throw`), which throws a **JavaScript
exception** carrying an i32 pointer (the C++ exception object in linear memory).
- Every call that may need cleanup-during-unwind (anything inside a `try`, or in a function
with destructors on the unwind path) is routed through an **`invoke_<sig>` wrapper** — a
JS function that does `stackSave()`, calls back into the wasm table inside a JS
`try/catch`, and on catch runs `stackRestore(sp)` + `_setThrew(1)` so the wasm side can
run destructors and re-dispatch. The generated `pcbnew.js` contains **1,049 `invoke_`
references**.
- Consequence: a "C++ call stack" is really a **wasm→JS→wasm→JS sandwich**, and C++
exceptions physically travel through JS frames.
## Model 2 — native wasm-EH (`-fwasm-exceptions`)
- `try`/`catch`/`throw` are **wasm instructions** (the exception-handling proposal;
standardized, supported in all modern browsers — note the legacy encoding vs the newer
`exnref`/`try_table` encoding, controlled by Emscripten's `WASM_LEGACY_EXCEPTIONS`,
default legacy as of this writing).
- No `invoke_*` wrappers exist at all. Throws never enter JS. The non-throwing path of a
`try` is ~zero-cost in modern engines. `SUPPORT_LONGJMP=wasm` rides the same instructions.
## The three concrete couplings into our Asyncify machine
**1. JS-EH is why Asyncify instruments almost everything.**
`scripts/common/apply-asyncify.sh:33`:
```
ASYNCIFY_IMPORTS="env.invoke_*,env.__asyncjs__*,env.emscripten_fiber_swap"
```
`env.invoke_*` is listed because suspensions genuinely flow through invokes (a modal opened
inside a parser's `try`, a tool yielding inside a try-scope). But every invoke is an import
call, so marking them suspension-capable breaks the static call graph at ~1,049 places and
the "can this suspend?" analysis answers *yes* for most of KiCad → near-total
instrumentation. This is the root of: the 338 MB pre-O2 binary, the V8 per-function-locals
silent-stall family (see memory/`chrome-asyncify-rewind-crash`), the `ASYNCIFY_REMOVE`
list, and the mandatory post-asyncify `wasm-opt -O2` pass. Under wasm-EH the imports list
shrinks to `env.__asyncjs__*,env.emscripten_fiber_swap` and instrumentation collapses to
the genuine suspend chains.
**2. JS-EH is why our wasm stacks have JS frames in the middle.**
Every asyncify unwind must early-return through, and every rewind must re-establish, the
invoke sandwich layers. The async dossier's test matrix calls out "`-fexceptions` invoke
wrappers" as a failure axis and `vcall_repro` (`invoke_vi``dynCall_vi`) as the smoking
gun. Phases 12 of `scripts/common/inject-dyncall-shims.sh` (per-signature dynCall
bindings, empty-callback fixes) exist substantially to keep this machinery alive after
asyncify+O2.
**3. JS-EH is why C++ throws can interact with the asyncify JS runtime at all.**
Under JS-EH a C++ exception *is* a JS exception: it can tear through
`Fibers.trampoline()`'s `do/while` exactly like the `throw "unwind"` park does (the §3c
trampoline self-heal in `inject-dyncall-shims.sh` guards that), and the invoke wrapper's
catch performs `stackRestore(sp)` — a stack-pointer write from a JS frame — while fibers
juggle the same stack pointer. Under wasm-EH this entire interaction class disappears.
## What wasm-EH does NOT change
The Asyncify *correctness* problems (single global `Asyncify.currData`, the crash/hang
family in `docs/features/async/`) are identical under either EH mode. Modals, clipboard, fonts,
and fiber swaps still suspend through the same register. wasm-EH is a de-bloating /
de-fragilizing move, not a concurrency fix. The arbiter is needed either way.

View file

@ -0,0 +1,93 @@
# 02 — Measurements: our controlled experiment + published benchmarks
## §1. Our experiment (pcbnew, 2026-06-10)
**Question:** how much of the asyncify size tax is invoke-driven (i.e., would vanish under
wasm-EH)?
**Method:** take the saved pre-asyncify artifact `output/pcbnew.finalized.wasm` (111 MB,
post-link, post-finalize, pre-asyncify) and run the production post-link pipeline
(`apply-asyncify.sh`: `wasm-opt --asyncify` + `wasm-opt -O2`, Binaryen v121) three ways,
into `output/measure/` (originals untouched):
1. **O2-only** (no asyncify) — the floor; *not a shippable config* (the app needs asyncify).
2. **Asyncify, `env.invoke_*` dropped** from the imports list — the wasm-EH proxy.
3. **Asyncify, full imports** — the shipped configuration (control).
Control sanity check: variant 3 reproduced the production `pcbnew.wasm` size exactly
(187 MB), so the pipeline replication is faithful.
**Results:**
| Variant | Raw | gzip -9 |
|---|---|---|
| O2-only floor | 77.6 MB | 19.8 MB |
| Asyncify w/o invokes (wasm-EH proxy) | 122.1 MB | 36.0 MB |
| Asyncify full (shipped) | 187.3 MB | 64.5 MB |
**Decomposition:**
- Total asyncify tax today: **+109.7 MB raw = +141% over floor** (+44.7 MB gzip). That is
~2× the worst-case figures in the published literature — KiCad is an extreme asyncify
case, and the invoke graph is why.
- **Invoke-driven share of the tax: 59% raw (65.2 MB), 64% gzip (28.5 MB).** This is the
part wasm-EH eliminates.
- The remaining no-invoke tax (+44.5 MB, +57% over floor) is the legitimate cost of the
real suspend chains (fibers + modal/clipboard sleeps) — in line with the published ~50% —
and is kept under any design.
**Headline:** wasm-EH ≈ **download 64.5 → 36.0 MB (44%), module 187 → 122 MB (35%)**,
before counting runtime. The real wasm-EH build differs from the proxy in both directions:
it also deletes the invoke wrappers + JS-EH glue outright (smaller still) while adding
wasm-EH's own try/catch instructions (~+4% per published prototype data).
**Caveats:** (a) the no-invoke proxy is **semantically unsound under `-fexceptions`**
with invokes present in JS but uninstrumented in wasm, the first suspension crossing a try
frame corrupts state; measurement artifact only, never ship. (b) The finalized input is a
May 26 snapshot while today's apps are newer — irrelevant for the *relative* comparison
(same input, same tool, only the imports list varies). (c) Runtime gain is not directly
measurable from these artifacts; the size-tracks-speed heuristic (Zakai) plus the published
reports below are the basis for the runtime estimate.
## §2. Published benchmarks and field reports (researched 2026-06-10)
No clean head-to-head JS-EH vs wasm-EH benchmark of a large C++ app exists; triangulate:
**Asyncify instrumentation tax (the thing our invoke graph inflates):**
| Source | Workload | Numbers |
|---|---|---|
| Alon Zakai, "Pause and Resume… Asyncify" (2019, canonical) | multiple benchmarks | size ~**+50%** typical, ≤2× worst; speed tracks size; **22%** Fannkuch; **5×** pathological when one huge function dominates (SQLite 150→300 KB) |
| wa-sqlite (production, measured) | SQLite wasm | default asyncify **+70.6%** → **+35.3%** after narrowing instrumentation to the ~6 actually-async entry points (auto-detection had instrumented 700+ functions) |
| Wasmer PHP/WordPress (production) | PHP setjmp/longjmp via asyncify | **1.5× size, 2× slower**; replacing with wasm-EH SjLj: **2×** faster cold start (120→60 ms), separately **4×** (100→25 ms) |
| WordPress Playground | auto-detected asyncify list | ~70,000 functions instrumented, startup +4.5 s, fixed by manual list |
**JS-EH (`invoke_*`) tax:**
| Source | Workload | Numbers |
|---|---|---|
| OpenCascade.js (CAD kernel — closest cousin to KiCad's code character) | STEP file load | global JS-EH **~10 min → ~2 min (~5×)** with narrowed EH; "performance degradation and file size impact is currently massive" |
| emscripten discussion #17526 | JS-EH → wasm-EH | "not just 2× faster, but an order of magnitude" (anecdote) |
| Emscripten docs (Box2D) | `-fno-rtti -fno-exceptions` | 15% size (RTTI+EH combined; only weak isolation) |
**wasm-EH's own cost (what we'd pay instead):**
| Source | Numbers |
|---|---|
| Heejin Ahn, TPAC slides (prototype) | **+4%** whole-module instructions, **+11%** in exception-using functions |
| V8 / Emscripten docs | non-throwing path **~zero cost** by design |
**Most transferable to us:** wa-sqlite's 70.6→35.3 (the narrowing effect — ours measured
5964%) and OpenCascade's ~5× (exception-dense C++ file loading — our `LoadBoard` path).
Triangulated runtime expectation for KiCad: **1.53× on load/parse/geometry paths**, more
on exception-dense I/O paths.
## §3. Reproducing
```sh
# control (uses the project script unmodified):
./scripts/common/apply-asyncify.sh output/pcbnew.finalized.wasm output/measure/pcbnew.asyncify-full.wasm
# proxy: same script with ASYNCIFY_IMPORTS="env.__asyncjs__*,env.emscripten_fiber_swap"
# floor: $(scripts/common/get-wasm-opt.sh | tail -1) -O2 in.wasm -o out.wasm
# then: ls -l + gzip -9c | wc -c
```

View file

@ -0,0 +1,81 @@
# 03 — Toolchain compatibility status (verified 2026-06-10/11)
## The compatibility matrix
| Combination | Status |
|---|---|
| `ASYNCIFY=1` + `-fexceptions` (ours today) | Supported; the expensive-but-working baseline |
| `ASYNCIFY=1` + `-fwasm-exceptions` | emcc emits a **warning, not an error** ("Parts of the program that mix ASYNCIFY and exceptions will not compile"); Binaryen support is **partial** — everything except unwinding from inside a catch arm (see 05) |
| `SUPPORT_LONGJMP=emscripten` + `-fwasm-exceptions` | **Hard error** in emcc; wasm-EH pairs with `SUPPORT_LONGJMP=wasm` (automatic default) |
| JSPI (`-sJSPI`) + `-fwasm-exceptions` | Compatible (JSPI doesn't transform the wasm) — **but fibers do not exist under JSPI** (`Asyncify.setDataHeader is not a function`, emscripten #18180), and KiCad tools are fibers → JSPI stays closed for us |
| Mixing `-fexceptions` and `-fwasm-exceptions` objects at link | Not supported; produces inconsistent internal state (emscripten #20165, #18500) — flag flip must be uniform across wx + kicad + test apps |
## Binaryen history (the part that moved since our earlier research)
- **Partial asyncify+wasm-EH support merged upstream 2025-11-19**, commit `ad13362b`
"Add partial support for -fwasm-exceptions in Asyncify (#5343) (#5475)" — these are the
caiiiycuk PRs from 2022/2023 (the author previously maintained the
`caiiiycuk/binaryen-fwasm-exceptions` fork). Shipped in **binaryen v125**
(released the same day). Upstream is at **v130** (2026-06-01).
- The merge is **+114 lines** in `src/passes/Asyncify.cpp` plus 1,139 lines of lit tests
(`asyncify_pass-arg=asyncify-eh*.wast`): Try-body traversal + an asserts-mode tripwire.
Details and the remaining hole in [`05-asyncify-fork-design.md`](05-asyncify-fork-design.md).
- Notable gaps in current main: the documented `asyncify-ignore-unwind-from-catch` pass-arg
is **not consumed anywhere in the code** (dead docs); **`TryTable` (standardized
exnref EH encoding) is entirely unsupported** by the pass; tail calls remain fatal.
- Tracking issue for full support: **WebAssembly/binaryen #4470** (open).
## Our local toolchain
- emsdk-bundled `wasm-opt`: **v121** (`version_121-72-g7353da707`) — verified **612 commits
behind** the EH merge. We do not have even the partial support locally.
- This only matters for the **post-link step**: `scripts/common/apply-asyncify.sh` resolves
its binary via `scripts/common/get-wasm-opt.sh`, so a newer/patched wasm-opt can be
slotted in for asyncify alone, without touching the emsdk compiler side.
(`apply-finalize.sh` keeps using the emsdk `wasm-emscripten-finalize`; cross-version
binary compatibility at the .wasm level is fine.)
- The compiler side (LLVM emitting wasm-EH instructions) has been mature for years; the
emcc warning about ASYNCIFY=1 is expected and survivable once the Binaryen side is fixed.
## setjmp/longjmp inventory (often raised as a blocker — it is not)
- KiCad: **zero** direct `setjmp`/libpng usage outside `thirdparty/` (and `thirdparty/`
does not bundle libpng). All image I/O goes through `wxImage`.
- wxWidgets bundles `png`, `jpeg`, `tiff`, `zlib`. Both libpng's `pngerror.c` and wx's own
PNG handler use the classic pattern (`src/common/imagpng.cpp:319/:527` `setjmp`, `:204`
`longjmp`); libjpeg's error manager likewise.
- Today this rides JS-based SjLj (default `SUPPORT_LONGJMP=emscripten`, invoke machinery).
Under wasm-EH it becomes `SUPPORT_LONGJMP=wasm` automatically, **no source changes**.
Constraints don't bite: setjmp is not called from C++ catch clauses there, and the
PNG/JPEG decode paths never suspend (pure computation → never asyncify-instrumented →
the unwind-from-catch limitation cannot arise in them).
## The parked end-to-end experiment (2026-06-11) — what it taught us
A parallel session actually built pcbnew with wasm-EH end-to-end
(`docs/wasm-exceptions-experiment.md`; full plumbing patch in its appendix, gated behind
`KICAD_WASM_EH=1`). Findings that supersede/extend the matrix above:
- Working flag set: `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0`,
uniformly on **every compile and link** (deps incl. boost/cairo/harfbuzz, wx, kicad).
- Result: links clean, **raw pcbnew 92 MB** (vs 338 MB post-asyncify pre-O2 today), zero
`invoke_*`/`dynCall` — corroborates this dossier's measurements from the compiler side.
- **Blocker #1 (before the catch issue ever arises): emscripten 4.0.2's LLVM emits an
invalid `br_table`** (label arity mismatch) in OpenCASCADE
(`ShapeUpgrade_SplitSurface::Build`) under wasm-EH — module malformed at the source; no
Binaryen can fix it. Resume = emsdk bump (5.0.7+), then full clean rebuild (~2.53 h).
- **Encoding wrinkle:** 4.0.2's *legacy* encoding output failed Binaryen parsing
(`popping from empty stack`), forcing `WASM_LEGACY_EXCEPTIONS=0` (exnref). But the
asyncify pass has **zero `TryTable` support** (05 §2) — so after the emsdk bump, the
encoding decision forks the asyncify work: legacy → v125 partial support + the
catch-arm-hoisting pre-pass; exnref → TryTable support + exnref spilling (05 §new-EH).
- Ops gotchas recorded there: deps scripts skip-if-stamped (flag changes don't trigger
rebuilds — `undefined symbol: emscripten_longjmp` from stale `libcairo.a`); CI already
self-builds Binaryen v130 for the post-link chain; build-time payoff is also large (the
~52 min `-O2` pass shrinks with the module).
## Browser support notes
wasm-EH (legacy encoding) is supported across modern Chrome/Firefox/Safari. One watch item
from research: a Safari 26.0 regression crashing `-fwasm-exceptions` apps at startup was
reported September 2025 (emscripten #25365) — re-check status at migration time.

View file

@ -0,0 +1,75 @@
# 04 — The KiCad catch-block audit
## Why an audit
Binaryen's asyncify cannot (yet) handle a suspension that begins while execution is inside
a wasm catch handler (see 05). Any C++ `catch` whose handler (directly or transitively)
opens a modal dialog, touches the async clipboard, etc., is therefore illegal under
`-fwasm-exceptions` + ASYNCIFY=1 today. KiCad's standard error pattern is exactly that:
```cpp
catch( const IO_ERROR& ioe )
{
DisplayErrorMessage( this, ioe.What() ); // → ShowModal → Asyncify suspension
}
```
## Method
[`catch_audit.py`](catch_audit.py): walks `kicad/**/*.cpp` (excluding `thirdparty/`, `qa/`),
extracts every catch block with real brace matching (string/comment aware), and classifies
each handler body:
- **direct_suspend** — contains a known suspending call (`DisplayError[Message]`,
`DisplayInfoMessage`, `wxMessageBox`, `ShowModal`, `ShowQuasiModal`, `KIDIALOG`,
`IsOK(`, clipboard ops, `wxFileDialog`, …).
- **infobar** — only `ShowInfoBar*` (non-modal, non-suspending).
- **trivial** — only rethrow / capture / format / logging. Note `wxLogError` is *safe*:
its GUI display is deferred to the idle-time log flush, outside any catch.
`PGM_BASE::HandleException` (`common/pgm_base.cpp:805`) was manually verified — it only
`wxLogError`s → benign.
- **needs_review** — calls functions not classifiable as benign; requires a transitive look.
Full output incl. all site locations: [`audit-results.txt`](audit-results.txt).
Re-run: `python3 catch_audit.py` (path to the kicad tree is hardcoded at the top).
## Results (2026-06-10, kicad @ wasm-port head)
| Category | Count |
|---|---|
| **direct_suspend** | **85** |
| needs_review | 93 (tail looks mostly benign — accessors/file ops; expect ~1020 to become refactors on inspection) |
| trivial/safe | 458 |
| infobar-only | 0 |
| **total** | **636** |
Per app (direct/review): eeschema 37/17, pcbnew 32/29, common 9/35, cvpcb 3/1, rest
scattered. Concentrated exactly on the file-load error paths the e2e tests exercise:
`pcbnew/files.cpp`, `eeschema/files-io.cpp`, `footprint_libraries_utils.cpp`,
`symbol_library_manager.cpp`, the design-block utils.
wxWidgets adds essentially nothing (zero catch-with-dialog sites in its own code).
## The hand-refactor option (superseded by the fork, kept for the record)
Mechanical hoist per site:
```cpp
wxString err;
try { ... }
catch( const IO_ERROR& ioe ) { err = ioe.What(); } // capture only
if( !err.IsEmpty() ) DisplayErrorMessage( this, err ); // suspend OUTSIDE the catch
```
Effort: 85 hoists (~1530 min each; error-UX paths with near-zero test coverage) ≈ 35
dev-days; 93 reviews ≈ 23 dev-days; destructor-during-unwind audit ≈ half day (cleanup
pads = `catch_all`; dialogs from destructors expected zero); **plus** permanent
upstream-sync policing (every KiCad merge adds new `catch { DisplayError }` sites —
`catch_audit.py` as a CI gate automates detection), **plus** the fork-divergence cost
against the "stay close to upstream" policy. Total ~23 weeks one-time + maintenance tax,
with hard-crash failure modes for any missed transitive site (asyncify asserts mode traps
deterministically — good in CI, fatal in production).
**Verdict:** with the catch-arm-hoisting fork (05) this entire refactor becomes
unnecessary — all 85 direct sites are C++ catches, which the fork makes legal. Only
suspend-inside-`catch_all`-cleanup remains forbidden, which KiCad does not do.

View file

@ -0,0 +1,170 @@
# 05 — Asyncify.cpp internals and the catch-arm-hoisting fork design
> Based on a direct read of `src/passes/Asyncify.cpp` @ binaryen main (2,030 lines,
> 2026-06-11) and the merged partial-support commit `ad13362b` (+114 lines + 1,139 test
> lines, shipped in v125).
## §1. How the pass works (the 60-second model)
From the header comment (lines 5697): three states (`__asyncify_state` =
Normal/Unwinding/Rewinding).
- **Unwind:** every instrumented call is followed by
`if (unwinding) { note call index; save locals; return }` — the stack collapses via
ordinary early returns, recording a path of call indices into the asyncify buffer.
- **Rewind:** re-enter the function from the top and *fall forward*: every non-call
statement is wrapped `if (state == Normal)` (skipped while rewinding); every call in
`if (normal OR call-index-matches)` — control "skips along" to the suspended call and
re-enters callees the same way. Locals are restored from / spilled to linear memory
(`AsyncifyLocals`).
- Pipeline (per instrumented function): `flatten``dce` → optimizers → `AsyncifyFlow`
(call-index + skip logic) → [asserts walkers] → `AsyncifyLocals` (spill/restore).
## §2. What the v125 "partial support" actually is
- `AsyncifyFlow` traverses a `Try`'s **body** like any control flow (suspending inside a
try body works), but **`catchBodies` are skipped wholesale** — line ~1172:
*"catchBodies are ignored because we assume that pause/resume will not happen inside
them."* Handler code gets no call indices, no unwind checks, no rewind steering.
- Asserts mode adds `AsyncifyUnwindWalker` (lines ~13581420): every call lexically inside
a catch arm (found via the expression stack: inside a `Try` but not in its `body`) is
wrapped with `if (state != Normal) unreachable` — a **tripwire, not support**. Without
asserts, a suspend inside a catch silently corrupts (callee early-returns with
state=Unwinding; the uninstrumented handler barrels on with a garbage value).
- Dead doc: the header documents `--pass-arg=asyncify-ignore-unwind-from-catch`, but the
flag is **never consumed** in current main.
- **`TryTable` (standardized exnref EH) is not handled at all** — it would hit
`WASM_UNREACHABLE("unexpected expression type")` in the flow walker.
## §3. Why catch arms are structurally hard (not just unimplemented)
Rewind steers with plain `if`s and forward motion. A wasm **catch arm cannot be entered by
falling into it** — only the engine's exception dispatch transfers control there. So
"rewind back into the middle of a catch handler" is impossible in this scheme: you would
have to re-throw, which would re-run (or mis-skip) the try body in rewind state. Separately,
the in-flight caught-exception state (what `rethrow` consumes) is not something asyncify
can save to linear memory.
## §4. The fork: catch-arm hoisting (outlining)
Stop needing to re-enter catch arms — make the handler ordinary code. A pre-transform on
structured IR (before `flatten`), applied only to instrumented functions whose catch arms
contain potentially-state-changing calls (the `ModuleAnalyzer` already knows):
```wat
(try (do BODY)
(catch $__cpp_exception HANDLER)) ;; payload: i32 exception ptr
;; becomes:
(block $done
(try (do BODY)
(catch $__cpp_exception ;; arm now contains ONLY:
(local.set $exn (pop i32)) ;; capture payload
(local.set $inCatch (i32.const 1)))) ;; set flag — nothing suspendable
(if (i32.eqz (local.get $inCatch)) (br $done))
HANDLER') ;; hoisted: plain straight-line code
```
After this, the existing machinery does everything for free: `HANDLER'` gets call indices,
unwind checks, and rewind steering like any other code; `$exn` is an ordinary i32 local, so
`AsyncifyLocals` spills/restores it across suspension automatically. The upstream invariant
("no pause/resume inside catchBodies") becomes true **by construction**. The `br $done`
guard is state-aware, so during rewind control falls into `HANDLER'` and the call-index
peek steers to the suspended call.
### The rethrow problem and its translation
LLVM's C++ lowering places a `rethrow` in catch arms on the personality **no-match** path
(user-level `throw;` compiles to a `__cxa_rethrow()` *call* — no instruction issue).
`rethrow` is only valid lexically inside a catch, so hoisted code must translate:
```
rethrow $T → throw $__cpp_exception (local.get $exn)
```
Sound for the C++ tag because the i32 payload *is* the exception identity; all libc++abi
state is driven by the `__cxa_*` calls, preserved verbatim (`__cxa_begin_catch` is only
called on the matched path, so the no-match translation does not disturb handler counts).
Known observable delta: an exception escaping to JS after such a re-throw is a fresh
`WebAssembly.Exception` (object identity / stack trace) — acceptable, document it.
### Hard limits (kept deliberately)
- Only tags whose payload fully identifies the exception are hoistable
(`__cpp_exception`'s i32 qualifies). **`catch_all` arms — LLVM cleanup pads
(destructors-during-unwind) and foreign-exception paths — cannot be hoisted** (no payload
to re-throw); they keep the asserts tripwire. This is the right boundary for KiCad: all
85 audited sites are C++ catches; suspending destructors-during-unwind is the case the
audit expects zero of.
- Make hoisting tag-configurable: `--pass-arg=asyncify-hoist-catch-tags@__cpp_exception`.
### Fiddly-but-tractable engineering list
- Tries with concrete result types: route arm values through a temp local.
- Nested tries inside hoisted handlers: recurse; resolve each `rethrow`'s target Try and
translate only those referring to hoisted-from tries (payload local per hoisted try).
- `delegate` arms: untouched by arm hoisting (they live on the body side), but must be
regression-tested.
- Label scoping: hoist to immediately-after-the-try (same enclosing blocks) so every outer
branch target stays in scope.
- Pipeline placement: run pre-`flatten`, per-function, only where the analyzer says a catch
arm can change state — keeps the transform off the 99% of tries that never suspend.
- Tests: extend the v125 lit tests (`asyncify_pass-arg=asyncify-eh*.wast`); binaryen's
fuzzer has asyncify support — use it.
### The new-EH (TryTable) variant, for later
Under `try_table`/exnref the handlers are *already* plain blocks outside the try — the
hoisted shape is the native shape. The hard part there is exnref liveness across
suspension (reference types cannot be stored to linear memory): park the exnref in an
auxiliary exnref table slot, save the slot index in the asyncify buffer, `throw_ref` from
the slot on the cleanup path. Worth doing when Emscripten flips `WASM_LEGACY_EXCEPTIONS`
off by default; until then the legacy hoisting fork is the pragmatic target.
**Reality check from the parked experiment (03 §experiment):** emscripten 4.0.2's
*legacy*-encoding output failed Binaryen parsing outright, so the end-to-end experiment
had to force `WASM_LEGACY_EXCEPTIONS=0` (exnref). Which variant of this design applies is
therefore decided **after the emsdk bump** that fixes the LLVM `br_table` bug: if newer
LLVM emits parseable legacy encoding → the §4 hoisting pre-pass; if exnref remains the
only viable encoding → this §TryTable variant (larger: TryTable traversal in
`AsyncifyFlow` + exnref spill machinery) becomes the required work. Budget accordingly
before committing to either.
## §5. Effort, deployment, upstreaming
**Key deployment insight (2026-06-11): this is a PRE-pass, not a fork of Asyncify.cpp.**
The hoisting is a standalone, semantics-preserving wasm→wasm rewrite on standard EH
constructs. Run it before stock `--asyncify` and the stock pass's assumption ("no
pause/resume inside catchBodies") holds by construction — zero changes to the asyncify
pass itself. It can even share one invocation (`wasm-opt --hoist-cpp-catches --asyncify
--pass-arg=… -O2` — passes run in listed order), avoiding an extra 111 MB roundtrip.
Three deployment grades:
1. **Zero-fork:** standalone native tool in our repo linking *stock* libbinaryen (C/C++
API has full EH expression support). Costs one extra parse/write roundtrip per app.
(`binaryen.js` is not viable — wasm32 4 GB ceiling vs our module sizes.)
2. **Additive build (recommended):** one new file `src/passes/HoistCppCatches.cpp` + two
registration lines; no existing binaryen code modified → rebases trivially.
`get-wasm-opt.sh` already has the path: `BINARYEN_BUILD_FROM_SOURCE=1` clones a tag and
builds wasm-opt (cached in `build-wasm/tools/`) — pointing the clone at our branch is a
one-line change.
3. **Upstream (end state):** because nothing in `Asyncify.cpp` changes, this is a pure
addition — file the design on binaryen #4470 first; if accepted, grade 2 collapses
into stock wasm-opt.
Notes:
- **Size/effort:** ~400800 lines + lit tests. **12 weeks** for someone comfortable in
Binaryen IR (Builder, branch-utils), plus fuzzing time.
- **Binaryen ≥ v125 is required for the `--asyncify` step regardless** (partial EH
support); emsdk bundles v121. Already-solved infra: `BINARYEN_VERSION=130` makes
`get-wasm-opt.sh` download the official release — v130 output was previously validated
by the full e2e suite (adopted for the v121 -O2 slowness fix). Heed the script's
version-skew warning: re-validate e2e after any bump.
- **Mechanics:** preserve the names section (`-g`) if hoisting runs as a separate
invocation (ASYNCIFY_REMOVE matches by name); verify EH feature flags ride the binary's
`target_features`; start with hoist-all-cpp-catches (no call-graph analysis needed,
`-O2` cleans up), keep selective hoisting (suspendable-closure only) as a refinement.
- **Payoff coupling:** with this pass, the wasm-EH migration needs **no KiCad refactor**
(04), and the measured 44% download / 35% module (02) becomes reachable with: binaryen
bump + this pass + uniform flag flip + dropping `env.invoke_*` from
`apply-asyncify.sh:33`.

View file

@ -0,0 +1,97 @@
# `-fexceptions` vs `-fwasm-exceptions` in KiCad-WASM — research dossier
> **Status:** research / decision record. A parallel session attempted the migration
> end-to-end and **parked it** on an emscripten-4.0.2 LLVM codegen bug — see
> [`docs/wasm-exceptions-experiment.md`](../../wasm-exceptions-experiment.md) (full
> build-plumbing patch preserved in its appendix; flags
> `-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=0`, raw linked
> pcbnew 92 MB and zero `invoke_*`/`dynCall`). This dossier is the research companion:
> mechanism, measurements, audit, and the asyncify catch-block design.
> Authored 2026-06-11/12. Companion to [`docs/features/async/`](../async/) (the Asyncify
> `currData` contention dossier) — this dossier covers the *exception-handling* axis of
> the same machine.
## Why this exists
The whole build is on **`-fexceptions`** (Emscripten's JavaScript-based exception
handling). That choice is not cosmetic — it is the single largest driver of our Asyncify
cost: it forces `env.invoke_*` into `ASYNCIFY_IMPORTS`, which makes nearly the whole call
graph "suspension-capable" and therefore instrumented. We **measured** the consequence on
our own binary: 59% of the raw asyncify tax (64% of the gzipped tax) on pcbnew exists only
because of the invoke machinery. Migrating to native **`-fwasm-exceptions`** would cut the
shipped pcbnew download from **64.5 MB to ~36 MB (44%)** and the module from
**187 MB to ~122 MB (35%)**, plus an unmeasured-but-real runtime win on every try-region
hot path.
The migration is currently blocked by one upstream limitation — Binaryen's Asyncify pass
cannot handle a suspension *inside a catch handler* — and KiCad triggers exactly that
pattern (modal error dialogs from catch blocks) in **85 audited places**. This dossier
records the mechanism, the measurements, the toolchain status, the audit, and a concrete
**fork design (catch-arm hoisting)** that would remove the blocker without refactoring
KiCad at all.
## TL;DR / decision
- **Stay on `-fexceptions` for now.** Asyncify stays under any design (fibers + EM_ASYNC_JS
have no wasm-EH replacement); this is purely about how much it must instrument.
- The prize is measured, not estimated: **44% download, 35% module size** (see 02).
- Binaryen merged *partial* asyncify+wasm-EH support in **v125** (2025-11-19). Our emsdk
bundles **v121** — we don't even have the partial support locally.
- The remaining hole — unwind-from-catch — is fixable with a **bounded new Binaryen pass**
(catch-arm hoisting, ~400800 lines, 12 weeks, genuinely upstreamable; see 05). It is a
*pre-pass* before stock `--asyncify``Asyncify.cpp` itself needs zero changes, so
"fork" overstates it (one added file; `get-wasm-opt.sh` already has the
build-from-source deployment path). It **obsoletes the 85-site KiCad refactor** entirely.
- **Blocker ordering (learned from the parked experiment):** the catch-block limitation
is blocker #2. Blocker #1 is an **LLVM codegen bug in emscripten 4.0.2** (invalid
`br_table` arity in OpenCASCADE code under wasm-EH) — needs an emsdk bump first. And the
experiment had to force the **exnref encoding** (`WASM_LEGACY_EXCEPTIONS=0`, because
4.0.2's legacy encoding output doesn't even parse in Binaryen), which collides with the
fact that Binaryen's asyncify has **zero `TryTable` support**: the encoding choice after
the emsdk bump decides which variant of the catch fix applies (see 03 §experiment, 05).
- Trigger to act: when we are ready to invest ~2 weeks of toolchain work, or if upstream
lands full support on binaryen #4470 first. Until then the Asyncify arbiter work
(docs/features/async/) is the priority — it fixes shipping bugs and is needed either way.
## Document index
| File | Contents |
|---|---|
| [`01-background-two-eh-models.md`](01-background-two-eh-models.md) | How JS-EH (`invoke_*`) and wasm-EH actually work, and the three concrete couplings into our Asyncify machine. |
| [`02-measurements.md`](02-measurements.md) | Our controlled size experiment on pcbnew (methodology + numbers) and the published third-party benchmarks. |
| [`03-toolchain-status.md`](03-toolchain-status.md) | Compatibility matrix: emcc checks, binaryen history (what merged in v125, what didn't), JSPI/fibers, setjmp/longjmp, mixing modes. |
| [`04-kicad-audit.md`](04-kicad-audit.md) | The brace-matching catch-block audit: 85 direct / 93 review / 458 trivial of 636; libpng/libjpeg setjmp story; refactor effort if done by hand. |
| [`05-asyncify-fork-design.md`](05-asyncify-fork-design.md) | Asyncify.cpp internals, why catch arms are structurally hard, and the catch-arm-hoisting fork design with limits and effort. |
| [`catch_audit.py`](catch_audit.py) | The audit tool (re-runnable; suitable as a CI gate on the kicad submodule). |
| [`audit-results.txt`](audit-results.txt) | Full audit output incl. all 85 direct-suspend sites. |
## Relationship to docs/features/async/
Independent axes of the same machine. The async dossier is about *correctness* (one global
`Asyncify.currData` shared by overlapping suspensions → crash/hang); this dossier is about
*cost* (how much code Asyncify instruments). Fixing one does not fix the other. Sequencing:
async arbiter first (shipping bugs), wasm-EH migration second (size/speed), and the
migration plan below assumes the arbiter exists.
## Migration plan (when triggered)
0. Resume the parked experiment (`docs/wasm-exceptions-experiment.md`): bump emsdk past
the 4.0.2 LLVM `br_table` bug, `git apply` its appendix patch (`KICAD_WASM_EH=1`
gated), full clean deps rebuild (stamp-skip gotcha: stale sjlj objects in cairo etc.).
Then decide the EH encoding: if newer LLVM emits parseable **legacy** encoding, the
catch-arm-hoisting pre-pass (05) applies; if **exnref** stays forced, asyncify needs
`TryTable` support + exnref spilling instead (05 §new-EH variant).
1. Newer Binaryen for the post-link step only: `scripts/common/get-wasm-opt.sh` already
abstracts the binary — point it at a ≥ v125 build carrying the hoisting patch (05).
2. Validate partial support first: rebuild one app `-fwasm-exceptions` + asyncify-asserts,
run the e2e suites; the asserts tripwire makes any missed unwind-from-catch a
deterministic trap.
3. Uniform flag flip: `-fexceptions``-fwasm-exceptions` in
`scripts/build-wxuniversal-wasm.sh:141-142`, `scripts/kicad/build-kicad-target.sh`
(lines ~203/207/211/214), `tests/apps/Makefile.wasm` (all occurrences) — plus
`-sSUPPORT_LONGJMP=wasm` (default with wasm-EH; the `emscripten` flavor is a hard error).
4. Drop `env.invoke_*` from `ASYNCIFY_IMPORTS` in `scripts/common/apply-asyncify.sh:33`.
5. Expect to delete/shrink shim machinery that exists only for the invoke world
(`inject-dyncall-shims.sh` phases 12) — verify, don't assume.
6. Keep `catch_audit.py` as a CI gate only if shipping *without* the fork (i.e., the
hand-refactor path); with the fork it is informational.

View file

@ -0,0 +1,135 @@
{
"total": 636,
"trivial": 458,
"needs_review": 93,
"direct_suspend": 85
}
== per top-level dir (direct/review/trivial/infobar):
eeschema direct= 37 review= 17 trivial=127 infobar= 0
pcbnew direct= 32 review= 29 trivial=128 infobar= 0
common direct= 9 review= 35 trivial=126 infobar= 0
cvpcb direct= 3 review= 1 trivial= 0 infobar= 0
pcb_calculator direct= 2 review= 0 trivial= 2 infobar= 0
kicad direct= 1 review= 1 trivial= 21 infobar= 0
pagelayout_editor direct= 1 review= 1 trivial= 3 infobar= 0
scripting direct= 0 review= 0 trivial= 1 infobar= 0
plugins direct= 0 review= 1 trivial= 4 infobar= 0
3d-viewer direct= 0 review= 2 trivial= 6 infobar= 0
utils direct= 0 review= 4 trivial= 27 infobar= 0
libs direct= 0 review= 2 trivial= 7 infobar= 0
gerbview direct= 0 review= 0 trivial= 6 infobar= 0
== top 25 unknown callees inside needs_review catches (freq):
4 clearOutlines
4 message
4 wxASSERT_MSG
4 handleException
4 tl::unexpected
3 wxLogFatalError
3 GetFullFilename
3 std::string
3 ReportMsg
3 line_at
3 positions
3 LIBRARY_PARSE_ERROR
3 move_push
3 wxRemoveFile
3 IsTooRecent
3 Contents
3 GetFieldValue
3 ToOrigString
3 SetValue
2 GetFilename
2 ClearShapes
2 GetFPIDAsString
2 wxString::FromUTF8
2 GetFormatName
2 nan
== direct-suspend sites (85):
cvpcb/display_footprints_frame.cpp:310
cvpcb/cvpcb_mainframe.cpp:977
cvpcb/readwrite_dlgs.cpp:163
pcb_calculator/datafile_read_write.cpp:69
pcb_calculator/calculator_panels/panel_r_calculator.cpp:130
kicad/kicad_manager_frame.cpp:822
common/draw_panel_gal.cpp:325
common/draw_panel_gal.cpp:561
common/drawing_sheet/ds_data_model_io.cpp:100
common/drawing_sheet/ds_data_model_io.cpp:131
common/widgets/design_block_pane.cpp:121
common/widgets/design_block_pane.cpp:212
common/widgets/design_block_pane.cpp:315
common/widgets/design_block_pane.cpp:356
common/widgets/design_block_pane.cpp:418
pcbnew/load_select_footprint.cpp:378
pcbnew/pcb_base_frame.cpp:1111
pcbnew/pcb_base_frame.cpp:1239
pcbnew/pcb_edit_frame.cpp:2209
pcbnew/files.cpp:670
pcbnew/files.cpp:678
pcbnew/files.cpp:689
pcbnew/files.cpp:1024
pcbnew/files.cpp:1108
pcbnew/footprint_libraries_utils.cpp:198
pcbnew/footprint_libraries_utils.cpp:288
pcbnew/footprint_libraries_utils.cpp:401
pcbnew/footprint_libraries_utils.cpp:545
pcbnew/footprint_libraries_utils.cpp:613
pcbnew/footprint_libraries_utils.cpp:673
pcbnew/footprint_libraries_utils.cpp:817
pcbnew/pcb_design_block_utils.cpp:107
pcbnew/pcb_design_block_utils.cpp:168
pcbnew/pcb_design_block_utils.cpp:198
pcbnew/pcb_design_block_utils.cpp:226
pcbnew/pcb_design_block_utils.cpp:332
pcbnew/pcb_design_block_utils.cpp:429
pcbnew/pcb_draw_panel_gal.cpp:781
pcbnew/tools/board_editor_control.cpp:751
pcbnew/tools/footprint_editor_control.cpp:529
pcbnew/tools/pcb_control.cpp:1990
pcbnew/netlist_reader/netlist.cpp:74
pcbnew/exporters/export_idf.cpp:662
pcbnew/exporters/export_idf.cpp:670
pcbnew/dialogs/dialog_board_setup.cpp:385
pcbnew/specctra_import_export/specctra_import.cpp:71
pcbnew/widgets/pcb_design_block_preview_widget.cpp:191
eeschema/project_rescue.cpp:671
eeschema/project_rescue.cpp:819
eeschema/sch_draw_panel.cpp:200
eeschema/project_sch.cpp:108
eeschema/project_sch.cpp:122
eeschema/sch_design_block_utils.cpp:139
eeschema/sch_design_block_utils.cpp:179
eeschema/sch_design_block_utils.cpp:222
eeschema/sch_design_block_utils.cpp:364
eeschema/sch_design_block_utils.cpp:446
eeschema/sch_design_block_utils.cpp:526
eeschema/sch_edit_frame.cpp:1528
eeschema/sch_base_frame.cpp:96
eeschema/sheet.cpp:231
eeschema/files-io.cpp:356
eeschema/files-io.cpp:365
eeschema/files-io.cpp:374
eeschema/files-io.cpp:911
eeschema/files-io.cpp:1410
eeschema/files-io.cpp:1423
eeschema/symbol_library_manager.cpp:376
eeschema/symbol_library_manager.cpp:553
eeschema/tools/sch_editor_control.cpp:569
eeschema/tools/sch_editor_control.cpp:1703
eeschema/dialogs/dialog_edit_symbols_libid.cpp:742
eeschema/dialogs/dialog_sheet_properties.cpp:597
eeschema/dialogs/dialog_sim_model.cpp:1526
eeschema/dialogs/dialog_bom.cpp:361
eeschema/sim/spice_simulator.cpp:44
eeschema/sim/spice_value.cpp:415
eeschema/sim/simulator_frame_ui.cpp:1672
eeschema/symbol_editor/symbol_editor.cpp:170
eeschema/symbol_editor/symbol_editor.cpp:236
eeschema/symbol_editor/symbol_editor.cpp:1147
eeschema/symbol_editor/symbol_editor.cpp:1187
eeschema/symbol_editor/symbol_editor_import_export.cpp:104
eeschema/symbol_editor/symbol_editor_import_export.cpp:110
pagelayout_editor/tools/pl_edit_tool.cpp:531

View file

@ -0,0 +1,109 @@
#!/usr/bin/env python3
"""Audit KiCad catch blocks for wasm-EH safety (suspension inside catch handlers)."""
import os, re, sys, json
from collections import defaultdict
ROOT = "/Users/V/IdeaProjects/kicad-wasm/kicad"
SKIP_DIRS = {"thirdparty", ".git", "qa", "build"}
# Calls that suspend (Asyncify) directly or are dialog wrappers in KiCad/wx
DIRECT_SUSPEND = [
"DisplayErrorMessage", "DisplayError", "DisplayInfoMessage", "DisplayHtmlInfoMessage",
"wxMessageBox", "ShowModal", "ShowQuasiModal", "KIDIALOG", "OKOrCancelDialog",
"wxMessageDialog", "IsOK(", "DisplayLoadError", "ShowAboutDialog",
"wxGetSingleChoice", "wxTextEntryDialog", "wxFileDialog", "wxDirDialog",
"GetDataFromClipboard", "SaveToClipboard", "wxClipboard", "EnumerateFacenames",
]
INFOBAR = ["ShowInfoBarError", "ShowInfoBarMsg", "ShowInfoBarWarning"]
# Benign callees: logging (wxLog* is deferred to idle-time flush -> not inside catch),
# string formatting, rethrow, reporters writing text
BENIGN = {
"wxLogError", "wxLogWarning", "wxLogMessage", "wxLogTrace", "wxLogDebug", "wxLogVerbose",
"Format", "Printf", "printf", "fprintf", "snprintf", "What", "Problem", "Where",
"GetErrorMessage", "wxString", "FROM_UTF8", "TO_UTF8", "UTF8", "c_str", "mb_str",
"GetChars", "IsEmpty", "empty", "clear", "size", "length", "Report", "ReportTail",
"ReportHead", "Add", "push_back", "emplace_back", "insert", "append", "Append",
"SetError", "assert", "wxASSERT", "wxFAIL", "wxCHECK", "abort", "exit",
"GetMessages", "GetErrors", "reset", "get", "release", "find", "count", "at",
"begin", "end", "str", "Mid", "Left", "Right", "Trim", "Lower", "Upper",
"StartsWith", "EndsWith", "Contains", "Replace", "Remove", "make_unique",
"make_shared", "static_cast", "dynamic_cast", "const_cast", "reinterpret_cast",
"Clear", "Close", "swap", "resize", "erase", "Set", "SetBitmap", "Destroy",
"wxT", "_", "_HKI", "traceSchPlugin", "TRACE", "what", "THROW_IO_ERROR", "wxS", "wxFAIL_MSG", "IDF_ERROR", "LIBRARY_ERROR", "FUTURE_FORMAT_ERROR", "PARSE_ERROR", "KI_PARAM_ERROR", "fmt::format", "format", "GetFullPath", "HandleException", "Pgm", "current_exception", "rethrow_exception", "Nickname", "GetName", "GetLibNickname", "GetLibItemName", "wx_str", "GetMessageString", "UnescapeString", "exceptions", "CLOSE_STREAM", "AddError", "GetRequiredVersion", "Disconnect", "From_UTF8", "typeid", "LogException", "Instance", "GetFullName", "GetUniStringLibId", "ShowText", "SetStatusText", "GetItemDescription", "GetClass", "GetFriendlyName", "IsValid", "GetPath", "GetFileName", "GetExtension", "Length", "GetData", "data", "front", "back", "pop_back", "emplace", "GetSettingsManager",
}
CALL_RE = re.compile(r"\b([A-Za-z_][A-Za-z0-9_:]*)\s*\(")
CATCH_RE = re.compile(r"\bcatch\s*\(")
def find_block(text, start):
"""Return (block_text, end_idx) for brace-block starting at first '{' at/after start."""
i = text.find("{", start)
if i < 0: return None, start
depth, j, n = 0, i, len(text)
in_str = in_chr = in_lc = in_bc = False
while j < n:
c = text[j]; p = text[j-1] if j else ""
if in_lc:
if c == "\n": in_lc = False
elif in_bc:
if p == "*" and c == "/": in_bc = False
elif in_str:
if c == '"' and p != "\\": in_str = False
elif in_chr:
if c == "'" and p != "\\": in_chr = False
elif c == "/" and j+1 < n and text[j+1] == "/": in_lc = True
elif c == "/" and j+1 < n and text[j+1] == "*": in_bc = True
elif c == '"': in_str = True
elif c == "'": in_chr = True
elif c == "{": depth += 1
elif c == "}":
depth -= 1
if depth == 0: return text[i:j+1], j+1
j += 1
return None, start
stats = defaultdict(int)
direct_sites, review_sites = [], []
review_callees = defaultdict(int)
per_dir = defaultdict(lambda: defaultdict(int))
for dirpath, dirnames, filenames in os.walk(ROOT):
dirnames[:] = [d for d in dirnames if d not in SKIP_DIRS]
for fn in filenames:
if not fn.endswith(".cpp"): continue
path = os.path.join(dirpath, fn)
rel = os.path.relpath(path, ROOT)
top = rel.split(os.sep)[0]
try: text = open(path, encoding="utf-8", errors="replace").read()
except OSError: continue
for m in CATCH_RE.finditer(text):
block, _ = find_block(text, m.end())
if block is None: continue
stats["total"] += 1
line = text[:m.start()].count("\n") + 1
loc = f"{rel}:{line}"
if any(s in block for s in DIRECT_SUSPEND):
stats["direct_suspend"] += 1; per_dir[top]["direct"] += 1
direct_sites.append(loc)
continue
if any(s in block for s in INFOBAR):
stats["infobar"] += 1; per_dir[top]["infobar"] += 1
continue
calls = set(CALL_RE.findall(block)) - {"catch", "if", "for", "while", "switch", "return", "sizeof", "throw"}
unknown = {c for c in calls if c.split("::")[-1] not in BENIGN and c not in BENIGN}
if not unknown:
stats["trivial"] += 1; per_dir[top]["trivial"] += 1
else:
stats["needs_review"] += 1; per_dir[top]["review"] += 1
review_sites.append((loc, sorted(unknown)[:6]))
for c in unknown: review_callees[c] += 1
print(json.dumps(stats, indent=1))
print("\n== per top-level dir (direct/review/trivial/infobar):")
for d in sorted(per_dir, key=lambda d: -per_dir[d]["direct"]):
p = per_dir[d]
print(f" {d:24s} direct={p['direct']:3d} review={p['review']:3d} trivial={p['trivial']:3d} infobar={p['infobar']:2d}")
print("\n== top 25 unknown callees inside needs_review catches (freq):")
for c, n in sorted(review_callees.items(), key=lambda kv: -kv[1])[:25]:
print(f" {n:3d} {c}")
print(f"\n== direct-suspend sites ({len(direct_sites)}):")
for s in direct_sites: print(" " + s)