pcbjam/docs/features/async/03-solutions-and-prior-art.md
Viktor Vaczi 66ce367703 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>
2026-06-12 17:02:59 +02:00

123 lines
8.1 KiB
Markdown

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