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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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