pcbjam/docs/features/async/04-decisions-tests-open-questions.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

121 lines
7.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.

# 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)).