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:
parent
a4ad69412e
commit
01dce40dc9
11 changed files with 616 additions and 1 deletions
75
tests/apps/standalone/coroutine-pthread/embind_repro.cpp
Normal file
75
tests/apps/standalone/coroutine-pthread/embind_repro.cpp
Normal 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;
|
||||
}
|
||||
50
tests/apps/standalone/coroutine-pthread/main_repro.cpp
Normal file
50
tests/apps/standalone/coroutine-pthread/main_repro.cpp
Normal 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;
|
||||
}
|
||||
62
tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp
Normal file
62
tests/apps/standalone/coroutine-pthread/mainloop_repro.cpp
Normal 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;
|
||||
}
|
||||
94
tests/apps/standalone/coroutine-pthread/nested_repro.cpp
Normal file
94
tests/apps/standalone/coroutine-pthread/nested_repro.cpp
Normal 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;
|
||||
}
|
||||
23
tests/apps/standalone/coroutine-pthread/worker_dom_stub.js
Normal file
23
tests/apps/standalone/coroutine-pthread/worker_dom_stub.js
Normal 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 () {},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue