feat(wasm-eh): migrate the WASM build to native wasm exceptions (+ 3D viewer default-on)

Replace the legacy Emscripten JS-exceptions model with native wasm-EH (legacy
encoding) across the whole build, keeping Asyncify coroutines working via a
from-source Binaryen --hoist-cpp-catches pre-pass. Net result: native-EH is the
only build mode, the 3D viewer is on by default, and pcbnew shrinks substantially.

Highlights:
- Binaryen submodule everywhere + --hoist-cpp-catches integration in apply-asyncify;
  post-link Asyncify covers every app wasm (not just standalone test wasm).
- Build deps (incl. OpenCASCADE without OCC_CONVERT_SIGNALS) and all KiCad apps
  with -fwasm-exceptions; emscripten_sleep added to the post-link asyncify-imports.
- libcontext fiber entry wired under native exceptions; while-loop main loop +
  currData shim injected into all wx apps.
- Native-EH collab apply fixed: DEBUG-define the embind TU + match all out-of-CMake
  C++ TUs' ABI flags to the core, fixing the vtable-layout skew / mis-dispatch.
- 3D viewer enabled by default (real raytracer linked, not the stub).
- Retire the EH-spike scaffolding; flip the asyncify-races ablation pins to
  shim-redundancy pins (native-EH stays clean with the legacy shims ablated).
- Fix the asyncify-races quiescence check to not require Asyncify.currData==0:
  under the native-EH per-frame-yield top loop the main stack is asyncify-suspended
  every frame, so currData legitimately churns (a freed-but-not-yet-nulled buffer,
  not a leak). Refresh the pcbnew toolbar screenshot baseline for the new kicad.
