jspi: migration phases 0-7 — build knob, scheduler shim, test successor suite

Toolchain: emsdk 6.0.6 (versions.sh; cache-hash keys on it). Build knob
PCBJAM_ASYNC_BACKEND=jspi|asyncify: build-kicad-target.sh links editors with
-sJSPI + -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt + --pre-js
jspi-scheduler.js (no DYNCALLS, no post-link asyncify pipeline); wx build
stamps the backend and forces clean on flip or unknown provenance;
docker/build.sh passes the knob, seeds the emscripten ports cache from the
volume every launch, jspi postprocess = patch-env-shim only.

scripts/common/shims/jspi-scheduler.js: the JSPI successor scheduler —
token-wait registry, resume turnstile (one armed resume between engine
re-entries, SP swaps only at microtask boundaries), green-region spill
stacks (16-aligned tops), S1 embind mutator FIFO lane + parker wraps, S6
shutdown, libctx integration hooks (suspend/end/quarantine + g_current
arm/clear), SuspendError attributor, lost-wake + stuck-window watchdogs,
__wxWaitDump observability.

Embind: PARKER registrations get emscripten::async() under PCBJAM_JSPI
(wasm/bindings/pcbjam_async_policy.h). nanosleep yields route via the shim.

Tests: tests/asyncify -> tests/jspi successor suite (jspi-stack red/green
shadow-stack battery, jspi-coroutine MiniCoro harness, suspend-races
semantic scenarios + __wxWaitDump books coherence); projects jspi-firefox/
jspi-chrome (asyncify-webkit retired — no JSPI in WebKit); unconditional
Firefox JSPI pref; guard-beacons -> wait-beacons (+wxScheduler/libctxJspi
families); Makefile.wasm links test apps against JSPI with the shim as a
tracked link prerequisite.

