diff --git a/.github/workflows/deploy-staging.yml b/.github/workflows/deploy-staging.yml index 7bdb9f5..9aaa148 100644 --- a/.github/workflows/deploy-staging.yml +++ b/.github/workflows/deploy-staging.yml @@ -25,7 +25,6 @@ jobs: build: uses: ./.github/workflows/wasm-build.yml with: - opt_level: "-O1" build_3d_viewer: "ON" run_tests: true upload_output: true diff --git a/.github/workflows/wasm-opt-bench.yml b/.github/workflows/wasm-opt-bench.yml deleted file mode 100644 index c3795e5..0000000 --- a/.github/workflows/wasm-opt-bench.yml +++ /dev/null @@ -1,197 +0,0 @@ -# Dedicated benchmark of the host-side `wasm-opt -O2` pass (the ~80-min CI -# bottleneck). Same ephemeral Hetzner ccx53 as the main CI, but instead of the -# full build+e2e it builds the asyncified `-O2` INPUT once, caches it as an -# artifact, and replays scripts/bench/o2-config-sweep.sh over it under a matrix -# of allocator/THP/thread configs. Goal: find the env that kills the kernel -# page-management storm (see docs/ci-build-slowness-findings.md). -# -# Manual-only (workflow_dispatch): every run costs a paid ephemeral Hetzner VM, -# so nothing here triggers on push. Dispatch from the Actions tab (the workflow -# must exist on the default branch for that). -# -# Parameters live in scripts/bench/sweep.conf (committed) or the dispatch -# inputs below. To reuse a prior run's fixture (skip the ~40-min build), set -# fixture_run_id to that run's id. -# -# Reuses the main CI's secrets: HCLOUD_TOKEN, HETZNER_RUNNER_PAT. - -name: wasm-opt bench - -on: - workflow_dispatch: - inputs: - configs: { description: "space-separated config presets (override conf)", required: false, default: "" } - cores: { description: "BINARYEN_CORES (override conf)", required: false, default: "" } - fixture_run_id: { description: "run id to pull the cached fixture from (blank = build it)", required: false, default: "" } - diagnostic: { description: "1 = capture perf/vmstat/irq around first config", required: false, default: "" } - cap_seconds: { description: "windowed-sample seconds per config (0 = run to completion)", required: false, default: "" } - -# One Hetzner bench VM at a time. (The main CI uses a different runner; still, -# don't push the ci-hetzner branches while a bench runs — we only have one slot.) -concurrency: - group: wasm-opt-bench - cancel-in-progress: false - -jobs: - create-runner: - name: Create Hetzner runner - runs-on: ubuntu-latest - outputs: - label: ${{ steps.create.outputs.label }} - server_id: ${{ steps.create.outputs.server_id }} - steps: - - name: Create ephemeral ccx53 runner - id: create - uses: Cyclenerd/hcloud-github-runner@v1 - with: - mode: create - github_token: ${{ secrets.HETZNER_RUNNER_PAT }} - hcloud_token: ${{ secrets.HCLOUD_TOKEN }} - server_type: ccx53 - location: nbg1 - image: ubuntu-24.04 - - bench: - name: wasm-opt -O2 config sweep (Hetzner ccx53) - needs: create-runner - runs-on: ${{ needs.create-runner.outputs.label }} - timeout-minutes: 210 - env: - KICAD_LOG_NESTED: "1" # live build logs straight to the Actions console - steps: - - name: Checkout (with submodules) - uses: actions/checkout@v4 - with: - submodules: recursive - - - name: Resolve sweep parameters - id: params - # Pass workflow_dispatch inputs via env (never interpolate ${{ }} into a - # shell body) so a value can't be parsed as shell. They override the - # committed conf when non-empty; the run id is validated numeric before - # it reaches download-artifact. - env: - IN_CONFIGS: ${{ inputs.configs }} - IN_CORES: ${{ inputs.cores }} - IN_FIXTURE_RUN_ID: ${{ inputs.fixture_run_id }} - IN_DIAGNOSTIC: ${{ inputs.diagnostic }} - IN_CAP: ${{ inputs.cap_seconds }} - run: | - set -e - CONF="scripts/bench/sweep.conf" - [ -f "$CONF" ] && . "$CONF" || true - CONFIGS="${IN_CONFIGS:-${CONFIGS_CONF:-baseline}}" - CORES="${IN_CORES:-${CORES_CONF:-}}" - FIXTURE_RUN_ID="${IN_FIXTURE_RUN_ID:-${FIXTURE_RUN_ID_CONF:-}}" - DIAGNOSTIC="${IN_DIAGNOSTIC:-${DIAGNOSTIC_CONF:-0}}" - CAP_SECONDS="${IN_CAP:-${CAP_SECONDS_CONF:-0}}" - if [ -n "$FIXTURE_RUN_ID" ] && ! printf '%s' "$FIXTURE_RUN_ID" | grep -qE '^[0-9]+$'; then - echo "::error::FIXTURE_RUN_ID must be numeric (got '$FIXTURE_RUN_ID')"; exit 1 - fi - BUILD_FIXTURE=true; [ -n "$FIXTURE_RUN_ID" ] && BUILD_FIXTURE=false - { - echo "CONFIGS=$CONFIGS" - echo "CORES=$CORES" - echo "FIXTURE_RUN_ID=$FIXTURE_RUN_ID" - echo "DIAGNOSTIC=$DIAGNOSTIC" - echo "CAP_SECONDS=$CAP_SECONDS" - echo "BUILD_FIXTURE=$BUILD_FIXTURE" - } >> "$GITHUB_ENV" - echo "build_fixture=$BUILD_FIXTURE" >> "$GITHUB_OUTPUT" - echo "::notice::configs='$CONFIGS' cores='${CORES:-nproc}' cap=${CAP_SECONDS}s build_fixture=$BUILD_FIXTURE fixture_run_id='${FIXTURE_RUN_ID:-none}' diagnostic=$DIAGNOSTIC" - - - name: Show machine - run: | - echo "nproc=$(nproc)"; free -h; df -h / - echo "THP: $(cat /sys/kernel/mm/transparent_hugepage/enabled)"; uname -a - - # Allocators + measurement tools. mimalloc is the key data point (Binaryen - # #5561): prefer the distro package, source-build it if not packaged. - - name: Install bench deps (allocators, time, perf, strace) - run: | - export DEBIAN_FRONTEND=noninteractive - sudo apt-get update - sudo apt-get install -y time strace libjemalloc2 cmake g++ git curl ca-certificates \ - linux-tools-common linux-tools-generic "linux-tools-$(uname -r)" || \ - sudo apt-get install -y time strace libjemalloc2 cmake g++ git curl ca-certificates linux-tools-common linux-tools-generic - sudo apt-get install -y libmimalloc2.0 || sudo apt-get install -y libmimalloc-dev || true - if ! ls /usr/lib/$(uname -m)-linux-gnu/libmimalloc.so* >/dev/null 2>&1; then - echo "mimalloc not packaged — building from source" - git clone --depth 1 https://github.com/microsoft/mimalloc /tmp/mimalloc - cmake -S /tmp/mimalloc -B /tmp/mimalloc/out -DCMAKE_BUILD_TYPE=Release >/dev/null - cmake --build /tmp/mimalloc/out -j"$(nproc)" >/dev/null - sudo cp -av /tmp/mimalloc/out/libmimalloc.so* /usr/lib/$(uname -m)-linux-gnu/ - fi - echo "allocators present:"; ls -l /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so* /usr/lib/$(uname -m)-linux-gnu/libmimalloc.so* 2>/dev/null || true - - # ---- Fixture: build once (asyncify, no -O2) and cache, or download it ---- - - name: Install Docker (fixture build only) - if: steps.params.outputs.build_fixture == 'true' - run: | - export DEBIAN_FRONTEND=noninteractive - sudo apt-get install -y ca-certificates curl - sudo install -m 0755 -d /etc/apt/keyrings - curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg - sudo chmod a+r /etc/apt/keyrings/docker.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \ - https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \ - | sudo tee /etc/apt/sources.list.d/docker.list >/dev/null - sudo apt-get update - sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin - - - name: Build asyncified fixture (ASYNCIFY_ONLY, no -O2) - if: steps.params.outputs.build_fixture == 'true' - run: | - # Stop after the asyncify pass so the artifact is exactly the -O2 INPUT. - ASYNCIFY_ONLY=1 BINARYEN_CORES="$(nproc)" ./docker/build.sh eeschema --build-deps -j "$(nproc)" - ls -lh output/eeschema.wasm - cp output/eeschema.wasm output/eeschema.asyncified.wasm - - - name: Upload fixture artifact - if: steps.params.outputs.build_fixture == 'true' - uses: actions/upload-artifact@v4 - with: - name: o2-fixture - path: output/eeschema.asyncified.wasm - retention-days: 7 - - - name: Download cached fixture - if: steps.params.outputs.build_fixture == 'false' - uses: actions/download-artifact@v4 - with: - name: o2-fixture - path: output - run-id: ${{ env.FIXTURE_RUN_ID }} - github-token: ${{ secrets.GITHUB_TOKEN }} - - # ---- The sweep ---- - - name: Run -O2 config sweep - run: | - FIX="output/eeschema.asyncified.wasm" - [ -f "$FIX" ] || FIX="output/eeschema.wasm" - ls -lh "$FIX" - CONFIGS="${CONFIGS}" CORES="${CORES}" DIAGNOSTIC="${DIAGNOSTIC}" CAP_SECONDS="${CAP_SECONDS}" \ - ./scripts/bench/o2-config-sweep.sh "$FIX" - - - name: Upload bench results - if: always() - uses: actions/upload-artifact@v4 - with: - name: o2-bench-results-${{ github.run_id }} - path: bench/o2-results/** - if-no-files-found: warn - - delete-runner: - name: Delete Hetzner runner - needs: [create-runner, bench] - runs-on: ubuntu-latest - if: always() - steps: - - name: Delete ephemeral runner - uses: Cyclenerd/hcloud-github-runner@v1 - with: - mode: delete - github_token: ${{ secrets.HETZNER_RUNNER_PAT }} - hcloud_token: ${{ secrets.HCLOUD_TOKEN }} - name: ${{ needs.create-runner.outputs.label }} - server_id: ${{ needs.create-runner.outputs.server_id }} diff --git a/.gitignore b/.gitignore index 1cc6ad3..2507383 100644 --- a/.gitignore +++ b/.gitignore @@ -72,7 +72,6 @@ wxwidgets-clean/ *.tmp output/ *.d -/tests/.test-port-coroutine .playwright-mcp .claude/worktrees/ @@ -80,18 +79,11 @@ output/ # Curated design docs live under docs/features/ and ARE committed. /features/ -# wasm-opt benchmark local artifacts. The harness scripts under scripts/bench/ -# ARE committed; the fixtures/results (/bench) and the QEMU VM image -# (scripts/bench/vm) are local-only. -/bench/ -/scripts/bench/vm/ - # Temporary/disposable prebuilt WASM sets (native-EH + JS-EH × eeschema + pcbnew # O1 builds) + the standalone perf harness, used to reproduce the runtime-perf # comparison on demand. Large (~1 GB), never committed. /benchmark-builds/ -/tests/.test-port-asyncify /memory/ # Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys. diff --git a/.gitmodules b/.gitmodules index ffd7e43..f4133fc 100644 --- a/.gitmodules +++ b/.gitmodules @@ -10,7 +10,3 @@ path = web/pcbjam-shared url = https://github.com/PCBJam/pcbjam-shared.git branch = main -[submodule "binaryen"] - path = binaryen - url = https://github.com/PCBJam/binaryen - branch = wasm-port diff --git a/README.md b/README.md index 690e12b..86b5abd 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ kicad-wasm/ ├── tests/ # Playwright E2E tests │ ├── e2e/ # Test specs │ └── apps/ # WASM test applications -├── tools/ # External tools (binaryen) +├── tools/ # External tools (local emsdk install) └── output/ # Build output (pcbnew.js, pcbnew.wasm) ``` @@ -95,8 +95,8 @@ See [docs/build.md](docs/build.md) for detailed build documentation. #### Creating an isolated worktree (with submodule branches) For an experiment or feature you can work in a disposable git worktree so the -main checkout stays pristine. This repo has four submodules -(`kicad`, `wxwidgets`, `binaryen`, `web/pcbjam-shared`); a new worktree starts +main checkout stays pristine. This repo has three submodules +(`kicad`, `wxwidgets`, `web/pcbjam-shared`); a new worktree starts with them empty, so initialize and branch each one: ```bash @@ -106,11 +106,11 @@ git worktree add -b experiment/my-thing ../kicad-wasm-my-thing main # 2. Check out the submodules INSIDE the worktree (working trees only; # git objects are shared with the main checkout) cd ../kicad-wasm-my-thing -git submodule update --init kicad wxwidgets binaryen web/pcbjam-shared +git submodule update --init kicad wxwidgets web/pcbjam-shared # 3. Create a matching branch in each submodule (they start at detached HEAD) git checkout -b experiment/my-thing # root already on it via -b above -for sm in kicad wxwidgets binaryen web/pcbjam-shared; do +for sm in kicad wxwidgets web/pcbjam-shared; do git -C "$sm" checkout -b experiment/my-thing done ``` @@ -119,12 +119,11 @@ Then build from inside the worktree. Use an **isolated** Docker project — do N set `COMPOSE_PROJECT_NAME` to another branch's project (e.g. `kicad-wasm-main`), which can collide with other workflows; `docker/build.sh` auto-derives an isolated project name from the worktree branch. The first build provisions deps -(wxWidgets + OCC) from scratch. To keep the machine responsive / bound wasm-opt -RAM, cap parallelism and skip the slow release optimization: +(wxWidgets + OCC) from scratch. To keep the machine responsive, cap +parallelism: ```bash -KICAD_DOCKER_CPUS=4 BINARYEN_CORES=4 BINARYEN_OPT_LEVEL=-O1 \ - ./docker/build.sh pcbnew -j 4 +KICAD_DOCKER_CPUS=4 ./docker/build.sh pcbnew -j 4 ``` Tear down afterward with `git worktree remove ../kicad-wasm-my-thing` (and diff --git a/docker/build.sh b/docker/build.sh index 04a14e6..48f5ffb 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -16,7 +16,7 @@ # eeschema standalone schematic engine (debug aid; not deployed) # # A comma-separated list builds just those apps in order (e.g. -# "calculator,pl_editor" — used to exercise the multi-app pipeline cheaply). +# "calculator,pl_editor" — used to exercise the multi-app path cheaply). # # Any extra args are forwarded to scripts/kicad/build-.sh (e.g. -j 8, # --full, --release, --diag=gal). @@ -24,17 +24,6 @@ # The build is split into two phases: # 1. Docker: Compile KiCad to WASM (fully finalized; JSPI links in-container) # 2. Host: ENV merge shim on the glue (patch-env-shim.mjs) -# -# 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 -# Amdahl-capped at ~4 effective cores, so on a many-core CI box the container -# would otherwise sit idle for the 1-2h of host-side wasm-opt (run 27226030304: -# 103 min of the 4h was tools serialized behind each other's wasm-opt). At most -# KICAD_PIPELINE_JOBS (default 2) postprocesses run concurrently — pcbnew's wasm-opt -# pass peaks ~34 GB RSS, so 2 fits the 128 GB CI box but NOT a dev Mac: leave -# KICAD_PIPELINE unset locally. -# -# Binaryen is downloaded automatically - no prerequisites needed. # Auto-launch the live progress dashboard in this terminal (handled by logging.sh, # which owns the TTY before it re-execs us with output redirected). Set KICAD_NO_MONITOR=1 @@ -104,13 +93,11 @@ APP_NAME="$1" shift # Expand the app argument into APPS[]: "all", a single app, or a comma list. -# kicad_editor first in "all" — the merged image is the largest bundle, so its -# host-side wasm-opt chain is the critical path and must start as early as -# possible (especially with KICAD_PIPELINE=1). pcbnew/eeschema stay buildable as -# standalone debug aids but are not part of "all" (not deployed). -# kicad_tools joined "all" for the runner-image CI (tasks-runner 0001 R2) — -# it finalizes in-container (no host wasm-opt tail), so it never contends -# with the editor's critical path. +# kicad_editor first in "all" — the merged image is the deployed bundle and the +# longest compile, so it starts first and surfaces failures earliest. +# pcbnew/eeschema stay buildable as standalone debug aids but are not part of +# "all" (not deployed). kicad_tools joined "all" for the runner-image CI +# (tasks-runner 0001 R2). if [[ "$APP_NAME" == "all" ]]; then APPS=(kicad_editor occ_service ngspice_service calculator pl_editor gerbview kicad_tools) else @@ -135,15 +122,14 @@ export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-kicad-wasm-${BRANCH_NAME}}" echo "Using Docker project: ${COMPOSE_PROJECT_NAME}" echo "Building app: ${APP_NAME}" -# Build phase (cache split). The container compile produces an OPT-INDEPENDENT -# base wasm; the only opt-DEPENDENT work is the final `wasm-opt -O$LEVEL` shrink -# Splitting compile and postprocess lets CI cache the expensive compile once -# and re-run just the host tail — see .github/workflows/. +# Build phase (cache split). The container compile emits the finalized wasm; +# the host tail is only the ENV merge shim on the glue. Splitting compile and +# postprocess lets CI cache the expensive compile once and re-run just the +# host tail — see .github/workflows/. # (default) both — compile in-container, then host post-process. -# --compile-only — only the in-container compile → base wasm in output/. +# --compile-only — only the in-container compile → wasm+glue in output/. # --postprocess-only — only the host post-process (ENV merge shim) on the -# existing output/ base wasm; NO container needed -# (build-wasm-opt.sh self-provisions the Binaryen submodule). +# existing output/ glue; NO container needed. # Extracted here so they are NOT forwarded to the inner build-.sh scripts. PHASE="both" _FILTERED=() @@ -252,7 +238,6 @@ compile_app() { # for headless CLIs like kicad_tools — the gl1 shim needs glm). docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \ -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-}" \ - -e PCBJAM_ASYNC_BACKEND="${PCBJAM_ASYNC_BACKEND:-}" \ kicad-wasm-builder \ "/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}" @@ -277,128 +262,40 @@ compile_app() { } # Phase 2 of one app: host-side post-processing (ENV merge shim). Pure host -# work on output/${app}.* — independent of the -# container, which is what makes it safe to run in the background while the -# next app compiles. +# work on output/${app}.js — no container needed. postprocess_app() { local app="$1" local out_dir="output" - # The headless CLI and the OCC/ngspice services are finalized in-container - # (real tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no - # host post-processing (no dyncall shims, no finalize, no asyncify). + # The headless CLI and the OCC/ngspice services skip the ENV merge shim: + # it exists for the interactive apps' runtime env overrides (?trace=), + # which these targets never read. if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ] || [ "$app" = "ngspice_service" ]; then echo "Skipping host post-processing for ${app} (finalized in-container)" return 0 fi - # JSPI: the app links fully finalized with the real in-container tools — - # no dyncall shims, no host finalize, no asyncify pass. Only the ENV merge - # shim remains: the emscripten glue never merges Module.ENV into the + # ENV merge shim: the emscripten glue never merges Module.ENV into the # runtime ENV (?trace= would be a silent no-op — see docs/features/libs/0013). kw_stage env-shim node ./scripts/common/patch-env-shim.mjs "${out_dir}/${app}.js" } -# --- Pipelined driver state (KICAD_PIPELINE=1) --- -# One background postprocess per app; logs + rc files land in logs/build/ so the -# interleaved output stays readable and failures survive until the final wait. -PIPELINE_PIDS=() -PIPELINE_APPS_BG=() -PIPELINE_LOG_DIR="logs/build" -PIPELINE_TS="$(date +%Y%m%d-%H%M%S)" - -pipeline_running_count() { - local n=0 pid - for pid in "${PIPELINE_PIDS[@]}"; do - kill -0 "$pid" 2>/dev/null && n=$((n + 1)) - done - echo "$n" -} - -# Launch postprocess_app in the background, capped at KICAD_PIPELINE_JOBS -# concurrent jobs (default 2: pcbnew's wasm-opt pass peaks ~34 GB RSS; two postprocesses -# plus the container compile fit the 128 GB CI box). Portable poll loop instead -# of `wait -n` (absent in macOS bash 3.2). -pipeline_postprocess() { - local app="$1" - local max_jobs="${KICAD_PIPELINE_JOBS:-2}" - while [ "$(pipeline_running_count)" -ge "$max_jobs" ]; do - sleep 10 - done - local log_file="${PIPELINE_LOG_DIR}/postprocess-${app}-${PIPELINE_TS}.log" - echo "Pipelining host-side postprocess of ${app} (log: ${log_file})" - ( - postprocess_app "$app" >"$log_file" 2>&1 - echo $? >"${log_file}.rc" - ) & - PIPELINE_PIDS+=($!) - PIPELINE_APPS_BG+=("$app") -} - -# Wait for all background postprocesses, replay their logs into the main log, -# and fail if any of them failed. -pipeline_wait_all() { - local failed=0 i pid app log_file rc - for i in "${!PIPELINE_PIDS[@]}"; do - pid="${PIPELINE_PIDS[$i]}" - app="${PIPELINE_APPS_BG[$i]}" - log_file="${PIPELINE_LOG_DIR}/postprocess-${app}-${PIPELINE_TS}.log" - wait "$pid" || true - rc="$(cat "${log_file}.rc" 2>/dev/null || echo 1)" - echo "" - echo "=== Postprocess ${app} (rc=${rc}) — ${log_file} ===" - cat "$log_file" 2>/dev/null || true - if [ "$rc" != "0" ]; then - echo "ERROR: postprocess of ${app} failed (rc=${rc})" - failed=1 - fi - done - return "$failed" -} - TOTAL_APPS="${#APPS[@]}" -# Shared pipeline trap: on a failure, kill orphaned background wasm-opt jobs -# (each ~30 GB) and keep the monitor's done/fail marker from the EXIT trap. -_install_pipeline_trap() { - trap '_rc=$?; for p in "${PIPELINE_PIDS[@]}"; do kill "$p" 2>/dev/null || true; done; if [ $_rc -eq 0 ]; then kw_done; else kw_fail $_rc; fi; stop_builder' EXIT -} - if [[ "$PHASE" == "compile" ]]; then - # --compile-only: produce the opt-independent base wasm; no host post-process. + # --compile-only: produce the wasm+glue in output/; no host post-process. idx=1 for app in "${APPS[@]}"; do compile_app "$app" "$idx" "$TOTAL_APPS" idx=$((idx + 1)) done elif [[ "$PHASE" == "postprocess" ]]; then - # --postprocess-only: pure host post-process on the existing output/ base - # wasm (no container). Parallelize across apps when pipelining. - if [[ "${KICAD_PIPELINE:-0}" == "1" ]] && [ "$TOTAL_APPS" -gt 1 ]; then - mkdir -p "$PIPELINE_LOG_DIR" - _install_pipeline_trap - for app in "${APPS[@]}"; do - pipeline_postprocess "$app" - done - pipeline_wait_all - else - for app in "${APPS[@]}"; do - postprocess_app "$app" - done - fi -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" - _install_pipeline_trap - idx=1 + # --postprocess-only: pure host post-process on the existing output/ glue + # (no container). for app in "${APPS[@]}"; do - compile_app "$app" "$idx" "$TOTAL_APPS" - pipeline_postprocess "$app" - idx=$((idx + 1)) + postprocess_app "$app" done - pipeline_wait_all else # both, sequential. idx=1 diff --git a/docs/README.md b/docs/README.md index 0882500..d585451 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,16 +14,17 @@ build KiCad with WASM and run it in a browser. ## Build -- [docs/build.md](build.md) — Docker-based KiCad WASM build system (two-phase build, outputs, memory) +- [docs/build.md](build.md) — Docker-based KiCad WASM build system (single-phase build, outputs, memory) - [docker/README.md](../docker/README.md) — Docker build environment, branch-specific containers, troubleshooting - [wasm/README.md](../wasm/README.md) — WASM compatibility layer (overrides/shims without patching KiCad) -## Debugging & Asyncify +## Debugging & WASM runtime -- [docs/debugging/DEBUG.md](debugging/DEBUG.md) — debugging guide: Asyncify stalls vs crashes, shim/codegen coupling, stub-bisection -- [docs/debugging/learning.md](debugging/learning.md) — Asyncify + consecutive modal dialogs: the lock pattern -- [docs/research/threading_1.md](research/threading_1.md) — deep dive: the Asyncify single-slot `currData` collision bug and the fix -- [docs/research/threading_2.md](research/threading_2.md) — external research: JSPI/WasmFX/state-machine alternatives, QEMU analysis +- [docs/debugging/DEBUG.md](debugging/DEBUG.md) — debugging guide: the JSPI runtime's observability (`__wxWaitDump`, beacons), reading a SuspendError, the harnesses +- [docs/features/async/23-jspi-runtime.md](features/async/23-jspi-runtime.md) — the JSPI runtime architecture (scheduler/turnstile, libcontext backend, embind call shapes) +- [docs/debugging/learning.md](debugging/learning.md) — historical: Asyncify + consecutive modal dialogs, the lock pattern (asyncify era) +- [docs/research/threading_1.md](research/threading_1.md) — historical deep dive: the Asyncify single-slot `currData` collision bug and its era's fix +- [docs/research/threading_2.md](research/threading_2.md) — external research that pre-studied the alternatives (JSPI/WasmFX/state machines), QEMU analysis ## Architecture diff --git a/docs/asyncify-allocator-suspend/plan.md b/docs/asyncify-allocator-suspend/plan.md index 41bddaf..10dbddd 100644 --- a/docs/asyncify-allocator-suspend/plan.md +++ b/docs/asyncify-allocator-suspend/plan.md @@ -1,3 +1,5 @@ +> **STATUS (2026-08-14):** Part 1 (zero-duration nanosleep guard) landed as 94cac5e and was ported to the JSPI branch. Parts 2-3 (removelist instrumentation) are moot — the JSPI migration removed Asyncify instrumentation entirely. + diff --git a/docs/build.md b/docs/build.md index daf2da3..89665ac 100644 --- a/docs/build.md +++ b/docs/build.md @@ -11,7 +11,7 @@ This document describes how to build KiCad for WebAssembly using the Docker-base ### Host Tools -Binaryen (wasm-opt) is downloaded automatically by the build script. No manual installation needed. +Node.js (for the seconds-long host postprocess step). Everything else runs inside the container. ## Quick Start @@ -36,47 +36,30 @@ Binaryen (wasm-opt) is downloaded automatically by the build script. No manual i - `build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm` - WASM binary - `build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm.map` - Source map (debug builds) -## Two-Phase Build +## Single-Phase Build -The build is split into two phases due to memory requirements: +The build is one pass: `docker/build.sh` compiles, links **and finalizes** the +wasm inside the container. The only host-side step is a postprocess on the +generated glue — `node scripts/common/patch-env-shim.mjs` merges `Module.ENV` +into the runtime's `ENV` (needed for `?trace=` and any future `Module.ENV` +use). It takes seconds and is idempotent. -### Phase 1: Docker Compilation -Compiles KiCad to WASM **without** asyncify transformation. This runs inside Docker with 32GB memory limit. +`--compile-only` / `--postprocess-only` split the two so CI can cache the +expensive compile and re-run just the host tail. -### Phase 2: Host Asyncify -Applies `wasm-opt --asyncify` on the host machine using Binaryen v121 (downloaded automatically to `tools/`). This transformation uses ~20-30GB RAM. +### Suspension: JSPI -**Note:** Binaryen v121 is used because v125 has a regression causing crashes in the asyncify liveness analysis. +Blocking calls — `wxDialog::ShowModal()`, `wxMessageBox()`, clipboard, +sleeps/waits, board loads — must yield to the browser event loop. This is +handled at **link time** by JSPI (JavaScript Promise Integration): every wasm +entry point that can suspend is a promising export +(`-sJSPI -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt`), and the +`jspi-scheduler.js` pre-js supplies the spill-stack and resume-serialization +discipline around it. See +[docs/features/async/23-jspi-runtime.md](features/async/23-jspi-runtime.md). -### Why Asyncify? - -Asyncify is an Emscripten transformation that allows WASM code to pause and resume execution. This is required for: - -- **Modal dialogs** - `wxDialog::ShowModal()` blocks until user closes the dialog -- **Message boxes** - `wxMessageBox()` waits for user response -- **Clipboard operations** - Browser clipboard API is async -- **Sleep/wait operations** - Any blocking call that needs to yield to the browser - -Without asyncify, modal dialogs would freeze the browser because WASM cannot yield control back to JavaScript's event loop. - -### How It Works - -1. `docker/build.sh` compiles KiCad in Docker (no asyncify flags) -2. Output is copied to `./output/` directory -3. `wasm-opt --asyncify` runs on host, transforming the WASM binary -4. Final output is ready for browser execution - -### Technical Details - -The asyncify transformation: -- Instruments every function that might be on the call stack during an async operation -- Adds stack save/restore logic to unwind and rewind the WASM stack -- Increases binary size by ~20% (141MB → 171MB for KiCad) -- Uses `asyncify-imports` pattern matching to identify async entry points - -Import patterns used: -- `env.invoke_*` - Exception handling trampolines -- `env.__asyncjs__*` - EM_ASYNC_JS functions (like `startModal()`) +There is no post-link binary rewriting: the wasm the container links is the +wasm that ships. ## Docker Architecture @@ -155,17 +138,18 @@ The build system is optimized for fast development iteration: - **ccache**: Caches compiled objects by hashing preprocessed source - **wxWidgets**: `configure` runs once, `make` handles file-level dependencies - **KiCad**: CMake tracks dependencies, only recompiles changed files -- **Asyncify**: Post-processing runs every build (~1 min, irreducible minimum) +- **Host postprocess**: the ENV-shim patch (`patch-env-shim.mjs`) re-runs every build — seconds ### Performance | Scenario | Time | |----------|------| -| No changes | ~1.5 min | -| Single file change (KiCad or wxWidgets) | ~1.5 min | +| No changes | seconds | +| Single file change (KiCad or wxWidgets) | dominated by the recompile + relink of that target | | Full rebuild | ~10 min | -Most time is spent on asyncify post-processing which runs on every build. +There is no fixed per-build post-processing cost: an unchanged tree re-runs +only the host ENV-shim patch. ### Debug vs Release @@ -259,24 +243,30 @@ The WASM port requires compatibility layers for browser execution: | Directory | Purpose | |-----------|---------| | `wasm/kiplatform/` | Platform abstraction (app, UI, printing, etc.) | -| `wasm/libcontext/` | Coroutine/fiber implementation for Asyncify | +| `kicad/thirdparty/libcontext/` | Coroutine backend (JSPI: one promising activation per coroutine) | | `wasm/stubs/` | Stub implementations (libgit2, curl) | | `wasm/config/` | Build configuration headers | ## Emscripten Flags -Key flags used in the build: +Key flags used in the build (browser apps; see +`scripts/kicad/build-kicad-target.sh` for the authoritative link surface): ``` --pthread -sUSE_PTHREADS=1 # Threading support --sASYNCIFY=1 # Async coroutine support --sALLOW_MEMORY_GROWTH=1 # Dynamic memory --sINITIAL_MEMORY=256MB # Starting memory --sMAXIMUM_MEMORY=4GB # Maximum memory --sLEGACY_GL_EMULATION # OpenGL compatibility --sMAX_WEBGL_VERSION=2 # WebGL 2.0 +-pthread -sUSE_PTHREADS=1 # Threading support +-sJSPI # JSPI suspension +-sJSPI_EXPORTS=@scripts/common/jspi-exports.txt # promising-export census +--pre-js scripts/common/shims/jspi-scheduler.js # scheduler/turnstile shim +-sALLOW_MEMORY_GROWTH=1 # Dynamic memory +-sINITIAL_MEMORY=256MB # Starting memory +-sMAXIMUM_MEMORY=4GB # Maximum memory +-sMAX_WEBGL_VERSION=2 # WebGL 2.0 ``` +Headless targets (`kicad_tools`, `occ_service`) link **no suspension +backend**: nothing in them may suspend, so they carry none of the three +JSPI-related flags above. + ## Testing After building, run the test suite: diff --git a/docs/debugging/DEBUG.md b/docs/debugging/DEBUG.md index 64a99fc..bd1c4a4 100644 --- a/docs/debugging/DEBUG.md +++ b/docs/debugging/DEBUG.md @@ -1,294 +1,276 @@ -# Debugging guide — KiCad / wxWidgets WASM +# Debugging guide — KiCad / wxWidgets WASM (JSPI runtime) -A practical reference for debugging this project: the kinds of issues WASM + -Asyncify + browser builds throw at you, the tools that actually work here, and -the gotchas of our specific build pipeline. It is **not** a writeup of any one -bug — for a concrete worked example see [§6](#6-a-worked-example) and the -project memory. +A practical reference for debugging this project: how the JSPI runtime is +wired, the observability surfaces built into it, and the recipes that actually +work here. It is **not** a writeup of any one bug — the numbered docs under +[`docs/features/async/`](../features/async/) carry those; the current +architecture is [`23-jspi-runtime.md`](../features/async/23-jspi-runtime.md). -If you're new to this codebase, read [§5 (project gotchas)](#5-project-specific-gotchas) -first — most wasted hours come from not knowing how the split build and the -shim layer behave. +If you're new to this codebase, read [§1](#1-the-runtime-in-one-paragraph) and +then go straight to [§2 (observability)](#2-observability) — most questions of +the form "why is nothing happening" are answered by one `__wxWaitDump()` call. --- -## 1. Classes of issue we hit here +## 1. The runtime, in one paragraph -- **Engine-specific intolerance** — the same `pcbnew.wasm` runs in Firefox but - not Chrome (or vice versa). Usually a V8-vs-SpiderMonkey difference in how an - Asyncify-instrumented or very large function is handled. -- **Silent stalls vs. hard crashes** — execution stops making progress with *no* - exception, trap, or crash report. Distinguishing "crashed" from "hung" from - "stalled" is half the battle (§2.6). -- **Asyncify state problems** — unwind/rewind not completing, instrumentation on - a function that shouldn't have it, or a function too large once instrumented. -- **Shim/codegen coupling** — `inject-dyncall-shims.sh` patches Emscripten output - by pattern; a flag change that alters codegen can silently break those patches. -- **Tooling blind spots** — async console delivery, stripped name sections, - Playwright hiding the renderer's stderr (§4). +Suspension is JSPI: every wasm entry point that can park is a +**promising export** (`-sJSPI` + the census in +`scripts/common/jspi-exports.txt`), and every suspension awaits a real JS +promise. One scheduler — `scripts/common/shims/jspi-scheduler.js`, shipped as +a `--pre-js` — owns the discipline around that: it wraps the promising +exports so it always knows which **activation** is executing or suspended, +gives each activation its own **spill-stack region** (JSPI switches the +native stack per activation but *not* the C spill stack — emscripten #27364), +and serializes engine re-entries through a resume **turnstile** so only one +activation's SP can be armed between wasm entries. KiCad tool coroutines run +on the JSPI backend of `kicad/thirdparty/libcontext/` (one promising +activation per coroutine, own region, promise-pair yield/resume) and +integrate with the same turnstile. There is no post-link instrumentation, no +unwind/rewind state machine, and no rewind buffer to corrupt: what used to be +"asyncify state problems" are now ordinary promise/call-shape problems. --- -## 2. Tools & techniques +## 2. Observability -### 2.1 Stub-bisection *(the workhorse)* -Comment out / early-`return` a suspect call, rebuild, and observe a **binary -survives-or-fails** outcome. This is the most reliable signal we have because it -does **not** depend on reading logs (which lag — see §4). Narrow by halving: -disable half the suspects, see which half flips the outcome. -- *When:* you can localize a failure to "before/after some call." -- *Caveat:* at `-O2`, dead-code elimination removes more around an early `return` - than you intend — keep this in mind when a stub "fixes" too much. +### 2.1 `__wxWaitDump()` — the first thing to run -### 2.2 `SHIM_DIAGNOSTICS=1` fast loop *(skip the rebuild)* -The only host-side JS step is `inject-dyncall-shims.sh`. Re-run it on a pristine -`pcbnew.js` while keeping the already-finalized/asyncified `pcbnew.wasm` — JS-only -changes go from a multi-minute rebuild to seconds: -```bash -cp output/pcbnew.pristine.js output/pcbnew.js -SHIM_DIAGNOSTICS=1 ./scripts/common/inject-dyncall-shims.sh output/pcbnew.js -cd tests && npm run setup:kicad +Available on any app page (and printed automatically by the SuspendError +attributor). Returns one object: + +| field | meaning | +|---|---| +| `dead` | scheduler shut down (teardown seen) | +| `waitsBegun` / `waitsResolved` | token-wait registry totals (modal, nested, clipboard, lib-bridge …) | +| `earlyWaitResolves` | waits resolved before their waiter parked (legal fast path) | +| `pendingWaits` | unresolved registry entries right now | +| `runningActivations` | promising exports currently on the JS stack | +| `suspendedActivations` | array of `{id, kind, waitKind, token, suspendedMs}` — every parked activation | +| `mutatorsWrapped` / `mutatorsDelivered` / `mutatorQueueDepth` | the embind mutator FIFO (queued while `kicadOpenFileBusy`) | +| `ring` | the last 64 scheduler events (see §2.2) | + +Reading it: a wedge usually shows up as an entry in `suspendedActivations` +with a large `suspendedMs` and a `waitKind`/`token` that tells you *what* it +is waiting for; cross-check `pendingWaits` and the ring. `id`s of the form +`"lc"` are libcontext coroutines; negative ids are untracked/anonymous +suspensions (boot-time `main`, foreign yields). + +### 2.2 Ring counting recipes + +`__wxScheduler._ring` holds `[epochMs, event, a, b]` tuples (last 256; the +dump slices the last 64). Useful counts: + +```js +// stale resumes refused by the doc-15 contract (a = 'lc') +__wxScheduler._ring.filter(e => e[1] === 'libctxRefusedResume').length + +// turnstile self-heals — should be 0 in a healthy run +__wxScheduler._ring.filter(e => e[1] === 'forceClearWindow') + +// wakes dropped at quarantined (released-while-parked) coroutines +__wxScheduler._ring.filter(e => e[1] === 'deadWakeDropped') + +// park/resolve balance per wait kind +__wxScheduler._ring.filter(e => e[1] === 'park').map(e => e[2]) ``` -See the `wasm-build-fast-iteration` project memory. -### 2.3 Logging-only diagnostics module (`scripts/common/shims/diagnostics.js`) -Injected **only** when `SHIM_DIAGNOSTICS=1` (off by default, safe to leave in -tree). Provides hooks that need no rebuild: -- Asyncify lifecycle: `doRewind`, `handleSleep` (unwind/rewind markers). -- Modal lifecycle. -- A **WebGL call tracer** (did any GL call happen before the failure?). -- A **dynCall tracer**: wraps the shim-bound `dynCall_ii`/`dynCall_vi` to log - `ptr`, `getWasmTableEntry(ptr).name` (the function index), and a JS stack for - rare/large table indices. Arm it at the main rewind to bound log volume. -- Periodic asyncify-state monitor (catch "JS task queue stopped pumping"). +Other event names you will see: `beginWait`, `resolve`, `wrapped`, +`libctxQuarantine`, `shutdown`. -Output is at `console.log` level (not error/warn). This is the JS-side tracer; the -C++ source diagnostics are separate and flag-gated — see §2.9. +### 2.3 `__libctxJspi` — the coroutine census -### 2.4 Symbolizing wasm function indices -The loaded (post-asyncify) wasm has **no `name` section**, so V8/Firefox report -bare function indices (`func[20736]`). The Asyncify pass **preserves function -indices**, so a symbol map taken from the *pre-asyncify* wasm is still valid: -```bash -# the in-container wasm-opt is a STUB; use the real one -/emsdk/upstream/bin/wasm-opt.real --symbolmap=/tmp/syms.map -# then look up the index, e.g. 20736 -> PCB_EDIT_FRAME::setupUIConditions() +The libcontext JSPI backend keeps its own JS-side census: + +```js +Object.keys(__libctxJspi.s).length // live coroutine slots (promise pairs) +__libctxJspi.tops // id -> spill-region top (SP swap target) +__libctxJspi.ghosts // ghost/refused transitions, ever +__libctxJspi.deadParked // coroutines released while parked mid-body ``` -Generate the map from a build that still has names (the debug build's -pre-asyncify wasm). See §5 on names/DWARF. -### 2.5 Cross-engine comparison -Run the **same** diagnostics build in Firefox and Chrome and compare state at the -**same dispatch point** (e.g. asyncify `state`/`currData` at the suspect -`dynCall`). If both reach a point with identical state but only one proceeds, you -have isolated an engine-specific bug and can stop looking for a logic error. +Records are tombstoned, never freed C-side, so stale handles / double +releases / ghost resumes are refused loudly instead of corrupting anything — +each refusal bumps `ghosts` and prints a beacon (§2.4). + +### 2.4 Beacon vocabulary + +Everything the runtime is unhappy about is announced on the console with a +stable prefix. Test helpers count these (`tests/kicad/utils/wait-beacons.ts`); +when debugging by hand, grep the captured console for the prefix. + +| beacon | source | meaning | +|---|---|---| +| `[libctx-jspi] ghost/refused transition … reason=` | `libcontext.cpp` | a refused coroutine transition; `reason` is one of `ghost-enter`, `yield-no-cur`, `released-while-parked`, `dead-cur-substituted`, `yield-to-dead-enterer`, `release-of-running-ignored` | +| `[libctx-jspi] coroutine N entry REJECTED: ` | `libcontext.cpp` | the coroutine's entry activation *rejected* (a trap inside the body); the enterer receives the refusal sentinel instead of hanging | +| `[libctx-jspi] REGION OVERFLOW: coroutine N …` | `libcontext.cpp` | the spill-region base canary tripped — a tool body outgrew its region | +| `[wx-scheduler] force-clearing stuck window …` | `jspi-scheduler.js` | turnstile self-heal: some suspension bypassed the shim (untracked raw await) and would otherwise block resumes forever | +| `[wx-scheduler] job tick error: …` | `evtloop.cpp` | a scheduled-job handler threw; containment fired | +| `[wx-scheduler] mailbox tick error: …` | `jspi-scheduler.js` | a delivered mailbox handler threw; `wx_dispatch_abandon` + top-wait resolution keep the app alive | +| `[wx-scheduler] shutdown () clean` / `… stranded:N` | `jspi-scheduler.js` | teardown contract — a clean exit *says so*; `stranded` counts waits that never resolved (asserted by `e2e/app-quit.spec.ts`) | +| `[wx-scheduler] SuspendError: …` | `jspi-scheduler.js` | see §3 — includes a full dump for targeting | +| `[wx-scheduler] LOST WAKE: …` | `jspi-scheduler.js` | watchdog: an activation parked >30 s on a token wait that is no longer registered | +| `[wx-scheduler] dropping wake for quarantined N` | `jspi-scheduler.js` | a late wake arrived for a released coroutine; refused (never re-enter a freed body) | +| `[wx-timer] retry storm: N retries …` | `timer.cpp` | a timer's `Notify` kept retrying against a held dispatch interlock — something is parked across ticks | +| `[wx-dispatch] ERASED … / NEGATIVE depth …` | `evtloop.cpp` | dispatch-interlock bookkeeping anomaly — depth accounting corrupt, report it | + +A healthy run is beacon-silent apart from at most a `shutdown … clean`. + +### 2.5 Tooling blind spots (read before trusting output) + +- **Console is async** — `printf`/`console.*` reaches Playwright via CDP + asynchronously; the *last delivered* line can lag the real failure point. + Prefer state dumps (§2.1) and binary-outcome bisection over "the last log + line". +- **Playwright hides the renderer** — it forces `--disable-breakpad` and only + pipes the *browser* process stderr. To see the renderer's own stderr and a + real crash reason, serve `tests/apps` with the COOP/COEP headers + (`tests/serve.json`, e.g. `npx serve apps -c ../serve.json`) and open the + page in a normal Chrome with crash reporting on. +- **macOS `sample`/`.ips`** see wasm frames as numeric offsets, not C++ names. + +**Crash vs. hang vs. stall** — a failure with no exception is not necessarily +a crash. Find the renderer PID and inspect it: -### 2.6 Crash vs. hang vs. stall -A failure with no exception is not necessarily a crash. Find the renderer PID and -inspect it: ```bash ps -axo pid,%cpu,%mem,command | grep -i 'Google Chrome' sample 3 # what is the main thread doing? ``` -- **Idle in `CFRunLoop`/`mach_msg2_trap`, ~0% CPU** → a *stall* (event loop alive, - but nothing scheduled to run). Not a deadlock. -- **Blocked on a futex / `Atomics.wait`** → a pthread/lock issue. -- **Spinning at 100%** → an infinite loop. -- **Gone + a `.ips` report** → a real signal crash. -To see the **renderer's own stderr** and a real crash reason, launch system -Chrome **outside Playwright** (Playwright forces `--disable-breakpad` and only -pipes the *browser* process stderr): serve `tests/apps` with the COOP/COEP headers -(`tests/serve.json`) and open the page in a normal Chrome with crash reporting on. -On-load failures need no interaction to reproduce. +- Idle in `CFRunLoop`/`mach_msg2_trap`, ~0% CPU → a *stall* (event loop + alive, nothing scheduled). Under JSPI this almost always means a parked + activation whose wake was lost or refused — go read `__wxWaitDump()` and + the ring. +- Blocked on a futex / `Atomics.wait` → a pthread/lock issue. +- Spinning at 100% → an infinite loop. +- Gone + a `.ips` report → a real signal crash. -### 2.7 Build-flag diagnostics -- `-sASSERTIONS=2` turns silent UB into named errors. **But** it changes - Emscripten codegen and can break `inject-dyncall-shims.sh`'s `sed` patterns - (causing a *different*, red-herring failure), and it implicitly enables - `STACK_OVERFLOW_CHECK`, whose `___set_stack_limits` our host Asyncify pass - strips → pair it with `-sSTACK_OVERFLOW_CHECK=0`. Prefer the §2.3 dynCall - tracer on a normal build when you can. -- `--pass-arg=asyncify-asserts` (added to the `wasm-opt --asyncify` invocation in - `apply-asyncify.sh`) adds Asyncify state-machine runtime checks — use it to - validate the removelist (a wrongly-excluded function that *does* unwind is - otherwise silent corruption). +--- -### 2.8 Isolated standalone probes -`tests/apps/standalone/coroutine-pthread/` builds minimal C++ probes with the -*real* libcontext + Asyncify + pthreads + DYNCALLS + the shim, run via -`tests/e2e/coroutine-pthread.spec.ts`. Use these to reproduce a mechanism in -isolation. **Reality check:** an isolated probe often *won't* reproduce a bug -that needs the full app runtime — don't over-trust a green probe. +## 3. Reading a SuspendError + +Chromium: `RangeError: Trying to suspend without WebAssembly.promising` (or +similar `Suspend…` wording). Firefox: `No matching WebAssembly.promising`. + +Both mean the same **call-shape problem**: a *plain* (non-promising) entry +into wasm reached a suspending import. The suspension has nowhere to go — a +promising activation is created at the *export* boundary, not at the park +site — so the engine throws at the park. + +The scheduler's attributor catches these globally and prints +`[wx-scheduler] SuspendError: …` with a full `__wxWaitDump()` — the engine +cannot say *which* export was entered plainly, but the dump (what is wrapped, +what was executing) is exactly the targeting data you need. + +Fixes, in order of likelihood: + +1. **A missing census entry.** The export can suspend but is not declared: + add it to `scripts/common/jspi-exports.txt` *and* the scheduler wrap list + in `jspi-scheduler.js` *and* `tests/apps/Makefile.wasm`'s + `WX_JSPI_EXPORTS` (three synchronized copies — see doc 23). +2. **A plain embind registration.** Suspending embind exports must be + registered `emscripten::async()` — use `PCBJAM_PARKER_POLICY` + (`wasm/bindings/pcbjam_async_policy.h`). +3. **A genuinely illegal park** — code that must not suspend (a CLI/service + target with no suspension backend, an `emscripten_set_main_loop` callback) + grew a suspending call. Move the work behind a promising export instead. + +--- + +## 4. The harnesses + +### 4.1 `tests/apps/standalone/jspi-coroutine` — the coroutine contract battery + +A wx-free MiniCoro that mirrors `tool/coroutine.h`'s protocol *exactly* +(INVOCATION_ARGS, callerStub + `finish_fcontext`, jumpIn/jumpOut, +CONTINUE_AFTER_ROOT) over the **real** `kicad/thirdparty/libcontext`. 18 +cases: entry/yield/resume/completion, deep-stack preservation, nesting with +enterer inference, RunMainStack, value transfer, yield-inside-catch under +native wasm-EH, timer-driven resume, slot reclaim, ghost-resume refusal +(sentinel-shaped), mid-body release census, phantom-release refusal, and +destroy-while-parked containment. -### 2.9 Source diagnostic logging flags (`--diag=`) -The KiCad C++ source carries built-in diagnostic logging, **off by default**, -enabled per category at build time: ```bash -./docker/build.sh --debug --diag=gal,coroutine,ctor # or: --diag=all +cd tests/apps/standalone/jspi-coroutine +./build.sh # rebuild both variants against the real libcontext +node run.mjs # single-thread build, node +node run_pt.mjs # pthread build +# browser (both variants): tests/jspi/jspi-coroutine.spec.ts ``` -| `--diag=` value | covers | -|---|---| -| `gal` | `[DIAG_GAL]` — GAL/WebGL pipeline (paint, context create/lock, init) | -| `coroutine` | `[WASM_FCONTEXT]` fiber switches + `[DIAG_TOOL]`/`[DIAG_DISP]` tool dispatch | -| `ctor` | `[DIAG_CTOR]` — `PCB_EDIT_FRAME` startup milestones | -- Each value maps to a `-DKICAD_DIAG_*` define that gates the `KI_DIAG_*` macros - in `kicad/include/kicad_wasm_diag.h`. All output goes to **stdout** → it shows - as `[KICAD_OUT]` logs, never `[KICAD_ERR]` errors. -- **Compile-time:** changing `--diag` changes `CMAKE_CXX_FLAGS`, so it forces a - recompile (slow once per flag combo, then ccache-cached). Works with `--debug` - or `--release`. -- Separate from the JS shim tracer (§2.3), which stays `SHIM_DIAGNOSTICS`-gated. +Output contract: `[JSPI_CORO] CASE PASS|FAIL()`, then +`[JSPI_CORO] SUMMARY passed= failed=`. If `build.sh` dies inside +emscripten's python driver, point `EMSDK_PYTHON` at a modern interpreter +(≥3.10; 3.13 known-good). + +### 4.2 `tests/jspi/suspend-races.spec.ts` — semantic suspension races + +The suspension-race scenarios (nested modal LIFO, out-of-order wake +resolution, no-lost-wakes, nested-loop teardown-on-error), run against the +races harness built for JSPI. The scenarios express through public wx + +coroutine APIs, so they are exactly as meaningful under JSPI — only the +failure *modes* they'd catch differ (activation misnesting or a lost wait +token). `tests/jspi/jspi-stack.spec.ts` is the red/green proof of the +spill-stack discipline itself. + +### 4.3 Isolated probes, generally + +A standalone probe often *won't* reproduce a bug that needs the full app +runtime — don't over-trust a green probe. The reverse recipe still holds +too: stub-bisection (comment out a suspect call, rebuild, observe a binary +survives-or-fails outcome) beats staring at logs, because it does not depend +on console delivery order. --- -## 3. Principles +## 5. Browser notes -1. **Reproduce cleanly first** — a stable engine-X-fails / engine-Y-passes - baseline before changing anything. -2. **Fix the build infra before iterating** — a flaky build wastes every - subsequent experiment. -3. **Narrow by bisection**, with binary outcomes, not by staring at logs. -4. **Turn silent failures into named ones** (assertions, asyncify-asserts) or - into a state comparison across engines. -5. **Know the tooling's blind spots** (§4) before trusting what it shows you. +- **Firefox 153+ is the strict engine.** JSPI is on by default (Playwright + ≥1.62 ships FF 153) and a *plain* embind call into a suspending body throws + immediately. Chromium tolerates some shapes FF refuses — the sync + `kicadTestFiberPark*` levers are usable for manual probing **on Chromium + only**. If a suspension bug reproduces on one engine only, suspect a + call-shape difference first (§3), not a logic difference. +- **Firefox runs big promising modules on a slow tier.** Observed as library + enumeration slowness (FootprintEnumerate rows never appearing within 60 s + on the remote read path); tracked upstream (#42199). The firefox leg of + `footprint-browse-remote` is gated on it. +- **COOP/COEP.** SharedArrayBuffer/pthreads need cross-origin isolation + headers; serve `tests/apps` with `tests/serve.json`. --- -## 4. Tooling blind spots (read before trusting output) +## 6. Build-side debugging -- **Console is async** — `printf`/`console.*` from WASM reaches Playwright via - CDP asynchronously; the *last delivered* line can lag the real failure point. - Use stub-bisection for ground truth, not "the last log line." -- **No name section** in the shipped wasm → bare indices (§2.4). -- **Asyncify shifts code offsets** — DWARF line info is generated before the host - Asyncify pass rewrites the code, so source-line mapping on the *shipped* wasm is - stale. Asyncify *does* preserve function indices and names. -- **Playwright hides the renderer** — forces `--disable-breakpad`, pipes only the - browser process stderr (§2.6). -- **macOS `sample`/`.ips`** see wasm frames as numeric offsets, not C++ names. +- **The build is single-phase.** `docker/build.sh` compiles, links *and + finalizes* inside the container; the only host step is + `node scripts/common/patch-env-shim.mjs` (merges `Module.ENV` into the + glue's `ENV` so `?trace=` works — seconds). `--compile-only` / + `--postprocess-only` split the two when CI caches the compile. There is no + post-link wasm rewriting to go wrong: what you linked is what runs. +- **Logs + monitor.** Builds redirect all output to + `logs/