diff --git a/.gitignore b/.gitignore index 5185bf7..1cc6ad3 100644 --- a/.gitignore +++ b/.gitignore @@ -97,3 +97,6 @@ output/ # Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys. .wrangler/ tests/.scratch/ +*.o.stamp +tests/apps/standalone/*/stack_test*.mjs +tests/apps/standalone/*/coroutine_jspi_test*.mjs diff --git a/docker/build.sh b/docker/build.sh index cf16a23..4e8f48b 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -177,6 +177,16 @@ docker compose -f docker/docker-compose.yml up -d --build # This avoids the timestamp mismatch cycle that caused full rebuilds every time. # Transferred files get current container time, so make detects them correctly. kw_stage container-sync +# Re-seed the emscripten ports cache from the persistent volume: /emsdk lives +# in the container layer, so any image-context edit recreates the container +# and wipes the cache — and the in-container github fetch is flaky. Seeds are +# staged once into the kicad-build-cache volume (emcache/ports); this copy +# survives every recreation. No-op when the staging dir is absent. +docker compose -f docker/docker-compose.yml exec kicad-wasm-builder bash -c \ + 'if [ -d /workspace/build-wasm/emcache/ports ]; then \ + mkdir -p /emsdk/upstream/emscripten/cache/ports && \ + cp -a /workspace/build-wasm/emcache/ports/. /emsdk/upstream/emscripten/cache/ports/ && \ + echo "seeded emscripten ports cache from volume"; fi' || true echo "Syncing source code to container..." # rsync into the macOS-backed volume intermittently hits transient VirtioFS glitches: # temp-file rename failures (exit 23) or vanished-source files (exit 24, harmless). @@ -244,6 +254,7 @@ compile_app() { # for headless CLIs like kicad_tools — the gl1 shim needs glm). docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \ -e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-}" \ + -e PCBJAM_ASYNC_BACKEND="${PCBJAM_ASYNC_BACKEND:-}" \ kicad-wasm-builder \ "/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}" @@ -283,6 +294,15 @@ postprocess_app() { return 0 fi + # JSPI backend: the app links with the real in-container tools and needs no + # dyncall shims (no -sDYNCALLS), no host finalize, and no asyncify pass — + # the scheduler ships as a --pre-js at link. Only the ENV merge shim remains. + if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then + kw_stage env-shim + node ./scripts/common/patch-env-shim.mjs "${out_dir}/${app}.js" + return 0 + fi + # Inject dynCall shims (fixes "dynCall_* is not defined" errors in Emscripten 4.x) kw_stage dyncall-shims ./scripts/common/inject-dyncall-shims.sh "${out_dir}/${app}.js" diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 0b0d8eb..d17d33f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -20,6 +20,14 @@ services: cpus: '${KICAD_DOCKER_CPUS:-10}' memory: ${KICAD_DOCKER_MEM:-32G} + # Emscripten cache on the PERSISTENT build volume: /emsdk lives in the + # container layer, so every container recreation (any image-context edit + # rebuilds the image) wiped the ports/sysroot cache and forced re-downloads + # through a flaky in-container fetcher. On the volume it survives, and the + # host can pre-seed port tarballs into emcache/ports/. + environment: + EM_CACHE: /workspace/build-wasm/emcache + volumes: # Host source mounted read-only at staging location # (rsync'd to /workspace on container start to fix macOS timestamp issues) diff --git a/kicad b/kicad index 99a7a36..a261e9f 160000 --- a/kicad +++ b/kicad @@ -1 +1 @@ -Subproject commit 99a7a368a6b36f2cd300d29bb136450e3c935cd7 +Subproject commit a261e9f0d0913b20dde3b0ac7b1271ee00578c79 diff --git a/scripts/build-wasm-test.sh b/scripts/build-wasm-test.sh index 73ddc9f..093fd9b 100755 --- a/scripts/build-wasm-test.sh +++ b/scripts/build-wasm-test.sh @@ -101,8 +101,16 @@ fi # submodule (version_130 + hoist pass) via apply-asyncify.sh. EMSDK_WASM_OPT="$PROJECT_ROOT/tools/emsdk/upstream/bin/wasm-opt" WASMOPT_STUB="$PROJECT_ROOT/wasm/stubs/wasm-opt-stub.sh" -_eh_restore_wasmopt() { [ -f "${EMSDK_WASM_OPT}.ehbak" ] && mv -f "${EMSDK_WASM_OPT}.ehbak" "${EMSDK_WASM_OPT}"; } +_eh_restore_wasmopt() { if [ -f "${EMSDK_WASM_OPT}.ehbak" ]; then mv -f "${EMSDK_WASM_OPT}.ehbak" "${EMSDK_WASM_OPT}"; fi; } EH_MARKER="$(mktemp)" # created before the build so 'find -newer' below selects freshly-linked apps +if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then + # JSPI: no binaryen instrumentation exists — no fork build, no stub dance, + # no post-link pass, no dyncall/scheduler injection (the jspi-scheduler + # ships as a --pre-js from Makefile.wasm). emcc's real wasm-opt runs + # in-link like any normal build. + echo "" + echo "=== JSPI backend: in-link build, no post-link instrumentation ===" +else echo "" echo "=== Building the Binaryen submodule (version_130 + hoist pass) ===" # One binaryen everywhere: the submodule fork is version_130 (asyncify unchanged) + our hoist @@ -114,6 +122,7 @@ echo "Stubbing in-link Asyncify (will run post-link instead)..." cp "$EMSDK_WASM_OPT" "${EMSDK_WASM_OPT}.ehbak" cp "$WASMOPT_STUB" "$EMSDK_WASM_OPT"; chmod +x "$EMSDK_WASM_OPT" trap _eh_restore_wasmopt EXIT +fi # Build (pass DEBUG flag if requested). App links are independent, so honor # JOBS/PARALLEL_JOBS from env.sh (each emcc link is slow due to Asyncify). @@ -139,6 +148,12 @@ fi # it for the coroutine apps; inject-dyncall-shims.sh is idempotent (skips an already-shimmed glue), # so re-running it here is safe. The .wasm gets post-link hoist + asyncify first. _eh_restore_wasmopt; trap - EXIT +if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then + rm -f "$EH_MARKER" + echo "" + echo "=== Build complete (jspi backend) ===" + exit 0 +fi echo "" echo "=== Post-link --hoist-cpp-catches + --asyncify (${JOBS:-1}-wide) ===" # The apps are independent here too (apply-asyncify rewrites each wasm in place; the shim diff --git a/scripts/build-wx-wasm.sh b/scripts/build-wx-wasm.sh index 5fef318..370820e 100755 --- a/scripts/build-wx-wasm.sh +++ b/scripts/build-wx-wasm.sh @@ -60,6 +60,22 @@ done BUILD_DIR="$PROJECT_ROOT/build-wasm/wxwidgets" WXLIB_PREFIX="libwx_wasmu" +# Async-backend stamp: PCBJAM_ASYNC_BACKEND changes COMPILE flags +# (-DPCBJAM_JSPI selects whole other halves of the wasm port), which the +# configure-cached incremental build cannot see — a knob flip against a stale +# tree silently links the WRONG backend into every consumer. Force a clean +# build whenever the stamp disagrees. +BACKEND_STAMP="$BUILD_DIR/.pcbjam-async-backend" +CURRENT_BACKEND="${PCBJAM_ASYNC_BACKEND:-asyncify}" +# ABSENT stamp = unknown provenance = same as a mismatch: an incremental build +# over objects of unknown backend produced a MIXED library once (jspi evtloop +# EM_JS in the glue next to live fiber dispatch — --allow-multiple-definition +# hid it and the editor took the fiber branch at runtime under JSPI). +if [ -d "$BUILD_DIR" ] && [ "$(cat "$BACKEND_STAMP" 2>/dev/null)" != "$CURRENT_BACKEND" ]; then + echo "=== Async backend changed ($(cat "$BACKEND_STAMP" 2>/dev/null || echo unknown) -> $CURRENT_BACKEND): forcing clean wx build ===" + CLEAN_BUILD=1 +fi + # Use our config.sub wrapper for autoconf projects # CONFIG_SHELL is critical: nested configures (pcre, etc.) do SHELL=${CONFIG_SHELL-/bin/sh} # Without CONFIG_SHELL, nested configures would reset SHELL to /bin/sh and bypass our wrapper @@ -177,6 +193,15 @@ if [ $NEEDS_CONFIGURE -eq 1 ]; then WX_EH_FLAGS="$DEPS_EH_FLAGS" echo "wx EH model flags: ${WX_EH_FLAGS}" + # Async backend (experiment/jspi): PCBJAM_ASYNC_BACKEND=jspi compiles the + # wasm port's JSPI lanes (evtloop/app/window PCBJAM_JSPI blocks) instead of + # the Asyncify/fiber lanes. ONE wx build output — flipping backends means + # rebuilding (use --clean). Default stays asyncify until Phase 4. + if [ "${PCBJAM_ASYNC_BACKEND:-asyncify}" = "jspi" ]; then + WX_EH_FLAGS="$WX_EH_FLAGS -DPCBJAM_JSPI=1" + echo "async backend: jspi (-DPCBJAM_JSPI)" + fi + # Include emscripten cache sysroot for zlib headers export CFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include ${WX_DEBUG_FLAGS} ${WX_EH_FLAGS} -pthread -matomics -mbulk-memory" export CXXFLAGS="-DZ_HAVE_UNISTD_H=1 -I$EM_CACHE_SYSROOT/include -I$PCRE2_INCLUDE ${WX_DEBUG_FLAGS} ${WX_EH_FLAGS} -pthread -matomics -mbulk-memory" @@ -194,7 +219,11 @@ if [ $NEEDS_CONFIGURE -eq 1 ]; then --with-cxx=17 \ --enable-utf8 \ --with-zlib=sys \ + --with-regex=builtin \ ${WX_CONFIGURE_DEBUG} + # --with-regex=builtin: without it a host pkg-config can find the host's + # pcre2 (homebrew on macOS) and configure silently picks "regex sys", which + # skips the bundled 3rdparty/pcre build dir the next step requires. # Build PCRE first to avoid race condition with parallel builds # PCRE headers (pcre2.h) must be generated before regex.cpp compiles @@ -268,5 +297,6 @@ done cd "$BUILD_DIR" echo "" +mkdir -p "$BUILD_DIR" && printf %s "$CURRENT_BACKEND" > "$BACKEND_STAMP" echo "=== Build complete ===" ls -lh "$BUILD_DIR"/lib/*.a 2>/dev/null || echo "Libraries built in $BUILD_DIR/lib" diff --git a/scripts/common/jspi-exports.txt b/scripts/common/jspi-exports.txt new file mode 100644 index 0000000..e3e40a8 --- /dev/null +++ b/scripts/common/jspi-exports.txt @@ -0,0 +1,12 @@ +main +wx_dom_event +wx_dom_mouse +wx_window_close +wx_window_move +wx_window_resize +ProcessEvents +wxWasmMailboxTick +wxWasmTopLevelTick +wxWasmMainLoopPump +wxWasmJobTick +pcbjam_libctx_entry diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js new file mode 100644 index 0000000..748a661 --- /dev/null +++ b/scripts/common/shims/jspi-scheduler.js @@ -0,0 +1,840 @@ +// jspi-scheduler.js — the JSPI-era successor of asyncify-scheduler.js. +// +// Ships as a --pre-js. Keeps the S4 token-wait registry contract byte-for-byte +// (beginWait/waitPromise/resolveWait/resolveTopWait/waitEarlyResolved/ +// takeWaitResult/pendingWaits/noteContextWait/shutdown) so every C++ bridge +// and web caller keeps working, and adds the two things JSPI needs: +// +// 1. ACTIVATION TRACKING. Every promising export the app declares is wrapped +// so the shim always knows which activation is executing synchronously +// (an explicit stack; JS is single-threaded so this is exact). +// +// 2. SHADOW-STACK DISCIPLINE (emscripten #27364, red/green-proven by +// tests/apps/standalone/jspi-stack). JSPI switches the native stack per +// activation but NOT the C spill stack. Every wrapped activation runs on +// its own pooled spill-stack region with SP swapped at the window +// boundaries ("green-region", same as libcontext's JSPI backend — KiCad +// tool coroutines carry their own regions there and do NOT route through +// here). Green-copy (snapshot/restore of the suspended range) is +// deliberately NOT used: it rolls back writes other activations make +// into parked frames' locals (stack-allocated wxDialog members mutated +// by a cross-tick EndModal), resurrecting dead state at resume. +// +// Observability: an event ring + live activation table via __wxWaitDump() +// (alias __wxAsyncifyDump kept one release for crash-report consumers). +// +// Asyncify-era machinery that has NO successor here, by design: currData +// single-writer tripwire, deferred-wake queue, stale-fiber quarantine, +// trampoline heal, dyncall shims — the states they policed are +// unrepresentable under JSPI. + +(function () { + "use strict"; + + if (globalThis.__wxScheduler && globalThis.__wxScheduler.backend === "jspi") { + return; // idempotent under double injection + } + + var RING_CAP = 256; + + var S = { + backend: "jspi", + + // --- mailbox lane (timers/wheel; ordering machinery, mechanism-free) ---- + // Same contract as the asyncify shim: enqueueAfter queues a C callback, + // delivery happens through the dedicated _wxWasmMailboxTick export from a + // fresh task, in order. Under JSPI the tick is a promising export — a + // suspension inside a delivered handler parks the tick's own activation, + // and the rejection path carries the same containment (a throwing handler + // must not leave a parked quasi-modal unresolved). + mailbox: [], + enqueued: 0, + delivered: 0, + _tickArmed: false, + enqueueAfter: function (fn, arg, ms) { + var self = this; + setTimeout(function () { + if (self.dead) return; // never deliver into a torn-down app + self.mailbox.push({ fn: fn, arg: arg }); + self.enqueued++; + self._armDeliveryTick(); + }, ms); + }, + pop: function () { + var m = this.mailbox.shift(); + if (m) this.delivered++; + return m || null; + }, + _tickErrorContainment: function (e) { + if (Module["_wx_dispatch_abandon"]) Module["_wx_dispatch_abandon"](); + this.resolveTopWait("nested", 0); + this.resolveTopWait("modal", 5101); // wxID_CANCEL + console.warn("[wx-scheduler] mailbox tick error: " + e); + }, + _armDeliveryTick: function () { + if (this._tickArmed) return; + this._tickArmed = true; + var self = this; + setTimeout(function tick() { + if (self.dead) { self._tickArmed = false; return; } + var p; + try { + p = Module["_wxWasmMailboxTick"] ? Module["_wxWasmMailboxTick"]() : undefined; + } catch (e) { + self._tickArmed = false; + self._tickErrorContainment(e); + throw e; + } + Promise.resolve(p).catch(function (e) { self._tickErrorContainment(e); }); + if (self.mailbox.length > 0) { + setTimeout(tick, 17); + } else { + self._tickArmed = false; + } + }, 0); + }, + + // --- S1 embind lane (contract-identical port from asyncify-scheduler) -- + // Mutators (doc 18 classification) must not enter wasm while a load is in + // flight: the open activation is suspended mid-load and a collab-apply / + // save / theme flip entering between its parks would mutate the board + // under it. Semantic exclusion — nothing asyncify-specific about it. + // The FIFO drains, in order, once kicadOpenFileBusy clears. + // + // Retired here, by design: _wrapOpenFile / kicadOpenFileStart (the + // asyncify Phase F starter route). Under JSPI kicadOpenFile is an embind + // async() export — its own promising activation parks legally and the + // call returns a real Promise; no dispatch-context detour exists. + MUTATOR_NAMES: [ + "kicadSetChrome", "kicadSetReadOnly", + "kicadCollabApply", "kicadCollabApplyItems", + "kicadCollabSnapshot", "kicadCollabSnapshotItems", + "kicadCollabPresenceStart", "kicadCollabSetRemote", + "kicadCollabSetPins", "kicadCollabSetStyle", + "kicadCollabSetViewport", "kicadCollabFitViewport", + "kicadCollabReleaseSelection", "kicadSetColorTheme", + "kicadSaveBoard", "kicadSaveSchematic", "kicadSaveDrawingSheet", + ], + mutatorQueue: [], + mutatorsWrapped: 0, + mutatorsDelivered: 0, + _mutatorPumpArmed: false, + _openBusy: function () { + var probe = Module["kicadOpenFileBusy"]; + if (typeof probe !== "function") return false; + try { return !!probe(); } catch (e) { return true; } + }, + _wrapMutators: function () { + var self = this; + this.MUTATOR_NAMES.forEach(function (name) { + var orig = Module[name]; + if (typeof orig !== "function") return; + self.mutatorsWrapped++; + Module[name] = function () { + var args = arguments; + var call = function () { return orig.apply(Module, args); }; + if (self.mutatorQueue.length === 0 && !self._openBusy()) { + self.mutatorsDelivered++; + return call(); + } + return new Promise(function (resolve, reject) { + self.mutatorQueue.push({ name: name, call: call, resolve: resolve, reject: reject }); + self._armMutatorPump(); + }); + }; + }); + if (this.mutatorsWrapped > 0) + this._note("wrapped", "mutators", this.mutatorsWrapped); + }, + _armMutatorPump: function () { + if (this._mutatorPumpArmed) return; + this._mutatorPumpArmed = true; + var self = this; + var now = (typeof performance !== "undefined" && performance.now) + ? function () { return performance.now(); } + : function () { return Date.now(); }; + setTimeout(function pump() { + if (self.dead) { self._mutatorPumpArmed = false; return; } + // Unkillable: an exception escaping this body would end the setTimeout + // chain and wedge the queue forever (observed: 559 frozen messages). + try { + if (!self._openBusy()) { + // Time-boxed drain: ~8 ms of work per 16 ms tick keeps the page + // live while a long backlog drains in order. + var t0 = now(); + while (self.mutatorQueue.length > 0 && now() - t0 < 8) { + if (self._openBusy()) break; + var m = self.mutatorQueue.shift(); + self.mutatorsDelivered++; + try { m.resolve(m.call()); } catch (e) { m.reject(e); } + } + } + } catch (e) { + self._pumpErrors = (self._pumpErrors || 0) + 1; + if (self._pumpErrors <= 5) + console.warn("[wx-scheduler] mutator pump error (occurrence " + + self._pumpErrors + "): " + e); + } + if (self.mutatorQueue.length > 0) setTimeout(pump, 16); + else self._mutatorPumpArmed = false; + }, 16); + }, + + // The embind PARKERs (kicadOpenFile / kicadOpenFiles / kicadLibsReload, + // registered emscripten::async() under PCBJAM_JSPI): wrap them with the + // same activation tracking as the raw promising exports, so their parks + // (wxWasmYieldUntil inside the load) find a tracked record and get the + // green-copy spill-stack discipline. Embind names live on Module WITHOUT + // the underscore prefix, hence the separate installer. + PARKER_NAMES: ["kicadOpenFile", "kicadOpenFiles", "kicadLibsReload"], + _wrapParkers: function () { + var wrapped = 0; + for (var i = 0; i < this.PARKER_NAMES.length; i++) { + var name = this.PARKER_NAMES[i]; + if (typeof Module[name] === "function") { + Module[name] = this._wrapPromising(name, Module[name], this.PARKER_REGION_BYTES); + wrapped++; + } + } + this._note("wrapped", "parkers", wrapped); + return wrapped; + }, + + // --- S4 wait registry (contract-compatible) ---------------------------- + waits: new Map(), // token -> {kind, promise, resolve, resolved, result, awaited} + waitSeq: 0, + waitStacks: {}, // kind -> [unresolved tokens], LIFO + waitsBegun: 0, + waitsResolved: 0, + earlyWaitResolves: 0, + + beginWait: function (kind) { + var token = ++this.waitSeq; + var entry = { kind: kind, resolved: false, resolve: null, promise: null }; + entry.promise = new Promise(function (resolve) { entry.resolve = resolve; }); + this.waits.set(token, entry); + (this.waitStacks[kind] = this.waitStacks[kind] || []).push(token); + this.waitsBegun++; + this._note("beginWait", kind, token); + return token; + }, + + waitPromise: function (token) { + var entry = this.waits.get(token); + if (!entry) { + console.warn("[wx-scheduler] waitPromise(" + token + "): unknown token"); + return Promise.resolve(0); + } + if (entry.resolved) { + // resolved before the waiter parked (early-resolve window) + this.waits.delete(token); + return Promise.resolve(entry.result | 0); + } + entry.awaited = true; + this._note("park", entry.kind, token); + return this._suspendOn(entry.promise, entry.kind, token); + }, + + waitEarlyResolved: function (token) { + var entry = this.waits.get(token); + return entry && entry.resolved ? 1 : 0; + }, + + takeWaitResult: function (token) { + var entry = this.waits.get(token); + if (!entry || !entry.resolved) return 0; + this.waits.delete(token); + return entry.result | 0; + }, + + // JSPI: context waits do not exist; kept as a loud no-op for transition + // callers (nothing registers them — wxWasmYieldUntil suspends in place). + noteContextWait: function (token) { + console.warn("[wx-scheduler] noteContextWait(" + token + ") under jspi backend"); + }, + + resolveWait: function (token, result) { + var entry = this.waits.get(token); + if (!entry || entry.resolved) return false; + entry.resolved = true; + this.waitsResolved++; + var stack = this.waitStacks[entry.kind]; + if (stack) { + var idx = stack.indexOf(token); + if (idx !== -1) stack.splice(idx, 1); + } + entry.result = result | 0; + entry.resolve(result | 0); + this._note("resolve", entry.kind, token); + if (entry.awaited) { + this.waits.delete(token); + } else { + // early resolve: keep the entry, result attached, for the late waiter + this.earlyWaitResolves++; + } + return true; + }, + + resolveTopWait: function (kind, result) { + var stack = this.waitStacks[kind]; + if (!stack || stack.length === 0) return false; + return this.resolveWait(stack[stack.length - 1], result); + }, + + pendingWaits: function (kind) { + var stack = this.waitStacks[kind]; + return stack ? stack.length : 0; + }, + + dead: false, + shutdown: function (why) { + this.dead = true; + // S6 teardown contract (same as the asyncify shim): queued-but- + // undelivered mutators FAIL LOUDLY instead of hanging their callers, + // and undelivered mailbox messages drop — the pumps stop themselves on + // the dead flag. + var q = this.mutatorQueue.splice(0, this.mutatorQueue.length); + for (var i = 0; i < q.length; i++) { + try { q[i].reject(new Error("wx scheduler shutdown: " + why)); } catch (e) { /* reject never throws */ } + } + this.mailbox.length = 0; + var stranded = this.waits.size; + if (stranded) { + console.warn("[wx-scheduler] shutdown (" + why + ") stranded:" + stranded); + } + this._note("shutdown", why, stranded); + }, + + // --- activation tracking + shadow-stack discipline --------------------- + // + // Window model (JS is single-threaded, so this is exact): + // * FIRST window — a promising export runs synchronously from its JS + // caller until first suspend or completion. It is a real JS call + // frame, so _actStack (push in the wrap, pop in its finally) mirrors + // the JS stack exactly, including exports entered synchronously from + // inside a resumed window. + // * RESUMED window — the engine re-enters a suspended activation from a + // promise reaction. There is NO JS frame of ours around it, so it is + // tracked by _windowLive instead. + // The wasm code executing at any suspension therefore belongs to + // _actStack's top when non-empty, else to _windowLive. + // + // Discipline: GREEN-REGION (per-activation spill-stack region + SP swap + // at the window boundaries), the same leg of the jspi-stack red/green + // bake-off the libcontext JSPI backend uses. NOT green-copy: a snapshot/ + // restore of the suspended range rolls back writes that OTHER activations + // legitimately made into the parked frames' locals — a stack-allocated + // wxDialog whose EndModal (from another tick's window) cleared + // m_isShowingModal would have the flag restored to true at resume, and + // its destructor then fires EndModal(wxID_CANCEL) into some OUTER modal's + // wait (observed as the triple-modal LIFO failure). With a region per + // activation nothing else ever executes on a parked activation's stack, + // so cross-activation writes persist and nothing needs copying. + // + // Resume TURNSTILE: SP swaps must happen (a) only at microtask + // boundaries (never while wasm frames are live on the JS stack) and + // (b) for at most ONE activation between wasm re-entries — between our + // swap and the engine's actual re-entry other microtasks still run, and + // a second swap would redirect it. So ready resumes queue in + // _resumeReady and _pumpResume (microtask-scheduled only) arms exactly + // one and resolves its gate; the engine's re-entry is the only reaction + // on that gate. The next pump happens when that window ENDS — its next + // suspension or its completion — both of which we observe. + _actSeq: 0, + _actStack: [], // records of FIRST windows currently on the JS stack + _suspended: new Map(),// actId -> record, while suspended (dump/watchdog) + _windowLive: null, // record whose RESUMED window is executing (or armed) + _resumeReady: [], // FIFO of {rec, gate} whose wait promise resolved + + _sp: function () { return Module["stackSave"](); }, + _setSp: function (v) { Module["stackRestore"](v); }, + + _top: function () { + return this._actStack.length + ? this._actStack[this._actStack.length - 1] + : null; + }, + + // Per-activation spill-stack regions, pooled (malloc'd from the wasm + // heap; wx dispatch chains are shallow compared to tool coroutines — + // the deep KiCad tool bodies run on libcontext's own 256K regions). + // PARKER_REGION_BYTES for the embind load chains (kicadOpenFile parses + // whole boards on this stack). + // KiCad dispatch chains and board loads run DEEP (a full board parse + // happens on the parker's region; a paint dispatch can recurse through + // tool handlers) — an overflowing region scribbles the heap below it and + // kills the renderer. Regions are pooled, so generous sizes cost little. + REGION_BYTES: 1024 * 1024, + PARKER_REGION_BYTES: 8 * 1024 * 1024, + _regionPool: {}, // size -> [regions] + // This file is a --pre-js, so the glue's bare _malloc/_free are in scope + // at call time; Module["_malloc"] is the fallback for glue shapes that + // attach them there instead. + _mallocFn: function () { + return (typeof _malloc === "function") ? _malloc : Module["_malloc"]; + }, + _freeFn: function () { + return (typeof _free === "function") ? _free : Module["_free"]; + }, + _regionAlloc: function (size) { + var pool = (this._regionPool[size] = this._regionPool[size] || []); + var r = pool.pop(); + if (r) return r; + var base = this._mallocFn()(size); + if (!base) throw new Error("[wx-scheduler] region alloc failed (" + size + ")"); + // A JS-initiated _malloc can GROW wasm memory, and glue code holding + // pre-growth views then writes into a detached buffer (observed: the + // fd_write out-param never landing, musl's __stdio_write retrying a + // 0-byte writev forever). Refresh the glue's views immediately; the + // install-time preallocation below makes this path rare to begin with. + try { + if (typeof updateMemoryViews === "function" && typeof wasmMemory !== "undefined" + && typeof HEAPU8 !== "undefined" && HEAPU8.buffer !== wasmMemory.buffer) { + updateMemoryViews(); + } + } catch (e) { /* non-glue host (unit tests) */ } + // The wasm C stack REQUIRES 16-byte alignment; wasm32 malloc only + // guarantees 8. A region top at base+size can be 8 (mod 16), and a + // misaligned SP skews every alignment-derived address in the + // activation by 8 (observed: EM_ASM's readEmAsmArgs assert, and musl + // __stdio_write passing an iov pointer 8 below the array it populated + // -> an infinite 0-byte writev retry loop wedging the main thread). + // Align the top DOWN; the lost <16 bytes are spare. + return { base: base, top: (base + size) & ~15, size: size }; + }, + // Fill the pools while nothing is suspended (runtime init): any memory + // growth this causes happens at a safe boundary instead of mid-window. + _preallocRegions: function (haveParkers) { + // Host without a wasm heap (the vitest fake runtime): nothing to fill. + if (typeof this._mallocFn() !== "function") return; + var i, rs = []; + for (i = 0; i < 8; i++) rs.push(this._regionAlloc(this.REGION_BYTES)); + if (haveParkers) for (i = 0; i < 2; i++) rs.push(this._regionAlloc(this.PARKER_REGION_BYTES)); + for (i = 0; i < rs.length; i++) this._regionFree(rs[i]); + }, + _regionFree: function (r) { + var pool = (this._regionPool[r.size] = this._regionPool[r.size] || []); + if (pool.length < 8) pool.push(r); + else this._freeFn()(r.base); + }, + + // Wrap one promising export so the shim tracks its windows and gives the + // activation its own spill region. The returned promise + // (WebAssembly.promising exports and embind async() invokers always + // return one) settles when the ACTIVATION completes — that frees the + // region and un-parks the turnstile. + _wrapPromising: function (name, fn, regionBytes) { + var S = this; + var bytes = regionBytes || S.REGION_BYTES; + return function () { + var enclosingSp = S._sp(); + var region = S._regionAlloc(bytes); + var rec = { + id: ++S._actSeq, + kind: name, + region: region, + entrySp: region.top, + suspendedAt: 0 + }; + if (globalThis.__wxTrace) console.log('[TRACE] alloc region ' + region.base + ' -> act ' + rec.id + ':' + name); + S._actStack.push(rec); + S._setSp(region.top); + var out; + try { + out = fn.apply(this, arguments); + } finally { + var popped = S._actStack.pop(); + if (popped !== rec) { + console.warn("[wx-scheduler] activation stack imbalance at " + name); + } + // First window over (completed, suspended, or threw): the caller + // continues on the enclosing stack either way. + S._setSp(enclosingSp); + if (!(out && typeof out.then === "function")) { + // sync throw or non-promise return: the activation is over now + S._endActivation(rec); + } + } + if (out && typeof out.then === "function") { + out.then( + function () { S._endActivation(rec); }, + function () { S._endActivation(rec); } + ); + } + return out; + }; + }, + + _endActivation: function (rec) { + if (globalThis.__wxTrace) console.log('[TRACE] end ' + rec.id + ':' + rec.kind + ' live=' + (this._windowLive ? this._windowLive.id : '-')); + this._suspended.delete(rec.id); + if (this._windowLive === rec) { + // completed from a RESUMED window: wasm's epilogue left SP at the + // region top — put the enclosing stack back before anything else + // enters wasm. + this._windowLive = null; + if (rec.enclosingSp !== undefined) this._setSp(rec.enclosingSp); + } + if (rec.region) { + if (globalThis.__wxTrace) console.log('[TRACE] free region ' + rec.region.base + ' <- act ' + rec.id); + this._regionFree(rec.region); + rec.region = null; + } + var S = this; + queueMicrotask(function () { S._pumpResume(); }); + }, + + _pumpResume: function () { + if (globalThis.__wxTrace) console.log('[TRACE] pump live=' + (this._windowLive ? this._windowLive.id + ':' + this._windowLive.kind : '-') + ' ready=' + this._resumeReady.map(function(e){return e.rec.id + ':' + e.rec.kind;}).join(',')); + if (this.dead) return; + if (this._windowLive) { + // Self-heal: an activation that suspended RAW (bypassing the shim) + // or completed untracked never ends its window here; without this + // the pump would refuse resumes forever. Anything armed >2s while + // resumes queue is such a leak — clear it loudly. (A window whose + // wasm is genuinely executing can't be observed here at all: the + // pump only runs between JS jobs.) + var w = this._windowLive; + if (w.windowArmedAt && Date.now() - w.windowArmedAt > 2000 + && this._resumeReady.length) { + console.warn("[wx-scheduler] force-clearing stuck window act " + + w.id + ":" + w.kind + " — some suspension bypassed the shim"); + this._note("forceClearWindow", w.kind, w.id); + this._windowLive = null; + } else { + return; + } + } + if (this._resumeReady.length === 0) return; + var e = this._resumeReady.shift(); + var rec = e.rec; + if (rec.dead) { + // Quarantined (coroutine released while parked): the body's C++ is + // freed — a late wake must never re-enter it. Drop the wake; the + // gate promise parks the leaked activation forever (censused C-side). + console.warn("[wx-scheduler] dropping wake for quarantined " + rec.id); + this._note("deadWakeDropped", rec.kind, rec.id); + var S = this; + queueMicrotask(function () { S._pumpResume(); }); + return; + } + this._suspended.delete(rec.id); + rec.suspendedAt = 0; + // Arm the window: remember the enclosing stack (to put back when this + // window ends) and point SP back into the activation's own region, + // exactly where it suspended. + rec.enclosingSp = this._sp(); + rec.windowArmedAt = Date.now(); + // Truthful attribution for the resumed slice's NEXT suspension: after + // a foreign wake a coroutine re-enters through plain C with nothing on + // the path to re-arm g_current (own-yield resumes re-set it C-side — + // idempotent with this). Before the SP swap: make_current has real + // frames (registry lookup) and must run on the enclosing stack. + this._libctxMakeCurrent(rec.lcid || 0); + this._setSp(rec.sp); + this._windowLive = rec; + // The engine's re-entry is the only reaction on the gate; microtasks + // queued before it can only enqueue further resumes (no SP swaps — + // _windowLive is set), so SP survives untouched until wasm runs. + if (e.rejected) e.gate.reject(e.value); + else e.gate.resolve(e.value); + }, + + // Untracked activations (main — the runtime calls it before the wraps + // exist — and libctx coroutine bodies doing FOREIGN yields like + // sleepYield) get a FRESH anonymous record per suspension. Not a shared + // singleton: main is eternally parked on its frame yield, and a + // singleton would let a coroutine's suspension overwrite main's saved + // SP (cross-wired resumes). An uncontended activation like main still + // keeps a stable identity naturally: its next suspension is attributed + // to its own live window (rec === _windowLive) and reuses the record. + _anonSeq: 0, + + // Route a suspension through the turnstile. No byte copying: the + // activation's frames live in its own region (main: the central stack) + // and stay valid while parked; only the shared SP global is handed back + // and forth. + _suspendOn: function (p, kind, token) { + var S = this; + // A FOREIGN yield from inside a KiCad coroutine body belongs to the + // COROUTINE's activation — the wx entry that entered it is still on + // the JS stack and must not be double-booked (its own libctx-enter + // suspension already owns that record). + var lcid = 0; + try { + lcid = (typeof _pcbjam_libctx_current === "function") ? _pcbjam_libctx_current() + : (Module["_pcbjam_libctx_current"] ? Module["_pcbjam_libctx_current"]() : 0); + } catch (e) { /* pre-runtime */ } + var rec; + if (lcid) { + rec = S._libctxRecs[lcid] || (S._libctxRecs[lcid] = { + id: "lc" + lcid, kind: "libctx", region: null, + entrySp: S._sp(), suspendedAt: 0, libctx: true, lcid: lcid + }); + } else { + rec = S._actStack.length ? S._top() : S._windowLive; + } + if (!rec) { + rec = { + id: --S._anonSeq, kind: "untracked", region: null, + entrySp: S._sp(), suspendedAt: 0, anon: true + }; + } + if (globalThis.__wxTrace) console.log('[TRACE] suspend ' + rec.id + ':' + rec.kind + ' ' + kind + '/' + token + ' live=' + (S._windowLive ? S._windowLive.id : '-') + ' stack=' + S._actStack.map(function(r){return r.id;}).join(',')); + rec.sp = S._sp(); + rec.suspendedAt = Date.now(); + rec.waitKind = kind; + rec.waitToken = token; + S._suspended.set(rec.id, rec); + // A coroutine that parks stops being the "current context": whatever + // the event loop runs next would otherwise attribute ITS suspensions + // to this parked coroutine (g_current dangles — no C code runs on the + // unwind path). The arm below re-establishes it on resume. + if (lcid) S._libctxMakeCurrent(0); + if (S._windowLive === rec) { + // a RESUMED window just suspended again — its window ends here; put + // the enclosing stack back for whatever runs next. + S._windowLive = null; + if (rec.enclosingSp !== undefined) S._setSp(rec.enclosingSp); + queueMicrotask(function () { S._pumpResume(); }); + } + // (a FIRST window's end — including its SP hand-back — is the wrap's + // finally; the completion hook frees the region.) + var gate = {}; + gate.promise = new Promise(function (res, rej) { + gate.resolve = res; + gate.reject = rej; + }); + p.then( + function (v) { + S._resumeReady.push({ rec: rec, gate: gate, value: v, rejected: false }); + queueMicrotask(function () { S._pumpResume(); }); + }, + function (err) { + S._resumeReady.push({ rec: rec, gate: gate, value: err, rejected: true }); + queueMicrotask(function () { S._pumpResume(); }); + } + ); + return gate.promise; + }, + + // --- libcontext (KiCad coroutine) turnstile integration ---------------- + // The coroutine backend manages its own spill REGIONS and SP save/restore + // around its awaits, but its engine-level resumes must still be + // SERIALIZED with everyone else's: un-turnstiled, the microtask that + // restores the coroutine's SP can interleave with a turnstile arm, and + // whichever runs last wins — the resumed wasm then spills into another + // activation's region (observed: PCB_SELECTION_TOOL's first Wait() + // trapping "memory access out of bounds" in Chromium, engine-ordering + // dependent). The coroutine's ENTER/RESUME awaits suspend the CALLING wx + // activation and route through promiseYield; the YIELD suspends the + // COROUTINE'S OWN activation and uses these two hooks instead (explicit + // SP, no _actStack attribution — at yield time the stack top is the + // caller, not the coroutine). + _libctxRecs: {}, + // Re-point the C-side g_current at the activation being armed (0 = root). + // Leaf export; absent on heapless hosts (vitest) and pre-runtime — no-op. + _libctxMakeCurrent: function (lcid) { + try { + if (typeof _pcbjam_libctx_make_current === "function") _pcbjam_libctx_make_current(lcid); + else if (typeof Module !== "undefined" && Module["_pcbjam_libctx_make_current"]) Module["_pcbjam_libctx_make_current"](lcid); + } catch (e) { /* pre-runtime */ } + }, + libctxSuspend: function (id, p, sp) { + var S = this; + var rec = S._libctxRecs[id] || (S._libctxRecs[id] = { + id: "lc" + id, kind: "libctx", region: null, + entrySp: sp, suspendedAt: 0, libctx: true, lcid: id + }); + rec.sp = sp; + rec.suspendedAt = Date.now(); + rec.waitKind = "libctx"; + rec.waitToken = 0; + S._suspended.set(rec.id, rec); + // Parked: clear the C-side current-context pointer (see _suspendOn). + S._libctxMakeCurrent(0); + if (S._windowLive === rec) { + S._windowLive = null; + if (rec.enclosingSp !== undefined) S._setSp(rec.enclosingSp); + queueMicrotask(function () { S._pumpResume(); }); + } + var gate = {}; + gate.promise = new Promise(function (res, rej) { + gate.resolve = res; + gate.reject = rej; + }); + p.then( + function (v) { + S._resumeReady.push({ rec: rec, gate: gate, value: v, rejected: false }); + queueMicrotask(function () { S._pumpResume(); }); + }, + function (err) { + S._resumeReady.push({ rec: rec, gate: gate, value: err, rejected: true }); + queueMicrotask(function () { S._pumpResume(); }); + } + ); + return gate.promise; + }, + // Coroutine released while parked (quarantine contract): mark the record + // dead so the pump drops any late wake (never re-enter the freed body), + // and forget it. The rec object itself stays reachable from pending + // p.then closures — the dead flag is what protects those paths. + libctxQuarantine: function (id) { + var rec = this._libctxRecs[id]; + if (!rec) return; + rec.dead = true; + this._suspended.delete(rec.id); + if (this._windowLive === rec) { + this._windowLive = null; + if (rec.enclosingSp !== undefined) this._setSp(rec.enclosingSp); + } + delete this._libctxRecs[id]; + this._note("libctxQuarantine", "libctx", rec.id); + var S = this; + queueMicrotask(function () { S._pumpResume(); }); + }, + // Coroutine's entry activation completed (finished or trapped): end its + // window so the turnstile moves on. + libctxEnd: function (id) { + var S = this; + var rec = S._libctxRecs[id]; + if (!rec) return; + S._suspended.delete(rec.id); + if (S._windowLive === rec) { + S._windowLive = null; + if (rec.enclosingSp !== undefined) S._setSp(rec.enclosingSp); + } + delete S._libctxRecs[id]; + queueMicrotask(function () { S._pumpResume(); }); + }, + + // Suspension helpers the wx EM_ASYNC_JS bodies route through, so every + // park shares the one discipline implementation. + frameYield: function () { + return this._suspendOn( + new Promise(function (r) { requestAnimationFrame(function () { r(0); }); }), + "frame", 0); + }, + sleepYield: function (ms) { + return this._suspendOn( + new Promise(function (r) { setTimeout(function () { r(0); }, ms); }), + "sleep", 0); + }, + promiseYield: function (p, kind) { + return this._suspendOn(Promise.resolve(p), kind || "promise", 0); + }, + + // Called once the runtime is up: wrap the app's promising entry exports. + // The list mirrors -sJSPI_EXPORTS (main excluded: the runtime calls it + // before this hook can matter, and boot suspensions predate any tracked + // activation anyway — see _suspendOn's null-rec path). + installExportWraps: function (names) { + var wrapped = 0; + for (var i = 0; i < names.length; i++) { + var key = "_" + names[i]; + if (typeof Module[key] === "function") { + Module[key] = this._wrapPromising(names[i], Module[key]); + wrapped++; + } + } + this._note("wrapped", "exports", wrapped); + return wrapped; + }, + + // --- observability skeleton (finalized in Phase 7) --------------------- + _ring: [], + _note: function (ev, a, b) { + this._ring.push([Date.now(), ev, String(a), b | 0]); + if (this._ring.length > RING_CAP) this._ring.shift(); + }, + + dump: function () { + var acts = []; + this._suspended.forEach(function (rec) { + acts.push({ + id: rec.id, kind: rec.kind, waitKind: rec.waitKind || null, + token: rec.waitToken || 0, + suspendedMs: rec.suspendedAt ? Date.now() - rec.suspendedAt : 0 + }); + }); + return { + backend: "jspi", + dead: this.dead, + waitsBegun: this.waitsBegun, + waitsResolved: this.waitsResolved, + earlyWaitResolves: this.earlyWaitResolves, + pendingWaits: this.waits.size, + runningActivations: this._actStack.length, + suspendedActivations: acts, + mutatorsWrapped: this.mutatorsWrapped, + mutatorsDelivered: this.mutatorsDelivered, + mutatorQueueDepth: this.mutatorQueue.length, + ring: this._ring.slice(-64) + }; + } + }; + + globalThis.__wxScheduler = S; + globalThis.__wxSchedulerInstalled = true; // wxWasmSchedulerAssertInstalled probe + + // --- Phase 7 signals ------------------------------------------------------ + // SuspendError attributor: a SuspendError means a PLAIN (non-promising) + // wasm entry tried to park — a missed -sJSPI_EXPORTS/installExportWraps + // entry. The engine cannot say WHICH export, but the live dump (what was + // wrapped, what was executing) is exactly the targeting data needed. + if (typeof addEventListener === "function") { + var suspendErr = function (m) { + if (!m || !/suspend/i.test(String(m))) return; + console.error("[wx-scheduler] SuspendError: a NON-promising wasm entry " + + "tried to park — add the missing entry export to -sJSPI_EXPORTS and " + + "installExportWraps. dump=" + JSON.stringify(S.dump())); + }; + addEventListener("unhandledrejection", function (ev) { + suspendErr(ev && ev.reason && (ev.reason.message || ev.reason)); + }); + addEventListener("error", function (ev) { + suspendErr(ev && (ev.message || (ev.error && ev.error.message))); + }); + } + // Lost-wake watchdog: an activation parked on a TOKEN wait whose registry + // entry is GONE (resolved+consumed or never registered) can never be + // resumed — a lost wake. Frame/sleep parks are excluded (a hidden tab + // legitimately parks the frame yield for minutes). + setInterval(function () { + if (S.dead) return; + S._suspended.forEach(function (rec) { + if (!rec.waitToken || rec.waitKind === "frame" || rec.waitKind === "sleep") return; + if (rec.suspendedAt && Date.now() - rec.suspendedAt > 30000 + && !S.waits.has(rec.waitToken) && !rec._lostWakeWarned) { + rec._lostWakeWarned = true; + console.warn("[wx-scheduler] LOST WAKE: act " + rec.id + ":" + rec.kind + + " parked " + Math.round((Date.now() - rec.suspendedAt) / 1000) + + "s on " + rec.waitKind + "/" + rec.waitToken + + " but the wait is no longer registered"); + } + }); + }, 10000); + globalThis.__wxWaitDump = function () { return S.dump(); }; + // transition alias: crash-report consumers read __wxAsyncifyDump + globalThis.__wxAsyncifyDump = globalThis.__wxWaitDump; + + // Self-install the activation wraps once the runtime is up (this file ships + // as a --pre-js, so Module exists here). The name set mirrors the + // suspension-capable half of -sJSPI_EXPORTS; absent names are skipped. + if (typeof Module !== "undefined") { + var prevInit = Module["onRuntimeInitialized"]; + Module["onRuntimeInitialized"] = function () { + if (prevInit) prevInit(); + S.installExportWraps([ + "wx_dom_event", "wx_dom_mouse", "wx_window_close", "wx_window_move", + "wx_window_resize", "ProcessEvents", "wxWasmMailboxTick", + "wxWasmTopLevelTick", "wxWasmMainLoopPump", "wxWasmJobTick" + ]); + // KiCad-only surfaces; both installers skip absent names, so the wx + // test apps (no embind) pass through here untouched. + var parkers = S._wrapParkers(); + S._wrapMutators(); + S._preallocRegions(parkers > 0); + }; + } +})(); diff --git a/scripts/common/versions.sh b/scripts/common/versions.sh index e6678cd..c26eb94 100755 --- a/scripts/common/versions.sh +++ b/scripts/common/versions.sh @@ -3,7 +3,8 @@ # These versions match KiCad 8.99 requirements from CMakeLists.txt and vcpkg.json # Emscripten SDK version (single source of truth for Docker and local builds) -export EMSCRIPTEN_VERSION="4.0.2" +# 6.0.6: JSPI-capable toolchain for the experiment/jspi branch (was 4.0.2). +export EMSCRIPTEN_VERSION="6.0.6" # KiCad submodule version export KICAD_COMMIT="4bfed3f1746e8cc0a7d942767770f56fa28b393c" @@ -53,7 +54,10 @@ export OCC_URL="https://github.com/Open-Cascade-SAS/OCCT/archive/refs/tags/V${OC export RAPIDJSON_URL="https://github.com/Tencent/rapidjson/archive/${RAPIDJSON_COMMIT}.tar.gz" # downloads.sourceforge.net serves the file directly; the projects/... /download # form returns an HTML redirect page that breaks curl-based fetches. -export NGSPICE_URL="https://downloads.sourceforge.net/project/ngspice/ng-spice-rework/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz" +# Superseded releases move from ng-spice-rework// to old-releases// on +# sourceforge (46 moved there when 47 shipped, 2026-08 - the top-level path +# 404s). old-releases is the durable home for a pinned version. +export NGSPICE_URL="https://downloads.sourceforge.net/project/ngspice/ng-spice-rework/old-releases/${NGSPICE_VERSION}/ngspice-${NGSPICE_VERSION}.tar.gz" # SHA256 checksums (to be filled in after first successful download) # export ZSTD_SHA256="" diff --git a/scripts/deploy/wasm-cache-hash.mjs b/scripts/deploy/wasm-cache-hash.mjs index 3bd37c6..6e8afec 100644 --- a/scripts/deploy/wasm-cache-hash.mjs +++ b/scripts/deploy/wasm-cache-hash.mjs @@ -72,6 +72,10 @@ const INPUTS = [ // Docker toolchain (base image, emsdk, build driver). { file: "docker/Dockerfile" }, { file: "docker/build.sh" }, + // Toolchain pins — EMSCRIPTEN_VERSION reaches the Dockerfile only as a build + // ARG, so without this entry an emsdk bump alone would cache-hit wasm built + // by the previous toolchain. + { file: "scripts/common/versions.sh" }, ]; // --- helpers ----------------------------------------------------------------- diff --git a/scripts/deps/build-harfbuzz.sh b/scripts/deps/build-harfbuzz.sh index 02ddba3..925b0d3 100755 --- a/scripts/deps/build-harfbuzz.sh +++ b/scripts/deps/build-harfbuzz.sh @@ -61,7 +61,7 @@ emcmake cmake "${HARFBUZZ_DIR}" \ -DCMAKE_INSTALL_PREFIX="${SYSROOT}" \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ -DCMAKE_C_FLAGS="${DEBUG_CFLAGS:--g -O0} -pthread -matomics -mbulk-memory" \ - -DCMAKE_CXX_FLAGS="${DEBUG_CFLAGS:--g -O0} -pthread -matomics -mbulk-memory" \ + -DCMAKE_CXX_FLAGS="${DEBUG_CFLAGS:--g -O0} -pthread -matomics -mbulk-memory -DHB_NO_PRAGMA_GCC_DIAGNOSTIC_ERROR" \ -DHB_HAVE_FREETYPE=ON \ -DHB_HAVE_GLIB=OFF \ -DHB_HAVE_ICU=OFF \ diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index 79a7e90..6a60ebb 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -296,6 +296,30 @@ else log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)" fi +# Async suspension backend (JSPI migration). Default asyncify; PCBJAM_ASYNC_BACKEND=jspi +# links the browser apps with -sJSPI (native stack switching) instead of +# -sASYNCIFY/-sDYNCALLS, defines PCBJAM_JSPI=1 for every TU (selects the JSPI +# libcontext coroutine backend + the wx port's JSPI dispatch paths), and skips +# the whole post-link asyncify pipeline (stub dance + host wasm-opt). The wx +# build this app links against must be built with the SAME knob +# (build-wx-wasm.sh reads it too). The headless CLIs (kicad_tools, occ_service) +# stay asyncify-shaped regardless: their targets pin -sASYNCIFY=0 and run in +# node/worker where no suspension backend is wanted. +PCBJAM_ASYNC_BACKEND="${PCBJAM_ASYNC_BACKEND:-asyncify}" +case "${APP_NAME}" in + kicad_tools|occ_service) PCBJAM_ASYNC_BACKEND="asyncify" ;; +esac +if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then + EXTRA_FLAGS="${EXTRA_FLAGS} -DPCBJAM_JSPI=1" + # PCBJAM_JSPI is ABI-affecting (coroutine.h's callerStub grows a finish + # hook — a template, so every instantiating TU must agree): route it into + # KICAD_TU_ABI_FLAGS via EMBIND_CONFIG_DEFINES like the Debug -DDEBUG. + EMBIND_CONFIG_DEFINES="${EMBIND_CONFIG_DEFINES} -DPCBJAM_JSPI=1" + log_info "Async backend: JSPI (-sJSPI, -DPCBJAM_JSPI=1)" +else + log_info "Async backend: asyncify" +fi + # Step 6: Create build directory mkdir -p "${KICAD_BUILD}" cd "${KICAD_BUILD}" @@ -371,9 +395,10 @@ fi EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt" EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize" -if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ]; then - # Use the real tools so the small -g0 module is fully finalized inside the - # container (no host post-processing / asyncify for these targets). +if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ] || [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then + # Use the real tools so the module is fully finalized inside the container + # (no host post-processing / asyncify for these targets). JSPI mode has no + # asyncify pass at all, so every app finalizes in-container. [ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}" [ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}" log_info "Using real wasm-opt/finalize for ${APP_NAME} (finalize in-container)" @@ -432,6 +457,16 @@ EMBIND_SRC="${PROJECT_ROOT}/wasm/bindings/${EMBIND_APP}_embind.cpp" # We use CMAKE_MODULE_PATH to inject our compatibility layer kw_stage kicad-configure log_info "Configuring KiCad with CMake..." +# emcc 6: -sUSE_ZLIB is gone; prebuild the zlib port (regular + PIC) so the +# explicit ZLIB_LIBRARY below always exists (PIC needed by the .so links). +"${EMSDK}/upstream/emscripten/embuilder" build zlib >/dev/null 2>&1 || true +"${EMSDK}/upstream/emscripten/embuilder" build zlib --pic >/dev/null 2>&1 || true + +# CMAKE_SHARED/MODULE_LINKER_FLAGS below: set EXPLICITLY (a stale CMakeCache +# once resurrected experimental -L hacks), and carry the SAME ODR policy as +# the exe links (wasm/editor/CMakeLists.txt): KiCad deliberately ships +# wx-compat copies (wxGetContentRect in grid_checkbox.cpp vs wx grid.cpp) +# that emcc 6 kiface/.so links reject without --allow-multiple-definition. # Use ccache if available (CMAKE_*_COMPILER_LAUNCHER is the proper CMake way) CCACHE_OPTS="" @@ -506,14 +541,23 @@ if [ "${APP_NAME}" = "kicad_tools" ] || [ "${APP_NAME}" = "occ_service" ]; then NANOSLEEP_YIELD_LINK="" else emcc -c -pthread "${PROJECT_ROOT}/wasm/shims/nanosleep_yield.c" -o "${STUBS_BUILD}/nanosleep_yield.o" - # Its scheduler-aware half (docs/features/async/22 Phase B): a main-thread - # sleep on a scheduler context parks THAT CONTEXT instead of suspending the - # stack in place. C++ because the registry is a header-only C++ layer in - # wx's port, hence WX_CXXFLAGS for the include path (the same reason - # thirdparty/libcontext needed it at Phase A). - em++ -c -std=c++17 -pthread ${WX_CXXFLAGS} \ - "${PROJECT_ROOT}/wasm/shims/context_sleep.cpp" -o "${STUBS_BUILD}/context_sleep.o" - NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o ${STUBS_BUILD}/context_sleep.o" + if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then + # JSPI: no scheduler-context lane exists — the weak + # pcbjam_context_sleep_ms stays null and every main-thread sleep + # suspends in place (legal on a promising activation; EM_ASYNC_JS + # auto-wraps as WebAssembly.Suspending under -sJSPI). + # context_sleep.cpp is fiber-only machinery and must not link. + NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o" + else + # Its scheduler-aware half (docs/features/async/22 Phase B): a main-thread + # sleep on a scheduler context parks THAT CONTEXT instead of suspending the + # stack in place. C++ because the registry is a header-only C++ layer in + # wx's port, hence WX_CXXFLAGS for the include path (the same reason + # thirdparty/libcontext needed it at Phase A). + em++ -c -std=c++17 -pthread ${WX_CXXFLAGS} \ + "${PROJECT_ROOT}/wasm/shims/context_sleep.cpp" -o "${STUBS_BUILD}/context_sleep.o" + NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o ${STUBS_BUILD}/context_sleep.o" + fi fi # mallinfo() stub for the mimalloc build: -sMALLOC=mimalloc doesn't export the @@ -539,6 +583,22 @@ else PTHREAD_POOL_EXPR='navigator.hardwareConcurrency' fi +# Suspension-backend link surface. Asyncify: binaryen instrumentation + +# DYNCALLS (+ the dynCall runtime export used by the post-link dyncall shims). +# JSPI: native suspension — -sJSPI + the promising-export census +# (scripts/common/jspi-exports.txt: the wx KEEPALIVE entries that can park + +# pcbjam_libctx_entry; regenerate by grep, not memory), the jspi-scheduler +# pre-js (successor of the post-link-injected asyncify-scheduler.js), and the +# runtime methods its green-copy stack discipline needs (stackSave/ +# stackRestore/HEAPU8). -sDYNCALLS is a fatal link error under JSPI. +if [ "${PCBJAM_ASYNC_BACKEND}" = "jspi" ]; then + ASYNC_LINK_FLAGS="-sJSPI -sJSPI_EXPORTS=@${PROJECT_ROOT}/scripts/common/jspi-exports.txt --pre-js ${PROJECT_ROOT}/scripts/common/shims/jspi-scheduler.js" + ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','stackSave','stackRestore','HEAPU8','HEAP8','HEAP32']" +else + ASYNC_LINK_FLAGS="-sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536" + ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall']" +fi + emcmake cmake "${KICAD_DIR}" \ ${CCACHE_OPTS} \ ${KICAD_TOOLS_CMAKE_FLAG} \ @@ -549,9 +609,13 @@ emcmake cmake "${KICAD_DIR}" \ -DCMAKE_MODULE_PATH="${WASM_LAYER}/cmake" \ -DSYSROOT="${SYSROOT}" \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ - -DCMAKE_CXX_FLAGS="${EXTRA_FLAGS} -Xclang -fno-pch-timestamp -pthread -sUSE_ZLIB=1 -DKICAD_USE_PLATFORM_WASM=1${DIAG_DEFINES} -I${SYSROOT}/include -I${STUBS_DIR} -include ${STUBS_DIR}/char_traits_uint16_workaround.h" \ - -DCMAKE_C_FLAGS="${EXTRA_FLAGS} -pthread -sUSE_ZLIB=1 -I${SYSROOT}/include -I${STUBS_DIR}" \ - -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread -sUSE_ZLIB=1 -sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536 -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall'] ${EMBIND_LINK_FLAG} -L${SYSROOT}/lib ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ + -DCMAKE_CXX_FLAGS="${EXTRA_FLAGS} -Xclang -fno-pch-timestamp -pthread --use-port=zlib -DKICAD_USE_PLATFORM_WASM=1${DIAG_DEFINES} -I${SYSROOT}/include -I${STUBS_DIR} -include ${STUBS_DIR}/char_traits_uint16_workaround.h" \ + -DCMAKE_C_FLAGS="${EXTRA_FLAGS} -pthread --use-port=zlib -I${SYSROOT}/include -I${STUBS_DIR}" \ + -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread ${ASYNC_LINK_FLAGS} -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} ${ASYNC_RUNTIME_METHODS} ${EMBIND_LINK_FLAG} -L${SYSROOT}/lib -L${KICAD_BUILD}/common -L${KICAD_BUILD}/common/gal ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ + -DCMAKE_SHARED_LINKER_FLAGS="-Wl,--allow-multiple-definition" \ + -DCMAKE_MODULE_LINKER_FLAGS="-Wl,--allow-multiple-definition" \ + -DZLIB_LIBRARY="${EMSDK}/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/pic/libz.a" \ + -DZLIB_INCLUDE_DIR="${EMSDK}/upstream/emscripten/cache/sysroot/include" \ -DCMAKE_PREFIX_PATH="${SYSROOT};${WX_BUILD}" \ -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \ \ diff --git a/tests/apps/Makefile.wasm b/tests/apps/Makefile.wasm index 486e468..3ded4da 100644 --- a/tests/apps/Makefile.wasm +++ b/tests/apps/Makefile.wasm @@ -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 diff --git a/tests/apps/standalone/asyncify-races/races_test.cpp b/tests/apps/standalone/asyncify-races/races_test.cpp index 5589152..8805935 100644 --- a/tests/apps/standalone/asyncify-races/races_test.cpp +++ b/tests/apps/standalone/asyncify-races/races_test.cpp @@ -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. diff --git a/tests/apps/standalone/jspi-coroutine/boot-check.cjs b/tests/apps/standalone/jspi-coroutine/boot-check.cjs new file mode 100644 index 0000000..83449aa --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/boot-check.cjs @@ -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 [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'); +}); diff --git a/tests/apps/standalone/jspi-coroutine/browser-run.cjs b/tests/apps/standalone/jspi-coroutine/browser-run.cjs new file mode 100644 index 0000000..0e5dc85 --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/browser-run.cjs @@ -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); +}); diff --git a/tests/apps/standalone/jspi-coroutine/build.sh b/tests/apps/standalone/jspi-coroutine/build.sh new file mode 100755 index 0000000..a69559d --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/build.sh @@ -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" diff --git a/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp b/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp new file mode 100644 index 0000000..3898f99 --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp @@ -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 PASS|FAIL() +// [JSPI_CORO] SUMMARY passed= failed= + +#include +#include +#include + +#include +#include +#include +#include +#include + +// --- 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; + + 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 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( 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( + libcontext::jump_fcontext( &m_caller.ctx, m_callee.ctx, + reinterpret_cast( 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( args ) ); + auto* ret = reinterpret_cast( 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 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 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 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; +} diff --git a/tests/apps/standalone/jspi-coroutine/index.html b/tests/apps/standalone/jspi-coroutine/index.html new file mode 100644 index 0000000..74c03a5 --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/index.html @@ -0,0 +1,17 @@ + + +jspi-coroutine harness + + + + diff --git a/tests/apps/standalone/jspi-coroutine/run.mjs b/tests/apps/standalone/jspi-coroutine/run.mjs new file mode 100644 index 0000000..687af51 --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/run.mjs @@ -0,0 +1,2 @@ +import factory from './coroutine_jspi_test.mjs'; +const m = await factory(); diff --git a/tests/apps/standalone/jspi-coroutine/run_pt.mjs b/tests/apps/standalone/jspi-coroutine/run_pt.mjs new file mode 100644 index 0000000..97c54ea --- /dev/null +++ b/tests/apps/standalone/jspi-coroutine/run_pt.mjs @@ -0,0 +1,2 @@ +import factory from './coroutine_jspi_test_pt.mjs'; +const m = await factory(); diff --git a/tests/apps/standalone/jspi-stack/build.sh b/tests/apps/standalone/jspi-stack/build.sh new file mode 100755 index 0000000..2f67b6d --- /dev/null +++ b/tests/apps/standalone/jspi-stack/build.sh @@ -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" diff --git a/tests/apps/standalone/jspi-stack/driver.mjs b/tests/apps/standalone/jspi-stack/driver.mjs new file mode 100644 index 0000000..22cb1b6 --- /dev/null +++ b/tests/apps/standalone/jspi-stack/driver.mjs @@ -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 corruptA= corruptB= corruptNested= verdict= +// +// 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); diff --git a/tests/apps/standalone/jspi-stack/index.html b/tests/apps/standalone/jspi-stack/index.html new file mode 100644 index 0000000..9b1a572 --- /dev/null +++ b/tests/apps/standalone/jspi-stack/index.html @@ -0,0 +1,166 @@ + + +jspi-stack harness + + + + + diff --git a/tests/apps/standalone/jspi-stack/stack_test.cpp b/tests/apps/standalone/jspi-stack/stack_test.cpp new file mode 100644 index 0000000..ad02394 --- /dev/null +++ b/tests/apps/standalone/jspi-stack/stack_test.cpp @@ -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 +#include +#include +#include +#include + +// 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 +#include +#include + +namespace { +std::atomic 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; +} diff --git a/tests/asyncify/asyncify-races-scheduler.spec.ts b/tests/asyncify/asyncify-races-scheduler.spec.ts deleted file mode 100644 index 72185c4..0000000 --- a/tests/asyncify/asyncify-races-scheduler.spec.ts +++ /dev/null @@ -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 { - 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); - }); -}); diff --git a/tests/asyncify/sched-context.spec.ts b/tests/asyncify/sched-context.spec.ts deleted file mode 100644 index c173d17..0000000 --- a/tests/asyncify/sched-context.spec.ts +++ /dev/null @@ -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(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(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); - }); -}); diff --git a/tests/jspi/jspi-coroutine.spec.ts b/tests/jspi/jspi-coroutine.spec.ts new file mode 100644 index 0000000..2cf1152 --- /dev/null +++ b/tests/jspi/jspi-coroutine.spec.ts @@ -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 PASS|FAIL" then +// "[JSPI_CORO] SUMMARY passed= failed=". + +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); + }); +}); diff --git a/tests/jspi/jspi-stack.spec.ts b/tests/jspi/jspi-stack.spec.ts new file mode 100644 index 0000000..27be547 --- /dev/null +++ b/tests/jspi/jspi-stack.spec.ts @@ -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 / corruptA=.. corruptB=.. corruptNested=.. verdict= + +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}`); + } + } + }); +}); diff --git a/tests/asyncify/asyncify-races.spec.ts b/tests/jspi/suspend-races.spec.ts similarity index 58% rename from tests/asyncify/asyncify-races.spec.ts rename to tests/jspi/suspend-races.spec.ts index 05999c9..04506bc 100644 --- a/tests/asyncify/asyncify-races.spec.ts +++ b/tests/jspi/suspend-races.spec.ts @@ -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); }); }); diff --git a/tests/kicad/footprint-chooser-close.spec.ts b/tests/kicad/footprint-chooser-close.spec.ts index 20c3db1..06f99c9 100644 --- a/tests/kicad/footprint-chooser-close.spec.ts +++ b/tests/kicad/footprint-chooser-close.spec.ts @@ -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'); diff --git a/tests/kicad/timer-park-repro.spec.ts b/tests/kicad/timer-park-repro.spec.ts index 3471d0c..68e27f3 100644 --- a/tests/kicad/timer-park-repro.spec.ts +++ b/tests/kicad/timer-park-repro.spec.ts @@ -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). diff --git a/tests/kicad/utils/guard-beacons.ts b/tests/kicad/utils/wait-beacons.ts similarity index 87% rename from tests/kicad/utils/guard-beacons.ts rename to tests/kicad/utils/wait-beacons.ts index d90784e..51e3c35 100644 --- a/tests/kicad/utils/guard-beacons.ts +++ b/tests/kicad/utils/wait-beacons.ts @@ -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, }; diff --git a/tests/package.json b/tests/package.json index 8cd6426..932da97 100644 --- a/tests/package.json +++ b/tests/package.json @@ -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", diff --git a/tests/playwright-web.config.ts b/tests/playwright-web.config.ts index 94fe9da..9d94cd1 100644 --- a/tests/playwright-web.config.ts +++ b/tests/playwright-web.config.ts @@ -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', diff --git a/tests/playwright.config.ts b/tests/playwright.config.ts index 15f08bd..a421bd5 100644 --- a/tests/playwright.config.ts +++ b/tests/playwright.config.ts @@ -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. diff --git a/tests/tools/lint-ci-coverage.ts b/tests/tools/lint-ci-coverage.ts index 456e91e..3407d3f 100644 --- a/tests/tools/lint-ci-coverage.ts +++ b/tests/tools/lint-ci-coverage.ts @@ -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) ]); diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index 31175db..6c0132e 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -65,6 +65,7 @@ #include "collab_presence_style.h" #include "pcbjam_theme.h" #include "pcbjam_libs_reload.h" +#include "pcbjam_async_policy.h" #include using namespace emscripten; @@ -2061,7 +2062,7 @@ EMSCRIPTEN_BINDINGS(eeschema) { // JS names ALSO registered by pcbnew_embind.cpp — in the merged image these are // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). - function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); @@ -2106,7 +2107,7 @@ EMSCRIPTEN_BINDINGS(eeschema) { function("kicadCollabTestSelectByUuid", &schCollabTestSelectByUuid); function("kicadCollabTestClearSelection", &schCollabTestClearSelection); // Library reload after a remote (synced) lib edit — r2-idb-sync realtime. - function("kicadLibsReload", &pcbjam_libs::reloadLibrary); + function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY); // Placed-instance count for a library symbol (drives the "a symbol you are // using was updated" toast after a remote lib edit). function("kicadLibsSymbolUsage", &schLibsSymbolUsage); diff --git a/wasm/bindings/gerbview_embind.cpp b/wasm/bindings/gerbview_embind.cpp index 52b3701..ea85d13 100644 --- a/wasm/bindings/gerbview_embind.cpp +++ b/wasm/bindings/gerbview_embind.cpp @@ -26,6 +26,7 @@ #include #include "open_gate.h" #include "main_stack_runner.h" +#include "pcbjam_async_policy.h" using namespace emscripten; using json = nlohmann::json; @@ -86,7 +87,7 @@ static bool kicadOpenFileBusy() EMSCRIPTEN_BINDINGS( gerbview ) { - function( "kicadOpenFile", &kicadOpenFile ); - function( "kicadOpenFiles", &kicadOpenFiles ); + function( "kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY ); + function( "kicadOpenFiles", &kicadOpenFiles PCBJAM_PARKER_POLICY ); function( "kicadOpenFileBusy", &kicadOpenFileBusy ); } diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 75d66c9..bcc28f8 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -41,6 +41,7 @@ #include #include "pcbjam_libs_reload.h" +#include "pcbjam_async_policy.h" #include "open_gate.h" #include "main_stack_runner.h" #include "timer_park.h" @@ -611,7 +612,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { // JS side must defer scratch saves while collab fiber work is in flight. function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe); // Programmatic file open (preferred over UI automation from the web app). - function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY); function("kicadOpenFileStart", &kicadOpenFileStart); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); @@ -671,7 +672,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { function("kicadCollabTestSelectFirst", &collabTestSelectFirst); function("kicadCollabTestClearSelection", &collabTestClearSelection); // Library reload after a remote (synced) lib edit — r2-idb-sync realtime. - function("kicadLibsReload", &pcbjam_libs::reloadLibrary); + function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY); // Placed-instance count for a library symbol (schematic sessions only — // 0 from any other frame; drives the "symbol you are using was updated" // toast after a remote lib edit). diff --git a/wasm/bindings/pcbjam_async_policy.h b/wasm/bindings/pcbjam_async_policy.h new file mode 100644 index 0000000..9444d44 --- /dev/null +++ b/wasm/bindings/pcbjam_async_policy.h @@ -0,0 +1,24 @@ +/* + * PCBJAM_PARKER_POLICY — registration policy for the suspending embind + * exports (the doc-18 "PARKER" class: kicadOpenFile / kicadOpenFiles / + * kicadLibsReload, whose bodies park on wx wait tokens mid-load). + * + * JSPI backend: they MUST be registered emscripten::async() so embind wraps + * the invoker in WebAssembly.promising — the call returns a real Promise and + * the suspension is legal. Without it the first park throws SuspendError + * ("trying to suspend without WebAssembly.promising"). + * + * Asyncify backend: no policy — the legacy contract stands (placeholder + * return, callers gate on kicadOpenFileBusy; the scheduler shim owns the + * await surface). + * + * Usage (note the macro carries its own leading comma): + * function( "kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY ); + */ +#pragma once + +#ifdef PCBJAM_JSPI +#define PCBJAM_PARKER_POLICY , emscripten::async() +#else +#define PCBJAM_PARKER_POLICY +#endif diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 62d276f..696d834 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -54,6 +54,7 @@ #include "collab_presence_core.h" #include "open_gate.h" #include "main_stack_runner.h" +#include "pcbjam_async_policy.h" #include "timer_park.h" #include "fiber_park.h" #include "collab_presence_style.h" @@ -2430,7 +2431,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // JS names ALSO registered by eeschema_embind.cpp — in the merged image these are // registered once by kicad_editor_embind.cpp, dispatching on the active frame. // Programmatic file open (preferred over UI automation from the web app). - function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); function("kicadTestArmTimerPark", &kicadTestArmTimerPark); @@ -2484,7 +2485,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) { function("kicadCollabTestSelectByUuid", &pcbCollabTestSelectByUuid); function("kicadCollabTestClearSelection", &pcbCollabTestClearSelection); // Library reload after a remote (synced) lib edit — r2-idb-sync realtime. - function("kicadLibsReload", &pcbjam_libs::reloadLibrary); + function("kicadLibsReload", &pcbjam_libs::reloadLibrary PCBJAM_PARKER_POLICY); #endif // !KICAD_MERGED_EMBIND } #endif diff --git a/wasm/bindings/pl_editor_embind.cpp b/wasm/bindings/pl_editor_embind.cpp index 211fb4b..d2491e8 100644 --- a/wasm/bindings/pl_editor_embind.cpp +++ b/wasm/bindings/pl_editor_embind.cpp @@ -19,6 +19,7 @@ #include #include "open_gate.h" #include "main_stack_runner.h" +#include "pcbjam_async_policy.h" #include #include #include @@ -616,7 +617,7 @@ std::string kicadCollabTestAddText( std::string aText, double aX, double aY ) EMSCRIPTEN_BINDINGS(pl_editor) { // Programmatic file open (preferred over UI automation from the web app). - function("kicadOpenFile", &kicadOpenFile); + function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY); function("kicadOpenFileBusy", &kicadOpenFileBusy); function("kicadTestSetOpenPark", &kicadTestSetOpenPark); // Read-only viewer lock (read-only-viewer). diff --git a/wasm/shims/nanosleep_yield.c b/wasm/shims/nanosleep_yield.c index 520b0a4..511e586 100644 --- a/wasm/shims/nanosleep_yield.c +++ b/wasm/shims/nanosleep_yield.c @@ -30,6 +30,15 @@ /* EM_ASYNC_JS integrates with Asyncify automatically (binaryen instruments every caller). */ EM_ASYNC_JS( void, __wasm_main_thread_yield_ms, ( double ms ), { + /* JSPI: route through the scheduler's turnstile when it exists - a raw + await's engine-level resume bypasses the shim's SP discipline and + leaves its window marked live (the pump then refuses every later + resume). Asyncify builds (no jspi scheduler) keep the raw await. */ + var S = globalThis.__wxScheduler; + if( S && S.backend === 'jspi' ) { + await S.sleepYield( ms ); + return; + } await new Promise( function( resolve ) { setTimeout( resolve, ms ); } ); } ); diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index 0749587..1a5fc11 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -1250,12 +1250,19 @@ export function WasmTool({ const promote = (kind: string, msg: string) => { append(`[fatal] ${kind}: ${msg}`); append(dumpTrace()); - // The asyncify flight recorder (handlesleep.js shim): event ring + - // machine state at death — the targeting data for the fiber trap. - const rec = ( - window as Window & { __wxAsyncifyDump?: () => string } - ).__wxAsyncifyDump?.(); - if (rec) append(rec); + // The scheduler flight recorder: event ring + wait/activation state at + // death — the targeting data for suspension-machinery traps. Canonical + // name is __wxWaitDump (jspi-scheduler); __wxAsyncifyDump is the legacy + // shim's name, kept as a fallback one release. The jspi dump is an + // object, the legacy one a string — normalize. + const dumper = ( + window as Window & { + __wxWaitDump?: () => unknown; + __wxAsyncifyDump?: () => unknown; + } + ); + const rec = (dumper.__wxWaitDump ?? dumper.__wxAsyncifyDump)?.(); + if (rec) append(typeof rec === "string" ? rec : JSON.stringify(rec)); setFatal(msg); setShowLog(true); // Arm the React-independent floor too: it stays invisible while our @@ -1763,15 +1770,27 @@ export function WasmTool({ // they proceed (saves are already MEMFS-only above). if (readOnly) { const setRo = ( - win.Module as { kicadSetReadOnly?: (v: boolean) => boolean } | undefined + win.Module as + | { kicadSetReadOnly?: (v: boolean) => boolean | Promise } + | undefined )?.kicadSetReadOnly; if (typeof setRo === "function") { - const t0 = Date.now(); - while (setRo(true) !== true) { - if (Date.now() - t0 > 30_000) { - throw new Error("read-only lock did not apply"); - } - await new Promise((r) => setTimeout(r, 150)); + // The scheduler's mutator lane returns the boolean synchronously + // when the wasm side is idle, and a Promise for the SAME call when + // it queued behind a live open — await covers both. (The old + // poll-until-literal-true loop could spin forever under JSPI: a + // queued call re-enqueues on every retry and never compares true.) + const applied = await Promise.race([ + Promise.resolve(setRo(true)), + new Promise((_, reject) => + setTimeout( + () => reject(new Error("read-only lock did not apply")), + 30_000, + ), + ), + ]); + if (applied !== true) { + throw new Error("read-only lock did not apply"); } append("[readonly] wasm frame locked (kicadSetReadOnly)"); } else if (tool !== "gerbview" && tool !== "calculator") { diff --git a/web/standalone/src/wasm/open-flow.ts b/web/standalone/src/wasm/open-flow.ts index fa22eca..ade3dda 100644 --- a/web/standalone/src/wasm/open-flow.ts +++ b/web/standalone/src/wasm/open-flow.ts @@ -21,9 +21,10 @@ export interface OpenFlowOptions { * Replace the programmatic invocation (default: `Module.kicadOpenFile(path)`) * while keeping the readiness handling around it — the frame wait, the * settle gate, the no-UI-automation-while-parked rule. GerbView uses this to - * open a whole fabrication set through `kicadOpenFiles`. + * open a whole fabrication set through `kicadOpenFiles`. May return the + * open call's Promise (JSPI embind async) — the flow contains its rejection. */ - open?: () => void; + open?: () => unknown; } const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -98,20 +99,22 @@ function hasProgrammaticHook(win: ToolWindow): boolean { } /** - * Invoke the programmatic hook. NOTE: kicadOpenFile runs OpenProjectFiles under - * Asyncify, so the call SUSPENDS and unwinds back to JS before the load finishes - * — its synchronous return is a falsy placeholder, not the real bool. So we fire - * it and ignore the return; the caller polls for the loaded schematic instead. + * Invoke the programmatic hook. kicadOpenFile suspends mid-load either way: + * under JSPI it is an embind async() export and returns a real Promise for the + * whole load chain; legacy asyncify builds return a falsy placeholder. The + * caller contains the Promise's rejection and gates readiness on the settle + * probe, which is truthful for both shapes. */ function invokeProgrammaticOpen( win: ToolWindow, absPath: string, log: (m: string) => void, -): void { +): unknown { // eslint-disable-next-line @typescript-eslint/no-explicit-any const mod = win.Module as any; - mod.kicadOpenFile(absPath); - log(`[open] invoked Module.kicadOpenFile(${absPath}) (async; polling for load)`); + const ret = mod.kicadOpenFile(absPath); + log(`[open] invoked Module.kicadOpenFile(${absPath}) (async; awaiting settle)`); + return ret; } /** Heuristic: the editor frame title drops "untitled" once a real file is open. */ @@ -222,14 +225,19 @@ export async function openFileInTool( } // Strategy 1: programmatic hook (preferred — deterministic, no UI automation). - // Because the call is Asyncify-async we can't trust its return value; instead - // we invoke it and wait for the open chain to settle (kicadOpenFileBusy — see - // waitForOpenSettled). We must NOT fall back to UI automation while the hook - // is in flight — synthesizing input would re-enter the suspended Asyncify - // call and corrupt it. + // The open call suspends mid-load; readiness comes from the settle probe + // (kicadOpenFileBusy — see waitForOpenSettled), NOT the call's return: under + // JSPI the returned Promise deliberately stays pending while the load is + // parked on a user dialog (file-version confirm, remap…), exactly the case + // the probe's input-dialog escape handles. We must NOT fall back to UI + // automation while the hook is in flight — synthesizing input would re-enter + // the suspended load and corrupt it. if (opts.open || hasProgrammaticHook(win)) { - if (opts.open) opts.open(); - else invokeProgrammaticOpen(win, absPath, log); + const ret = opts.open ? opts.open() : invokeProgrammaticOpen(win, absPath, log); + // JSPI: contain the load Promise's rejection — a failed open clears the + // busy gate (RAII) and reports through the settle path like it always + // has; it must not ALSO surface as an unhandled rejection. + Promise.resolve(ret).catch((e) => log(`[open] open chain rejected: ${e}`)); const settled = await waitForOpenSettled(win, log, timeoutMs, opts.settleTimeoutMs); return settled ? "programmatic" : "failed"; } diff --git a/web/standalone/src/wasm/scheduler-shim.test.ts b/web/standalone/src/wasm/scheduler-shim.test.ts index dae5d7f..bd92bf8 100644 --- a/web/standalone/src/wasm/scheduler-shim.test.ts +++ b/web/standalone/src/wasm/scheduler-shim.test.ts @@ -1,12 +1,18 @@ /** * N5 — flood/fairness unit gates for the scheduler shim * (docs/features/async/17 §3d N5; the shim source is scripts/common/shims/ - * asyncify-scheduler.js, loaded here against a fake runtime surface). + * jspi-scheduler.js — the JSPI-era successor of asyncify-scheduler.js — + * loaded here against a fake runtime surface). * * Doc 06 §starvation: FIFO by default; a stimulus flood must neither reorder * deliveries nor starve them, and the time-boxed pump must not monopolize the * thread in one burst. These are unit gates — the e2e batteries cover the * same machinery under the real runtime. + * + * Retired with the asyncify shim (states unrepresentable under JSPI): + * deferred wakes (readyWakes/_scheduleWakeDrain), currData single-writer + * tripwire, state() machine string. The S4 wait-registry gates below are the + * JSPI-era additions. */ import { describe, expect, it, vi, beforeEach } from "vitest"; import { readFileSync } from "node:fs"; @@ -15,24 +21,27 @@ import path from "node:path"; const SHIM_PATH = path.resolve( path.dirname(fileURLToPath(import.meta.url)), - "../../../../scripts/common/shims/asyncify-scheduler.js", + "../../../../scripts/common/shims/jspi-scheduler.js", ); type SchedulerShape = { + backend: string; mailbox: unknown[]; mutatorQueue: unknown[]; mutatorsDelivered: number; - readyWakes: { deliver: (r: unknown) => void; result: unknown }[]; - deferredWakes: number; - drainedWakes: number; - strayWrites: number; dead: boolean; shutdown(reason: string): void; enqueueAfter(fn: number, arg: number, ms: number): void; _openBusy(): boolean; _armMutatorPump(): void; - _scheduleWakeDrain(): void; - state(): string; + beginWait(kind: string): number; + waitPromise(token: number): Promise; + waitEarlyResolved(token: number): number; + takeWaitResult(token: number): number; + resolveWait(token: number, result: number): boolean; + resolveTopWait(kind: string, result: number): boolean; + pendingWaits(kind: string): number; + earlyWaitResolves: number; }; declare global { @@ -47,16 +56,6 @@ function loadShim(opts: { busy: () => boolean }) { delete (globalThis as Record).__wxSchedulerInstalled; delete (globalThis as Record).__wxScheduler; const g = globalThis as Record; - g.Asyncify = { - state: 0, - exportCallStack: [], - currData: null, - handleSleep: function (startAsync: (wake: (r: unknown) => void) => void) { - startAsync(() => undefined); - }, - allocateData: () => 0, - maybeStopUnwind: () => undefined, - }; g.Module = { kicadOpenFileBusy: opts.busy, kicadCollabApplyItems: (x: unknown) => `applied:${String(x)}`, @@ -64,17 +63,24 @@ function loadShim(opts: { busy: () => boolean }) { // eslint-disable-next-line no-eval (0, eval)(readFileSync(SHIM_PATH, "utf8")); const S = (globalThis as Record).__wxScheduler as SchedulerShape; - // Run the Module init hook (fake runtime: pretend init fired). + // Run the Module init hook (fake runtime: pretend init fired) — installs + // the export/parker/mutator wraps; absent names are skipped. const M = g.Module as { onRuntimeInitialized?: () => void }; M.onRuntimeInitialized?.(); return S; } -describe("N5: scheduler shim under flood", () => { +describe("N5: scheduler shim under flood (jspi backend)", () => { beforeEach(() => { vi.useRealTimers(); }); + it("identifies as the jspi backend", () => { + const S = loadShim({ busy: () => false }); + expect(S.backend).toBe("jspi"); + expect(globalThis.__wxSchedulerInstalled).toBe(true); + }); + it("500-call mutator flood delivers strictly FIFO with zero drops", async () => { vi.useFakeTimers(); try { @@ -140,7 +146,18 @@ describe("N5: scheduler shim under flood", () => { } }); - it("S6 shutdown: queued mutators reject, messages and wakes drop, pumps stop", async () => { + it("mutators bypass the queue when idle and not open-busy", () => { + const S = loadShim({ busy: () => false }); + const M = (globalThis as Record).Module as { + kicadCollabApplyItems: (x: number) => unknown; + }; + // Sync fast path: the wrapped call returns the real value, unqueued. + expect(M.kicadCollabApplyItems(7)).toBe("applied:7"); + expect(S.mutatorQueue.length).toBe(0); + expect(S.mutatorsDelivered).toBe(1); + }); + + it("S6 shutdown: queued mutators reject, messages drop, pumps stop", async () => { vi.useFakeTimers(); try { let busy = true; @@ -152,7 +169,6 @@ describe("N5: scheduler shim under flood", () => { const exP = expect(p).rejects.toThrow("shutdown"); S.enqueueAfter(1234, 0, 5); await vi.advanceTimersByTimeAsync(6); // message lands in the mailbox - S.readyWakes.push({ deliver: () => undefined, result: 0 }); expect(S.mailbox.length).toBe(1); expect(S.mutatorQueue.length).toBe(1); @@ -160,9 +176,7 @@ describe("N5: scheduler shim under flood", () => { await exP; expect(S.mailbox.length).toBe(0); expect(S.mutatorQueue.length).toBe(0); - expect(S.readyWakes.length).toBe(0); expect(S.dead).toBe(true); - expect(S.state()).toContain("DEAD"); // Post-shutdown enqueues are dropped, and idempotent shutdown is safe. S.enqueueAfter(1234, 0, 1); @@ -177,22 +191,47 @@ describe("N5: scheduler shim under flood", () => { } }); - it("deferred wakes drain strictly FIFO", async () => { - vi.useFakeTimers(); - try { - const S = loadShim({ busy: () => false }); - const order: number[] = []; - // Queue 50 deferred wakes directly (the runtime path queues these when - // a wake arrives mid-transition); the drain must preserve order. - for (let i = 0; i < 50; i++) { - S.readyWakes.push({ deliver: (r) => order.push(r as number), result: i }); - } - S._scheduleWakeDrain(); - await vi.advanceTimersByTimeAsync(50); - expect(order).toEqual(Array.from({ length: 50 }, (_, i) => i)); - expect(S.readyWakes.length).toBe(0); - } finally { - vi.useRealTimers(); - } + // --- S4 wait registry (JSPI-era unit gates) ------------------------------ + + it("early resolve is consumed by the late waiter (no lost wake)", async () => { + const S = loadShim({ busy: () => false }); + const token = S.beginWait("modal"); + // Resolve BEFORE anyone awaits — the EndModal-during-Show() race. + expect(S.resolveWait(token, 42)).toBe(true); + expect(S.waitEarlyResolved(token)).toBe(1); + expect(S.earlyWaitResolves).toBe(1); + // The late waiter still gets the result, immediately. + await expect(S.waitPromise(token)).resolves.toBe(42); + // Consumed: a second take returns nothing. + expect(S.takeWaitResult(token)).toBe(0); + }); + + it("resolveTopWait pops per-kind LIFO — innermost modal first", () => { + const S = loadShim({ busy: () => false }); + const outer = S.beginWait("modal"); + const inner = S.beginWait("modal"); + const nested = S.beginWait("nested"); // different kind: untouched + expect(S.pendingWaits("modal")).toBe(2); + + expect(S.resolveTopWait("modal", 7)).toBe(true); + expect(S.waitEarlyResolved(inner), "inner resolved first").toBe(1); + expect(S.waitEarlyResolved(outer)).toBe(0); + expect(S.pendingWaits("modal")).toBe(1); + expect(S.pendingWaits("nested")).toBe(1); + + expect(S.resolveTopWait("modal", 8)).toBe(true); + expect(S.waitEarlyResolved(outer)).toBe(1); + expect(S.resolveTopWait("modal", 9), "empty stack refuses").toBe(false); + void nested; + }); + + it("double resolve is refused; unknown token is a defined no-op", async () => { + const S = loadShim({ busy: () => false }); + const token = S.beginWait("sleep"); + expect(S.resolveWait(token, 1)).toBe(true); + expect(S.resolveWait(token, 2), "second resolve refused").toBe(false); + await expect(S.waitPromise(token)).resolves.toBe(1); + expect(S.resolveWait(99999, 0)).toBe(false); + await expect(S.waitPromise(99999), "unknown token resolves 0").resolves.toBe(0); }); }); diff --git a/wxwidgets b/wxwidgets index 4d479cb..8921d7a 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 4d479cb3095f6b6cbc9550257c8f0c5b1deb102d +Subproject commit 8921d7aaa4f3c5491d0e880369d88cdefa98e933