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

@ -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} \