Web: WasmTool setRo await + __wxWaitDump forensics, open-flow contained
promise, scheduler-shim.test.ts retargeted (8 green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
This commit is contained in:
Viktor Vaczi 2026-08-13 07:06:24 +02:00
commit 3f09a46ff5
48 changed files with 2640 additions and 546 deletions

View file

@ -55,7 +55,49 @@ CXXFLAGS += -MMD -MP
# wasm setjmp/longjmp, matching the libwx build (scripts/build-wx-wasm.sh) and scripts/common/env.sh.
# The in-link Asyncify (emsdk Binaryen v121) crashes on wasm-EH, so build-wasm-test.sh stubs it and
# runs the real pipeline (--hoist-cpp-catches + --asyncify via apply-asyncify.sh) post-link.
# Async backend switch (experiment/jspi): PCBJAM_ASYNC_BACKEND=jspi links the
# apps with JSPI instead of Asyncify. Requires the wx libs built with the same
# knob (build-wx-wasm.sh). -sDYNCALLS is asyncify-only machinery and a FATAL
# link error under -sJSPI; -DPCBJAM_JSPI selects the JSPI lanes in the wasm
# port sources and libcontext.
PCBJAM_ASYNC_BACKEND ?= asyncify
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -DPCBJAM_JSPI=1
# Promising entry exports: every JS->wasm entry that can transitively suspend
# (fresh census 2026-08-12 of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the
# Sched*/abandon probes never suspend and stay plain). pcbjam_libctx_entry is
# libcontext's coroutine entry (only present in apps that link libcontext —
# emscripten warns-and-ignores absent names).
WX_JSPI_EXPORTS = main,wx_dom_event,wx_dom_mouse,wx_window_close,wx_window_move,wx_window_resize,ProcessEvents,wxWasmMailboxTick,wxWasmTopLevelTick,wxWasmMainLoopPump,wxWasmJobTick,pcbjam_libctx_entry
# Per-app suspending test levers (fresh-stack ccalls that park or swap a
# coroutine). Appended as a LAST -sJSPI_EXPORTS on the app's link line, which
# wins over the one inside ASYNC_LDFLAGS (emcc last-wins). The non-parking
# levers (races_end_active_modal — the answer-synchronously resolve path)
# 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)
ASYNC_LDFLAGS = -sJSPI \
-sJSPI_EXPORTS=$(WX_JSPI_EXPORTS) \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stackSave','stackRestore']" \
--pre-js $(JSPI_SHIM)
ASYNC_CORO_LDFLAGS = $(ASYNC_LDFLAGS)
ASYNC_SCHED_CTX_LDFLAGS = $(ASYNC_LDFLAGS)
else
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -sDYNCALLS=1
ASYNC_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
ASYNC_CORO_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
ASYNC_SCHED_CTX_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
endif
CXXFLAGS += $(EH_FLAGS)
# Base Emscripten flags (for all apps)
@ -64,10 +106,7 @@ CXXFLAGS += $(EH_FLAGS)
# - js_writeTextToClipboard, js_readTextFromClipboard, js_clipboardHasText, js_clearClipboard: for clipboard
# - js_enumerateFonts: for font enumeration via Local Font Access API
BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
$(ASYNC_LDFLAGS)
# LDFLAGS for non-GL apps (standalone tests)
LDFLAGS_NOGL = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
@ -99,10 +138,7 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
COROUTINE_BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
$(ASYNC_CORO_LDFLAGS)
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
# The asyncify-races harness must match PRODUCTION asyncify semantics: the KiCad
@ -118,10 +154,7 @@ LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_L
# operation" state; hitting one is the bug.
LDFLAGS_SCHED_CTX = $(DEBUG_LDFLAGS) $(EH_FLAGS) -sALLOW_MEMORY_GROWTH \
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=65536 \
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
$(ASYNC_SCHED_CTX_LDFLAGS)
# LDFLAGS for the raytracer thread-deadlock repro. Same pthread + pool config as
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the 65536 ASYNCIFY
@ -144,6 +177,11 @@ LDFLAGS_REALPOOL = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
# depend on them — otherwise editing a shim wouldn't trigger a relink.
JS = $(TOOLS_ROOT)/wx.js --pre-js $(TOOLS_ROOT)/wx-dom.js
JS_FILES = $(TOOLS_ROOT)/wx.js $(TOOLS_ROOT)/wx-dom.js
# The scheduler shim is a pre-js on every jspi link (ASYNC_LDFLAGS): apps
# whose objects are up to date must still relink when it changes.
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
JS_FILES += $(JSPI_SHIM)
endif
HTML = $(TOOLS_ROOT)/template.html
# wxWidgets library directory - used as dependency to rebuild when libs change
@ -777,7 +815,7 @@ $(S)/asyncify-races/races_test.o: $(S)/asyncify-races/races_test.cpp $(S)/corout
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
$(S)/asyncify-races/races_test.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
$(CXX) $^ $(LDFLAGS_RACES) $(RACES_EXTRA_LDFLAGS) --pre-js $(JS) --shell-file $(HTML) -o $@
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js

View file

@ -105,7 +105,14 @@ void LogLine( const std::string& aLine )
// Park the calling stack until JS resolves the token (races_resolve_token_after).
EM_ASYNC_JS( int, races_await_token, ( int aToken ), {
Module.__racesWaits = Module.__racesWaits || {};
return await new Promise( ( resolve ) => { Module.__racesWaits[aToken] = resolve; } );
var p = new Promise( ( resolve ) => { Module.__racesWaits[aToken] = resolve; } );
// JSPI: EVERY suspension must route through the scheduler's turnstile —
// a raw await's engine-level resume bypasses the SP discipline and never
// ends the current window (windowLive wedges the pump).
var S = globalThis.__wxScheduler;
if( S && S.backend === 'jspi' )
return await S.promiseYield( p, 'races-token' );
return await p;
} );
// Resolve a parked token after a JS-side delay (independent of the C++ world,
@ -120,6 +127,11 @@ EM_JS( void, races_resolve_token_after, ( int aToken, int aValue, int aDelayMs )
// Plain parked sleep.
EM_ASYNC_JS( int, races_sleep_ms, ( int aMs ), {
var S = globalThis.__wxScheduler;
if( S && S.backend === 'jspi' ) {
await S.sleepYield( aMs ); // turnstile-routed (see races_await_token)
return 1;
}
await new Promise( ( r ) => setTimeout( r, aMs ) );
return 1;
} );
@ -183,7 +195,12 @@ EM_JS( void, races_mark_done, ( const char* aName ), {
// no fiber is queued.
EM_JS( int, races_quiescent, (), {
try {
var stOk = ( typeof Asyncify === 'undefined' ) || Asyncify.state === 0;
// JSPI glue still defines an Asyncify object (shared library file)
// but with no state machine - Asyncify.state is undefined there, and
// that is quiescent-by-construction (suspensions are engine-native).
var stOk = ( typeof Asyncify === 'undefined' )
|| Asyncify.state === undefined
|| Asyncify.state === 0;
var nfOk = ( typeof Fibers === 'undefined' ) || !Fibers.nextFiber;
return ( stOk && nfOk ) ? 1 : 0;
} catch( e ) {
@ -841,6 +858,16 @@ public:
LogLine( "[ASYNCIFY_RACES] PARAMS only='" + only + "' sleepPark="
+ std::to_string( sleepPark ? 1 : 0 ) );
// JSPI: the ccall'd test levers are promising entries (JSPI_EXPORTS)
// that can PARK — the shim must track their activations like the wx
// entries, or their windows leak (untracked completion wedges the
// resume turnstile until the watchdog clears it).
EM_ASM( {
if( globalThis.__wxScheduler && globalThis.__wxScheduler.installExportWraps )
globalThis.__wxScheduler.installExportWraps(
[ 'races_swap_once', 'races_park_token2', 'races_wdt_park_b' ] );
} );
#endif
// THE LOAD-BEARING TOPOLOGY: complete a fiber swap cycle during OnInit.

View file

@ -0,0 +1,58 @@
// Boot-check a built wx test app under JSPI in bundled Chromium (+ optionally
// Firefox with the pref). Usage: node boot-check.cjs <app.html> [firefox]
const path = require('path');
const http = require('http');
const fs = require('fs');
const PW = '/Users/V/IdeaProjects/pcbjam-private/pcbjam/tests/node_modules/playwright';
const { chromium, firefox } = require(PW);
const APPS = '/Users/V/IdeaProjects/kicad-wasm-jspi/tests/apps';
const page_url = process.argv[2] || 'minimal_test.html';
const useFirefox = process.argv[3] === 'firefox';
const MIME = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.wasm': 'application/wasm', '.data': 'application/octet-stream' };
const server = http.createServer((req, res) => {
const f = path.join(APPS, decodeURIComponent(req.url.split('?')[0]));
try {
const body = fs.readFileSync(f);
res.writeHead(200, {
'Content-Type': MIME[path.extname(f)] ?? 'application/octet-stream',
'Cross-Origin-Opener-Policy': 'same-origin',
'Cross-Origin-Embedder-Policy': 'require-corp',
});
res.end(body);
} catch {
res.writeHead(404).end();
}
});
server.listen(0, '127.0.0.1', async () => {
const launcher = useFirefox ? firefox : chromium;
const opts = useFirefox
? { firefoxUserPrefs: { 'javascript.options.wasm_js_promise_integration': true } }
: {};
const browser = await launcher.launch(opts);
const page = await browser.newPage();
const lines = [];
page.on('console', (m) => lines.push(m.text()));
page.on('pageerror', (e) => lines.push('PAGEERROR: ' + e.message));
await page.goto(`http://127.0.0.1:${server.address().port}/${page_url}`);
await new Promise((r) => setTimeout(r, 12000));
// Probe scheduler state + DOM
const probe = await page.evaluate(() => ({
scheduler: globalThis.__wxScheduler ? globalThis.__wxScheduler.backend : null,
dump: globalThis.__wxWaitDump ? globalThis.__wxWaitDump() : null,
windows: document.querySelectorAll('.wx-window, [id^=wx]').length,
bodyChildren: document.body.children.length,
})).catch((e) => ({ error: String(e) }));
await browser.close();
server.close();
console.log('=== console (last 30) ===');
for (const l of lines.slice(-30)) console.log(l);
console.log('=== probe ===');
console.log(JSON.stringify(probe, null, 1));
const bad = lines.filter((l) => /PAGEERROR|SuspendError|RuntimeError|abort/i.test(l));
console.log(bad.length ? 'BOOT: ERRORS(' + bad.length + ')' : 'BOOT: CLEAN');
});

View file

@ -0,0 +1,58 @@
// Quick browser validation of the jspi-coroutine harness in bundled
// Chromium 143 (JSPI default-on) and Firefox 144 (JSPI behind pref).
// Uses the main checkout's installed Playwright. Proper spec wiring lands in
// tests/jspi/ (Phase 6).
const path = require('path');
const http = require('http');
const fs = require('fs');
const PW = '/Users/V/IdeaProjects/pcbjam-private/pcbjam/tests/node_modules/playwright';
const { chromium, firefox } = require(PW);
const DIR = __dirname;
const MIME = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.wasm': 'application/wasm' };
async function runIn(name, launcher, opts) {
const browser = await launcher.launch(opts);
const page = await browser.newPage();
const lines = [];
page.on('console', (msg) => {
const t = msg.text();
if (t.includes('[JSPI_CORO]') || t.includes('[libctx-jspi]')) lines.push(t);
});
await page.goto(`http://127.0.0.1:${server.address().port}/index.html`);
await page.waitForFunction(
() => performance.now() > 0, // anchor; real wait below
);
const deadline = Date.now() + 30000;
while (Date.now() < deadline && !lines.some((l) => l.includes('SUMMARY'))) {
await new Promise((r) => setTimeout(r, 200));
}
await browser.close();
const summary = lines.find((l) => l.includes('SUMMARY')) ?? 'NO SUMMARY';
const fails = lines.filter((l) => l.includes('FAIL') || l.includes('FATAL'));
console.log(`${name}: ${summary}`);
for (const f of fails) console.log(`${name}: ${f}`);
return summary.includes('failed=0');
}
const server = http.createServer((req, res) => {
const f = path.join(DIR, req.url === '/' ? 'index.html' : req.url);
try {
const body = fs.readFileSync(f);
res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] ?? 'application/octet-stream' });
res.end(body);
} catch {
res.writeHead(404).end();
}
});
server.listen(0, '127.0.0.1', async () => {
let ok = true;
ok = (await runIn('chromium', chromium, {})) && ok;
ok = (await runIn('firefox', firefox, {
firefoxUserPrefs: { 'javascript.options.wasm_js_promise_integration': true },
})) && ok;
server.close();
process.exit(ok ? 0 : 1);
});

View file

@ -0,0 +1,20 @@
#!/bin/bash
# Ad-hoc build for the jspi-coroutine harness (Makefile.wasm wiring in Phase 3).
# Compiles the REAL kicad/thirdparty/libcontext with -DPCBJAM_JSPI.
set -eo pipefail
cd "$(dirname "$0")"
ROOT="$(cd ../../../.. && pwd)"
EMXX="${EMXX:-$ROOT/tools/emsdk/upstream/emscripten/em++}"
LIBCTX="$ROOT/kicad/thirdparty/libcontext"
"$EMXX" coroutine_jspi_test.cpp "$LIBCTX/libcontext.cpp" \
-I"$LIBCTX" \
-DPCBJAM_JSPI=1 \
-O1 \
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 \
-sJSPI -sJSPI_EXPORTS=pcbjam_libctx_entry,main \
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=node,web \
-sALLOW_MEMORY_GROWTH=1 \
-o coroutine_jspi_test.mjs
echo "built: coroutine_jspi_test.mjs"

View file

@ -0,0 +1,434 @@
// jspi-coroutine — validates the JSPI libcontext backend (PCBJAM_JSPI) through
// the EXACT protocol coroutine.h drives it with, without linking wx or KiCad.
//
// MiniCoro below is a compact transcription of COROUTINE<>'s libcontext
// mechanics (doCall/jumpIn/jumpOut/callerStub, INVOCATION_ARGS, the
// CALL_CONTEXT::Continue root-bounce loop, the finish_fcontext completion
// hook) — kicad/include/tool/coroutine.h stays untouched; if that file's
// protocol changes, change this mirror too.
//
// Scenarios ported from the study prototype (.jspi-assets/jspi-proto), plus
// backend-specific cases: finished-activation reclaim, mid-body release
// census, ghost-jump refusal.
//
// Output contract (parsed by tests/jspi/jspi-coroutine.spec.ts):
// [JSPI_CORO] CASE <name> PASS|FAIL(<detail>)
// [JSPI_CORO] SUMMARY passed=<n> failed=<n>
#include <emscripten.h>
#include <emscripten/em_js.h>
#include <libcontext.h>
#include <cstdint>
#include <cstdio>
#include <functional>
#include <string>
#include <vector>
// --- MiniCoro: coroutine.h's libcontext protocol, minimally ------------------
struct MiniCoro;
struct INVOCATION_ARGS
{
enum { FROM_ROOT = 0, FROM_ROUTINE = 1, CONTINUE_AFTER_ROOT = 2 };
int type;
MiniCoro* destination;
void* context; // CALL_CONTEXT in KiCad; opaque here
};
struct CTX_SLOT
{
libcontext::fcontext_t ctx = nullptr;
};
struct MiniCoro
{
using Body = std::function<void( MiniCoro& )>;
explicit MiniCoro( Body aBody ) : m_body( std::move( aBody ) ) {}
~MiniCoro()
{
if( m_caller.ctx )
libcontext::release_fcontext( m_caller.ctx );
if( m_callee.ctx )
libcontext::release_fcontext( m_callee.ctx );
}
bool Call( intptr_t aValue = 0 )
{
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
m_currentValue = aValue;
m_callee.ctx = libcontext::make_fcontext( nullptr, 128 * 1024, callerStub );
m_running = true;
INVOCATION_ARGS* ret = jumpIn( &args );
return continueAfterRoot( ret );
}
bool Resume( intptr_t aValue = 0 )
{
if( !m_running )
return false;
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
m_resumeValue = aValue;
INVOCATION_ARGS* ret = jumpIn( &args );
return continueAfterRoot( ret );
}
void Yield( intptr_t aValue = 0 )
{
m_yieldValue = aValue;
jumpOut();
m_currentValue = m_resumeValue;
}
void RunMainStack( std::function<void()> aFunc )
{
m_mainFn = std::move( aFunc );
m_argsOut = { INVOCATION_ARGS::CONTINUE_AFTER_ROOT, this, nullptr };
jumpOutWith( &m_argsOut );
m_currentValue = m_resumeValue;
}
bool Running() const { return m_running; }
intptr_t CurrentValue() const { return m_currentValue; }
intptr_t YieldValue() const { return m_yieldValue; }
size_t EntryCount() const { return m_entryCount; }
// -- protocol internals (transcribed from coroutine.h) --
static void callerStub( intptr_t aData )
{
auto& args = *reinterpret_cast<INVOCATION_ARGS*>( aData );
MiniCoro* cor = args.destination;
++cor->m_entryCount;
cor->m_body( *cor );
cor->m_running = false;
// the 3-line JSPI hook coroutine.h carries under PCBJAM_JSPI
libcontext::finish_fcontext( cor->m_callee.ctx );
cor->jumpOut();
// JSPI backend: the finishing jumpOut RETURNS (sentinel) and this
// frame unwinds, completing the activation.
}
INVOCATION_ARGS* jumpIn( INVOCATION_ARGS* args )
{
return reinterpret_cast<INVOCATION_ARGS*>(
libcontext::jump_fcontext( &m_caller.ctx, m_callee.ctx,
reinterpret_cast<intptr_t>( args ) ) );
}
void jumpOut() { jumpOutWith( &m_argsFromRoutine ); }
void jumpOutWith( INVOCATION_ARGS* args )
{
intptr_t r = libcontext::jump_fcontext( &m_callee.ctx, m_caller.ctx,
reinterpret_cast<intptr_t>( args ) );
auto* ret = reinterpret_cast<INVOCATION_ARGS*>( r );
// like coroutine.h jumpOut: touch the returned args (sentinel-safe)
m_lastContext = ret ? ret->context : nullptr;
}
// CALL_CONTEXT::Continue (coroutine.h:175-183) — service root bounces
bool continueAfterRoot( INVOCATION_ARGS* ret )
{
while( m_running && ret && ret->type == INVOCATION_ARGS::CONTINUE_AFTER_ROOT )
{
m_mainFn();
++m_rootRuns;
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
m_resumeValue = m_rootResumeValue;
ret = jumpIn( &args );
}
return m_running;
}
Body m_body;
CTX_SLOT m_caller, m_callee;
bool m_running = false;
intptr_t m_currentValue = 0, m_resumeValue = 0, m_yieldValue = 0;
intptr_t m_rootResumeValue = 77;
size_t m_entryCount = 0;
int m_rootRuns = 0;
void* m_lastContext = nullptr;
std::function<void()> m_mainFn;
INVOCATION_ARGS m_argsFromRoutine{ INVOCATION_ARGS::FROM_ROUTINE, nullptr, nullptr };
INVOCATION_ARGS m_argsOut{ INVOCATION_ARGS::FROM_ROUTINE, nullptr, nullptr };
};
// --- JS census helpers -------------------------------------------------------
EM_JS( int, js_live_slot_count, (), {
const L = globalThis.__libctxJspi;
return L ? Object.keys( L.s ).length : -1;
} );
EM_JS( int, js_dead_parked, (), {
const L = globalThis.__libctxJspi;
return L ? L.deadParked : -1;
} );
EM_JS( void, js_schedule_resume_marker, (), {
globalThis.__timerFired = 0;
setTimeout( () => { globalThis.__timerFired = 1; }, 10 );
} );
EM_ASYNC_JS( void, js_wait_ms, ( int ms ), {
await new Promise( ( r ) => setTimeout( r, ms ) );
} );
EM_JS( int, js_timer_fired, (), { return globalThis.__timerFired | 0; } );
// --- test rig ----------------------------------------------------------------
static int g_passed = 0, g_failed = 0;
static void report( const char* name, bool ok, const std::string& detail = "" )
{
if( ok )
{
++g_passed;
std::printf( "[JSPI_CORO] CASE %s PASS\n", name );
}
else
{
++g_failed;
std::printf( "[JSPI_CORO] CASE %s FAIL(%s)\n", name, detail.c_str() );
}
}
int main()
{
// 1. first entry runs body exactly once, to first yield
{
int steps = 0;
MiniCoro c( [&]( MiniCoro& me ) { steps++; me.Yield( 10 ); steps++; } );
bool alive = c.Call( 5 );
report( "first_entry_runs_once",
alive && steps == 1 && c.YieldValue() == 10 && c.EntryCount() == 1 );
c.Resume(); // let it finish
}
// 2. yield/resume preserves locals across suspensions
{
std::vector<int> seen;
MiniCoro c( [&]( MiniCoro& me ) {
int local = 100;
me.Yield( local );
local += (int) me.CurrentValue();
me.Yield( local );
local += (int) me.CurrentValue();
seen.push_back( local );
} );
c.Call();
bool ok = c.YieldValue() == 100;
c.Resume( 11 );
ok = ok && c.YieldValue() == 111;
c.Resume( 22 );
ok = ok && !c.Running() && seen.size() == 1 && seen[0] == 133;
report( "yield_resume_preserves_state", ok );
}
// 3. deep recursion preserved across yields (spill-region proof)
{
std::function<int( MiniCoro&, int )> deep = [&]( MiniCoro& me, int d ) -> int {
volatile int frame[32];
for( int i = 0; i < 32; i++ ) frame[i] = d * 100 + i;
if( d > 0 )
{
int below = deep( me, d - 1 );
for( int i = 0; i < 32; i++ )
if( frame[i] != d * 100 + i ) return -1000000;
return below + 1;
}
me.Yield( 1 );
me.Yield( 2 );
for( int i = 0; i < 32; i++ )
if( frame[i] != i ) return -1000000;
return 0;
};
int result = -1;
MiniCoro c( [&]( MiniCoro& me ) { result = deep( me, 6 ); } );
c.Call();
c.Resume();
c.Resume();
report( "deep_stack_preserved_across_yield", !c.Running() && result == 6,
"result=" + std::to_string( result ) );
}
// 4. nested coroutine: child created+run inside parent's body
{
std::string order;
MiniCoro child( [&]( MiniCoro& me ) { order += "c1"; me.Yield(); order += "c2"; } );
MiniCoro parent( [&]( MiniCoro& me ) {
order += "p1";
child.Call();
order += "p2";
me.Yield();
child.Resume();
order += "p3";
} );
parent.Call();
bool mid = order == "p1c1p2";
parent.Resume();
report( "nested_coroutine_call_and_resume",
mid && order == "p1c1p2c2p3" && !parent.Running() && !child.Running(),
order );
}
// 5. parent yields while child stays suspended; child resumes intact
{
std::string order;
MiniCoro child( [&]( MiniCoro& me ) {
int keep = 42;
order += "c1";
me.Yield();
order += ( keep == 42 ) ? "c2" : "cX";
} );
MiniCoro parent( [&]( MiniCoro& me ) {
child.Call();
order += "p1";
me.Yield(); // parent suspends; child still parked
order += "p2";
child.Resume(); // child must resume with locals intact
order += "p3";
} );
parent.Call();
parent.Resume();
report( "nested_parent_yield_preserves_suspend", order == "c1p1p2c2p3", order );
}
// 6. RunMainStack: functor runs on the caller's activation, then resume
{
std::string order;
MiniCoro c( [&]( MiniCoro& me ) {
order += "before-root;";
me.RunMainStack( [&] { order += "on-root;"; } );
order += "after-root(" + std::to_string( me.CurrentValue() ) + ");";
} );
c.Call();
report( "root_bounce_continue_after_root",
!c.Running() && c.m_rootRuns == 1
&& order == "before-root;on-root;after-root(77);",
order );
}
// 7. completion: body runs to the end, Running() flips, activation reclaimed
{
MiniCoro c( [&]( MiniCoro& me ) { me.Yield( 1 ); } );
c.Call();
c.Resume();
report( "completion", !c.Running() );
}
// 8. resume after finish is refused (ghost contract), no re-entry
{
int entries = 0;
MiniCoro c( [&]( MiniCoro& me ) { entries++; me.Yield(); } );
c.Call();
c.Resume(); // finishes
bool resumed = c.Resume( 99 ); // must refuse: m_running false short-circuits
// force a backend-level ghost jump too:
intptr_t r = libcontext::jump_fcontext( &c.m_caller.ctx, c.m_callee.ctx, 0 );
report( "resume_after_finish_does_not_reenter",
!resumed && entries == 1 && r == -1 );
}
// 9. interleaving multiple coroutines
{
std::string order;
MiniCoro a( [&]( MiniCoro& me ) { order += "a1"; me.Yield(); order += "a2"; me.Yield(); order += "a3"; } );
MiniCoro b( [&]( MiniCoro& me ) { order += "b1"; me.Yield(); order += "b2"; } );
a.Call(); b.Call(); a.Resume(); b.Resume(); a.Resume();
report( "interleaving_multiple_coroutines", order == "a1b1a2b2a3", order );
}
// 10. stress: many round trips
{
int n = 0;
MiniCoro c( [&]( MiniCoro& me ) {
for( int i = 0; i < 48; i++ ) { n++; me.Yield( i ); }
} );
c.Call();
while( c.Running() )
c.Resume();
report( "stress_many_round_trips", n == 48, std::to_string( n ) );
}
// 11. transfer values round-trip through yields and resumes
{
intptr_t got = 0;
MiniCoro c( [&]( MiniCoro& me ) {
me.Yield( 1234 );
got = me.CurrentValue();
} );
c.Call();
bool y = c.YieldValue() == 1234;
c.Resume( 4321 );
report( "transfer_values_round_trip", y && got == 4321 );
}
// 12. yield INSIDE a C++ catch block under native wasm-EH — the case the
// HoistCppCatches binaryen pass existed for
{
std::string order;
MiniCoro c( [&]( MiniCoro& me ) {
try
{
order += "t";
throw 42;
}
catch( int e )
{
order += "c" + std::to_string( e );
me.Yield( e );
order += "r" + std::to_string( (int) me.CurrentValue() );
}
order += "d";
} );
c.Call();
c.Resume( 7 );
report( "yield_inside_catch_block_wasm_eh",
!c.Running() && order == "tc42r7d", order );
}
// 13. resume driven from a JS timer through the wait import (dispatch shape)
{
js_schedule_resume_marker();
MiniCoro c( [&]( MiniCoro& me ) { me.Yield(); } );
c.Call();
js_wait_ms( 25 ); // suspends main; timer fires meanwhile
bool fired = js_timer_fired() == 1;
c.Resume();
report( "async_wait_resume_after_timer", fired && !c.Running() );
}
// 14. finished coroutines fully reclaim their JS slots + regions
{
int before = js_live_slot_count();
{
MiniCoro c( [&]( MiniCoro& me ) { me.Yield(); } );
c.Call();
c.Resume();
}
int after = js_live_slot_count();
report( "finished_activation_reclaimed", before == after,
std::to_string( before ) + "->" + std::to_string( after ) );
}
// 15. release while suspended mid-body: censused, never resumed
{
int deadBefore = js_dead_parked();
int bodySteps = 0;
{
MiniCoro c( [&]( MiniCoro& me ) { bodySteps++; me.Yield(); bodySteps++; } );
c.Call();
// destructor releases while parked mid-body
}
int deadAfter = js_dead_parked();
report( "midbody_release_censused",
bodySteps == 1 && deadAfter == deadBefore + 1,
"steps=" + std::to_string( bodySteps )
+ " dead=" + std::to_string( deadAfter ) );
}
std::printf( "[JSPI_CORO] SUMMARY passed=%d failed=%d\n", g_passed, g_failed );
return g_failed == 0 ? 0 : 1;
}

View file

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>jspi-coroutine harness</title></head>
<body>
<script type="module">
// ?pt=1 loads the pthread build (needs the COOP/COEP the test server sets).
const pt = new URLSearchParams(location.search).get('pt') === '1';
const { default: factory } = await import(
pt ? './coroutine_jspi_test_pt.mjs' : './coroutine_jspi_test.mjs');
try {
await factory();
} catch (e) {
console.log('[JSPI_CORO] FATAL ' + e);
}
</script>
</body>
</html>

View file

@ -0,0 +1,2 @@
import factory from './coroutine_jspi_test.mjs';
const m = await factory();

View file

@ -0,0 +1,2 @@
import factory from './coroutine_jspi_test_pt.mjs';
const m = await factory();

View file

@ -0,0 +1,25 @@
#!/bin/bash
# Ad-hoc build for the jspi-stack harness (Makefile.wasm wiring lands in Phase 3).
# Uses the worktree-local emsdk (tools/emsdk, 6.0.6). EH flags mirror production.
set -eo pipefail
cd "$(dirname "$0")"
ROOT="$(cd ../../../.. && pwd)"
EMCC="${EMCC:-$ROOT/tools/emsdk/upstream/emscripten/em++}"
COMMON=(
-O1
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1
-sJSPI -sJSPI_EXPORTS=activation
-sEXPORTED_RUNTIME_METHODS=stackSave,stackRestore,HEAPU8
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=node,web,worker
-sALLOW_MEMORY_GROWTH=1
)
"$EMCC" stack_test.cpp -o stack_test.mjs "${COMMON[@]}" \
-sEXPORTED_FUNCTIONS=_activation,_stomp,_stack_current,_stack_base,_stack_end,_main,_malloc,_free
"$EMCC" stack_test.cpp -o stack_test_pt.mjs "${COMMON[@]}" \
-pthread -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \
-sEXPORTED_FUNCTIONS=_activation,_stomp,_stack_current,_stack_base,_stack_end,_main,_malloc,_free,_start_churn,_stop_churn
echo "built: stack_test.mjs + stack_test_pt.mjs"

View file

@ -0,0 +1,162 @@
// jspi-stack driver — runs the red/green shadow-stack scenarios (#27364).
//
// node --experimental-wasm-jspi driver.mjs red
// node --experimental-wasm-jspi driver.mjs green-copy # Pyodide-style save/restore
// node --experimental-wasm-jspi driver.mjs green-region # per-activation stack region + SP swap
//
// Output contract (parsed by the future tests/jspi/jspi-stack.spec.ts):
// [JSPI_STACK] SCENARIO <name> corruptA=<n> corruptB=<n> corruptNested=<n> verdict=<RED|GREEN|UNEXPECTED>
//
// RED must observe corruption with mitigation OFF (proves the harness can see
// the bug). GREEN legs must be corruption-free across interleaved rounds and
// the nested case.
const DEPTH = 24;
const STOMP_DEPTH = 48;
const REGION_BYTES = 256 * 1024;
const mode = process.argv[2] ?? 'red';
const variant = process.argv[3] ?? 'single'; // single | pthread
const { default: factory } = await import(
variant === 'pthread' ? './stack_test_pt.mjs' : './stack_test.mjs');
// --- gate plumbing -----------------------------------------------------------
const gates = new Map(); // id -> resolve
let gateHook = null; // (id) => wrap/observe, set per mitigation
globalThis.__jspiGate = (id) => {
const base = new Promise((res) => gates.set(id, res));
return gateHook ? gateHook(id, base) : base;
};
function release(id) {
const r = gates.get(id);
if (!r) throw new Error(`no gate armed for id ${id}`);
gates.delete(id);
r(0);
}
const m = await factory();
const centralBase = m.stackSave();
// --- mitigations -------------------------------------------------------------
// Bookkeeping keyed by activation id. entryTop[id] = SP at promising entry.
const entryTop = new Map();
const actSp = new Map();
const regions = new Map(); // id -> malloc'd base (green-region)
const snapshots = new Map(); // id -> Uint8Array copy (green-copy)
// Wrap a promising-export call according to the active mitigation. Returns the
// export's promise. `centralRestore` puts the shared SP back for whatever runs
// next on the central stack.
function startActivation(id) {
if (mode === 'green-region') {
const base = regions.get(id) ?? m._malloc(REGION_BYTES);
regions.set(id, base);
const top = base + REGION_BYTES; // stacks grow down
const saved = m.stackSave();
m.stackRestore(top);
entryTop.set(id, top);
const p = m._activation(id, DEPTH);
m.stackRestore(saved);
return finishActivation(id, p);
}
entryTop.set(id, m.stackSave());
const p = m._activation(id, DEPTH);
return finishActivation(id, p);
}
// After an activation fully completes, its epilogue leaves the shared SP at
// ITS entry value — restore the central SP before any central-stack wasm runs.
function finishActivation(id, p) {
return p.then((v) => {
m.stackRestore(centralBase);
if (mode === 'green-region') {
const base = regions.get(id);
if (base) { m._free(base); regions.delete(id); }
}
return v;
});
}
if (mode === 'green-copy') {
gateHook = (id, base) => {
const sp = m.stackSave();
const top = entryTop.get(id);
snapshots.set(id, new Uint8Array(m.HEAPU8.buffer, sp, top - sp).slice());
return base.then((v) => {
// restore this activation's spilled bytes + SP right before it resumes
new Uint8Array(m.HEAPU8.buffer).set(snapshots.get(id), sp);
snapshots.delete(id);
m.stackRestore(sp);
return v;
});
};
} else if (mode === 'green-region') {
gateHook = (id, base) => {
actSp.set(id, m.stackSave());
return base.then((v) => {
m.stackRestore(actSp.get(id)); // point shared SP back into this region
return v;
});
};
}
// red: gateHook stays null — raw JSPI, no discipline.
// --- scenarios ---------------------------------------------------------------
async function interleavedRound() {
// A suspends deep; B suspends below A; A completes (epilogue resets SP above
// B's live frames); central stomp scribbles downward; B resumes and verifies.
const pA = startActivation(1);
const pB = startActivation(2);
release(1);
const corruptA = await pA;
if (mode === 'red') {
m._stomp(STOMP_DEPTH);
} else {
m.stackRestore(centralBase); // central discipline: own SP before central work
m._stomp(STOMP_DEPTH);
}
release(2);
const corruptB = await pB;
return { corruptA, corruptB };
}
async function nestedCase() {
// Second activation starts from the first activation's RESUME path — the
// "nested modal over a parked tool body" shape.
const pA = startActivation(3);
let corruptInner = -1;
const innerDone = (async () => {
// arm: when A's gate resolves, immediately start B before A's verify walk
const pB = startActivation(4);
release(4);
corruptInner = await pB;
})();
release(3);
const corruptOuter = await pA;
await innerDone;
return { corruptOuter, corruptInner };
}
let totalA = 0, totalB = 0, totalNested = 0;
if (variant === 'pthread') m._start_churn(); // cross-thread allocator churn during parks
const ROUNDS = mode === 'red' ? 1 : 3;
for (let i = 0; i < ROUNDS; i++) {
const { corruptA, corruptB } = await interleavedRound();
totalA += corruptA; totalB += corruptB;
}
const { corruptOuter, corruptInner } = await nestedCase();
totalNested = corruptOuter + corruptInner;
if (variant === 'pthread') m._stop_churn();
const corrupted = totalA + totalB + totalNested > 0;
const verdict =
mode === 'red' ? (corrupted ? 'RED' : 'UNEXPECTED')
: (corrupted ? 'UNEXPECTED' : 'GREEN');
console.log(
`[JSPI_STACK] SCENARIO ${mode} corruptA=${totalA} corruptB=${totalB} ` +
`corruptNested=${totalNested} verdict=${verdict}`
);
process.exit(verdict === 'UNEXPECTED' ? 1 : 0);

View file

@ -0,0 +1,166 @@
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"><title>jspi-stack harness</title></head>
<body>
<!--
Browser port of driver.mjs — the red/green shadow-stack battery
(emscripten #27364). Runs every mode/variant combo sequentially and emits
one line per combo:
[JSPI_STACK] SCENARIO <mode>/<variant> corruptA=<n> corruptB=<n> corruptNested=<n> verdict=<RED|GREEN|UNEXPECTED>
then [JSPI_STACK] DONE. RED must observe corruption with mitigation OFF
(proves the harness can still see the bug); GREEN legs must be
corruption-free. Parsed by tests/jspi/jspi-stack.spec.ts.
-->
<script type="module">
const DEPTH = 24;
const STOMP_DEPTH = 48;
const REGION_BYTES = 256 * 1024;
async function runScenario(mode, variant) {
const { default: factory } = await import(
variant === 'pthread' ? './stack_test_pt.mjs' : './stack_test.mjs');
// --- gate plumbing (fresh per run; the instance reads the global) ------
const gates = new Map();
let gateHook = null;
globalThis.__jspiGate = (id) => {
const base = new Promise((res) => gates.set(id, res));
return gateHook ? gateHook(id, base) : base;
};
const release = (id) => {
const r = gates.get(id);
if (!r) throw new Error(`no gate armed for id ${id}`);
gates.delete(id);
r(0);
};
const m = await factory();
const centralBase = m.stackSave();
// --- mitigations (same discipline as driver.mjs) -----------------------
const entryTop = new Map();
const actSp = new Map();
const regions = new Map();
const snapshots = new Map();
function startActivation(id) {
if (mode === 'green-region') {
const base = regions.get(id) ?? m._malloc(REGION_BYTES);
regions.set(id, base);
const top = base + REGION_BYTES;
const saved = m.stackSave();
m.stackRestore(top);
entryTop.set(id, top);
const p = m._activation(id, DEPTH);
m.stackRestore(saved);
return finishActivation(id, p);
}
entryTop.set(id, m.stackSave());
const p = m._activation(id, DEPTH);
return finishActivation(id, p);
}
function finishActivation(id, p) {
return p.then((v) => {
m.stackRestore(centralBase);
if (mode === 'green-region') {
const base = regions.get(id);
if (base) { m._free(base); regions.delete(id); }
}
return v;
});
}
if (mode === 'green-copy') {
gateHook = (id, base) => {
const sp = m.stackSave();
const top = entryTop.get(id);
snapshots.set(id, new Uint8Array(m.HEAPU8.buffer, sp, top - sp).slice());
return base.then((v) => {
new Uint8Array(m.HEAPU8.buffer).set(snapshots.get(id), sp);
snapshots.delete(id);
m.stackRestore(sp);
return v;
});
};
} else if (mode === 'green-region') {
gateHook = (id, base) => {
actSp.set(id, m.stackSave());
return base.then((v) => {
m.stackRestore(actSp.get(id));
return v;
});
};
}
// red: gateHook stays null — raw JSPI, no discipline.
async function interleavedRound() {
const pA = startActivation(1);
const pB = startActivation(2);
release(1);
const corruptA = await pA;
if (mode === 'red') {
m._stomp(STOMP_DEPTH);
} else {
m.stackRestore(centralBase);
m._stomp(STOMP_DEPTH);
}
release(2);
const corruptB = await pB;
return { corruptA, corruptB };
}
async function nestedCase() {
const pA = startActivation(3);
let corruptInner = -1;
const innerDone = (async () => {
const pB = startActivation(4);
release(4);
corruptInner = await pB;
})();
release(3);
const corruptOuter = await pA;
await innerDone;
return { corruptOuter, corruptInner };
}
let totalA = 0, totalB = 0, totalNested = 0;
if (variant === 'pthread') m._start_churn();
const ROUNDS = mode === 'red' ? 1 : 3;
for (let i = 0; i < ROUNDS; i++) {
const { corruptA, corruptB } = await interleavedRound();
totalA += corruptA; totalB += corruptB;
}
const { corruptOuter, corruptInner } = await nestedCase();
totalNested = corruptOuter + corruptInner;
if (variant === 'pthread') m._stop_churn();
const corrupted = totalA + totalB + totalNested > 0;
const verdict =
mode === 'red' ? (corrupted ? 'RED' : 'UNEXPECTED')
: (corrupted ? 'UNEXPECTED' : 'GREEN');
console.log(
`[JSPI_STACK] SCENARIO ${mode}/${variant} corruptA=${totalA} ` +
`corruptB=${totalB} corruptNested=${totalNested} verdict=${verdict}`);
}
const COMBOS = [
['red', 'single'],
['green-copy', 'single'],
['green-region', 'single'],
['green-copy', 'pthread'],
['green-region', 'pthread'],
];
try {
for (const [mode, variant] of COMBOS) {
await runScenario(mode, variant);
}
console.log('[JSPI_STACK] DONE');
} catch (e) {
console.log('[JSPI_STACK] FATAL ' + e);
}
</script>
</body>
</html>

View file

@ -0,0 +1,131 @@
// jspi-stack — shadow-stack red/green harness (emscripten #27364).
//
// JSPI switches the NATIVE wasm stack per promising activation, but the C/C++
// linear-memory spill stack (__stack_pointer) is shared module state. Two
// concurrently-suspended activations therefore interleave their spill frames
// in one region, and a completed activation's epilogue resets __stack_pointer
// over a still-suspended activation's live frames. This harness makes that
// corruption OBSERVABLE (red leg) and proves a mitigation closes it (green
// leg). Mitigation policy lives entirely in the JS driver so the same binary
// serves both legs; see driver.mjs.
//
// Wx-free and libcontext-free on purpose: a failure here can only be the
// primitive (the sched-context doctrine).
#include <emscripten.h>
#include <emscripten/em_js.h>
#include <emscripten/stack.h>
#include <cstdint>
#include <cstdio>
// The suspend point. Under -sJSPI every EM_ASYNC_JS import is wrapped in
// WebAssembly.Suspending automatically. The driver resolves gates by id, in
// whatever order the scenario prescribes.
EM_ASYNC_JS(int, js_gate, (int id), {
return await globalThis.__jspiGate(id);
});
namespace {
// Per-frame canary: value depends on activation id, recursion depth, and slot,
// so a frame overwritten by ANY other frame (same or different activation)
// cannot verify.
inline uint32_t canary(int id, int depth, int slot) {
return 0x9E3779B9u * (uint32_t)(id * 1000003 + depth * 8191 + slot + 1);
}
constexpr int SLOTS = 64; // 256 B of spill payload per frame
// Recurse to `depth`, stamping canaries into a stack buffer at every level;
// suspend at the bottom; verify every frame's canaries on the way back up.
// The buffer is spilled to the shadow stack (address taken via the volatile
// pointer, so it cannot live in registers/locals only). noinline keeps one
// real spill frame per recursion level.
__attribute__((noinline))
int canary_frame(int id, int depth) {
uint32_t buf[SLOTS];
volatile uint32_t* p = buf;
for (int i = 0; i < SLOTS; i++) p[i] = canary(id, depth, i);
int corrupt = 0;
if (depth > 0) {
corrupt = canary_frame(id, depth - 1);
} else {
js_gate(id); // park this activation; driver decides when it wakes
}
for (int i = 0; i < SLOTS; i++) {
if (p[i] != canary(id, depth, i)) corrupt++;
}
return corrupt;
}
} // namespace
extern "C" {
// A promising activation (must be in JSPI_EXPORTS): recurse `depth` frames,
// suspend at the bottom, return the number of corrupted canary words observed
// while unwinding. 0 == clean.
EMSCRIPTEN_KEEPALIVE
int activation(int id, int depth) {
return canary_frame(id, depth);
}
// Plain (non-promising) export: deep central-stack work that scribbles its own
// frames. This is the "other wasm work" that grows down over a suspended
// activation's live frames once another activation's epilogue reset the SP.
EMSCRIPTEN_KEEPALIVE
__attribute__((noinline))
int stomp(int depth) {
uint32_t buf[SLOTS];
volatile uint32_t* p = buf;
for (int i = 0; i < SLOTS; i++) p[i] = 0xDEADBEEFu;
int acc = (int)p[depth % SLOTS];
if (depth > 0) acc ^= stomp(depth - 1);
return acc;
}
// Introspection for the driver's mitigation bookkeeping.
EMSCRIPTEN_KEEPALIVE uintptr_t stack_current(void) { return emscripten_stack_get_current(); }
EMSCRIPTEN_KEEPALIVE uintptr_t stack_base(void) { return emscripten_stack_get_base(); }
EMSCRIPTEN_KEEPALIVE uintptr_t stack_end(void) { return emscripten_stack_get_end(); }
} // extern "C"
#ifdef __EMSCRIPTEN_PTHREADS__
// Production shape: worker threads never suspend, but they churn the shared
// allocator while main-thread activations sit suspended. The churn thread
// must not perturb suspended activations' spill frames.
#include <atomic>
#include <thread>
#include <cstdlib>
namespace {
std::atomic<bool> g_churn{false};
std::thread g_churn_thread;
}
extern "C" {
EMSCRIPTEN_KEEPALIVE void start_churn(void) {
g_churn = true;
g_churn_thread = std::thread([] {
while (g_churn) {
void* blocks[32];
for (auto& b : blocks) b = std::malloc(64 + (rand() % 512));
for (auto& b : blocks) std::free(b);
}
});
}
EMSCRIPTEN_KEEPALIVE void stop_churn(void) {
g_churn = false;
if (g_churn_thread.joinable()) g_churn_thread.join();
}
} // extern "C"
#endif
int main() {
// Driver-controlled; nothing to do. Keep the runtime alive for export calls.
EM_ASM({ console.log("[JSPI_STACK] READY"); });
return 0;
}

View file

@ -1,122 +0,0 @@
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
/**
* S2 scheduler-core gates (docs/features/async/17 §3d N1/N4, §4 S2).
* Runs against the races harness; the scheduler shim is the only runtime
* (the legacy handlesleep opt-out was deleted at doc 20 D-1). The self-skip
* on shim-less glue is kept as a stale-build guard.
*
* N1 — single-writer tripwire: Asyncify.currData is an accessor; a pure-JS
* write without scheduler authorization beacons (and throws in strict mode).
* The meta-test INTRODUCES a stray writer and expects the tripwire to fire —
* proving the alarm works, not merely that nobody tripped it.
*
* N4 — wake-never-rewinds-mid-transition: across the races battery (which
* stages overlapping parks, nested modals, out-of-order wakes) the
* scheduler's books must be coherent at settle: no queued wake left, every
* deferral drained, zero unplanned strays, battery green.
*/
type SchedulerState = {
state(): string;
strayWrites: number;
strictStrays: boolean;
readyWakes: unknown[];
deferredWakes: number;
drainedWakes: number;
};
function findSummary(logs: string[]) {
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
}
async function bootAndSettle(
page: import('@playwright/test').Page,
testLogger: { consoleLogs: string[] },
): Promise<boolean> {
await page.goto('/standalone/asyncify-races/races_test.html');
const loaded = await tryLoadApp(page, 30000);
expect(loaded, 'races harness should load').toBe(true);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 60000,
message: 'battery should emit its SUMMARY line',
})
.not.toBeNull();
return page.evaluate(
() => !!(globalThis as unknown as { __wxScheduler?: unknown }).__wxScheduler,
);
}
test.describe('S2 scheduler core (scheduler glue)', () => {
test('N4: battery leaves coherent books — wakes drained, no strays, battery green', async ({
page,
testLogger,
}) => {
test.setTimeout(180000);
const scheduler = await bootAndSettle(page, testLogger);
test.skip(!scheduler, 'legacy glue — scheduler core absent');
await expect
.poll(
() =>
page.evaluate(
() =>
(globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler
.readyWakes.length,
),
{ timeout: 30000, intervals: [250] },
)
.toBe(0);
const books = await page.evaluate(() => {
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
return {
ready: S.readyWakes.length,
deferred: S.deferredWakes,
drained: S.drainedWakes,
strays: S.strayWrites,
state: S.state(),
};
});
console.log(`[TEST] scheduler books: ${books.state}`);
expect(books.ready, 'no wake left queued after settle').toBe(0);
expect(books.drained, 'every deferred wake was drained').toBe(books.deferred);
expect(books.strays, 'no stray currData writes during the battery').toBe(0);
const fails = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] FAIL '));
expect(fails, 'battery green under the scheduler core').toEqual([]);
});
test('N1 meta: an introduced stray currData write trips the alarm', async ({
page,
testLogger,
}) => {
test.setTimeout(180000);
const scheduler = await bootAndSettle(page, testLogger);
test.skip(!scheduler, 'legacy glue — scheduler core absent');
const result = await page.evaluate(() => {
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
const A = (globalThis as unknown as { Asyncify: { currData: number | null } }).Asyncify;
const before = S.strayWrites;
const saved = A.currData;
A.currData = saved; // value-preserving, still a stray WRITE
const counted = S.strayWrites === before + 1;
S.strictStrays = true;
let threw = false;
try {
A.currData = saved;
} catch {
threw = true;
}
S.strictStrays = false;
return { counted, threw };
});
expect(result.counted, 'stray write was counted').toBe(true);
expect(result.threw, 'strict mode throws on stray write').toBe(true);
const beacons = testLogger.consoleLogs.filter((l) => l.includes('stray-currdata-write'));
expect(beacons.length, 'stray write beaconed to the console').toBeGreaterThan(0);
});
});

View file

@ -1,249 +0,0 @@
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
/**
* Design B D1 gate — scheduler contexts (docs/features/async/20 §6 D1, 21).
*
* Drives tests/apps/standalone/sched-context/sched_context_test.cpp, which
* exercises wasm/sched/context.{h,cpp}: create / yield_park / mark_ready /
* drain. The point of the layer is that a resume is something the REGISTRY
* knows rather than something a guard guesses (doc 19's disease), so the
* battery asserts the invariants, and this spec additionally gates the thing
* doc 20 §7 risk 1 demands: contexts must be bounded and MEASURED.
*
* No production path runs on contexts yet — that starts at D2.
*/
const SCENARIOS = [
'create_runs_on_drain',
'park_and_resume',
'no_park_in_place',
'parked_does_not_block',
'fifo_order',
'one_transition_in_flight',
'registry_refusals',
'deep_park_sizing',
'fiber_nests_in_context',
'foreign_stack_refused',
'fiber_roundtrip',
'fiber_release_suspended',
'fiber_and_star_coexist',
'star_transfer_call_is_synchronous',
'star_transfer_chain',
'async_wake',
];
/** Frame count the deep-park scenario recurses to before parking (must match
* DEEP_PARK_FRAMES in the harness) — the divisor for the per-frame cost. */
const DEEP_PARK_FRAMES = 64;
/** Memory ceiling for the battery (doc 20 risk 1). The worst scenario holds
* ~6 live contexts; 16 leaves headroom without letting a leak hide. */
const MAX_PEAK_LIVE = 16;
/** Peak bytes the battery may charge to contexts. At 256 KB per context
* (128 KB C stack + 128 KB asyncify buffer) 16 contexts = 4 MB. */
const MAX_PEAK_BYTES = 4 * 1024 * 1024;
type Stats = {
live: number;
peakLive: number;
created: number;
finished: number;
transitions: number;
refusals: number;
foreignStackRefusals: number;
running: number;
transitionInFlight: boolean;
readyQueued: number;
bytes: number;
peakBytes: number;
perContextBytes: number;
cStackBytes: number;
asyncifyBytes: number;
asyncifyHighWater: number;
// Fiber lane (doc 22 Phase A) — libcontext's clients, separate counters so
// this battery's star assertions keep meaning what they meant.
fiberLive: number;
fiberPeakLive: number;
fiberCreated: number;
fiberReleased: number;
fiberSwaps: number;
fiberRefusals: number;
fiberReleasedSuspended: number;
fiberReleasedRunning: number;
fiberNonEnterableSwaps: number;
fiberRunning: number;
fiberBytes: number;
fiberPeakBytes: number;
fiberAsyncifyHighWater: number;
};
function findLine(logs: string[], marker: string): string | undefined {
return logs.find((l) => l.includes(`[SCHED_CTX] ${marker}`));
}
function parseTagged<T>(logs: string[], marker: string): T | null {
const line = findLine(logs, marker);
if (!line) return null;
const json = line.slice(line.indexOf(`[SCHED_CTX] ${marker}`) + `[SCHED_CTX] ${marker}`.length);
return JSON.parse(json.trim()) as T;
}
test.describe('Design B D1 — scheduler contexts', () => {
test('battery: every context invariant holds', async ({ page, testLogger }) => {
await page.goto('/standalone/sched-context/sched_context_test.html');
expect(await tryLoadApp(page, 30000), 'sched-context harness should load').toBe(true);
await expect
.poll(() => findLine(testLogger.consoleLogs, 'SUMMARY') ?? null, {
timeout: 30000,
message: 'battery should emit SUMMARY (a missing one means a wedge — e.g. a context that never resumed)',
})
.not.toBeNull();
const summary = findLine(testLogger.consoleLogs, 'SUMMARY')!;
const m = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
expect(m, `unparseable summary: ${summary}`).not.toBeNull();
const [total, passed, failed] = [Number(m![1]), Number(m![2]), Number(m![3])];
const fails = testLogger.consoleLogs.filter((l) => l.includes('[SCHED_CTX] FAIL '));
expect(fails, `failures: ${fails.join(' || ')}`).toHaveLength(0);
expect(failed).toBe(0);
expect(total, 'every scenario ran').toBe(SCENARIOS.length);
expect(passed).toBe(SCENARIOS.length);
for (const name of SCENARIOS) {
expect
.soft(
testLogger.consoleLogs.some((l) => l.includes(`[SCHED_CTX] PASS ${name}`)),
`scenario ${name} should PASS`,
)
.toBe(true);
}
});
test('memory: contexts are bounded and measured (doc 20 risk 1)', async ({
page,
testLogger,
}) => {
await page.goto('/standalone/sched-context/sched_context_test.html');
expect(await tryLoadApp(page, 30000)).toBe(true);
await expect
.poll(() => findLine(testLogger.consoleLogs, 'STATS') ?? null, { timeout: 30000 })
.not.toBeNull();
const stats = parseTagged<Stats>(testLogger.consoleLogs, 'STATS')!;
console.log(`[TEST] context stats: ${JSON.stringify(stats)}`);
// --- the ceiling: a leak must fail here, not in a user's tab ------------
expect(stats.peakLive, 'peak live contexts within the ceiling').toBeLessThanOrEqual(
MAX_PEAK_LIVE,
);
expect(stats.peakBytes, 'peak context bytes within the budget').toBeLessThanOrEqual(
MAX_PEAK_BYTES,
);
// --- no leaks: every context the battery created was destroyed ----------
expect(stats.live, 'no context left live after the battery').toBe(0);
expect(stats.bytes, 'no context bytes left charged').toBe(0);
expect(stats.finished, 'every created context finished').toBe(stats.created);
// --- the layer did real work (guards against a vacuous pass) -----------
expect(stats.created, 'the battery created contexts').toBeGreaterThan(5);
expect(stats.transitions, 'the scheduler performed swaps').toBeGreaterThan(
stats.created,
);
// --- the invariant: nothing left mid-transition ------------------------
expect(stats.transitionInFlight, 'no transition left in flight').toBe(false);
expect(stats.running, 'no context left running').toBe(0);
expect(stats.readyQueued, 'ready queue drained').toBe(0);
// --- refusals are EXPECTED here: three scenarios provoke them ----------
// (yield_park off a context, destroy while parked, mark_ready twice /
// unknown id). Zero would mean those scenarios stopped provoking.
expect(stats.refusals, 'illegal operations were refused and counted').toBeGreaterThan(0);
// --- the D3 blocker, pinned -------------------------------------------
// A wait called from a fiber running ON TOP of a context (a KiCad tool
// coroutine opening a dialog) must be refused, not allowed to yield
// someone else's context — that would save the tool fiber's stack into the
// host context's fiber struct. Exactly one scenario provokes it.
expect(
stats.foreignStackRefusals,
'a yield from a foreign stack was refused rather than corrupting the host context',
).toBe(1);
// --- sizing evidence (doc 20 risk 1: derive, don't inherit) ------------
// The high-water mark is what a future buffer size must be justified by.
// Assert it is both real (>0 — the parks did save frames) and comfortably
// inside the buffer, so the number in the log is trustworthy.
expect(stats.asyncifyHighWater, 'asyncify use was measured').toBeGreaterThan(0);
expect(
stats.asyncifyHighWater,
'asyncify high-water inside the per-context buffer',
).toBeLessThan(stats.asyncifyBytes);
// Per-frame cost from the deep-park scenario: the number a future buffer
// size must be derived from. The high-water is dominated by that park
// (every other scenario parks 1-2 frames deep).
//
// CAVEAT, do not skip when quoting this number: the harness's frames carry
// three locals each, so this is a FLOOR for per-frame cost, not a
// production estimate. Real park sites (a lib fetch inside commit.Push →
// connectivity → font work) save far more per frame — that is why
// libcontext runs a 512 K buffer after a 64 K one silently overflowed.
// Treat this as "the measurement apparatus works and these are its units";
// the sizing decision needs deep-park numbers from real bridges at D3/D4.
const perFrame = stats.asyncifyHighWater / DEEP_PARK_FRAMES;
console.log(
`[TEST] asyncify sizing: high-water ${stats.asyncifyHighWater}B of ` +
`${stats.asyncifyBytes}B buffer ` +
`(${((stats.asyncifyHighWater / stats.asyncifyBytes) * 100).toFixed(1)}%); ` +
`~${perFrame.toFixed(0)}B per frame over ${DEEP_PARK_FRAMES} frames ` +
`→ the ${stats.asyncifyBytes}B buffer holds ~${Math.floor(
stats.asyncifyBytes / Math.max(perFrame, 1),
)} frames`,
);
// The deep park must actually dominate — otherwise the per-frame number
// above is noise from a shallow park and cannot justify any size.
expect(
stats.asyncifyHighWater,
'the deep park (64 frames) drove the high-water mark',
).toBeGreaterThan(1024);
// No buffer-pressure beacon should have fired (>75% use).
const pressure = testLogger.consoleLogs.filter((l) => l.includes('BUFFER-PRESSURE'));
expect(pressure, `buffer pressure: ${pressure.join(' || ')}`).toHaveLength(0);
// --- fiber lane (doc 22 Phase A): libcontext semantics over the registry --
// The adopted root is the only fiber that outlives the battery (libcontext's
// main context never dies); everything else was released.
expect(stats.fiberLive, 'only the adopted root fiber remains').toBe(1);
// Phase B invariant, stronger than the Phase A one it replaces: once the
// pump is quiescent NO fiber is on the CPU — the scheduler is. Under the
// star that is what "between transitions" means.
expect(stats.fiberRunning, 'no fiber is current when the pump is quiescent').toBe(0);
expect(stats.fiberCreated, 'every fiber the battery made').toBeGreaterThanOrEqual(8);
expect(stats.fiberReleased, 'all but the adopted root were released').toBe(
stats.fiberCreated - 1,
);
expect(stats.fiberSwaps, 'symmetric swaps and star transfers happened').toBeGreaterThanOrEqual(
12,
);
// Both releases happened mid-suspend — libcontext's refcount-drop shape.
expect(stats.fiberReleasedSuspended, 'suspended releases are legal and counted').toBe(2);
// One deliberate stale-id swap was refused (use-after-free made loud).
expect(stats.fiberRefusals, 'a stale fiber id was refused').toBeGreaterThanOrEqual(1);
// THE Phase A tripwires: no swap ever entered a non-enterable fiber, and
// no release ever hit a fiber the registry believed was running.
expect(stats.fiberNonEnterableSwaps, 'zero swaps into stale rewind state').toBe(0);
expect(stats.fiberReleasedRunning, 'zero releases of a running fiber').toBe(0);
// The sizing input Phase E reads: a suspended fiber's capture was measured
// (sampled before the resume consumes it, when the buffer is non-empty).
expect(
stats.fiberAsyncifyHighWater,
'fiber-lane asyncify use was measured while suspended',
).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,55 @@
import { test, expect } from '../e2e/utils/fixtures';
// Contract battery for the JSPI libcontext coroutine backend
// (kicad/thirdparty/libcontext/libcontext.cpp under PCBJAM_JSPI). The harness
// (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
// call-in-call routed by enterer inference, RunMainStack payload propagation,
// ghost-resume refusal (dead tombstones), mid-body release census.
//
// Output contract: per-case "[JSPI_CORO] CASE <name> PASS|FAIL" then
// "[JSPI_CORO] SUMMARY passed=<n> failed=<n>".
const EXPECTED_PASSES = 15;
function findSummary(logs: string[]) {
return logs.find((l) => l.includes('[JSPI_CORO] SUMMARY'));
}
function assertSummary(logs: string[]) {
const summary = findSummary(logs)!;
const match = summary.match(/passed=(\d+)\s+failed=(\d+)/);
expect(match, `summary parseable: ${summary}`).not.toBeNull();
expect(Number(match![1]), 'all cases pass').toBe(EXPECTED_PASSES);
expect(Number(match![2]), 'no case fails').toBe(0);
const fails = logs.filter((l) => l.includes('[JSPI_CORO] CASE') && l.includes('FAIL'));
expect(fails, `FAIL cases: ${fails.join(' || ')}`).toHaveLength(0);
const fatal = logs.filter((l) => l.includes('[JSPI_CORO] FATAL'));
expect(fatal, `harness fatal: ${fatal.join(' || ')}`).toHaveLength(0);
}
test.describe('JSPI coroutine backend contract battery', () => {
test('single-thread build: 15/15 protocol cases pass', async ({ page, testLogger }) => {
await page.goto('/standalone/jspi-coroutine/');
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 60000,
message: 'harness should emit its SUMMARY line',
})
.not.toBeNull();
assertSummary(testLogger.consoleLogs);
});
test('pthread build: 15/15 protocol cases pass', async ({ page, testLogger }) => {
await page.goto('/standalone/jspi-coroutine/?pt=1');
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
timeout: 60000,
message: 'pthread harness should emit its SUMMARY line',
})
.not.toBeNull();
assertSummary(testLogger.consoleLogs);
});
});

View file

@ -0,0 +1,67 @@
import { test, expect } from '../e2e/utils/fixtures';
// The permanent regression tripwire for the JSPI shadow-stack hazard
// (emscripten #27364): JSPI switches the native stack per activation but NOT
// the C spill stack (__stack_pointer), so concurrently-suspended activations
// corrupt each other unless a discipline is applied. The harness
// (tests/apps/standalone/jspi-stack, browser battery in its index.html) runs:
//
// red/single mitigation OFF → MUST detect corruption (the
// harness can still see the bug; a silent red means
// the tripwire itself broke)
// green-copy/single per-suspension [sp, entrySp] snapshot+restore — the
// discipline jspi-scheduler.js applies to wx entries
// green-region/single per-activation stack region + SP swap — the
// discipline libcontext's JSPI backend applies to
// KiCad coroutines
// green-copy/pthread both disciplines again with cross-thread allocator
// green-region/pthread churn running during the parks
//
// Output contract per combo:
// [JSPI_STACK] SCENARIO <mode>/<variant> corruptA=.. corruptB=.. corruptNested=.. verdict=<RED|GREEN|UNEXPECTED>
const COMBOS = [
{ name: 'red/single', verdict: 'RED' },
{ name: 'green-copy/single', verdict: 'GREEN' },
{ name: 'green-region/single', verdict: 'GREEN' },
{ name: 'green-copy/pthread', verdict: 'GREEN' },
{ name: 'green-region/pthread', verdict: 'GREEN' },
];
test.describe('JSPI shadow-stack red/green battery', () => {
test('red detects corruption; both mitigations hold, incl. pthread churn', async ({
page,
testLogger,
}) => {
test.setTimeout(120000);
await page.goto('/standalone/jspi-stack/');
await expect
.poll(
() =>
testLogger.consoleLogs.find((l) => l.includes('[JSPI_STACK] DONE')) ??
testLogger.consoleLogs.find((l) => l.includes('[JSPI_STACK] FATAL')) ??
null,
{
timeout: 90000,
message: 'battery should emit DONE (FATAL/silence = harness wedge)',
},
)
.not.toBeNull();
const fatal = testLogger.consoleLogs.filter((l) => l.includes('[JSPI_STACK] FATAL'));
expect(fatal, `harness fatal: ${fatal.join(' || ')}`).toHaveLength(0);
for (const combo of COMBOS) {
const line = testLogger.consoleLogs.find((l) =>
l.includes(`[JSPI_STACK] SCENARIO ${combo.name} `),
);
expect.soft(line, `combo ${combo.name} should have run`).toBeTruthy();
if (line) {
expect
.soft(line, `combo ${combo.name} verdict`)
.toContain(`verdict=${combo.verdict}`);
}
}
});
});

View file

@ -1,13 +1,19 @@
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
// Red-green specs for the Asyncify race-condition harness
// (tests/apps/standalone/asyncify-races/races_test.cpp — see docs/features/async/).
// Successor of tests/asyncify/asyncify-races.spec.ts (retired with the
// asyncify backend): the SEMANTIC suspension-race scenarios, run against the
// same races harness (tests/apps/standalone/asyncify-races/races_test.cpp)
// built for JSPI. The scenarios express through public wx + coroutine APIs —
// nested modal LIFO, out-of-order wake resolution, no-lost-wakes, nested-loop
// teardown-on-error — so they are exactly as meaningful under JSPI as under
// asyncify; only the failure MODES they'd catch differ (activation misnesting
// or a lost wait token instead of a clobbered rewind buffer). The harness's
// Fibers.* probes self-disable on non-asyncify glue ('n/a').
//
// GREEN-target tests assert the desired end state (clean pass, clean console).
// While a fix is missing they FAIL — that failing run is the recorded "red".
// (The shim-ablation builds and their pins were retired at doc 20 D-1 together
// with the legacy runtime they exercised; the scheduler shim is the only
// runtime, and the battery below runs every scenario against it.)
// Retired asyncify-mechanism gates, deliberately NOT ported: the
// Asyncify.currData single-writer tripwire (N1) and the deferred-wake books
// (readyWakes/drainedWakes) — those states are unrepresentable under JSPI.
// Their intent (coherent books at settle) lives on below via __wxWaitDump.
const BATTERY = [
'post_park_fiber_swap',
@ -17,14 +23,25 @@ const BATTERY = [
];
const CRASH_SIGNATURES = [
// backend-agnostic trouble
'index out of bounds',
'indirect call to null',
'invalid state',
'unwind',
// assertion-free builds surface a clobbered doRewind as a TypeError
'is not a function',
// the JSPI-specific loud failure: an un-promised export tried to suspend
'suspenderror',
'trying to suspend',
];
type WaitDump = {
pendingWaits: number;
runningActivations: number;
suspendedActivations: unknown[];
mutatorQueueDepth: number;
waitsBegun: number;
waitsResolved: number;
};
function findSummary(logs: string[]) {
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
}
@ -49,7 +66,7 @@ function realErrors(testLogger: { errors: string[] }) {
return testLogger.errors.filter((e) => !e.includes('favicon'));
}
test.describe('Asyncify races — green targets (full shims)', () => {
test.describe('Suspension races — green targets (jspi backend)', () => {
test('battery: all chained scenarios pass with a clean console', async ({
page,
testLogger,
@ -86,6 +103,56 @@ test.describe('Asyncify races — green targets (full shims)', () => {
expect(realErrors(testLogger), 'no page errors').toHaveLength(0);
});
test('battery leaves coherent books — no pending waits, no stuck activations', async ({
page,
testLogger,
}) => {
// Successor of the retired N4 gate: after the battery settles, the
// scheduler's books must be clean — every begun wait resolved or
// consumed, no activation left suspended, no queued mutators.
test.setTimeout(180000);
await page.goto('/standalone/asyncify-races/races_test.html');
const loaded = await tryLoadApp(page, 30000);
expect(loaded, 'races harness should load').toBe(true);
await expect
.poll(() => findSummary(testLogger.consoleLogs) ?? null, { timeout: 60000 })
.not.toBeNull();
const hasDump = await page.evaluate(
() => typeof (globalThis as unknown as { __wxWaitDump?: unknown }).__wxWaitDump === 'function',
);
test.skip(!hasDump, 'stale build — no __wxWaitDump');
// The last scenario's teardown can lag the SUMMARY line by a tick.
// main() legitimately parks on its per-frame yield forever ("frame") —
// the invariant is that no TOKEN wait (modal/nested/sleep/…) and no
// queued mutator survives the battery.
const stuck = (dump: WaitDump) =>
(dump.suspendedActivations as { waitKind: string | null }[]).filter(
(a) => a.waitKind !== 'frame',
).length + dump.mutatorQueueDepth;
await expect
.poll(
() =>
page.evaluate(() => {
const dump = (
globalThis as unknown as { __wxWaitDump: () => WaitDump }
).__wxWaitDump();
return (dump.suspendedActivations as { waitKind: string | null }[]).filter(
(a) => a.waitKind !== 'frame',
).length + dump.mutatorQueueDepth;
}),
{ timeout: 30000, intervals: [250] },
)
.toBe(0);
const books = await page.evaluate(() =>
(globalThis as unknown as { __wxWaitDump: () => WaitDump }).__wxWaitDump(),
);
console.log(`[TEST] scheduler books: ${JSON.stringify(books)}`);
expect(stuck(books), 'no token wait or mutator left stuck').toBe(0);
});
test('modal_in_modal_in_modal: three nested ShowModals resolve LIFO', async ({
page,
testLogger,
@ -93,9 +160,9 @@ test.describe('Asyncify races — green targets (full shims)', () => {
// Historical red: the pre-scheduler wx dialog.cpp kept the modal resolver
// in a single slot (Module._endModal), so with three nested modals the
// middle EndModal resolved nothing and its ShowModal parked forever.
// Green since the LIFO resolver semantics, now the scheduler wait
// registry's per-kind stacks (doc 17 S4; legacy machinery deleted at
// doc 20 D-1).
// Green since the LIFO resolver semantics — under JSPI the wait
// registry's per-kind LIFO stacks (jspi-scheduler.js, contract-identical
// to the asyncify shim's S4).
await page.goto('/standalone/asyncify-races/races_test.html#only=modal_in_modal_in_modal');
await tryLoadApp(page, 30000);
@ -152,7 +219,7 @@ test.describe('Asyncify races — green targets (full shims)', () => {
expect(failed).toBe(0);
});
test('sleep-park mode: park throw must not escape as an unhandled "unwind" rejection', async ({
test('sleep-park mode: park throw must not escape as an unhandled rejection', async ({
page,
testLogger,
}) => {
@ -167,13 +234,12 @@ test.describe('Asyncify races — green targets (full shims)', () => {
expect(passed).toBe(1);
expect(failed).toBe(0);
const unwindLeaks = [...testLogger.errors, ...testLogger.consoleLogs].filter(
(l) =>
l.toLowerCase().includes('unwind') &&
!l.includes('[ASYNCIFY_RACES]') &&
// console *log* lines about unwind from our own shims are fine; errors are not
(testLogger.errors.includes(l) || l.toLowerCase().includes('uncaught'))
const rejectionLeaks = testLogger.errors.filter(
(l) => !l.includes('[ASYNCIFY_RACES]') && !l.includes('favicon')
);
expect(unwindLeaks, `unwind escaped the park: ${unwindLeaks.join(' || ')}`).toHaveLength(0);
expect(
rejectionLeaks,
`park throw escaped: ${rejectionLeaks.join(' || ')}`
).toHaveLength(0);
});
});

View file

@ -104,6 +104,8 @@ test.describe('Add Footprint chooser close (doc-19 dead-app repro)', () => {
framesBefore,
{ timeout: 40000 },
)
// best-effort wait: the frame-count expect right below is the
// real assertion; a timeout must reach it, not throw (documented)
.catch(() => {});
const framesOpen = await frameCount(page);
console.log(`[TEST] frames: ${framesBefore} → ${framesOpen}`);
@ -131,6 +133,8 @@ test.describe('Add Footprint chooser close (doc-19 dead-app repro)', () => {
framesBefore,
{ timeout: 20000 },
)
// best-effort wait: the trap check below is the real assertion;
// a stalled close must reach it, not throw here (documented)
.catch(() => {});
await assertResponsive(page, 'chooser cancel');

View file

@ -1,6 +1,6 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
import { expectGuardsSilent } from "./utils/guard-beacons";
import { expectGuardsSilent } from "./utils/wait-beacons";
/**
* Timer-park concurrent-Asyncify repro (gal-refresh-timer investigation).

View file

@ -1,5 +1,5 @@
// Guard-beacon extraction for the mailbox/scheduler migration
// (docs/features/async/17-mailbox-scheduler-plan.md, step S0.3).
// Wait-beacon extraction (JSPI-era successor of guard-beacons.ts; the
// mailbox/scheduler migration doc is docs/features/async/17, step S0.3).
//
// Every legacy anti-collision guard announces itself on the console when it fires.
// During the migration each superseded guard is kept as a TRIPWIRE: the mailbox is
@ -30,6 +30,10 @@ export interface GuardBeaconCounts {
libcontext: BeaconFamilyCount;
// open-settle gate giving up (open-flow.ts)
openSettleFailed: BeaconFamilyCount;
// jspi-scheduler.js turnstile/containment beacons (JSPI builds)
wxScheduler: BeaconFamilyCount;
// libcontext JSPI backend ghost/refused-transition census
libctxJspi: BeaconFamilyCount;
// scheduler build marker — identifies the dual-glue variant, not a guard
schedulerBuild: boolean;
}
@ -45,6 +49,10 @@ const FAMILY_PATTERNS: Record<
libcontext:
/\[collab-fcontext\] (jump-refused|jump-refused-hot-main|hot-main-swap-out|jump-hot-into-main|jump-ghost|entry-orphaned)/,
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)/,
libctxJspi: /\[libctx-jspi\] ghost\/refused/,
};
const OCCURRENCE_RE = /\(occurrence (\d+)\)/;
@ -61,6 +69,8 @@ export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
wxAsyncify: emptyFamily(),
libcontext: emptyFamily(),
openSettleFailed: emptyFamily(),
wxScheduler: emptyFamily(),
libctxJspi: emptyFamily(),
schedulerBuild: false,
};

View file

@ -4,10 +4,10 @@
"description": "Playwright tests for wxWidgets WASM and KiCad",
"scripts": {
"test": "npm run test:e2e",
"test:e2e": "npm run setup:kicad && playwright test --project=wx-chromium --project=kicad-firefox --project=kicad-chromium --project=asyncify-firefox --project=coroutine-firefox",
"test:e2e": "npm run setup:kicad && playwright test --project=wx-chromium --project=kicad-firefox --project=kicad-chromium --project=jspi-firefox --project=coroutine-firefox",
"test:kicad": "npm run setup:kicad && playwright test --project=kicad-firefox",
"test:kicad:headed": "npm run setup:kicad && playwright test --project=kicad-chrome --headed",
"test:asyncify:safari": "playwright test --project=asyncify-webkit",
"test:jspi:chrome": "playwright test --project=jspi-chrome",
"test:perf": "npm run setup:kicad && playwright test --project=perf --workers=1",
"test:web": "npm run setup:kicad && playwright test --config=playwright-web.config.ts --project=web-firefox",
"test:web:ci": "npm run setup:kicad && playwright test --config=playwright-web.config.ts --project=web-firefox --project=web-chromium --project=web-mobile",

View file

@ -53,15 +53,31 @@ const CHROMIUM_CI_ARGS = process.env.CI
}
: {};
// Headless Firefox can't create a GL context on GPU-less CI VMs — run headed
// under Xvfb (the CI step wraps in xvfb-run) with the no-GPU blocklist bypassed,
// same as the kicad-firefox project in playwright.config.ts.
// 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).
// 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 —
// keep this a single composed object, never two competing spreads.
const FIREFOX_PREFS_ALWAYS = {
'javascript.options.wasm_js_promise_integration': true,
};
const FIREFOX_CI_OPTS = process.env.CI
? {
headless: false,
launchOptions: { firefoxUserPrefs: { 'webgl.force-enabled': true } },
launchOptions: {
firefoxUserPrefs: {
...FIREFOX_PREFS_ALWAYS,
'webgl.force-enabled': true,
},
},
}
: {};
: {
launchOptions: {
firefoxUserPrefs: { ...FIREFOX_PREFS_ALWAYS },
},
};
export default defineConfig({
globalSetup: './web/global-setup-web.ts',

View file

@ -101,21 +101,32 @@ const CHROMIUM_CI_ARGS = process.env.CI
}
: {};
// CI-only Firefox prefs: GPU-less CI VMs can't create a headless GL context
// (FEATURE_FAILURE_WEBGL_EXHAUSTED_DRIVERS) — run headed under Xvfb, where
// GLX + Mesa llvmpipe provides software WebGL, and bypass the no-GPU
// blocklist so the GAL canvas gets a context. CI invokes the suite via
// `xvfb-run`.
// 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).
// 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
// `xvfb-run`. NOTE: spreads REPLACE launchOptions wholesale — this must stay
// the single composed object, never two competing spreads.
const FIREFOX_PREFS_ALWAYS = {
'javascript.options.wasm_js_promise_integration': true,
};
const FIREFOX_CI_OPTS = process.env.CI
? {
headless: false,
launchOptions: {
firefoxUserPrefs: {
...FIREFOX_PREFS_ALWAYS,
'webgl.force-enabled': true,
},
},
}
: {};
: {
launchOptions: {
firefoxUserPrefs: { ...FIREFOX_PREFS_ALWAYS },
},
};
export default defineConfig({
globalSetup: './global-setup.ts',
@ -184,12 +195,14 @@ export default defineConfig({
},
},
{
// Asyncify-layer harnesses: the race-condition red-green battery and
// the Design-B scheduler-context gate. One heavy WASM app at a time.
// Matches EVERY spec in ./asyncify — a new harness here is covered by
// construction rather than by remembering to widen this pattern.
name: 'asyncify-firefox',
testDir: './asyncify',
// JSPI-layer harnesses: the shadow-stack red/green battery, the
// coroutine-backend contract battery, and the semantic suspension-race
// scenarios (successor of the retired ./asyncify suite). One heavy
// WASM app at a time. Matches EVERY spec in ./jspi — a new harness
// here is covered by construction rather than by remembering to widen
// this pattern.
name: 'jspi-firefox',
testDir: './jspi',
testMatch: /\.spec\.ts$/,
fullyParallel: false,
timeout: 120000,
@ -243,9 +256,13 @@ export default defineConfig({
},
},
{
name: 'asyncify-chrome',
testDir: './asyncify',
testMatch: /asyncify-races.*\.spec\.ts$/,
// System Chrome (real V8/GPU) leg of the jspi harnesses. There is no
// WebKit leg anymore: Safari has no JSPI at all (accepted trade-off of
// the migration) — the retired asyncify-webkit project had no
// successor to rename into.
name: 'jspi-chrome',
testDir: './jspi',
testMatch: /\.spec\.ts$/,
fullyParallel: false,
timeout: 120000,
use: {
@ -254,19 +271,6 @@ export default defineConfig({
permissions: ['clipboard-read', 'clipboard-write'],
},
},
{
// WebKit (Safari's engine) — project policy: every asyncify spec must be
// green in all three engines. Run via npm run test:asyncify:safari.
name: 'asyncify-webkit',
testDir: './asyncify',
testMatch: /asyncify-races.*\.spec\.ts$/,
fullyParallel: false,
timeout: 120000,
use: {
...devices['Desktop Safari'],
viewport: { width: 1280, height: 720 },
},
},
{
// Coroutine harness on system Chrome (real V8/GPU — where the KiCad
// coroutine crash historically manifested). Must be --headed on ARM Mac.

View file

@ -32,8 +32,7 @@ const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
// or be added here on purpose — silence is exactly how the web suite rotted.
const LOCAL_ONLY_PROJECTS = new Set([
'kicad-chrome', // system Chrome, headed KiCad debugging
'asyncify-chrome', // system Chrome
'asyncify-webkit', // Safari-engine policy suite, run manually (test:asyncify:safari)
'jspi-chrome', // system Chrome (real V8; run manually via test:jspi:chrome)
'coroutine-chrome', // system Chrome (real V8/GPU)
]);