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:
parent
33e23e0a65
commit
3f09a46ff5
48 changed files with 2640 additions and 546 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
58
tests/apps/standalone/jspi-coroutine/boot-check.cjs
Normal file
58
tests/apps/standalone/jspi-coroutine/boot-check.cjs
Normal 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');
|
||||
});
|
||||
58
tests/apps/standalone/jspi-coroutine/browser-run.cjs
Normal file
58
tests/apps/standalone/jspi-coroutine/browser-run.cjs
Normal 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);
|
||||
});
|
||||
20
tests/apps/standalone/jspi-coroutine/build.sh
Executable file
20
tests/apps/standalone/jspi-coroutine/build.sh
Executable 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"
|
||||
434
tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp
Normal file
434
tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp
Normal 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;
|
||||
}
|
||||
17
tests/apps/standalone/jspi-coroutine/index.html
Normal file
17
tests/apps/standalone/jspi-coroutine/index.html
Normal 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>
|
||||
2
tests/apps/standalone/jspi-coroutine/run.mjs
Normal file
2
tests/apps/standalone/jspi-coroutine/run.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import factory from './coroutine_jspi_test.mjs';
|
||||
const m = await factory();
|
||||
2
tests/apps/standalone/jspi-coroutine/run_pt.mjs
Normal file
2
tests/apps/standalone/jspi-coroutine/run_pt.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import factory from './coroutine_jspi_test_pt.mjs';
|
||||
const m = await factory();
|
||||
25
tests/apps/standalone/jspi-stack/build.sh
Executable file
25
tests/apps/standalone/jspi-stack/build.sh
Executable 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"
|
||||
162
tests/apps/standalone/jspi-stack/driver.mjs
Normal file
162
tests/apps/standalone/jspi-stack/driver.mjs
Normal 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);
|
||||
166
tests/apps/standalone/jspi-stack/index.html
Normal file
166
tests/apps/standalone/jspi-stack/index.html
Normal 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>
|
||||
131
tests/apps/standalone/jspi-stack/stack_test.cpp
Normal file
131
tests/apps/standalone/jspi-stack/stack_test.cpp
Normal 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;
|
||||
}
|
||||
Loading…
Reference in a new issue