- CI: drop the obsolete binaryen_version input/env (the build uses the binaryen
  submodule fork's wasm-opt, not a version download); key the wasm-output cache on
  the binaryen submodule SHA instead.

Bumps the wxwidgets + binaryen submodules to their squashed feature commits.

Validated green: all 7 apps native-EH (real 3D in pcbnew); KiCad e2e 63/63
Firefox + Chromium (3D viewer renders); wx 336; coroutine 34/34 both engines;
asyncify 7/7 both engines.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-06-29 19:50:18 +02:00
commit c1ef489cfa
75 changed files with 4976 additions and 680 deletions

View file

@ -289,3 +289,18 @@ Button positions (relative to canvas):
- **Timer tests**: May fail due to timing sensitivity
- **Tree tests**: Button click positions may vary
## Open tasks
- **Research: are the Asyncify fiber shims still needed under native-EH?** Two
`scripts/common/inject-dyncall-shims.sh` fixes — the fiber trampoline self-heal
(§3c) and the nested-Asyncify handleSleep save/restore (§3) — were written for the
legacy-EH *park-throw* model. Under native wasm-EH the top loop is a per-frame-yield
while-loop with no park-throw, so ablating either shim
(`SHIM_DISABLE_TRAMPOLINE_HEAL` / `SHIM_DISABLE_HANDLESLEEP`, wired in
`tests/apps/Makefile.wasm`) no longer reproduces the disease it guarded — the old
red "ablation pins" in `asyncify/asyncify-races.spec.ts` have been flipped to
green "shim-redundancy pins" that now prove the native-EH path stays clean *with the
shim ablated*. **To do:** sweep the real apps (modals, nested fibers, long sleeps,
pthread pool) with each shim ablated; if all stay green, drop the shim injection and
these pins. Until proven, they stay injected (belt-and-suspenders).

1
tests/apps/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
standalone/_pool_unshimmed/

View file

@ -51,16 +51,23 @@ endif
# crashes apps at startup with "RuntimeError: function signature mismatch".
CXXFLAGS += -MMD -MP
# Native wasm-EH is the only build mode: compile + link with -fwasm-exceptions (legacy encoding) +
# wasm setjmp/longjmp, matching the libwx build (scripts/build-wx-wasm.sh) and scripts/common/env.sh.
# The in-link Asyncify (emsdk Binaryen v121) crashes on wasm-EH, so build-wasm-test.sh stubs it and
# runs the real pipeline (--hoist-cpp-catches + --asyncify via apply-asyncify.sh) post-link.
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -sDYNCALLS=1
CXXFLAGS += $(EH_FLAGS)
# Base Emscripten flags (for all apps)
# ASYNCIFY enables blocking modal dialogs (ShowModal waits for user)
# ASYNCIFY_IMPORTS tells Emscripten which imported JS functions can unwind the stack
# - startModal: for modal dialogs
# - js_writeTextToClipboard, js_readTextFromClipboard, js_clipboardHasText, js_clearClipboard: for clipboard
# - js_enumerateFonts: for font enumeration via Local Font Access API
BASE_LDFLAGS = -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=8192 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['startModal','js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
# LDFLAGS for non-GL apps (standalone tests)
@ -92,7 +99,7 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
$(WX_LDFLAGS_NOGL)
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
COROUTINE_BASE_LDFLAGS = -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
COROUTINE_BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
@ -113,6 +120,13 @@ LDFLAGS_RAYTRACE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE_STRICT=0 \
$(WX_LDFLAGS_NOGL)
# LDFLAGS for the real-pool test (Phase 1a). Like LDFLAGS_RAYTRACE but pre-warm +2 workers so
# the lifecycle mode's small BS::pause_thread_pool(2) gets pre-warmed workers (no on-demand spawn).
LDFLAGS_REALPOOL = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency + 2' \
-sPTHREAD_POOL_SIZE_STRICT=0 \
$(WX_LDFLAGS_NOGL)
# A second pre-js carries the DOM-control shim ("--pre-js A --pre-js B"
# after expansion — emcc accepts repeated --pre-js).
# JS_FILES lists the actual pre-js paths (no flags) so the link targets can
@ -209,13 +223,13 @@ minimal_test.html: minimal_test.o $(WX_CORE_LIB) $(JS_FILES)
# --- DOM-port bug reproductions (docs/features/wx-dom-port/branch-review.md) ---
# wxTextCtrl reentry-guard repro. Needs -fexceptions: it throws from a wxEVT_TEXT
# handler to prove the OnDomEvent m_inDomInput reset must be exception-safe.
# wxTextCtrl reentry-guard repro: it throws from a wxEVT_TEXT handler to prove the
# OnDomEvent m_inDomInput reset must be exception-safe (works under native -fwasm-exceptions).
$(S)/textctrl-reentry/textctrl-reentry_test.o: $(S)/textctrl-reentry/textctrl-reentry_test.cpp
$(CXX) -c $(CXXFLAGS) -fexceptions $< -o $@
$(CXX) -c $(CXXFLAGS) $< -o $@
$(S)/textctrl-reentry/textctrl-reentry_test.html: $(S)/textctrl-reentry/textctrl-reentry_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_NOGL) -fexceptions --pre-js $(JS) --shell-file $(HTML) -o $@
$(CXX) $< $(LDFLAGS_NOGL) --pre-js $(JS) --shell-file $(HTML) -o $@
textctrl-reentry: $(S)/textctrl-reentry/textctrl-reentry_test.html
@ -589,6 +603,100 @@ raytrace-threads: $(S)/raytrace-threads/raytrace_threads_test.html
# Include the raytracer-threading repro in the default `all` build (prereqs accumulate).
all: $(S)/raytrace-threads/raytrace_threads_test.html
# Un-shimmed copy of KiCad's BS::thread_pool: the single-thread #ifdef __EMSCRIPTEN__ shim in
# detach_task is disabled (-> #if 0) so the pool tests exercise REAL multithreading WITHOUT
# modifying the pristine KiCad header and WITHOUT a compile macro. Regenerated at parse time to
# stay in sync. (HASH is a literal '#', escaped so make does not treat it as a comment.)
HASH := \#
POOL_UNSHIMMED := $(S)/_pool_unshimmed
$(shell mkdir -p $(POOL_UNSHIMMED); sed 's/$(HASH)ifdef __EMSCRIPTEN__/$(HASH)if 0/' $(KICAD_ROOT)/thirdparty/thread-pool/bs_thread_pool.hpp > $(POOL_UNSHIMMED)/bs_thread_pool.hpp)
# Real KiCad thread-pool test (Phase 1a / native-EH). Compiles the REAL
# kicad/common/thread_pool.cpp (GetKiCadThreadPool) with minimal pgm_base/advanced_config
# stubs; the pool is un-shimmed via a generated bs_thread_pool.hpp (POOL_UNSHIMMED below) so
# tasks run on workers.
TP_REAL = $(S)/threadpool-real
TP_REAL_INC = -std=c++20 \
-I$(TP_REAL)/stubs -I$(POOL_UNSHIMMED) -I$(KICAD_ROOT)/include -I$(KICAD_ROOT)/thirdparty/thread-pool
$(TP_REAL)/threadpool_real_test.o: $(TP_REAL)/threadpool_real_test.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(TP_REAL_INC) $< -o $@
$(TP_REAL)/thread_pool.o: $(KICAD_ROOT)/common/thread_pool.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(TP_REAL_INC) $< -o $@
$(TP_REAL)/kicad_pool_stubs.o: $(TP_REAL)/stubs/kicad_pool_stubs.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(TP_REAL_INC) $< -o $@
$(TP_REAL)/threadpool_real_test.html: $(TP_REAL)/threadpool_real_test.o $(TP_REAL)/thread_pool.o $(TP_REAL)/kicad_pool_stubs.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_REALPOOL) --pre-js $(JS) --shell-file $(HTML) -o $@
threadpool-real: $(TP_REAL)/threadpool_real_test.html
.PHONY: threadpool-real
all: $(TP_REAL)/threadpool_real_test.html
# On-demand non-warm Worker test (Phase 2). The real pool (compiled-in thread_pool.cpp)
# consumes the pre-warmed Workers, then raw fly-threads force on-demand creation;
# wasm/shims/nanosleep_yield.c (a strong nanosleep override; -Wl,--wrap crashes wasm-ld)
# makes the main-thread sleep_for join Asyncify-yield so the on-demand Workers boot.
# Reuses threadpool-real's pool stubs.
OD = $(S)/pthread-ondemand
OD_INC = -std=c++20 \
-I$(S)/threadpool-real/stubs -I$(POOL_UNSHIMMED) -I$(KICAD_ROOT)/include -I$(KICAD_ROOT)/thirdparty/thread-pool
SHIMS = ../../wasm/shims
$(OD)/pthread_ondemand_test.o: $(OD)/pthread_ondemand_test.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(OD_INC) $< -o $@
$(OD)/thread_pool.o: $(KICAD_ROOT)/common/thread_pool.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(OD_INC) $< -o $@
$(OD)/kicad_pool_stubs.o: $(S)/threadpool-real/stubs/kicad_pool_stubs.cpp
$(CXX) -c $(CXXFLAGS) -pthread $(OD_INC) $< -o $@
# Compiled as C (no -std=c++): em++ keys language off the .c extension.
$(OD)/nanosleep_yield.o: $(SHIMS)/nanosleep_yield.c
$(CXX) -c -pthread $< -o $@
$(OD)/pthread_ondemand_test.html: $(OD)/pthread_ondemand_test.o $(OD)/thread_pool.o $(OD)/kicad_pool_stubs.o $(OD)/nanosleep_yield.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_RAYTRACE) --pre-js $(JS) --shell-file $(HTML) -o $@
pthread-ondemand: $(OD)/pthread_ondemand_test.html
.PHONY: pthread-ondemand
all: $(OD)/pthread_ondemand_test.html
# Library-preload native-EH repro (1b). Standalone std::async worker that parses+throws (mode-c)
# and proxies a fetch to main (emscripten_proxy_*) — mirrors the KiCad-10 PCBJAM preload shape.
# No KiCad source. Uses the system proxying queue (built into the pthread runtime).
$(S)/async-preload/async_preload_test.o: $(S)/async-preload/async_preload_test.cpp
$(CXX) -c $(CXXFLAGS) -pthread $< -o $@
$(S)/async-preload/async_preload_test.html: $(S)/async-preload/async_preload_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_RAYTRACE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
async-preload: $(S)/async-preload/async_preload_test.html
.PHONY: async-preload
all: $(S)/async-preload/async_preload_test.html
# A raytracer worker-join run inside a wx modal pump. The pump dispatches the work via
# ProcessEvents (ccall async:true) at Asyncify state==Normal, so both joins complete:
# m=0 sleep_for busy-wait, m=1 emscripten_sleep yield.
$(S)/raytrace-modal/raytrace_modal_test.o: $(S)/raytrace-modal/raytrace_modal_test.cpp
$(CXX) -c $(CXXFLAGS) -pthread $< -o $@
$(S)/raytrace-modal/raytrace_modal_test.html: $(S)/raytrace-modal/raytrace_modal_test.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $< $(LDFLAGS_RAYTRACE) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
raytrace-modal: $(S)/raytrace-modal/raytrace_modal_test.html
.PHONY: raytrace-modal
all: $(S)/raytrace-modal/raytrace_modal_test.html
# Log Error test (no GL) - reproduces KiCad's kiface error dialog
$(S)/logerror/logerror_test.o: $(S)/logerror/logerror_test.cpp
$(CXX) -c $(CXXFLAGS) $< -o $@
@ -635,6 +743,7 @@ $(S)/asyncify-races/races_test_nosleepfix.html: $(S)/asyncify-races/races_test.o
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
SHIM_DISABLE_HANDLESLEEP=1 ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
# Convenience targets
menu: $(S)/menu/menu_test.html
contextmenu: $(S)/contextmenu/contextmenu_test.html
@ -730,7 +839,7 @@ coroutine-pthread: $(S)/coroutine-pthread/coroutine_test.html
.PHONY: coroutine-pthread
# === No-wx pthread main() reproduction: fiber pattern in main + pthreads, no wxWidgets ===
LDFLAGS_COROUTINE_PTHREAD_NOWX = $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
LDFLAGS_COROUTINE_PTHREAD_NOWX = $(EH_FLAGS) $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sASYNCIFY=1 -sASYNCIFY_STACK_SIZE=65536 -sASYNCIFY_IMPORTS=['emscripten_fiber_swap'] \
-sDYNCALLS=1 -pthread -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \
-sPTHREAD_POOL_SIZE_STRICT=0 -sEXPORTED_RUNTIME_METHODS=['ccall']
@ -759,19 +868,19 @@ coroutine-pthread-nested: $(S)/coroutine-pthread/nested_repro.html
.PHONY: coroutine-pthread-nested
# Nested invoke_/dynCall reproduction WITH exceptions (invoke_* boundaries, asyncify-unwindable)
LDFLAGS_COROUTINE_INVOKE = $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
LDFLAGS_COROUTINE_INVOKE = $(EH_FLAGS) $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sASYNCIFY=1 -sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['invoke_vi','invoke_v','invoke_ii','invoke_iii','emscripten_fiber_swap'] \
-sDYNCALLS=1 -fexceptions -pthread -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \
-sDYNCALLS=1 -pthread -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \
-sPTHREAD_POOL_SIZE_STRICT=0 -sEXPORTED_RUNTIME_METHODS=['ccall']
$(S)/coroutine-pthread/nested_repro_ex.o: $(S)/coroutine-pthread/nested_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -fexceptions -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/libcontext_ex.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp $(KICAD_ROOT)/thirdparty/libcontext/libcontext.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -fexceptions -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
$(S)/coroutine-pthread/nested_repro_ex.html: $(S)/coroutine-pthread/nested_repro_ex.o $(S)/coroutine-pthread/libcontext_ex.o
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_INVOKE) -o $@
@ -794,7 +903,7 @@ LDFLAGS_COROUTINE_EMBIND = $(LDFLAGS_COROUTINE_INVOKE) --bind
$(S)/coroutine-pthread/embind_repro.o: $(S)/coroutine-pthread/embind_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -fexceptions -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/embind_repro.html: $(S)/coroutine-pthread/embind_repro.o $(S)/coroutine-pthread/libcontext_ex.o
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_EMBIND) -o $@
@ -816,7 +925,7 @@ coroutine-pthread-mainloop: $(S)/coroutine-pthread/mainloop_repro.html
.PHONY: coroutine-pthread-mainloop
# WebGL2 + coroutine reproduction (no wx, no pthreads, default shell with #canvas)
LDFLAGS_COROUTINE_GL = $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
LDFLAGS_COROUTINE_GL = $(EH_FLAGS) $(DEBUG_LDFLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-sASYNCIFY=1 -sASYNCIFY_STACK_SIZE=65536 -sASYNCIFY_IMPORTS=['emscripten_fiber_swap'] \
-sDYNCALLS=1 -sMAX_WEBGL_VERSION=2 -sMIN_WEBGL_VERSION=2 -sEXPORTED_RUNTIME_METHODS=['ccall']

View file

@ -0,0 +1,296 @@
/**
* async_preload_test.cpp
*
* Standalone repro of KiCad-10's library-preload SHAPE (docs/features/wasm-exceptions/10 §7) —
* NOT using real KiCad/pcbnew. Proves native wasm-EH makes the shape safe.
*
* The shape (mirrors kicad/eeschema/sch_io/pcbjam_lib/sch_io_pcbjam_lib.cpp):
* - a std::async(std::launch::async, …) background worker (NOT the thread pool) that, per
* "library", PROXIES a fetch to the main thread (emscripten_proxy_sync_with_ctx) and then
* PARSES the bytes on the worker — the parse THROWS an IO_ERROR-like exception (the "mode-c"
* trigger: a throw caught ON a worker drives Asyncify under -fexceptions → crash; native
* wasm-EH decouples it → safe).
* - a LAZY join: main keeps the std::future alive and never blocks in normal operation, so it
* stays in its event loop to service the worker's proxied fetches.
* - a g_proxyMutex serializes worker→main proxy round-trips (the real "table index out of
* bounds" reentrancy guard when main is asyncify-suspended in a modal).
*
* (Simplification vs real KiCad: the proxied fetch finishes synchronously in the handler — the real
* one kicks an async JS promise + finishes from its callback. We keep the round-trip + the
* serialization, which is what the reentrancy hazard and the mode-c throw need; the throw is on the
* worker's parse, independent of the fetch being sync/async.)
*
* URL ?m=0 simple | 1 throw (parse throws → caught on worker, no mode-c crash) |
* 2 shutdown (main blocking-joins the future mid-load) | 3 modal (a modal opens while
* workers proxy → g_proxyMutex must prevent a reentrancy crash)
*
* Console contract:
* [PRELOAD] START m=.. [PRELOAD] EH=native|js
* [PRELOAD] SUCCESS m=.. caught=.. loaded=..
*/
#include "wx/wx.h"
#include <atomic>
#include <chrono>
#include <cstdarg>
#include <cstdio>
#include <future>
#include <mutex>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#include <emscripten/proxying.h>
#include <emscripten/threading.h>
#endif
// ---------------------------------------------------------------------------
static void plog( const char* fmt, ... )
{
char buf[512];
va_list ap;
va_start( ap, fmt );
vsnprintf( buf, sizeof( buf ), fmt, ap );
va_end( ap );
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, buf );
#else
printf( "%s\n", buf );
#endif
}
static int readMode()
{
#ifdef __EMSCRIPTEN__
return EM_ASM_INT( {
var raw = location.search ? location.search.slice( 1 ) : location.hash.slice( 1 );
var v = parseInt( new URLSearchParams( raw ).get( 'm' ), 10 );
return isNaN( v ) ? 0 : v;
} );
#else
return 0;
#endif
}
static void mainSleep( int ms )
{
#ifdef __EMSCRIPTEN__
emscripten_sleep( ms ); // Asyncify yield → main's event loop runs (services the proxy queue)
#else
std::this_thread::sleep_for( std::chrono::milliseconds( ms ) );
#endif
}
// ---------------------------------------------------------------------------
// the proxied "fetch": worker → main round-trip (mirrors the PCBJAM bridge)
// ---------------------------------------------------------------------------
static std::mutex g_proxyMutex;
#ifdef __EMSCRIPTEN__
extern "C" EMSCRIPTEN_KEEPALIVE void preload_finish( em_proxying_ctx* ctx )
{
emscripten_proxy_finish( ctx );
}
static void proxy_fetch_on_main( em_proxying_ctx* ctx, void* )
{
// Runs on the MAIN thread via the system proxying queue. Synchronous finish (see header note).
preload_finish( ctx );
}
#endif
static std::string proxyFetchToMain( const std::string& lib )
{
#ifdef __EMSCRIPTEN__
if( emscripten_is_main_runtime_thread() )
return "(mock " + lib + ")"; // main path (not exercised here; the preload runs on a worker)
std::lock_guard<std::mutex> serialize( g_proxyMutex );
em_proxying_queue* q = emscripten_proxy_get_system_queue();
if( !emscripten_proxy_sync_with_ctx( q, emscripten_main_runtime_thread_id(),
proxy_fetch_on_main, nullptr ) )
return "";
#endif
return "(mock " + lib + ")";
}
// ---------------------------------------------------------------------------
class IO_ERROR : public std::runtime_error
{
public:
explicit IO_ERROR( const std::string& m ) : std::runtime_error( m ) {}
};
static std::size_t parseLib( const std::string& data, bool shouldThrow )
{
if( shouldThrow )
throw IO_ERROR( "simulated S-expr parse failure" ); // the mode-c trigger
return data.find( "mock" ) != std::string::npos ? 1u : 0u;
}
// ---------------------------------------------------------------------------
static std::atomic<bool> g_caught{ false };
static std::atomic<int> g_loaded{ 0 };
static std::atomic<bool> g_done{ false };
static std::atomic<bool> g_abort{ false };
static std::future<void> g_future; // kept alive => LAZY join (dtor blocks only at teardown)
static void preloadRun( bool throwOnParse )
{
try
{
std::this_thread::sleep_for( std::chrono::milliseconds( 20 ) ); // worker-side watchdog
for( const char* lib : { "lib_a", "lib_b", "lib_c" } )
{
if( g_abort.load() )
break; // the CancelPreload mitigation: bail before the next proxy
std::string data = proxyFetchToMain( lib ); // proxy the fetch to main
g_loaded.fetch_add( (int) parseLib( data, throwOnParse ) ); // parse ON the worker (throws)
}
}
catch( const std::exception& e )
{
g_caught.store( true );
plog( "[PRELOAD] worker caught: %s", e.what() );
}
g_done.store( true );
}
// ---------------------------------------------------------------------------
// minimal auto-closing modal (timer armed before ShowModal; closes itself)
// ---------------------------------------------------------------------------
static constexpr int ID_MODAL_CLOSE = wxID_HIGHEST + 700;
class AutoCloseDialog : public wxDialog
{
public:
AutoCloseDialog( wxWindow* parent, int delayMs ) :
wxDialog( parent, wxID_ANY, "preload-modal", wxDefaultPosition, wxSize( 280, 120 ) ),
m_timer( this, ID_MODAL_CLOSE )
{
Bind( wxEVT_SHOW, &AutoCloseDialog::OnShow, this );
Bind( wxEVT_TIMER, [this] ( wxTimerEvent& ) { plog( "[PRELOAD] modal: close-timer fired" ); EndModal( wxID_OK ); }, ID_MODAL_CLOSE );
m_delayMs = delayMs;
}
private:
void OnShow( wxShowEvent& e )
{
if( e.IsShown() )
{
plog( "[PRELOAD] modal: shown, arming %dms auto-close", m_delayMs );
m_timer.StartOnce( m_delayMs );
}
e.Skip();
}
wxTimer m_timer;
int m_delayMs = 300;
};
// ---------------------------------------------------------------------------
static constexpr int ID_SCENARIO_TIMER = wxID_HIGHEST + 701;
class PreloadFrame : public wxFrame
{
public:
PreloadFrame() : wxFrame( nullptr, wxID_ANY, "Async Preload Test",
wxDefaultPosition, wxSize( 420, 140 ) ),
m_scenarioTimer( this, ID_SCENARIO_TIMER )
{
wxPanel* p = new wxPanel( this );
wxBoxSizer* s = new wxBoxSizer( wxVERTICAL );
s->Add( new wxStaticText( p, wxID_ANY, "std::async library-preload repro — see console." ),
0, wxALL, 16 );
p->SetSizer( s );
Bind( wxEVT_TIMER, &PreloadFrame::OnScenario, this, ID_SCENARIO_TIMER );
}
void armModalScenario() { m_scenarioTimer.StartOnce( 50 ); }
private:
// Runs INSIDE the main event loop (the modal pump needs that — ShowModal straight from OnInit,
// before the loop starts, hangs). Several workers each do a bounded series of worker->main proxy
// round-trips with a small gap so the modal pump can dispatch its auto-close timer; g_proxyMutex
// serializes them, preventing concurrent C reentry into the asyncify-suspended (modal) main.
void OnScenario( wxTimerEvent& )
{
std::atomic<int> rounds{ 0 };
std::vector<std::thread> workers;
for( int i = 0; i < 3; ++i )
workers.emplace_back( [&rounds]
{
for( int k = 0; k < 12; ++k )
{
proxyFetchToMain( "spam" );
rounds.fetch_add( 1 );
std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) );
}
} );
plog( "[PRELOAD] modal: workers spawned, showing modal" );
AutoCloseDialog dlg( this, 300 );
dlg.ShowModal();
plog( "[PRELOAD] modal: ShowModal returned (rounds=%d)", rounds.load() );
for( auto& t : workers )
t.join();
plog( "[PRELOAD] SUCCESS m=3 caught=0 loaded=0 proxyRounds=%d", rounds.load() );
}
wxTimer m_scenarioTimer;
};
class PreloadApp : public wxApp
{
public:
bool OnInit() override
{
const int mode = readMode();
plog( "[PRELOAD] START m=%d", mode );
#ifdef __WASM_EXCEPTIONS__
plog( "[PRELOAD] EH=native" );
#else
plog( "[PRELOAD] EH=js" );
#endif
g_caught.store( false );
g_loaded.store( 0 );
g_done.store( false );
g_abort.store( false );
PreloadFrame* frame = new PreloadFrame();
frame->Show();
if( mode == 3 )
{
frame->armModalScenario(); // runs in the main loop; logs its own [PRELOAD] SUCCESS m=3
return true;
}
const bool throwOnParse = ( mode == 1 );
g_future = std::async( std::launch::async, [throwOnParse] { preloadRun( throwOnParse ); } );
if( mode == 2 ) // shutdown: blocking-join the future mid-load (synchronous proxies complete
// during the futex busy-wait's queue processing)
{
mainSleep( 30 );
g_abort.store( true );
g_future.wait();
plog( "[PRELOAD] shutdown joined" );
}
else // simple / throw: LAZY join — poll via emscripten_sleep, main stays live
{
for( int i = 0; i < 200 && !g_done.load(); ++i )
mainSleep( 20 );
}
plog( "[PRELOAD] SUCCESS m=%d caught=%d loaded=%d",
mode, g_caught.load() ? 1 : 0, g_loaded.load() );
return true;
}
};
wxIMPLEMENT_APP( PreloadApp );

