jspi: retire the asyncify pipeline — knob, post-link tail, binaryen hooks

Phase 8 in the parent repo. Deleted: asyncify-scheduler.js, apply-asyncify.sh,
apply-finalize.sh, inject-dyncall-shims.sh, asyncify-imports/removelist.txt,
the wasm-opt/finalize stub pair, scripts/binaryen-hoist-pass/ (the fork stays
a dormant submodule; removal is a follow-up), bench/wasm-opt-bench.sh (README
marked historical), wasm/shims/context_sleep.cpp, and the sched-context
harness app + Makefile targets.

PCBJAM_ASYNC_BACKEND is gone: build-wx-wasm.sh hardcodes the jspi stamp
(still force-cleans pre-migration trees), build-kicad-target.sh gives editors
the JSPI link surface and the CLIs nothing (they pin ASYNCIFY=0), the stub
dance is replaced by an unconditional .real-restore, build-wasm-test.sh lost
its whole post-link loop, docker/build.sh's postprocess is the ENV shim only,
and Makefile.wasm links every app JSPI with the scheduler shim as a tracked
prerequisite. pcbjam_async_policy.h keys on __EMSCRIPTEN__.

jspi-scheduler.js: wxWasmMainLoopPump dropped from the wrap census (the
export died with the D5 detach); inert [TRACE] instrumentation removed.

CI: wasm-build.yml rewritten for the single-cache pipeline (one output cache
keyed on compile inputs; post-processed bytes cached after the shim);
opt_level input removed from both callers. wasm-cache-hash.mjs inputs now
cover patch-env-shim.mjs + jspi-scheduler.js + jspi-exports.txt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
This commit is contained in:
Viktor Vaczi 2026-08-13 08:39:12 +02:00
commit e14faeca8b
36 changed files with 170 additions and 3348 deletions

View file

@ -3,9 +3,8 @@ name: CI full build + e2e (Ubicloud)
# Main/PR gate: build all 6 KiCad WASM tools + run the wxWidgets + KiCad e2e
# suites on a Ubicloud runner. The actual recipe lives in the reusable
# wasm-build.yml (the SINGLE build definition) — this is just the main/PR caller.
# The asyncify wasm-opt tail is -O1 everywhere (main, release, local); the tag
# release (release.yml) calls the same recipe at the same -O1 and then publishes,
# so the build can never diverge from what ships.
# The tag release (release.yml) calls the same recipe with the same inputs and
# then publishes, so the build can never diverge from what ships.
#
# Routine edits under scripts/ do NOT bust the WASM output cache — only the subset
# in scripts/deploy/wasm-cache-hash.mjs does. For inputs the sc-hash can't see
@ -44,9 +43,7 @@ jobs:
# post-test step can post the screenshot + perf report on push to main.
secrets: inherit
with:
# -O1 asyncify shrink — the level we ship (release.yml uses the same -O1).
# 3D viewer ON so 3d-viewer.spec.ts has a viewer.
opt_level: "-O1"
build_3d_viewer: "ON"
run_tests: true
no_cache: ${{ inputs.no_cache || false }}

View file

@ -1,10 +1,9 @@
name: release
# Release pipeline on a vX.Y.Z tag, in order:
# 1) build — the SAME wasm-build.yml recipe as CI, at the SAME -O1 asyncify tail
# + run the e2e gate. Because the opt level now matches main, this
# FINAL-cache-hits main's build and skips the asyncify tail rebuild.
# Uploads output/.
# 1) build — the SAME wasm-build.yml recipe as CI + the e2e gate. For an
# already-built commit this hits main's WASM output cache and
# skips the build entirely. Uploads output/.
# 2) publish-wasm — push the build to the CDN (content-addressed, idempotent)
# and write manifest-<tag>.json. The registry now reflects THIS tag.
# 3) deploy-demo — build the standalone pinned to that manifest + deploy to
@ -69,13 +68,12 @@ jobs:
- id: tag
run: echo "tag=${{ github.event.inputs.tag || github.ref_name }}" >> "$GITHUB_OUTPUT"
# 1) Build (-O1, identical to main) + e2e gate. Reuses main's FINAL cache (same
# opt level) so the asyncify tail isn't rebuilt. Uploads the publishable
# output/ as the 'wasm-output' artifact for the publish job.
# 1) Build (identical to main) + e2e gate. Reuses main's WASM output cache for
# an already-built commit. Uploads the publishable output/ as the
# 'wasm-output' artifact for the publish job.
build:
uses: ./.github/workflows/wasm-build.yml
with:
opt_level: "-O1"
build_3d_viewer: "ON"
run_tests: true
upload_output: true

View file

