test(wasm): coroutine crash reproduction harness + per-engine runner

Investigation scaffolding for the Chrome-only KiCad coroutine renderer crash.
Adds isolated reproduction probes exercising the coroutine/Asyncify/fiber layer
under KiCad-like conditions, runnable in BOTH Firefox and system Chrome.

- tests/playwright-coroutine.config.ts + test:coroutine:firefox|chrome npm
  scripts: run the coroutine specs in Firefox AND system Chrome (the old e2e
  config only used bundled Chromium, which never reproduced the crash).
- tests/apps/standalone/coroutine-pthread/: no-wx + pthreads reproduction probes
  (fiber-in-main, nested invoke_/dynCall boundaries, RunMainStack, embind,
  main-loop/rAF activation) + worker_dom_stub.js for wx+pthreads builds.
- tests/apps/Makefile.wasm: coroutine-pthread{,-main,-nested,-nested-ex,-wx,
  -embind,-mainloop} targets.
- scripts/common/shims/diagnostics.js: add EM_ASYNC_JS handleSleep enter/wake
  tracking (DIAG_SLEEP) to detect nested-async at the crash.

Findings (details in research notes): every isolated factor so far — direct /
nested / RunMainStack fiber, wx event loop + all 13 scenarios incl EM_ASYNC_JS,
pthreads, and main-loop/rAF activation — runs CLEAN in system Chrome. The
coroutine/Asyncify layer is exonerated; GL/WebGL is the remaining untested factor
(next). The reliable FF-pass/Chrome-fail repro is still the KiCad pcbnew e2e.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Viktor Vaczi 2026-05-25 18:44:12 +02:00
commit 01dce40dc9
11 changed files with 616 additions and 1 deletions

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ wxwidgets-clean/
*.tmp
output/
*.d
/tests/.test-port-coroutine

View file

@ -78,6 +78,26 @@
}, 100);
}
// 6. EM_ASYNC_JS sleeps (startModal, js_enumerateFonts, clipboard, etc.) — log
// enter/wake so we can see whether an async sleep is NESTED with a fiber swap
// at the crash (the #9153 collision). Logging only; delegates unchanged.
if (typeof Asyncify !== "undefined" && typeof Asyncify.handleSleep === "function") {
var __diagOrigHandleSleep = Asyncify.handleSleep.bind(Asyncify);
var diagSleepId = 0;
Asyncify.handleSleep = function(startAsync) {
var id = ++diagSleepId;
console.warn("[DIAG_SLEEP] ENTER id=" + id + " state=" + asyncState() +
" currData=" + ((typeof Asyncify.currData !== "undefined" && Asyncify.currData) || "null"));
return __diagOrigHandleSleep(function(wakeUp) {
return startAsync(function(result) {
console.warn("[DIAG_SLEEP] WAKE id=" + id + " state=" + asyncState() +
" currData=" + ((typeof Asyncify.currData !== "undefined" && Asyncify.currData) || "null"));
return wakeUp(result);
});
});
};
}
console.warn("[DIAG] Asyncify/fiber/modal diagnostics installed (logging only)");
})();
// === End diagnostics ===

View file

