pcbjam/docs/features/wasm-exceptions/08-wx-app-render-rootcause.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

12 KiB
Raw Blame History

08 — Native wasm-EH: the wx application (Phase 2) — render-failure root cause & fix (2026-06-22)

Phase 2 of the plan in 07-spike-results-and-opinion.md: flip a real wx standalone app (the dialog test) to -fwasm-exceptions end-to-end and see if it runs. It builds and links clean, but rendered blank. This documents the deep-debug that found why, the one-line-of-reasoning root cause, the fix, and an honest account of what is proven vs. still open — including a render/screenshot discrepancy that is not yet resolved.

Status (read this first)

  • Proven, C++-level: the app was destroying its own main window during startup under native wasm-EH. Root cause identified with certainty (instrumented build), and the fix makes the destruction stop (the ~wxNonOwnedWindow destructor no longer fires). That specific bug is fixed, and the why is understood and re-derivable.
  • Proven, in my checks: after the fix, a headless-Chromium load of dialog_test.html showed a full render — #canvas present and visible, 5 buttons, the description text, the event-log control, the status bar; canvases=1, traps=0; and the screenshot I captured showed the complete dialog UI.
  • OPEN / unresolved: the screenshot is reported empty on inspection. My headless ad-hoc check and that observation disagree, and I have not reconciled them. Do not treat the app as "verified rendering" yet. See §Open: the empty-screenshot discrepancy.
  • Not yet done: the real e2e spec in all three browsers; modal dialogs (which now nest Asyncify one level deeper); the rest of the wx suite; cleanup/commit of the Phase-2 changes.

The symptom

dialog built and linked under -fwasm-exceptions -sWASM_LEGACY_EXCEPTIONS=1 (libwx + app, with the post-link hoist+asyncify pipeline). At runtime: boots, prints its startup logs, no JS error, no wasm trap, main thread responsive — but #window-container empty, no visible canvas, the e2e waitForApp (waits for a visible #canvas) would time out. A silent non-render.

How it was found (the debug chain)