@ -1,31 +1,25 @@
name: wasm-build (reusable)
# THE single KiCad WASM build+test recipe, called by ci-ubicloud.yml (main/PR) and
# release.yml (tag). Both build the SAME way — the asyncify `wasm-opt` tail is -O1
# everywhere — so the build can never diverge from what ships (the bug that shipped
# a -O1 / 3D-off demo while CI built -O1 / 3D-on). The opt_level input remains as an
# escape hatch for a one-off -O2 build, but is -O1 for both real callers, so a tag
# release FINAL-cache-hits main's build and skips the asyncify tail entirely.
# release.yml (tag). Both build the SAME way — the only build knob is the
# 3D-viewer flag, ON for both real callers — so the build can never diverge from
# what ships (the bug that once shipped a 3D-off demo while CI built 3D-on).
#
# Two-tier output cache around docker/build.sh's --compile-only / --postprocess-only
# split. The expensive container compile (→ base wasm) is opt- and binaryen-
# independent; only the host `asyncify + wasm-opt -O` tail depends on the binaryen
# fork + opt level. So:
# - BASE cache (compile-input key): the --compile-only output (base wasm +
# sysroot headers). Reused whenever only the binaryen fork / asyncify config
# changed — the compile is skipped and just the tail reruns.
# - FINAL cache (base + binaryen SHA + opt level): the post-processed output;
# fast-path for re-running the same SHA (a tag release reusing main, a re-deploy).
# Both keys include the 3D-viewer flag, so a 3D-on and 3D-off build can never
# poison each other's cache.
# ONE output cache around docker/build.sh's --compile-only / --postprocess-only
# split. Under JSPI the wasm links fully finalized in-container; the only host
# post-process left is the deterministic, seconds-cheap ENV merge shim
# (patch-env-shim.mjs). So the cache holds the post-processed output/, keyed on
# the compile inputs alone: the kicad/wx submodule SHAs, the sc source-content
# hash (scripts/deploy/wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D-viewer flag (a 3D-on and 3D-off build can never poison
# each other's entry), and the manual .ci-cache-epoch escape hatch. On a hit
# the whole build is skipped (a tag release reuses main's entry for the same
# commit); on a miss the compile reruns and the shim is re-applied before the
# save.
on:
workflow_call:
inputs:
opt_level:
description: "Binaryen wasm-opt shrink level for the asyncify tail (-O1 everywhere; escape hatch for a one-off -O2)"
type: string
default: "-O1"
build_3d_viewer:
description: "Build the WASM 3D viewer into kicad_editor (ON/OFF)"
type: string
@ -35,7 +29,7 @@ on:
type: boolean
default: true
no_cache:
description: "Bypass the WASM output caches (force a full rebuild this run)"
description: "Bypass the WASM output cache (force a full rebuild this run)"
type: boolean
default: false
upload_output:
@ -60,19 +54,18 @@ jobs:
timeout-minutes: 300
env:
KICAD_LOG_NESTED: "1"
# 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 }}
# Stable docker-compose project → deterministic build-cache volume name.
COMPOSE_PROJECT_NAME: kicad-wasm-ci
steps:
- name: Install build toolchain (Binaryen from-source)
- name: Install build toolchain
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
# xvfb: kicad e2e runs headed Firefox under a virtual display.
# autoconf/automake/make: host wxWidgets + wx test-app builds.
sudo apt-get install -y cmake ninja-build g++ libjemalloc2 xvfb autoconf automake make
# cmake/ninja/g++: host GAL/3D test-app builds.
sudo apt-get install -y cmake ninja-build g++ xvfb autoconf automake make
- uses: actions/checkout@v4
with: { submodules: recursive }
@ -80,29 +73,27 @@ jobs:
- uses: actions/setup-node@v4
with: { node-version: 20 }
# --- cache keys --------------------------------------------------------
# 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.
# --- cache key ---------------------------------------------------------
# ONE key, from the compile inputs only: kicad/wx submodule SHAs, the sc
# source-content hash (wasm-cache-hash.mjs — build logic outside the
# submodules), the 3D flag (3D-on/off never share an entry), and the
# manual .ci-cache-epoch. The host post-process (ENV merge shim) is
# deterministic and cheap, so the cached bytes are the fully
# post-processed output — no separate post-process tier.
- 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)
EMV=$(. scripts/common/versions.sh && echo "$EMSCRIPTEN_VERSION")
THREED='${{ inputs.build_3d_viewer }}'
BASE="kbase-${{ runner.os }}-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}"
KEY="kwasm-${{ runner.os }}-k${KICAD}-wx${WX}-sc${SC}-3d${THREED}-e${EPOCH}"
{
echo "kicad=$KICAD"; echo "wx=$WX"; echo "sc=$SC"; echo "epoch=$EPOCH"
echo "bin=$BIN"; echo "emv=$EMV"
echo "base_key=$BASE"; echo "final_key=$FINAL"
echo "emv=$EMV"
echo "key=$KEY"
} >> "$GITHUB_OUTPUT"
- name: Cache control (commit message / dispatch)
@ -118,32 +109,10 @@ jobs:
echo "skip=$SKIP" >> "$GITHUB_OUTPUT"
echo "WASM output-cache restore skip=$SKIP"
# Binaryen post-process tools (submodule fork): otherwise built from source on
# every fresh VM (~46s on 30 cores, measured run 28577824366). Cache bin/ AND
# lib/ (the tools dynamically link lib/libbinaryen.so — bin/ alone is a loader
# error at first exec, run 28585074335), keyed on the exact submodule SHA; on a
# hit BINARYEN_TRUST_PREBUILT tells build-wasm-opt.sh to skip cmake+ninja and
# trust the restored binaries. v2: v1 entries hold a poisoned bin-only layout.
# Needed by BOTH the host post-process (final-miss path) and the test-app
# build, hence not gated on run_tests.
- name: Cache Binaryen post-process tools
id: binopt-cache
uses: actions/cache@v4
with:
path: |
build-wasm/tools/binaryen-hoist-build/bin
build-wasm/tools/binaryen-hoist-build/lib
key: binopt-v2-${{ runner.os }}-${{ steps.keys.outputs.bin }}
- name: Trust prebuilt Binaryen tools (cache hit)
if: steps.binopt-cache.outputs.cache-hit == 'true'
run: echo "BINARYEN_TRUST_PREBUILT=1" >> "$GITHUB_ENV"
# The cached paths the e2e tests need: final wasms (or base, mid-build) +
# the sysroot headers the host GAL build compiles against. Same glob set for
# both tiers — only the bytes (base vs final) and the key differ.
- name: Restore FINAL WASM output cache
id: final-cache
# The cached paths the e2e tests need: the post-processed wasms + the
# sysroot headers the host GAL build compiles against.
- name: Restore WASM output cache
id: wasm-cache
if: steps.cachectl.outputs.skip != 'true'
uses: actions/cache/restore@v4
with:
@ -155,64 +124,46 @@ jobs:
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.final_key }}
key: ${{ steps.keys.outputs.key }}
# Only consulted when the final (opt-specific) cache missed: the
# opt-independent compile output, warm across -O1/-O2.
- name: Restore BASE compile cache
id: base-cache
if: steps.cachectl.outputs.skip != 'true' && steps.final-cache.outputs.cache-hit != 'true'
uses: actions/cache/restore@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.base_key }}
# deps (sysroot + stamps) are only needed when we must COMPILE (base miss).
# deps (sysroot + stamps) are only needed when we must COMPILE (cache miss).
- name: Restore deps cache
id: deps-cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
if: steps.wasm-cache.outputs.cache-hit != 'true'
uses: actions/cache@v4
with:
path: deps-cache
key: deps-${{ runner.os }}-${{ hashFiles('scripts/deps/**','scripts/common/versions.sh','scripts/common/functions.sh','scripts/common/env.sh','docker/Dockerfile','docker/docker-compose.yml') }}
- name: Seed deps volume from cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit == 'true'
if: steps.wasm-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit == 'true'
run: |
docker volume create kicad-wasm-ci_kicad-build-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'tar xzf /cache/deps.tar.gz -C /bw'
# PHASE 1 (base miss only): container compile of all 4 bundles → output/ base
# wasm (opt-independent). 3D viewer per input. --compile-only skips the host
# asyncify/-O tail. KICAD_PIPELINE has no effect here (no post-process to
# overlap), but the deps short-circuit on a warm volume.
- name: Compile all KiCad tools (container, base wasm)
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
# PHASE 1 (cache miss only): container compile of all bundles → output/
# wasm, fully finalized in-container (JSPI links with the real tools).
# 3D viewer per input.
- name: Compile all KiCad tools (container)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
export KICAD_DOCKER_CPUS="$(( $(nproc) - 1 ))" KICAD_DOCKER_MEM=110G
echo "Compiling ALL tools (base wasm), 3D viewer=${BUILD_3D_VIEWER}, -j $(nproc)"
echo "Compiling ALL tools, 3D viewer=${BUILD_3D_VIEWER}, -j $(nproc)"
./docker/build.sh all --compile-only --build-deps -j "$(nproc)"
ls -lh output/*.wasm
- name: Package deps for cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit != 'true'
if: steps.wasm-cache.outputs.cache-hit != 'true' && steps.deps-cache.outputs.cache-hit != 'true'
run: |
mkdir -p deps-cache
docker run --rm -v kicad-wasm-ci_kicad-build-cache:/bw -v "$PWD/deps-cache":/cache \
alpine sh -c 'cd /bw && tar czf /cache/deps.tar.gz sysroot stamps'
# GAL test compiles against kicad headers from the docker sysroot volume —
# export them to the host so they ride in the base cache (and thus any hit).
# export them to the host so they ride in the output cache (and thus any hit).
- name: Expose docker sysroot headers to host builds
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
VOL=kicad-wasm-ci_kicad-build-cache
mkdir -p build-wasm/sysroot
@ -220,36 +171,18 @@ jobs:
sh -c 'cp -r /bw/sysroot/include /host/'
sudo chown -R "$(id -u):$(id -g)" build-wasm/sysroot
# Save the opt-independent base (compile output + headers) for cross-opt reuse.
- name: Save BASE compile cache
if: steps.final-cache.outputs.cache-hit != 'true' && steps.base-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
output/*.js
output/*.wasm
output/*.wasm.map
output/*.worker.js
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.base_key }}
# 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; 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'
# PHASE 2 (cache miss only): the sole remaining host post-process — the
# deterministic ENV merge shim (patch-env-shim.mjs) on the editor glue.
# No container needed; the CLIs (kicad_tools/occ_service/ngspice_service)
# skip even this inside build.sh.
- name: Host post-process (ENV merge shim)
if: steps.wasm-cache.outputs.cache-hit != 'true'
run: |
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/binaryen-hoist-pass/build-wasm-opt.sh 2>/dev/null)" --version || true
ls -lh output/*.wasm
- name: Save FINAL WASM output cache
if: steps.final-cache.outputs.cache-hit != 'true'
- name: Save WASM output cache
if: steps.wasm-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
@ -260,7 +193,7 @@ jobs:
output/images.tar.gz
build-wasm/sysroot/include
!output/*.wasm.debug.wasm
key: ${{ steps.keys.outputs.final_key }}
key: ${{ steps.keys.outputs.key }}
# --- publishable artifact (release path) -------------------------------
- name: Upload WASM output artifact
@ -313,15 +246,14 @@ jobs:
if: inputs.run_tests
run: ./scripts/build-wx-wasm.sh
# Built wx test apps (tests/apps): without this every run recompiles, relinks
# and — the expensive part — re-runs the hoist+asyncify post-link on all ~74
# apps (~2m20s even 30-wide). Key = every build input: the wx lib identity
# (submodule SHA + the same script hashes as the wx cache key; the wx SHA also
# covers the Makefile's JS_FILES from wxwidgets/build/wasm), the KICAD
# submodule SHA (some apps compile real KiCad sources — thread_pool.cpp,
# libcontext, headers), the binaryen SHA (post-link wasm-opt), the app
# sources (tracked cpp/h/html + Makefile), and the build/post-link scripts +
# JS shims (wasm/** = shims + the wasm-opt stub). hashFiles runs at restore
# Built wx test apps (tests/apps): without this every run recompiles and
# relinks all ~74 apps (~2m20s even 30-wide). Key = every build input: the
# wx lib identity (submodule SHA + the same script hashes as the wx cache
# key; the wx SHA also covers the Makefile's JS_FILES from
# wxwidgets/build/wasm), the KICAD submodule SHA (some apps compile real
# KiCad sources — thread_pool.cpp, libcontext, headers), the app sources
# (tracked cpp/h/html + Makefile), and the build scripts +
# JS shims (wasm/**). hashFiles runs at restore
# time, on a fresh checkout, so it sees only tracked sources — never build
# outputs. On a hit the build step is skipped entirely. Excluded:
# tests/apps/kicad (setup:kicad staging from output/) and gal-webgl (its own
@ -341,7 +273,7 @@ jobs:
!tests/apps/kicad
!tests/apps/gal-webgl
!tests/apps/3d-webgl
key: testapps-${{ runner.os }}-wx${{ steps.keys.outputs.wx }}-k${{ steps.keys.outputs.kicad }}-bin${{ steps.keys.outputs.bin }}-${{ hashFiles('tests/apps/**/*.cpp', 'tests/apps/**/*.h', 'tests/apps/**/*.html', 'tests/apps/Makefile.wasm', 'scripts/build-wx-wasm.sh', 'scripts/build-wasm-test.sh', 'scripts/common/versions.sh', 'scripts/common/env.sh', 'scripts/common/functions.sh', 'scripts/common/apply-asyncify.sh', 'scripts/common/asyncify-imports.txt', 'scripts/common/inject-dyncall-shims.sh', 'scripts/common/shims/**', 'wasm/**') }}
key: testapps-${{ runner.os }}-wx${{ steps.keys.outputs.wx }}-k${{ steps.keys.outputs.kicad }}-${{ hashFiles('tests/apps/**/*.cpp', 'tests/apps/**/*.h', 'tests/apps/**/*.html', 'tests/apps/Makefile.wasm', 'scripts/build-wx-wasm.sh', 'scripts/build-wasm-test.sh', 'scripts/common/versions.sh', 'scripts/common/env.sh', 'scripts/common/functions.sh', 'scripts/common/shims/**', 'wasm/**') }}
- name: Build wxWidgets test apps
if: inputs.run_tests && steps.testapps-cache.outputs.cache-hit != 'true'
@ -429,7 +361,7 @@ jobs:
run: npm run setup:kicad
# ONE merged Playwright invocation for every apps-server suite (wx,
# kicad×2 engines, asyncify, coroutine — see playwright.config.ts). One
# kicad×2 engines, jspi, coroutine — see playwright.config.ts). One
# invocation = one start-of-run outputDir wipe BEFORE anything rendered,
# so the engine-scoped screenshots in test-results/{chromium,firefox}/
# accumulate naturally for the offline compare. A failure fails the JOB

View file

@ -1,6 +1,6 @@
#!/bin/bash
# Build a KiCad app (pcbnew, eeschema, calculator) inside Docker, then run
# asyncify and friends on the host.
# the ENV shim on the host.
#
# Usage:
# ./docker/build.sh <app>[,<app>...] [args...]
@ -22,8 +22,8 @@
# --full, --release, --diag=gal).
#
# The build is split into two phases:
# 1. Docker: Compile KiCad to WASM (without asyncify)
# 2. Host: dyncall shims + finalize + asyncify + wasm-opt -O1 (Binaryen submodule via build-wasm-opt.sh)
# 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
@ -137,13 +137,11 @@ 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
# inside the host post-process (apply-asyncify.sh). Splitting them lets CI cache
# the expensive compile once (shared regardless of the asyncify tail) and re-run just the
# asyncify/-O tail per opt level — see .github/workflows/.
# 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/.
# --postprocess-only — only the host post-process (dyncall + finalize +
# asyncify + wasm-opt -O$BINARYEN_OPT_LEVEL) on the
# --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).
# Extracted here so they are NOT forwarded to the inner build-<app>.sh scripts.
@ -272,14 +270,14 @@ compile_app() {
# The container runs as root, so files in the bind-mounted ./output land
# root-owned on the host. macOS Docker Desktop remaps ownership to the host
# user, but on a Linux CI runner the following host-side steps (dyncall,
# finalize, asyncify) can't write into ./output. Hand ownership back.
# user, but on a Linux CI runner the host-side ENV-shim step can't write
# into ./output. Hand ownership back.
docker compose -f docker/docker-compose.yml exec kicad-wasm-builder \
chown -R "$(id -u):$(id -g)" /workspace/output || true
}
# Phase 2 of one app: host-side post-processing (dyncall shims, finalize,
# asyncify + wasm-opt -O1). Pure host work on output/${app}.* — independent of the
# 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.
postprocess_app() {
@ -294,36 +292,12 @@ postprocess_app() {
return 0
fi
# JSPI backend: the app links with the real in-container tools and needs no
# dyncall shims (no -sDYNCALLS), no host finalize, and no asyncify pass —
# the scheduler ships as a --pre-js at link. Only the ENV merge shim remains.
if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then
kw_stage env-shim
node ./scripts/common/patch-env-shim.mjs "${out_dir}/${app}.js"
return 0
fi
# Inject dynCall shims (fixes "dynCall_* is not defined" errors in Emscripten 4.x)
kw_stage dyncall-shims
./scripts/common/inject-dyncall-shims.sh "${out_dir}/${app}.js"
# Merge Module.ENV into the runtime ENV: the emscripten glue never merges it,
# so ?trace= (boot.ts sets Module.ENV.KICAD_TRACE) was a silent no-op —
# environ_get on the app pthread proxies to the main thread, whose ENV stayed
# empty. Replaces a manual per-build glue edit. Idempotent; runtime no-op when
# Module.ENV is unset. See docs/features/libs/0013.
# 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
# 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"
# Apply wasm-emscripten-finalize on host (skipped in Docker due to memory limits)
kw_stage finalize
./scripts/common/apply-finalize.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm"
# Apply asyncify transformation on host (the ASYNCIFY=0 CLIs returned
# early above and never reach this).
# 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 + wasm-opt -O1.
kw_stage asyncify
./scripts/common/apply-asyncify.sh "${out_dir}/${app}.wasm" "${out_dir}/${app}.wasm"
}
# --- Pipelined driver state (KICAD_PIPELINE=1) ---
@ -403,8 +377,6 @@ 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"
kw_stage binaryen
./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"
@ -419,10 +391,6 @@ 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-build the Binaryen submodule once — two concurrent postprocesses racing
# the first from-source build would collide.
kw_stage binaryen
./scripts/binaryen-hoist-pass/build-wasm-opt.sh >/dev/null
_install_pipeline_trap
idx=1
for app in "${APPS[@]}"; do

2
kicad

@ -1 +1 @@
Subproject commit a261e9f0d0913b20dde3b0ac7b1271ee00578c79
Subproject commit 09163c0a1a0323045def89bbaca86ae967e08c8f

View file

@ -1,3 +1,9 @@
> **Historical (asyncify era).** These benches timed the Binaryen
> `apply-asyncify` post-link tail, which the JSPI migration deleted — the
> scripts below that reference it are gone. The VM provisioning pieces
> (setup-vm.sh, vm-build.sh, cloud-init) remain useful for any host-side
> build benching.
# wasm-opt allocator/core benchmark
Fast, local feedback loop for the CI perf issue: the host-side `wasm-opt`/asyncify

View file

@ -1,114 +0,0 @@
#!/bin/bash
# wasm-opt allocator/core benchmark — RUNS INSIDE THE LINUX VM.
#
# Times the host-side wasm-opt/asyncify pass (scripts/common/apply-asyncify.sh)
# over a prebuilt eeschema .wasm across a matrix of {glibc, jemalloc} x core
# counts, to find why the step is slow on glibc CI and what BINARYEN_CORES helps.
#
# Why this isolates the right thing: wasm-opt/asyncify is a standalone pass over
# an already-compiled .wasm (see docker/build.sh:194). We never compile KiCad
# here — we just replay the optimizer over a fixture built once on the host.
#
# Usage (in the VM, from the repo root):
# ./scripts/bench/wasm-opt-bench.sh [fixture.wasm]
# Env:
# CORES="1 4 8 10" core counts to sweep (BINARYEN_CORES)
# ALLOCS="glibc jemalloc"
# STRACE=1 also run a syscall-count pass per allocator at max cores
#
# Output: a CSV table on stdout (also tee'd to bench/results.csv) plus per-cell
# logs under bench/results/ (each holds apply-asyncify's own per-pass `time -v`).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)"
FIXTURE="${1:-${REPO}/bench/eeschema.finalized.wasm}"
CORES="${CORES:-1 4 8 10}"
ALLOCS="${ALLOCS:-glibc jemalloc}"
OUTDIR="${REPO}/bench/results"
CSV="${REPO}/bench/results.csv"
if [[ ! -f "${FIXTURE}" ]]; then
echo "ERROR: fixture not found: ${FIXTURE}" >&2
echo "Create it on the host (see scripts/bench/README.md) and scp it in." >&2
exit 1
fi
if [[ "$(uname -s)" != "Linux" ]]; then
echo "ERROR: run this inside the Linux VM (glibc is the point); host is $(uname -s)." >&2
exit 1
fi
command -v /usr/bin/time >/dev/null || { echo "ERROR: install GNU time (apt-get install -y time)" >&2; exit 1; }
mkdir -p "${OUTDIR}"
echo "cores,alloc,wall_clock,wall_s,peak_rss_kb,preload" > "${CSV}"
# Convert GNU time's "Elapsed (wall clock)" field ([h:]m:ss[.ss]) to seconds.
to_seconds() {
awk -F: '{ if (NF==3) print $1*3600+$2*60+$3; else if (NF==2) print $1*60+$2; else print $1 }'
}
run_cell() {
local cores="$1" alloc="$2"
local logf="${OUTDIR}/${alloc}-c${cores}.log"
local timef="${OUTDIR}/${alloc}-c${cores}.time"
cp "${FIXTURE}" /tmp/bench-in.wasm
# glibc baseline forces no preload; jemalloc leaves WASM_OPT_PRELOAD unset so
# apply-asyncify.sh auto-detects the system libjemalloc.
local -a env_prefix=(BINARYEN_CORES="${cores}")
if [[ "${alloc}" == "glibc" ]]; then
env_prefix+=(WASM_OPT_PRELOAD=none)
fi
echo ">>> ${alloc} BINARYEN_CORES=${cores}" >&2
if ! env "${env_prefix[@]}" /usr/bin/time -v -o "${timef}" \
"${REPO}/scripts/common/apply-asyncify.sh" /tmp/bench-in.wasm /tmp/bench-out.wasm \
>"${logf}" 2>&1; then
echo " FAILED (see ${logf})" >&2
echo "${cores},${alloc},FAILED,,," >> "${CSV}"
return 0
fi
local wall maxrss preload wall_s
wall=$(grep -F "Elapsed (wall clock)" "${timef}" | awk '{print $NF}')
maxrss=$(grep -F "Maximum resident set size" "${timef}" | awk '{print $NF}')
preload=$(grep -m1 -F "LD_PRELOAD=" "${logf}" | sed 's/.*LD_PRELOAD=//' | tr -d ' ')
wall_s=$(printf '%s' "${wall}" | to_seconds)
echo " wall=${wall} (${wall_s}s) peakRSS=${maxrss}KB preload=${preload}" >&2
echo "${cores},${alloc},${wall},${wall_s},${maxrss},${preload}" >> "${CSV}"
}
for c in ${CORES}; do
for a in ${ALLOCS}; do
run_cell "${c}" "${a}"
done
done
# Optional: confirm the futex storm collapses with jemalloc. strace -c adds heavy
# overhead, so this is a separate, single-pass-per-allocator measurement at the
# highest core count, not part of the timing matrix above.
if [[ "${STRACE:-0}" == "1" ]]; then
command -v strace >/dev/null || { echo "strace not installed; skipping" >&2; STRACE=0; }
fi
if [[ "${STRACE:-0}" == "1" ]]; then
maxc="$(echo ${CORES} | tr ' ' '\n' | sort -n | tail -1)"
WASM_OPT="$("${REPO}/scripts/common/get-wasm-opt.sh" 2>/dev/null)"
for a in ${ALLOCS}; do
cp "${FIXTURE}" /tmp/bench-in.wasm
local_preload=""
[[ "${a}" == "jemalloc" ]] && local_preload="$(ls /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2 2>/dev/null || true)"
echo ">>> strace ${a} (asyncify pass, BINARYEN_CORES=${maxc})" >&2
env BINARYEN_CORES="${maxc}" ${local_preload:+LD_PRELOAD=${local_preload}} \
strace -f -c -e trace=futex,mmap,munmap -o "${OUTDIR}/strace-${a}.txt" \
"${WASM_OPT}" --asyncify /tmp/bench-in.wasm -o /tmp/bench-out.wasm \
>"${OUTDIR}/strace-${a}.log" 2>&1 || echo " strace ${a} failed (see log)" >&2
done
echo "strace summaries: ${OUTDIR}/strace-*.txt" >&2
fi
echo ""
echo "=== results (${CSV}) ==="
column -t -s, "${CSV}"

View file

@ -1,51 +0,0 @@
#!/bin/bash
# Build the host post-process Binaryen tools from our submodule: wasm-opt (with the
# catch-arm-hoisting pass, --hoist-cpp-catches) AND wasm-emscripten-finalize.
#
# Source of truth is the tracked Binaryen submodule (binaryen/, branch wasm-port =
# upstream version_130 + src/passes/HoistCppCatches.cpp). This configures an out-of-source
# build into the gitignored build-wasm/ tree and prints the wasm-opt path on stdout
# (build progress to stderr); wasm-emscripten-finalize lands next to it in the same bin/
# (apply-finalize.sh derives it from the wasm-opt dir). Building both from one submodule
# keeps finalize and asyncify on a single Binaryen version and removes the host emsdk
# dependency from the post-process. See docs/features/wasm-exceptions/06-spike-plan.md (Phase 1.5).
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
SRC="${PROJECT_ROOT}/binaryen"
BUILD="${PROJECT_ROOT}/build-wasm/tools/binaryen-hoist-build"
if [ ! -f "${SRC}/src/passes/HoistCppCatches.cpp" ]; then
echo "ERROR: binaryen submodule is missing the hoist pass." >&2
echo " Run: git submodule update --init binaryen" >&2
exit 1
fi
# CI fast-path: the workflow cache-restores bin/ + lib/ keyed on the exact submodule
# SHA and sets this var on a hit, so the restored binaries are authoritative — skip
# cmake+ninja. Trust only if both tools actually RUN: the binaries dynamically link
# lib/libbinaryen.so, so an existence check alone passes on an incomplete restore
# (bin/ without lib/ shipped a red main, run 28585074335) while --version proves the
# loader resolves everything. Never set the var locally when iterating on the pass:
# uncommitted source edits would be silently ignored (the SHA key can't see them).
if [ "${BINARYEN_TRUST_PREBUILT:-0}" = "1" ] \
&& "${BUILD}/bin/wasm-opt" --version >/dev/null 2>&1 \
&& "${BUILD}/bin/wasm-emscripten-finalize" --version >/dev/null 2>&1; then
echo "Using prebuilt Binaryen tools (BINARYEN_TRUST_PREBUILT=1): ${BUILD}/bin" >&2
echo "${BUILD}/bin/wasm-opt"
exit 0
fi
# Configure once (mirrors scripts/common/get-wasm-opt.sh's from-source flags).
if [ ! -f "${BUILD}/build.ninja" ]; then
echo "Configuring Binaryen submodule build (one-time, ~5 min to build)..." >&2
cmake -S "${SRC}" -B "${BUILD}" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-Wno-maybe-uninitialized" \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON -DBUILD_TESTS=OFF >&2
fi
ninja -C "${BUILD}" wasm-opt wasm-emscripten-finalize >&2
echo "${BUILD}/bin/wasm-opt"

View file

@ -1,36 +0,0 @@
// Minimal Asyncify harness: drive one unwind/rewind through $vt and check it yields 50.
const fs = require('fs');
const path = process.argv[2];
const BUF = 16, STACK = 1024, STACK_END = 16384;
let inst, rewinding = false, pending = 0;
const imports = {
env: {
sleep: (ms) => {
if (!rewinding) {
inst.exports.asyncify_start_unwind(BUF);
pending = ms;
return 0; // dummy; value is discarded as we unwind
} else {
inst.exports.asyncify_stop_rewind();
rewinding = false;
return pending; // real value supplied on rewind
}
},
},
};
const mod = new WebAssembly.Module(fs.readFileSync(path));
inst = new WebAssembly.Instance(mod, imports);
// asyncify buffer struct: [current, end]
const mem = new Int32Array(inst.exports.memory.buffer);
mem[BUF >> 2] = STACK;
mem[(BUF + 4) >> 2] = STACK_END;
inst.exports.vt(); // runs, throws, catch calls sleep -> starts unwind, returns
inst.exports.asyncify_stop_unwind();
inst.exports.asyncify_start_rewind(BUF);
rewinding = true;
const r = inst.exports.vt(); // rewinds into the catch handler; sleep returns 50; vt completes
console.log('vt result =', r);
process.exit(r === 50 ? 0 : 1);

View file

@ -1,32 +0,0 @@
;; The DuplicateSymbol delegate-orphan shape, but the hoisted cpp arm SUSPENDS (calls an async import)
;; before its __cxa_end_catch cleanup (try (do ..) (delegate $M)). Drives a real Asyncify unwind/rewind
;; through the hoisted arm AND its retargeted delegate ($M -> caller), proving the fix doesn't break
;; the suspend/rewind the hoist exists for. Same harness contract as the other -suspend tests: $vt
;; yields 50.
(module
(import "env" "sleep" (func $sleep (param i32) (result i32)))
(memory (export "memory") 1)
(tag $cpp (param i32))
(func $vt (export "vt") (result i32)
(local $r i32)
(try $A
(do
(throw $cpp (i32.const 0)))
(catch_all
(try $M
(do
(try $inner
(do
(rethrow $A))
(catch $cpp
(drop (pop i32))
(local.set $r (call $sleep (i32.const 50)))
(try
(do (nop))
(delegate $M)))
(catch_all
(rethrow $A))))
(catch_all
(rethrow $A)))))
(local.get $r))
)

View file

@ -1,60 +0,0 @@
;; Regression repro of SYMBOL_EDIT_FRAME::DuplicateSymbol (KiCad eeschema). A cpp catch arm that is
;; hoisted PAST an ancestor catch_all (the case-6 deferral) carries a nested __cxa_end_catch cleanup
;; (try (do ..) (delegate $M)) whose delegate target $M is a mid try sitting INSIDE that ancestor
;; catch_all. Hoisting the arm out (into the dispatch section, outside every try) orphaned the
;; delegate -> wasm-validator "all delegate targets must be valid, on (delegate $M)". The fix
;; retargets it to DELEGATE_CALLER_TARGET (a delegate can only target a try or the caller, not the
;; $done block); re-throwing a cleanup exception to the caller is the C++ throw-during-cleanup
;; (std::terminate) path, never taken in normal flow. cpp tag = single i32.
(module
(tag $cpp (param i32))
;; Exception path: $A throws cpp(1); its catch_all (re)throws into $inner whose cpp arm sets r:=42,
;; its __cxa_end_catch cleanup delegates to the enclosing $M. expect 42.
(func $caught (export "caught") (result i32)
(local $r i32)
(try $A
(do
(throw $cpp (i32.const 1)))
(catch_all
(try $M
(do
(try $inner
(do
(rethrow $A))
(catch $cpp
(drop (pop i32))
(local.set $r (i32.const 42))
(try
(do (nop))
(delegate $M)))
(catch_all
(rethrow $A))))
(catch_all
(rethrow $A)))))
(local.get $r))
;; No-exception path: $A body falls through with r:=7. expect 7.
(func $normal (export "normal") (result i32)
(local $r i32)
(try $A
(do
(local.set $r (i32.const 7)))
(catch_all
(try $M
(do
(try $inner
(do
(rethrow $A))
(catch $cpp
(drop (pop i32))
(local.set $r (i32.const 42))
(try
(do (nop))
(delegate $M)))
(catch_all
(rethrow $A))))
(catch_all
(rethrow $A)))))
(local.get $r))
)

View file

@ -1,49 +0,0 @@
;; Regression repro of PGM_BASE::HandleException (KiCad) — the case-6 shape (a cpp catch nested in an
;; outer try's catch_all cleanup pad) WITH an intervening block in that cleanup that the cpp arm
;; br's to. LLVM emits this for `try{} catch(A&) catch(B&) catch(...)`: the outer try has only a
;; catch_all (destructor cleanup), inside which it (rethrow)s into a nested try whose cpp catch does
;; the __cxa type dispatch and, when done, (br)s OUT to a block ($blk) sitting between the escape
;; target and the arm. Hoisting that arm to the dispatch section orphaned the br — "all break targets
;; must be valid, on (br $blk)". cpp tag = single i32.
(module
(tag $cpp (param i32))
;; Exception path: outer body throws cpp(1); the catch_all reclassifies via (rethrow $outer) inside
;; (block $blk); the nested cpp arm handles it (r := 42) then (br $blk) to finish. expect 42.
(func $caught (export "caught") (result i32)
(local $r i32)
(try $outer
(do
(throw $cpp (i32.const 1)))
(catch_all
(block $blk
(try
(do
(rethrow $outer))
(catch $cpp
(drop (pop i32))
(local.set $r (i32.const 42))
(br $blk))
(catch_all
(rethrow $outer))))))
(local.get $r))
;; No-exception path: body falls through with r := 7, neither catch runs. expect 7.
(func $normal (export "normal") (result i32)
(local $r i32)
(try $outer
(do
(local.set $r (i32.const 7)))
(catch_all
(block $blk
(try
(do
(rethrow $outer))
(catch $cpp
(drop (pop i32))
(local.set $r (i32.const 42))
(br $blk))
(catch_all
(rethrow $outer))))))
(local.get $r))
)

View file

@ -1,26 +0,0 @@
;; The HandleException nested-catchall-exit-block shape, but the nested cpp arm SUSPENDS (calls an
;; async import) before it br's the intervening block. This drives a real Asyncify unwind/rewind
;; through the hoisted arm AND its retargeted br ($blk -> $done), proving the fix doesn't break the
;; suspend/rewind the hoist exists for. Same harness contract as value-typed-suspend.wat: $vt yields 50.
(module
(import "env" "sleep" (func $sleep (param i32) (result i32)))
(memory (export "memory") 1)
(tag $cpp (param i32))
(func $vt (export "vt") (result i32)
(local $r i32)
(try $outer
(do
(throw $cpp (i32.const 0)))
(catch_all
(block $blk
(try
(do
(rethrow $outer))
(catch $cpp
(drop (pop i32))
(local.set $r (call $sleep (i32.const 50)))
(br $blk))
(catch_all
(rethrow $outer))))))
(local.get $r))
)

View file

@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Regression test for the value-typed (concrete-result) path of --hoist-cpp-catches.
#
# Value-typed cpp-catch tries do not arise from normal C++ EH lowering (LLVM keeps catch values in
# locals → void/unreachable tries), so this case can't live in a C++ EH toy. These
# hand-written modules exercise it directly:
# (1) fuzz-exec — the pass must preserve the result value of value-typed cpp-catch tries
# (exception path, no-exception path, and exception-payload routing).
# (2) a real asyncify unwind/rewind through a value-typed catch that SUSPENDS (must yield 50).
#
# Requires wasm-opt built from the binaryen submodule (scripts/binaryen-hoist-pass/build-wasm-opt.sh);
# that one binary (version_130 + our hoist pass) does both --hoist-cpp-catches and --asyncify. Run from anywhere.
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/../../.." && pwd)"
DIR="$ROOT/scripts/binaryen-hoist-pass/tests"
WASMOPT="$ROOT/build-wasm/tools/binaryen-hoist-build/bin/wasm-opt"
V130="$WASMOPT" # the submodule fork IS version_130 (asyncify unchanged), so it does --asyncify too
[ -x "$WASMOPT" ] || { echo "build wasm-opt first: scripts/binaryen-hoist-pass/build-wasm-opt.sh"; exit 1; }
echo "== (1) value semantics preserved (fuzz-exec) =="
"$WASMOPT" --hoist-cpp-catches -all -all --fuzz-exec "$DIR/value-typed-cpp-catch.wat" -o /dev/null 2>&1 \
| grep -E 'comparing|=>'
echo "== (2) asyncify unwind/rewind through a value-typed suspending catch =="
"$WASMOPT" --hoist-cpp-catches -all -all "$DIR/value-typed-suspend.wat" -o /tmp/vt_s.hoisted.wasm
"$V130" --asyncify -all --pass-arg=asyncify-imports@env.sleep /tmp/vt_s.hoisted.wasm -o /tmp/vt_s.async.wasm
node "$DIR/asyncify-harness.js" /tmp/vt_s.async.wasm
echo "OK — value-typed path verified"
# Regression for the PGM_BASE::HandleException shape: a cpp catch nested in an outer try's catch_all
# cleanup pad whose arm br's to an intervening block ($blk). Before the fix the hoisted arm's br was
# orphaned -> "all break targets must be valid". (3) validates + checks value semantics; (4) drives a
# real unwind/rewind through the hoisted arm with the retargeted br.
echo "== (3) nested-catchall exit-block hoists + validates (fuzz-exec: caught=>42, normal=>7) =="
"$WASMOPT" --hoist-cpp-catches -all -all --fuzz-exec "$DIR/nested-catchall-exit-block.wat" -o /dev/null 2>&1 \
| grep -E 'comparing|=>'
echo "== (4) asyncify unwind/rewind through a suspending nested-catchall arm =="
"$WASMOPT" --hoist-cpp-catches -all -all "$DIR/nested-catchall-suspend.wat" -o /tmp/ncb_s.hoisted.wasm
"$V130" --asyncify -all --pass-arg=asyncify-imports@env.sleep /tmp/ncb_s.hoisted.wasm -o /tmp/ncb_s.async.wasm
node "$DIR/asyncify-harness.js" /tmp/ncb_s.async.wasm
echo "OK — nested-catchall path verified"
# Regression for the SYMBOL_EDIT_FRAME::DuplicateSymbol shape: a cpp catch arm hoisted PAST an ancestor
# catch_all carries a nested __cxa_end_catch cleanup (try (do ..) (delegate $M)) whose delegate target
# sits inside that catch_all -> orphaned by hoisting ("all delegate targets must be valid"). The fix
# retargets it to DELEGATE_CALLER_TARGET. (5) validates + checks value semantics; (6) drives a real
# unwind/rewind through the hoisted arm with the retargeted delegate.
echo "== (5) delegate-orphan hoists + validates (fuzz-exec: caught=>42, normal=>7) =="
"$WASMOPT" --hoist-cpp-catches -all -all --fuzz-exec "$DIR/delegate-orphan.wat" -o /dev/null 2>&1 \
| grep -E 'comparing|=>'
echo "== (6) asyncify unwind/rewind through a suspending delegate-orphan arm =="
"$WASMOPT" --hoist-cpp-catches -all -all "$DIR/delegate-orphan-suspend.wat" -o /tmp/dlg_s.hoisted.wasm
"$V130" --asyncify -all --pass-arg=asyncify-imports@env.sleep /tmp/dlg_s.hoisted.wasm -o /tmp/dlg_s.async.wasm
node "$DIR/asyncify-harness.js" /tmp/dlg_s.async.wasm
echo "OK — delegate-orphan path verified"

View file

@ -1,20 +0,0 @@
;; Hand-written value-typed cpp-catch tries to exercise the $result routing in --hoist-cpp-catches.
;; cpp tag = single i32 param. Each function returns an i32 via a (try (result i32) ...).
(module
(tag $cpp (param i32))
;; exception path: body throws, catch yields 42 -> expect 42
(func $vt_throw (export "vt_throw") (result i32)
(try (result i32)
(do (throw $cpp (i32.const 99)))
(catch $cpp (drop (pop i32)) (i32.const 42))))
;; normal path: body yields 7, catch never runs -> expect 7
(func $vt_normal (export "vt_normal") (result i32)
(try (result i32)
(do (i32.const 7))
(catch $cpp (drop (pop i32)) (i32.const 42))))
;; payload routing: catch returns the exception payload it caught (123) -> expect 123
(func $vt_payload (export "vt_payload") (result i32)
(try (result i32)
(do (throw $cpp (i32.const 123)))
(catch $cpp (pop i32))))
)

View file

@ -1,10 +0,0 @@
;; Value-typed try whose catch SUSPENDS: the catch calls an async import and yields its result.
;; Drives the $result routing across a real asyncify unwind/rewind.
(module
(import "env" "sleep" (func $sleep (param i32) (result i32)))
(memory (export "memory") 1)
(tag $cpp (param i32))
(func $vt (export "vt") (result i32)
(try (result i32)
(do (throw $cpp (i32.const 0)))
(catch $cpp (drop (pop i32)) (call $sleep (i32.const 50))))))

View file

@ -96,36 +96,12 @@ if [ "$CLEAN_BUILD" = "1" ]; then
make -f Makefile.wasm clean 2>/dev/null || true
fi
# Native wasm-EH is the only build mode. The emsdk-bundled Binaryen v121 crashes asyncifying wasm-EH,
# so we stub the in-link Asyncify and run --hoist-cpp-catches + --asyncify post-link on the Binaryen
# submodule (version_130 + hoist pass) via apply-asyncify.sh.
EMSDK_WASM_OPT="$PROJECT_ROOT/tools/emsdk/upstream/bin/wasm-opt"
WASMOPT_STUB="$PROJECT_ROOT/wasm/stubs/wasm-opt-stub.sh"
_eh_restore_wasmopt() { if [ -f "${EMSDK_WASM_OPT}.ehbak" ]; then mv -f "${EMSDK_WASM_OPT}.ehbak" "${EMSDK_WASM_OPT}"; fi; }
EH_MARKER="$(mktemp)" # created before the build so 'find -newer' below selects freshly-linked apps
if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then
# JSPI: no binaryen instrumentation exists — no fork build, no stub dance,
# no post-link pass, no dyncall/scheduler injection (the jspi-scheduler
# ships as a --pre-js from Makefile.wasm). emcc's real wasm-opt runs
# in-link like any normal build.
echo ""
echo "=== JSPI backend: in-link build, no post-link instrumentation ==="
else
echo ""
echo "=== Building the Binaryen submodule (version_130 + hoist pass) ==="
# One binaryen everywhere: the submodule fork is version_130 (asyncify unchanged) + our hoist
# pass, so the same binary does --hoist-cpp-catches AND --asyncify/-O2. No separate v130 clone.
export HOIST_WASMOPT="$("$SCRIPT_DIR/binaryen-hoist-pass/build-wasm-opt.sh")"
export V130_WASMOPT="$HOIST_WASMOPT"
echo " submodule wasm-opt: $HOIST_WASMOPT"
echo "Stubbing in-link Asyncify (will run post-link instead)..."
cp "$EMSDK_WASM_OPT" "${EMSDK_WASM_OPT}.ehbak"
cp "$WASMOPT_STUB" "$EMSDK_WASM_OPT"; chmod +x "$EMSDK_WASM_OPT"
trap _eh_restore_wasmopt EXIT
fi
# JSPI: no binaryen instrumentation exists — no fork build, no stub dance, no
# post-link pass, no shim injection (the jspi-scheduler ships as a --pre-js
# from Makefile.wasm). emcc's real wasm-opt runs in-link like any normal build.
# Build (pass DEBUG flag if requested). App links are independent, so honor
# JOBS/PARALLEL_JOBS from env.sh (each emcc link is slow due to Asyncify).
# JOBS/PARALLEL_JOBS from env.sh.
if [ "$DEBUG_BUILD" = "1" ]; then
make -j"${JOBS:-1}" -f Makefile.wasm DEBUG=1 "$MAKE_TARGET"
else
@ -133,54 +109,11 @@ else
fi
make_rc=$?
if [ "$make_rc" -ne 0 ]; then
# Fail loudly. Silently continuing to the post-link leaves the freshly-linked apps
# asyncify-stubbed / un-injected, which looks like mass test failures rather than a build
# error. (The EXIT trap restores the stubbed emsdk wasm-opt in the native-EH build.)
echo "" >&2
echo "ERROR: make failed (exit $make_rc); aborting before the post-link step." >&2
echo "ERROR: make failed (exit $make_rc)." >&2
exit "$make_rc"
fi
# Inject the dyncall + asyncify-scheduler shims into every freshly-linked app. The
# scheduler's currData save/restore (Emscripten #9153) is needed: without it a rewind that
# resumes through a fresh wasm re-entry hits _asyncify_start_rewind(null) -> "memory access out
# of bounds" — e.g. a context-menu pick while the main loop is parked. The Makefile only injects
# it for the coroutine apps; inject-dyncall-shims.sh is idempotent (skips an already-shimmed glue),
# so re-running it here is safe. The .wasm gets post-link hoist + asyncify first.
_eh_restore_wasmopt; trap - EXIT
if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then
rm -f "$EH_MARKER"
echo ""
echo "=== Build complete (jspi backend) ==="
exit 0
fi
echo ""
echo "=== Post-link --hoist-cpp-catches + --asyncify (${JOBS:-1}-wide) ==="
# The apps are independent here too (apply-asyncify rewrites each wasm in place; the shim
# injector's temp file is per-js), so fan the post-link out across JOBS like the make phase.
# This dominates the build: per-app wasm-opt can't feed many cores (small modules, serial
# parse/write), so parallelism must come from running apps side by side — serial 3m52s even
# with BINARYEN_CORES=16, vs 2m08s fanned out at ~11 GB peak RAM. HOIST_WASMOPT is resolved
# once above, so workers skip the binaryen ninja check. xargs fails the build (exit 123) if
# any app's post-link fails.
export SCRIPT_DIR
# Match EVERY freshly-linked app wasm, not just standalone/*/*_test.wasm: the main demo
# (apps/minimal_test.wasm) is at the apps/ root, and the coroutine-pthread repros + wxpt app
# are *_repro*.wasm / *_wxpt.wasm. The old '*_test.wasm under standalone' filter silently
# skipped all of those, so under native wasm-EH they never got hoist+asyncify and crashed at
# runtime with "asyncify_start_unwind not found".
find "$WASM_APP_DIR" -name '*.wasm' -newer "$EH_MARKER" -print0 \
| xargs -0 -n1 -P "${JOBS:-1}" bash -c '
set -eo pipefail
w="$1"
"$SCRIPT_DIR/common/apply-asyncify.sh" --no-removelist "$w"
js="${w%.wasm}.js"
if [ -f "$js" ]; then
( cd "$(dirname "$js")" && "$SCRIPT_DIR/common/inject-dyncall-shims.sh" "$(basename "$js")" )
fi
' _
rm -f "$EH_MARKER"
echo ""
echo "=== Build complete ==="