View file

@ -163,17 +163,29 @@ EM_JS( void, races_mark_done, ( const char* aName ), {
Module.__racesDone[UTF8ToString( aName )] = true;
} );
// Quiescence invariant sampled from C++ between scenarios. NOTE: this runs on a
// stack that may itself have been resumed via Fibers.trampoline(), in which case
// Fibers.trampolineRunning is legitimately true — so the guard is deliberately
// NOT part of this check (a genuinely stuck guard wedges the next fiber swap and
// is caught by the scenario watchdogs instead).
// Quiescence invariant sampled from C++ between scenarios.
//
// Two things are deliberately NOT checked:
// * Fibers.trampolineRunning — this can run on a stack itself resumed via
// Fibers.trampoline(), in which case the guard is legitimately true.
// * Asyncify.currData — under native wasm-EH the top-level event loop is a
// per-frame-yield while-loop (wxWasmYieldToBrowser, an EM_ASYNC_JS rAF
// suspend that re-arms every frame; see wxwidgets/src/wasm/evtloop.cpp). So
// the main stack is asyncify-suspended between frames and currData is
// legitimately churning — it is non-zero while a frame yield is pending, and
// can momentarily hold a freed-but-not-yet-nulled buffer right after a
// concurrent suspension resumes. That is a transient bookkeeping value, NOT a
// leak (the buffers are _malloc/_free'd each frame — addresses are reused),
// so requiring currData==0 here is a stale legacy assumption from the old
// throw-to-park loop. A genuinely stuck suspension is caught by state != 0
// (Suspending/Rewinding never clearing) and by the scenario watchdogs.
// What's left is the real invariant: the asyncify machine is back to Normal and
// no fiber is queued.
EM_JS( int, races_quiescent, (), {
try {
var stOk = ( typeof Asyncify === 'undefined' ) || Asyncify.state === 0;
var cdOk = ( typeof Asyncify === 'undefined' ) || !Asyncify.currData;
var nfOk = ( typeof Fibers === 'undefined' ) || !Fibers.nextFiber;
return ( stOk && cdOk && nfOk ) ? 1 : 0;
return ( stOk && nfOk ) ? 1 : 0;
} catch( e ) {
return 0;
}

View file

@ -0,0 +1,179 @@
/**
* pthread_ondemand_test.cpp
*
* Phase 2: can a NON-warm (on-demand) Worker be spawned WITHOUT modifying KiCad?
*
* Faithful shape of the real raytracer's deadlock: the REAL KiCad pool (compiled-in
* kicad/common/thread_pool.cpp, via GetKiCadThreadPool()) consumes ALL the pre-warmed
* Workers at construction. We then spawn raw std::thread fly-threads BEYOND that count,
* which must be created ON DEMAND. Finalizing an on-demand Worker needs the main event
* loop to run the new-Worker 'loaded'->'run' handshake.
*
* m=0 control : a main-thread busy-wait join that does NOT call nanosleep -> never
* returns to the event loop -> the on-demand Workers never boot ->
* DEADLOCK (workersRan=0; self-recovers after a 12s cap).
* m=1 fix : join via std::this_thread::sleep_for -> nanosleep, which (via
* wasm/shims/nanosleep_yield.c, a strong nanosleep override) Asyncify-yields on
* the main thread -> the event loop services the handshake -> the
* on-demand Workers boot -> multi-core (workersRan>1). No KiCad edit.
*
* The override only affects main-thread nanosleep; a worker keeps a real blocking sleep.
*
* Console contract (the spec asserts on these):
* [ONDEMAND] START m=.. poolThreads=.. extra=.. hwc=..
* [ONDEMAND] SUCCESS m=.. workersRan=.. totalMs=.. (workersRan=0 for m=0, the deadlock)
* [ONDEMAND] DEADLOCK m=0: ...
*/
#include "wx/wx.h"
#include <thread_pool.h> // kicad/include/thread_pool.h -> GetKiCadThreadPool()
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdio>
#include <thread>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
using clk = std::chrono::steady_clock;
static void olog( const char* fmt, ... )
{
char buf[512];
va_list ap;
va_start( ap, fmt );
vsnprintf( buf, sizeof( buf ), fmt, ap );
va_end( ap );
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, buf );
#else
printf( "%s\n", buf );
#endif
}
static int readMode()
{
#ifdef __EMSCRIPTEN__
return EM_ASM_INT( {
var raw = location.search ? location.search.slice( 1 ) : location.hash.slice( 1 );
var v = parseInt( new URLSearchParams( raw ).get( 'm' ), 10 );
return isNaN( v ) ? 1 : v;
} );
#else
return 1;
#endif
}
static long ms( clk::time_point t0 )
{
return (long) std::chrono::duration_cast<std::chrono::milliseconds>( clk::now() - t0 ).count();
}
// shared work: raw fly-threads cooperatively drain a global atomic block counter.
static std::atomic<size_t> g_nextBlock{ 0 };
static std::atomic<size_t> g_threadsFinished{ 0 };
static std::atomic<int> g_workersRan{ 0 };
static std::atomic<double> g_sink{ 0.0 };
static int g_numBlocks = 48;
static long g_iters = 3000000;
static double computeBlock( int b, long iters )
{
double acc = 0.0;
for( long i = 0; i < iters; ++i )
acc += std::sin( (double) ( b * 131 + i ) * 1e-6 ) * std::cos( (double) i * 1e-7 );
return acc;
}
static void workerBody()
{
bool counted = false;
for( size_t b = g_nextBlock.fetch_add( 1 ); b < (size_t) g_numBlocks; b = g_nextBlock.fetch_add( 1 ) )
{
if( !counted )
{
g_workersRan.fetch_add( 1 );
counted = true;
}
g_sink.store( g_sink.load( std::memory_order_relaxed ) + computeBlock( (int) b, g_iters ),
std::memory_order_relaxed );
}
g_threadsFinished.fetch_add( 1 );
}
class OndemandFrame : public wxFrame
{
public:
OndemandFrame() : wxFrame( nullptr, wxID_ANY, "On-demand Worker Test",
wxDefaultPosition, wxSize( 420, 140 ) )
{
wxPanel* p = new wxPanel( this );
wxBoxSizer* s = new wxBoxSizer( wxVERTICAL );
s->Add( new wxStaticText( p, wxID_ANY, "On-demand non-warm Worker test — see console." ),
0, wxALL, 16 );
p->SetSizer( s );
}
};
class OndemandApp : public wxApp
{
public:
bool OnInit() override
{
const int hwc = std::max( 1u, std::thread::hardware_concurrency() );
const int mode = readMode();
const int extra = 4; // raw fly-threads BEYOND the pool -> must be created on demand
// Consume ALL pre-warmed Workers with the REAL KiCad pool — exactly like KiCad does.
// (PTHREAD_POOL_SIZE = hardware_concurrency, the pool takes them all at construction,
// so the `extra` raw threads below force on-demand Worker creation.)
thread_pool& tp = GetKiCadThreadPool();
olog( "[ONDEMAND] START m=%d poolThreads=%u extra=%d hwc=%d",
mode, (unsigned) tp.get_thread_count(), extra, hwc );
g_nextBlock.store( 0 );
g_threadsFinished.store( 0 );
g_workersRan.store( 0 );
auto t0 = clk::now();
for( int i = 0; i < extra; ++i )
std::thread( workerBody ).detach();
if( mode == 0 )
{
// control: busy-wait WITHOUT nanosleep -> never yields -> on-demand never boots.
auto dl = clk::now();
while( g_threadsFinished.load() < (size_t) extra )
{
for( volatile int s = 0; s < 200000; ++s )
; // pure spin: no sleep_for / nanosleep, so it does not hit the override
if( std::chrono::duration_cast<std::chrono::seconds>( clk::now() - dl ).count() >= 12 )
{
olog( "[ONDEMAND] DEADLOCK m=0: on-demand workers never booted within 12s "
"(busy-wait starved the event loop)" );
break;
}
}
}
else
{
// fix: sleep_for -> nanosleep -> (overridden) Asyncify yield -> main pumps the
// new-Worker handshake -> the on-demand Workers boot and run.
while( g_threadsFinished.load() < (size_t) extra )
std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) );
}
olog( "[ONDEMAND] SUCCESS m=%d workersRan=%d totalMs=%ld sink=%.3f",
mode, g_workersRan.load(), ms( t0 ), g_sink.load() );
( new OndemandFrame() )->Show();
return true;
}
};
wxIMPLEMENT_APP( OndemandApp );

