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

4.3 KiB
Raw Blame History

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