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

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 () {},
};
}