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>
12 KiB
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 (thedialogtest) to-fwasm-exceptionsend-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
~wxNonOwnedWindowdestructor 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.htmlshowed a full render —#canvaspresent 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).
- 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 ownModule.Asyncifyprobe, not the app. main"throwsunwind" — but that is normal: it'semscripten_exit_with_live_runtime's sentinel, caught and swallowed byhandleException(glue line ~5066). A red herring on its own.- The main window is created, then destroyed.
createWindow(id=-1 → cssId 0)runs in the frame ctor; thendestroyWindow(0)runs — the DOM window is torn down.wxNonOwnedWindow::~wxNonOwnedWindowis the caller (itEM_ASMsdestroyWindow(m_cssId)). So the frame's own destructor runs during startup, leaving the app with no window. - The destruction is deliberate, not an exception unwind. Instrumented
~wxNonOwnedWindowto logstd::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.) - It happens after
OnInitfully completes. LoggedOnInit: "frame created" → "Show done, returning true" both print before the destructor. So the teardown is inOnRun, not OnInit. - It happens inside
emscripten_set_main_loop. BracketedwxGUIEventLoop::DoRun'sSetSize/Refresh/set_main_loopwith logs. Order: "before set_main_loop" → then the~wxNonOwnedWindow. So the frame dies during theemscripten_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/setTimeoutpump). The final form is the rAF pumpwxWasmParkMainLoop(keepsrequestAnimationFrame, dropsemscripten_set_main_loopentirely) — see09. Both share the root insight (suspend, don'tthrow); 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)DoRunno longer falls through toemscripten_set_main_loop(ProcessEvents, 0, 1); it does the initial top-windowSetSize/Refreshand then callswxWasmRunNestedLoop(), exactly like a nested loop. Both levels now share one path.wxGUIEventLoop::ScheduleExit— alwayswxWasmExitNestedLoop()(resolve the innermost pump); dropped the top-levelemscripten_cancel_main_loop()branch (there is noset_main_loopto cancel).
Why this fix and not the alternatives
simulate_infinite_loop=0(don't throw): thenDoRunreturns,OnRunreturns, andwxEntryruns 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=0fires right after "before set_main_loop";#window-containerempty. - After fix:
~wxNonOwnedWindowno longer fires at startup (definitive C++-level signal the frame survives); headless load reportscanvas: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:
- Stale image — an earlier (pre-fix) empty capture vs. the post-fix one. Cheapest to rule out.
- 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.
- 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. - 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.sh—WX_NATIVE_EH=1swaps-fexceptionsfor-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1in C/CXXFLAGS. (The whole libwx + every app must share one EH model — EH ABI is all-or-nothing.)tests/apps/Makefile.wasm—WX_NATIVE_EHadds the same EH flags +-sDYNCALLS=1to app CXXFLAGS/LDFLAGS.scripts/build-wasm-test.sh— underWX_NATIVE_EH, stubs the emsdk-bundledwasm-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=1preserves 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-currDatasave/restore inhandlesleep.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 theset_main_loop-"unwind"conflict is a general wasm-EH×wx-DOM-port interaction, not adialog-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.