#!/bin/bash # Unified post-link Asyncify pass for KiCad AND the wx test apps. # # Usage: apply-asyncify.sh [--no-removelist] [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:-}" 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}"