View file

@ -60,13 +60,14 @@ done
BUILD_DIR="$PROJECT_ROOT/build-wasm/wxwidgets"
WXLIB_PREFIX="libwx_wasmu"
# Async-backend stamp: PCBJAM_ASYNC_BACKEND changes COMPILE flags
# (-DPCBJAM_JSPI selects whole other halves of the wasm port), which the
# configure-cached incremental build cannot see — a knob flip against a stale
# tree silently links the WRONG backend into every consumer. Force a clean
# build whenever the stamp disagrees.
# Async-backend stamp: the JSPI migration changed COMPILE flags in ways the
# configure-cached incremental build cannot see — building over a stale
# asyncify-era tree silently links a MIXED library. Force a clean build
# whenever the stamp disagrees.
BACKEND_STAMP="$BUILD_DIR/.pcbjam-async-backend"
CURRENT_BACKEND="${PCBJAM_ASYNC_BACKEND:-asyncify}"
# Single backend since the JSPI migration: the stamp still force-cleans any
# pre-migration tree (asyncify objects would silently mix into the library).
CURRENT_BACKEND="jspi"
# ABSENT stamp = unknown provenance = same as a mismatch: an incremental build
# over objects of unknown backend produced a MIXED library once (jspi evtloop
# EM_JS in the glue next to live fiber dispatch — --allow-multiple-definition
@ -193,15 +194,6 @@ if [ $NEEDS_CONFIGURE -eq 1 ]; then
WX_EH_FLAGS="$DEPS_EH_FLAGS"
echo "wx EH model flags: ${WX_EH_FLAGS}"
# Async backend (experiment/jspi): PCBJAM_ASYNC_BACKEND=jspi compiles the
# wasm port's JSPI lanes (evtloop/app/window PCBJAM_JSPI blocks) instead of
# the Asyncify/fiber lanes. ONE wx build output — flipping backends means
# rebuilding (use --clean). Default stays asyncify until Phase 4.
if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then
WX_EH_FLAGS="$WX_EH_FLAGS -DPCBJAM_JSPI=1"
echo "async backend: jspi (-DPCBJAM_JSPI)"
fi
# Include emscripten cache sysroot for zlib headers
export CFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include ${WX_DEBUG_FLAGS} ${WX_EH_FLAGS} -pthread -matomics -mbulk-memory"
export CXXFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include -I$PCRE2_INCLUDE ${WX_DEBUG_FLAGS} ${WX_EH_FLAGS} -pthread -matomics -mbulk-memory"

View file

