From c1ef489cfabfb933969bf56fe63c3ec63efce703 Mon Sep 17 00:00:00 2001 From: Viktor Vaczi Date: Mon, 29 Jun 2026 19:50:18 +0200 Subject: [PATCH] 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) --- .github/workflows/ci-ubicloud.yml | 5 - .github/workflows/wasm-build.yml | 25 +- .gitmodules | 4 + binaryen | 1 + docker/Dockerfile | 11 +- docker/build.sh | 26 +- docker/docker-compose.yml | 5 + docker/shell.sh | 4 + docs/features/async/02-asyncify-internals.md | 2 + .../04-decisions-tests-open-questions.md | 2 + .../async/05-design-a-js-asyncify-arbiter.md | 2 + .../async/06-design-b-fiber-first-runtime.md | 2 + .../async/07-decisions-and-outcome.md | 2 + ...2-design-b-asyncify-implementation-plan.md | 103 ++++ .../async/13-design-b-engineering-spec.md | 163 +++++++ docs/features/perf/README.md | 278 +++++++++++ docs/features/threading/README.md | 434 +++++++++++++++++ .../wasm-exceptions/03-toolchain-status.md | 9 + .../wasm-exceptions/04-kicad-audit.md | 75 --- .../features/wasm-exceptions/06-spike-plan.md | 267 +++++++++++ .../07-spike-results-and-opinion.md | 244 ++++++++++ .../08-wx-app-render-rootcause.md | 175 +++++++ .../09-event-loop-deparking-plan.md | 160 +++++++ .../wasm-exceptions/10-pthreads-native-eh.md | 339 ++++++++++++++ .../11-collab-apply-native-eh.md | 438 ++++++++++++++++++ docs/features/wasm-exceptions/README.md | 21 +- .../wasm-exceptions/audit-results.txt | 135 ------ docs/features/wasm-exceptions/catch_audit.py | 109 ----- docs/wasm-exceptions-experiment.md | 8 + scripts/binaryen-hoist-pass/build-wasm-opt.sh | 32 ++ .../tests/asyncify-harness.js | 36 ++ .../tests/delegate-orphan-suspend.wat | 32 ++ .../tests/delegate-orphan.wat | 60 +++ .../tests/nested-catchall-exit-block.wat | 49 ++ .../tests/nested-catchall-suspend.wat | 26 ++ scripts/binaryen-hoist-pass/tests/run.sh | 57 +++ .../tests/value-typed-cpp-catch.wat | 20 + .../tests/value-typed-suspend.wat | 10 + scripts/build-wasm-test.sh | 51 ++ scripts/build-wx-wasm.sh | 10 +- scripts/common/apply-asyncify.sh | 227 +++++---- scripts/common/apply-finalize.sh | 16 +- scripts/common/asyncify-imports.txt | 21 + scripts/common/asyncify-removelist.txt | 26 ++ scripts/common/env.sh | 9 + scripts/common/inject-dyncall-shims.sh | 124 ++--- scripts/common/shims/dyncall-binding.js.tmpl | 25 - scripts/deps/build-cairo.sh | 15 +- scripts/deps/build-freetype.sh | 4 +- scripts/deps/build-opencascade.sh | 19 +- scripts/kicad/build-kicad-target.sh | 59 ++- tests/README.md | 15 + tests/apps/.gitignore | 1 + tests/apps/Makefile.wasm | 137 +++++- .../async-preload/async_preload_test.cpp | 296 ++++++++++++ .../standalone/asyncify-races/races_test.cpp | 26 +- .../pthread_ondemand_test.cpp | 179 +++++++ .../raytrace-modal/raytrace_modal_test.cpp | 269 +++++++++++ .../threadpool-real/stubs/advanced_config.h | 8 + .../threadpool-real/stubs/import_export.h | 4 + .../stubs/kicad_pool_stubs.cpp | 28 ++ .../threadpool-real/stubs/pgm_base.h | 12 + .../threadpool-real/threadpool_real_test.cpp | 280 +++++++++++ tests/asyncify/asyncify-races.spec.ts | 94 ++-- tests/e2e/coroutine-async-preload.spec.ts | 67 +++ tests/e2e/coroutine-pthread-ondemand.spec.ts | 46 ++ tests/e2e/coroutine-raytrace-modal.spec.ts | 48 ++ tests/e2e/coroutine-threadpool-real.spec.ts | 73 +++ tests/kicad/pcbnew-collab.spec.ts | 39 ++ tests/package.json | 4 +- tests/playwright-asyncify.config.ts | 6 + tests/serve.json | 3 +- tests/wizard-04-finish-headless.png | Bin 120359 -> 159602 bytes wasm/shims/nanosleep_yield.c | 52 +++ wxwidgets | 2 +- 75 files changed, 4981 insertions(+), 685 deletions(-) create mode 160000 binaryen create mode 100644 docs/features/async/12-design-b-asyncify-implementation-plan.md create mode 100644 docs/features/async/13-design-b-engineering-spec.md create mode 100644 docs/features/perf/README.md create mode 100644 docs/features/threading/README.md delete mode 100644 docs/features/wasm-exceptions/04-kicad-audit.md create mode 100644 docs/features/wasm-exceptions/06-spike-plan.md create mode 100644 docs/features/wasm-exceptions/07-spike-results-and-opinion.md create mode 100644 docs/features/wasm-exceptions/08-wx-app-render-rootcause.md create mode 100644 docs/features/wasm-exceptions/09-event-loop-deparking-plan.md create mode 100644 docs/features/wasm-exceptions/10-pthreads-native-eh.md create mode 100644 docs/features/wasm-exceptions/11-collab-apply-native-eh.md delete mode 100644 docs/features/wasm-exceptions/audit-results.txt delete mode 100644 docs/features/wasm-exceptions/catch_audit.py create mode 100755 scripts/binaryen-hoist-pass/build-wasm-opt.sh create mode 100644 scripts/binaryen-hoist-pass/tests/asyncify-harness.js create mode 100644 scripts/binaryen-hoist-pass/tests/delegate-orphan-suspend.wat create mode 100644 scripts/binaryen-hoist-pass/tests/delegate-orphan.wat create mode 100644 scripts/binaryen-hoist-pass/tests/nested-catchall-exit-block.wat create mode 100644 scripts/binaryen-hoist-pass/tests/nested-catchall-suspend.wat create mode 100755 scripts/binaryen-hoist-pass/tests/run.sh create mode 100644 scripts/binaryen-hoist-pass/tests/value-typed-cpp-catch.wat create mode 100644 scripts/binaryen-hoist-pass/tests/value-typed-suspend.wat create mode 100644 scripts/common/asyncify-imports.txt create mode 100644 scripts/common/asyncify-removelist.txt delete mode 100644 scripts/common/shims/dyncall-binding.js.tmpl create mode 100644 tests/apps/.gitignore create mode 100644 tests/apps/standalone/async-preload/async_preload_test.cpp create mode 100644 tests/apps/standalone/pthread-ondemand/pthread_ondemand_test.cpp create mode 100644 tests/apps/standalone/raytrace-modal/raytrace_modal_test.cpp create mode 100644 tests/apps/standalone/threadpool-real/stubs/advanced_config.h create mode 100644 tests/apps/standalone/threadpool-real/stubs/import_export.h create mode 100644 tests/apps/standalone/threadpool-real/stubs/kicad_pool_stubs.cpp create mode 100644 tests/apps/standalone/threadpool-real/stubs/pgm_base.h create mode 100644 tests/apps/standalone/threadpool-real/threadpool_real_test.cpp create mode 100644 tests/e2e/coroutine-async-preload.spec.ts create mode 100644 tests/e2e/coroutine-pthread-ondemand.spec.ts create mode 100644 tests/e2e/coroutine-raytrace-modal.spec.ts create mode 100644 tests/e2e/coroutine-threadpool-real.spec.ts create mode 100644 wasm/shims/nanosleep_yield.c diff --git a/.github/workflows/ci-ubicloud.yml b/.github/workflows/ci-ubicloud.yml index a8d053a..f83d9f7 100644 --- a/.github/workflows/ci-ubicloud.yml +++ b/.github/workflows/ci-ubicloud.yml @@ -21,10 +21,6 @@ on: paths-ignore: ["docs/**", "**.md"] workflow_dispatch: inputs: - binaryen_version: - description: "Binaryen version for the host asyncify step" - required: false - default: "130" no_cache: description: "Bypass the KiCad WASM output cache (force a full rebuild this run)" type: boolean @@ -44,6 +40,5 @@ jobs: # -O2 via release.yml). 3D viewer ON so 3d-viewer.spec.ts has a viewer. opt_level: "-O1" build_3d_viewer: "ON" - binaryen_version: ${{ inputs.binaryen_version || '130' }} run_tests: true no_cache: ${{ inputs.no_cache || false }} diff --git a/.github/workflows/wasm-build.yml b/.github/workflows/wasm-build.yml index eaf5854..2d854e4 100644 --- a/.github/workflows/wasm-build.yml +++ b/.github/workflows/wasm-build.yml @@ -28,10 +28,6 @@ on: description: "Build the WASM 3D viewer into pcbnew (ON/OFF)" type: string default: "ON" - binaryen_version: - description: "Binaryen version for the host asyncify step" - type: string - default: "130" run_tests: description: "Run the wxWidgets + KiCad e2e suites after building" type: boolean @@ -55,7 +51,6 @@ jobs: timeout-minutes: 300 env: KICAD_LOG_NESTED: "1" - BINARYEN_VERSION: ${{ inputs.binaryen_version }} # Opt level for the asyncify shrink pass (the only opt-dependent step). BINARYEN_OPT_LEVEL: ${{ inputs.opt_level }} BUILD_3D_VIEWER: ${{ inputs.build_3d_viewer }} @@ -77,19 +72,23 @@ jobs: with: { node-version: 20 } # --- cache keys -------------------------------------------------------- - # base = opt-INDEPENDENT (no binaryen ver / opt level): docker compile out. - # final = opt-SPECIFIC: post-processed (asyncify + wasm-opt -O) out. + # base = opt-INDEPENDENT (no binaryen / opt level): docker compile out. + # final = opt-SPECIFIC: post-processed (asyncify + wasm-opt -O) out, keyed + # on the binaryen submodule SHA — the host post-process uses that fork's + # wasm-opt (--hoist-cpp-catches + --asyncify + -O), so bumping the fork + # must bust this cache — plus the opt level. # Both include the 3D flag so 3D-on/off never share an entry. - name: Compute build inputs id: keys run: | KICAD=$(git -C kicad rev-parse HEAD) WX=$(git -C wxwidgets rev-parse HEAD) + BIN=$(git -C binaryen rev-parse --short HEAD) SC=$(node scripts/deploy/wasm-cache-hash.mjs) EPOCH=$(cat .ci-cache-epoch 2>/dev/null || echo 0) THREED='${{ inputs.build_3d_viewer }}' BASE="kbase-${{ runner.os }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}" - FINAL="kwasm-${{ runner.os }}-bin${{ inputs.binaryen_version }}${{ inputs.opt_level }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}" + FINAL="kwasm-${{ runner.os }}-bin${BIN}${{ inputs.opt_level }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}" { echo "kicad=$KICAD"; echo "wx=$WX"; echo "sc=$SC"; echo "epoch=$EPOCH" echo "base_key=$BASE"; echo "final_key=$FINAL" @@ -167,7 +166,6 @@ jobs: if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true' run: | export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G - export BINARYEN_BUILD_FROM_SOURCE=1 echo "Compiling ALL tools (base wasm), 3D viewer=${BUILD_3D_VIEWER}, -j $(nproc)" ./docker/build.sh all --compile-only --build-deps -j "$(nproc)" ls -lh output/*.wasm @@ -207,14 +205,15 @@ jobs: # PHASE 2 (any final miss): pure-host post-process on the base wasm — # dyncall + finalize + asyncify + `wasm-opt ${opt_level}`. The ONLY - # opt-dependent work. No container; get-wasm-opt self-provisions Binaryen. + # opt-dependent work. No container; the binaryen submodule fork's wasm-opt + # is built on demand via scripts/binaryen-hoist-pass/build-wasm-opt.sh. - name: Host post-process (asyncify + wasm-opt ${{ inputs.opt_level }}) if: steps.final-cache.outputs.cache-hit != 'true' run: | - export KICAD_PIPELINE=1 BINARYEN_CORES=16 BINARYEN_BUILD_FROM_SOURCE=1 - echo "Post-processing ALL tools with ${BINARYEN_OPT_LEVEL}, BINARYEN_VERSION=${BINARYEN_VERSION}" + export KICAD_PIPELINE=1 BINARYEN_CORES=16 + echo "Post-processing ALL tools with ${BINARYEN_OPT_LEVEL}" ./docker/build.sh all --postprocess-only - echo "wasm-opt used:"; ./scripts/common/get-wasm-opt.sh --version 2>/dev/null || true + echo "wasm-opt used:"; "$(./scripts/binaryen-hoist-pass/build-wasm-opt.sh 2>/dev/null)" --version || true ls -lh output/*.wasm - name: Save FINAL WASM output cache diff --git a/.gitmodules b/.gitmodules index 39421c0..8c1e77e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,3 +10,7 @@ path = web/pcbjam-shared url = https://github.com/emergence-engineering/pcbjam-shared.git branch = main +[submodule "binaryen"] + path = binaryen + url = https://github.com/emergence-engineering/binaryen + branch = wasm-port diff --git a/binaryen b/binaryen new file mode 160000 index 0000000..a6c26e3 --- /dev/null +++ b/binaryen @@ -0,0 +1 @@ +Subproject commit a6c26e3c606e381603b2763a1b2f10f3ddb2ca07 diff --git a/docker/Dockerfile b/docker/Dockerfile index e65a7f1..4b09f65 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -25,10 +25,13 @@ RUN apt-get update && apt-get install -y \ unzip \ && rm -rf /var/lib/apt/lists/* -# Install emsdk from source (same approach as scripts/setup-emsdk.sh) -# EMSCRIPTEN_VERSION must match scripts/common/versions.sh -ARG EMSCRIPTEN_VERSION=4.0.2 -RUN git clone https://github.com/emscripten-core/emsdk.git /emsdk \ +# Install emsdk from source (same approach as scripts/setup-emsdk.sh). The version is the SINGLE +# source of truth in scripts/common/versions.sh, passed in as a build arg by docker/build.sh (via the +# compose build.args). Do NOT hardcode it here — fail fast if the arg is missing so a stale image can +# never silently use the wrong toolchain. +ARG EMSCRIPTEN_VERSION +RUN test -n "${EMSCRIPTEN_VERSION}" || { echo "EMSCRIPTEN_VERSION build-arg required (scripts/common/versions.sh)"; exit 1; }; \ + git clone https://github.com/emscripten-core/emsdk.git /emsdk \ && cd /emsdk \ && ./emsdk install ${EMSCRIPTEN_VERSION} \ && ./emsdk activate ${EMSCRIPTEN_VERSION} diff --git a/docker/build.sh b/docker/build.sh index 0196bdd..9a8c635 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -22,7 +22,7 @@ # # The build is split into two phases: # 1. Docker: Compile KiCad to WASM (without asyncify) -# 2. Host: dyncall shims + finalize + asyncify + -O2 (Binaryen via get-wasm-opt.sh) +# 2. Host: dyncall shims + finalize + asyncify + -O2 (Binaryen submodule via build-wasm-opt.sh) # # KICAD_PIPELINE=1 (multi-app builds only): run phase 2 of each app in the # background while the next app compiles in the container. wasm-opt is @@ -49,6 +49,11 @@ source "$(dirname "$0")/../scripts/common/logging.sh" # Build-progress markers (parsed by scripts/build-monitor.sh). source "$(dirname "$0")/../scripts/common/stages.sh" +# Pinned toolchain version (single source of truth). Exported so the compose build.args can pass it +# into the Docker image's emsdk install — bumping the toolchain is then a one-line edit in versions.sh. +source "$(dirname "$0")/../scripts/common/versions.sh" +export EMSCRIPTEN_VERSION + set -e # Emit a completion/failure marker no matter how the build ends, so the monitor @@ -120,7 +125,7 @@ echo "Building app: ${APP_NAME}" # --postprocess-only — only the host post-process (dyncall + finalize + # asyncify + wasm-opt -O$BINARYEN_OPT_LEVEL) on the # existing output/ base wasm; NO container needed -# (get-wasm-opt.sh self-provisions Binaryen). +# (build-wasm-opt.sh self-provisions the Binaryen submodule). # Extracted here so they are NOT forwarded to the inner build-.sh scripts. PHASE="both" _FILTERED=() @@ -143,8 +148,9 @@ fi # which is pure host work on the already-built base wasm in output/. if [[ "$PHASE" != "postprocess" ]]; then -# Start container if not running -docker compose -f docker/docker-compose.yml up -d +# Start container if not running. --build so the image is rebuilt when the pinned EMSCRIPTEN_VERSION +# (build-arg from versions.sh) changes; Docker layer-caches it to a near no-op when unchanged. +docker compose -f docker/docker-compose.yml up -d --build # Sync source code to container volume (fixes macOS Docker VirtioFS issues) # Use --checksum to only transfer files with different CONTENT, not timestamps. @@ -217,7 +223,7 @@ compile_app() { # emsdk_env.sh, so the build shell would lack emcc/embuilder on PATH. Setting # EMSDK lets scripts/common/env.sh source /emsdk/emsdk_env.sh and activate the toolchain. docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \ - -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-OFF}" \ + -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-ON}" \ kicad-wasm-builder \ "/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}" @@ -266,6 +272,8 @@ postprocess_app() { # Apply asyncify transformation on host. The converter is a synchronous node # CLI built with ASYNCIFY=0, so asyncify is unnecessary and would be wrong. + # apply-asyncify always runs the --hoist-cpp-catches pass FIRST (native wasm-EH is the only build + # mode) so Asyncify can suspend from inside C++ catch arms, then asyncify + removelist + -O2. if [ "$app" != "sym_convert" ]; then kw_stage asyncify ./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm" @@ -349,7 +357,7 @@ elif [[ "$PHASE" == "postprocess" ]]; then # wasm (no container). Parallelize across apps when pipelining. if [[ "${KICAD_PIPELINE:-0}" == "1" ]] && [ "$TOTAL_APPS" -gt 1 ]; then mkdir -p "$PIPELINE_LOG_DIR" - ./scripts/common/get-wasm-opt.sh >/dev/null # pre-warm Binaryen once + ./scripts/binaryen-hoist-pass/build-wasm-opt.sh >/dev/null # pre-warm Binaryen (submodule) once _install_pipeline_trap for app in "${APPS[@]}"; do pipeline_postprocess "$app" @@ -364,9 +372,9 @@ elif [[ "${KICAD_PIPELINE:-0}" == "1" ]] && [ "$TOTAL_APPS" -gt 1 ]; then # both, pipelined: overlap app[i+1]'s container compile with app[i]'s host # post-process (KICAD_PIPELINE=1). mkdir -p "$PIPELINE_LOG_DIR" - # Pre-warm the Binaryen download once — two concurrent postprocesses racing - # the first download would collide on the extract/mv. - ./scripts/common/get-wasm-opt.sh >/dev/null + # Pre-build the Binaryen submodule once — two concurrent postprocesses racing + # the first from-source build would collide. + ./scripts/binaryen-hoist-pass/build-wasm-opt.sh >/dev/null _install_pipeline_trap idx=1 for app in "${APPS[@]}"; do diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 372b325..aed21d5 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -3,6 +3,11 @@ services: build: context: .. dockerfile: docker/Dockerfile + args: + # Single source of truth: scripts/common/versions.sh, exported into the env by the scripts + # that drive compose (docker/build.sh, docker/shell.sh). No default -> the Dockerfile fails + # fast if it's unset, so the image can't be built against the wrong toolchain. + EMSCRIPTEN_VERSION: ${EMSCRIPTEN_VERSION:?source scripts/common/versions.sh before docker compose} # Container name is auto-generated with project prefix (set in build.sh) # Resource limits. Defaults are sized for a dev Mac (Docker Desktop VM). diff --git a/docker/shell.sh b/docker/shell.sh index ab2f3a6..9bb50b9 100755 --- a/docker/shell.sh +++ b/docker/shell.sh @@ -4,6 +4,10 @@ set -e cd "$(dirname "$0")/.." +# Pinned toolchain version (single source of truth) -> compose build.args needs it in the env. +source "$(dirname "$0")/../scripts/common/versions.sh" +export EMSCRIPTEN_VERSION + # Start container if not running docker compose -f docker/docker-compose.yml up -d diff --git a/docs/features/async/02-asyncify-internals.md b/docs/features/async/02-asyncify-internals.md index e1e3113..bc32231 100644 --- a/docs/features/async/02-asyncify-internals.md +++ b/docs/features/async/02-asyncify-internals.md @@ -1,5 +1,7 @@ # 02 — The machine: Asyncify internals and control flows +> **STATUS (2026-06-23):** the top-level `emscripten_set_main_loop(...,1)` `throw "unwind"` this doc treats as current **is gone** — it was fatal under native wasm-EH and was replaced by the Asyncify **de-park** rAF pump (`wxWasmParkMainLoop`); see [`../wasm-exceptions/09`](../wasm-exceptions/09-event-loop-deparking-plan.md). The de-park regressed the coroutine suite, and **Design B is now being built to fix it** ([`12`](12-design-b-asyncify-implementation-plan.md) + [`13`](13-design-b-engineering-spec.md)). Read below as the pre-de-park analysis (the internals are still accurate). + This is the legible model: what suspends, who owns the single slot, and exact line-by-line control flow for the park, the hang, the crash, and **de-parking**. diff --git a/docs/features/async/04-decisions-tests-open-questions.md b/docs/features/async/04-decisions-tests-open-questions.md index 905a4b2..666cc39 100644 --- a/docs/features/async/04-decisions-tests-open-questions.md +++ b/docs/features/async/04-decisions-tests-open-questions.md @@ -1,5 +1,7 @@ # 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`](../wasm-exceptions/09-event-loop-deparking-plan.md)). The de-park regressed the coroutine suite, and **Design B is now being built to fix it** ([`12`](12-design-b-asyncify-implementation-plan.md) + [`13`](13-design-b-engineering-spec.md)). 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. diff --git a/docs/features/async/05-design-a-js-asyncify-arbiter.md b/docs/features/async/05-design-a-js-asyncify-arbiter.md index d62fdd6..16f8162 100644 --- a/docs/features/async/05-design-a-js-asyncify-arbiter.md +++ b/docs/features/async/05-design-a-js-asyncify-arbiter.md @@ -1,5 +1,7 @@ # 05 - Design A: JS Asyncify arbiter +> **STATUS (2026-06-23):** the throw-based top loop assumed here **is gone** (de-park; [`../wasm-exceptions/09`](../wasm-exceptions/09-event-loop-deparking-plan.md)). This arbiter is the **core of Design B's scheduler**, now being implemented — the de-park created the red scenario (coroutine regression) that 07/D3 said this arbiter lacked. See [`12`](12-design-b-asyncify-implementation-plan.md) + [`13`](13-design-b-engineering-spec.md); this doc's arbiter design is reused there. + > Goal: fix the current system with the smallest architectural move. Keep > `EM_ASYNC_JS` modal/clipboard/font calls and Emscripten fibers, but introduce one JS-side > authority that owns `Asyncify.currData`, `Asyncify.state` transitions, the fiber trampoline, diff --git a/docs/features/async/06-design-b-fiber-first-runtime.md b/docs/features/async/06-design-b-fiber-first-runtime.md index 969dc83..8a84f23 100644 --- a/docs/features/async/06-design-b-fiber-first-runtime.md +++ b/docs/features/async/06-design-b-fiber-first-runtime.md @@ -1,5 +1,7 @@ # 06 - Design B: fiber-first async runtime +> **STATUS (2026-06-23):** Design B is **now being implemented** on Asyncify — see [`12`](12-design-b-asyncify-implementation-plan.md) (plan/phases/test-matrix) and [`13`](13-design-b-engineering-spec.md) (engineering spec/work log). The de-park ([`../wasm-exceptions/09`](../wasm-exceptions/09-event-loop-deparking-plan.md)) replaced the top-level `throw` with an Asyncify park and regressed the coroutine suite — the red scenario this design fixes. External research (Ruby-WASM, Julia-WASM, Qt-for-WASM) confirms the fiber-scheduler is the proven path. + > Goal: make the architecture conceptually cleaner by reducing the number of suspension > primitives. Instead of having tool coroutines use fibers while modal/clipboard/font/nested loops > use `EM_ASYNC_JS` sleeps, put every blocking-looking operation onto a fiber-like runtime and let diff --git a/docs/features/async/07-decisions-and-outcome.md b/docs/features/async/07-decisions-and-outcome.md index 8c18611..3747c1e 100644 --- a/docs/features/async/07-decisions-and-outcome.md +++ b/docs/features/async/07-decisions-and-outcome.md @@ -1,5 +1,7 @@ # 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 D1–D5 outcomes below were correct for the JS-EH / throw world. + > The dossier (01–06) 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/` diff --git a/docs/features/async/12-design-b-asyncify-implementation-plan.md b/docs/features/async/12-design-b-asyncify-implementation-plan.md new file mode 100644 index 0000000..8495dc9 --- /dev/null +++ b/docs/features/async/12-design-b-asyncify-implementation-plan.md @@ -0,0 +1,103 @@ +# 12 — Design B on Asyncify: implementation plan to make suspensions compose + +> How to realize the dossier's **Design B** ([`06`](06-design-b-fiber-first-runtime.md)) on the +> **current Asyncify toolchain — no JSPI**. The goal is concrete: make the parked main loop, modal +> dialogs, nested loops, clipboard/font waits, and **tool coroutines** all coexist without +> corrupting Asyncify's single suspension slot, so the de-park's coroutine regression goes green — +> under both `-fexceptions` and `-fwasm-exceptions`. Builds on the internal audit (current building +> blocks, the gap) and Design A ([`05`](05-design-a-js-asyncify-arbiter.md)). + +## Why now — the red scenario the dossier didn't have + +Doc 07/D3 shelved Design A's arbiter because *"no scenario could be made red that it would fix"* — at production semantics the per-sleep `handlesleep.js` capture already satisfied the core invariant. **The de-park changed that.** Measured in `wasm-exceptions/09`: config 3 (JS-EH + de-park) **fails** the 6 coroutine tests that config 1 (no de-park) **passes**. We now have a deterministic red test that only a scheduler fixes. The de-park and Design B are **coupled**: the de-park is *required* for native-EH (the `throw "unwind"` is fatal under wasm-EH catch_all), it breaks coroutines, and Design B is the fix. + +## The physics we must obey (why this is hard) + +Asyncify is **one** `Asyncify.currData` (active save-buffer pointer) + **one** `Asyncify.state` (`Normal`/`Unwinding`/`Rewinding`). The law: **at most one unwind-or-rewind in flight at a time**; it must begin at `state==Normal` and fully complete before the next. But **many contexts may be *parked* at once**, each holding its own durable buffer (a parked context = `state Normal`, its stack saved in *its* buffer, waiting for a wake). + +- **Fibers** (KiCad tool coroutines via libcontext) already give per-context buffers (`wasm_fcontext.asyncify_stack`, 64 KB each) — durable storage is fine. But the *act* of swapping still drives the single global register. +- **`handleSleep`/`handleAsync`** (modals, clipboard, the de-park park, the per-tick `ccall`) take buffers from `Asyncify.allocateData`; only the live `currData` register remembers a parked one — `handlesleep.js` patches this for **one** level of sleep nesting, and is **blind to `handleAsync` and to fibers**. + +**The de-park bug, precisely:** `wxWasmParkMainLoop` is a `handleAsync` suspend that is **live for the app's whole life**, and each rAF tick's `await ccall('ProcessEvents',{async:true})` is a second `handleAsync` suspend. A coroutine fiber-swap is then a **third** unwind, attempted while the slot is dirty / `state != Normal` → `Aborted(invalid state: 1)`. Three uncoordinated writers of one slot. + +## Architecture: one scheduler owns the slot; everything is a context + +The universal rule (06): **no API touches `Asyncify` directly. APIs ask the scheduler to park/wake contexts. The scheduler alone performs Asyncify transitions.** + +``` +Scheduler (JS) — the single authority + owns: Asyncify.currData, Asyncify.state, the fiber trampoline + registry: ctx = { id, kind: main|modal|nested|coroutine|sleep, buffer, status, wakeReason, result } + readyQueue + drain(): + a wake event marks a ctx READY (it does NOT rewind directly) + drain() resumes the next ready ctx ONLY when state==Normal && no transition in flight + transitions (the only code that writes currData): + park(ctx) = set currData=ctx.buffer; start_unwind; (slot now free, ctx parked) + resume(ctx) = set currData=ctx.buffer; start_rewind; doRewind +``` + +Every blocking-looking thing becomes a context that *yields* and is later *resumed*. A coroutine swap becomes "park ctx A, resume ctx B" — a normal scheduler operation serialized with the main loop and modals, exactly the doc-11 cure: *"if the pump and render yields were both scheduler-owned fiber contexts, 'render yields while the pump is parked' becomes a normal context switch instead of an illegal nested unwind."* + +## The gap — what to build (from the internal audit) + +None of these exist today: (1) a single owner of `currData`/`state`/the trampoline; (2) a scheduler-owned **fiber** context for the main loop + pump (today it's a `handleAsync` park, not a fiber); (3) a deferred-wakeup ready-queue/drain (today wakeups call `doRewind` inline); (4) **`handleAsync` coverage** (the park + the per-tick ccall are entirely unprotected); (5) sleep contexts promoted from "restore one pointer" to "registered context"; (6) trampoline ownership as a scheduler invariant; (7) a lifetime owner coordinated with `currData` management (the de-park gave us D1/lifetime without D2/ownership). + +## Phased implementation (each phase gated by the red harness) + +**Phase 0 — Red-green harness (2–3 days).** Make the coroutine regression a deterministic, minimal red test in `tests/asyncify/` (and a CPP test app): a tool-style fiber swap *while the main loop is parked* and *while a modal pump is live*. Reproduce `invalid state: 1` reliably in all three engines. This is the acceptance gate for every later phase. Also fold the 6 failing `coroutine*` specs in as the integration gate. + +**Phase 1 — The scheduler core (1–2 weeks). The likely coroutine fix.** Extend `scripts/common/shims/handlesleep.js` into the `AsyncifyArbiter` of doc 05, but covering everything the de-park introduced: +- Own `Asyncify.currData`/`state` + `Fibers.trampoline`; make `currData` a *derived* register set only inside a managed transition; the registry records are the truth. +- **Register `handleAsync`** (wrap it as `handleSleep` is wrapped) so the de-park park and the per-tick `ccall` are tracked contexts, not invisible slot-writers. +- **Track fiber buffers** at `_emscripten_fiber_swap` (`oldFiber+20`/`newFiber+20`) so a coroutine swap is a managed transition. +- **Deferred-wakeup `drain()`** with *explicit* completion signals from `stop_rewind`/`maybeStopUnwind` (not JS `finally` — `doRewind` can re-enter and unwind again before returning). +- Keep it JS-only — **no C++ restructuring yet.** Build, run Phase 0. If green, the coroutine regression is fixed at lowest risk. If still red (the permanent `handleAsync` park can't be made a clean parked context), escalate to Phase 2. + +**Phase 2 — Root fiber for the main loop (≈1 week). The clean cure.** Replace the `handleAsync` park with a scheduler-owned **fiber**: run `main → wxEntry → OnRun → DoRun` inside a managed root fiber (06's B2). The main loop *yields its fiber* to the scheduler instead of `handleAsync`-parking. Crucially, **drive `ProcessEvents` from the wasm-side scheduler (the root fiber calls it directly), not the current JS-side `await ccall('ProcessEvents',{async:true})`** — that JS-awaits-a-suspending-export boundary is the Emscripten #13302 corruption hazard (see Prior art). The rAF/`setTimeout` tick just resumes the root fiber (or returns through `set_main_loop`, Ruby/Julia-style, to keep the top off Asyncify entirely). Now the main loop is a sibling context to the coroutines and modals — no permanent `handleAsync` occupant, every swap is fiber↔fiber under the scheduler. This is the definitive fix if Phase 1's "park-as-context" proves fragile. + +**Phase 3 — Migrate the waits to one yield API (1–2 weeks). The full Design B.** Add the C++ API and route the ad-hoc suspends through it: +```cpp +WAKE_TOKEN wasm_begin_async_wait(...); +int wasm_yield_until(WAKE_TOKEN); // park current ctx, run scheduler +void wasm_resolve_wait(WAKE_TOKEN, int result); // mark ctx ready +``` +Reimplement `wxDialog::ShowModal` (`dialog.cpp` `startModal`), `wxGUIEventLoop` nested `DoRun` (`evtloop.cpp` `wxWasmRunNestedLoop`), clipboard, and font enum as `yield_until` waits. Removes the second suspension family entirely; the LIFO resolver stacks (`_wxModalResolvers`, `_wxNestedLoopExit`) become scheduler ready/wait bookkeeping. + +**Phase 4 — Lifetime + cleanup (few days).** Coordinate the de-park's lifetime (D1) with the scheduler (D2): the browser/scheduler owns app lifetime; `wxEntryCleanupReal`/`OnExit` deferred to real exit/unload; `emscripten_cancel_main_loop` + teardown ordered after the root fiber resolves. (Already half-done in `evtloop.cpp` `ScheduleExit`.) + +## Test matrix (doc 06 + nesting + EH) + +Each asserts **no crash, no hang, correct return value, app stays interactive, no cleanup during steady-state pumping** — and runs in **Firefox + Chrome + Safari** and under **both `-fexceptions` and `-fwasm-exceptions`**: +- the 6 regressed `coroutine`/`coroutine-nested`/`coroutine-pthread` specs (the gate); +- `ShowModal` from root, from a tool coroutine; `ShowQuasiModal` from a coroutine; nested modal inside quasi-modal; +- coroutine swap **while** a modal pump is live (the Phase-0 red test); +- clipboard read from root and from a coroutine; font enum during startup; +- the **raytracer** threading suite (the doc-11 nesting wall) multi-core; +- exit/unload cleanup after parked contexts exist; +- a `-sASYNCIFY_ASSERTIONS=1` pass + a production `-sASSERTIONS=0` pass (the dossier's "production semantics already satisfy the invariant" claim must be re-validated post-de-park). + +## Risks + mitigations + +- **A partial arbiter is worse than none** (doc 05) — one path still writing `currData` behind the scheduler corrupts silently. → enumerate *every* `currData` writer (`handleSleep`, `handleAsync`, `_emscripten_fiber_swap`, `finishContextSwitch`, the park, the ccall), route all through the scheduler, assert on stray writes in dev builds. +- **Trampoline wedge** (`Fibers.trampolineRunning` stuck after a mid-flight unwind). → scheduler *owns* the trampoline; keep the `inject-dyncall-shims §3c` self-heal as a belt-and-suspenders. +- **Lifetime cleanup too early** → Phase 4 ordering; defer wx teardown to unload. +- **Reentrancy / out-of-order resolution** → explicit tests; keep wx modal-disabling semantics. +- **Native-EH coexistence** — the scheduler and the `HoistCppCatches` pass must compose (suspend-inside-catch under the scheduler). → test the whole matrix under `-fwasm-exceptions`, including a modal opened from inside a `catch`. +- **Starvation** → FIFO ready-queue; diagnostics for context age. + +## Effort + +Phase 0 ≈ 2–3 d · Phase 1 ≈ 1–2 wk · Phase 2 ≈ 1 wk · Phase 3 ≈ 1–2 wk · Phase 4 ≈ few d. **Coroutine fix = Phase 0–1 (+2 if needed) ≈ 2–3 wk; full Design B ≈ 4–6 wk** including the test matrix. Phase 1 is the high-value, lowest-risk step and may suffice on its own. + +## Prior art (external research) + +**The Asyncify fiber scheduler (this plan) is the proven path** for "event loop + blocking `ShowModal` + green threads" — it ships in real runtimes, and the comparisons sharpen two implementation details. + +- **Qt for WebAssembly** (closest analog) uses a deliberate **two-tier** scheme: top-level `QApplication::exec()` uses Emscripten's `simulateInfiniteLoop` throw — which keeps the top loop **off Asyncify so the single slot stays free** — while `QDialog::exec()`/nested `QEventLoop::exec()` consume the one Asyncify slot. **This is the inverse of what our de-park did** (the de-park made the *top* loop a live Asyncify occupant, consuming the slot — exactly why coroutines broke). Qt's scheme caps at *one* modal at a time and the Qt team calls Asyncify "not quite scaling to Qt-sized software" — i.e. a single-slot scheme *without* a real scheduler hits a wall; the per-fiber-buffer scheduler (Design B) is the way past it. [Qt commit 6d039a5e; Qt dev ML, June 2024] +- **Ruby-WASM / Julia-WASM** implement this design directly: a **root fiber that *is* the browser event loop**; tasks/coroutines are fibers, each with its own C stack + `asyncify_data` buffer; the scheduler resumes the next ready fiber; "yield to browser" is `emscripten_sleep(0)` *or* returning through the `set_main_loop` callback (the latter keeps the top off Asyncify entirely). That is precisely Design B's B2 root-fiber — already shipping in production runtimes. [Julia PR #32532; Emscripten fiber PR #9859] +- **Pyodide** pre-JSPI used stackless CPS (`WebLoop` + `setTimeout(0)` per task) — not retrofittable to C++; post-JSPI uses per-`promising`-entry stacks plus explicit **spill-stack** save/restore. +- **Dart/Flutter, Blazor, Unity** all use compiler-lowered stackless state machines — not applicable to a C++ toolkit. + +**Two findings that sharpen the plan:** +1. **The JS-boundary async-return hazard (Emscripten #13302):** returning a value to JS from a wasm export that internally `fiber_swap`s is broken — *"within-wasm scheduling is fine; the JS-awaits-a-suspending-wasm-export boundary is not."* Our per-tick `await ccall('ProcessEvents',{async:true})` is exactly that boundary. **Design B must drive `ProcessEvents` from the wasm-side scheduler (the root fiber), not via a JS async `ccall`** — folded into Phase 2 below. +2. **JSPI would not have helped this case anyway** (independently confirming the decision to scratch it): the wit-bindgen analysis shows that when the whole scheduler lives inside one app context, *a single `promising` root = a single suspension unit* — JSPI gives no fiber-multiplexing benefit unless each fiber is separately surfaced as a `promising` export (awkward; Chrome also showed a ~350× per-suspension penalty on the JS→wasm path). Pattern-C / Asyncify is the right tool regardless of JSPI availability. diff --git a/docs/features/async/13-design-b-engineering-spec.md b/docs/features/async/13-design-b-engineering-spec.md new file mode 100644 index 0000000..2de9a5f --- /dev/null +++ b/docs/features/async/13-design-b-engineering-spec.md @@ -0,0 +1,163 @@ +# 13 — Design B: engineering spec & work log + +> The granular, file-by-file implementation spec for [`12`](12-design-b-asyncify-implementation-plan.md) +> (the plan/phases/test-matrix). This document is the **engineering design + per-phase checklists**, +> and is updated as a **work log** as the phases land. Build it on Asyncify — no JSPI. + +## 0. State of the world (2026-06-23) + +- The **de-park is live**: `wxwidgets/src/wasm/evtloop.cpp` `DoRun` at depth 0 calls `wxWasmParkMainLoop()` (an `EM_ASYNC_JS`/`Asyncify.handleAsync` suspend driving an rAF `await ccall('ProcessEvents',{async:true})` pump). The old `emscripten_set_main_loop(...,1)` throw is gone (it was fatal under native wasm-EH; see `wasm-exceptions/08`+`09`). +- **Measured regression** (`wasm-exceptions/09`): config 1 (no de-park) passes all; config 3 (JS-EH + de-park) fails the 6 `coroutine`/`coroutine-nested`/`coroutine-pthread` specs; config 2 (native-EH + de-park) fails those 6 + raytracer(5) + main-app(10). The **6 coroutine failures are the de-park's**, both EH models. +- **The gate** for this work = those 6 specs going green again, plus a minimal unit repro (Phase 0). +- **Stale code to clean up in Phase 1:** `scripts/common/shims/handlesleep.js` still has the `"unwind"`-sentinel swallow (lines ~57-68) referencing `set_main_loop(...,1)` — dead under the de-park; the rewrite subsumes it. + +## 1. The scheduler — JS design (the heart of the fix) + +### 1.1 What exists today (`handlesleep.js`) +Per-sleep `currData` capture/restore for **one** level of nesting, **`handleSleep` only**: +- Wraps `Asyncify.allocateData` to record which buffer pointer the active `handleSleep` allocated (`ctx.capturedData`). +- In the `wakeUp` callback, restores `Asyncify.currData = ctx.capturedData` before `handleSleep` does `_asyncify_start_rewind`+`doRewind`, so a fiber swap that clobbered the slot during the `await` doesn't make the sleep rewind the wrong buffer. +- **Blind to `handleAsync`** (the de-park park + the per-tick ccall) **and to fibers** (libcontext buffers don't come from `allocateData`). + +### 1.2 The scheduler object (`AsyncifyScheduler`, replaces the shim) +A single JS authority that is the **only** writer of `Asyncify.currData` during managed transitions. State: +``` +contexts: Map // every parked/running suspendable thing +readyQueue: id[] // FIFO of contexts whose wake fired +running: id | null // the one context currently executing +transitionRunning: bool // an unwind or rewind is in flight +trampolineRunning: bool // a fiber-swap trampoline is mid-flight +``` +`ctx = { id, kind: 'main'|'modal'|'nested'|'coroutine'|'sleep', buffer /*dataPtr*/, status: 'running'|'parked'|'ready', wakeReason, result, cancel }`. + +**Governing rule (from doc 05):** `Asyncify.currData` is *not* durable state — it is a register loaded from the current context only at the instant of a managed transition. The `contexts` records are the truth. **Many parked; at most one unwinding-or-rewinding.** + +### 1.3 The four hooks (intercept every `currData` writer) +1. **`Asyncify.handleSleep`** — register a `sleep` ctx (today's capture), but route its wakeup through `drain()` (below), not an inline `doRewind`. +2. **`Asyncify.handleAsync`** — wrap it the same way. **New, load-bearing:** the de-park park and the per-tick `await ccall` are `handleAsync`; they must be tracked contexts, not invisible slot-writers. +3. **`_emscripten_fiber_swap`** — *track* (not allocate) the per-fiber buffers `oldFiber+20` / `newFiber+20` so a coroutine swap is a managed transition the scheduler knows about. +4. **`Fibers.trampoline`** — own it (and the `trampolineRunning` guard); keep `inject-dyncall-shims §3c` self-heal as backstop. + +### 1.4 The transitions (the only code that writes `currData`) +``` +park(ctx): assert state==Normal; currData=ctx.buffer; start_unwind // ctx now parked, slot free +resume(ctx): assert state==Normal; currData=ctx.buffer; start_rewind; doRewind +drain(): if (transitionRunning || trampolineRunning || state!=Normal || !readyQueue.length) return; + resume(contexts[readyQueue.shift()]) +``` +**Deferred wakeup:** a Promise/event resolution **marks a ctx ready and calls `scheduleDrain()`** — it never calls `doRewind` inline (because `doRewind` can re-enter wasm and unwind again before returning). `drain` runs only when the slot is provably free, and receives **explicit transition-completion signals** by wrapping `_asyncify_stop_rewind` / `Asyncify.maybeStopUnwind` (clear `transitionRunning`, then `scheduleDrain()`), not a JS `finally`. + +### 1.5 Invariants (assert in dev builds; doc 05 §invariants) +(1) only the scheduler writes `currData` during managed transitions; (2) ≤1 context unwinding-or-rewinding; (3) Promise resolution never `doRewind`s directly while a transition runs; (4) `currData` may be null while contexts are parked — records are truth; (5) every `allocateData`/fiber buffer belongs to exactly one ctx; (6) the scheduler owns the trampoline; (7) a parked ctx's buffer is never reused until it resumes-and-completes; (8) FIFO readyQueue (no starvation). + +> **Correction (verified in the glue during Phase 0, 2026-06-23):** `handleAsync` routes through the wrapped `handleSleep` — `handleAsync(fn) = handleSleep(wakeUp => fn().then(wakeUp))` (`coroutine_test.js:9989`) — so the shim **already covers** the de-park park (`wxWasmParkMainLoop`) and the per-tick `await ccall`. §1.3's "hook 2 (handleAsync) is load-bearing/new" is therefore **wrong**: no separate `handleAsync` hook is needed. The genuine *uncovered* `currData` writer is the **fiber swap** (libcontext buffers come from `emscripten_fiber_init`, not `allocateData`). **So Phase 1's scheduler should focus on fiber tracking (§1.3 hook 3) + the deferred drain (§1.4) + single-transition serialization — not handleAsync coverage.** The Phase-0 red gate is confirmed (6 `coroutine*` specs fail on the de-park build); this sharpens where the fix lives. + +## 2. The C++ yield API (Phase 3 surface) +```cpp +using WAKE_TOKEN = int; +WAKE_TOKEN wasm_begin_async_wait(int kind); // EM_JS → scheduler.beginWait(kind) → token +int wasm_yield_until(WAKE_TOKEN token); // EM_ASYNC_JS → park current ctx, return result on resume +void wasm_resolve_wait(WAKE_TOKEN, int); // EM_JS → mark ctx ready + scheduleDrain +``` +Reimplement each wait on top of it: `wxDialog::ShowModal` (replaces `dialog.cpp:startModal` `EM_ASYNC_JS` + `_wxModalResolvers`), `wxGUIEventLoop` nested `DoRun` (replaces `evtloop.cpp:wxWasmRunNestedLoop`), `wxClipboard::GetData`, font enum. Each becomes "begin wait → yield_until → (JS event) resolve_wait". The existing LIFO resolver stacks fold into scheduler ready/wait bookkeeping. + +## 3. The root fiber (Phase 2, B2) +Run `main → wxEntry → OnRun → DoRun` inside a managed **root fiber** (via libcontext's `emscripten_fiber_init_from_current_context`, already used for the coroutine main stack at `libcontext.cpp:202-217` — generalize it to the app root). At depth 0, `DoRun` **yields the root fiber to the scheduler** instead of `handleAsync`-parking. The browser tick (rAF or `set_main_loop` callback) **resumes the root fiber**, which calls `ProcessEvents` **directly (wasm-side), not via `await ccall(...,{async:true})`** — that JS-awaits-a-suspending-export boundary is the Emscripten #13302 corruption hazard. Now the main loop is a sibling fiber to coroutines/modals; a coroutine swap is fiber↔fiber under the scheduler — no nested unwind. + +## 4. File-by-file change map +| File | Change | Phase | +|---|---|---| +| `scripts/common/shims/handlesleep.js` | → `asyncify-scheduler.js`: the scheduler (1.2–1.5); cover `handleAsync` + fiber tracking + deferred drain; drop the stale `"unwind"` swallow | 1 | +| `scripts/common/inject-dyncall-shims.sh` | inject the new scheduler; keep §3c self-heal | 1 | +| `wxwidgets/src/wasm/evtloop.cpp` | `DoRun` top-level → root-fiber yield; `ProcessEvents` driven wasm-side; `ScheduleExit` → scheduler wake | 2,3 | +| `wxwidgets/src/wasm/dialog.cpp` | `ShowModal`/`EndModal` → `wasm_yield_until`/`wasm_resolve_wait` | 3 | +| `kicad/thirdparty/libcontext/libcontext.cpp` | register fiber create/swap with the scheduler; expose the root-fiber init | 2 | +| wx clipboard/font wasm files | → yield API | 3 | +| `tests/apps/standalone/coroutine*`, `*raytrace*` | the integration gate (already exist) | 0 | +| `tests/apps/standalone/sched-nest/` (new) | the minimal Phase-0 unit repro | 0 | +| `tests/asyncify/*.spec.ts` | red-green specs for the harness, 3 engines, both EH | 0,1 | + +## 5. Test harness +- **Phase 0 minimal repro:** a tiny `wxIMPLEMENT_APP` that, from a `CallAfter`/timer (i.e. inside the parked rAF pump), does a libcontext fiber swap and swaps back; assert no `invalid state: 1`, correct round-trip value. RED under the current de-park; the unit gate for Phase 1. +- **Integration gate:** the 6 `coroutine*` specs (already RED under de-park). +- **Full matrix (12 §test-matrix):** `ShowModal` from root & from coroutine; nested modal in quasi-modal; coroutine swap while a modal pumps; clipboard from root & coroutine; raytracer multi-core; exit/unload cleanup — in **Firefox+Chrome+Safari**, under **both `-fexceptions` and `-fwasm-exceptions`** (incl. a modal from inside a `catch`, to prove composition with the hoist pass), with a `-sASYNCIFY_ASSERTIONS=1` pass. + +## 6. Phase checklist (work log — update as landed) + +- [ ] **Phase 0 — red harness** (2–3 d). Minimal `sched-nest` repro RED in 3 engines; the 6 `coroutine*` specs confirmed RED under de-park; CI/local script to run them. +- [ ] **Phase 1 — scheduler core** (1–2 wk). `asyncify-scheduler.js` with the 4 hooks + deferred drain; covers `handleAsync` + fibers. **Gate:** `sched-nest` + the 6 `coroutine*` specs GREEN, all 3 engines, both EH. (If the permanent `handleAsync` park can't be a clean parked context, escalate to Phase 2.) +- [ ] **Phase 2 — root fiber** (≈1 wk). Main loop = scheduler root fiber; `ProcessEvents` wasm-side (no JS async ccall). **Gate:** Phase-1 gate still green + no `handleAsync` park remains. +- [ ] **Phase 3 — migrate waits** (1–2 wk). `wasm_yield_until` API; `ShowModal`/nested loop/clipboard/font on it. **Gate:** full matrix green. +- [ ] **Phase 4 — lifetime** (few d). Cleanup ordering vs the scheduler; teardown deferred to unload. **Gate:** exit/unload tests green; no cleanup during steady-state pumping. + +## 6b. Phase-0 finding — Phase 1 is insufficient; Phase 2 (root fiber) is REQUIRED (2026-06-23) + +**Exact failure** (coroutine_test, de-park build): the first case `yield_resume_preserves_state` **passes** (it runs during the startup burst, *before* the main-loop park), then a later fiber swap aborts with **`Aborted(Assertion failed: We cannot stop an async operation in flight)`**, surfacing as `[wxWasm] main loop pump error`. + +**Why:** `wxWasmParkMainLoop` is `Asyncify.handleAsync(...)` — a **permanently in-flight async operation** for the app's whole life. A coroutine `emscripten_fiber_swap` inside the rAF pump calls `stop_unwind`, but the park's async op is in flight → abort. Under the old `throw`, the top loop was *not* an async op (`throw "unwind"` is a plain JS exception), so swaps from a clean base worked. + +**Tested & ruled out:** changing the rAF pump's `await ccall('ProcessEvents',{async:true})` to a **synchronous** `ccall` does NOT help — the in-flight op is the *park*, not the per-tick ccall. And the park is **permanent** (never completes until exit), so no scheduler serialization can let a coroutine swap "wait for the slot." **So §6's Phase-1 escalation condition is met.** + +**The fix (Phase 2, now confirmed required):** the main loop must not be a `handleAsync` park. Make the main stack a **libcontext fiber** (Ruby/Julia pattern): the main fiber runs `ProcessEvents` on its own stack and **yields to the browser by a fiber swap / return-through-`set_main_loop(...,0)`**, not a `handleAsync` suspend — so there is no permanent in-flight async operation, and a coroutine swap is a sibling fiber↔fiber switch from the same `g_main_context`. `ProcessEvents` must run on `g_main_context` (the main fiber), not the fresh rAF-ccall stack. The Phase-1 scheduler is still needed to coordinate modal/clipboard waits that *do* suspend — but **the main-loop park must move off `handleAsync` first.** + +**Open Phase-2 design point:** how the main fiber yields to / resumes from the browser each frame (rAF resumes `g_main_context` to run one `ProcessEvents` tick, then the main fiber yields back) without re-introducing a permanent asyncify operation. Candidate: `set_main_loop(tick,0,0)` where `tick` resumes the main fiber via libcontext, the main fiber runs `ProcessEvents` then swaps back, and wx teardown is suppressed until unload (Phase 4 lifetime). + +## 6c. IMPLEMENTED & verified (2026-06-23): the per-frame-yield while-loop + +The fix is **simpler than "an explicit libcontext root fiber."** `DoRun` (top level) is now a plain C++ loop on the real main stack (`evtloop.cpp`): +```cpp +while (!m_shouldExit) { ProcessEvents(); wxWasmYieldToBrowser(); } +``` +`wxWasmYieldToBrowser` is `EM_ASYNC_JS(void, …, { await new Promise(r => requestAnimationFrame(r)); })` — an Asyncify suspend that **completes every frame**. Because nothing is permanently suspended, the Asyncify slot is free (`state==Normal`) whenever `ProcessEvents` runs, so a tool-coroutine fiber swap inside it succeeds; and `ProcessEvents` runs on the real main C stack (= libcontext's `g_main_context`), so swaps are from the right context. `ScheduleExit` just sets `m_shouldExit` for the top level (nested/quasi-modal loops still use the `setTimeout` pump + `wxWasmExitNestedLoop`). `wxWasmParkMainLoop` is removed. No explicit fiber API or scheduler was needed for the *main-loop* fix — the key was only that the suspension **completes** each frame instead of being permanent. + +**Result (JS-EH):** coroutine in-app suite **13/13 pass, 0 fail** (was: abort after case 1); `coroutine` + `coroutine-nested` e2e specs **green**; dialog renders + modals **green** (no regression). Only `coroutine-pthread` outstanding — but its `coroutine_test_wxpt.wasm` was **stale** (the `coroutine-pthread` make target didn't rebuild it); all apps are being rebuilt to confirm. + +**Still likely needed later (Phase 1 scheduler / Phase 3):** modal/clipboard waits that genuinely suspend across the loop still use the nested `setTimeout` pump; if overlapping suspensions there prove fragile, layer the scheduler on. But the *coroutine regression itself is fixed by this main-loop change alone.* + +## 6d. Phase-2 exposes a SECOND coupling: context-menu re-entrancy needs Phase 1 (2026-06-23) + +The while-loop main loop (§6c) fixed the coroutines but **regressed the context menu** (2 e2e specs). Right-click → choose *Cut* → `Aborted(RuntimeError: unreachable)` / `memory access out of bounds`. Stack: a DOM mouse event (`mouseEventHandlerFunc` → the Asyncify export wrapper → `wasm-function[…]`) **re-enters wasm while `DoPopupMenu`'s `wxDomPopupMenuModal` context is suspended on the deep main stack** — a single-slot re-entrancy fault. The de-park's *permanent-context* loop masked it (its menu context was shallow — a fresh-ccall `ProcessEvents` — and always "in flight"); the while-loop's no-permanent-context, deep-stack suspend exposes it. + +**Three targeted fixes, all empirically REJECTED (don't retry these):** +1. **C++ pump in `wxDomPopupMenuModal`** (mirror `startModal`) — *redundant*: `wx-dom.js`'s `wxShowContextMenu` **already** runs the same `setTimeout` ProcessEvents pump. No effect. (Reverted.) +2. **`ASYNCIFY_STACK_SIZE` 8192→65536** — not a buffer-size fault (still crashes at 65536; emscripten appends that hint to *every* `unreachable`). (Kept anyway — the while-loop genuinely deepens every suspension, so 65536 ≈ the coroutine apps + KiCad is the right call for all wx apps.) +3. **DOM backdrop blocking canvas pointer events** (`wx-dom.js`) — confirmed present in the rebuilt glue; still crashes. So the re-entry is **not** a canvas leak — the wx DOM port's document-level mouse handler re-enters wasm regardless. (Reverted.) + +**Conclusion — the hard tension, stated plainly:** +- **de-park** (permanent-context loop): menus ✅, coroutines ❌ +- **while-loop** (no permanent context): coroutines ✅, menus ❌ + +Neither is clean alone. Both faults are the SAME single-slot `currData`/state arbiter problem — Design B's **scheduler (Phase 1)** — now *proven necessary, not optional*. The §6c while-loop is the correct **foundation** (it removes the permanent park that blocked coroutine swaps); Phase 1 must layer on top so a wasm re-entry during ANY suspension (coroutine swap, menu/modal `handleAsync`, main-loop yield) is coordinated (deferred/queued or serialized) rather than misfiring a rewind. Kept in-tree: `evtloop.cpp` while-loop + the 65536 bump. Reverted: the redundant C++ pump and the backdrop. + +## 6e. The precise mechanism (export-wrapper diagnostic, 2026-06-23) + +Instrumented the Asyncify export wrapper to log every wasm entry while `state != Normal`. The menu crash is **not** a one-shot bad rewind — it's an **infinite busy unwind/rewind loop** on one buffer: +``` +asyncify_start_unwind state=1(Unwinding) currData=1240280 ← main suspends +asyncify_start_rewind state=2(Rewinding) currData=1240280 ← …immediately resumed +__main_argc_argv state=2 currData=1240280 ← main runs a few dynCall_ii deep +…repeats forever (currData unchanged) until the OOB crash +``` +Buffer `1240280` (the parked main stack, suspended at the menu) is **suspended then immediately re-resumed, over and over**. Only one context exists, but it is being re-driven in a tight spin: its continuation re-suspends instantly (the menu promise is still pending), and something re-rewinds it each cycle. + +**Two drivers fight over the single slot:** with the while-loop, the main-loop structure AND the **menu's own `setTimeout` ProcessEvents pump** (`wx-dom.js`) both try to drive the parked main stack — one re-rewinds what the other parked. Under the de-park there was a *single* pump chain (the rAF pump *was* the loop; the menu pump nested inside its `await`), so nothing double-drove the slot. + +**Scheduler invariant this pins (the central requirement):** exactly ONE unwind/rewind transition in flight; a pump tick runs a **fresh** `ProcessEvents` (new stack) and must NEVER re-rewind an already-parked context — only that context's own `wakeUp` (its promise resolving) may resume it. The scheduler must enforce this across the main-loop yield, the menu/modal/nested pumps, and fiber swaps. (A plausible smaller first cut: a single shared "is a transition in flight / is a context parked" guard the pumps consult before re-driving — test it against the contextmenu specs before committing to the full registry.) + +## 6f. RESOLVED (2026-06-23): the arbiter already existed — it just wasn't injected + +A gated export-wrapper + `start_rewind` probe nailed the proximate cause: the menu's wakeUp fires `_asyncify_start_rewind(Asyncify.currData)` with **`currData == null`** → reads address 0 → OOB. The cause: the **`handlesleep.js` currData save/restore shim** — the existing Design-A / Emscripten #9153 arbiter (`scripts/common/shims/handlesleep.js`, which restores `currData` to the parked context's buffer before every rewind) — was **NOT injected into the contextmenu glue** (`pendingSleepContexts` count 0, vs 9 in the working coroutine app). Appending it manually → crash gone, `[CTXMENU_EVENT] Cut chosen` fires, spec 4/4 green. + +**Why it was missing:** `inject-dyncall-shims.sh` gates the handleSleep shim on the libcontext fiber marker (`_emscripten_fiber_swap.isAsync = true;`), and `build-wasm-test.sh` only ran the injector under `WX_NATIVE_EH=1`. So plain (non-fiber) wx apps under JS-EH never received the currData arbiter. They didn't crash *before* the while-loop because the de-park's shallow menu context (a fresh-ccall `ProcessEvents`) never hit the null-rewind path; the while-loop's deeper main-stack suspend exposes it. + +**Fix — build-system only, NO new runtime code:** +1. `build-wasm-test.sh` injects the shim into every freshly-linked app for **both** EH models (idempotent — the Makefile-injected coroutine apps are skipped). +2. `inject-dyncall-shims.sh` appends the handleSleep shim at EOF when there's no fiber glue (Asyncify is defined by then; it wraps `handleSleep` at load, before any runtime sleep). + +**So §6c–6e's "build the single-owner currData arbiter" conclusion was right about the diagnosis but the arbiter already exists (`handlesleep.js`) — it only needed to reach these apps.** The scheduler invariant in §6e *is* what `handlesleep.js` implements (each parked context owns its buffer; `currData` is restored before its own rewind). The while-loop (coroutine fix) + this injection fix together resolve both regressions. §7's open question is therefore moot: no Phase-1 scheduler nor Phase-2 root fiber was needed — the while-loop main loop + the pre-existing currData shim suffice. + +## 7. Open decisions (resolve during implementation) +- Is Phase 1 (scheduler treating the `handleAsync` park as a tracked parked context) sufficient, or is Phase 2 (root fiber) required? — answered by the Phase-0 harness against the Phase-1 build. +- One scheduler file injected post-link (like today's shim) vs an emscripten `--js-library` (link-time, cleaner, survives JS regen). Lean js-library for durability. +- Whether to keep an Asyncify-only "no scheduler" fast path for apps with no coroutines (most standalone tests) to avoid scheduler overhead — likely yes, gated on a runtime "any non-main context registered?" check. +- Native-EH interaction: confirm the scheduler's transitions compose with `HoistCppCatches` (suspend-inside-catch) — a matrix test, not a design change expected. diff --git a/docs/features/perf/README.md b/docs/features/perf/README.md new file mode 100644 index 0000000..919159e --- /dev/null +++ b/docs/features/perf/README.md @@ -0,0 +1,278 @@ +# Cross-browser performance: why Firefox > Chrome > Safari, and how to close the gap + +> Research notes, **2026-06-18**. The KiCad WASM port runs fastest in Firefox, +> slower in Chrome, slowest in Safari. This document explains *why* at the +> browser-engine level and lays out a ranked, build-specific plan to speed up +> Chrome and Safari. Web claims are dated and linked in [Sources](#sources); +> codebase claims carry `file:line` refs. Companion work lives in +> [`../async/`](../async/) (Asyncify) and [`../wasm-exceptions/`](../wasm-exceptions/) +> (the `-fwasm-exceptions` migration). + +--- + +## TL;DR + +The Firefox lead is **not** a Firefox trick. Our binary is dominated by +**Asyncify** instrumentation, and Firefox's compilers simply tolerate Asyncify's +pathological code far better than Chrome's or Safari's do. So the highest-leverage +work for Chrome *and* Safari is to **shrink/attack the Asyncify footprint**, plus a +handful of cheap, orthogonal wins. + +There are **two independent axes**, and both need attention: + +1. **WASM compile/execute** — Asyncify-dominated. This explains the + Firefox > Chrome > Safari **ordering**. +2. **WebGL rendering** — Safari's Metal/ANGLE overhead. This is *extra* Safari + slowness on top of axis 1, and several fixes are one-liners. + +### Ranked levers + +| # | Lever | Axis | Effort | Impact | Where | +|---|---|---|---|---|---| +| 1 | Confirm/force `instantiateStreaming` + `Content-Type: application/wasm` + stable URL/ETag | startup | hours | ~1.5–1.8× cold start (FF); arms V8 cache | `web/standalone/src/wasm/boot.ts` | +| 2 | **Brotli** instead of gzip-9 on R2 | startup | hours | ~15–25% smaller transfer | R2 / edge config | +| 3 | `powerPreference: 'high-performance'` + context-lost handlers | WebGL (Safari/Chrome) | hours | discrete GPU instead of integrated | `wxwidgets/src/wasm/glcanvas.cpp:524-535` | +| 4 | Audit GAL shaders for the `flat` qualifier | WebGL (Safari) | hours–days | up to *seconds/frame* in worst case | `kicad/common/gal/shaders/` | +| 5 | Remove `glGetError()` from the render loop | WebGL (Safari) | hours | avoids per-call Metal flush | GAL compositor | +| 6 | Test `antialias: false` | WebGL (Safari) | hours | cuts MSAA resolve cost | `glcanvas.cpp:524-535` | +| 7 | Enable `-msimd128` | WASM exec (all) | days | 1.5–2.5× geometry/render hot loops | build flags | +| 8 | `ASYNCIFY_ADVISE` → `ASYNCIFY_IGNORE_INDIRECT` + extend `REMOVE` | WASM exec (all, esp. Chrome/Safari) | days | smaller binary + faster tier-up | `scripts/common/apply-asyncify.sh` | +| 9 | `-fwasm-exceptions` (size) | WASM (all) | weeks | 64.5 → 36 MB gz | tracked — see [§ Structural bets](#structural-bets-track--prototype) | +| 10 | JSPI (delete Asyncify) | WASM (all, esp. Safari) | weeks | ~40–50% smaller, removes JIT pressure | tracked — see [§ Structural bets](#structural-bets-track--prototype) | +| — | wasm-split, WebGPU GAL backend | startup / WebGL | weeks+ | deferred (see [§ Deferred](#deferred--not-now)) | — | + +--- + +## Current build (the baseline) + +Verified from the build scripts and runtime glue: + +| Knob | Value | Location | +|---|---|---| +| Asyncify | `-sASYNCIFY=1`, `ASYNCIFY_STACK_SIZE=65536` | `scripts/kicad/build-kicad-target.sh:~400` | +| Exceptions | **legacy `-fexceptions`** (not `-fwasm-exceptions`) | `build-kicad-target.sh:240-255` | +| SIMD | **none** (`-msimd128` absent) | — | +| Threads | `-sUSE_PTHREADS=1`, pool = `navigator.hardwareConcurrency` (+ COOP/COEP) | `build-kicad-target.sh`, `web/.../preflight/capabilities.ts` | +| Memory | `INITIAL_MEMORY=256MB`, `MAXIMUM_MEMORY=4GB`, `ALLOW_MEMORY_GROWTH=1` | `build-kicad-target.sh` | +| Opt | clang `-O2` (release); link `-O0` then **host `wasm-opt -O2` after `--asyncify`** | `apply-asyncify.sh:88-157` | +| WebGL | WebGL2 (`-sMAX_WEBGL_VERSION=2`), `antialias:true`, **`powerPreference:DEFAULT`** | `glcanvas.cpp:524-535` | +| Loading | Emscripten script-glue; **streaming not confirmed**; gzip-9, **no Brotli** | `boot.ts:145-294` | +| Artifact | pcbnew **186 MB raw / 64 MB gzip**; eeschema 99/34; pl_editor 52/17; gerbview 49/16 | `output/` | + +Note: all three modern browsers support `SharedArrayBuffer`/threads under COOP+COEP +(the app demonstrably runs in each) — capability gating is in +`capabilities.ts`, not UA sniffing. + +--- + +## Why the ordering exists (engine internals) + +### The villain: Asyncify + +Asyncify rewrites every instrumented function with unwind/rewind state checks and +saves/restores all locals to linear memory. That expands each local's live range +across the *whole* function, producing a nearly fully-connected interference graph +— exactly the input that is catastrophic for optimizing register allocators. +Asyncify's own docs warn: *"VMs may also limit compilation to the baseline tier on +such pathological code."* Result: ~+70% binary, giant functions, and the +186 MB-raw pcbnew. See [`../async/02-asyncify-internals.md`](../async/02-asyncify-internals.md). + +### How each engine copes + +| Engine | Baseline tier | Optimizing tier | On Asyncify's giant functions | +|---|---|---|---| +| **Firefox / SpiderMonkey** | Rabaldr, **~25 ns/byte**, eager whole-module, multithreaded (30–60 MB/s) | **Ion** — [75× large-function fix, Oct 2024](https://spidermonkey.dev/blog/2024/10/16/75x-faster-optimizing-the-ion-compiler-backend.html) (sorted live ranges, Semi-NCA dominators, sparse bitsets) targeting *exactly* the huge-CFG/high-vreg shape Asyncify creates (ONNX: 5 min → 3.9 s) | **Best.** Whole module baseline-compiled before download finishes; Ion swallows the big functions. No OSR gap. | +| **Chrome / V8** | Liftoff, **~50 ns/byte** (½ Firefox) | **TurboFan** — chokes on huge fns (a 1.96 MB fn → 95 s, 7.4 GB RAM, 87% in regalloc); falls back to mid-tier allocator or **skips optimization** | **Middle.** **V8 has no OSR for wasm** — a function in a long loop (Asyncify rewind/unwind loops!) finishes that whole call in Liftoff; only the *next* call gets TurboFan. | +| **Safari / JSC** | **Lazy everything**: IPInt (interpreter) → BBQ → OMG. Nothing eager. | **OMG** (B3) — did *not* get Ion's 2024 large-fn treatment | **Worst.** First run executes at interpreter speed; Asyncify ~doubles fn count → huge OMG backlog → documented **300–400% CPU spike for 30 s+** after a workload. **No persistent compiled-code cache**, so it re-pays every session; above ~10 MB it switches to a slower JIT mode. | + +### Two corollaries that bite us specifically + +- **Chrome's V8 wasm code cache is effectively unavailable.** It only caches + modules under ~150 MB *compiled*, and compiled code is 5–7× the `.wasm`. Our + 186 MB pcbnew → ~1 GB compiled — far over the ceiling. So Chrome **re-runs + TurboFan on every cold load** today. Shrinking the binary (levers 7–10) is the + only way to get Chrome's repeat-load cache back. See + [V8 wasm code caching](https://v8.dev/blog/wasm-code-caching). +- **Benchmark trap:** with DevTools open, V8 tiers all wasm *down* to Liftoff. + Never measure Chrome speed with DevTools open (except via an actual Performance + recording, which forces tier-up). This likely makes Chrome look worse than it is + in casual testing. + +--- + +## The ranked plan + +### Tier 1 — cheap, do now (days, low risk) + +**1. Confirm + force streaming instantiation and cache headers.** The loader +injects the Emscripten JS glue via `