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>
This commit is contained in:
Viktor Vaczi 2026-06-12 13:53:43 +02:00
commit 66ce367703
16 changed files with 3252 additions and 0 deletions

View file

@ -0,0 +1,121 @@
# 04 — How the fixes relate, the test matrix, open questions
> The goal is **one universal mechanism**, not patches scattered around. This file classifies the
> candidate fixes by *root cause* so it's clear what is part of the one solution, what is
> subsumed, and what is genuinely separate.
## §1. The candidate levers, by root cause
| Lever | Root cause it addresses | Relationship to the universal fix |
|---|---|---|
| **Sync clipboard `IsSupported`** | A *separate* semantic/UX bug: a cheap predicate doing a 2 s permission-gated async read on the idle path. | **NOT needed for correctness.** A correct per-context authority makes the 2 s sleep *safe* even when overlapped. Keep it only as an independent UX/perf improvement, or drop it. |
| **Trampoline self-heal** (`try/finally` around `Fibers.trampoline`) | A *different register*, `Fibers.trampolineRunning`, getting stuck `true` after a mid-loop unwind. | **Subsumed.** A real authority must own the trampoline anyway; "always reset the guard" becomes one of its invariants, not a standalone bolt-on. |
| **De-park `main()`** (`simulate_infinite_loop=0` + suppress teardown) | The `throw "unwind"` abandoning Asyncify state outside any scheduler's control, plus the null-`IsModal` lifecycle crash. | **Orthogonal** to `currData`. Whether it's required is decided by one measurement (§2). If required, it is *part of* the universal design (the main loop becomes a normal scheduler participant), not a hack. |
| **Per-context `currData` authority** | The single global slot shared by overlapping suspensions. | **The core.** This is the one universal mechanism. |
### Why de-parking doesn't automatically fall out of the authority
The authority governs `currData` during *normal* unwind/rewind transitions. But
`simulate_infinite_loop=1` is a **`throw "unwind"`** (`pcbnew.js:11392`) — a JS exception that
**bypasses all asyncify bookkeeping, including the authority's.** Two orthogonal design axes:
- **D1 — how does `main()` avoid returning into destructive cleanup?** (throw, vs. de-park +
suppress cleanup)
- **D2 — how is `currData` managed across overlaps?** (single slot, vs. unified authority)
The authority is purely D2; the throw is purely D1. They don't subsume each other. You *can*
build the authority while keeping the throw — its safety then hinges entirely on §2.
## §2. The one measurement that sets the scope
> **Is `Asyncify.currData` clean (null, no context parked) at the instant `wxGUIEventLoop::DoRun()`
> executes the `throw "unwind"`?**
- **If yes** → the throw abandons nothing; the **per-context authority alone is the complete
universal solution.** No de-parking needed.
- **If no** (a startup coroutine or the main fiber is still parked at the throw) → the throw
orphans a live buffer *outside the authority's control*. Then the clean answer is to **remove
the throw** (de-park), so the authority never has to recover from an abnormal teardown. (The
alternative — "reconcile/reset `currData` at the next tick" — is exactly the kind of paper-over
we want to avoid.)
**The diagnostic (cheap, JS-only, no full rebuild):** add `console.log`s of
`Asyncify.currData` / `Asyncify.state` / `Fibers.trampolineRunning` at:
1. the last line of `DoRun` *before* `emscripten_set_main_loop(...,1)` (C side, `evtloop.cpp`),
2. the first `MainLoop.runner` rAF tick (`pcbnew.js:11342`),
3. the entry of the first post-startup `_emscripten_fiber_swap` (`pcbnew.js:11557`).
Compare `currData` to `g_main_context`'s buffer (libcontext) and to any live coroutine's
`fiber+20`. This disambiguates §5 mechanism #1 (orphaned buffer) vs. #2 (stuck guard) in
[`02-asyncify-internals.md`](02-asyncify-internals.md), and answers the yes/no above.
**Run this before designing anything.**
## §3. The universal, hack-free framing
The cleanest single design is **one cooperative Asyncify scheduler in which *every* suspendable
thing is a registered context with its own buffer** — tool coroutines, modals, clipboard, fonts,
**and the main loop itself** — where the scheduler is the sole owner of `currData` *and* the
fiber trampoline, and the main loop participates normally instead of being parked via a
state-abandoning `throw`.
In that framing:
- the per-context `currData` authority is the **core** (D2),
- the trampoline self-heal is an **internal invariant** of it,
- making the main loop a normal scheduler participant **is** de-parking (D1) — a consequence of
"no special-case suspension may abandon state," not a bolt-on,
- the clipboard sync change is **out of scope** — a separate UX fix.
The §2 measurement decides whether the scheduler must own the main loop from the start (cleaner)
or can be scoped to coroutines + sleeps and leave the main loop alone.
## §4. Tests — enumerating "every possible case"
A strong harness already exists: `tests/apps/standalone/coroutine*/` (nine probes:
`main/nested/nested_ex/embind/mainloop/gl/gl_pt/vcall/wxpt`) built by `scripts/build-wasm-test.sh`
via `tests/apps/Makefile.wasm` (each links `-sASYNCIFY=1
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']` then the same `inject-dyncall-shims.sh`), plus
`tests/e2e/coroutine-nested.spec.ts` (8 modal×fiber scenarios) and `coroutine-pthread.spec.ts`,
all asserting **no `index out of bounds`** and polling a `SUMMARY total/passed/failed` line.
**Gaps:** no systematic coverage of *out-of-order* and *long-parked* overlaps, and it asserts
crash-freedom but **not liveness** (so it would not catch a hang).
Make it a **generated combinatorial product** and assert three outcomes per cell:
- **Primitives (cells):** `S1`=EM_ASYNC_JS sleep (modal/clipboard/font), `S2`=fiber swap (tool
coroutine), `S3`=parked main loop, `S4`=pthread boundary.
- **Overlap shape:** none / nested-LIFO / **interleaved out-of-order** / **long-parked outer**
(the 2 s clipboard shape). The last two are under-tested.
- **Host context:** direct / rAF main-loop tick / embind dispatch / WebGL2 frame / deep stack /
`-fexceptions` invoke wrappers.
- **Resume target:** continuation / **virtual call** (`invoke_vi→dynCall_vi`, the `vcall_repro`
smoking gun).
- **Assert per cell:** (1) no `index out of bounds` / `indirect call to null` / `unwind`
rejection (no crash); (2) **completes within a timeout** (no hang — the missing assertion
today); (3) returned value correct (no silent wrong-buffer rewind).
- **Two must-add named scenarios** that pin our exact bugs deterministically:
`long_parked_sleep_clobbered_by_swap` (the clipboard crash) and `fiber_swap_after_main_park`
(the §5 hang — the harness must install a `simulate_infinite_loop=1` main loop, then swap a
fiber post-park).
## §5. Open questions to resolve before any implementation
1. **Run the §2 diagnostic.** Is idle `currData` the orphaned-from-park buffer (§5#1 in 02), a
stuck trampoline guard (§5#2), or both? This decides whether the authority alone suffices or
the main loop must be de-parked, and whether the trampoline self-heal is meaningful on its own.
2. **For de-parking, which suppression shape** (2a UnloadCallback-driven cleanup vs. 2b guarded
`wxEntryCleanupReal`) is least invasive given our `wxApp`/`init.cpp` fork delta? Check
`scripts/kicad-diff-stats.sh` and current wx fork divergence first.
3. **Does sync-clipboard alone stop the load-pcb route hanging, or only stop crashing?** If the
open hangs even with clipboard synchronous, the authority (and likely de-park) is required, not
optional.
---
## Provenance
This dossier was reconstructed from: Claude session
`3301ab3a-c457-4359-be9a-dcf4dcfc9fbc.jsonl`; the in-repo
`docs/docs/features/fix-asyncify-O2-and-modal-promise-rejection/{rtree,clipboard}-*-findings.md` and
`research/threading_*.md`; direct reading of the generated `tests/apps/kicad/pcbnew.js` runtime,
the `wxwidgets/src/wasm/` and `kicad/thirdparty/libcontext/` sources, and the
`scripts/common/` build pipeline; plus web research into Emscripten/Binaryen Asyncify and fibers
(sources listed in [`03-solutions-and-prior-art.md`](03-solutions-and-prior-art.md)).