@ -1,151 +0,0 @@
#!/bin/bash
# Unified post-link Asyncify pass for KiCad AND the wx test apps.
#
# Usage: apply-asyncify.sh [--no-removelist] <input.wasm> [output.wasm]
#
# Always: run our --hoist-cpp-catches fork pass FIRST (lets Asyncify suspend from inside C++ catch
# blocks under native wasm-EH) with all wasm features enabled (-all, so binaryen parses the
# EH instructions), then --asyncify + remove-list + -O2. Native wasm-EH is the only build mode.
# --no-removelist skip the KiCad big-function remove-list (the small wx test apps don't contain
# those symbols, and one bare entry — "match" — could collide).
#
# WHY post-link (not emcc's in-link Asyncify): the emsdk-bundled Binaryen crashes asyncifying
# wasm-EH and a compiler/standalone version skew corrupts asyncify metadata. So the in-link pass is
# stubbed (build-kicad-target.sh / build-wasm-test.sh) and the real transform runs here, on the host
# (more RAM), with a pinned Binaryen. The cost: emcc's automatic asyncify-imports generation is
# bypassed, so the BOUNDARY import list lives in asyncify-imports.txt (see that file).
#
# Binaryen selection (env overrides win, set by build-wasm-test.sh): one binaryen everywhere — the
# submodule fork (version_130 + our --hoist-cpp-catches). Override the path via HOIST_WASMOPT / V130_WASMOPT.
set -eo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# --- flags ---
# Native wasm-EH is the only build mode, so the catch-arm hoist pass always runs.
DO_HOIST=1
USE_REMOVELIST=1
while [[ "${1:-}" == --* ]]; do
case "$1" in
--no-removelist) USE_REMOVELIST=0; shift ;;
*) echo "apply-asyncify: unknown flag: $1" >&2; exit 1 ;;
esac
done
INPUT_WASM="${1:-output/pcbnew.wasm}"
OUTPUT_WASM="${2:-${INPUT_WASM}}"
[ -f "${INPUT_WASM}" ] || { echo "ERROR: Input file not found: ${INPUT_WASM}" >&2; exit 1; }
# --- Binaryen tool: ONE binaryen everywhere (incl. docker/CI) — the submodule fork. It IS Binaryen
# version_130 (its Asyncify.cpp is unmodified upstream) + our HoistCppCatches pass, so the SAME binary
# does --hoist-cpp-catches AND --asyncify/-O2, for both native-EH and legacy JS-EH wasm. Built once via
# build-wasm-opt.sh; no separate binaryen downloads (the emsdk-bundled v121 can't even asyncify wasm-EH).
SUBMODULE_WASMOPT="${HOIST_WASMOPT:-$("${SCRIPT_DIR}/../binaryen-hoist-pass/build-wasm-opt.sh")}"
WASM_OPT="${V130_WASMOPT:-$SUBMODULE_WASMOPT}"
[ "$DO_HOIST" = 1 ] && HOIST_OPT="$SUBMODULE_WASMOPT"
# native wasm-EH needs -all so binaryen parses the EH instructions; HOIST_KEEP_NAMES keeps the
# names section through -O2 for callstack debugging. KiCad/JS-EH uses neither (matches old behavior).
FEAT=()
[ "$DO_HOIST" = 1 ] && FEAT=(-all)
G="${HOIST_KEEP_NAMES:+-g}"
# The asyncify removelist matches functions by NAME. wasm-opt strips the names section by default, so
# the hoist pass (run before asyncify) would otherwise hand asyncify nameless functions — the
# removelist then matches NOTHING and the giant try-dense functions it is meant to exclude
# (BuildBitmapInfo: ~4986 native tries, etc.) get instrumented, which is the dominant driver of the
# multi-GB asyncify RAM blowup on native-EH. So force the hoist pass to keep names whenever a
# removelist is in play, so asyncify can see + exclude them. asyncify/-O2 keep their own G, so the
# FINAL wasm is unchanged unless HOIST_KEEP_NAMES is set (asyncify matches on its INPUT names, which
# the hoist output now carries).
HOIST_G="${G}"
[ "$USE_REMOVELIST" = 1 ] && HOIST_G="-g"
# --- the import boundary (shared) + the KiCad remove-list (opt-out), read from sibling files ---
_join_list() { grep -vE '^[[:space:]]*#|^[[:space:]]*$' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | tr '\n' ',' | sed 's/,$//'; }
ASYNCIFY_IMPORTS="${ASYNCIFY_IMPORTS_PASS:-$(_join_list "${SCRIPT_DIR}/asyncify-imports.txt")}"
REMOVE_ARG=()
if [ "$USE_REMOVELIST" = 1 ]; then
REMOVE_ARG=("--pass-arg=asyncify-removelist@$(_join_list "${SCRIPT_DIR}/asyncify-removelist.txt")")
fi
# --- memory machinery (identical to before; matters for the host-side KiCad pass) ---
# Bound Binaryen's host thread pool: peak RAM scales with thread count.
export BINARYEN_CORES="${BINARYEN_CORES:-8}"
# Preload a scalable allocator on Linux — glibc malloc collapses into futex lock-spin under
# wasm-opt's many-thread allocation churn; jemalloc/mimalloc roughly halve wall-clock. macOS
# already ships a scalable allocator. WASM_OPT_PRELOAD=none|0 forces a clean glibc baseline.
if [[ "${WASM_OPT_PRELOAD:-}" == "none" || "${WASM_OPT_PRELOAD:-}" == "0" ]]; then
WASM_OPT_PRELOAD=""; _PRELOAD_FORCED_OFF=1
fi
if [[ -z "${WASM_OPT_PRELOAD:-}" && -z "${_PRELOAD_FORCED_OFF:-}" && "$(uname -s)" == "Linux" ]]; then
for _alloc in \
"/usr/lib/$(uname -m)-linux-gnu/libjemalloc.so.2" \
"/usr/lib/$(uname -m)-linux-gnu/libmimalloc.so.2" \
/usr/lib/libjemalloc.so.2 /usr/lib/libmimalloc.so.2; do
[[ -e "${_alloc}" ]] && { WASM_OPT_PRELOAD="${_alloc}"; break; }
done
fi
if [[ -n "${WASM_OPT_PRELOAD:-}" ]]; then
PRELOAD_CMD=(env "LD_PRELOAD=${WASM_OPT_PRELOAD}${LD_PRELOAD:+:${LD_PRELOAD}}")
else
PRELOAD_CMD=()
fi
# GNU `time -v` on Linux CI records peak RSS + wall-clock per pass; macOS `time` lacks -v.
if /usr/bin/time -v true >/dev/null 2>&1; then TIME_CMD=(/usr/bin/time -v); else TIME_CMD=(); fi
echo "Applying Asyncify${DO_HOIST:+ (+hoist-cpp-catches)}..."
echo " Input: ${INPUT_WASM}"
echo " Output: ${OUTPUT_WASM}"
echo " asyncify wasm-opt: ${WASM_OPT}"
[ "$DO_HOIST" = 1 ] && echo " hoist wasm-opt: ${HOIST_OPT}"
echo " BINARYEN_CORES=${BINARYEN_CORES} LD_PRELOAD=${WASM_OPT_PRELOAD:-<none>}"
SRC="${INPUT_WASM}"
# Refuse to double-instrument. A postprocess re-run on an artifact that a
# previous (killed/partial) run already asyncified re-instruments the
# instrumented module: the pass balloons to OOM/jetsam death, and the output
# would be broken anyway. The asyncify export names only exist in a module
# the pass already touched. Recover by re-copying the pristine post-link
# artifact from the docker volume (docker/build.sh compile copy step).
if LC_ALL=C grep -aq "asyncify_start_unwind" "${INPUT_WASM}"; then
echo "ERROR: ${INPUT_WASM} already contains asyncify exports - refusing to" >&2
echo "double-instrument. Restore the pristine post-link wasm first." >&2
exit 1
fi
# 1. (native wasm-EH only) hoist C++ catch arms so Asyncify can suspend from inside them.
if [ "$DO_HOIST" = 1 ]; then
echo "Running --hoist-cpp-catches${HOIST_G:+ (keeping names for removelist matching)}..."
"${PRELOAD_CMD[@]}" "${TIME_CMD[@]}" "${HOIST_OPT}" --hoist-cpp-catches "${FEAT[@]}" ${HOIST_G} "${SRC}" -o "${OUTPUT_WASM}"
SRC="${OUTPUT_WASM}"
fi
# 2. The real Asyncify transform. ASYNCIFY_EXTRA_OPTS: optional extra wasm-opt flags (origin/main hook).
echo "Running wasm-opt --asyncify (several minutes + significant RAM)..."
"${PRELOAD_CMD[@]}" "${TIME_CMD[@]}" "${WASM_OPT}" --asyncify ${ASYNCIFY_EXTRA_OPTS:-} "${FEAT[@]}" ${G} \
"--pass-arg=asyncify-imports@${ASYNCIFY_IMPORTS}" \
"${REMOVE_ARG[@]}" \
--pass-arg=asyncify-propagate-addlist \
"${SRC}" -o "${OUTPUT_WASM}"
# ASYNCIFY_ONLY=1 stops before -O2 (benchmark harness in scripts/bench/ times just the transform).
if [[ "${ASYNCIFY_ONLY:-0}" == "1" ]]; then
echo "ASYNCIFY_ONLY=1 → skipping -O2 (benchmark mode)."; ls -lh "${OUTPUT_WASM}"; exit 0
fi
# 3. Post-asyncify shrink. Asyncify spills every live local; without coalescing, large
# coroutine-entry functions exceed V8's per-function locals limit and stall/crash the renderer.
# -O1 runs CoalesceLocals (enough to keep instrumented functions under V8's local limit) and is the
# level we ship EVERYWHERE — main CI, tag releases, and local builds all use it. NOT -Os/-Oz (they
# break the asyncify runtime — Binaryen #4484). See docs/debugging/DEBUG.md §6-7.
BINARYEN_OPT_LEVEL="${BINARYEN_OPT_LEVEL:--O1}"
echo "Running wasm-opt ${BINARYEN_OPT_LEVEL} (shrink instrumented functions under V8's local limit)..."
"${PRELOAD_CMD[@]}" "${TIME_CMD[@]}" "${WASM_OPT}" "${BINARYEN_OPT_LEVEL}" "${FEAT[@]}" ${G} "${OUTPUT_WASM}" -o "${OUTPUT_WASM}"
echo "Asyncify + ${BINARYEN_OPT_LEVEL} complete: ${OUTPUT_WASM}"
ls -lh "${OUTPUT_WASM}"

View file

@ -1,44 +0,0 @@
#!/bin/bash
# Apply wasm-emscripten-finalize transformation on host.
#
# Usage: ./scripts/common/apply-finalize.sh <input.wasm> <output.wasm>
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# wasm-emscripten-finalize comes from our Binaryen submodule (version_130 + --hoist-cpp-catches),
# built alongside wasm-opt by build-wasm-opt.sh. Pinning finalize and the asyncify wasm-opt to ONE
# Binaryen version removes the host's emsdk dependency from the post-process entirely (it is now
# dyncall=node + finalize/asyncify=submodule), which is what was breaking on the ephemeral CI host.
# HOIST_WASMOPT, when the build driver pre-warms the submodule build, points at the already-built
# wasm-opt so we don't re-invoke the build per app; finalize sits next to it in the same bin/.
WASM_OPT="${HOIST_WASMOPT:-$("${SCRIPT_DIR}/../binaryen-hoist-pass/build-wasm-opt.sh")}"
FINALIZE="$(dirname "${WASM_OPT}")/wasm-emscripten-finalize"
INPUT_WASM="${1:-output/pcbnew.wasm}"
OUTPUT_WASM="${2:-${INPUT_WASM}}"
if [ ! -f "${INPUT_WASM}" ]; then
echo "ERROR: Input file not found: ${INPUT_WASM}"
exit 1
fi
echo "Applying wasm-emscripten-finalize..."
echo " Input: ${INPUT_WASM}"
echo " Output: ${OUTPUT_WASM}"
echo " Tool: ${FINALIZE}"
# Run finalize with the same flags Emscripten would use
# NOTE: --dwarf removed because debug info is in separate .debug.wasm file
"${FINALIZE}" \
-g \
--bigint \
--no-legalize-javascript-ffi \
--detect-features \
"${INPUT_WASM}" \
-o "${OUTPUT_WASM}"
echo "Finalize complete: ${OUTPUT_WASM}"
ls -lh "${OUTPUT_WASM}"

View file

@ -1,20 +0,0 @@
# Suspending imports for the post-link Asyncify pass (consumed by apply-asyncify.sh).
# Binaryen's --asyncify instruments every function that can transitively REACH one of these
# imports. This is the BOUNDARY list, not a function allowlist — the callers are auto-discovered.
#
# We run Asyncify post-link (outside emcc), so emcc's automatic import generation never runs;
# this file replicates emcc's default async built-ins + our own suspending imports. A superset
# is safe — binaryen ignores any import the wasm doesn't actually contain.
#
# --- Emscripten async built-ins (what emcc auto-adds for the in-link Asyncify) ---
env.emscripten_sleep
env.emscripten_scan_registers
env.emscripten_lazy_load_code
env.emscripten_wget
env.emscripten_wget_data
env.emscripten_idb_*
#
# --- Project suspending imports ---
env.__asyncjs__*
env.emscripten_fiber_swap
env.js_*

View file

@ -1,32 +0,0 @@
# Functions EXCLUDED from Asyncify instrumentation (consumed by apply-asyncify.sh unless
# --no-removelist). These are large, NON-suspending KiCad/OpenCASCADE functions (generated resource
# tables, wxFormBuilder UI constructors, OCC geometry) that are dense with native wasm try/catch.
# Asyncify's per-function cost is superlinear in try-count (it builds a CFG + liveness over every
# try), so instrumenting these few giants is what drives the multi-GB RAM blowup of `wasm-opt
# --asyncify` on the native-EH build. They never call a suspending import, so excluding them is safe.
#
# MATCHING: Binaryen escapes each entry, then treats entries containing `*` as wildcard patterns
# (String::wildcardMatch) and entries without `*` as exact escaped function names. Full demangled
# signatures don't exact-match (subtle spacing) and a `*` inside a pointer type (e.g. wxWindow*) turns
# the whole entry into a pattern that still misses — so use a PREFIX wildcard per symbol, which matches
# the function's escaped demangled name regardless of argument formatting. Patterns that match nothing
# in a given app (e.g. the OCC entries when 3D is off) just emit a harmless "non-matching" warning.
# Only the KiCad build passes the remove-list; the small wx test apps opt out via --no-removelist
# (and the bare name "match" could collide there).
COLOR_SETTINGS::COLOR_SETTINGS*
BuildBitmapInfo*
match
DIALOG_PAD_PROPERTIES_BASE::DIALOG_PAD_PROPERTIES_BASE*
buildKicadAboutBanner*
IGESToBRep_CurveAndSurface::TransferGeometry*
StepAP214_Protocol::StepAP214_Protocol*
BRepCheck_ParallelAnalyzer::operator*
ShapeFix_Wire::FixGap3d*
ShapeFix_Wire::FixGap2d*
PCB_EDIT_FRAME::setupUIConditions*
# The ngspice model-parameter table initializers (sim_model_ngspice_data_*.cpp,
# restored for the simulator split): thousands of straight-line emplace_backs,
# nothing that can suspend. Uninstrumented they stay under the JS engines'
# per-function locals limit and shave the post-asyncify module size
# (bsim4/b3soi/b4soi/hsim alone are the four largest functions in eeschema).
NGSPICE_MODEL_INFO_MAP::add*

View file