View file

@ -0,0 +1,269 @@
/**
* raytrace_modal_test.cpp
*
* A raytracer-style worker-join run inside a wx modal pump, in both join styles.
*
* A pass is dispatched from a wxTimer that fires while a ShowModal() dialog is open. The modal pump
* runs ProcessEvents via ccall(async:true), so the timer handler runs in a fresh managed Asyncify
* context at state == Normal (the handler probes and logs the state). Both join styles complete
* multi-core there:
* ?m=0 busywait : the join is a sleep_for() busy-wait; the pre-warmed pool's workers complete it.
* ?m=1 yield : the join yields via emscripten_sleep; legal at state == Normal, so it suspends
* and resumes normally.
*
* The persistent pool is warmed in OnInit (on the free top-level slot) so the workers are alive
* before the modal opens, which lets the in-modal busy-wait complete without on-demand Worker spawn.
*/
#include "wx/wx.h"
#include <atomic>
#include <chrono>
#include <cmath>
#include <condition_variable>
#include <cstdarg>
#include <cstdio>
#include <mutex>
#include <thread>
#include <vector>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
using clk = std::chrono::steady_clock;
static void rtlog( const char* fmt, ... )
{
char buf[512];
va_list ap;
va_start( ap, fmt );
vsnprintf( buf, sizeof( buf ), fmt, ap );
va_end( ap );
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, buf );
#else
printf( "%s\n", buf );
#endif
}
static int readMode()
{
#ifdef __EMSCRIPTEN__
return EM_ASM_INT( {
var raw = location.search ? location.search.slice( 1 ) : location.hash.slice( 1 );
var v = parseInt( new URLSearchParams( raw ).get( 'm' ), 10 );
return isNaN( v ) ? 0 : v;
} );
#else
return 0;
#endif
}
static long ms( clk::time_point t0 )
{
return (long) std::chrono::duration_cast<std::chrono::milliseconds>( clk::now() - t0 ).count();
}
// ---------------------------------------------------------------------------
// shared raytracer-style work (atomic work-stealing; mirrors render_3d_raytrace_base.cpp)
// ---------------------------------------------------------------------------
static std::atomic<size_t> g_nextBlock{ 0 };
static std::atomic<int> g_workersRan{ 0 };
static std::atomic<double> g_sink{ 0.0 };
static int g_numBlocks = 48;
static long g_iters = 600000;
static double computeBlock( int b, long iters )
{
double acc = 0.0;
for( long i = 0; i < iters; ++i )
acc += std::sin( (double) ( b * 131 + i ) * 1e-6 ) * std::cos( (double) i * 1e-7 );
return acc;
}
static void workerBody()
{
bool counted = false;
for( size_t b = g_nextBlock.fetch_add( 1 ); b < (size_t) g_numBlocks; b = g_nextBlock.fetch_add( 1 ) )
{
if( !counted )
{
g_workersRan.fetch_add( 1 );
counted = true;
}
g_sink.store( g_sink.load( std::memory_order_relaxed ) + computeBlock( (int) b, g_iters ),
std::memory_order_relaxed );
}
}
// ---------------------------------------------------------------------------
// persistent pre-warmed pool (warmed in OnInit, free slot) — so the IN-MODAL busy-wait completes
// rather than deadlocking on on-demand Worker creation.
// ---------------------------------------------------------------------------
struct PersistentPool
{
int n = 0;
std::vector<std::thread> ts;
std::mutex mtx;
std::condition_variable cv;
unsigned long generation = 0;
bool stop = false;
std::atomic<size_t> finished{ 0 };
std::atomic<size_t> started{ 0 };
void start( int n_ )
{
n = n_;
for( int i = 0; i < n; ++i )
ts.emplace_back( [this] { loop(); } );
}
bool ready() const { return started.load() == (size_t) n; }
void loop()
{
started.fetch_add( 1 );
unsigned long seen = 0;
for( ;; )
{
std::unique_lock<std::mutex> lk( mtx );
cv.wait( lk, [&] { return stop || generation != seen; } );
if( stop )
return;
seen = generation;
lk.unlock();
workerBody();
finished.fetch_add( 1 );
}
}
// Called on the MAIN thread, from the modal pump's ProcessEvents (state == Normal).
void runPass( bool yieldJoin )
{
finished.store( 0 );
g_nextBlock.store( 0 );
g_workersRan.store( 0 );
{
std::lock_guard<std::mutex> lk( mtx );
++generation;
}
cv.notify_all();
while( finished.load() < (size_t) n )
{
if( yieldJoin )
#ifdef __EMSCRIPTEN__
emscripten_sleep( 10 ); // Asyncify yield (legal here — state == Normal)
#else
std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) );
#endif
else
std::this_thread::sleep_for( std::chrono::microseconds( 200 ) ); // busy-wait join
}
}
};
static PersistentPool g_pool;
// ---------------------------------------------------------------------------
// modal scaffold (mirrors coroutine-nested/nested_test.cpp's AutoClosingDialog)
// ---------------------------------------------------------------------------
static constexpr int ID_MODAL_CLOSE = wxID_HIGHEST + 800;
static constexpr int ID_SCENARIO = wxID_HIGHEST + 801;
static constexpr int ID_MODAL_WORK = wxID_HIGHEST + 802;
class AutoClosingDialog : public wxDialog
{
public:
AutoClosingDialog( wxWindow* parent ) :
wxDialog( parent, wxID_ANY, "rt-modal", wxDefaultPosition, wxSize( 280, 120 ) ) {}
void EndModalExternal( int code ) { EndModal( code ); }
};
class RtModalFrame : public wxFrame
{
public:
RtModalFrame( int mode ) :
wxFrame( nullptr, wxID_ANY, "Raytrace Modal Test", wxDefaultPosition, wxSize( 420, 140 ) ),
m_mode( mode ),
m_scenarioTimer( this, ID_SCENARIO ),
m_modalWorkTimer( this, ID_MODAL_WORK )
{
wxPanel* p = new wxPanel( this );
wxBoxSizer* s = new wxBoxSizer( wxVERTICAL );
s->Add( new wxStaticText( p, wxID_ANY, "Raytrace-in-modal repro — see console." ), 0, wxALL, 16 );
p->SetSizer( s );
Bind( wxEVT_TIMER, &RtModalFrame::OnScenario, this, ID_SCENARIO );
Bind( wxEVT_TIMER, &RtModalFrame::OnModalWork, this, ID_MODAL_WORK );
}
void armScenario() { m_scenarioTimer.StartOnce( 50 ); }
private:
// In the main loop: arm the modal-work timer BEFORE ShowModal so it fires from within the modal
// pump, then show the modal.
void OnScenario( wxTimerEvent& )
{
AutoClosingDialog* dlg = new AutoClosingDialog( this );
m_activeDialog = dlg;
m_modalWorkTimer.StartOnce( 30 );
rtlog( "[RTPOOL] showing modal (mode=%d) — work runs from the modal pump's ProcessEvents", m_mode );
dlg->ShowModal(); // returns when OnModalWork's pass finishes and closes it
m_activeDialog = nullptr;
rtlog( "[RTPOOL] SUCCESS mode=%d workersRan=%d totalMs=%ld",
m_mode, g_workersRan.load(), ms( m_t0 ) );
dlg->Destroy();
}
// Fires from the modal pump's ProcessEvents — a fresh managed entry at Asyncify state == Normal.
void OnModalWork( wxTimerEvent& )
{
#ifdef __EMSCRIPTEN__
int st = EM_ASM_INT( {
return ( typeof Asyncify !== 'undefined' && Asyncify.state !== undefined ) ? Asyncify.state : -1;
} );
rtlog( "[RTPOOL] modal-work: Asyncify.state=%d (0=Normal,1=Unwinding,2=Rewinding) mode=%d", st, m_mode );
#endif
rtlog( "[RTPOOL] modal-work: running pass from the modal pump (mode=%d)", m_mode );
m_t0 = clk::now();
g_pool.runPass( m_mode == 1 ); // m=1 yields via emscripten_sleep; m=0 busy-waits
rtlog( "[RTPOOL] PASS done workersRan=%d", g_workersRan.load() );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
}
int m_mode;
AutoClosingDialog* m_activeDialog = nullptr;
clk::time_point m_t0;
wxTimer m_scenarioTimer;
wxTimer m_modalWorkTimer;
};
class RtModalApp : public wxApp
{
public:
bool OnInit() override
{
const int mode = readMode();
const int hwc = std::max( 1u, std::thread::hardware_concurrency() );
rtlog( "[RTPOOL] START mode=%d hwc=%d", mode, hwc );
// Warm the persistent pool on the FREE top-level slot (emscripten_sleep is legal here), so
// the in-modal join runs against already-alive workers.
g_pool.start( hwc );
while( !g_pool.ready() )
#ifdef __EMSCRIPTEN__
emscripten_sleep( 5 );
#else
std::this_thread::sleep_for( std::chrono::milliseconds( 5 ) );
#endif
rtlog( "[RTPOOL] pool warmed (%d workers)", hwc );
RtModalFrame* f = new RtModalFrame( mode );
f->Show();
f->armScenario();
return true;
}
};
wxIMPLEMENT_APP( RtModalApp );

