diff --git a/kicad b/kicad index 09163c0..012d95e 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 09163c0a1a0323045def89bbaca86ae967e08c8f +Subproject commit 012d95ecb4606a94d198a4348e576b4794be8f66 diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index fbc89c0..e357c47 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -186,6 +186,11 @@ // (wxWasmYieldUntil inside the load) find a tracked record and get the // green-copy spill-stack discipline. Embind names live on Module WITHOUT // the underscore prefix, hence the separate installer. + // NOT here: the kicadTestFiberPark* levers. They are emscripten::async() + // (a plain embind call into a suspending body throws on strict-JSPI + // Firefox), but the parker wrap's turnstile queueing would DEFER a + // mid-park poke until the park drains — the exact race the levers exist + // to stage. Their suspensions ride the untracked-anon-record path. PARKER_NAMES: ["kicadOpenFile", "kicadOpenFiles", "kicadLibsReload"], _wrapParkers: function () { var wrapped = 0; @@ -301,6 +306,10 @@ var stranded = this.waits.size; if (stranded) { console.warn("[wx-scheduler] shutdown (" + why + ") stranded:" + stranded); + } else { + // teardown-gate contract (e2e/app-quit.spec.ts, ported from the + // asyncify shim): a clean exit must SAY so on the console + console.log("[wx-scheduler] shutdown (" + why + ") clean"); } this._note("shutdown", why, stranded); }, @@ -680,8 +689,14 @@ rec.dead = true; this._suspended.delete(rec.id); if (this._windowLive === rec) { + // Released from within its own running slice: that slice's wasm is + // executing on this region RIGHT NOW — restoring the enclosing SP + // here would yank the stack out from under it. Just drop the window + // marker; the turnstile moves on when this job ends. (g_current is + // reset C-side by release_fcontext.) + console.warn("[wx-scheduler] quarantine of the LIVE window lc" + id + + " — self-release mid-slice; skipping SP restore"); this._windowLive = null; - if (rec.enclosingSp !== undefined) this._setSp(rec.enclosingSp); } delete this._libctxRecs[id]; this._note("libctxQuarantine", "libctx", rec.id); diff --git a/tests/apps/Makefile.wasm b/tests/apps/Makefile.wasm index d08d4b2..2efe134 100644 --- a/tests/apps/Makefile.wasm +++ b/tests/apps/Makefile.wasm @@ -69,9 +69,14 @@ WX_JSPI_EXPORTS = main,wx_dom_event,wx_dom_mouse,wx_window_close,wx_window_move, # deliberately stay plain. RACES_EXTRA_LDFLAGS = -sJSPI_EXPORTS=$(WX_JSPI_EXPORTS),races_swap_once,races_park_token2,races_wdt_park_b JSPI_SHIM = $(abspath ../../scripts/common/shims/jspi-scheduler.js) +# $stringToNewUTF8: the JSPI rejection path decodes a thrown C++ exception +# through it; without forced inclusion the reference resolves to emscripten's +# throwing stub and the FIRST throwing wx handler aborts the whole runtime +# ("native code called abort()" -> pthread mutex deadlock storm). ASYNC_LDFLAGS = -sJSPI \ -sJSPI_EXPORTS=$(WX_JSPI_EXPORTS) \ -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stackSave','stackRestore']" \ + -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='$$stringToNewUTF8' \ --pre-js $(JSPI_SHIM) ASYNC_CORO_LDFLAGS = $(ASYNC_LDFLAGS) CXXFLAGS += $(EH_FLAGS) @@ -708,7 +713,6 @@ $(S)/async-preload/async_preload_test.o: $(S)/async-preload/async_preload_test.c $(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 @@ -723,7 +727,6 @@ $(S)/raytrace-modal/raytrace_modal_test.o: $(S)/raytrace-modal/raytrace_modal_te $(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 @@ -746,7 +749,6 @@ $(S)/coroutine/libcontext.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp $(S)/coroutine/coroutine_test.html: $(S)/coroutine/coroutine_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB) $(JS_FILES) $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js # Nested coroutine+modal interaction harness - reproduces Asyncify rewind corruption # when fiber swaps happen inside a wxDialog::ShowModal event loop (Issue #9153). @@ -755,9 +757,7 @@ $(S)/coroutine-nested/nested_test.o: $(S)/coroutine-nested/nested_test.cpp $(S)/ $(S)/coroutine-nested/nested_test.html: $(S)/coroutine-nested/nested_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB) $(JS_FILES) $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js # (The trampoline-heal / handleSleep ablation variants — races_test_noheal / # races_test_nosleepfix — were retired at doc 20 D-1 together with the legacy @@ -767,7 +767,6 @@ $(S)/asyncify-races/races_test.o: $(S)/asyncify-races/races_test.cpp $(S)/corout $(S)/asyncify-races/races_test.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB) $(CXX) $^ $(LDFLAGS_RACES) $(RACES_EXTRA_LDFLAGS) --pre-js $(JS) --shell-file $(HTML) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js # Convenience targets @@ -870,15 +869,18 @@ $(S)/coroutine-pthread/libcontext_pt.o: $(KICAD_ROOT)/thirdparty/libcontext/libc $(S)/coroutine-pthread/coroutine_test.html: $(S)/coroutine-pthread/coroutine_test_pt.o $(S)/coroutine-pthread/libcontext_pt.o $(WX_CORE_LIB) $(JS_FILES) $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD) --pre-js $(JS) --shell-file $(HTML) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread: $(S)/coroutine-pthread/coroutine_test.html .PHONY: coroutine-pthread -# === No-wx pthread main() reproduction: fiber pattern in main + pthreads, no wxWidgets === +# === No-wx pthread main() reproduction: coroutine pattern in main + pthreads, no wx === +# JSPI since 2026-08-13: these pages compile the JSPI-only libcontext, so they must +# link the JSPI suspension engine (the old -sASYNCIFY/-sDYNCALLS link produced a +# frankenbuild matching no shipped configuration — instant stack overflow at the +# first yield). 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' \ + -sJSPI -sJSPI_EXPORTS=main,pcbjam_libctx_entry \ + -pthread -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \ -sPTHREAD_POOL_SIZE_STRICT=0 -sEXPORTED_RUNTIME_METHODS=['ccall'] $(S)/coroutine-pthread/main_repro.o: $(S)/coroutine-pthread/main_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h @@ -887,7 +889,6 @@ $(S)/coroutine-pthread/main_repro.o: $(S)/coroutine-pthread/main_repro.cpp $(S)/ $(S)/coroutine-pthread/main_repro.html: $(S)/coroutine-pthread/main_repro.o $(S)/coroutine-pthread/libcontext_pt.o $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-main: $(S)/coroutine-pthread/main_repro.html .PHONY: coroutine-pthread-main @@ -899,16 +900,15 @@ $(S)/coroutine-pthread/nested_repro.o: $(S)/coroutine-pthread/nested_repro.cpp $ $(S)/coroutine-pthread/nested_repro.html: $(S)/coroutine-pthread/nested_repro.o $(S)/coroutine-pthread/libcontext_pt.o $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-nested: $(S)/coroutine-pthread/nested_repro.html .PHONY: coroutine-pthread-nested -# Nested invoke_/dynCall reproduction WITH exceptions (invoke_* boundaries, asyncify-unwindable) +# Nested invoke_/dynCall reproduction WITH exceptions (invoke_* boundaries). +# JSPI since 2026-08-13 — see the NOWX comment above. 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 -pthread -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \ + -sJSPI -sJSPI_EXPORTS=main,pcbjam_libctx_entry \ + -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 @@ -921,7 +921,6 @@ $(S)/coroutine-pthread/libcontext_ex.o: $(KICAD_ROOT)/thirdparty/libcontext/libc $(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 $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-nested-ex: $(S)/coroutine-pthread/nested_repro_ex.html .PHONY: coroutine-pthread-nested-ex @@ -930,7 +929,6 @@ coroutine-pthread-nested-ex: $(S)/coroutine-pthread/nested_repro_ex.html # pthread workers don't crash on wx's module-eval document access. $(S)/coroutine-pthread/coroutine_test_wxpt.html: $(S)/coroutine-pthread/coroutine_test_pt.o $(S)/coroutine-pthread/libcontext_pt.o $(WX_CORE_LIB) $(JS_FILES) $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD) --pre-js $(S)/coroutine-pthread/worker_dom_stub.js --pre-js $(JS) --shell-file $(HTML) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-wx: $(S)/coroutine-pthread/coroutine_test_wxpt.html .PHONY: coroutine-pthread-wx @@ -944,7 +942,6 @@ $(S)/coroutine-pthread/embind_repro.o: $(S)/coroutine-pthread/embind_repro.cpp $ $(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 $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-embind: $(S)/coroutine-pthread/embind_repro.html .PHONY: coroutine-pthread-embind @@ -954,25 +951,25 @@ $(S)/coroutine-pthread/mainloop_repro.o: $(S)/coroutine-pthread/mainloop_repro.c @mkdir -p $(S)/coroutine-pthread $(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@ +# repro_tick is the promising rAF tick export (see mainloop_repro.cpp); a LAST +# -sJSPI_EXPORTS overrides the base list. $(S)/coroutine-pthread/mainloop_repro.html: $(S)/coroutine-pthread/mainloop_repro.o $(S)/coroutine-pthread/libcontext_pt.o - $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js + $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -sJSPI_EXPORTS=main,pcbjam_libctx_entry,repro_tick -o $@ 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 = $(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'] + -sJSPI -sJSPI_EXPORTS=main,pcbjam_libctx_entry \ + -sMAX_WEBGL_VERSION=2 -sMIN_WEBGL_VERSION=2 -sEXPORTED_RUNTIME_METHODS=['ccall'] $(S)/coroutine-pthread/gl_repro.o: $(S)/coroutine-pthread/gl_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h @mkdir -p $(S)/coroutine-pthread $(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@ $(S)/coroutine-pthread/gl_repro.html: $(S)/coroutine-pthread/gl_repro.o $(S)/coroutine/libcontext.o - $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_GL) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js + $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_GL) -sJSPI_EXPORTS=main,pcbjam_libctx_entry,repro_tick -o $@ coroutine-pthread-gl: $(S)/coroutine-pthread/gl_repro.html .PHONY: coroutine-pthread-gl @@ -986,8 +983,7 @@ $(S)/coroutine-pthread/gl_repro_pt.o: $(S)/coroutine-pthread/gl_repro.cpp $(S)/c $(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@ $(S)/coroutine-pthread/gl_repro_pt.html: $(S)/coroutine-pthread/gl_repro_pt.o $(S)/coroutine-pthread/libcontext_pt.o - $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_GL_PTHREAD) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js + $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_GL_PTHREAD) -sJSPI_EXPORTS=main,pcbjam_libctx_entry,repro_tick -o $@ coroutine-pthread-gl-pt: $(S)/coroutine-pthread/gl_repro_pt.html .PHONY: coroutine-pthread-gl-pt @@ -998,7 +994,6 @@ $(S)/coroutine-pthread/vcall_repro.o: $(S)/coroutine-pthread/vcall_repro.cpp $(S $(S)/coroutine-pthread/vcall_repro.html: $(S)/coroutine-pthread/vcall_repro.o $(S)/coroutine-pthread/libcontext_pt.o $(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -o $@ - ../../scripts/common/inject-dyncall-shims.sh $(basename $@).js coroutine-pthread-vcall: $(S)/coroutine-pthread/vcall_repro.html .PHONY: coroutine-pthread-vcall diff --git a/tests/apps/kicad/calculator.html b/tests/apps/kicad/calculator.html index cf6cd7b..87ea5bf 100644 --- a/tests/apps/kicad/calculator.html +++ b/tests/apps/kicad/calculator.html @@ -135,7 +135,7 @@ // NeedsUserInput()==false and the wizard never opens — same as eeschema.html // and pl_editor.html. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/kicad/eeschema.html b/tests/apps/kicad/eeschema.html index 3374022..7c46bd7 100644 --- a/tests/apps/kicad/eeschema.html +++ b/tests/apps/kicad/eeschema.html @@ -129,7 +129,7 @@ // wizard's "use defaults" path — and it never opens. Settings dir matches // PATHS::GetUserSettingsPath() for this build. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/kicad/gerbview-print.html b/tests/apps/kicad/gerbview-print.html index 842db97..f7389aa 100644 --- a/tests/apps/kicad/gerbview-print.html +++ b/tests/apps/kicad/gerbview-print.html @@ -62,7 +62,7 @@ }; // KiCad config dir baked into the WASM build (see boot.js / constants.ts). - var KICAD_CONFIG_DIR = '/home/kicad/.config/kicad/kicad/9.99'; + var KICAD_CONFIG_DIR = '/home/kicad/.config/kicad/kicad/10.0'; // A lightweight subset of the tiny_tapeout demo board: enough layers/drill // files to enable the Print action and populate the dialog's layer list, diff --git a/tests/apps/kicad/gerbview.html b/tests/apps/kicad/gerbview.html index 04439d3..c79cfd8 100644 --- a/tests/apps/kicad/gerbview.html +++ b/tests/apps/kicad/gerbview.html @@ -127,7 +127,7 @@ // NeedsUserInput()==false and the first-run setup wizard never opens — // same as eeschema.html / pl_editor.html. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/kicad/pcbnew-collab.html b/tests/apps/kicad/pcbnew-collab.html index 74d4311..2a32133 100644 --- a/tests/apps/kicad/pcbnew-collab.html +++ b/tests/apps/kicad/pcbnew-collab.html @@ -133,7 +133,7 @@ // pcbnew.spec.ts explicitly exercises the wizard; the collab tests instead // need a clean, wizard-free boot (like eeschema.html), hence this variant. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/kicad/pcbnew.html b/tests/apps/kicad/pcbnew.html index cfffe99..ec1874c 100644 --- a/tests/apps/kicad/pcbnew.html +++ b/tests/apps/kicad/pcbnew.html @@ -132,7 +132,7 @@ // NeedsUserInput()==false and the first-run setup wizard never opens — // same as eeschema.html / pl_editor.html. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/kicad/pl_editor.html b/tests/apps/kicad/pl_editor.html index 9a4e68c..6b448c3 100644 --- a/tests/apps/kicad/pl_editor.html +++ b/tests/apps/kicad/pl_editor.html @@ -130,7 +130,7 @@ // before main() so every provider reports NeedsUserInput()==false and the // wizard never opens — same as eeschema.html and the web app's boot.ts. var seedKicadConfig = function() { - var cfgDir = '/home/kicad/.config/kicad/kicad/9.99'; + var cfgDir = '/home/kicad/.config/kicad/kicad/10.0'; FS.mkdirTree(cfgDir); var writeIfAbsent = function(path, contents) { diff --git a/tests/apps/standalone/coroutine-pthread/gl_repro.cpp b/tests/apps/standalone/coroutine-pthread/gl_repro.cpp index e7463f6..67a8017 100644 --- a/tests/apps/standalone/coroutine-pthread/gl_repro.cpp +++ b/tests/apps/standalone/coroutine-pthread/gl_repro.cpp @@ -3,8 +3,10 @@ // KiCad's GAL renders via WebGL 2.0 in the rAF refresh, and tool coroutines activate // during the same refresh — so the Asyncify unwind/rewind happens MID-RENDER-FRAME with // the GL context current. This probe creates a real WebGL-2.0 context and activates the -// coroutine between GL draw calls inside an emscripten_set_main_loop(rAF) frame, then the -// coroutine yields back -> main rewinds the render frame. +// coroutine between GL draw calls inside the rAF-driven frame, then the coroutine +// yields back and the frame's promising activation suspends/resumes. (JSPI 2026-08-13: +// emscripten_set_main_loop callbacks are plain calls and cannot suspend — the frame is +// driven like the shipped app drives ticks, a JS rAF loop over a promising export.) // // No-wx (single-threaded first; GL+pthreads needs OFFSCREEN proxying — add later if this // passes). Firefox should reach "[REPRO] DONE"; if system Chrome crashes before DONE, the @@ -13,6 +15,7 @@ #include "kicad_coroutine_harness.h" #include +#include #include #include @@ -41,8 +44,13 @@ static void run_coroutine() std::fflush( stdout ); } -static void render_frame() +static bool g_done = false; + +extern "C" EMSCRIPTEN_KEEPALIVE void repro_tick() { + if( g_done ) + return; + ++g_frame; glClearColor( 0.1f, 0.2f, 0.3f, 1.0f ); glClear( GL_COLOR_BUFFER_BIT ); // a real WebGL2 draw call before the coroutine @@ -52,16 +60,24 @@ static void render_frame() std::printf( "[REPRO] frame %d: activating coroutine mid-GL-frame\n", g_frame ); std::fflush( stdout ); - run_coroutine(); // coroutine yields -> Asyncify rewinds the render frame + run_coroutine(); // coroutine yields -> the frame activation suspends + resumes glClearColor( 0.3f, 0.2f, 0.1f, 1.0f ); glClear( GL_COLOR_BUFFER_BIT ); // another GL call after the coroutine resumes std::printf( "[REPRO] DONE\n" ); std::fflush( stdout ); - emscripten_cancel_main_loop(); + g_done = true; } } +// rAF driver over the promising tick export (each tick may suspend mid-frame). +EM_JS( void, install_raf_driver, (), { + const tick = () => { + Promise.resolve( _repro_tick() ).then( () => requestAnimationFrame( tick ) ); + }; + requestAnimationFrame( tick ); +} ); + int main() { EmscriptenWebGLContextAttributes attrs; @@ -73,6 +89,7 @@ int main() std::printf( "[REPRO] start; WebGL2 context=%d\n", (int) g_ctx ); std::fflush( stdout ); - emscripten_set_main_loop( render_frame, 0, 0 ); + install_raf_driver(); + emscripten_exit_with_live_runtime(); // main returns; rAF drives repro_tick return 0; } diff --git a/tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp b/tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp index 55d7d9e..1f48b47 100644 --- a/tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp +++ b/tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp @@ -1,22 +1,26 @@ -// Reproduction probe #4: activate the coroutine from inside an emscripten_set_main_loop -// (requestAnimationFrame) callback — the SINGLE JS->wasm boundary KiCad actually uses -// (rAF -> callUserCallback -> iterFunc -> dynCall_v -> wasm refresh -> tool coroutine). -// The crash trace's "main-refresh ctx=#2" is exactly this. Unlike the EM_JS/embind probes, -// there is NO synchronous JS frame sitting above the coroutine — the coroutine runs in a -// wasm chain below dynCall_v, so the Asyncify rewind re-enters via dynCall_v (like KiCad). +// Reproduction probe #4: activate the coroutine from inside the rAF-driven tick — +// the SINGLE JS->wasm boundary KiCad actually uses. Asyncify-era shape: rAF -> +// callUserCallback -> iterFunc -> dynCall_v -> wasm refresh -> tool coroutine, via +// emscripten_set_main_loop. JSPI (2026-08-13): a plain main-loop callback cannot +// suspend ("SuspendError: trying to suspend without WebAssembly.promising"), and the +// shipped app doesn't use emscripten_set_main_loop anymore — wx drives rAF ticks +// through PROMISING exports (wxWasmTopLevelTick et al). This probe mirrors that: a JS +// rAF driver calls the exported repro_tick(), which is on the target's JSPI_EXPORTS +// list, so the coroutine suspends mid-tick exactly like a tool coroutine mid-refresh. // -// No-wx + pthreads. Firefox should reach "[REPRO] DONE"; if system Chrome crashes before -// DONE, the main-loop/rAF activation is the missing factor. +// No-wx + pthreads. Both engines should reach "[REPRO] DONE". #include "kicad_coroutine_harness.h" #include +#include #include using coroutine_test::TestCoroutine; static int g_frame = 0; +static bool g_done = false; static void run_coroutine() { @@ -26,7 +30,7 @@ static void run_coroutine() self.Yield( 42 ); } ); - bool running = co.Call( 1 ); // unwinds the main-loop callback back to dynCall_v; yields back + bool running = co.Call( 1 ); // suspends the tick's promising activation; yields back std::printf( "[REPRO] after Call: running=%d lastValue=%ld\n", (int) running, (long) co.LastReturnValue() ); std::fflush( stdout ); @@ -36,8 +40,11 @@ static void run_coroutine() std::fflush( stdout ); } -static void main_loop_iter() +extern "C" EMSCRIPTEN_KEEPALIVE void repro_tick() { + if( g_done ) + return; + ++g_frame; std::printf( "[REPRO] main-loop frame %d\n", g_frame ); std::fflush( stdout ); @@ -49,14 +56,27 @@ static void main_loop_iter() run_coroutine(); std::printf( "[REPRO] DONE\n" ); std::fflush( stdout ); - emscripten_cancel_main_loop(); + g_done = true; } } +// rAF driver calling the PROMISING tick export (the glue wraps every +// JSPI_EXPORTS entry with WebAssembly.promising, so each tick may suspend). +EM_JS( void, install_raf_driver, (), { + const tick = () => { + Promise.resolve( _repro_tick() ).then( () => { + if( !Module.__reproDone ) + requestAnimationFrame( tick ); + } ); + }; + requestAnimationFrame( tick ); +} ); + int main() { - std::printf( "[REPRO] start; installing emscripten_set_main_loop (rAF)\n" ); + std::printf( "[REPRO] start; installing rAF driver over the promising tick export\n" ); std::fflush( stdout ); - emscripten_set_main_loop( main_loop_iter, 0, 0 ); // main returns; rAF drives main_loop_iter + install_raf_driver(); + emscripten_exit_with_live_runtime(); // main returns; rAF drives repro_tick return 0; } diff --git a/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp b/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp index 3898f99..45e0c0a 100644 --- a/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp +++ b/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp @@ -50,8 +50,11 @@ struct MiniCoro ~MiniCoro() { - if( m_caller.ctx ) - libcontext::release_fcontext( m_caller.ctx ); + // Mirror of coroutine.h's ownership rule: m_callee.ctx is the one + // record we own. m_caller.ctx is BORROWED (the enterer's record or + // the root, written by jump_fcontext's symmetric protocol) — the old + // release here was the phantom-release bug the JSPI backend turned + // into a live-coroutine kill. if( m_callee.ctx ) libcontext::release_fcontext( m_callee.ctx ); } @@ -168,6 +171,10 @@ EM_JS( int, js_dead_parked, (), { const L = globalThis.__libctxJspi; return L ? L.deadParked : -1; } ); +EM_JS( int, js_ghost_count, (), { + const L = globalThis.__libctxJspi; + return L ? L.ghosts : -1; +} ); EM_JS( void, js_schedule_resume_marker, (), { globalThis.__timerFired = 0; setTimeout( () => { globalThis.__timerFired = 1; }, 10 ); @@ -325,10 +332,20 @@ int main() c.Call(); c.Resume(); // finishes bool resumed = c.Resume( 99 ); // must refuse: m_running false short-circuits - // force a backend-level ghost jump too: + // force a backend-level ghost jump too. Contract update: the ghost + // refusal must return the SENTINEL, never raw -1 — coroutine.h + // dereferences the return unconditionally, and a live COROUTINE CAN + // reach this path (a nested-dispatch partner's record dying + // mid-flight). The old "coroutine.h can't reach the raw -1" premise + // was disproven by the boot-time jumpOut OOB. intptr_t r = libcontext::jump_fcontext( &c.m_caller.ctx, c.m_callee.ctx, 0 ); + auto* sent = reinterpret_cast( r ); + bool sentinelShaped = r != -1 && r != 0 + && sent->type == INVOCATION_ARGS::FROM_ROUTINE + && sent->context == nullptr; report( "resume_after_finish_does_not_reenter", - !resumed && entries == 1 && r == -1 ); + !resumed && entries == 1 && sentinelShaped, + "r=" + std::to_string( (long long) r ) ); } // 9. interleaving multiple coroutines @@ -429,6 +446,95 @@ int main() + " dead=" + std::to_string( deadAfter ) ); } + // 16. release of the RUNNING record is refused (legacy ~CALL_CONTEXT + // phantom-release shape): the coroutine keeps working afterwards + { + int deadBefore = js_dead_parked(); + std::string order; + MiniCoro c( [&]( MiniCoro& me ) { + order += "a"; + // what the old ~CALL_CONTEXT did: release the borrowed handle of + // the coroutine that is executing RIGHT NOW + libcontext::release_fcontext( me.m_callee.ctx ); + order += "b"; + me.Yield( 5 ); // must still park normally + order += "c"; + } ); + c.Call(); + bool parked = c.Running() && c.YieldValue() == 5; + c.Resume(); // must still be resumable (record not killed) + report( "release_of_running_record_refused", + parked && !c.Running() && order == "abc" + && js_dead_parked() == deadBefore, + order + " dead=" + std::to_string( js_dead_parked() ) ); + } + + // 17. release of a record on the ENTERER CHAIN is refused: a child body + // releasing its (running) parent must not kill the parent + { + int deadBefore = js_dead_parked(); + std::string order; + MiniCoro* parentPtr = nullptr; + MiniCoro child( [&]( MiniCoro& me ) { + order += "c1"; + // parent is mid-slice on the enterer chain right now + libcontext::release_fcontext( parentPtr->m_callee.ctx ); + me.Yield(); + order += "c2"; + } ); + MiniCoro parent( [&]( MiniCoro& me ) { + order += "p1"; + child.Call(); + order += "p2"; + me.Yield(); // parent must still park fine + order += "p3"; + child.Resume(); + } ); + parentPtr = &parent; + parent.Call(); + bool mid = order == "p1c1p2" && parent.Running(); + parent.Resume(); // parent record must still be alive + report( "release_of_enterer_chain_refused", + mid && order == "p1c1p2p3c2" && !parent.Running() + && !child.Running() && js_dead_parked() == deadBefore, + order + " dead=" + std::to_string( js_dead_parked() ) ); + } + + // 18. destroy-while-parked quarantines WITHOUT poisoning the world: + // census +1 exactly once (double release idempotent), later jumps at + // the corpse return the sentinel, and fresh coroutines run clean + { + int deadBefore = js_dead_parked(); + int stepsAfterPark = 0; + auto* victim = new MiniCoro( [&]( MiniCoro& me ) { + me.Yield(); + stepsAfterPark++; // must NEVER run + } ); + victim->Call(); + libcontext::fcontext_t corpse = victim->m_callee.ctx; + delete victim; // release while parked mid-body -> quarantine + int deadMid = js_dead_parked(); + libcontext::release_fcontext( corpse ); // idempotent second release + bool alive = libcontext::context_alive( corpse ); + // a stray jump at the corpse must refuse with the sentinel + libcontext::fcontext_t from = nullptr; + intptr_t r = libcontext::jump_fcontext( &from, corpse, 0 ); + auto* sent = reinterpret_cast( r ); + bool sentinelShaped = r != -1 && r != 0 + && sent->type == INVOCATION_ARGS::FROM_ROUTINE; + // the scheduler keeps working: a fresh coroutine full lifecycle + std::string order; + MiniCoro after( [&]( MiniCoro& me ) { order += "x"; me.Yield(); order += "y"; } ); + after.Call(); + after.Resume(); + report( "destroy_while_parked_is_contained", + deadMid == deadBefore + 1 && js_dead_parked() == deadBefore + 1 + && stepsAfterPark == 0 && !alive && sentinelShaped + && order == "xy" && !after.Running(), + "dead=" + std::to_string( js_dead_parked() ) + + " steps=" + std::to_string( stepsAfterPark ) ); + } + std::printf( "[JSPI_CORO] SUMMARY passed=%d failed=%d\n", g_passed, g_failed ); return g_failed == 0 ? 0 : 1; } diff --git a/tests/e2e/coroutine-raytrace-modal.spec.ts b/tests/e2e/coroutine-raytrace-modal.spec.ts index 344e561..066d687 100644 --- a/tests/e2e/coroutine-raytrace-modal.spec.ts +++ b/tests/e2e/coroutine-raytrace-modal.spec.ts @@ -35,13 +35,16 @@ test.describe( 'Raytracer worker-join inside a wx modal pump', () => { 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. + // The in-modal work runs in a fresh ProcessEvents entry, so an emscripten_sleep join is a + // legal scheduler park and the pass completes multi-core. (The asyncify-era `Asyncify.state=0` + // probe log retired with that backend — under JSPI the equivalent invariant is that the + // sleep park suspends cleanly, i.e. SUCCESS is reached with no scheduler anomaly.) 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( testLogger.consoleLogs.filter( l => + /\[wx-scheduler\] (force-clearing stuck window|job tick error)|\[libctx-jspi\] ghost\/refused/.test( l ) ), + 'no scheduler anomaly during the in-modal join' ).toHaveLength( 0 ); expect( workersRan( testLogger.consoleLogs ), 'the yield-join completes → multi-core' ).toBeGreaterThan( 1 ); expect( abortErrors( testLogger ), 'no Asyncify abort' ).toHaveLength( 0 ); } ); diff --git a/tests/gal-regression/wasm/wasm_stubs.cpp b/tests/gal-regression/wasm/wasm_stubs.cpp index a068ad6..f969e8b 100644 --- a/tests/gal-regression/wasm/wasm_stubs.cpp +++ b/tests/gal-regression/wasm/wasm_stubs.cpp @@ -312,6 +312,14 @@ PGM_BASE& Pgm() { return *s_pgmInstance; } +// Newer GAL code probes the program via the nullable accessor +// (graphics_abstraction_layer.h GetSettings path); without this the +// -sERROR_ON_UNDEFINED_SYMBOLS=0 stub aborts with +// "missing function: _Z9PgmOrNullv" during WEBGL_GAL creation. +PGM_BASE* PgmOrNull() { + return &Pgm(); +} + const ADVANCED_CFG& ADVANCED_CFG::GetCfg() { static ADVANCED_CFG instance; return instance; diff --git a/tests/jspi/jspi-coroutine.spec.ts b/tests/jspi/jspi-coroutine.spec.ts index 2cf1152..37b8d61 100644 --- a/tests/jspi/jspi-coroutine.spec.ts +++ b/tests/jspi/jspi-coroutine.spec.ts @@ -5,14 +5,14 @@ import { test, expect } from '../e2e/utils/fixtures'; // (tests/apps/standalone/jspi-coroutine) is a wx-free MiniCoro that mirrors // tool/coroutine.h's protocol exactly — INVOCATION_ARGS, callerStub with the // finish_fcontext hook, jumpIn/jumpOut, CONTINUE_AFTER_ROOT — over the real -// libcontext.cpp. 15 cases: create/run/finish, yield chains, nested +// libcontext.cpp. 18 cases: create/run/finish, yield chains, nested // call-in-call routed by enterer inference, RunMainStack payload propagation, -// ghost-resume refusal (dead tombstones), mid-body release census. +// ghost-resume refusal (dead tombstones, sentinel-shaped), mid-body release census, phantom-release refusal (running record + enterer chain), destroy-while-parked containment. // // Output contract: per-case "[JSPI_CORO] CASE PASS|FAIL" then // "[JSPI_CORO] SUMMARY passed= failed=". -const EXPECTED_PASSES = 15; +const EXPECTED_PASSES = 18; function findSummary(logs: string[]) { return logs.find((l) => l.includes('[JSPI_CORO] SUMMARY')); @@ -31,7 +31,7 @@ function assertSummary(logs: string[]) { } test.describe('JSPI coroutine backend contract battery', () => { - test('single-thread build: 15/15 protocol cases pass', async ({ page, testLogger }) => { + test('single-thread build: 18/18 protocol cases pass', async ({ page, testLogger }) => { await page.goto('/standalone/jspi-coroutine/'); await expect .poll(() => findSummary(testLogger.consoleLogs) ?? null, { @@ -42,7 +42,7 @@ test.describe('JSPI coroutine backend contract battery', () => { assertSummary(testLogger.consoleLogs); }); - test('pthread build: 15/15 protocol cases pass', async ({ page, testLogger }) => { + test('pthread build: 18/18 protocol cases pass', async ({ page, testLogger }) => { await page.goto('/standalone/jspi-coroutine/?pt=1'); await expect .poll(() => findSummary(testLogger.consoleLogs) ?? null, { diff --git a/tests/kicad/coroutine-lifecycle.spec.ts b/tests/kicad/coroutine-lifecycle.spec.ts new file mode 100644 index 0000000..70a1ec4 --- /dev/null +++ b/tests/kicad/coroutine-lifecycle.spec.ts @@ -0,0 +1,193 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "./fixtures"; +import { loadBoard } from "./utils/threed-viewer"; +import { waitForPcbnew } from "./utils/pcbnew-ready"; +import { clickMenuBarItem, clickMenuItemByText } from "../e2e/utils/element-tracker"; + +/** + * JSPI coroutine lifecycle in the REAL editor — successor to + * fiber-resume-park.spec.ts (which pinned the retired Asyncify rewind guard; + * its beacon string `fiber-resume-refused` no longer exists, making its + * engagement assert vacuous). + * + * The prod-shaped gate for the August 2026 ownership bug: coroutine.h's + * ~CALL_CONTEXT released a BORROWED context record (the live enterer of a + * nested dispatch); the JSPI backend read that as destroy-while-parked, + * killed the record, and the poisoned caller slot made PCB_SELECTION_TOOL's + * first Wait() dereference the raw -1 refusal: "coroutine 1 entry REJECTED: + * memory access out of bounds" at boot, then a "[wx-scheduler] job tick + * error" per tool activation and no dialog ever opening. This walks + * boot → board load → Place Footprints → chooser opens → Cancel → chooser + * closes, asserting a zero ghost/deadParked census and no rejection/trap + * lines at every stage. Deterministically RED on the pre-fix build. + * + * (The old spec's mid-park-resume staging lives on as the jspi-coroutine + * standalone harness, 18 protocol cases in both engines — the doc-15 + * refusal, destroy-while-parked containment, and the phantom-release + * refusals are pinned there, against the same libcontext.cpp.) + */ + +const TRAP_SIGNATURE = + /Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds|entry REJECTED|job tick error/; + +interface CoroCensus { + ghosts: number; + deadParked: number; + refusedResumes: number; + quarantines: number; + parkedCoroutines: string[]; +} + +function census(page: Page): Promise { + return page.evaluate(() => { + const L = (globalThis as any).__libctxJspi; + const S = (globalThis as any).__wxScheduler; + const ring: [number, string, string, number][] = S?._ring ?? []; + return { + ghosts: L?.ghosts ?? -1, + deadParked: L?.deadParked ?? -1, + refusedResumes: ring.filter((r) => r[1] === "libctxRefusedResume").length, + quarantines: ring.filter((r) => r[1] === "libctxQuarantine").length, + parkedCoroutines: [...(S?._suspended?.keys?.() ?? [])].filter((k: string) => + String(k).startsWith("lc"), + ), + }; + }); +} + +// The footprint chooser is a wxFrame, not a wxDialog — count top-level frames +// (same detection as footprint-chooser-close.spec.ts). +function frameCount(page: Page): Promise { + return page.evaluate( + () => + (window.wxElementRegistry?.findAll({ visible: true }) ?? []).filter((e) => + /Frame$/.test(e.typeName || ""), + ).length, + ); +} + +// Synthetic emscripten mouse events on the canvas: a Playwright click is +// intercepted by the wx scrollbar overlay (same helper as +// footprint-chooser-close.spec.ts). +async function synthClick(page: Page, x: number, y: number): Promise { + await page.evaluate( + ([cx, cy]) => { + const c = document.querySelector("#canvas") as HTMLCanvasElement; + const opt = (b: number) => ({ + clientX: cx, + clientY: cy, + bubbles: true, + cancelable: true, + view: window, + button: 0, + buttons: b, + }); + c.dispatchEvent(new MouseEvent("mousemove", opt(0))); + c.dispatchEvent(new MouseEvent("mousedown", opt(1))); + c.dispatchEvent(new MouseEvent("mouseup", opt(0))); + c.dispatchEvent(new MouseEvent("click", opt(0))); + }, + [x, y], + ); +} + +test.describe("JSPI coroutine lifecycle (prod-shaped)", () => { + test("boot, board load and tool activation stay coroutine-clean; chooser opens and cancels", async ({ + page, + testLogger, + }) => { + test.setTimeout(240000); + await page.goto("/kicad/pcbnew.html"); + await waitForPcbnew(page); + + const atBoot = await census(page); + console.log(`[TEST] boot census: ${JSON.stringify(atBoot)}`); + expect(atBoot.ghosts, "no ghost/refused transitions at boot").toBe(0); + expect(atBoot.deadParked, "no coroutine died parked at boot").toBe(0); + expect( + atBoot.parkedCoroutines.length, + "the always-on selection tool is parked in Wait()", + ).toBeGreaterThan(0); + + // The board load is the historical trigger surface (SetBoard → + // ResetTools(MODEL_RELOAD) ×2 → wake/dispatch storm over the parked + // selection tool — where the phantom release fired). + await loadBoard(page, testLogger); + + const afterLoad = await census(page); + console.log(`[TEST] after-load census: ${JSON.stringify(afterLoad)}`); + expect(afterLoad.ghosts, "no ghost transitions across the board load").toBe(0); + expect(afterLoad.deadParked, "no coroutine died across the board load").toBe(0); + + // Place Footprints via the menu, then a canvas click opens the chooser + // (the pre-fix build swallowed the activation: no dialog, job tick OOB). + const framesBefore = await frameCount(page); + expect(await clickMenuBarItem(page, "Place"), "Place menu findable").toBe(true); + await clickMenuItemByText(page, "Place Footprints"); + const canvas = (await page.locator("#canvas").boundingBox())!; + await synthClick(page, Math.round(canvas.width * 0.35), Math.round(canvas.height * 0.45)); + + await expect + .poll(() => frameCount(page), { timeout: 40000, intervals: [200] }) + .toBeGreaterThan(framesBefore); + + const chooserOpen = await census(page); + console.log(`[TEST] chooser-open census: ${JSON.stringify(chooserOpen)}`); + expect(chooserOpen.ghosts, "no ghost transitions opening the chooser").toBe(0); + expect(chooserOpen.deadParked, "no coroutine died opening the chooser").toBe(0); + + // Cancel the quasimodal. NOTE: in the harness the chooser's library + // enumeration stays parked on its `fp-lib` bridge wait forever (the page + // installs no `window.kicadLibs` provider), so the CLOSE itself is not + // assertable here — the same reason footprint-chooser-close.spec.ts + // soft-waits it. The live app (with a provider) closes on Cancel — probed + // as part of the 2026-08-13 fix verification. What IS assertable, and + // what the pre-fix build fails: the census stays clean and the app stays + // responsive across the whole exercise. + const cancel = await page.evaluate(() => { + const btn = (window.wxElementRegistry?.findAll({ visible: true }) ?? []).find( + (e) => /Button/i.test(e.typeName || "") && /cancel/i.test(e.label || ""), + ); + return btn ? { x: btn.centerX, y: btn.centerY } : null; + }); + expect(cancel, "Cancel button found in the chooser").not.toBeNull(); + await synthClick(page, cancel!.x, cancel!.y); + + await expect + .poll(() => frameCount(page), { timeout: 15000, intervals: [200] }) + .toBe(framesBefore) + .catch(() => + console.log( + "[TEST] chooser close not observable without a lib provider (fp-lib park) — soft", + ), + ); + + // Responsiveness after the cancel: a wx timer still gets delivered (the + // doc-19 dead-app class froze the loop here). + const firedBefore = await page.evaluate(() => { + try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; } + catch { return -1; } + }); + await page.evaluate(() => (window as any).Module.kicadTestArmTimerPark(30, 0)); + await expect + .poll( + () => + page.evaluate(() => { + try { return JSON.parse((window as any).Module.kicadTestTimerParkState()).fired as number; } + catch { return -1; } + }), + { timeout: 10000, intervals: [100] }, + ) + .toBeGreaterThan(firedBefore); + + const afterCancel = await census(page); + console.log(`[TEST] after-cancel census: ${JSON.stringify(afterCancel)}`); + expect(afterCancel.ghosts, "cancel left no ghost transitions").toBe(0); + expect(afterCancel.deadParked, "cancel killed no coroutine").toBe(0); + + const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => + TRAP_SIGNATURE.test(l), + ); + expect(trapLines, "no trap/rejection signature anywhere in the run").toEqual([]); + }); +}); diff --git a/tests/kicad/fiber-resume-park.spec.ts b/tests/kicad/fiber-resume-park.spec.ts deleted file mode 100644 index 24658c0..0000000 --- a/tests/kicad/fiber-resume-park.spec.ts +++ /dev/null @@ -1,226 +0,0 @@ -import type { Page } from "@playwright/test"; -import { test, expect } from "./fixtures"; - -/** - * Resume-into-asyncify-parked-coroutine repro — the DECODED production - * board-load trap (docs/features/async/15-timer-park-repro.md round 3). - * - * A coroutine suspended by a real yield has valid rewind data in its fiber - * struct; one whose body is asyncify-parked inside handleSleep does NOT. - * TOOL_MANAGER cannot tell the difference, so an event arriving during the - * park Resume()s it → the swap rewinds the STALE suspension → - * finishContextSwitch → doRewind → "unreachable executed", and the runtime is - * poisoned ("index out of bounds" from every later entry). The - * kicadTestFiberPark* levers (wasm/bindings/fiber_park.h) stage exactly that - * state machine: - * - * start(parkMs) Call + first KiYield — valid suspension primed (phase 1) - * prime() legitimate Resume; body parks in emscripten_sleep (phase 2) - * poke() Resume DURING the park — the fatal prod operation - * - * This spec asserts the HEALTHY contract: the mid-park poke must be refused - * (null-INVOCATION_ARGS ghost contract), the body must complete its park and - * yield again undisturbed, a post-yield poke must resume it for real, and no - * trap signature may appear anywhere. On a runtime without the libcontext - * guard this is deterministically RED with the prod signature. - */ - -const TRAP_SIGNATURE = - /Aborted\(|index out of bounds|unreachable executed|indirect call signature|null function or function signature|memory access out of bounds/; - -type Mod = { - kicadTestFiberParkStart(parkMs: number): boolean; - kicadTestFiberParkPrime(): boolean; - kicadTestFiberParkPoke(): boolean; - kicadTestFiberParkState(): string; - kicadTestFiberParkStartSecond(): boolean; - kicadTestFiberParkPokeSecond(): boolean; - kicadCollabSnapshotItems(): string; -}; - -interface ParkState { - phase: number; - pokes: number; - parkMs: number; - running: boolean; - phase2: number; -} - -async function bootHarness(page: Page): Promise { - await page.goto("/kicad/pcbnew-collab.html"); - await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 }); - await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); - await page.waitForFunction( - () => { - const m = (window as unknown as { Module?: Partial }).Module; - return ( - typeof m?.kicadTestFiberParkStart === "function" && - typeof m?.kicadCollabSnapshotItems === "function" - ); - }, - null, - { timeout: 90000 }, - ); - await page.waitForFunction( - () => - !!window.wxElementRegistry && - window.wxElementRegistry - .findAll({ visible: true }) - .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), - null, - { timeout: 90000 }, - ); -} - -function parkState(page: Page): Promise { - return page.evaluate(() => - JSON.parse((window.Module as unknown as Mod).kicadTestFiberParkState()), - ); -} - -test.describe("Resume() into an asyncify-parked coroutine (libcontext guard)", () => { - test("mid-park resume is refused; the parked body completes undisturbed", async ({ - page, - testLogger, - }) => { - test.setTimeout(240000); - await bootHarness(page); - - // Phase 1: prime a real suspension (Call + first KiYield). NOTE: embind - // return values are asyncify unwind PLACEHOLDERS for anything that - // crosses a fiber swap (the real return lands in the discarded ghost - // rewind) — every assertion here is on polled state, never on returns. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkStart(2000); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] }) - .toBe(1); - - // Phase 2: legitimate resume; the body enters its 2s asyncify park. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPrime(); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] }) - .toBe(2); - - // THE PROD OPERATION: resume while the body is parked. On an unguarded - // runtime this rewinds the stale fiber suspension and traps right here - // (the page's uncaught "unreachable executed"); with the guard it is a - // clean no-op refusal. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPoke(); - }); - const afterPoke = await parkState(page); - console.log(`[TEST] mid-park poke: ${JSON.stringify(afterPoke)}`); - expect(afterPoke.pokes, "poke reached the coroutine layer").toBe(1); - expect(afterPoke.phase, "refused poke left the parked body undisturbed").toBe(2); - - // The park must complete on its own wake and yield again (phase 3) — - // if the poke corrupted the fiber, the wake rewind dies instead. - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 15000, intervals: [100] }) - .toBe(3); - - // A post-yield poke is a LEGITIMATE resume and must work (the guard must - // not refuse valid suspensions): body runs to completion. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPoke(); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [100] }) - .toBe(4); - - // Runtime integrity: a model walk still works and no trap signature - // appeared anywhere in the console. - const snapshot = await page.evaluate(() => - (window.Module as unknown as Mod).kicadCollabSnapshotItems(), - ); - expect(typeof snapshot, "snapshot entry still functional").toBe("string"); - const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => - TRAP_SIGNATURE.test(l), - ); - expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]); - }); - - test("poisoned attribution: a second coroutine launders the parked fiber; the JS guard still quarantines it", async ({ - page, - testLogger, - }) => { - test.setTimeout(240000); - await bootHarness(page); - - // Prime + park the first coroutine (same staging as scenario 1). - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkStart(2500); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] }) - .toBe(1); - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPrime(); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [50] }) - .toBe(2); - - // THE LAUNDERING: start a second coroutine while the first is parked. - // libcontext attributes this jump's old side to the PARKED fiber - // (g_current_context is stale) — writing a fresh suspension into its - // struct and re-marking it swap_suspended, exactly how the prod resume - // bypassed the C++ guard on v0.1.21. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkStartSecond(); - }); - await expect - .poll(async () => (await parkState(page)).phase2, { timeout: 10000, intervals: [50] }) - .toBe(1); - - // The fatal prod operation, now with the C++ guard blinded. The JS - // stale-rewind guard must refuse it (quarantine beacon) instead of - // rewinding foreign/stale data. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPoke(); - }); - const afterPoke = await parkState(page); - console.log(`[TEST] laundered mid-park poke: ${JSON.stringify(afterPoke)}`); - expect(afterPoke.phase, "quarantined poke left the parked body undisturbed").toBe(2); - - // The park must still complete on its own wake and yield again. - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 15000, intervals: [100] }) - .toBe(3); - - // Post-yield resume is legitimate again and completes the first body. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPoke(); - }); - await expect - .poll(async () => (await parkState(page)).phase, { timeout: 10000, intervals: [100] }) - .toBe(4); - - // The second coroutine also completes cleanly. - await page.evaluate(() => { - (window.Module as unknown as Mod).kicadTestFiberParkPokeSecond(); - }); - await expect - .poll(async () => (await parkState(page)).phase2, { timeout: 10000, intervals: [100] }) - .toBe(2); - - // Window-engagement proof: the JS guard must have actually refused the - // laundered resume — silence means the scenario never bypassed the C++ - // guard and the test is vacuous. - const refusals = testLogger.consoleLogs.filter((l) => - l.includes("fiber-resume-refused"), - ); - console.log(`[TEST] refusal beacons: ${refusals.length}`); - for (const l of refusals.slice(0, 4)) console.log(`[TEST] ${l}`); - expect(refusals.length, "the stale-rewind guard intercepted the laundered resume").toBeGreaterThan(0); - - const trapLines = [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => - TRAP_SIGNATURE.test(l), - ); - expect(trapLines, "no wasm trap signature anywhere in the run").toEqual([]); - }); -}); diff --git a/tests/kicad/footprint-chooser-close.spec.ts b/tests/kicad/footprint-chooser-close.spec.ts index 06f99c9..323d191 100644 --- a/tests/kicad/footprint-chooser-close.spec.ts +++ b/tests/kicad/footprint-chooser-close.spec.ts @@ -58,20 +58,36 @@ async function synthClick(page: Page, x: number, y: number): Promise { } async function assertResponsive(page: Page, label: string): Promise { - const before = await page.evaluate( - () => Number((window.__wxAsyncifyDump?.().match(/fcsTotal=(\d+)/) || [])[1] || 0), - ); - await page.waitForTimeout(1500); // eslint-disable-line -- sampling the loop counter across a fixed window - const after = await page.evaluate( - () => Number((window.__wxAsyncifyDump?.().match(/fcsTotal=(\d+)/) || [])[1] || 0), - ); - if (after === before) { - const dump = await page.evaluate(() => - typeof window.__wxAsyncifyDump === 'function' ? window.__wxAsyncifyDump() : 'no dump', - ); - console.log(`[TEST] RECORDER after ${label} (loop STALLED, fcs=${after}):\n${dump}`); + // JSPI liveness metric: a wx timer firing IS the doc-19 liveness semantic + // (the dead-app class = the event loop stops delivering). Arm the + // parking-timer lever with parkMs=0 (fires, no park; `fired` is a + // cumulative counter, so re-arming per call is fine) and poll for the + // increment. Engine-neutral, unlike scheduler wait counters, which sit + // still on an idle Firefox loop. (The asyncify-era fcsTotal retired with + // that scheduler.) + const fired = () => + page.evaluate(() => { + const m = (window as any).Module; + try { return JSON.parse(m.kicadTestTimerParkState()).fired as number; } + catch { return -1; } + }); + const before = await fired(); + await page.evaluate(() => (window as any).Module.kicadTestArmTimerPark(30, 0)); + let after = before; + for (let i = 0; i < 40 && after <= before; i++) { + await page.waitForTimeout(100); // eslint-disable-line -- polling the timer heartbeat + after = await fired(); } - expect(after, `main loop still advancing after ${label}`).toBeGreaterThan(before); + if (after <= before) { + const dump = await page.evaluate(() => { + const w = window as any; + return typeof w.__wxWaitDump === 'function' + ? JSON.stringify(w.__wxWaitDump()) + : 'no dump'; + }); + console.log(`[TEST] RECORDER after ${label} (loop STALLED, fired=${after}):\n${dump}`); + } + expect(after, `wx timer still delivered after ${label}`).toBeGreaterThan(before); } test.describe('Add Footprint chooser close (doc-19 dead-app repro)', () => { diff --git a/tests/kicad/quasimodal-strand.spec.ts b/tests/kicad/quasimodal-strand.spec.ts index dd11c90..1b8d390 100644 --- a/tests/kicad/quasimodal-strand.spec.ts +++ b/tests/kicad/quasimodal-strand.spec.ts @@ -298,21 +298,22 @@ test.describe("quasi-modal strand (doc 19)", () => { expect(await dialogCount(page)).toBeGreaterThan(0); await okButtonCenter(page); - // RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08). The - // overlap this staging proved (a timer park on top of an open-ended park) - // required an in-place park for the timer to land on; post-flip every - // party is a scheduler context and D5 removed the main loop's in-place - // park, so the window is structurally impossible. The staging now pins - // the post-migration invariant: the dialog opens, the timer fires and its - // park survives (asserted above), and NO concurrent-park window is - // observable. + // RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08), and + // RE-KEYED for JSPI (2026-08-13): the `[wx-asyncify] concurrent-park|…` + // beacons retired with the asyncify scheduler, which made the old filter + // vacuous. The post-migration invariant is the same — the dialog opens, + // the timer fires and its park survives (asserted above) — and the + // observable JSPI failure modes of an overlap are ghost/refused + // transitions, a stuck-window force-clear, or a job-tick trap. const overlapBeacons = testLogger.consoleLogs.filter((l) => - /\[wx-asyncify\] (concurrent-park|aliased-wake-live|overlapped-wake)/.test(l), + /\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)/.test( + l, + ), ); console.log(`[STRAND] staging overlap beacons: ${overlapBeacons.length}`); expect( overlapBeacons.length, - "no concurrent-park window is observable post-flip", + "no ghost/stuck-window/job-tick anomaly is observable post-flip", ).toBe(0); }); diff --git a/tests/kicad/utils/ngspice-service.ts b/tests/kicad/utils/ngspice-service.ts index 2daee64..9b646d4 100644 --- a/tests/kicad/utils/ngspice-service.ts +++ b/tests/kicad/utils/ngspice-service.ts @@ -82,12 +82,32 @@ export async function installNgspiceServiceStub(page: Page): Promise { workerP = null; try { worker.terminate(); } catch { /* already gone */ } }; + // Legible boot: bound the handshake and surface worker + // death — the bare version hung to the spec timeout with + // zero evidence (occ-service.ts has the same guard). await new Promise((resolve, reject) => { + const fail = (msg: string) => { + clearTimeout(timer); + reject(new Error(msg)); + }; + const timer = setTimeout( + () => fail('[TEST-NGSPICE] ngspice_service boot timed out after ' + + '60s (no ready/bootError from the worker)'), 60000); const onFirst = (e: MessageEvent) => { - if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); } - else if (e.data?.bootError) reject(new Error(e.data.bootError)); + if (e.data?.ready) { + worker.removeEventListener('message', onFirst); + clearTimeout(timer); + resolve(); + } else if (e.data?.bootError) { + fail(`[TEST-NGSPICE] ngspice_service bootError: ${e.data.bootError}`); + } }; worker.addEventListener('message', onFirst); + worker.addEventListener('error', (e: any) => fail( + `[TEST-NGSPICE] ngspice_service worker error: ${e?.message ?? e} ` + + `(${e?.filename ?? '?'}:${e?.lineno ?? '?'})`)); + worker.addEventListener('messageerror', () => fail( + '[TEST-NGSPICE] ngspice_service worker messageerror (structured clone failed)')); }); console.log('[TEST-NGSPICE] ngspice_service ready'); return worker; diff --git a/tests/kicad/utils/occ-service.ts b/tests/kicad/utils/occ-service.ts index 2686e0d..bdc027a 100644 --- a/tests/kicad/utils/occ-service.ts +++ b/tests/kicad/utils/occ-service.ts @@ -63,12 +63,34 @@ export async function installOccServiceStub(page: Page): Promise { const resolve = pending.get(id); if (resolve) { pending.delete(id); resolve(res); } }; + // Legible boot: the old handshake could never reject on a + // worker DEATH (importScripts throw, pthread spawn wedge, + // OOM-kill) — the promise just hung until the spec's 180s + // timeout with zero evidence. Surface worker errors and + // bound the boot. await new Promise((resolve, reject) => { + const fail = (msg: string) => { + clearTimeout(timer); + reject(new Error(msg)); + }; + const timer = setTimeout( + () => fail('[TEST-OCC] occ_service boot timed out after 60s ' + + '(no ready/bootError from the worker)'), 60000); const onFirst = (e: MessageEvent) => { - if (e.data?.ready) { worker.removeEventListener('message', onFirst); resolve(); } - else if (e.data?.bootError) reject(new Error(e.data.bootError)); + if (e.data?.ready) { + worker.removeEventListener('message', onFirst); + clearTimeout(timer); + resolve(); + } else if (e.data?.bootError) { + fail(`[TEST-OCC] occ_service bootError: ${e.data.bootError}`); + } }; worker.addEventListener('message', onFirst); + worker.addEventListener('error', (e: any) => fail( + `[TEST-OCC] occ_service worker error: ${e?.message ?? e} ` + + `(${e?.filename ?? '?'}:${e?.lineno ?? '?'})`)); + worker.addEventListener('messageerror', () => fail( + '[TEST-OCC] occ_service worker messageerror (structured clone failed)')); }); console.log('[TEST-OCC] occ_service ready'); return worker; diff --git a/tests/kicad/utils/wait-beacons.ts b/tests/kicad/utils/wait-beacons.ts index 51e3c35..2cc9421 100644 --- a/tests/kicad/utils/wait-beacons.ts +++ b/tests/kicad/utils/wait-beacons.ts @@ -51,7 +51,7 @@ const FAMILY_PATTERNS: Record< openSettleFailed: /\[open\] load chain never settled/, // JSPI-era families (jspi-scheduler.js + libcontext's JSPI backend): wxScheduler: - /\[wx-scheduler\] (force-clearing stuck window|mailbox tick error|untracked promising entry|activation stack imbalance|resume window misnested)/, + /\[wx-scheduler\] (force-clearing stuck window|job tick error|untracked promising entry|activation stack imbalance|resume window misnested)/, libctxJspi: /\[libctx-jspi\] ghost\/refused/, }; diff --git a/tests/package-lock.json b/tests/package-lock.json index 2123e21..78904f8 100644 --- a/tests/package-lock.json +++ b/tests/package-lock.json @@ -8,12 +8,13 @@ "name": "kicad-wasm-tests", "version": "1.0.0", "devDependencies": { - "@playwright/test": "^1.40.0", + "@playwright/test": "^1.62.1", "@types/node": "^24.10.1", "@types/pixelmatch": "^5.2.6", "@types/pngjs": "^6.0.5", "esbuild": "^0.28.0", "pixelmatch": "^5.3.0", + "playwright": "^1.62.1", "pngjs": "^7.0.0", "serve": "^14.2.0", "tsx": "^4.22.4", @@ -464,19 +465,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", - "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz", + "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.57.0" + "playwright": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@types/node": { @@ -1334,35 +1335,35 @@ } }, "node_modules/playwright": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", - "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz", + "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.57.0" + "playwright-core": "1.62.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" }, "optionalDependencies": { "fsevents": "2.3.2" } }, "node_modules/playwright-core": { - "version": "1.57.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", - "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/pngjs": { diff --git a/tests/package.json b/tests/package.json index 932da97..f67bd9b 100644 --- a/tests/package.json +++ b/tests/package.json @@ -32,12 +32,13 @@ "tools:contract": "tsx tools/cli-contract.ts" }, "devDependencies": { - "@playwright/test": "^1.40.0", + "@playwright/test": "^1.62.1", "@types/node": "^24.10.1", "@types/pixelmatch": "^5.2.6", "@types/pngjs": "^6.0.5", "esbuild": "^0.28.0", "pixelmatch": "^5.3.0", + "playwright": "^1.62.1", "pngjs": "^7.0.0", "serve": "^14.2.0", "tsx": "^4.22.4", diff --git a/tests/playwright-web.config.ts b/tests/playwright-web.config.ts index 9d94cd1..29c9699 100644 --- a/tests/playwright-web.config.ts +++ b/tests/playwright-web.config.ts @@ -53,9 +53,9 @@ const CHROMIUM_CI_ARGS = process.env.CI } : {}; -// Firefox prefs. JSPI is default-on only in Firefox >=153; the bundled 144 -// needs the pref — set it UNCONDITIONALLY, exactly like FIREFOX_PREFS_ALWAYS -// in playwright.config.ts (a CI-gated blob would leave local runs without it). +// Firefox prefs. JSPI is default-on since Firefox 153 (the Playwright 1.62 +// bundle); the pref stays as documentation of intent, UNCONDITIONAL exactly +// like FIREFOX_PREFS_ALWAYS in playwright.config.ts. // CI additionally runs headed under Xvfb (the CI step wraps in xvfb-run) with // the no-GPU blocklist bypassed, since headless Firefox can't create a GL // context on GPU-less CI VMs. NOTE: spreads REPLACE launchOptions wholesale — diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index a421bd5..acdfe69 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -101,9 +101,10 @@ const CHROMIUM_CI_ARGS = process.env.CI } : {}; -// Firefox prefs. JSPI (the wasm suspension mechanism, experiment/jspi) is -// default-on only in Firefox >=153; the bundled 144 needs the pref — set it -// UNCONDITIONALLY (a previous CI-gated blob left local runs without it). +// Firefox prefs. JSPI (the wasm suspension mechanism) is default-on since +// Firefox 153 — the Playwright 1.62 bundle — so the pref below is now a +// belt-and-braces documentation of intent; keep it UNCONDITIONAL (a previous +// CI-gated blob left local runs without it on the pref-gated 144). // CI additionally runs headed under Xvfb with software-WebGL forced: GPU-less // CI VMs can't create a headless GL context // (FEATURE_FAILURE_WEBGL_EXHAUSTED_DRIVERS); CI invokes the suite via diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index bcc28f8..3796457 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -229,8 +229,14 @@ static std::string kicadTestTimerParkState() return pcbjam_timer_park::stateJson(); } -// Test-only (fiber-resume-park repro, fiber_park.h): Resume() into an -// asyncify-parked coroutine — the decoded prod board-load trap. +// Test-only (fiber_park.h): Resume() into a foreign-parked coroutine — the +// decoded prod board-load trap family. NOTE: these are sync embind bindings +// for MANUAL probing on Chromium only. Do not build specs on them: the +// mutating levers suspend, and no embind shape delivers that correctly +// (plain registration throws on strict-JSPI Firefox; emscripten::async() +// re-executes its invoker when the awaited promise settles). The runtime +// contracts they staged are pinned by the jspi-coroutine harness (18 cases) +// and tests/kicad/coroutine-lifecycle.spec.ts instead. static bool kicadTestFiberParkStart( int aParkMs ) { return pcbjam_fiber_park::start( aParkMs ); diff --git a/web/standalone/src/wasm/ngspice-worker.js b/web/standalone/src/wasm/ngspice-worker.js index 94e05a4..2ef1470 100644 --- a/web/standalone/src/wasm/ngspice-worker.js +++ b/web/standalone/src/wasm/ngspice-worker.js @@ -20,6 +20,15 @@ */ const GLUE = self.NGSPICE_GLUE_URL; +// PTHREAD CHILD REALM (emscripten 6): the glue spawns pthread workers +// (ngspice's bg_run thread) from _scriptName = self.location.href — THIS +// wrapper blob (mainScriptUrlOrBlob was removed upstream). Load the glue (its +// tail self-instantiates into pthread-child mode) and get out of the way; see +// occ-worker.js for the full story. +if (globalThis.name === "em-pthread") { + importScripts(GLUE); +} else { + self.addEventListener("error", (e) => console.error("[ngspice_service] worker error:", e.message, e.filename, e.lineno)); self.addEventListener("unhandledrejection", (e) => @@ -29,12 +38,8 @@ importScripts(GLUE); const modP = NgspiceService({ onAbort: (what) => console.error("[ngspice_service] ABORT:", what), - // Same blob-importScripts trick as boot.ts / occ-worker.js: the module's own - // pthread children (ngspice's bg_run thread) must boot from a same-origin - // script even when the glue lives on a CDN. - mainScriptUrlOrBlob: new Blob( - ["importScripts(" + JSON.stringify(GLUE) + ");"], - { type: "text/javascript" }), + // (emscripten 6 removed mainScriptUrlOrBlob; pthread children re-run this + // wrapper blob instead — handled by the em-pthread branch at the top.) // A blob: worker has no http base URL — absolutize every asset path against // the glue's URL or the .wasm fetch dies with "Failed to parse URL". locateFile: (f) => new URL(f, GLUE).href, @@ -139,3 +144,5 @@ onmessage = async (e) => { } postMessage({ id, res }, transfer); }; + +} // end non-pthread (top service) realm diff --git a/web/standalone/src/wasm/occ-worker.js b/web/standalone/src/wasm/occ-worker.js index 1bbc752..047ba88 100644 --- a/web/standalone/src/wasm/occ-worker.js +++ b/web/standalone/src/wasm/occ-worker.js @@ -13,6 +13,19 @@ */ const GLUE = self.OCC_GLUE_URL; +// PTHREAD CHILD REALM (emscripten 6): the glue spawns its pthread workers from +// `_scriptName` = self.location.href — which, for a blob-booted service, is +// THIS wrapper blob (`Module.mainScriptUrlOrBlob` was removed upstream). Each +// child therefore re-executes this file. Detect the em-pthread realm, load the +// glue (its tail self-instantiates into pthread-child mode and installs its own +// onmessage for the wasmModule/wasmMemory handshake) and get out of the way — +// running the wrapper's own boot here would recursively spawn whole new +// services and clobber the pthread handshake handler (observed: occ_service +// boot hanging forever in a worker-spawn storm while the pool never fills). +if (globalThis.name === "em-pthread") { + importScripts(GLUE); +} else { + self.addEventListener("error", (e) => console.error("[occ_service] worker error:", e.message, e.filename, e.lineno)); self.addEventListener("unhandledrejection", (e) => @@ -26,11 +39,8 @@ const noise = (s) => /(^|: )Debug: /.test(String(s)); const modP = OccService({ onAbort: (what) => console.error("[occ_service] ABORT:", what), - // The module's own pthread children must boot from a same-origin script even - // when the glue lives on a CDN — same blob-importScripts trick as boot.ts. - mainScriptUrlOrBlob: new Blob( - ["importScripts(" + JSON.stringify(GLUE) + ");"], - { type: "text/javascript" }), + // (emscripten 6 removed mainScriptUrlOrBlob; pthread children re-run this + // wrapper blob instead — handled by the em-pthread branch at the top.) // A blob: worker has no http base URL — every asset path must be absolutized // against the glue's own URL or the .wasm fetch dies with "Failed to parse // URL" (root-relative bases like "/wasm" don't resolve). @@ -68,3 +78,5 @@ onmessage = async (e) => { }; postMessage({ id, res: out }, out.bytes ? [out.bytes.buffer] : []); }; + +} // end non-pthread (top service) realm diff --git a/wxwidgets b/wxwidgets index e637b9f..1b5f0e3 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit e637b9f3669ac4e18e043191772ee261fdd2b79d +Subproject commit 1b5f0e31f424e0771efcb26393aac4c54df5a4a5