@ -1,176 +0,0 @@
#!/bin/bash
# Post-process the Emscripten-generated <app>.js for KiCad WASM (pcbnew, eeschema,
# pl_editor, calculator, …).
#
# The actual JavaScript that gets injected lives in readable, standalone files in
# scripts/common/shims/ (not inline heredocs):
# - asyncify-scheduler.js the mailbox/scheduler (docs/features/async/17) —
# the ONLY asyncify runtime (the legacy handlesleep.js
# opt-out was deleted at doc 20 D-1)
# - diagnostics.js optional logging-only instrumentation (see SHIM_DIAGNOSTICS)
#
# Native wasm-EH is the only build mode, so the .js has no invoke_* wrappers / dynCall_<sig> call
# sites to bind. The build still links -sDYNCALLS=1, so asyncify-INSTRUMENTED dynCall_* trampolines
# exist as wasm EXPORTS; the empty-callback fixes below route function-pointer stubs through
# wasmExports["dynCall_<sig>"]. This MUST be the wasm trampoline, NOT getWasmTableEntry — the latter
# bypasses the instrumentation and breaks unwind/rewind through indirect calls ("indirect call
# signature mismatch" — caught every frame in Firefox; a hard renderer crash in Chrome/V8).
#
# Usage:
# inject-dyncall-shims.sh <pcbnew.js>
# SHIM_DIAGNOSTICS=1 inject-dyncall-shims.sh <pcbnew.js> # also inject diagnostics.js
set -e
JS_FILE="$1"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SHIM_DIR="$SCRIPT_DIR/shims"
# One-line toggle for the diagnostics module (default OFF).
SHIM_DIAGNOSTICS="${SHIM_DIAGNOSTICS:-0}"
if [ -z "$JS_FILE" ] || [ ! -f "$JS_FILE" ]; then
echo "Error: JS file not found: $JS_FILE"
echo "Usage: $0 <path/to/pcbnew.js>"
exit 1
fi
for f in asyncify-scheduler.js diagnostics.js; do
if [ ! -f "$SHIM_DIR/$f" ]; then
echo "Error: missing shim source $SHIM_DIR/$f"
exit 1
fi
done
# --- 1. Empty-callback fixes ---------------------------------------------------
# Emscripten+pthreads emits some direct-call paths as no-op ((a1)=>{}) stubs that ARE used. Native
# wasm-EH eliminates the invoke_* wrappers, so the .js has no dynCall_<sig> call sites to bind — but
# the DYNCALLS=1 trampolines are still EXPORTED on the wasm, so route each function-pointer stub
# through wasmExports["dynCall_<sig>"]. This MUST be the wasm trampoline, NOT getWasmTableEntry: the
# fiber entry runs a coroutine that suspends+rewinds via Asyncify, and an Asyncify rewind cannot
# resume through getWasmTableEntry's JS wrapper — the fiber would re-enter from the top and the tool's
# Wait() re-runs (tool_manager ScheduleWait "!pendingWait" assert + busy-loop). The instrumented
# dynCall_<sig> export rewinds correctly. (Without these fixes the libcontext fiber entry stays the
# empty (a1=>{}) stub, so tool coroutines never start and every GAL app stalls at InvokeTool.)
echo "Fixing empty callback arrow functions..."
TOTAL_FIXED=0
apply_fix() { # <grep/sed pattern> <sed replacement> <label>
local before; before=$(grep -c "$1" "$JS_FILE" || true)
if [ "$before" -gt 0 ]; then
# Portable in-place edit (BSD `sed -i ''` and GNU `sed -i` differ; temp+mv works on both).
sed "s/$1/$2/g" "$JS_FILE" > "${JS_FILE}.sedtmp" && mv "${JS_FILE}.sedtmp" "$JS_FILE"
local after; after=$(grep -c "$1" "$JS_FILE" || true)
echo " Fixed $((before - after)) $3"
TOTAL_FIXED=$((TOTAL_FIXED + before - after))
fi
}
apply_fix '((a1, a2, a3) => {})(eventTypeId,' '((a1, a2, a3) => wasmExports["dynCall_iiii"](callbackfunc, a1, a2, a3))(eventTypeId,' "HTML5 event callback(s) (wasmExports.dynCall_iiii)"
apply_fix 'var result = (a1 => {})(arg);' 'var result = wasmExports["dynCall_ii"](ptr, arg);' "pthread entry callback(s) (wasmExports.dynCall_ii)"
apply_fix 'return (a1 => {})(sig);' 'return wasmExports["dynCall_vi"](fp, sig);' "signal handler callback(s) (wasmExports.dynCall_vi)"
apply_fix 'var wrapper = () => (a1 => {})(arg);' 'var wrapper = () => wasmExports["dynCall_vi"](func, arg);' "async timer callback(s) (wasmExports.dynCall_vi)"
apply_fix 'var iterFunc = (() => {});' 'var iterFunc = () => wasmExports["dynCall_v"](func);' "main loop callback(s) (wasmExports.dynCall_v)"
apply_fix '(a1 => {})(userData);' 'wasmExports["dynCall_vi"](entryPoint, userData);' "fiber entry callback(s) (wasmExports.dynCall_vi)"
echo "Total: Fixed $TOTAL_FIXED empty callback(s)"
# --- 3. Asyncify scheduler shim ------------------------------------------------
# Injected after Emscripten's fiber glue (the _emscripten_fiber_swap.isAsync marker),
# or at EOF for non-fiber apps (a plain wx app still needs the currData machinery:
# without it a rewind resuming through a fresh wasm re-entry hits
# _asyncify_start_rewind(null) -> "memory access out of bounds").
#
# asyncify-scheduler.js (docs/features/async/17) is the ONLY asyncify runtime:
# it owns the currData capture/restore, fiber guard, and trampoline heal, and
# adds the deferred-wake drain + N1 single-writer tripwire + the mailbox/wait
# lanes. The legacy handlesleep.js opt-out (WX_SCHEDULER=0) and the ablation
# skip (SHIM_DISABLE_HANDLESLEEP) were deleted at doc 20 D-1 together with the
# wx C++ paths they exercised.
inject_shim_at_marker() { # <shim file> <label>
local shim_file="$1" label="$2"
local marker
marker=$(grep -n '^_emscripten_fiber_swap\.isAsync = true;$' "$JS_FILE" | head -1 | cut -d: -f1)
if [ -z "$marker" ]; then
echo "" >> "$JS_FILE"
cat "$SHIM_DIR/$shim_file" >> "$JS_FILE"
echo "Injected $label at EOF (no fiber glue)"
else
head -n "$marker" "$JS_FILE" > "${JS_FILE}.tmp"
echo "" >> "${JS_FILE}.tmp"
cat "$SHIM_DIR/$shim_file" >> "${JS_FILE}.tmp"
tail -n +$((marker + 1)) "$JS_FILE" >> "${JS_FILE}.tmp"
mv "${JS_FILE}.tmp" "$JS_FILE"
echo "Injected $label after line $marker"
fi
}
# NOTE: idempotence via the shim-source sentinel, not __wxSchedulerInstalled —
# that string also appears in evtloop.cpp's EM_JS probe inside every glue.
if grep -q '__WX_SCHEDULER_SHIM_SOURCE__' "$JS_FILE"; then
echo "asyncify-scheduler already present - skipping"
else
inject_shim_at_marker asyncify-scheduler.js "asyncify-scheduler"
fi
# --- 3b. embind dynCall fallback (dynCallLegacy -> wasmExports) ----------------
# embind's generic caller (getDynCaller) routes through dynCallLegacy, which only
# reads Module["dynCall_<sig>"]. But the DYNCALLS=1 trampolines are wasm EXPORTS,
# not Module properties, so that lookup is undefined and an Asyncify unwind/rewind
# through an embind call (e.g. kicadOpenFile -> OpenProjectFiles) dies with
# "f is not a function" in Asyncify.doRewind. Add a wasmExports fallback so the
# instrumented trampoline is found and rewind survives.
if grep -q 'embind dynCall fallback installed' "$JS_FILE"; then
echo "dynCallLegacy fallback already present - skipping"
elif grep -qF ' var f = Module["dynCall_" + sig];' "$JS_FILE"; then
perl -0pi -e 's/(\Q var f = Module["dynCall_" + sig];\E)/$1\n \/\/ embind dynCall fallback installed: DYNCALLS=1 trampolines live on wasmExports, not Module.\n if (!f && typeof wasmExports !== "undefined") f = wasmExports["dynCall_" + sig];/' "$JS_FILE"
echo "Injected dynCallLegacy wasmExports fallback"
else
echo "Warning: dynCallLegacy pattern not found - skipping embind dynCall fallback"
fi
# --- 3d. Embind invoker: don't Promise-wrap a SYNCHRONOUS call when the main loop is parked --------
# Emscripten's embind invoker returns a Promise iff Asyncify.currData is set AFTER the wasm call. But
# the native-EH per-frame-yield main loop parks via Asyncify (currData stays SET between frames), so a
# JS-initiated embind call (e.g. kicadCollabSnapshot from a test or the UI) that does NOT itself
# suspend is mis-detected as async and returns "[object Promise]" instead of the value -> the caller's
# JSON.parse(...) gets "[object Promise]". Capture currData before the call and only treat it as async
# if THIS call left a NEW currData. Harmless under legacy/JS-EH (currData is null when the app is idle).
if grep -q 'Asyncify.currData !== __ehPrev' "$JS_FILE"; then
echo "embind invoker currData re-entrancy fix already present - skipping"
elif grep -q 'return Asyncify.currData ? Asyncify.whenDone' "$JS_FILE"; then
perl -0pi -e 's/(invokerFnBody \+= \(returns \|\| isAsync \? "var rv = " : ""\))/invokerFnBody += "var __ehPrev = Asyncify.currData;\\n";\n $1/' "$JS_FILE"
perl -0pi -e 's/return Asyncify\.currData \? Asyncify\.whenDone/return (Asyncify.currData && Asyncify.currData !== __ehPrev) ? Asyncify.whenDone/' "$JS_FILE"
echo "Injected embind invoker currData re-entrancy fix"
else
echo "Warning: embind invoker currData pattern not found - skipping embind re-entrancy fix"
fi
# --- 3c. Fiber trampoline self-heal -------------------------------------------
# emscripten_set_main_loop(...,1) throws "unwind" during startup to establish the
# main loop. KiCad establishes that loop from inside a tool coroutine, so the throw
# propagates THROUGH Fibers.trampoline()'s do/while, skipping its
# `trampolineRunning = false` reset. The flag then stays true forever and
# Fibers.trampoline() becomes a permanent no-op (guard: `if (!trampolineRunning ...)`),
# so every later fiber swap silently fails to switch — the schematic load and all
# post-idle tool actions hang. Wrap the loop in try/finally so the flag is always
# reset (self-healing).
# (The SHIM_DISABLE_TRAMPOLINE_HEAL ablation skip was deleted at doc 20 D-1
# with the races_test_noheal build that used it.)
if grep -qF '} finally { Fibers.trampolineRunning = false; }' "$JS_FILE"; then
echo "fiber trampoline self-heal already present - skipping"
elif grep -qF 'Fibers.trampolineRunning = true;' "$JS_FILE"; then
perl -0pi -e 's/(Fibers\.trampolineRunning = true;)(\s*)(do \{.*?\} while \(Fibers\.nextFiber\);)(\s*)(Fibers\.trampolineRunning = false;)/$1$2try {$3} finally { $5 }/s' "$JS_FILE"
echo "Injected fiber trampoline self-heal (try/finally)"
else
echo "Warning: Fibers.trampoline pattern not found - skipping trampoline self-heal"
fi
# --- 4. Optional diagnostics (logging only) -----------------------------------
if [ "$SHIM_DIAGNOSTICS" = "1" ]; then
if grep -q 'DIAG] Asyncify/fiber/modal diagnostics installed' "$JS_FILE"; then
echo "diagnostics already present - skipping"
else
echo "" >> "$JS_FILE"
cat "$SHIM_DIR/diagnostics.js" >> "$JS_FILE"
echo "Appended diagnostics module (SHIM_DIAGNOSTICS=1)"
fi
else
echo "diagnostics disabled (set SHIM_DIAGNOSTICS=1 to enable)"
fi

View file

@ -7,6 +7,5 @@ wx_window_resize
ProcessEvents
wxWasmMailboxTick
wxWasmTopLevelTick
wxWasmMainLoopPump
wxWasmJobTick
pcbjam_libctx_entry

View file