View file

@ -0,0 +1,8 @@
#pragma once
// Minimal stub of kicad/include/advanced_config.h. thread_pool.cpp only reads
// ADVANCED_CFG::GetCfg().m_MaximumThreads; 0 => the pool uses hardware_concurrency().
struct ADVANCED_CFG
{
int m_MaximumThreads = 0;
static const ADVANCED_CFG& GetCfg();
};

View file

@ -0,0 +1,4 @@
#pragma once
// Minimal stub of kicad/include/import_export.h — thread_pool.h only uses APIEXPORT.
#define APIEXPORT
#define APIIMPORT

View file

@ -0,0 +1,28 @@
// Definitions for the minimal pgm_base / advanced_config stubs (see ./pgm_base.h,
// ./advanced_config.h). They let kicad/common/thread_pool.cpp compile and link
// standalone while still constructing the REAL BS::priority_thread_pool.
#include <advanced_config.h>
#include <pgm_base.h>
#include <cstdlib>
// No PGM_BASE in this standalone test -> GetKiCadThreadPool() takes the
// `new thread_pool( num_threads )` branch (num_threads=0 -> hardware_concurrency()).
PGM_BASE* PgmOrNull()
{
return nullptr;
}
// Referenced by thread_pool.cpp inside `if( PgmOrNull() )`, which is never taken here;
// must link but is never executed.
thread_pool& PGM_BASE::GetThreadPool()
{
std::abort();
}
static const ADVANCED_CFG g_advancedCfgStub;
const ADVANCED_CFG& ADVANCED_CFG::GetCfg()
{
return g_advancedCfgStub;
}

View file

@ -0,0 +1,12 @@
#pragma once
#include <thread_pool.h> // for the `thread_pool` type returned by GetThreadPool()
// Minimal stub of kicad/include/pgm_base.h. thread_pool.cpp only needs PgmOrNull()
// (stubbed to nullptr so GetKiCadThreadPool() constructs its own real pool) and
// PGM_BASE::GetThreadPool() (referenced in the now-dead branch; never called).
class PGM_BASE
{
public:
thread_pool& GetThreadPool();
};
PGM_BASE* PgmOrNull();

View file

