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,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.