pcbjam/docs/features/wasm-exceptions/05-asyncify-fork-design.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

9.9 KiB
Raw Blame History

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): flattendce → 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 ifs 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):

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