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>
123 lines
8.1 KiB
Markdown
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.
|