2025-12-22 16:30:53 +01:00
#!/bin/bash
2026-06-03 19:37:15 +02:00
# Post-process the Emscripten-generated <app>.js for KiCad WASM (pcbnew, eeschema,
# pl_editor, calculator, …).
2025-12-22 16:30:53 +01:00
#
2026-05-25 15:07:10 +02:00
# The actual JavaScript that gets injected lives in readable, standalone files in
# scripts/common/shims/ (not inline heredocs):
# - handlesleep.js nested-Asyncify handleSleep currData save/restore (#9153)
# - diagnostics.js optional logging-only instrumentation (see SHIM_DIAGNOSTICS)
2025-12-22 16:30:53 +01:00
#
2026-06-29 19:50:18 +02:00
# 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).
2026-05-25 15:07:10 +02:00
#
# Usage:
# inject-dyncall-shims.sh <pcbnew.js>
# SHIM_DIAGNOSTICS=1 inject-dyncall-shims.sh <pcbnew.js> # also inject diagnostics.js
2025-12-22 16:30:53 +01:00
set -e
JS_FILE = " $1 "
2026-05-25 15:07:10 +02:00
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 } "
2025-12-22 16:30:53 +01:00
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
2026-06-29 19:50:18 +02:00
for f in handlesleep.js diagnostics.js; do
2026-05-25 15:07:10 +02:00
if [ ! -f " $SHIM_DIR / $f " ] ; then
echo " Error: missing shim source $SHIM_DIR / $f "
exit 1
fi
done
2025-12-22 16:30:53 +01:00
2026-06-29 19:50:18 +02:00
# --- 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.)
2026-01-12 14:19:13 +01:00
echo "Fixing empty callback arrow functions..."
TOTAL_FIXED = 0
2026-05-25 15:07:10 +02:00
apply_fix( ) { # <grep/sed pattern> <sed replacement> <label>
local before; before = $( grep -c " $1 " " $JS_FILE " || true )
if [ " $before " -gt 0 ] ; then
2026-06-11 07:46:48 +02:00
# 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 "
2026-05-25 15:07:10 +02:00
local after; after = $( grep -c " $1 " " $JS_FILE " || true )
echo " Fixed $(( before - after)) $3 "
TOTAL_FIXED = $(( TOTAL_FIXED + before - after))
fi
}
2026-06-29 19:50:18 +02:00
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)"
2026-05-25 15:07:10 +02:00
echo " Total: Fixed $TOTAL_FIXED empty callback(s) "
# --- 3. Nested-Asyncify handleSleep fix ---------------------------------------
# Injected after Emscripten's fiber glue (the _emscripten_fiber_swap.isAsync marker).
2026-06-12 16:59:07 +02:00
# SHIM_DISABLE_HANDLESLEEP=1 skips it: used by the asyncify-races red-green harness
# to keep the historical "sleep buffer clobbered by fiber swap" crash reproducible.
if [ " ${ SHIM_DISABLE_HANDLESLEEP :- 0 } " = "1" ] ; then
echo "handleSleep fix DISABLED (SHIM_DISABLE_HANDLESLEEP=1) - ablation build"
elif grep -q '__nestedHandleSleepInstalled' " $JS_FILE " ; then
2026-05-25 15:07:10 +02:00
echo "handleSleep fix already present - skipping"
else
HS_MARKER = $( grep -n '^_emscripten_fiber_swap\.isAsync = true;$' " $JS_FILE " | head -1 | cut -d: -f1)
if [ -z " $HS_MARKER " ] ; then
2026-06-29 19:50:18 +02:00
# No libcontext fiber glue (a non-fiber Asyncify app — e.g. a plain wx app with
# modals/menus, no tool coroutines). The currData save/restore is still needed:
# without it a rewind resuming through a fresh wasm re-entry hits
# _asyncify_start_rewind(null) -> "memory access out of bounds" (the context-menu
# pick while the main loop is Asyncify-parked). Append at EOF — Asyncify is defined
# by then and the shim wraps handleSleep at load, before any runtime sleep.
echo "" >> " $JS_FILE "
cat " $SHIM_DIR /handlesleep.js " >> " $JS_FILE "
echo "Injected handleSleep fix at EOF (no fiber glue)"
2026-05-25 15:07:10 +02:00
else
head -n " $HS_MARKER " " $JS_FILE " > " ${ JS_FILE } .tmp "
echo "" >> " ${ JS_FILE } .tmp "
cat " $SHIM_DIR /handlesleep.js " >> " ${ JS_FILE } .tmp "
tail -n +$(( HS_MARKER + 1 )) " $JS_FILE " >> " ${ JS_FILE } .tmp "
mv " ${ JS_FILE } .tmp " " $JS_FILE "
echo " Injected handleSleep fix after line $HS_MARKER "
fi
wip: nested-asyncify fix, wxAuiToolBar registration, tests, research docs
Main-repo side of a multi-part WIP covering the KiCad WASM tool-selection
and nested-Asyncify work. Submodule commits are in kicad@f6e9239aaa
(libcontext hygiene) and wxwidgets@bb80f91e8b (auibar registration +
dialog diagnostics).
## scripts/common/inject-dyncall-shims.sh
Wrap Asyncify.handleSleep / allocateData to save-and-restore Asyncify.currData
around each EM_ASYNC_JS sleep. This fixes the nested Asyncify collision where
a fiber swap that fired during a modal's event loop clobbered currData, and
the modal's later doRewind used the fiber's buffer and hit "RuntimeError:
index out of bounds". Root cause documented as Emscripten Issue #9153
(wontfix upstream).
Diagnostic-rewind logging (forcedBottomOfCallStack, callStack traces) is
retained to help future debugging of Asyncify state corruption.
## tests/
- tests/playwright-kicad.config.ts: add `channel: 'chrome'` for the
chromium project so --project=chromium --headed uses system Chrome
(real GPU) instead of SwiftShader on ARM Mac. Also switch trace to
retain-on-failure + screenshot on-failure for easier E2E debugging.
- tests/kicad/pcbnew.spec.ts: replace `tool.checked` assertions with a
label-suffix check (`[checked]`) since our auibar registration encodes
checked state in the label (no schema change to the registry).
- tests/apps/Makefile.wasm: add `coroutine-nested` build target + include
it in the all: list.
- tests/apps/standalone/coroutine/: kicad_coroutine_harness.h + test app
reproducing KiCad COROUTINE semantics against real libcontext.
- tests/apps/standalone/coroutine-nested/: nested_test.cpp reproduces the
EM_ASYNC_JS-modal + fiber-swap nesting bug in isolation. 8 scenarios
from baseline_modal_alone through nested_fibers_inside_modal.
- tests/e2e/coroutine.spec.ts + coroutine-nested.spec.ts: Playwright specs
that load the standalone apps and assert all case cases pass via
[COROUTINE_TEST] SUMMARY log parsing.
## research/ and features/browser-tools/
Three background docs capturing the investigation trajectory:
- features/browser-tools/0001-kicad-wasm-tool-activation-investigation.md
Early investigation: why tools don't activate; initial dynCall-empty-
callback hypothesis.
- features/browser-tools/0002-wasm-coroutine-deep-dive.md
Deep dive on Asyncify internals, fiber API, QEMU's coroutine-wasm
reference implementation.
- features/browser-tools/0003-wxauitoolbar-registration-fix.md
The narrow fix: why wxAuiToolBar needs a registration block, where to
add it, what the fallback plan is.
- research/threading_1.md: corrected root-cause analysis after reading
runtime logs — nested-Asyncify currData collision, Emscripten #9153.
- research/threading_2.md: extended research on alternative approaches
(JSPI/WasmFX/state-machines) and why they don't help here.
## Submodule pointer updates
kicad: f6e9239aaa (wip: libcontext WASM hygiene cleanup)
wxwidgets: bb80f91e8b (wip: wxAuiToolBar element-registry registration +
dialog diagnostics)
## Open threads not yet in scope
- Firefox/Chrome divergent behavior: "indirect call signature mismatch"
traps in Firefox vs renderer crash in system Chrome (tracked in
plans/peaceful-hugging-pnueli.md and the research docs).
- E2E pixel-diff for Draw Lines fails because the test's diff region does
not cover where the line is actually drawn; tool activation works, the
line is visible in test-results/pcbnew-draw-lines-02-after-drawing.png.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 13:58:16 +02:00
fi
2026-06-01 22:30:04 +02:00
# --- 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"
2026-06-29 19:50:18 +02:00
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"
2026-06-01 22:30:04 +02:00
fi
2026-06-02 12:03:34 +02:00
# --- 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).
2026-06-12 16:59:07 +02:00
# SHIM_DISABLE_TRAMPOLINE_HEAL=1 skips it: used by the asyncify-races red-green
# harness to keep the historical "park throw wedges the trampoline guard" hang
# reproducible.
if [ " ${ SHIM_DISABLE_TRAMPOLINE_HEAL :- 0 } " = "1" ] ; then
echo "fiber trampoline self-heal DISABLED (SHIM_DISABLE_TRAMPOLINE_HEAL=1) - ablation build"
elif grep -qF '} finally { Fibers.trampolineRunning = false; }' " $JS_FILE " ; then
2026-06-02 12:03:34 +02:00
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
2026-05-25 15:07:10 +02:00
# --- 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
2026-01-12 14:19:13 +01:00
else
2026-05-25 15:07:10 +02:00
echo "diagnostics disabled (set SHIM_DIAGNOSTICS=1 to enable)"
2026-01-12 14:19:13 +01:00
fi