@ -541,3 +541,112 @@ clean:
rm -f $(S)/*/*.o $(S)/*/*.html $(S)/*/*.js $(S)/*/*.wasm
.PHONY: all clean menu clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize threadpool logerror retinascale coroutine coroutine-nested
# === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash ===
# Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match
# KiCad's runtime (-pthread + PTHREAD_POOL_SIZE). The single-threaded `coroutine` build
# passes in system Chrome; KiCad (pthreads) crashes. This isolates that difference.
LDFLAGS_COROUTINE_PTHREAD = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' -sPTHREAD_POOL_SIZE_STRICT=0 \
$(WX_LDFLAGS_NOGL)
$(S)/coroutine-pthread/coroutine_test_pt.o: $(S)/coroutine/coroutine_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/libcontext_pt.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp $(KICAD_ROOT)/thirdparty/libcontext/libcontext.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext $< -o $@
$(S)/coroutine-pthread/coroutine_test.html: $(S)/coroutine-pthread/coroutine_test_pt.o $(S)/coroutine-pthread/libcontext_pt.o $(WX_CORE_LIB)
$(CXX) $^ $(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 ===
LDFLAGS_COROUTINE_PTHREAD_NOWX = $(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']
$(S)/coroutine-pthread/main_repro.o: $(S)/coroutine-pthread/main_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/main_repro.html: $(S)/coroutine-pthread/main_repro.o $(S)/coroutine-pthread/libcontext_pt.o
$(CXX) $^ $(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
# Nested JS<->wasm dynCall-boundary reproduction (no wx + pthreads)
$(S)/coroutine-pthread/nested_repro.o: $(S)/coroutine-pthread/nested_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/nested_repro.html: $(S)/coroutine-pthread/nested_repro.o $(S)/coroutine-pthread/libcontext_pt.o
$(CXX) $^ $(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)
LDFLAGS_COROUTINE_INVOKE = $(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' \
-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 $@
$(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 $@
$(S)/coroutine-pthread/nested_repro_ex.html: $(S)/coroutine-pthread/nested_repro_ex.o $(S)/coroutine-pthread/libcontext_ex.o
$(CXX) $^ $(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
# wx event loop + pthreads (KiCad's 13-scenario harness) with a worker DOM stub so the
# 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)
$(CXX) $^ $(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
# embind reproduction: coroutine activated via an embind (--bind) call
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 $@
$(S)/coroutine-pthread/embind_repro.html: $(S)/coroutine-pthread/embind_repro.o $(S)/coroutine-pthread/libcontext_ex.o
$(CXX) $^ $(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
# Main-loop (rAF) activation reproduction: coroutine activated inside emscripten_set_main_loop
$(S)/coroutine-pthread/mainloop_repro.o: $(S)/coroutine-pthread/mainloop_repro.cpp $(S)/coroutine/kicad_coroutine_harness.h
@mkdir -p $(S)/coroutine-pthread
$(CXX) -c $(CXXFLAGS) -pthread -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/coroutine-pthread/mainloop_repro.html: $(S)/coroutine-pthread/mainloop_repro.o $(S)/coroutine-pthread/libcontext_pt.o
$(CXX) $^ $(LDFLAGS_COROUTINE_PTHREAD_NOWX) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
coroutine-pthread-mainloop: $(S)/coroutine-pthread/mainloop_repro.html
.PHONY: coroutine-pthread-mainloop

View file

@ -0,0 +1,75 @@
// Reproduction probe #3: add embind (--bind) to the (exonerated) coroutine layer.
//
// KiCad is built with --bind (pcbnew_embind.o); UI/JS reaches C++ tool code through the
// embind dispatch (which uses dynCall trampolines). This probe activates the coroutine
// FROM an embind method call (JS -> Module.ToolHost.activate() -> C++ -> coroutine), so
// that when the coroutine yields back, the Asyncify rewind must replay through the embind
// dispatch frame. Built no-wx + pthreads + exceptions + --bind. Firefox should reach
// "[REPRO] DONE"; if system Chrome crashes before DONE, embind is the missing factor.
#include <emscripten/bind.h>
#include <emscripten.h>
#include "kicad_coroutine_harness.h"
#include <cstdio>
using namespace emscripten;
using coroutine_test::TestCoroutine;
static void run_coroutine()
{
TestCoroutine co( []( TestCoroutine& self ) {
std::printf( "[REPRO] coroutine body running, RunMainStack + yield\n" );
std::fflush( stdout );
self.RunMainStack( []() {
std::printf( "[REPRO] main-stack lambda ran\n" );
std::fflush( stdout );
} );
self.Yield( 42 );
} );
bool running = co.Call( 1 );
std::printf( "[REPRO] after Call: running=%d lastValue=%ld\n",
(int) running, (long) co.LastReturnValue() );
std::fflush( stdout );
running = co.Resume( 2 );
std::printf( "[REPRO] after Resume: running=%d\n", (int) running );
std::fflush( stdout );
}
// A C++ class reached from JS via embind (mirrors KiCad's UI->C++ tool dispatch).
struct ToolHost
{
void activate()
{
std::printf( "[REPRO] ToolHost::activate (via embind) -> run coroutine\n" );
std::fflush( stdout );
run_coroutine(); // coroutine yields -> rewind replays through the embind dispatch
}
};
EMSCRIPTEN_BINDINGS( repro )
{
class_<ToolHost>( "ToolHost" )
.constructor<>()
.function( "activate", &ToolHost::activate );
}
int main()
{
std::printf( "[REPRO] start, activating coroutine via embind\n" );
std::fflush( stdout );
// JS -> embind -> C++ ToolHost::activate -> coroutine
EM_ASM( {
var h = new Module.ToolHost();
h.activate();
h.delete();
} );
std::printf( "[REPRO] DONE\n" );
std::fflush( stdout );
return 0;
}

View file

@ -0,0 +1,50 @@
// Isolated reproduction probe for the KiCad Asyncify-fiber x pthreads Chrome crash.
//
// Two factors that the existing (passing) coroutine harness does NOT combine:
// 1. pthreads (KiCad links -pthread + PTHREAD_POOL_SIZE; the standalone harness is
// single-threaded).
// 2. the fiber swap originates from main() — so the Asyncify rewind re-enters
// __main_argc_argv (matching the KiCad crash trace rewindId=0), whereas the
// wx-callback harness re-enters a callback export instead.
//
// This program does both: runs the libcontext Call/Yield/Resume fiber pattern directly
// in main(), built with pthreads. NO wxWidgets (so the wx glue's worker-unsafe DOM
// access doesn't get in the way). printf goes to console; "[REPRO] DONE" means the
// rewind survived. If system Chrome crashes before DONE while Firefox prints DONE,
// we've reproduced the crash in isolation.
#include "kicad_coroutine_harness.h"
#include <cstdio>
using coroutine_test::TestCoroutine;
int main()
{
std::printf( "[REPRO] start in main (__main_argc_argv)\n" );
std::fflush( stdout );
TestCoroutine coroutine( []( TestCoroutine& self ) {
std::printf( "[REPRO] coroutine body running, about to yield\n" );
std::fflush( stdout );
self.Yield( 42 );
std::printf( "[REPRO] coroutine resumed, finishing\n" );
std::fflush( stdout );
} );
std::printf( "[REPRO] before Call (will unwind main; coroutine yields back -> rewind main)\n" );
std::fflush( stdout );
bool running = coroutine.Call( 1 );
std::printf( "[REPRO] after Call: running=%d lastValue=%ld\n",
(int) running, (long) coroutine.LastReturnValue() );
std::fflush( stdout );
running = coroutine.Resume( 2 );
std::printf( "[REPRO] after Resume: running=%d\n", (int) running );
std::fflush( stdout );
std::printf( "[REPRO] DONE\n" );
std::fflush( stdout );
return 0;
}

View file

@ -0,0 +1,62 @@
// 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).
//
// No-wx + pthreads. Firefox should reach "[REPRO] DONE"; if system Chrome crashes before
// DONE, the main-loop/rAF activation is the missing factor.
#include "kicad_coroutine_harness.h"
#include <emscripten.h>
#include <cstdio>
using coroutine_test::TestCoroutine;
static int g_frame = 0;
static void run_coroutine()
{
TestCoroutine co( []( TestCoroutine& self ) {
std::printf( "[REPRO] coroutine body running, about to yield\n" );
std::fflush( stdout );
self.Yield( 42 );
} );
bool running = co.Call( 1 ); // unwinds the main-loop callback back to dynCall_v; yields back
std::printf( "[REPRO] after Call: running=%d lastValue=%ld\n",
(int) running, (long) co.LastReturnValue() );
std::fflush( stdout );
running = co.Resume( 2 );
std::printf( "[REPRO] after Resume: running=%d\n", (int) running );
std::fflush( stdout );
}
static void main_loop_iter()
{
++g_frame;
std::printf( "[REPRO] main-loop frame %d\n", g_frame );
std::fflush( stdout );
if( g_frame >= 2 )
{
std::printf( "[REPRO] activating coroutine inside main-loop refresh\n" );
std::fflush( stdout );
run_coroutine();
std::printf( "[REPRO] DONE\n" );
std::fflush( stdout );
emscripten_cancel_main_loop();
}
}
int main()
{
std::printf( "[REPRO] start; installing emscripten_set_main_loop (rAF)\n" );
std::fflush( stdout );
emscripten_set_main_loop( main_loop_iter, 0, 0 ); // main returns; rAF drives main_loop_iter
return 0;
}

View file

@ -0,0 +1,94 @@
// Reproduction probe #2 for the KiCad Asyncify-fiber Chrome crash.
//
// KiCad's crashing main-context rewind replays through ~11 NESTED dynCall_* (JS<->wasm
// boundary) frames. Those frames come from C++ invoke_* exception wrappers: an indirect
// call inside a try-region compiles to wasm -> invoke_vi(JS) -> dynCall_vi(JS) -> wasm,
// and invoke_* is in ASYNCIFY_IMPORTS so asyncify can unwind/rewind through it (which is
// why Firefox tolerates it). This program recreates that shape: from main, recurse
// through N indirect-call-in-try hops (each an invoke_/dynCall JS<->wasm boundary), then
// at the deepest hop run a coroutine that yields back -> main rewinds through the chain.
//
// Built no-wx + pthreads + exceptions, ASYNCIFY_IMPORTS=invoke_*,emscripten_fiber_swap
// (matching KiCad). Firefox should reach "[REPRO] DONE"; if system Chrome crashes before
// DONE, we've reproduced the crash in isolation.
#include "kicad_coroutine_harness.h"
#include <emscripten.h>
#include <cstdint>
#include <cstdio>
using coroutine_test::TestCoroutine;
static const int kBoundaries = 20; // nested invoke_/dynCall JS<->wasm hops (KiCad had ~11)
typedef void ( *LevelFn )( int );
static LevelFn g_level = nullptr; // indirect-call target (forces invoke_vi wrappers)
static void run_coroutine()
{
// Mirror KiCad's TOOL_MANAGER pattern: the tool coroutine does RunMainStack
// (CALL_CONTEXT / ContinueAfterRoot bounce — run work on the main stack, then resume),
// then a Wait-style Yield. KiCad startup tools use exactly this, not a plain Yield.
TestCoroutine co( []( TestCoroutine& self ) {
std::printf( "[REPRO] coroutine body running, RunMainStack bounce\n" );
std::fflush( stdout );
self.RunMainStack( []() {
std::printf( "[REPRO] main-stack lambda ran (ContinueAfterRoot)\n" );
std::fflush( stdout );
} );
std::printf( "[REPRO] coroutine resumed after RunMainStack, about to yield\n" );
std::fflush( stdout );
self.Yield( 42 );
} );
bool running = co.Call( 1 ); // drives the bounce + unwinds main through the invoke_ chain
std::printf( "[REPRO] after Call: running=%d lastValue=%ld\n",
(int) running, (long) co.LastReturnValue() );
std::fflush( stdout );
running = co.Resume( 2 );
std::printf( "[REPRO] after Resume: running=%d\n", (int) running );
std::fflush( stdout );
}
extern "C" EMSCRIPTEN_KEEPALIVE void level( int depth )
{
if( depth > 0 )
{
// Indirect call inside a try-region => Emscripten emits an invoke_vi wrapper:
// wasm -> invoke_vi(JS) -> dynCall_vi(JS) -> wasm. One nested asyncify-unwindable
// JS<->wasm boundary per hop (the KiCad shape).
try
{
g_level( depth - 1 );
}
catch( ... )
{
throw;
}
return;
}
run_coroutine(); // deepest hop: coroutine yields -> main rewinds through the chain
}
int main()
{
g_level = &level;
std::printf( "[REPRO] start, %d nested invoke_/dynCall JS<->wasm boundaries\n", kBoundaries );
std::fflush( stdout );
try
{
g_level( kBoundaries );
}
catch( ... )
{
}
std::printf( "[REPRO] DONE\n" );
std::fflush( stdout );
return 0;
}

View file

@ -0,0 +1,23 @@
// pthread workers have no DOM, but wxWidgets' glue touches `document` at module-eval
// (e.g. document.getElementById("window-container") in the wxNonOwnedWindow code), which
// throws "document is not defined" in every worker. Provide a no-op document stub when it
// is undefined so workers initialize; the real DOM work still runs on the main thread.
if (typeof document === 'undefined') {
var __noopEl = {
style: {},
getContext: function () { return null; },
appendChild: function () {},
setAttribute: function () {},
addEventListener: function () {},
getBoundingClientRect: function () { return { left: 0, top: 0, width: 0, height: 0 }; },
};
globalThis.document = {
getElementById: function () { return null; },
querySelector: function () { return null; },
querySelectorAll: function () { return []; },
createElement: function () { return __noopEl; },
body: __noopEl,
documentElement: __noopEl,
addEventListener: function () {},
};
}

View file

@ -0,0 +1,98 @@
import { test, expect, tryLoadApp } from './utils/fixtures';
// Isolated reproduction probe for the KiCad Asyncify-fiber x pthreads Chrome crash.
// Loads main_repro.html (fiber Call/Yield/Resume in main(), built WITH pthreads, no wx).
// Firefox should print "[REPRO] DONE"; if system Chrome crashes before DONE, the crash
// is reproduced in isolation.
test.describe('Coroutine pthread main() reproduction', () => {
test('fiber-in-main + pthreads reaches DONE without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/main_repro.html');
await tryLoadApp(page, 20000).catch(() => {});
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (i.e. the main-context rewind survived)',
})
.toBe(true);
// Sanity: the coroutine body must actually have run (fiber entry wired up).
expect(
testLogger.consoleLogs.some((l) => l.includes('[REPRO] coroutine body running')),
'coroutine body should have executed'
).toBe(true);
});
// Probe #2: coroutine activated through nested JS<->wasm dynCall boundaries (KiCad's shape).
test('nested dynCall-boundary fiber reaches DONE without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/nested_repro_ex.html');
await tryLoadApp(page, 20000).catch(() => {});
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (main rewind through the dynCall chain survived)',
})
.toBe(true);
expect(
testLogger.consoleLogs.some((l) => l.includes('[REPRO] coroutine body running')),
'coroutine body should have executed'
).toBe(true);
});
// Probe #3: the full 13-scenario wx-event-loop harness, built WITH pthreads.
test('wx event loop + pthreads coroutine suite completes without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/coroutine_test_wxpt.html');
await tryLoadApp(page, 30000).catch(() => {});
await expect
.poll(() => testLogger.consoleLogs.find((l) => l.includes('[COROUTINE_TEST] SUMMARY')) ?? null, {
timeout: 45000,
message: 'wx+pthreads suite should emit a SUMMARY line (no crash)',
})
.not.toBeNull();
const summary = testLogger.consoleLogs.find((l) => l.includes('[COROUTINE_TEST] SUMMARY'))!;
const m = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
expect(m, 'SUMMARY parseable').not.toBeNull();
expect(Number(m![3]), 'no failed cases').toBe(0);
});
// Probe #4: coroutine activated via an embind (--bind) call.
test('embind-activated fiber reaches DONE without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/embind_repro.html');
await tryLoadApp(page, 20000).catch(() => {});
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (rewind through the embind dispatch survived)',
})
.toBe(true);
expect(
testLogger.consoleLogs.some((l) => l.includes('ToolHost::activate')),
'embind activate should have run'
).toBe(true);
});
// Probe #5: coroutine activated inside an emscripten_set_main_loop (rAF) callback
// (the single JS->wasm boundary KiCad uses; matches "main-refresh" in the crash trace).
test('main-loop(rAF)-activated fiber reaches DONE without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/mainloop_repro.html');
await tryLoadApp(page, 20000).catch(() => {});
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (rewind through the main-loop dynCall_v survived)',
})
.toBe(true);
expect(
testLogger.consoleLogs.some((l) => l.includes('activating coroutine inside main-loop refresh')),
'main loop should have run and activated the coroutine'
).toBe(true);
});
});