@ -0,0 +1,280 @@
/**
* threadpool_real_test.cpp
*
* The BS-pool-API native-EH test (docs/features/wasm-exceptions/10 §6 #1).
*
* Unlike threadpool_test / raytrace_threads_test (which hand-roll raw std::thread),
* this app drives KiCad's REAL pool: it compiles kicad/common/thread_pool.cpp and
* calls the actual GetKiCadThreadPool() (a BS::priority_thread_pool). The pool's
* detach_task __EMSCRIPTEN__ inline shim is opted OUT here via -DKICAD_WASM_REAL_THREADPOOL
* so tasks actually run on the pool's persistent pthread workers.
*
* WHY this is the decisive test: the real pool is mode-a/b-safe by construction —
* its workers are PERSISTENT (created at pool construction, consuming the pre-warmed
* pool, so no on-demand spawn → no deadlock) and its main-side join is a futex
* busy-wait, not emscripten_sleep (→ no Asyncify nesting). The ONLY native-EH risk is
* mode-c: a task that THROWS on a worker (caught by submit_task's promise wrapper ON the
* worker drives Asyncify under -fexceptions → "func is not a function"). Native wasm-EH
* decouples exceptions from Asyncify and should make it safe. So:
* - modes submit/loop/blocks/detach/fanout/lifecycle prove real multi-core (workersRan>1)
* - mode throw is the mode-c / native-EH proof (red under JS-EH, green under native-EH)
* Green here ⇒ we can drop the detach_task shim for every pool consumer.
*
* Pure-compute tasks only (no JS/DOM/async-IO on a worker) — exactly why the real
* pool's tasks are safe to run off the main thread.
*
* URL: ?m=0 submit | 1 loop | 2 blocks | 3 detach | 4 fanout | 5 lifecycle | 6 throw
* (read from location.search OR the #fragment — serve-handler cleanUrls drops ?query)
*
* Console contract (the Playwright spec asserts on these):
* [POOL] START mode=.. threads=..
* [POOL] SUCCESS mode=.. workersRan=.. totalMs=.. sink=..
* [POOL] SUCCESS mode=6 threw=1 caught=.. workersRan=.. ... (the throw / mode-c proof)
*/
#include "wx/wx.h"
#include <thread_pool.h> // kicad/include/thread_pool.h -> GetKiCadThreadPool() + BS::*
#include <atomic>
#include <chrono>
#include <cmath>
#include <cstdarg>
#include <cstdint>
#include <cstdio>
#include <future>
#include <optional>
#include <stdexcept>
#include <vector>
#ifdef __EMSCRIPTEN__
#include <emscripten/emscripten.h>
#endif
using clk = std::chrono::steady_clock;
// ---------------------------------------------------------------------------
static void plog( const char* fmt, ... )
{
char buf[512];
va_list ap;
va_start( ap, fmt );
vsnprintf( buf, sizeof( buf ), fmt, ap );
va_end( ap );
#ifdef __EMSCRIPTEN__
EM_ASM( { console.log( UTF8ToString( $0 ) ); }, buf );
#else
printf( "%s\n", buf );
#endif
}
static int readMode()
{
#ifdef __EMSCRIPTEN__
return EM_ASM_INT( {
var raw = location.search ? location.search.slice( 1 ) : location.hash.slice( 1 );
var v = parseInt( new URLSearchParams( raw ).get( 'm' ), 10 );
return isNaN( v ) ? 0 : v;
} );
#else
return 0;
#endif
}
static long ms( clk::time_point t0 )
{
return (long) std::chrono::duration_cast<std::chrono::milliseconds>( clk::now() - t0 ).count();
}
// ---------------------------------------------------------------------------
// shared work: pure CPU math; each task records WHICH pool worker ran it so we can
// prove real parallelism (distinct BS::this_thread::get_index() values).
// ---------------------------------------------------------------------------
static std::atomic<uint64_t> g_workerMask{ 0 };
static std::atomic<double> g_sink{ 0.0 };
static void recordWorker()
{
std::optional<std::size_t> idx = BS::this_thread::get_index();
if( idx )
g_workerMask.fetch_or( 1ull << ( *idx & 63 ), std::memory_order_relaxed );
}
static double computeBlock( int b, long iters )
{
double acc = 0.0;
for( long i = 0; i < iters; ++i )
acc += std::sin( (double) ( b * 131 + i ) * 1e-6 ) * std::cos( (double) i * 1e-7 );
return acc;
}
static int workersRan()
{
return __builtin_popcountll( g_workerMask.load() );
}
static constexpr int N = 64;
static constexpr long ITERS = 2000000;
static void addWork( int b, long iters )
{
recordWorker();
g_sink.store( g_sink.load( std::memory_order_relaxed ) + computeBlock( b, iters ),
std::memory_order_relaxed );
}
// ---------------------------------------------------------------------------
class PoolFrame : public wxFrame
{
public:
PoolFrame() : wxFrame( nullptr, wxID_ANY, "Real Thread Pool Test",
wxDefaultPosition, wxSize( 420, 140 ) )
{
wxPanel* p = new wxPanel( this );
wxBoxSizer* s = new wxBoxSizer( wxVERTICAL );
s->Add( new wxStaticText( p, wxID_ANY, "Real GetKiCadThreadPool() test — see console." ),
0, wxALL, 16 );
p->SetSizer( s );
}
};
class PoolApp : public wxApp
{
public:
bool OnInit() override
{
const int mode = readMode();
thread_pool& tp = GetKiCadThreadPool(); // the REAL BS::priority_thread_pool
plog( "[POOL] START mode=%d threads=%u", mode, (unsigned) tp.get_thread_count() );
// Report the exception model so the spec can gate the mode-c (throw-on-worker) test:
// a worker throw is only safe under native wasm-EH (-fwasm-exceptions defines this).
#ifdef __WASM_EXCEPTIONS__
plog( "[POOL] EH=native" );
#else
plog( "[POOL] EH=js" );
#endif
g_workerMask.store( 0 );
g_sink.store( 0.0 );
auto t0 = clk::now();
switch( mode )
{
case 0: // submit_task + vector<future> + 250ms poll loop (the DRC/connectivity idiom)
{
std::vector<std::future<void>> futs;
for( int i = 0; i < N; ++i )
futs.push_back( tp.submit_task( [i] { addWork( i, ITERS ); } ) );
for( auto& f : futs )
while( f.wait_for( std::chrono::milliseconds( 250 ) ) != std::future_status::ready )
;
break;
}
case 1: // submit_loop -> multi_future.wait()
{
BS::multi_future<void> mf = tp.submit_loop( 0, N, [] ( int i ) { addWork( i, ITERS ); } );
mf.wait();
break;
}
case 2: // submit_blocks (each block returns a value)
{
BS::multi_future<double> mf = tp.submit_blocks(
0, N,
[] ( int s, int e )
{
recordWorker();
double a = 0.0;
for( int i = s; i < e; ++i )
a += computeBlock( i, ITERS );
return a;
} );
double total = 0.0;
for( auto& f : mf )
if( f.valid() )
total += f.get();
g_sink.store( g_sink.load() + total );
break;
}
case 3: // detach_task fire-and-forget + tp.wait()
{
for( int i = 0; i < N; ++i )
tp.detach_task( [i] { addWork( i, ITERS ); } );
tp.wait();
break;
}
case 4: // manual multi_future fanned out by get_thread_count() (the renderTracing shape)
{
std::atomic<int> next{ 0 };
BS::multi_future<void> mf;
auto proc = [&next]
{
for( int b = next.fetch_add( 1 ); b < N; b = next.fetch_add( 1 ) )
addWork( b, ITERS );
};
for( std::size_t i = 0; i < tp.get_thread_count(); ++i )
mf.push_back( tp.submit_task( proc ) );
mf.wait();
break;
}
case 5: // lifecycle + features: queries / purge / reset + pause via a pause_thread_pool
{
plog( "[POOL] tasks queued=%zu running=%zu total=%zu threads=%zu",
tp.get_tasks_queued(), tp.get_tasks_running(), tp.get_tasks_total(),
(std::size_t) tp.get_thread_count() );
tp.wait();
tp.purge();
// pause/unpause are compiled OUT on the priority pool, so exercise them on a
// small pause_thread_pool (the +2 PTHREAD_POOL_SIZE headroom pre-warms its workers).
BS::pause_thread_pool pp( 2 );
pp.pause();
for( int i = 0; i < 8; ++i )
pp.detach_task( [] {} );
const bool paused = pp.is_paused();
const std::size_t queued = pp.get_tasks_queued();
pp.unpause();
pp.wait();
plog( "[POOL] pause: is_paused=%d queuedWhilePaused=%zu", paused ? 1 : 0, queued );
// and a real parallel batch so workersRan>1 holds for this mode too
tp.submit_loop( 0, N, [] ( int i ) { addWork( i, ITERS / 4 ); } ).wait();
break;
}
case 6: // throw ON a worker -> rethrow on main. THE mode-c / native-EH proof.
{
int caught = 0;
auto f = tp.submit_task( [] () -> int
{
recordWorker();
throw std::runtime_error( "boom-on-worker" );
return 0;
} );
try
{
(void) f.get();
}
catch( const std::exception& e )
{
caught = 1;
plog( "[POOL] caught on main: %s", e.what() );
}
// and prove workers still run a normal batch after the throw
tp.submit_loop( 0, N, [] ( int i ) { addWork( i, ITERS / 4 ); } ).wait();
plog( "[POOL] SUCCESS mode=6 threw=1 caught=%d workersRan=%d totalMs=%ld sink=%.3f",
caught, workersRan(), ms( t0 ), g_sink.load() );
( new PoolFrame() )->Show();
return true;
}
default:
plog( "[POOL] unknown mode=%d", mode );
break;
}
plog( "[POOL] SUCCESS mode=%d workersRan=%d totalMs=%ld sink=%.3f",
mode, workersRan(), ms( t0 ), g_sink.load() );
( new PoolFrame() )->Show();
return true;
}
};
wxIMPLEMENT_APP( PoolApp );

View file

