pcbjam/docs/features/async/07-decisions-and-outcome.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

157 lines
11 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.

# 07 — Decisions and outcome (2026-06-12)
> **STATUS (2026-06-23):** **D4 (kept the throw-based main-loop park) has been reversed.** Native wasm-EH made the `throw "unwind"` fatal (its catch_all cleanup destroys the main frame), so the top loop is now the Asyncify **de-park** ([`../wasm-exceptions/09`](../wasm-exceptions/09-event-loop-deparking-plan.md)). That de-park regressed the coroutine suite — the red scenario D3 said the arbiter lacked — so **Design B is now being built** ([`12`](12-design-b-asyncify-implementation-plan.md) + [`13`](13-design-b-engineering-spec.md)). The D1D5 outcomes below were correct for the JS-EH / throw world.
> The dossier (0106) ended with designs and open questions. This file records what was
> actually decided, built, and deliberately NOT built — and the trigger conditions for
> revisiting each road not taken. Working artifacts: `docs/features/asyncify-arbiter/`
> (baseline.md, redgreen.md), harness in `tests/apps/standalone/asyncify-races/` +
> `tests/asyncify/`, runnable via `npm run test:asyncify:firefox`.
## D1 — Root cause: the dossier's two hang mechanisms were ONE mechanism
The §5 (doc 02) question "orphaned currData vs stuck trampoline guard" was settled by code
trace, then pinned by a deterministic test. At the `emscripten_set_main_loop(...,1)`
`throw "unwind"` park, Asyncify state is clean — but **any OnInit-era fiber swap means
main() is, from then on, executing inside `Fibers.trampoline()`'s `do/while`** (resumed via
`finishContextSwitch → doRewind`). The throw tears through that live frame; the
`trampolineRunning = false` reset is skipped; the guard wedges forever; the first post-idle
swap strands. "Orphaned currData" is structurally impossible (the throw only executes from
straight-line Normal-state code). Corollary: when main's *last* pre-park suspension was a
**sleep**, the same throw instead escapes through that sleep's wakeUp promise reaction —
that was the long-standing `uncaught exception: unwind` rejection family.
Consequence: the trampoline self-heal (`inject-dyncall-shims.sh` §3c, commit `18a9de0`)
is the **structural cure** for the hang, not a band-aid (doc 04 had it demoted), and
**de-parking (02 §7) is not needed for correctness.**
## D2 — Red-green doctrine governed everything
Rule applied throughout: *no fix lands before a test has been observed failing for the
exact disease, and the failing run is recorded* (`asyncify-arbiter/redgreen.md`). Two red
flavors: **natural red** (bug unfixed today) and **ablation red** (fix exists; rebuild the
harness with `SHIM_DISABLE_TRAMPOLINE_HEAL=1` / `SHIM_DISABLE_HANDLESLEEP=1` to prove the
test detects the disease the fix prevents — mutation-testing style). The ablation flags are
permanent injector features; the diseases stay reproducible on demand.
## D3 — Decision: the full Design-A arbiter was NOT built
Doc 05's arbiter (central context registry + deferred-wakeup queue + single-owner
transitions) was the plan. It was dropped because **no scenario could be made red that it
would fix**: at production asyncify semantics (`-sASSERTIONS=0`), the pre-existing
per-sleep capture in `scripts/common/shims/handlesleep.js` (commit `a4ad694`) already
implements Design A's core invariant — *`Asyncify.currData` is a transient register; every
parked context owns its buffer pointer elsewhere* (fibers: in the `wasm_fcontext` struct;
sleeps: in per-sleep closures, restored immediately before that sleep's own rewind). The
scenarios predicted to need the arbiter (`wakeup_during_transition`,
`out_of_order_sleep_resolution`, `sleep_inside_fiber_inside_modal`) are green under the
existing shim and are kept as regression pins.
Why the residual race is structurally hard to hit: promise resolvers only queue
microtasks; microtasks run only at JS-stack-empty; every transition (unwind chain,
trampoline loop, rewind chain) completes synchronously within one task. The only mechanism
that splits a transition across tasks is an `await` inside the chain — the async-ccall
pumps — which are exactly what was hardened (D5).
**Trigger to revisit:** any reappearance of a wakeup-during-transition signature in
`tests/logs/` (e.g. handleSleep entered at `state=2`, `invalid state` aborts), or any new
red the existing shims can't turn green. The arbiter design in doc 05 remains the blueprint;
the deferred-wakeup queue alone is a ~50-line `handlesleep.js` extension.
## D4 — Decision: keep the park throw (+ heal + sentinel catch); defer the asyncify-park
The alternative discussed ("Option C": park main in a never-resolving `EM_ASYNC_JS` sleep
instead of the throw — clean unwind, no exceptional control flow, explicit teardown owner,
natural step one of Design B) was deliberately **deferred**. With the heal (§3c) covering
the fiber face and the `handlesleep.js` sentinel catch covering the sleep face, both damage
classes of the throw are individually fixed and pinned; the asyncify-park would have landed
green-on-green. Costs it would incur now: wx lifecycle change + rebuild, `MainLoop.pause()`
interplay (handleSleep auto-pauses the loop), a permanently-parked buffer, divergence from
stock emscripten idiom.
**Trigger to revisit:** recurring `"unwind"`-leak variants that the sentinel catch doesn't
cover, or starting Design B (fiber-first runtime) in earnest.
## D5 — wx-layer fixes (the bugs that were wx's, not the shim's)
1. **`dialog.cpp` — modal resolver is a LIFO stack.** `Module._endModal` was a single slot
that deleted itself after use; with 3+ nested modals the middle `EndModal` resolved
nothing and its `ShowModal` parked forever. Found by the new `modal_in_modal_in_modal`
scenario — a previously unknown product bug (KiCad nests dialogs routinely). Now:
stable `Module._endModal` dispatcher popping `Module._wxModalResolvers` (LIFO — matches
wx modal discipline and c27's `_wxNestedLoopExit` convention). If programmatic
out-of-order EndModal ever matters, key the dispatcher per-dialog (small change, needs
its own red test first).
2. **Pumps must never stop without resolving.** Both `startModal` (modal pump) and
`wxWasmRunNestedLoop` (c27 quasi-modal pump) caught ProcessEvents rejections and
silently stopped, leaving the suspended C++ stack parked forever. Now both resolve on
error, loudly (`console.error`): the modal cancels with `wxID_CANCEL` (passed in from
C++), the nested loop exits. Self-cancel paths splice their own resolver out of the
stack (they may not be topmost).
3. **`clipbrd.cpp``IsSupported` is synchronous again.** It called the 2 s
permission-gated `js_clipboardHasText` EM_ASYNC_JS from a synchronous-by-contract
predicate on the idle path — creating the long-parked sleeps behind the
`index out of bounds` family. Now answers optimistically from the sync
`js_isClipboardAPIAvailable` probe; the real read stays in `GetData()` (user-gesture
gated).
## D6 — Harness design decisions (tests/apps/standalone/asyncify-races/)
- **KiCad-faithful topology is the point:** ≥1 fiber swap in `OnInit` so main is
trampoline-resumed at park time — the precondition `coroutine-nested` lacked, which is
why it never reproduced the hang. `#mode=sleep-park` flips the last pre-park suspension
to a sleep for the rejection face.
- **`-sASSERTIONS=0` (LDFLAGS_RACES):** emscripten's debug assert ("cannot start an async
operation when one is already in flight") forbids the very multi-parked-sleep states the
production shims exist to handle; the harness must match production semantics.
- **Params travel in the URL hash:** `npx serve`'s cleanUrls redirect for `*.html` drops
query strings.
- **EM_JS bodies use C parameter NAMES** (`aToken`), not `$0` — that is EM_ASM syntax.
- **To make a pump fail, throw from a PENDING EVENT** (`CallAfter` → ProcessPendingEvents
inside the pump's awaited ccall). wx timers fire via
`emscripten_async_call`/`callUserCallback` and bypass pumps entirely.
- **Quiescence probe** (state==Normal, currData==null, nextFiber==0) is checked
synchronously between scenarios; `trampolineRunning` is deliberately excluded from the
sync check (code resumed via a fiber legitimately runs inside the trampoline do/while) —
a genuinely stuck guard is caught by the per-scenario JS watchdogs instead.
- Wedge-prone scenarios run as `#only=` singles on separate page loads; the chained battery
holds only scenarios that can't kill the chain.
## D7 — Attribution ledger (who fixed what, when)
Pre-existing (earlier sessions): per-sleep buffer capture/restore in `handlesleep.js`
(`a4ad694`); trampoline self-heal §3c (`18a9de0`); per-fiber buffers (stock emscripten +
libcontext). This session's code: the 13-line `"unwind"` sentinel catch in
`handlesleep.js`; the three wx fixes (D5); `SHIM_DISABLE_*` ablation flags; the harness +
specs + config; spec tightening (D8). This session's *proof*: before it, the shim and heal
were unverified folklore — nothing failed if you deleted them. Now their diseases reproduce
on demand and their absence fails tests.
## D8 — Acceptance bar moved into the specs
`'uncaught exception: unwind'` tolerance filters DELETED from `pcbnew.spec.ts` /
`eeschema.spec.ts`; `load-pcb.spec.ts` gained a hard clean-console gate over five
signatures (`index out of bounds`, `indirect call to null`, `uncaught exception: unwind`,
`invalid state`, `is not a function`) covering before AND after board render.
## Outcome (final verification)
- Harness: 7/7 green (battery of 4 + three singles); both ablation builds still reproduce
their diseases. wx e2e 291 passed / 1 skipped / 0 failed; coroutine 13/13.
- KiCad e2e after rebuilding **all six apps**: 40 passed / 2 skipped / 0 failed / 0 flaky,
and a sweep of every `tests/logs/kicad/` log finds **zero** corruption signatures and
**zero** `.errors.log` files (baseline had load-pcb `index out of bounds` and
calculator/gerbview `uncaught exception: unwind`).
- Upstream (researched): Fibers/Asyncify JS runtime unchanged since 2020; the single-slot
limitation family is WONTFIX (#9153, #12270, #13302, #16291, #18412). The §3c
try/finally heal is a good candidate for an upstream PR.
## Roads not taken, with triggers
| Option | Status | Revisit when |
|---|---|---|
| Full Design-A arbiter (registry + wakeup queue) | not built | any wakeup-during-transition signature in logs, or a red the shims can't fix |
| Park-via-unresolved-sleep (no throw) | deferred | recurring unwind-leak variants, or Design B work starts |
| De-parking (02 §7, lifecycle surgery) | rejected | only as part of Design B |
| Design B (fiber-first runtime) | long-term option | architectural appetite, not correctness need |
| Per-dialog-keyed modal resolvers | not needed | a real out-of-order EndModal use case (write the red first) |