244 lines
17 KiB
Markdown
244 lines
17 KiB
Markdown
|
|
# 07 — Native wasm-EH spike: results and engineering opinion (2026-06-22)
|
|||
|
|
|
|||
|
|
> What the spike actually built and proved, then a candid opinion on whether to pursue the
|
|||
|
|
> `-fwasm-exceptions` migration and how. Companion to [`06-spike-plan.md`](06-spike-plan.md)
|
|||
|
|
> (the plan + Phase 0/1/1.5 result log). Verdict up front, evidence and caveats after.
|
|||
|
|
|
|||
|
|
## Verdict
|
|||
|
|
|
|||
|
|
**The migration is viable on the current toolchain, the single blocking limitation is real
|
|||
|
|
and narrow, and a bounded Binaryen pass fixes it. I recommend pursuing it** — but the
|
|||
|
|
schedule risk is the **emsdk bump for OpenCASCADE**, not the exception machinery, and the
|
|||
|
|
pass still needs generalization before full KiCad. This was a genuine de-risking: the core
|
|||
|
|
"can Asyncify and native wasm-EH coexist?" question is now answered **yes, empirically**,
|
|||
|
|
in **all three engines — Chrome (V8), Firefox (SpiderMonkey), and Safari (WebKit)** — with no
|
|||
|
|
emsdk bump required to prove it. (Project policy, set this session: every spec is run in all
|
|||
|
|
three browsers — `cd tests && npm run test:asyncify:all`.)
|
|||
|
|
|
|||
|
|
## What was proven (with evidence, not estimates)
|
|||
|
|
|
|||
|
|
Everything below ran on the **current pinned toolchain** (emscripten 4.0.2, Binaryen v130 via
|
|||
|
|
`BINARYEN_VERSION=130`) — **no emsdk bump**.
|
|||
|
|
|
|||
|
|
1. **em 4.0.2 emits runnable legacy wasm-EH.** A trivial `-fwasm-exceptions
|
|||
|
|
-sWASM_LEGACY_EXCEPTIONS=1` throw/catch builds (finalize on the bundled v121, *no* "popping
|
|||
|
|
from empty stack") and runs correctly under node. So the parked experiment's finalize
|
|||
|
|
failure was scale/OCC-specific, not a general codegen break.
|
|||
|
|
(`tests/apps/standalone/eh-spike/eh_probe.cpp`.)
|
|||
|
|
2. **Binaryen v130 asyncifies legacy-wasm-EH; v121 cannot.** v130 instruments the module
|
|||
|
|
(asyncify_* exports appear); the emsdk-bundled v121 crashes (`UNREACHABLE …
|
|||
|
|
Asyncify.cpp:1146`). We already ship v130 for the post-link asyncify, so this costs nothing.
|
|||
|
|
3. **The three Asyncify mechanisms split exactly as predicted.** A red-green toy
|
|||
|
|
(`eh_spike_test.cpp`) exercises sleep-across-throw, fiber-swap-then-throw, and
|
|||
|
|
suspend-inside-catch. Under native wasm-EH: **sleep ✅, fiber ✅, suspend-in-catch ❌
|
|||
|
|
(hard trap: `indirect call to null`).** Identical in V8 (node) and Firefox. So the *only*
|
|||
|
|
failure mode is the documented one (Binaryen #4470: AsyncifyFlow skips catch bodies), and
|
|||
|
|
it fails **loudly and deterministically** — not a silent corruption.
|
|||
|
|
4. **A ~150-line Binaryen pass closes it.** `--hoist-cpp-catches`
|
|||
|
|
(`binaryen/src/passes/HoistCppCatches.cpp`, our fork) flips suspend-in-catch to **green**
|
|||
|
|
in both engines. `tests/asyncify/eh-spike.spec.ts` is a 3-variant ablation harness pinning
|
|||
|
|
JS-EH-green / wasm-EH-red / wasm-EH+hoist-green. Rebuilt and re-verified end-to-end from the
|
|||
|
|
tracked submodule.
|
|||
|
|
|
|||
|
|
## The artifacts (all reproducible)
|
|||
|
|
|
|||
|
|
| Thing | Where |
|
|||
|
|
|---|---|
|
|||
|
|
| Red-green toy (3 mechanisms) | `tests/apps/standalone/eh-spike/eh_spike_test.cpp` |
|
|||
|
|
| Phase-0 probe | `tests/apps/standalone/eh-spike/eh_probe.cpp` (+ `eh-probe` Makefile target) |
|
|||
|
|
| 3-variant build (stub→post-link-v130, +hoist) | `scripts/build-eh-spike.sh` |
|
|||
|
|
| The Binaryen pass | `binaryen/` submodule (fork, branch `wasm-port` = `version_130` + the pass) |
|
|||
|
|
| Fork build wrapper | `scripts/binaryen-hoist-pass/build-wasm-opt.sh` |
|
|||
|
|
| Red-green-fixed spec | `tests/asyncify/eh-spike.spec.ts` |
|
|||
|
|
|
|||
|
|
## How the pass works (one paragraph)
|
|||
|
|
|
|||
|
|
For a `try` whose cpp-tag `catch` arm contains a suspending call, it rewrites the arm to just
|
|||
|
|
*capture the exception payload into a local and set a flag*, and **hoists the real handler to
|
|||
|
|
plain straight-line code after the try**, guarded by the flag. In the hoisted handler the
|
|||
|
|
payload `pop` becomes a `local.get`, and the personality no-match `rethrow` becomes an explicit
|
|||
|
|
`throw` of the cpp tag with the captured payload. Stock `--asyncify` then instruments the
|
|||
|
|
hoisted handler like any other code — the upstream "no pause/resume inside catchBodies"
|
|||
|
|
invariant becomes true *by construction*. It is a **pre-pass**; `Asyncify.cpp` is unchanged,
|
|||
|
|
which is why it's a clean ~150-line addition and genuinely upstreamable.
|
|||
|
|
|
|||
|
|
## Generalization (follow-up, same session)
|
|||
|
|
|
|||
|
|
The pass was generalized from the MVP (one direct-suspend catch) to **hoist-all-cpp-catches** and
|
|||
|
|
tested against a richer toy covering the real KiCad/wx shapes. **All 7 shapes are green in all
|
|||
|
|
three engines** (Firefox + Chrome + Safari/WebKit):
|
|||
|
|
|
|||
|
|
| shape | covered |
|
|||
|
|
|---|---|
|
|||
|
|
| direct suspend in catch | ✅ |
|
|||
|
|
| transitive (catch → helper → … → suspend) | ✅ (hoist-all; direct detection would miss it) |
|
|||
|
|
| value-returning try/catch | ✅ (LLVM keeps the value in a local → void try, no result routing needed) |
|
|||
|
|
| suspend-in-catch on a fiber/coroutine stack (eeschema Paste) | ✅ |
|
|||
|
|
| nested suspend-in-catch tries | ✅ |
|
|||
|
|
| **catch nested in a catch_all cleanup** (try body has a local with a destructor) | ✅ (escape past the outermost try; see below) |
|
|||
|
|
|
|||
|
|
**The one gap — catch_all-wrapped catches.** When the try body holds a local with a non-trivial
|
|||
|
|
destructor, LLVM lowers the C++ catch *nested inside* the cleanup `catch_all`
|
|||
|
|
(`catch_all { ~g; try { rethrow } catch $cpp { sleep } }`). Hoisting the cpp catch leaves the sleep
|
|||
|
|
inside the `catch_all`; legacy `catch_all` gives no payload to capture/re-raise, so the sleep can
|
|||
|
|
only be freed by hoisting **past the outermost enclosing try** — have the cpp catch capture the
|
|||
|
|
payload and `br` to a `$done` block placed after that try. **Prototyped this session and
|
|||
|
|
reverted:** the escape transform *validates* and fixes the catch_all case *in isolation*, but the
|
|||
|
|
`block` + `br` + flag-dispatch control-flow shape it produces is **not asyncify-rewindable**
|
|||
|
|
(rewind traps with `null function`) and it regressed the simple cases too. So the real work is
|
|||
|
|
finding an asyncify-friendly escape shape — the per-try inline `br_if`-skip form (handler inline
|
|||
|
|
right after the try) rewinds fine; a `br` out to a separate dispatch does not. That's the
|
|||
|
|
fix is now landed (see "catch_all-escape: LANDED" below); all 7 shapes are green. How often it
|
|||
|
|
bites KiCad depends on whether the specific catch's try body constructs a destructible
|
|||
|
|
local/temporary (e.g. a `wxString`); a `try { ptr = Load(fn); } catch(IO_ERROR&)` with a pointer
|
|||
|
|
result has no cleanup pad and is already covered. (`HOIST_ONLY_SUSPEND` switches off hoist-all back
|
|||
|
|
to direct-suspend-only, useful for narrowing blast radius while debugging.)
|
|||
|
|
|
|||
|
|
### catch_all-escape: LANDED (2026-06-22)
|
|||
|
|
|
|||
|
|
**Fixed — all 7 shapes green in Firefox + Chrome + Safari.** When the try body holds a local with a
|
|||
|
|
non-trivial destructor, LLVM lowers the C++ catch *nested inside* the cleanup `catch_all`; the pass
|
|||
|
|
hoists it PAST the outermost enclosing try. Confirmed to occur in real KiCad (`pcbnew/files.cpp:670`
|
|||
|
|
builds `std::map<std::string, UTF8> props` in the try, so its IO/format/bad_alloc catches are
|
|||
|
|
catch_all-nested). Landing it took a from-source Binaryen build + a minimal *multi-function* repro
|
|||
|
|
(`/tmp/eh_min2.cpp`, `/tmp/eh_min3.cpp`); two bugs, both invisible on a single function and only live
|
|||
|
|
once several shapes inline together:
|
|||
|
|
|
|||
|
|
1. **Over-eager deferral → `null function`.** A nested cpp catch is deferred to its ancestor escape
|
|||
|
|
target, but the test matched ANY ancestor catch body — so a cpp catch in a *regular* catch body
|
|||
|
|
(the `__cxa_end_catch` cleanup tries LLVM emits everywhere) was deferred to a target that never
|
|||
|
|
hoisted it; its suspend was dropped and rewind trapped. Fix: defer only when the catch sits in an
|
|||
|
|
ancestor's `catch_all` cleanup pad (`hasCatchAll() && catchBodies.back() == child`).
|
|||
|
|
2. **Trailing catch_all code → `unreachable`.** The rewritten minimal arm completes, but the
|
|||
|
|
catch_all body has trailing `(unreachable)` after the nested try (it assumed the handler
|
|||
|
|
diverged). Fix: wrap the escape target in a `block $esc`; the arm `br $esc`s after capturing,
|
|||
|
|
landing fall-through just before the dispatch (so Asyncify still rewinds the handler).
|
|||
|
|
|
|||
|
|
The pass is in the `binaryen` submodule (`src/passes/HoistCppCatches.cpp`). The earlier
|
|||
|
|
"not asyncify-rewindable" worry was wrong — Asyncify rewinds the escape form fine; the blockers were
|
|||
|
|
ordinary IR bugs, exactly as the multi-function-debug plan predicted.
|
|||
|
|
|
|||
|
|
### value-typed (concrete-result) tries: LANDED (2026-06-22)
|
|||
|
|
|
|||
|
|
The pass also handles an escape target whose try yields a *value* (i32/i64/…), not just
|
|||
|
|
void/unreachable — it routes the body/handler value through a `$result` local (the no-exception
|
|||
|
|
body value is captured inside `block $esc`; a caught arm br's out and each per-arm dispatch writes
|
|||
|
|
`$result`; the block yields `local.get $result`). Non-defaultable result types are still skipped.
|
|||
|
|
These tries don't arise from normal C++ EH lowering (LLVM keeps catch values in locals →
|
|||
|
|
void/unreachable tries), so they're covered by hand-written modules in
|
|||
|
|
`scripts/binaryen-hoist-pass/tests/` (`run.sh`): `--fuzz-exec` confirms the pass preserves the
|
|||
|
|
result value across the exception / no-exception / payload paths, and a real asyncify unwind+rewind
|
|||
|
|
through a value-typed *suspending* catch yields the correct value (50).
|
|||
|
|
|
|||
|
|
#### Debugging history (superseded)
|
|||
|
|
|
|||
|
|
The notes below trace the path to the fix; their "blocked" conclusions are superseded by the
|
|||
|
|
landing above.
|
|||
|
|
|
|||
|
|
##### catch_all-escape: confirmed real; the fix is asyncify-SOUND, not a wall (2026-06-22)
|
|||
|
|
|
|||
|
|
> **Correction (later same session):** the "not asyncify-rewindable" conclusion below was
|
|||
|
|
> **disproven**. Diffing the *asyncified* output of the per-try vs escape forms on a minimal
|
|||
|
|
> single-function suspend-in-catch (`/tmp/eh_min.cpp`) shows them **structurally identical** — all
|
|||
|
|
> 22 diff hunks are pure local-index renumbering — and **both run cleanly in node**. So Asyncify
|
|||
|
|
> rewinds the escape form fine. The real blocker is ordinary structural bugs in the escape pass on
|
|||
|
|
> the complex *inlined* toy (one found: the skip-to-escape-target coordination drops a catch when
|
|||
|
|
> its escape target is value-typed; fixing that surfaced a load-time trap, so there's ≥1 more).
|
|||
|
|
> That is tractable engineering — methodical per-function isolation like the eh_min repro — **not**
|
|||
|
|
> an asyncify-internals wall. WIP + partial fix preserved in the escape-wip file below.
|
|||
|
|
|
|||
|
|
> **Further localization (same session):** the breakage is **not** fiber-specific (cases 1/3/4/5
|
|||
|
|
> with no fibers still trap) and **not** one bug. It is a **layout-sensitive structural corruption**
|
|||
|
|
> the escape restructure introduces on MULTI-function modules — `null function` / wrong
|
|||
|
|
> `call_indirect`, which V8 then mis-compiles unpredictably (the trap point *moves* with module
|
|||
|
|
> composition). Single-function repros (`eh_min`) work; the corruption only appears once several
|
|||
|
|
> functions/cases compile together. So the next step is NOT more single-function isolation but a
|
|||
|
|
> small **multi-function** repro under a Binaryen **debug build (assertions)** + `--fuzz-exec`, to
|
|||
|
|
> catch the exact expression the restructure corrupts. Deferred to dedicated debugging.
|
|||
|
|
|
|||
|
|
**Confirmed we DO hit the gap.** A spot-check of the audited sites found destructible locals in the
|
|||
|
|
try bodies: e.g. `pcbnew/files.cpp:670` declares `std::map<std::string, UTF8> props;` in the try,
|
|||
|
|
so its three `catch (… ) { DisplayErrorMessage(…) }` arms are lowered nested inside a cleanup
|
|||
|
|
`catch_all`. The file-load sites generally construct `wxString`/`std::map`/smart-pointer locals, so
|
|||
|
|
this is not academic — a real subset of the ~85 sites is affected.
|
|||
|
|
|
|||
|
|
**The fix was attempted extensively and is blocked.** The escape-target restructure (hoist the cpp
|
|||
|
|
catch — own or nested — past the outermost enclosing try, dispatching handlers after it) **validates**
|
|||
|
|
in every variant but is **not asyncify-rewindable**: it traps with `null function` even on the simple
|
|||
|
|
cases the per-try form handles. Tried: inline flag-dispatch (`if (flag==n)` — asyncify skips `if`
|
|||
|
|
bodies on rewind), a bare single handler, `br_if`-skip guards, and `ReFinalize` (for stale `Try`
|
|||
|
|
types). None worked at the time — the actual root causes (over-eager deferral + trailing catch_all code)
|
|||
|
|
were found later with a multi-function repro; the fix landed in the submodule pass (see above).
|
|||
|
|
|
|||
|
|
**Open options:** (1) diff the *asyncified* IR of the working per-try form vs the escape form on one
|
|||
|
|
simple case, to pinpoint exactly what Asyncify mis-handles; (2) hand-refactor the affected KiCad
|
|||
|
|
sites (move the destructible local out of the try body) — a targeted subset, not all 85; (3) the new
|
|||
|
|
`exnref` EH encoding gives `catch_all` a payload (a clean fix) but Asyncify has no `exnref` support.
|
|||
|
|
The per-try pass (6/7 shapes) is the shipped state.
|
|||
|
|
|
|||
|
|
## Opinions (the part you asked for)
|
|||
|
|
|
|||
|
|
**1. Do it — the size/perf prize is real and the risk is now bounded.** −44% download / −35%
|
|||
|
|
module (measured, see 02) plus a large `-O2` build-time drop. The thing everyone feared
|
|||
|
|
(Asyncify ⊥ wasm-EH) is disproven. I would not have said this before the spike; I say it now
|
|||
|
|
because the toy actually runs.
|
|||
|
|
|
|||
|
|
**2. Switch the pass from "selective" to "hoist-all-cpp-catches" before KiCad.** My MVP only
|
|||
|
|
hoists arms with a *direct* suspending-import call. KiCad's real pattern is **transitive** —
|
|||
|
|
`catch (IO_ERROR&) { DisplayErrorMessage(...); }` → `ShowModal` → `startModal` — so direct
|
|||
|
|
detection would miss most of the 85 audited sites. The design doc already recommends hoist-all
|
|||
|
|
+ let `-O2` prune the no-op hoists, and having now written the selective version I agree: it
|
|||
|
|
removes the call-graph analysis entirely, is robust to transitivity, and the only cost is
|
|||
|
|
transforming more tries (which `-O2` coalesces). Selective was the right call for *proving the
|
|||
|
|
concept with minimal blast radius*; hoist-all is the right call for *shipping*.
|
|||
|
|
|
|||
|
|
**3. The remaining pass work is small (~1–2 days), and I know exactly what it is.** (a)
|
|||
|
|
Concrete-result-typed tries — route the body value through a temp local (the toy already
|
|||
|
|
forced me to handle `unreachable`-typed; `i32`/others are the same shape). (b) `catch_all`
|
|||
|
|
coexistence on the same try (cpp catch + cleanup pad). (c) hoist-all gating. None are research;
|
|||
|
|
all are mechanical Binaryen-IR work. The two real-IR gotchas are already solved in the MVP:
|
|||
|
|
**nested-catch pops** (don't clobber a nested catch's payload — fixed with a Try-ancestor guard)
|
|||
|
|
and **nested suspend-in-catch tries** (KiCad will have these; the toy already did, and hoisting
|
|||
|
|
*both* was required).
|
|||
|
|
|
|||
|
|
**4. The schedule risk is the emsdk bump, not exceptions.** Everything above avoided the bump.
|
|||
|
|
Full KiCad cannot: OpenCASCADE miscompiles a `br_table` under wasm-EH on em 4.0.2 (candidate
|
|||
|
|
LLVM fix exists; em 6.0.0 = LLVM 23 should cover it), and the bump changes the compiler for the
|
|||
|
|
*JS-EH build too* → whole-project revalidation. That is the multi-week, cross-cutting item.
|
|||
|
|
Budget the migration as "1–2 days pass + N weeks emsdk-bump-and-revalidate," not the reverse.
|
|||
|
|
|
|||
|
|
**5. Keep the legacy encoding; ignore the exnref/Chrome noise.** Asyncify can only instrument
|
|||
|
|
legacy `try/catch` (no roadmap to change through v130), and legacy ships unflagged everywhere
|
|||
|
|
since 2021. The size win is native-vs-JS EH, independent of the encoding — so legacy is free
|
|||
|
|
and correct. The exnref "Chrome problem" never touches us.
|
|||
|
|
|
|||
|
|
**6. Honest caveat — the toy is small; scale is unproven.** This spike de-risks the *semantic*
|
|||
|
|
interaction, not KiCad-scale behavior. Two known scale hazards remain untested under wasm-EH:
|
|||
|
|
V8's per-function locals limit on huge asyncified functions
|
|||
|
|
([[chrome-asyncify-rewind-crash]]) and unwind-time landing-pad reliability
|
|||
|
|
([[asyncify-eh-unwind-landing-pads-unreliable]] — which *might improve* under wasm-EH, worth
|
|||
|
|
re-checking). The Safari 26.0 transient legacy-EH crash (emscripten #25365, since patched) is a
|
|||
|
|
reminder that even legacy can break on a fresh engine. None of these are blockers; all are
|
|||
|
|
"verify at scale," and the recommended order (toy → wx app → full KiCad) is designed to surface
|
|||
|
|
them cheaply.
|
|||
|
|
|
|||
|
|
## Recommended path forward
|
|||
|
|
|
|||
|
|
1. **Generalize the pass** — DONE: hoist-all, catch_all-escape, and value-typed/concrete-result
|
|||
|
|
tries are all handled and verified (the value-typed path via `scripts/binaryen-hoist-pass/tests/`).
|
|||
|
|
No further generalization is needed for the 7 KiCad shapes.
|
|||
|
|
2. **Phase 2 — a wx standalone app** flipped to native EH: the first *real* `ShowModal`-from-catch
|
|||
|
|
path, and the forcing function for transitive hoisting.
|
|||
|
|
3. **File the design on binaryen #4470** (the pass is a pure addition; upstreaming collapses our
|
|||
|
|
fork back into stock wasm-opt eventually).
|
|||
|
|
4. **Phase 3 — full KiCad**, gated on the emsdk bump (the real work) + the generalized pass +
|
|||
|
|
the uniform flag flip + dropping `env.invoke_*` from `apply-asyncify.sh`, with an e2e audit
|
|||
|
|
under `-sASYNCIFY_ASSERTIONS`.
|
|||
|
|
|
|||
|
|
## Status of the tracked changes (for review)
|
|||
|
|
|
|||
|
|
- `binaryen/` submodule added (fork `emergence-engineering/binaryen`), branch **`wasm-port`** at
|
|||
|
|
`version_130 + 1` (`58f25ebb2`) — the pass is **committed in the submodule but not pushed**.
|
|||
|
|
Pushing the branch to the fork (and committing the parent gitlink) is the user's call.
|
|||
|
|
- Parent-repo changes are **uncommitted**, pending review: `.gitmodules` + the `binaryen`
|
|||
|
|
gitlink, the spike toy/scripts/spec, and these dossier docs.
|