@ -1,801 +0,0 @@
// === AsyncifyScheduler (S2 — scheduler core: registry, deferred wakes, single writer) ===
// __WX_SCHEDULER_SHIM_SOURCE__ — injector idempotence sentinel. Must appear ONLY in
// this file: the obvious marker (__wxSchedulerInstalled) also occurs in evtloop.cpp's
// EM_JS probe text inside every glue, which made the injector skip real injections.
// docs/features/async/17-mailbox-scheduler-plan.md · injected only on WX_SCHEDULER=1 builds.
//
// S2 state: this file REPLACES the legacy handlesleep.js on scheduler builds (the
// injector's either-or flip). It carries:
// S1 · the wx mailbox (timer/wheel messages, wx/wasm/private/mailbox.h) and the
// embind mutator lane (doc 18 classification).
// S2 · the scheduler core — every behavior the legacy shim provided (per-sleep
// currData capture/restore, the stale-fiber consume-once/quarantine guard,
// wake-window flags, flight recorder, trampoline heal) PLUS:
// - DEFERRED WAKES: a sleep wake arriving while a transition is in flight
// (state != Normal, or the fiber trampoline mid-loop) is queued and
// delivered from a clean macrotask when the slot frees — the aliased-wake
// class becomes unrepresentable instead of merely detected (doc 12 §law).
// - N1 SINGLE-WRITER TRIPWIRE: Asyncify.currData is an accessor; a write
// from pure JS (no wasm frames on the export stack) without scheduler
// authorization is a STRAY — beaconed, counted, and (opt-in strict mode)
// thrown. Wasm-driven writes (fiber_swap, handleSleep internals) are
// runtime-legitimate and pass through.
// Contract surfaces kept name-identical (external readers!): Asyncify.__wakingRoot
// (libcontext EM_JS wasm_root_wake_in_flight), __inSleepWake, __wakingOwnerFiber,
// __pendingSleepContexts, Fibers.__fcsTotal/__rootHotTotal/__rootFiber/
// __validSuspensions/__internallyParked/__parkSleepBuf/__inFiberEntry,
// the "[wx-asyncify] STATE"/"RECORDER" dump formats, window.__wxAsyncifyDump.
if (typeof Asyncify !== "undefined" && !globalThis.__wxSchedulerInstalled) {
globalThis.__wxSchedulerInstalled = true;
Asyncify.__schedulerBuild = 1;
var AsyncifyScheduler = {
// --- S1 wx mailbox ----------------------------------------------------
mailbox: [],
enqueued: 0,
delivered: 0,
_tickArmed: false,
enqueueAfter: function (fn, arg, ms) {
var self = this;
setTimeout(function () {
if (self.dead) return; // S6: never deliver into a torn-down app
self.mailbox.push({ fn: fn, arg: arg });
self.enqueued++;
self._armDeliveryTick();
}, ms);
},
pop: function () {
var m = this.mailbox.shift();
if (m) this.delivered++;
return m || null;
},
// Deliver via a dedicated PLAIN export call (wxWasmMailboxTick), never
// from inside a pump's awaited ProcessEvents ccall — a fiber swap there
// sits on the JS-awaits-a-suspending-export boundary (#13302) and traps.
// Resume ready contexts from a FRESH task. Separate from the mailbox tick
// because it must run even when the mailbox is empty: a context wake is
// work the pump owns, not a queued message.
_armSchedPump: function () {
if (this._pumpArmed) return;
this._pumpArmed = true;
var self = this;
setTimeout(function () {
self._pumpArmed = false;
if (self.dead) return;
try {
if (Module["_wxWasmSchedPump"]) Module["_wxWasmSchedPump"]();
} catch (e) {
if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"]();
// The exception escaped a context through drain()'s fiber swap, so
// the transition it started never completed: without this every
// later pump refuses ("transition in flight") and all outstanding
// waits stall forever (doc 22 Phase B).
if (Module["_wxWasmSchedAbandon"]) Module["_wxWasmSchedAbandon"]();
throw e;
}
}, 0);
},
_armDeliveryTick: function () {
if (this._tickArmed) return;
this._tickArmed = true;
var self = this;
setTimeout(function tick() {
if (self.dead) { self._tickArmed = false; return; }
try {
if (Module["_wxWasmMailboxTick"]) Module["_wxWasmMailboxTick"]();
} catch (e) {
self._tickArmed = false;
if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"]();
// Same containment as the top-level tick's error path (evtloop.cpp):
// a throwing handler must not leave a parked quasi-modal unresolved.
// No-ops when no such wait is open (5101 = wxID_CANCEL).
self.resolveTopWait('nested', 0);
self.resolveTopWait('modal', 5101);
throw e;
}
if (self.mailbox.length > 0) {
setTimeout(tick, 17);
} else {
self._tickArmed = false;
}
}, 0);
},
// --- S1 embind lane ---------------------------------------------------
MUTATOR_NAMES: [
"kicadSetChrome", "kicadSetReadOnly",
"kicadCollabApply", "kicadCollabApplyItems",
"kicadCollabSnapshot", "kicadCollabSnapshotItems",
"kicadCollabPresenceStart", "kicadCollabSetRemote",
"kicadCollabSetPins", "kicadCollabSetStyle",
"kicadCollabSetViewport", "kicadCollabFitViewport",
"kicadCollabReleaseSelection", "kicadSetColorTheme",
"kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet",
],
mutatorQueue: [],
mutatorsWrapped: 0,
mutatorsDelivered: 0,
_mutatorPumpArmed: false,
_openBusy: function () {
var probe = Module["kicadOpenFileBusy"];
if (typeof probe !== "function") return false;
try { return !!probe(); } catch (e) { return true; }
},
_wrapMutators: function () {
var self = this;
this.MUTATOR_NAMES.forEach(function (name) {
var orig = Module[name];
if (typeof orig !== "function") return;
self.mutatorsWrapped++;
Module[name] = function () {
var args = arguments;
var call = function () { return orig.apply(Module, args); };
if (self.mutatorQueue.length === 0 && !self._openBusy()) {
self.mutatorsDelivered++;
return call();
}
return new Promise(function (resolve, reject) {
self.mutatorQueue.push({ name: name, call: call, resolve: resolve, reject: reject });
self._armMutatorPump();
});
};
});
if (this.mutatorsWrapped > 0)
console.log("[wx-scheduler] embind lane: wrapped " + this.mutatorsWrapped + " mutator(s)");
},
// Phase F (doc 22 §10, the awaited-ccall entry class): when the binary
// carries kicadOpenFileStart, the open body runs on a DISPATCH CONTEXT
// and the await surface becomes a plain JS promise over the wait token —
// the main stack never parks in place during a load. Conditional: older
// binaries without the starter keep the legacy suspending export.
_wrapOpenFile: function () {
var self = this;
var start = Module["kicadOpenFileStart"];
if (typeof start !== "function" || typeof Module["kicadOpenFile"] !== "function") return;
Module["kicadOpenFile"] = function (path) {
// Mint the token HERE, in pure JS — the starter runs the load on a
// dispatch context and Asyncify-suspends its own frame, so a token
// RETURNED from it would arrive as a placeholder (0). We own the token
// and await its promise; the job resolves it when the load finishes.
var token = self.beginWait("open");
start(token, path);
// waitPromise consumes early-resolved entries (the fast-error path),
// so a job that finished before this await still resolves correctly.
return self.waitPromise(token).then(function (r) { return !!r; });
};
console.log("[wx-scheduler] open lane: kicadOpenFile routed through the dispatch context");
},
_armMutatorPump: function () {
if (this._mutatorPumpArmed) return;
this._mutatorPumpArmed = true;
var self = this;
var now = (typeof performance !== "undefined" && performance.now)
? function () { return performance.now(); }
: function () { return Date.now(); };
setTimeout(function pump() {
if (self.dead) { self._mutatorPumpArmed = false; return; }
// Unkillable: an exception escaping this body would end the setTimeout
// chain and wedge the queue forever (observed: 559 frozen messages).
try {
if (!self._openBusy()) {
// Time-boxed drain: ~8 ms of work per 16 ms tick keeps the page
// live while a long backlog drains in order.
var t0 = now();
while (self.mutatorQueue.length > 0 && now() - t0 < 8) {
if (self._openBusy()) break;
var m = self.mutatorQueue.shift();
self.mutatorsDelivered++;
try { m.resolve(m.call()); } catch (e) { m.reject(e); }
}
}
} catch (e) {
self._pumpErrors = (self._pumpErrors || 0) + 1;
if (self._pumpErrors <= 5)
console.warn("[wx-scheduler] mutator pump error (occurrence "
+ self._pumpErrors + "): " + e);
}
if (self.mutatorQueue.length > 0) setTimeout(pump, 16);
else self._mutatorPumpArmed = false;
}, 16);
},
// --- S4 wait registry ---------------------------------------------------
// Token-based waits (doc 13 §2: wasm_begin_async_wait / wasm_yield_until /
// wasm_resolve_wait). A wait is begun BEFORE the C++ side parks, so a
// resolve that races ahead of the park (EndModal during Show()) simply
// pre-resolves the promise — yieldUntil then returns immediately. Per-kind
// LIFO stacks give wx modal/nested semantics ("innermost first") without
// the legacy per-wait resolver stacks (_wxModalResolvers /
// _wxNestedLoopExit — deleted at doc 20 D-1). Resolution flows
// through the S2 deferred-wake law automatically: resolving a wait wakes
// its parked sleep via the wrapped handleSleep path.
waits: new Map(), // token → {kind, promise, resolve, resolved, result, awaited, contextParked}
waitSeq: 0,
waitStacks: {}, // kind → [unresolved tokens], LIFO
waitsBegun: 0,
waitsResolved: 0,
earlyWaitResolves: 0, // resolves that landed before their waiter parked (Phase E)
beginWait: function (kind) {
var token = ++this.waitSeq;
var entry = { kind: kind, resolved: false, resolve: null, promise: null };
var self = this;
entry.promise = new Promise(function (resolve) { entry.resolve = resolve; });
this.waits.set(token, entry);
(this.waitStacks[kind] = this.waitStacks[kind] || []).push(token);
this.waitsBegun++;
return token;
},
waitPromise: function (token) {
var entry = this.waits.get(token);
if (!entry) {
console.warn("[wx-scheduler] waitPromise(" + token + "): unknown token");
return Promise.resolve(0);
}
if (entry.resolved) {
// Resolved before the waiter parked (Phase E early-resolve window).
// Consume the retained entry and hand the real result over — the old
// path warned "unknown token" and returned 0, dropping it.
this.waits.delete(token);
return Promise.resolve(entry.result | 0);
}
entry.awaited = true;
return entry.promise;
},
// Phase E: a wait resolved before its C++ waiter reached the park keeps its
// entry (see resolveWait) so the result is not lost. wxWasmYieldUntil peeks
// before parking a context and consumes the result instead of parking a
// context nobody will ever resume.
waitEarlyResolved: function (token) {
var entry = this.waits.get(token);
return entry && entry.resolved ? 1 : 0;
},
takeWaitResult: function (token) {
var entry = this.waits.get(token);
if (!entry || !entry.resolved) return 0;
this.waits.delete(token);
return entry.result | 0;
},
// doc 22 Phase C: this token's waiter parked a SCHEDULER CONTEXT instead of
// suspending its stack in place, so there is no promise anyone awaits —
// resolving one would strand the context forever. Marked from C++ at park
// time; resolveWait routes such tokens to the registry instead.
noteContextWait: function (token) {
var entry = this.waits.get(token);
if (entry) entry.contextParked = true;
},
resolveWait: function (token, result) {
var entry = this.waits.get(token);
if (!entry || entry.resolved) return false;
entry.resolved = true;
this.waitsResolved++;
var stack = this.waitStacks[entry.kind];
if (stack) {
var idx = stack.indexOf(token);
if (idx !== -1) stack.splice(idx, 1);
}
if (entry.contextParked) {
// Mark ready only — never resume inline. The pump picks it up from a
// fresh task, which is doc 13 §1.4's deferred-wake law applied to
// contexts (a rewind inside this resolver's own turn is the whole
// class of bug the scheduler exists to remove).
this.waits.delete(token);
try {
Module["_wxWasmSchedResolveContextWait"](token, result | 0);
} catch (e) {
console.warn("[wx-scheduler] context wait " + token + " resolve failed: " + e);
}
this._armSchedPump();
return true;
}
entry.result = result | 0;
entry.resolve(result | 0);
if (entry.awaited) {
this.waits.delete(token);
} else {
// Nobody has parked on this token yet (Phase E early-resolve window:
// a bridge whose request settled before the C++ frame reached the
// park). Keep the entry, result attached — wxWasmYieldUntil or a late
// waitPromise consumes it. Deleting here is what stranded the first
// Phase E attempt: the later park waited on a wake nobody could send.
this.earlyWaitResolves++;
}
return true;
},
// Resolve the INNERMOST unresolved wait of a kind (wx LIFO semantics).
resolveTopWait: function (kind, result) {
var stack = this.waitStacks[kind];
if (!stack || stack.length === 0) return false;
return this.resolveWait(stack[stack.length - 1], result);
},
pendingWaits: function (kind) {
var stack = this.waitStacks[kind];
return stack ? stack.length : 0;
},
// --- S2 scheduler core state -------------------------------------------
// Deferred sleep wakes: {deliver, result} queued because a transition was
// in flight when the wake arrived. Delivered FIFO from a clean macrotask.
readyWakes: [],
deferredWakes: 0,
drainedWakes: 0,
_wakeDrainArmed: false,
// N1: pure-JS currData writes seen without scheduler authorization.
strayWrites: 0,
// Phase E: fresh in-place Asyncify parks that began on a NON-main stack
// (tool coroutine / scheduler context). Must be ZERO at the flip.
inplaceParksOnFiberStack: 0,
strictStrays: false, // tests set true → stray throws instead of beaconing
_authorizedWrite: 0,
authorize: function (fn) {
this._authorizedWrite++;
try { return fn(); } finally { this._authorizedWrite--; }
},
// --- S6 lifetime --------------------------------------------------------
// Called when the wx main loop exits (DoRun's top-level return path). The
// app object is about to be destroyed: delivering anything after this
// point runs callbacks into freed C++ state. Queued mutators reject,
// queued messages and wakes drop — loudly, so a teardown that strands
// work is visible in the console instead of surfacing as a later UAF.
dead: false,
shutdown: function (reason) {
if (this.dead) return;
this.dead = true;
var stranded = {
mailbox: this.mailbox.length,
mutators: this.mutatorQueue.length,
wakes: this.readyWakes.length,
waits: this.waits.size,
};
this.mailbox.length = 0;
for (var i = 0; i < this.mutatorQueue.length; i++) {
try { this.mutatorQueue[i].reject(new Error("[wx-scheduler] shutdown: " + reason)); } catch (e) {}
}
this.mutatorQueue.length = 0;
this.readyWakes.length = 0;
if (stranded.mailbox || stranded.mutators || stranded.wakes || stranded.waits) {
console.warn("[wx-scheduler] shutdown (" + reason + ") stranded:"
+ " mailbox=" + stranded.mailbox
+ " mutators=" + stranded.mutators
+ " wakes=" + stranded.wakes
+ " pendingWaits=" + stranded.waits);
} else {
console.log("[wx-scheduler] shutdown (" + reason + ") clean");
}
},
state: function () {
return "[wx-scheduler] build=1 impl=S2-core"
+ (this.dead ? " DEAD" : "")
+ " mailbox=" + this.mailbox.length
+ " enqueued=" + this.enqueued
+ " delivered=" + this.delivered
+ " mutQ=" + this.mutatorQueue.length
+ " mutWrapped=" + this.mutatorsWrapped
+ " mutDelivered=" + this.mutatorsDelivered
+ " readyWakes=" + this.readyWakes.length
+ " deferredWakes=" + this.deferredWakes
+ " drainedWakes=" + this.drainedWakes
+ " strayWrites=" + this.strayWrites
+ " waits=" + this.waits.size
+ " waitsBegun=" + this.waitsBegun
+ " waitsResolved=" + this.waitsResolved
+ " earlyWaitResolves=" + this.earlyWaitResolves
+ " fiberStackParks=" + this.inplaceParksOnFiberStack;
},
};
globalThis.__wxScheduler = AsyncifyScheduler;
// ======================================================================
// S2 core install. Skipped defensively if the legacy shim somehow got in
// first — double-managing the wake path corrupts (the injector's either-or
// flip should make this unreachable).
// ======================================================================
if (Asyncify.__nestedHandleSleepInstalled) {
console.warn("[wx-scheduler] legacy handlesleep present - S2 core NOT installed (dual-management guard)");
} else if (typeof Asyncify.handleSleep === "function"
&& typeof Asyncify.allocateData === "function") {
// --- flight recorder + state dump (ported verbatim-in-spirit from
// handlesleep.js; formats are parsed by guard-beacons.ts and
// apps/tests/tools/repro-board-load.ts — do not change shapes) ---------
Asyncify.__pendingSleepContexts = [];
var __recMax = 96;
Asyncify.__rec = [];
var __rec = function (ev) {
var r = Asyncify.__rec;
r.push(((typeof performance !== "undefined" ? performance.now() : 0) | 0) + " " + ev);
if (r.length > __recMax) r.shift();
};
Asyncify.__recPush = __rec;
var __dumpState = function () {
var F = (typeof Fibers !== "undefined") ? Fibers : null;
var pend = Array.isArray(Asyncify.__pendingSleepContexts)
? Asyncify.__pendingSleepContexts.map(function (c) { return c.capturedData || 0; }).join(",")
: "n/a";
var head = "[wx-asyncify] STATE"
+ " state=" + Asyncify.state
+ " currData=" + (Asyncify.currData || 0)
+ " inSleepWake=" + (Asyncify.__inSleepWake || 0)
+ " exportStack=" + (Asyncify.exportCallStack ? Asyncify.exportCallStack.length : -1)
+ " pendingSleeps=[" + pend + "]"
+ (F ? (" nextFiber=" + F.nextFiber
+ " trampolining=" + F.trampolineRunning
+ " root=" + F.__rootFiber
+ " fcsTotal=" + (F.__fcsTotal || 0)
+ " rootHotTotal=" + (F.__rootHotTotal || 0)
+ " valid=[" + (F.__validSuspensions ? Array.from(F.__validSuspensions).join(",") : "") + "]"
+ " parked=[" + (F.__internallyParked ? Array.from(F.__internallyParked).join(",") : "") + "]"
+ " deferrals=" + (F.__rootDeferrals || 0))
: " (no Fibers)")
+ " | " + AsyncifyScheduler.state();
return head + "\n[wx-asyncify] RECORDER (oldest first):\n " + Asyncify.__rec.join("\n ");
};
if (typeof window !== "undefined") {
window.__wxAsyncifyDump = __dumpState;
var __dumps = 0;
var __onTrap = function (msg) {
if (__dumps >= 2) return;
if (!/index out of bounds|unreachable executed|table index|indirect call signature|null function or function signature|memory access out of bounds/i.test(msg)) return;
++__dumps;
try { console.error(__dumpState()); } catch (e) {}
};
window.addEventListener("error", function (e) {
__onTrap(e && e.error instanceof Error ? e.error.message : String((e && e.message) || ""));
});
window.addEventListener("unhandledrejection", function (e) {
__onTrap(e && e.reason instanceof Error ? e.reason.message : String((e && e.reason) || ""));
});
}
var __wxAsyncifyReport = (function () {
var counts = {};
return function (kind, msg, withStack) {
var n = (counts[kind] = (counts[kind] || 0) + 1);
if (n > 10 && n % 100 !== 0) return;
var line = "[wx-asyncify] " + kind + ": " + msg + " (occurrence " + n + ")";
if (withStack) {
try { line += "\n" + String(new Error().stack).split("\n").slice(1, 8).join("\n"); } catch (e) {}
}
console.warn(line);
};
})();
// --- N1: single-writer accessor on Asyncify.currData ------------------
// Writes made while compiled code is on the export stack are the wasm
// runtime's own (fiber_swap, handleSleep's park/stop paths) — legitimate.
// A pure-JS write (empty export stack) must come from a scheduler-
// authorized span; anything else is a STRAY: the exact shape of every
// historical corruption's bad write. Beacon + count; strict mode throws.
(function () {
var realCurrData = Asyncify.currData; // null at install time
Object.defineProperty(Asyncify, "currData", {
configurable: true,
get: function () { return realCurrData; },
set: function (v) {
if ((!Asyncify.exportCallStack || Asyncify.exportCallStack.length === 0)
&& AsyncifyScheduler._authorizedWrite === 0
&& !(typeof Fibers !== "undefined" && Fibers.trampolineRunning)) {
AsyncifyScheduler.strayWrites++;
__wxAsyncifyReport("stray-currdata-write",
"currData=" + (v || 0) + " written from pure JS without scheduler authorization", true);
if (AsyncifyScheduler.strictStrays)
throw new Error("[wx-scheduler] stray currData write (strict mode)");
}
realCurrData = v;
},
});
})();
// --- deferred-wake drain ----------------------------------------------
var __transitionFree = function () {
return Asyncify.state === 0
&& !(typeof Fibers !== "undefined" && Fibers.trampolineRunning);
};
AsyncifyScheduler._scheduleWakeDrain = function () {
if (this._wakeDrainArmed) return;
this._wakeDrainArmed = true;
var self = this;
setTimeout(function () {
self._wakeDrainArmed = false;
if (self.dead) return; // S6: parked stacks are gone with the app
// Deliver from a CLEAN macrotask (export stack empty by construction).
while (self.readyWakes.length > 0 && __transitionFree()) {
var w = self.readyWakes.shift();
self.drainedWakes++;
w.deliver(w.result);
}
if (self.readyWakes.length > 0) self._scheduleWakeDrain();
}, 0);
};
// --- handleSleep wrap: registry + capture/restore + deferral ----------
var __originalAllocateData = Asyncify.allocateData.bind(Asyncify);
Asyncify.allocateData = function () {
var ptr = __originalAllocateData();
for (var i = Asyncify.__pendingSleepContexts.length - 1; i >= 0; --i) {
var ctx = Asyncify.__pendingSleepContexts[i];
if (!ctx.capturedData) {
ctx.capturedData = ptr;
break;
}
}
return ptr;
};
var __originalHandleSleep = Asyncify.handleSleep.bind(Asyncify);
Asyncify.handleSleep = function (startAsync) {
__rec("sleep s=" + Asyncify.state + " cd=" + (Asyncify.currData || 0)
+ " w=" + (Asyncify.__inSleepWake || 0));
if (Asyncify.state === 0 && Asyncify.currData) {
__wxAsyncifyReport("concurrent-park",
"handleSleep entered while currData=" + Asyncify.currData, true);
}
if (Asyncify.state === 1) {
__wxAsyncifyReport("reentrant-state",
"handleSleep entered mid-unwind (state=1) currData=" + Asyncify.currData, true);
}
// Only a FRESH park (state 0) allocates data and needs tracking; the
// state-2 resume re-entry returns synchronously through the rewind
// branch (a context pushed for it leaks one per resume).
if (Asyncify.state !== 0) {
return __originalHandleSleep(startAsync);
}
// Phase F (doc 22 §10 F2/F3): report every fresh in-place park to the
// REGISTRY. Begin() returns the owning context id (0 = main stack);
// while recorded, fiber_enterable()/fiber_transfer refuse entering that
// context — the registry-owned replacement for the deleted quarantine.
// Also the Phase E telemetry: fiberStackParks must be 0 at the flip's
// repro gate. Leaf probe into wasm; state is 0 here so no unwind is in
// flight yet.
var parkOwnerCtx = 0;
try {
if (Module["_wxWasmSchedInplaceParkBegin"]) {
parkOwnerCtx = Module["_wxWasmSchedInplaceParkBegin"]() | 0;
if (parkOwnerCtx) {
AsyncifyScheduler.inplaceParksOnFiberStack++;
__rec("inplace-park-on-fiber-stack ctx=" + parkOwnerCtx
+ " n=" + AsyncifyScheduler.inplaceParksOnFiberStack);
}
}
} catch (e) { /* probe must never break a park */ }
var sleepCtx = {
capturedData: null,
cleanedUp: false,
parkOwnerCtx: parkOwnerCtx,
rootOwned: (typeof Fibers === "undefined")
|| (!Fibers.__inFiberEntry
&& !(Asyncify.__wakingOwnerFiber || false)),
};
Asyncify.__pendingSleepContexts.push(sleepCtx);
var cleanup = function () {
if (sleepCtx.cleanedUp) return;
sleepCtx.cleanedUp = true;
if (sleepCtx.parkOwnerCtx) {
try {
if (Module["_wxWasmSchedInplaceParkEnd"]) {
Module["_wxWasmSchedInplaceParkEnd"](sleepCtx.parkOwnerCtx);
}
} catch (e) { /* never break a wake */ }
sleepCtx.parkOwnerCtx = 0;
}
var idx = Asyncify.__pendingSleepContexts.indexOf(sleepCtx);
if (idx !== -1) Asyncify.__pendingSleepContexts.splice(idx, 1);
};
try {
return __originalHandleSleep(function (wakeUp) {
// deliver(): the legacy shim's whole wake path — restore OUR buffer,
// mark the wake window, swallow the "unwind" sentinel.
var deliver = function (result) {
__rec("wake buf=" + (sleepCtx.capturedData || 0) + " cdWas=" + (Asyncify.currData || 0)
+ (sleepCtx.rootOwned ? " R" : " f"));
if (sleepCtx.capturedData) {
if (Asyncify.currData !== sleepCtx.capturedData) {
__wxAsyncifyReport(
Asyncify.currData ? "aliased-wake-live" : "overlapped-wake",
"restoring currData=" + sleepCtx.capturedData +
" over " + (Asyncify.currData || "null") +
" state=" + Asyncify.state, !!Asyncify.currData);
}
AsyncifyScheduler.authorize(function () {
Asyncify.currData = sleepCtx.capturedData;
});
}
cleanup();
Asyncify.__inSleepWake = (Asyncify.__inSleepWake || 0) + 1;
var prevWakingOwnerFiber = Asyncify.__wakingOwnerFiber || false;
Asyncify.__wakingOwnerFiber = !sleepCtx.rootOwned;
var prevWakingRoot = Asyncify.__wakingRoot || 0;
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = (Asyncify.__wakingRoot || 0) + 1;
try {
return wakeUp(result);
} catch (e) {
if (e === "unwind") return;
throw e;
} finally {
Asyncify.__inSleepWake -= 1;
Asyncify.__wakingOwnerFiber = prevWakingOwnerFiber;
if (sleepCtx.rootOwned) Asyncify.__wakingRoot = prevWakingRoot;
}
};
return startAsync(function (result) {
// THE S2 LAW (doc 12): a wake never starts a rewind while another
// transition is in flight — it enqueues and the drain delivers
// from a clean macrotask when the slot frees. The legacy shim
// could only beacon this window (aliased-wake-live); the
// scheduler removes it.
if (!__transitionFree()) {
AsyncifyScheduler.deferredWakes++;
__rec("defer-wake buf=" + (sleepCtx.capturedData || 0)
+ " s=" + Asyncify.state);
AsyncifyScheduler.readyWakes.push({ deliver: deliver, result: result });
AsyncifyScheduler._scheduleWakeDrain();
return;
}
return deliver(result);
});
});
} catch (e) {
cleanup();
throw e;
}
};
// Transition-completion signal: maybeStopUnwind is where an unwind
// finishes (state → Normal) and the trampoline runs queued fiber
// switches. After it settles, deferred wakes may proceed.
var __originalMaybeStopUnwind = Asyncify.maybeStopUnwind.bind(Asyncify);
Asyncify.maybeStopUnwind = function () {
var ret = __originalMaybeStopUnwind();
if (AsyncifyScheduler.readyWakes.length > 0 && __transitionFree())
AsyncifyScheduler._scheduleWakeDrain();
return ret;
};
Asyncify.__nestedHandleSleepInstalled = true; // compat: tools probe this
console.log("[wx-scheduler] S2 core installed (deferred wakes + N1 accessor)");
}
// --- stale-fiber-rewind guard (ported from handlesleep.js; semantics
// unchanged — these encode the consume-once/quarantine contracts of
// docs/features/async/16) + trampoline heal ownership -------------------
// Phase F F2/F3 (doc 22 §10, 2026-08-09): deletion was built, measured and
// REVERTED. The registry now carries the in-place-park fact
// (wxWasmSchedInplaceParkBegin/End) and refuses on the transfer lane, but
// the quarantine's DROP is still the only correct recovery for a misrouted
// yield-back under attribution rot (a C++-level refusal ghost-resumes the
// yielding coroutine — measured as the lever's phase2 overshoot). This
// block stays until attribution is registry-authoritative (gap 3).
if (typeof Fibers !== "undefined"
&& typeof Fibers.finishContextSwitch === "function"
&& !Fibers.__staleRewindGuardInstalled) {
Fibers.__validSuspensions = new Set();
Fibers.__internallyParked = new Set();
Fibers.__parkSleepBuf = new Map();
var __origFinishContextSwitch = Fibers.finishContextSwitch.bind(Fibers);
var __fiberRefusals = 0;
var __fcsRec = (typeof Asyncify !== "undefined" && Asyncify.__recPush)
? Asyncify.__recPush
: function () {};
var __refuseFiber = function (newFiber, why) {
__fcsRec("refuse new=" + newFiber);
++__fiberRefusals;
if (__fiberRefusals <= 10 || __fiberRefusals % 100 === 0) {
console.warn("[wx-asyncify] fiber-resume-refused: fiber=" + newFiber + " " + why
+ " (occurrence " + __fiberRefusals + ")");
}
AsyncifyScheduler.authorize(function () {
Asyncify.currData = null;
});
};
Fibers.finishContextSwitch = function (newFiber) {
Fibers.__fcsTotal = (Fibers.__fcsTotal || 0) + 1;
if (newFiber === Fibers.__rootFiber && (Asyncify.__inSleepWake || 0) > 0) {
Fibers.__rootHotTotal = (Fibers.__rootHotTotal || 0) + 1;
}
var __remStr = "";
if (Asyncify.currData) {
var __H = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32;
__remStr = " rem=" + (__H[((Asyncify.currData + 4) >>> 2) >>> 0] - __H[(Asyncify.currData >>> 2) >>> 0])
+ " rf=" + (Asyncify.getDataRewindFuncName ? Asyncify.getDataRewindFuncName(Asyncify.currData) : "?")
+ " es=[" + (Asyncify.exportCallStack || []).join("|") + "]";
}
__fcsRec("fcs old=" + (Asyncify.currData ? Asyncify.currData - 20 : 0)
+ " new=" + newFiber
+ (newFiber === Fibers.__rootFiber ? " ROOT" : "")
+ " w=" + (Asyncify.__inSleepWake || 0) + __remStr);
if (Asyncify.currData) {
var oldFiber = Asyncify.currData - 20;
if (Fibers.__rootFiber === undefined) {
Fibers.__rootFiber = oldFiber;
}
var parkBuf = Fibers.__parkSleepBuf.get(oldFiber);
var stillParked = parkBuf !== undefined
&& Array.isArray(Asyncify.__pendingSleepContexts)
&& Asyncify.__pendingSleepContexts.some(function (c) { return c.capturedData === parkBuf; });
if (!stillParked) {
Fibers.__validSuspensions.add(oldFiber);
Fibers.__internallyParked.delete(oldFiber);
Fibers.__parkSleepBuf.delete(oldFiber);
}
}
var isRoot = newFiber === Fibers.__rootFiber;
var HEAPU32v = (typeof GROWABLE_HEAP_U32 === "function") ? GROWABLE_HEAP_U32() : HEAPU32;
var entryPoint = HEAPU32v[((newFiber + 12) >>> 2) >>> 0];
if (!isRoot && Fibers.__internallyParked.has(newFiber)) {
__refuseFiber(newFiber, "is asyncify-parked mid-body (sleep in flight)");
return;
}
if (entryPoint === 0) {
if (!Fibers.__validSuspensions.has(newFiber)) {
__refuseFiber(newFiber, isRoot
? "root suspension already consumed - a second rewind would replay stale frames"
: "has no live suspension - rewinding would replay stale data");
return;
}
Fibers.__validSuspensions.delete(newFiber);
}
if (!isRoot) Fibers.__inFiberEntry = (Fibers.__inFiberEntry || 0) + 1;
var ret;
try {
// The original writes currData (entry path nulls it, resume path sets
// the fiber's buffer) from pure JS — scheduler-supervised here.
ret = AsyncifyScheduler.authorize(function () {
return __origFinishContextSwitch(newFiber);
});
} finally {
if (!isRoot) Fibers.__inFiberEntry -= 1;
}
if (!isRoot && !Fibers.nextFiber && Asyncify.currData) {
Fibers.__internallyParked.add(newFiber);
Fibers.__parkSleepBuf.set(newFiber, Asyncify.currData);
}
return ret;
};
// Trampoline heal ownership (subsumes inject-dyncall-shims §3c): a throw
// escaping the trampoline loop must not leave trampolineRunning wedged —
// that guard being stuck turns every later fiber swap into a silent no-op.
var __origTrampoline = Fibers.trampoline.bind(Fibers);
Fibers.trampoline = function () {
try {
return __origTrampoline();
} catch (e) {
Fibers.trampolineRunning = false;
throw e;
}
};
Fibers.__staleRewindGuardInstalled = true;
}
// Wrap the embind mutators once the runtime has registered them.
if (typeof Module !== "undefined") {
if (Module["calledRun"]) {
AsyncifyScheduler._wrapMutators();
AsyncifyScheduler._wrapOpenFile();
} else {
var __wxSchedPrevInit = Module["onRuntimeInitialized"];
Module["onRuntimeInitialized"] = function () {
if (typeof __wxSchedPrevInit === "function") __wxSchedPrevInit();
AsyncifyScheduler._wrapMutators();
AsyncifyScheduler._wrapOpenFile();
};
}
}
console.log("[wx-scheduler] scaffolding installed (S2, core live)");
}
// === End AsyncifyScheduler ===