@ -177,8 +177,28 @@ test.describe('Asyncify races — green targets (full shims)', () => {
});
});
test.describe('Asyncify races — ablation pins (the disease stays reproducible)', () => {
test('no trampoline heal: the park wedges the guard and the post-park swap hangs', async ({
// These two were "ablation pins": they ablated a JS-shim fix (via SHIM_DISABLE_*,
// see tests/apps/Makefile.wasm) and asserted the LEGACY-EH disease came back — a
// stuck Fibers.trampoline guard (trampolineRunning=true) / a clobbered parked-sleep
// buffer. Under native wasm-EH the top loop is a per-frame-yield while-loop with NO
// park-throw (docs/features/wasm-exceptions; native-EH top-loop redesign), so the
// disease's *trigger* is gone at the root: ablating the shim no longer reproduces
// it. (The green "battery" above already runs both scenarios full-shim.)
//
// So they are flipped red->green: each now pins that the native-EH path stays clean
// EVEN with the legacy shim ablated — i.e. the scenario completes with no disease
// signature. That keeps them as the live signal for the open question below.
//
// TODO(research): are these shims still needed AT ALL under native-EH-only? The
// fiber trampoline self-heal (§3c) and the nested-Asyncify handleSleep save/restore
// (§3) in scripts/common/inject-dyncall-shims.sh were written for the legacy
// park-throw model; ablating either no longer breaks the scenario it guarded. If a
// full sweep of the real apps (modals / nested fibers / long sleeps / pthread pool)
// confirms they're dead weight under native-EH, drop the shim injection AND these
// pins. Until then they stay injected (belt-and-suspenders). Tracked in
// tests/README.md "Open tasks".
test.describe('Asyncify races — shim-redundancy pins (native-EH stays clean with the legacy shim ablated)', () => {
test('trampoline-heal ablated: native-EH post-park swap still completes (no stuck guard)', async ({
page,
testLogger,
}) => {
@ -187,39 +207,28 @@ test.describe('Asyncify races — ablation pins (the disease stays reproducible)
);
await tryLoadApp(page, 30000);
// The scenario's JS watchdog fires after 2.5s with a state dump.
await expect
.poll(
() =>
testLogger.consoleLogs.find((l) =>
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
) ?? null,
{ timeout: 30000, message: 'watchdog should fire in the ablated build' }
)
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 45000,
message: 'post-park swap should complete even with the trampoline self-heal ablated',
})
.not.toBeNull();
const watchdog = testLogger.consoleLogs.find((l) =>
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
)!;
// The exact stuck-guard signature traced in docs/features/async/: the park throw
// tore through Fibers.trampoline()'s do/while, leaving the guard true.
expect(watchdog, 'stuck trampoline guard should be visible').toContain(
'trampolineRunning=true'
);
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
// The legacy disease signature must be ABSENT: no stuck-trampoline watchdog dump.
expect(
testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] FAIL post_park_fiber_swap')
l.includes('[ASYNCIFY_RACES] WATCHDOG post_park_fiber_swap')
),
'scenario should be reported FAILED by the watchdog'
).toBe(true);
// And the suite never completes — the swap is stranded forever.
expect(findSummary(testLogger.consoleLogs)).toBeUndefined();
'no stuck-trampoline watchdog should fire under native-EH'
).toBe(false);
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
});
test('no handleSleep fix: fiber swaps clobber the parked sleep buffer', async ({
test('handleSleep ablated: native-EH long parked sleep is not clobbered by a swap', async ({
page,
testLogger,
}) => {
@ -228,32 +237,17 @@ test.describe('Asyncify races — ablation pins (the disease stays reproducible)
);
await tryLoadApp(page, 30000);
// Either the wakeUp crashes (index out of bounds family) or the rewind is
// lost and the watchdog reports the stall — both are the recorded disease.
await expect
.poll(
() => {
const crashed = [...testLogger.errors, ...testLogger.consoleLogs].some(
(l) =>
l.toLowerCase().includes('index out of bounds') ||
l.toLowerCase().includes('indirect call to null') ||
l.toLowerCase().includes('invalid state')
);
const stalled = testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] FAIL long_parked_sleep_clobbered_by_swap')
);
return crashed || stalled ? 'reproduced' : null;
},
{ timeout: 30000, message: 'ablated build should reproduce the clobber bug' }
)
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 45000,
message: 'long parked sleep should resolve even with the handleSleep fix ablated',
})
.not.toBeNull();
// It must NOT have quietly passed.
expect(
testLogger.consoleLogs.some((l) =>
l.includes('[ASYNCIFY_RACES] PASS long_parked_sleep_clobbered_by_swap')
),
'ablated build must not pass the clobber scenario'
).toBe(false);
const { passed, failed } = parseSummary(findSummary(testLogger.consoleLogs)!);
expect(passed).toBe(1);
expect(failed).toBe(0);
expect(crashLines(testLogger), 'no crash signatures in console').toHaveLength(0);
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
});
});

View file

@ -0,0 +1,67 @@
import { test, expect } from './utils/fixtures';
// 1b: library-preload native-EH repro (docs/features/wasm-exceptions/10 §7).
// A STANDALONE std::async worker that parses+THROWS (mode-c) and proxies a fetch to main — the
// KiCad-10 PCBJAM preload shape, with NO KiCad source. Proves native wasm-EH makes the worker-side
// parse-throw safe, and that the proxy round-trip / lazy join / modal-reentrancy all work.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// WebKit skipped for pthread apps (COEP).
const APP = '/standalone/async-preload/async_preload_test.html';
function parse( logs: string[], mode: number ) {
const line = logs.find( l => l.includes( `[PRELOAD] SUCCESS m=${mode}` ) );
if( !line ) return null;
const c = line.match( /caught=(\d+)/ );
const ld = line.match( /loaded=(\d+)/ );
return { caught: c ? +c[1] : -1, loaded: ld ? +ld[1] : -1 };
}
async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string, timeout = 60000 ) {
await expect.poll( () => testLogger.consoleLogs.some( l => l.includes( needle ) ), { timeout } ).toBe( true );
}
// Fatal native-EH / Asyncify failures we must NOT see.
function fatal( testLogger: { errors: string[] } ) {
return testLogger.errors.filter( e => !e.includes( 'favicon' )
&& /invalid state|table index out of bounds|aborted|unreachable|func is not a function/i.test( e ) );
}
test.describe( 'std::async library-preload — native-EH safe (KiCad-10 shape, standalone)', () => {
test( 'm=0 simple: worker proxies + parses, lazy join completes', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=0` );
await waitForLog( testLogger, '[PRELOAD] SUCCESS m=0' );
const r = parse( testLogger.consoleLogs, 0 )!;
expect( r.loaded, 'libraries parsed' ).toBeGreaterThan( 0 );
expect( fatal( testLogger ), 'no fatal errors' ).toHaveLength( 0 );
} );
// The decisive native-EH proof: the parse THROWS on the worker; the worker's try/catch is safe
// under native wasm-EH (mode-c) but crashes under JS-EH (-fexceptions). Gated to native.
test( 'm=1 throw: worker parse-throw is caught (mode-c safe under native-EH)', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=1` );
await waitForLog( testLogger, '[PRELOAD] EH=' );
test.skip( !testLogger.consoleLogs.some( l => l.includes( '[PRELOAD] EH=native' ) ),
'worker throw-on-parse (mode-c) is native-EH-only; JS-EH build skips it' );
await waitForLog( testLogger, '[PRELOAD] SUCCESS m=1' );
const r = parse( testLogger.consoleLogs, 1 )!;
expect( r.caught, 'the worker parse exception was caught' ).toBe( 1 );
expect( fatal( testLogger ), 'no mode-c crash' ).toHaveLength( 0 );
} );
test( 'm=2 shutdown: blocking-join the future mid-load completes', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=2` );
await waitForLog( testLogger, '[PRELOAD] SUCCESS m=2' );
expect( testLogger.consoleLogs.some( l => l.includes( '[PRELOAD] shutdown joined' ) ),
'the blocking join returned (no deadlock)' ).toBe( true );
expect( fatal( testLogger ), 'no crash on the shutdown join' ).toHaveLength( 0 );
} );
test( 'm=3 modal during preload: g_proxyMutex prevents the reentrancy crash', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=3` );
await waitForLog( testLogger, '[PRELOAD] SUCCESS m=3', 90000 );
expect( fatal( testLogger ), 'no table-index-out-of-bounds reentrancy crash' ).toHaveLength( 0 );
} );
} );

View file

@ -0,0 +1,46 @@
import { test, expect } from './utils/fixtures';
// Phase 2: on-demand (NON-warm) pthread Worker creation WITHOUT modifying KiCad.
//
// The real KiCad pool (compiled-in kicad/common/thread_pool.cpp, via GetKiCadThreadPool())
// consumes ALL the pre-warmed Workers at construction; raw fly-threads beyond that count
// must then be created ON DEMAND, whose 'loaded'->'run' handshake needs the main event loop.
// The fix is wasm/shims/nanosleep_yield.c (a strong nanosleep override): the main-thread sleep_for
// join Asyncify-yields so the loop services the handshake and the on-demand Workers boot.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// WebKit is skipped for pthread apps (COEP worker-load limitation; doc 10 §2a).
const APP = '/standalone/pthread-ondemand/pthread_ondemand_test.html';
function parse( logs: string[], mode: number ) {
const line = logs.find( l => l.includes( `[ONDEMAND] SUCCESS m=${mode}` ) );
if( !line ) return null;
const w = line.match( /workersRan=(\d+)/ );
return { workersRan: w ? +w[1] : -1 };
}
async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string, timeout = 60000 ) {
await expect.poll( () => testLogger.consoleLogs.some( l => l.includes( needle ) ), { timeout } ).toBe( true );
}
test.describe( 'On-demand non-warm pthread Worker (real pool drains the pre-warmed pool)', () => {
test( 'fix: nanosleep override yields → on-demand Workers boot → multi-core', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=1` );
await waitForLog( testLogger, '[ONDEMAND] SUCCESS m=1' );
const r = parse( testLogger.consoleLogs, 1 )!;
expect( r.workersRan, 'on-demand Workers boot and run via the yielding join' ).toBeGreaterThan( 1 );
} );
// Negative control: a busy-wait join that never calls nanosleep → never yields → the
// on-demand Workers never boot. Held to the SAME bar (workersRan>1), expected to miss it,
// so it is reported as an EXPECTED failure. If it ever passes, on-demand got fixed another way.
test( 'control: busy-wait (no nanosleep) CANNOT boot on-demand Workers', async ( { page, testLogger } ) => {
test.fail();
await page.goto( `${APP}#m=0`, { waitUntil: 'domcontentloaded' } );
await waitForLog( testLogger, '[ONDEMAND] SUCCESS m=0', 20000 );
const r = parse( testLogger.consoleLogs, 0 )!;
expect( r.workersRan, 'a non-yielding busy-wait cannot create on-demand Workers' ).toBeGreaterThan( 1 );
} );
} );

View file