Each step ruled out a hypothesis and narrowed the next. All via injected logging in the built glue + instrumented libwx rebuilds (the browser symbolizes wasm frames only as wasm-function[N], so callstack mapping was a dead end on a release build — direct source instrumentation was the reliable tool).

  1. It's not a trap or asyncify/indirect-call corruption. Calling a wasm export (ProcessEvents) from JS post-boot returns cleanly. The "table index out of bounds" seen earlier was an artifact of my own Module.Asyncify probe, not the app.
  2. main "throws unwind" — but that is normal: it's emscripten_exit_with_live_runtime's sentinel, caught and swallowed by handleException (glue line ~5066). A red herring on its own.
  3. The main window is created, then destroyed. createWindow(id=-1 → cssId 0) runs in the frame ctor; then destroyWindow(0) runs — the DOM window is torn down. wxNonOwnedWindow::~wxNonOwnedWindow is the caller (it EM_ASMs destroyWindow(m_cssId)). So the frame's own destructor runs during startup, leaving the app with no window.
  4. The destruction is deliberate, not an exception unwind. Instrumented ~wxNonOwnedWindow to log std::uncaught_exceptions()0. So no C++ exception is in flight; this is a normal destructor call. (This momentarily looked like it ruled out the landing-pad hazard — it didn't; see root cause.)
  5. It happens after OnInit fully completes. Logged OnInit: "frame created" → "Show done, returning true" both print before the destructor. So the teardown is in OnRun, not OnInit.
  6. It happens inside emscripten_set_main_loop. Bracketed wxGUIEventLoop::DoRun's SetSize/Refresh/set_main_loop with logs. Order: "before set_main_loop" → then the ~wxNonOwnedWindow. So the frame dies during the emscripten_set_main_loop(ProcessEvents, 0, 1) call.

Root cause (one paragraph)

emscripten_set_main_loop(fn, fps, simulate_infinite_loop=1) implements "loop forever" by throwing a JS "unwind" exception to abandon the C++ stack — the code after it never runs; the browser drives fn thereafter. That "unwind" propagates out through every C++ frame between set_main_loop and callMain. Under native wasm-EH, the compiler emits catch_all cleanup landing pads (for destructors/RAII) that reliably catch any in-flight exception — including a foreign JS one — run their cleanup, and rethrow. As the "unwind" passes back through wxEntry/OnRun, those cleanup pads fire and destroy wxTopLevelWindows.front() — the main frame — before the browser ever calls ProcessEvents to paint it. uncaught_exceptions()==0 is consistent: the "unwind" is a JS exception, invisible to the C++ exception machinery, so the cleanup-pad destructors see no C++ unwind in progress.

This is the inverse of the documented hazard [asyncify-eh-unwind-landing-pads-unreliable]: under legacy -fexceptions the cleanup landing pads fire unreliably, and that accidentally spared the frame (the destroy that should run, didn't). Native wasm-EH makes them reliable — so the latent "abandon-the-stack vs. run-the-cleanup" conflict finally bites. The JS-EH build never rendered-correctly-by-design here; it rendered correctly by a landing-pad bug canceling a stack-abandon assumption.

The fix

Superseded form (2026-06-23): the fix described in this section is the interim option A (wxWasmRunNestedLoop / setTimeout pump). The final form is the rAF pump wxWasmParkMainLoop (keeps requestAnimationFrame, drops emscripten_set_main_loop entirely) — see 09. Both share the root insight (suspend, don't throw); the root cause above is unchanged. Note the de-park regresses the coroutine suite (Asyncify-nesting wall), fixed by ../async/12 + ../async/13.

Drive the top-level event loop via Asyncify instead of set_main_loop's abandon-the-stack "unwind" — i.e. the same mechanism the nested/quasi-modal loops already use (wxWasmRunNestedLoop, an EM_ASYNC_JS that suspends via Asyncify and pumps ProcessEvents from a setTimeout loop). Asyncify suspends with a return-based unwind that saves the stack rather than abandoning it: no "unwind" JS exception is thrown, so no catch_all cleanup pad fires, so the frame survives. ProcessEvents is then driven by the JS setTimeout(17ms) pump instead of requestAnimationFrame.

src/wasm/evtloop.cpp:

  • wxGUIEventLoop::DoRun — the first (top-level) DoRun no longer falls through to emscripten_set_main_loop(ProcessEvents, 0, 1); it does the initial top-window SetSize/Refresh and then calls wxWasmRunNestedLoop(), exactly like a nested loop. Both levels now share one path.
  • wxGUIEventLoop::ScheduleExit — always wxWasmExitNestedLoop() (resolve the innermost pump); dropped the top-level emscripten_cancel_main_loop() branch (there is no set_main_loop to cancel).

Why this fix and not the alternatives

  • simulate_infinite_loop=0 (don't throw): then DoRun returns, OnRun returns, and wxEntry runs its normal teardown (deletes the TLWs) and exits — same dead frame, plus the app exits. Doesn't help.
  • Suppress/avoid the cleanup pads: they're compiler-generated; you can't selectively disable the one that catches "unwind". Not actionable.
  • Asyncify the top loop: it's the existing, tested suspension primitive in this codebase, it saves the stack (no abandon → no foreign-exception propagation through cleanup pads), and it unifies top-level and nested loops on one mechanism. This is the minimal, principled change, and it lives in the wasm port layer (src/wasm/), per the "fix in the wasm layer" policy.

Evidence

  • Before fix: ~wxNonOwnedWindow cssId=0 uncaught=0 fires right after "before set_main_loop"; #window-container empty.
  • After fix: ~wxNonOwnedWindow no longer fires at startup (definitive C++-level signal the frame survives); headless load reports canvas:true, canvasVisible:true, buttons:5, body text = "wxDialog and wxMessageBox Test…", canvases=1, traps=0; rebuilt clean (debug logging removed) and re-checked → same.

Open: the empty-screenshot discrepancy

My headless-Chromium screenshot showed the full dialog; on inspection the screenshot is reported empty. These disagree and I have not reconciled them. Until resolved, the app is not confirmed rendering. Candidate explanations, to check in order:

  1. Stale image — an earlier (pre-fix) empty capture vs. the post-fix one. Cheapest to rule out.
  2. Headless vs. headed / real engine — my check was headless Chromium; a real/headed browser (esp. WebKit/Firefox) may differ. The whole point of project policy is all three engines; I only spot-checked one, headless.
  3. Ad-hoc load vs. the real e2e spec — my load waits a fixed 6 s; the spec has its own waitForApp/timing and asserts against tracked baseline screenshots. The spec is the authoritative render check and I have not run it yet.
  4. A separate, still-present rendering issue — the frame-destruction fix is proven, but a different paint/canvas problem could remain (e.g. the canvas drawing path, or DOM-widget vs. canvas content). The C++ signal (destructor no longer firing) proves the frame lives; it does not prove every pixel paints.

Immediate next step: run tests/.../dialog through the real e2e spec in Firefox + Chrome + Safari (WebKit) and compare to the baseline screenshots — that reconciles the discrepancy and is the real Phase-2 acceptance gate.

Build-system decisions made for Phase 2 (for review)

All gated so the default (JS-EH) build is unchanged; native EH is opt-in via WX_NATIVE_EH=1.

  • scripts/build-wx-wasm.shWX_NATIVE_EH=1 swaps -fexceptions for -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 in C/CXXFLAGS. (The whole libwx + every app must share one EH model — EH ABI is all-or-nothing.)
  • tests/apps/Makefile.wasmWX_NATIVE_EH adds the same EH flags + -sDYNCALLS=1 to app CXXFLAGS/LDFLAGS.
  • scripts/build-wasm-test.sh — under WX_NATIVE_EH, stubs the emsdk-bundled wasm-opt (v121 crashes asyncifying wasm-EH) so the in-link Asyncify no-ops, then post-link runs the real pipeline on Binaryen v130 over each freshly-linked app + injects the dyncall shims.
  • scripts/common/hoist-and-asyncify.sh (new) — the post-link pipeline: --hoist-cpp-catches (our fork pass) → --asyncify-O2, all on v130. HOIST_KEEP_NAMES=1 preserves the names section through -O2 (added for the callstack debugging here).
  • src/wasm/evtloop.cpp — the loop fix above (the only behavioral wx-port change).

Implications beyond dialog

  • Modals now nest Asyncify two levels deep. Previously: top = set_main_loop (no Asyncify suspend on the main stack), modal = Asyncify (1 level). Now: top = Asyncify, modal = Asyncify (2 levels). This leans harder on the nested-currData save/restore in handlesleep.js ([asyncify-park-throw-root-cause]). Must be tested (open the Custom/Input dialogs).
  • KiCad uses the same evtloop.cpp. If this fix holds for wx apps, it's the same fix KiCad needs under native EH — and it means the set_main_loop-"unwind" conflict is a general wasm-EH×wx-DOM-port interaction, not a dialog-specific quirk. This is exactly the kind of "scale hazard" 07 §6 flagged ("unwind-time landing-pad reliability … might change under wasm-EH") — it changed, and here's the concrete consequence + remedy.

Honest verdict

The deep-debug succeeded at the hard part: a silent blank-render is now a fully understood, evidence-backed root cause with a minimal, principled fix, and the specific bug (frame self-destruct) is provably gone. But Phase 2's acceptance bar — the app verifiably renders and is interactive in all three browsers via the e2e spec — is not met yet, and the empty-screenshot observation is an unresolved flag against it. Next action is reconciliation via the real spec, not more root-causing.