View file

@ -436,7 +436,6 @@
entrySp: region.top,
suspendedAt: 0
};
if (globalThis.__wxTrace) console.log('[TRACE] alloc region ' + region.base + ' -> act ' + rec.id + ':' + name);
S._actStack.push(rec);
S._setSp(region.top);
var out;
@ -466,7 +465,6 @@
},
_endActivation: function (rec) {
if (globalThis.__wxTrace) console.log('[TRACE] end ' + rec.id + ':' + rec.kind + ' live=' + (this._windowLive ? this._windowLive.id : '-'));
this._suspended.delete(rec.id);
if (this._windowLive === rec) {
// completed from a RESUMED window: wasm's epilogue left SP at the
@ -476,7 +474,6 @@
if (rec.enclosingSp !== undefined) this._setSp(rec.enclosingSp);
}
if (rec.region) {
if (globalThis.__wxTrace) console.log('[TRACE] free region ' + rec.region.base + ' <- act ' + rec.id);
this._regionFree(rec.region);
rec.region = null;
}
@ -485,7 +482,6 @@
},
_pumpResume: function () {
if (globalThis.__wxTrace) console.log('[TRACE] pump live=' + (this._windowLive ? this._windowLive.id + ':' + this._windowLive.kind : '-') + ' ready=' + this._resumeReady.map(function(e){return e.rec.id + ':' + e.rec.kind;}).join(','));
if (this.dead) return;
if (this._windowLive) {
// Self-heal: an activation that suspended RAW (bypassing the shim)
@ -580,7 +576,6 @@
entrySp: S._sp(), suspendedAt: 0, anon: true
};
}
if (globalThis.__wxTrace) console.log('[TRACE] suspend ' + rec.id + ':' + rec.kind + ' ' + kind + '/' + token + ' live=' + (S._windowLive ? S._windowLive.id : '-') + ' stack=' + S._actStack.map(function(r){return r.id;}).join(','));
rec.sp = S._sp();
rec.suspendedAt = Date.now();
rec.waitKind = kind;
@ -828,7 +823,7 @@
S.installExportWraps([
"wx_dom_event", "wx_dom_mouse", "wx_window_close", "wx_window_move",
"wx_window_resize", "ProcessEvents", "wxWasmMailboxTick",
"wxWasmTopLevelTick", "wxWasmMainLoopPump", "wxWasmJobTick"
"wxWasmTopLevelTick", "wxWasmJobTick"
]);
// KiCad-only surfaces; both installers skip absent names, so the wx
// test apps (no embind) pass through here untouched.

View file

@ -1,13 +1,13 @@
#!/usr/bin/env node
// Computes the "sc" (source-content) hash for the KiCad-WASM output cache key
// in .github/workflows/ci-ubicloud.yml.
// in .github/workflows/wasm-build.yml.
//
// The cache key is:
// kwasm-<os>-bin<binaryen-ver><opt-level>-k<kicad-sha>-wx<wx-sha>-sc<HASH>-e<epoch>
// kwasm-<os>-k<kicad-sha>-wx<wx-sha>-sc<HASH>-3d<flag>-e<epoch>
//
// The kicad/wx submodule SHAs already capture the *sources*. This hash captures
// the *build logic* that shapes the wasm bytes but lives outside those
// submodules — the asyncify/finalize/dyncall/wasm-opt host steps, the per-tool
// submodules — the host ENV-shim patch, the per-tool
// compile scripts, the dependency builds, and the Docker toolchain. Those were
// deliberately dropped from the key's hashFiles() (so routine script edits don't
// trigger a 1-2h rebuild); folding the *output-determining* subset back in here
@ -47,14 +47,12 @@ import { join, relative, sep } from "node:path";
// { dir: "<repo-relative dir>", match: RE } files under the dir whose BASENAME matches RE, recursive
// Paths are POSIX, relative to the repo root. This script always adds itself.
const INPUTS = [
// Host-side post-processing — these directly shape the final wasm bytes.
{ file: "scripts/common/apply-asyncify.sh" },
{ file: "scripts/common/apply-finalize.sh" },
{ file: "scripts/common/inject-dyncall-shims.sh" },
// The submodule build that provides BOTH host wasm-opt and wasm-emscripten-finalize.
// (get-wasm-opt.sh is no longer on the build path — bench-only — so it no longer
// belongs in the cache key.)
{ file: "scripts/binaryen-hoist-pass/build-wasm-opt.sh" },
// Host-side post-processing — shapes the shipped glue.
{ file: "scripts/common/patch-env-shim.mjs" },
// Link-time inputs baked into every editor app: the scheduler pre-js and
// the promising-export census.
{ file: "scripts/common/shims/jspi-scheduler.js" },
{ file: "scripts/common/jspi-exports.txt" },
// Per-tool compile recipes (compile flags / emcc link options).
{ dir: "scripts/kicad", match: /^build-.*\.sh$/ },

View file

@ -296,29 +296,11 @@ else
log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)"
fi
# Async suspension backend (JSPI migration). Default asyncify; PCBJAM_ASYNC_BACKEND=jspi
# links the browser apps with -sJSPI (native stack switching) instead of
# -sASYNCIFY/-sDYNCALLS, defines PCBJAM_JSPI=1 for every TU (selects the JSPI
# libcontext coroutine backend + the wx port's JSPI dispatch paths), and skips
# the whole post-link asyncify pipeline (stub dance + host wasm-opt). The wx
# build this app links against must be built with the SAME knob
# (build-wx-wasm.sh reads it too). The headless CLIs (kicad_tools, occ_service)
# stay asyncify-shaped regardless: their targets pin -sASYNCIFY=0 and run in
# node/worker where no suspension backend is wanted.
PCBJAM_ASYNC_BACKEND="${PCBJAM_ASYNC_BACKEND:-asyncify}"
case "${APP_NAME}" in
kicad_tools|occ_service) PCBJAM_ASYNC_BACKEND="asyncify" ;;
esac
if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then
EXTRA_FLAGS="${EXTRA_FLAGS} -DPCBJAM_JSPI=1"
# PCBJAM_JSPI is ABI-affecting (coroutine.h's callerStub grows a finish
# hook — a template, so every instantiating TU must agree): route it into
# KICAD_TU_ABI_FLAGS via EMBIND_CONFIG_DEFINES like the Debug -DDEBUG.
EMBIND_CONFIG_DEFINES="${EMBIND_CONFIG_DEFINES} -DPCBJAM_JSPI=1"
log_info "Async backend: JSPI (-sJSPI, -DPCBJAM_JSPI=1)"
else
log_info "Async backend: asyncify"
fi
# Suspension backend: JSPI (native stack switching) since the migration.
# The headless CLIs (kicad_tools, occ_service) get NO suspension backend at
# all — their targets pin -sASYNCIFY=0 and run in node/worker where nothing
# may suspend. coroutine.h/libcontext key on __EMSCRIPTEN__ directly, so no
# ABI define is threaded through the TU flags anymore.
# Step 6: Create build directory
mkdir -p "${KICAD_BUILD}"
@ -395,32 +377,11 @@ fi
EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ] || [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then
# Use the real tools so the module is fully finalized inside the container
# (no host post-processing / asyncify for these targets). JSPI mode has no
# asyncify pass at all, so every app finalizes in-container.
[ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}"
[ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}"
log_info "Using real wasm-opt/finalize for ${APP_NAME} (finalize in-container)"
else
if [ -f "${EMSDK_WASM_OPT}" ] && [ ! -f "${EMSDK_WASM_OPT}.real" ]; then
log_info "Backing up real wasm-opt..."
mv "${EMSDK_WASM_OPT}" "${EMSDK_WASM_OPT}.real"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-opt-stub.sh" "${EMSDK_WASM_OPT}"
chmod +x "${EMSDK_WASM_OPT}"
log_info "wasm-opt stub installed (asyncify will run on host)"
if [ -f "${EMSDK_FINALIZE}" ] && [ ! -f "${EMSDK_FINALIZE}.real" ]; then
log_info "Backing up real wasm-emscripten-finalize..."
mv "${EMSDK_FINALIZE}" "${EMSDK_FINALIZE}.real"
fi
# Always copy the latest stub (in case it was updated)
cp "${STUBS_DIR}/wasm-emscripten-finalize-stub.sh" "${EMSDK_FINALIZE}"
chmod +x "${EMSDK_FINALIZE}"
log_info "wasm-emscripten-finalize stub installed (finalize will run on host)"
fi
# JSPI has no post-link asyncify pass: every app finalizes in-container with
# the REAL tools. The .real backups exist on containers that ran the retired
# asyncify stub dance — restore them if present.
[ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}"
[ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}"
# Step 6.5: Verify WASM support is in KiCad fork
# The kicad submodule should already have WASM port detection and kiplatform support
@ -527,37 +488,20 @@ if [ "${BUILD_3D_VIEWER}" = "ON" ]; then
fi
# Multi-threaded CPU raytracer (mainline threading restored): link the main-thread
# nanosleep->Asyncify-yield shim so the raw-thread raytracer joins (sleep_for busy-wait)
# yield to the JS event loop instead of deadlocking on-demand pthread-Worker creation.
# Mirrors the wasm/gl1 pattern (compile to .o, add to the link). Shim:
# wasm/shims/nanosleep_yield.c; its EM_ASYNC_JS yield is covered by env.__asyncjs__* in
# scripts/common/asyncify-imports.txt.
# The synchronous node CLIs (ASYNCIFY=0) must NOT link it: their Asyncify JS
# runtime doesn't exist, so the shim's yield throws "Asyncify is not defined"
# on the first main-thread sleep (e.g. DRC copper-clearance's worker-poll).
# A blocking CLI wants libc's real blocking nanosleep anyway — node permits
# nanosleep-yield shim so the raw-thread raytracer joins (sleep_for busy-wait)
# yield to the JS event loop instead of deadlocking on-demand pthread-Worker
# creation (wasm/shims/nanosleep_yield.c, suspends via the jspi scheduler).
# The synchronous node CLIs must NOT link it: nothing in them may suspend and
# a blocking CLI wants libc's real blocking nanosleep anyway — node permits
# Atomics.wait on its main thread.
if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ]; then
NANOSLEEP_YIELD_LINK=""
else
# Every main-thread sleep suspends in place (legal on a promising
# activation; EM_ASYNC_JS auto-wraps as WebAssembly.Suspending under
# -sJSPI) and routes through the jspi-scheduler turnstile.
emcc -c -pthread "${PROJECT_ROOT}/wasm/shims/nanosleep_yield.c" -o "${STUBS_BUILD}/nanosleep_yield.o"
if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then
# JSPI: no scheduler-context lane exists — the weak
# pcbjam_context_sleep_ms stays null and every main-thread sleep
# suspends in place (legal on a promising activation; EM_ASYNC_JS
# auto-wraps as WebAssembly.Suspending under -sJSPI).
# context_sleep.cpp is fiber-only machinery and must not link.
NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o"
else
# Its scheduler-aware half (docs/features/async/22 Phase B): a main-thread
# sleep on a scheduler context parks THAT CONTEXT instead of suspending the
# stack in place. C++ because the registry is a header-only C++ layer in
# wx's port, hence WX_CXXFLAGS for the include path (the same reason
# thirdparty/libcontext needed it at Phase A).
em++ -c -std=c++17 -pthread ${WX_CXXFLAGS} \
"${PROJECT_ROOT}/wasm/shims/context_sleep.cpp" -o "${STUBS_BUILD}/context_sleep.o"
NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o ${STUBS_BUILD}/context_sleep.o"
fi
NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o"
fi
# mallinfo() stub for the mimalloc build: -sMALLOC=mimalloc doesn't export the
@ -583,21 +527,22 @@ else
PTHREAD_POOL_EXPR='navigator.hardwareConcurrency'
fi
# Suspension-backend link surface. Asyncify: binaryen instrumentation +
# DYNCALLS (+ the dynCall runtime export used by the post-link dyncall shims).
# JSPI: native suspension — -sJSPI + the promising-export census
# Suspension link surface. Browser apps: -sJSPI + the promising-export census
# (scripts/common/jspi-exports.txt: the wx KEEPALIVE entries that can park +
# pcbjam_libctx_entry; regenerate by grep, not memory), the jspi-scheduler
# pre-js (successor of the post-link-injected asyncify-scheduler.js), and the
# runtime methods its green-copy stack discipline needs (stackSave/
# stackRestore/HEAPU8). -sDYNCALLS is a fatal link error under JSPI.
if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then
ASYNC_LINK_FLAGS="-sJSPI -sJSPI_EXPORTS=@${PROJECT_ROOT}/scripts/common/jspi-exports.txt --pre-js ${PROJECT_ROOT}/scripts/common/shims/jspi-scheduler.js"
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','stackSave','stackRestore','HEAPU8','HEAP8','HEAP32']"
else
ASYNC_LINK_FLAGS="-sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536"
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall']"
fi
# pre-js, and the runtime methods its spill-stack discipline needs
# (stackSave/stackRestore/HEAPU8). Headless CLIs: nothing — their targets pin
# -sASYNCIFY=0 and nothing in them may suspend.
case "${APP_NAME}" in
kicad_tools|occ_service)
ASYNC_LINK_FLAGS=""
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8']"
;;
*)
ASYNC_LINK_FLAGS="-sJSPI -sJSPI_EXPORTS=@${PROJECT_ROOT}/scripts/common/jspi-exports.txt --pre-js ${PROJECT_ROOT}/scripts/common/shims/jspi-scheduler.js"
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','stackSave','stackRestore','HEAPU8','HEAP8','HEAP32']"
;;
esac
emcmake cmake "${KICAD_DIR}" \
${CCACHE_OPTS} \

