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

170 lines
9.9 KiB
Markdown
Raw 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.

# 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): `flatten``dce` → 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 `if`s 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):
```wat
(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`.