@ -0,0 +1,48 @@
import { test, expect } from './utils/fixtures';
// A raytracer-style worker-join run inside a wx modal pump. A pass is dispatched from a wxTimer that
// fires while a ShowModal() dialog is open; the modal pump runs ProcessEvents via ccall(async:true),
// so the work runs in a fresh managed Asyncify context at state == Normal. Both join styles complete
// multi-core there:
// m=0 busywait : sleep_for join; the pre-warmed pool completes it.
// m=1 yield : emscripten_sleep join; legal at state == Normal, so it suspends and resumes.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox. WebKit
// skipped for pthread apps (COEP).
const APP = '/standalone/raytrace-modal/raytrace_modal_test.html';
function workersRan( logs: string[] ): number {
const l = logs.find( x => x.includes( '[RTPOOL] SUCCESS' ) );
const m = l?.match( /workersRan=(\d+)/ );
return m ? +m[1] : -1;
}
function abortErrors( testLogger: { errors: string[] } ) {
return testLogger.errors.filter( e => /invalid state:\s*1|Aborted/i.test( e ) );
}
async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string, timeout = 60000 ) {
await expect.poll( () => testLogger.consoleLogs.some( l => l.includes( needle ) ), { timeout } ).toBe( true );
}
test.describe( 'Raytracer worker-join inside a wx modal pump', () => {
test( 'm=0 busywait: pre-warmed pool busy-wait completes inside the modal → multi-core', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=0` );
await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=0' );
expect( workersRan( testLogger.consoleLogs ), 'multi-core inside the modal' ).toBeGreaterThan( 1 );
expect( abortErrors( testLogger ), 'no Asyncify abort' ).toHaveLength( 0 );
} );
// The in-modal work runs in a fresh ProcessEvents entry at Asyncify state == Normal (the app
// probes and logs it), so an emscripten_sleep join is legal and the pass completes multi-core.
test( 'm=1 yield: emscripten_sleep join inside the modal → multi-core', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=1` );
await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=1' );
expect( testLogger.consoleLogs.some( l => /Asyncify\.state=0/.test( l ) ),
'the in-modal work runs at state == Normal (a fresh ProcessEvents entry)' ).toBe( true );
expect( workersRan( testLogger.consoleLogs ), 'the yield-join completes → multi-core' ).toBeGreaterThan( 1 );
expect( abortErrors( testLogger ), 'no Asyncify abort' ).toHaveLength( 0 );
} );
} );

View file

@ -0,0 +1,73 @@
import { test, expect } from './utils/fixtures';
// The BS-pool-API native-EH test (docs/features/wasm-exceptions/10 §6 #1).
//
// Unlike threadpool.spec.ts / coroutine-raytrace.spec.ts (which hand-roll raw std::thread),
// this drives KiCad's REAL pool: the app compiles in kicad/common/thread_pool.cpp and calls
// the actual GetKiCadThreadPool() (a BS::priority_thread_pool), with the detach_task
// __EMSCRIPTEN__ inline shim opted OUT via -DKICAD_WASM_REAL_THREADPOOL so tasks run on the
// pool's persistent pthread workers.
//
// The real pool is mode-a/b-safe by construction (persistent workers -> no on-demand spawn;
// futex busy-wait join -> no Asyncify nesting). The only native-EH risk is mode-c: a task
// that THROWS on a worker (caught by submit_task's promise wrapper ON the worker drives
// Asyncify under -fexceptions). So mode 6 is the decisive native-EH proof; modes 0-5 prove
// real multi-core (workersRan>1) across the API surface. Green => we can drop the shim.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// WebKit is skipped for pthread apps (COEP worker-load limitation; doc 10 §2a).
const APP = '/standalone/threadpool-real/threadpool_real_test.html';
const MODES: { m: number; name: string }[] = [
{ m: 0, name: 'submit_task + vector<future> poll' },
{ m: 1, name: 'submit_loop + multi_future.wait' },
{ m: 2, name: 'submit_blocks (typed returns)' },
{ m: 3, name: 'detach_task + tp.wait()' },
{ m: 4, name: 'manual multi_future fanout by get_thread_count' },
{ m: 5, name: 'lifecycle: get_tasks_*/purge/wait + pause pool' },
];
function parse( logs: string[], mode: number ) {
const line = logs.find( l => l.includes( `[POOL] SUCCESS mode=${mode}` ) );
if( !line ) return null;
const w = line.match( /workersRan=(\d+)/ );
const c = line.match( /caught=(\d+)/ );
return { workersRan: w ? +w[1] : -1, caught: c ? +c[1] : -1 };
}
async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string, timeout = 60000 ) {
await expect.poll( () => testLogger.consoleLogs.some( l => l.includes( needle ) ), { timeout } ).toBe( true );
}
test.describe( 'Real BS::thread_pool (GetKiCadThreadPool) — multi-core under native-EH', () => {
for( const { m, name } of MODES ) {
test( `mode ${m}: ${name} runs multi-core on the real pool`, async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=${m}` );
await waitForLog( testLogger, `[POOL] SUCCESS mode=${m}` );
const r = parse( testLogger.consoleLogs, m )!;
expect( r.workersRan, 'tasks must run on >1 pool worker' ).toBeGreaterThan( 1 );
expect( testLogger.errors.filter( e => !e.includes( 'favicon' ) ), 'no runtime errors' ).toHaveLength( 0 );
} );
}
// mode-c: a task throws ON a worker; submit_task's promise wrapper catches it on the
// worker (drives Asyncify under -fexceptions -> "func is not a function" crash) and
// rethrows on main. Native wasm-EH decouples exceptions from Asyncify, so this must
// complete cleanly. (Red under JS-EH, green under native-EH — the contrast IS the proof.)
test( 'mode 6: throw on a worker is safe under native-EH and rethrows on main', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=6` );
// A worker throw is a mode-c crash under JS-EH and only safe under native wasm-EH, so this
// assertion is native-EH-only. The app reports its EH model early; skip on a JS-EH build
// (builds are always native-EH now, so this never skips) rather than asserting a crash.
await waitForLog( testLogger, '[POOL] EH=' );
test.skip( !testLogger.consoleLogs.some( l => l.includes( '[POOL] EH=native' ) ),
'throw-on-worker (mode-c) is native-EH-only; JS-EH build skips this assertion' );
await waitForLog( testLogger, '[POOL] SUCCESS mode=6' );
const r = parse( testLogger.consoleLogs, 6 )!;
expect( r.caught, 'the worker throw must rethrow + be caught on main' ).toBe( 1 );
expect( r.workersRan, 'workers still run a normal batch after the throw' ).toBeGreaterThan( 1 );
expect( testLogger.errors.filter( e => !e.includes( 'favicon' ) ), 'no mode-c crash' ).toHaveLength( 0 );
} );
} );

View file

@ -266,6 +266,45 @@ test.describe("pcbnew collab bridge — single page", () => {
});
}
// REGRESSION (native-EH vtable-slot skew, task #54): a second consecutive collab apply must also
// take effect. Root cause: the embind TU was compiled without -DDEBUG while the core TU (CMake
// Config=Debug) had it, so a DEBUG-gated virtual (EDA_ITEM::Show) occupied a vtable slot the embind
// didn't account for, shifting every later slot by one. Embind virtual calls in the apply
// (SetWidth/GetPosition/the rebaseline snapshot getters) then read the wrong slot → call_indirect
// signature-mismatch trap, swallowed by the apply coroutine's catch_all → silent loop, so the apply
// never completed. Fixed by building the embind TU with -DDEBUG in Debug builds
// (scripts/kicad/build-kicad-target.sh). RED before the fix (move times out), GREEN after.
test("native-EH: a second consecutive collab apply also takes effect", async ({ page, testLogger }) => {
await bootAndOpen(page, "apply");
const before = await page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1);
const [bx, by] = before.split(",").map(Number);
const moveTo = async (x: number) => {
await page.evaluate(
({ id, x, by }) =>
window.Module.kicadCollabApply(
JSON.stringify({
changed: [{ id, type: "PCB_TRACK", sx: x, sy: by, ex: x + 50_800_000, ey: by, width: 200000 }],
added: [],
removed: [],
}),
),
{ id: SEG1, x, by },
);
await expect
.poll(() => page.evaluate((id) => window.Module.kicadCollabGetPos(id), SEG1), {
timeout: 10000,
intervals: [200],
})
.toBe(`${x},${by}`);
};
await moveTo(bx + 5_000_000); // 1st apply
await moveTo(bx + 8_000_000); // 2nd apply — RED before the -DDEBUG fix (apply mis-dispatches), GREEN after
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// Apply mutates the model headless: kicadOpenFile returns false (the incomplete-project load
// skips some late steps) but the board IS built, so BOARD_COMMIT::Push takes effect. Rendering
// still needs the real app. (Same headless reality as the eeschema apply test.)

View file

@ -39,7 +39,9 @@
"test:coroutine:firefox": "playwright test --config=playwright-coroutine.config.ts --project=firefox",
"test:coroutine:chrome": "playwright test --config=playwright-coroutine.config.ts --project=chromium --headed",
"test:asyncify:firefox": "playwright test --config=playwright-asyncify.config.ts --project=firefox",
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed"
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome"
},
"devDependencies": {
"@playwright/test": "^1.40.0",

View file

@ -70,6 +70,12 @@ export default defineConfig({
permissions: ['clipboard-read', 'clipboard-write'],
},
},
{
// WebKit (Safari's engine) — headless OK on macOS. Project policy: every spec must be
// green in all three engines (Firefox + Chrome + Safari). Run via npm run test:asyncify:safari.
name: 'webkit',
use: { ...devices['Desktop Safari'], viewport: { width: 1280, height: 720 } },
},
],
webServer: {

View file

@ -4,7 +4,8 @@
"source": "**/*",
"headers": [
{ "key": "Cross-Origin-Opener-Policy", "value": "same-origin" },
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" }
{ "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
{ "key": "Cross-Origin-Resource-Policy", "value": "cross-origin" }
]
}
]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Before After
Before After