View file

@ -53,23 +53,15 @@ CXXFLAGS += -MMD -MP
# Native wasm-EH is the only build mode: compile + link with -fwasm-exceptions (legacy encoding) +
# wasm setjmp/longjmp, matching the libwx build (scripts/build-wx-wasm.sh) and scripts/common/env.sh.
# The in-link Asyncify (emsdk Binaryen v121) crashes on wasm-EH, so build-wasm-test.sh stubs it and
# runs the real pipeline (--hoist-cpp-catches + --asyncify via apply-asyncify.sh) post-link.
# Async backend switch (experiment/jspi): PCBJAM_ASYNC_BACKEND=jspi links the
# apps with JSPI instead of Asyncify. Requires the wx libs built with the same
# knob (build-wx-wasm.sh). -sDYNCALLS is asyncify-only machinery and a FATAL
# link error under -sJSPI; -DPCBJAM_JSPI selects the JSPI lanes in the wasm
# port sources and libcontext.
PCBJAM_ASYNC_BACKEND ?= asyncify
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -DPCBJAM_JSPI=1
# JSPI is the only suspension backend since the migration (-sDYNCALLS and the
# whole Asyncify/binaryen post-link pipeline are retired).
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1
# Promising entry exports: every JS->wasm entry that can transitively suspend
# (fresh census 2026-08-12 of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the
# Sched*/abandon probes never suspend and stay plain). pcbjam_libctx_entry is
# libcontext's coroutine entry (only present in apps that link libcontext —
# emscripten warns-and-ignores absent names).
WX_JSPI_EXPORTS = main,wx_dom_event,wx_dom_mouse,wx_window_close,wx_window_move,wx_window_resize,ProcessEvents,wxWasmMailboxTick,wxWasmTopLevelTick,wxWasmMainLoopPump,wxWasmJobTick,pcbjam_libctx_entry
# (census of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the Sched*/abandon
# probes never suspend and stay plain). pcbjam_libctx_entry is libcontext's
# coroutine entry (only present in apps that link libcontext — emscripten
# warns-and-ignores absent names).
WX_JSPI_EXPORTS = main,wx_dom_event,wx_dom_mouse,wx_window_close,wx_window_move,wx_window_resize,ProcessEvents,wxWasmMailboxTick,wxWasmTopLevelTick,wxWasmJobTick,pcbjam_libctx_entry
# Per-app suspending test levers (fresh-stack ccalls that park or swap a
# coroutine). Appended as a LAST -sJSPI_EXPORTS on the app's link line, which
# wins over the one inside ASYNC_LDFLAGS (emcc last-wins). The non-parking
@ -82,27 +74,10 @@ ASYNC_LDFLAGS = -sJSPI \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stackSave','stackRestore']" \
--pre-js $(JSPI_SHIM)
ASYNC_CORO_LDFLAGS = $(ASYNC_LDFLAGS)
ASYNC_SCHED_CTX_LDFLAGS = $(ASYNC_LDFLAGS)
else
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -sDYNCALLS=1
ASYNC_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
ASYNC_CORO_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
ASYNC_SCHED_CTX_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
endif
CXXFLAGS += $(EH_FLAGS)
# Base Emscripten flags (for all apps)
# ASYNCIFY enables blocking modal dialogs (ShowModal waits for user)
# ASYNCIFY_IMPORTS tells Emscripten which imported JS functions can unwind the stack
# JSPI enables blocking modal dialogs (ShowModal suspends its promising activation)
# - js_writeTextToClipboard, js_readTextFromClipboard, js_clipboardHasText, js_clearClipboard: for clipboard
# - js_enumerateFonts: for font enumeration via Local Font Access API
BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
@ -141,23 +116,14 @@ COROUTINE_BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_
$(ASYNC_CORO_LDFLAGS)
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
# The asyncify-races harness must match PRODUCTION asyncify semantics: the KiCad
# The suspend-races harness must match PRODUCTION suspension semantics: the KiCad
# apps ship with assertions off and rely on the shim layer, while emscripten's
# debug assert ("We cannot start an async operation when one is already flight")
# forbids the very multi-parked-sleep states the harness exists to exercise.
LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_LDFLAGS_NOGL)
# Scheduler-context harness (docs/features/async/20 D1). Deliberately does NOT
# link wx: the contexts layer is pure emscripten fibers + Asyncify, and keeping
# wx out means a failure here can only be the primitives. Assertions stay ON —
# unlike the races harness this must NOT tolerate a "cannot start an async
# operation" state; hitting one is the bug.
LDFLAGS_SCHED_CTX = $(DEBUG_LDFLAGS) $(EH_FLAGS) -sALLOW_MEMORY_GROWTH \
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
$(ASYNC_SCHED_CTX_LDFLAGS)
# LDFLAGS for the raytracer thread-deadlock repro. Same pthread + pool config as
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the 65536 ASYNCIFY
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the
# stack KiCad actually ships (emscripten_sleep needs Asyncify to yield).
LDFLAGS_RAYTRACE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \
@ -177,11 +143,9 @@ LDFLAGS_REALPOOL = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
# depend on them — otherwise editing a shim wouldn't trigger a relink.
JS = $(TOOLS_ROOT)/wx.js --pre-js $(TOOLS_ROOT)/wx-dom.js
JS_FILES = $(TOOLS_ROOT)/wx.js $(TOOLS_ROOT)/wx-dom.js
# The scheduler shim is a pre-js on every jspi link (ASYNC_LDFLAGS): apps
# whose objects are up to date must still relink when it changes.
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
# The scheduler shim is a pre-js on every link (ASYNC_LDFLAGS): apps whose
# objects are up to date must still relink when it changes.
JS_FILES += $(JSPI_SHIM)
endif
HTML = $(TOOLS_ROOT)/template.html
# wxWidgets library directory - used as dependency to rebuild when libs change
@ -249,7 +213,6 @@ all: minimal_test.html \
$(S)/coroutine/coroutine_test.html \
$(S)/coroutine-nested/nested_test.html \
$(S)/asyncify-races/races_test.html \
$(S)/sched-context/sched_context_test.html \
$(S)/coroutine-pthread/coroutine_test_wxpt.html \
$(S)/coroutine-pthread/embind_repro.html \
$(S)/coroutine-pthread/gl_repro.html \
@ -794,18 +757,6 @@ $(S)/coroutine-nested/nested_test.html: $(S)/coroutine-nested/nested_test.o $(S)
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Asyncify race-condition red-green harness (tests/asyncify/). Three variants of
# Scheduler-context harness (docs/features/async/20 D1/D2, 21). The layer is
# header-only in wx's wasm port (wx/wasm/private/sched_context.h — D2 puts wx's
# own dispatch on it), so this app needs no wx LIBRARY, just the include path:
# a failure here can still only be the contexts layer.
SCHED_CTX_INC = ../../wxwidgets/include
$(S)/sched-context/sched_context_test.o: $(S)/sched-context/sched_context_test.cpp $(SCHED_CTX_INC)/wx/wasm/private/sched_context.h
$(CXX) -c $(CXXFLAGS) -I$(SCHED_CTX_INC) $< -o $@
$(S)/sched-context/sched_context_test.html: $(S)/sched-context/sched_context_test.o
$(CXX) $^ $(LDFLAGS_SCHED_CTX) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# (The trampoline-heal / handleSleep ablation variants — races_test_noheal /
@ -890,7 +841,6 @@ retinascale: $(S)/retinascale/retinascale_test.html
coroutine: $(S)/coroutine/coroutine_test.html
coroutine-nested: $(S)/coroutine-nested/nested_test.html
asyncify-races: $(S)/asyncify-races/races_test.html
sched-context: $(S)/sched-context/sched_context_test.html
# Only delete generated app files (*_test*, *_repro*) — a bare $(S)/*/*.js would
# also delete checked-in sources like coroutine-pthread/worker_dom_stub.js.

View file

@ -1,6 +1,6 @@
#!/bin/bash
# Ad-hoc build for the jspi-coroutine harness (Makefile.wasm wiring in Phase 3).
# Compiles the REAL kicad/thirdparty/libcontext with -DPCBJAM_JSPI.
# Compiles the REAL kicad/thirdparty/libcontext (JSPI backend).
set -eo pipefail
cd "$(dirname "$0")"
ROOT="$(cd ../../../.. && pwd)"
@ -9,7 +9,7 @@ LIBCTX="$ROOT/kicad/thirdparty/libcontext"
"$EMXX" coroutine_jspi_test.cpp "$LIBCTX/libcontext.cpp" \
-I"$LIBCTX" \
-DPCBJAM_JSPI=1 \
\
-O1 \
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 \
-sJSPI -sJSPI_EXPORTS=pcbjam_libctx_entry,main \

File diff suppressed because it is too large Load diff

View file

@ -3,21 +3,17 @@
* exports (the doc-18 "PARKER" class: kicadOpenFile / kicadOpenFiles /
* kicadLibsReload, whose bodies park on wx wait tokens mid-load).
*
* JSPI backend: they MUST be registered emscripten::async() so embind wraps
* the invoker in WebAssembly.promising the call returns a real Promise and
* the suspension is legal. Without it the first park throws SuspendError
* ("trying to suspend without WebAssembly.promising").
*
* Asyncify backend: no policy the legacy contract stands (placeholder
* return, callers gate on kicadOpenFileBusy; the scheduler shim owns the
* await surface).
* They MUST be registered emscripten::async() so embind wraps the invoker in
* WebAssembly.promising the call returns a real Promise and the suspension
* is legal. Without it the first park throws SuspendError ("trying to
* suspend without WebAssembly.promising").
*
* Usage (note the macro carries its own leading comma):
* function( "kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY );
*/
#pragma once
#ifdef PCBJAM_JSPI
#ifdef __EMSCRIPTEN__
#define PCBJAM_PARKER_POLICY , emscripten::async()
#else
#define PCBJAM_PARKER_POLICY

View file

@ -1,129 +0,0 @@
/*
* Main-thread sleep as a CONTEXT PARK (docs/features/async/22, Phase B).
*
* THE PROBLEM THIS SOLVES. `nanosleep` on the main thread yields via Asyncify
* (see nanosleep_yield.c): it parks THE STACK IT STANDS ON. Doc 21 filed that
* as K7, the "anywhere" class, and its worst caller is a loop inside a tool
* body TOOL_MANAGER::RunSynchronousAction spins
*
* while( synchronousControl == STS_RUNNING ) { wxYield(); wxMilliSleep(1); }
*
* (kicad/common/tool/tool_manager.cpp:370-371). Every canvas edit/draw/move
* action goes through it. An in-place park there means a tool stack sits
* mid-Asyncify-suspension while a nested wxYield() dispatch runs on top of it,
* and once the scheduler owns dispatch (Phase D) a star transfer aimed at that
* stack rewinds a capture that is still in flight: `index out of bounds` in
* doRewind the blue screen, measured on four canvas-tool specs 2026-08-07.
*
* THE FIX. When the sleeping frame stands on a scheduler context that OWNS
* that stack, the wait becomes what every other migrated wait already is: arm
* a timed wake, YIELD THE CONTEXT, and let the scheduler resume it. Nothing
* is suspended in place, so there is no in-flight capture for a transfer to
* land on, and the caller's `for(;;)`-shaped poll keeps its exact semantics
* it just waits by yielding instead of by suspending.
*
* WHY HERE AND NOT IN KiCad. The loop is upstream KiCad code, and CLAUDE.md
* asks the fork to stay close to upstream. Routing this through the sleep
* primitive keeps KiCad and the wx core untouched AND fixes the whole K7 class
* at once (every main-thread sleep_for/wxMilliSleep reached on a context), not
* just the one caller that happened to be measured.
*
* WHY THE POLL DOESN'T NEED A SIGNAL. The waited-for state (an atomic set by a
* later dispatch) has no wake source of its own, so this keeps polling the
* caller's contract. What changes is only which stack the wait suspends. The
* dispatch that eventually flips the atomic runs on a DIFFERENT context: the
* tick reuses an idle dispatch context or makes one when all are parked
* deeper (the idle-reuse set), which is exactly why that change had to land
* before this one.
*/
#include <wx/wasm/private/sched_context.h>
#include <emscripten/emscripten.h>
#include <cstdint>
// The scheduler mailbox (wx/wasm/private/mailbox.h). A timer message is the
// wake source: it is delivered from a fresh JS task on the main stack, which
// is where a resume is allowed to happen.
extern "C" void wxWasmMailboxEnqueueAfter( void ( *aFn )( void* ), void* aArg, int aMillisecs );
// Does some wx pump already own this context's wake (the main loop's rAF, a
// dispatch context's tick)? Parking such a context here would give it TWO
// owners, and the second wake resumes a capture the first already consumed —
// measured 2026-08-07 as a doRewind trap through wxWasmArmFrameWake. Those
// contexts keep the in-place yield; the tool coroutines this exists for have
// no other wake source, which is precisely why their wait must park.
extern "C" int wxWasmContextWakeIsPumpOwned( unsigned aId );
namespace
{
void wake_sleeper( void* aArg )
{
const pcbjam_sched::ContextId id =
static_cast<pcbjam_sched::ContextId>( reinterpret_cast<uintptr_t>( aArg ) );
// mark_ready never resumes inline (doc 13 §1.4). WHO performs the entry
// depends on where this delivery landed:
if( !pcbjam_sched::mark_ready( id, 0 ) )
return;
if( pcbjam_sched::current() == 0 )
{
// On the scheduler stack: drain here.
pcbjam_sched::drain_all();
return;
}
// Delivered ON a context — the mailbox tick itself now runs on a dispatch
// context (doc 22 §10). drain() would refuse re-entry, and it should: the
// outer drain_all that entered this context keeps pumping once it parks,
// and picks up the context we just marked ready. Arm a pump anyway so a
// delivery that reached here by some other route cannot strand it.
EM_ASM( {
setTimeout( function() {
if( Module["_wxWasmSchedPump"] ) Module["_wxWasmSchedPump"]();
}, 0 );
} );
}
} // namespace
extern "C" {
/**
* Park the running context for aMillisecs instead of suspending this stack.
*
* Returns 1 if the wait was taken as a context park, 0 if the caller must fall
* back to the in-place Asyncify yield which is the right answer whenever no
* context owns this stack: the main loop itself, a bridge entered before the
* scheduler exists, or a libcontext fiber swapped in above a context (yielding
* there would save the WRONG stack doc 22 §7 rule 4, enforced by
* can_yield_here()).
*/
int pcbjam_context_sleep_ms( double aMillisecs )
{
const pcbjam_sched::ContextId self = pcbjam_sched::current();
if( !self || !pcbjam_sched::can_yield_here() )
return 0;
if( wxWasmContextWakeIsPumpOwned( self ) )
return 0;
// Round up: a 0 ms mailbox delay would re-enter this poll in the same
// macrotask chain and spin the CPU exactly as the sleep exists to avoid.
int delay = static_cast<int>( aMillisecs );
if( delay < 1 )
delay = 1;
wxWasmMailboxEnqueueAfter( &wake_sleeper,
reinterpret_cast<void*>( static_cast<uintptr_t>( self ) ),
delay );
pcbjam_sched::yield_park( "main-thread-sleep" );
return 1;
}
} // extern "C"

View file

@ -1,34 +0,0 @@
#!/bin/bash
# Stub wasm-emscripten-finalize that just copies input to output
# The real finalize runs on the host where more RAM is available
# Handle --version query
if [[ "$1" == "--version" ]]; then
echo "wasm-emscripten-finalize version 121"
exit 0
fi
# Parse arguments to find input/output files
# wasm-emscripten-finalize <input.wasm> -o <output.wasm> [options]
INPUT=""
OUTPUT=""
PREV_ARG=""
for arg in "$@"; do
if [ "$PREV_ARG" = "-o" ]; then
OUTPUT="$arg"
elif [[ "$arg" != -* ]] && [[ "$arg" == *.wasm ]]; then
INPUT="$arg"
fi
PREV_ARG="$arg"
done
# Copy input to output (no transformation)
if [ -n "$INPUT" ] && [ -n "$OUTPUT" ]; then
cp "$INPUT" "$OUTPUT"
elif [ -n "$INPUT" ]; then
# In-place mode: nothing to do
:
fi
exit 0

View file

@ -1,32 +0,0 @@
#!/bin/bash
# Stub wasm-opt that does nothing
# This allows Emscripten to generate Asyncify JS runtime without running the actual transformation
# The real wasm-opt --asyncify is run on the host where more RAM is available
# Handle --version query from Emscripten
if [[ "$1" == "--version" ]]; then
echo "wasm-opt version 121 (version_121)"
exit 0
fi
# Find the output file argument (-o)
OUTPUT=""
INPUT=""
PREV_ARG=""
for arg in "$@"; do
if [ "$PREV_ARG" = "-o" ]; then
OUTPUT="$arg"
elif [[ "$arg" != -* ]] && [ -f "$arg" ]; then
INPUT="$arg"
fi
PREV_ARG="$arg"
done
# If we have both input and output, copy input to output
if [ -n "$INPUT" ] && [ -n "$OUTPUT" ] && [ "$INPUT" != "$OUTPUT" ]; then
cp "$INPUT" "$OUTPUT"
fi
# Exit successfully so Emscripten thinks wasm-opt ran
exit 0

@ -1 +1 @@
Subproject commit 8921d7aaa4f3c5491d0e880369d88cdefa98e933
Subproject commit e637b9f3669ac4e18e043191772ee261fdd2b79d