View file

@ -12,7 +12,9 @@
"test:kicad:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox",
"test:kicad:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed",
"test:kicad": "npm run test:kicad:firefox",
"test:kicad:headed": "npm run test:kicad:chrome"
"test:kicad:headed": "npm run test:kicad:chrome",
"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"
},
"devDependencies": {
"@playwright/test": "^1.40.0",

View file

@ -0,0 +1,81 @@
import { defineConfig, devices } from '@playwright/test';
import { execSync } from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
// Runs the standalone coroutine harness specs (tests/e2e/coroutine*.spec.ts) in BOTH
// Firefox and system Chrome — the same engines as the KiCad app. The old default
// (tests/playwright.config.ts) only ran them in bundled Chromium with SwiftShader,
// which is NOT where the real KiCad coroutine crash manifests. Mirrors
// playwright-kicad.config.ts (Chrome must be --headed on ARM Mac; Firefox headless OK).
const PORT_FILE = path.join(__dirname, '.test-port-coroutine');
function findFreePort(): number {
try {
const result = execSync(
'python3 -c "import socket; s=socket.socket(); s.bind((\'\',0)); print(s.getsockname()[1]); s.close()"',
{ encoding: 'utf-8' }
);
return parseInt(result.trim());
} catch {
return 9100 + Math.floor(Math.random() * 800);
}
}
function getOrFindPort(): number {
try {
const stat = fs.statSync(PORT_FILE);
if (Date.now() - stat.mtimeMs < 60000) {
const port = parseInt(fs.readFileSync(PORT_FILE, 'utf-8').trim());
if (port > 0 && port < 65536) return port;
}
} catch { /* fall through */ }
const port = findFreePort();
fs.writeFileSync(PORT_FILE, port.toString());
return port;
}
const port = getOrFindPort();
export default defineConfig({
globalSetup: './global-setup.ts',
testDir: './e2e',
testMatch: /coroutine.*\.spec\.ts$/,
fullyParallel: false, // one heavy WASM app at a time
forbidOnly: !!process.env.CI,
retries: 0,
workers: 1,
reporter: 'html',
timeout: 120000,
use: {
baseURL: `http://localhost:${port}`,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
},
projects: [
{
// Firefox: headless, reliable on ARM Mac. (No clipboard perms — Firefox rejects them.)
name: 'firefox',
use: { ...devices['Desktop Firefox'], viewport: { width: 1280, height: 720 } },
},
{
// System Chrome (real V8/GPU) — the engine where the KiCad coroutine crash
// manifests. Run via: npm run test:coroutine:chrome (must be --headed).
name: 'chromium',
use: {
channel: 'chrome',
viewport: { width: 1280, height: 720 },
permissions: ['clipboard-read', 'clipboard-write', 'local-fonts'],
},
},
],
webServer: {
command: `npx serve apps -p ${port} -c ../serve.json`,
port: port,
reuseExistingServer: !process.env.CI,
},
});