pcbjam/docs/features/wasm-exceptions/01-background-two-eh-models.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

76 lines
4.3 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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