pcbjam/docs/features/async/04-decisions-tests-open-questions.md
Viktor Vaczi c1ef489cfa feat(wasm-eh): migrate the WASM build to native wasm exceptions (+ 3D viewer default-on)
Replace the legacy Emscripten JS-exceptions model with native wasm-EH (legacy
encoding) across the whole build, keeping Asyncify coroutines working via a
from-source Binaryen --hoist-cpp-catches pre-pass. Net result: native-EH is the
only build mode, the 3D viewer is on by default, and pcbnew shrinks substantially.

Highlights:
- Binaryen submodule everywhere + --hoist-cpp-catches integration in apply-asyncify;
  post-link Asyncify covers every app wasm (not just standalone test wasm).
- Build deps (incl. OpenCASCADE without OCC_CONVERT_SIGNALS) and all KiCad apps
  with -fwasm-exceptions; emscripten_sleep added to the post-link asyncify-imports.
- libcontext fiber entry wired under native exceptions; while-loop main loop +
  currData shim injected into all wx apps.
- Native-EH collab apply fixed: DEBUG-define the embind TU + match all out-of-CMake
  C++ TUs' ABI flags to the core, fixing the vtable-layout skew / mis-dispatch.
- 3D viewer enabled by default (real raytracer linked, not the stub).
- Retire the EH-spike scaffolding; flip the asyncify-races ablation pins to
  shim-redundancy pins (native-EH stays clean with the legacy shims ablated).
- Fix the asyncify-races quiescence check to not require Asyncify.currData==0:
  under the native-EH per-frame-yield top loop the main stack is asyncify-suspended
  every frame, so currData legitimately churns (a freed-but-not-yet-nulled buffer,
  not a leak). Refresh the pcbnew toolbar screenshot baseline for the new kicad.
- CI: drop the obsolete binaryen_version input/env (the build uses the binaryen
  submodule fork's wasm-opt, not a version download); key the wasm-output cache on
  the binaryen submodule SHA instead.

Bumps the wxwidgets + binaryen submodules to their squashed feature commits.

Validated green: all 7 apps native-EH (real 3D in pcbnew); KiCad e2e 63/63
Firefox + Chromium (3D viewer renders); wx 336; coroutine 34/34 both engines;
asyncify 7/7 both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 09:40:26 +02:00

8.4 KiB
Raw Blame History

04 — How the fixes relate, the test matrix, open questions

STATUS (2026-06-23): the top-level set_main_loop(...,1) throw "unwind" treated as current here is gone — replaced by the Asyncify de-park rAF pump (fatal under native wasm-EH; see ../wasm-exceptions/09). The de-park regressed the coroutine suite, and Design B is now being built to fix it (12 + 13). Read below as the pre-de-park analysis.

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.logs 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, 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).