jspi: migration phases 0-7 — build knob, scheduler shim, test successor suite
Toolchain: emsdk 6.0.6 (versions.sh; cache-hash keys on it). Build knob PCBJAM_ASYNC_BACKEND=jspi|asyncify: build-kicad-target.sh links editors with -sJSPI + -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt + --pre-js jspi-scheduler.js (no DYNCALLS, no post-link asyncify pipeline); wx build stamps the backend and forces clean on flip or unknown provenance; docker/build.sh passes the knob, seeds the emscripten ports cache from the volume every launch, jspi postprocess = patch-env-shim only. scripts/common/shims/jspi-scheduler.js: the JSPI successor scheduler — token-wait registry, resume turnstile (one armed resume between engine re-entries, SP swaps only at microtask boundaries), green-region spill stacks (16-aligned tops), S1 embind mutator FIFO lane + parker wraps, S6 shutdown, libctx integration hooks (suspend/end/quarantine + g_current arm/clear), SuspendError attributor, lost-wake + stuck-window watchdogs, __wxWaitDump observability. Embind: PARKER registrations get emscripten::async() under PCBJAM_JSPI (wasm/bindings/pcbjam_async_policy.h). nanosleep yields route via the shim. Tests: tests/asyncify -> tests/jspi successor suite (jspi-stack red/green shadow-stack battery, jspi-coroutine MiniCoro harness, suspend-races semantic scenarios + __wxWaitDump books coherence); projects jspi-firefox/ jspi-chrome (asyncify-webkit retired — no JSPI in WebKit); unconditional Firefox JSPI pref; guard-beacons -> wait-beacons (+wxScheduler/libctxJspi families); Makefile.wasm links test apps against JSPI with the shim as a tracked link prerequisite. Web: WasmTool setRo await + __wxWaitDump forensics, open-flow contained promise, scheduler-shim.test.ts retargeted (8 green). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NDeBaKKhQztd8KiVtHuyXr
This commit is contained in:
parent
33e23e0a65
commit
3f09a46ff5
48 changed files with 2640 additions and 546 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
2
kicad
2
kicad
|
|
@ -1 +1 @@
|
|||
Subproject commit 99a7a368a6b36f2cd300d29bb136450e3c935cd7
|
||||
Subproject commit a261e9f0d0913b20dde3b0ac7b1271ee00578c79
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
12
scripts/common/jspi-exports.txt
Normal file
12
scripts/common/jspi-exports.txt
Normal file
|
|
@ -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
|
||||
840
scripts/common/shims/jspi-scheduler.js
Normal file
840
scripts/common/shims/jspi-scheduler.js
Normal file
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
|
@ -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/<v>/ to old-releases/<v>/ 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=""
|
||||
|
|
|
|||
|
|
@ -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 -----------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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" \
|
||||
\
|
||||
|
|
|
|||
|
|
@ -55,7 +55,49 @@ CXXFLAGS += -MMD -MP
|
|||
# wasm setjmp/longjmp, matching the libwx build (scripts/build-wx-wasm.sh) and scripts/common/env.sh.
|
||||
# The in-link Asyncify (emsdk Binaryen v121) crashes on wasm-EH, so build-wasm-test.sh stubs it and
|
||||
# runs the real pipeline (--hoist-cpp-catches + --asyncify via apply-asyncify.sh) post-link.
|
||||
# Async backend switch (experiment/jspi): PCBJAM_ASYNC_BACKEND=jspi links the
|
||||
# apps with JSPI instead of Asyncify. Requires the wx libs built with the same
|
||||
# knob (build-wx-wasm.sh). -sDYNCALLS is asyncify-only machinery and a FATAL
|
||||
# link error under -sJSPI; -DPCBJAM_JSPI selects the JSPI lanes in the wasm
|
||||
# port sources and libcontext.
|
||||
PCBJAM_ASYNC_BACKEND ?= asyncify
|
||||
|
||||
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
|
||||
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -DPCBJAM_JSPI=1
|
||||
# Promising entry exports: every JS->wasm entry that can transitively suspend
|
||||
# (fresh census 2026-08-12 of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the
|
||||
# Sched*/abandon probes never suspend and stay plain). pcbjam_libctx_entry is
|
||||
# libcontext's coroutine entry (only present in apps that link libcontext —
|
||||
# emscripten warns-and-ignores absent names).
|
||||
WX_JSPI_EXPORTS = main,wx_dom_event,wx_dom_mouse,wx_window_close,wx_window_move,wx_window_resize,ProcessEvents,wxWasmMailboxTick,wxWasmTopLevelTick,wxWasmMainLoopPump,wxWasmJobTick,pcbjam_libctx_entry
|
||||
# Per-app suspending test levers (fresh-stack ccalls that park or swap a
|
||||
# coroutine). Appended as a LAST -sJSPI_EXPORTS on the app's link line, which
|
||||
# wins over the one inside ASYNC_LDFLAGS (emcc last-wins). The non-parking
|
||||
# levers (races_end_active_modal — the answer-synchronously resolve path)
|
||||
# deliberately stay plain.
|
||||
RACES_EXTRA_LDFLAGS = -sJSPI_EXPORTS=$(WX_JSPI_EXPORTS),races_swap_once,races_park_token2,races_wdt_park_b
|
||||
JSPI_SHIM = $(abspath ../../scripts/common/shims/jspi-scheduler.js)
|
||||
ASYNC_LDFLAGS = -sJSPI \
|
||||
-sJSPI_EXPORTS=$(WX_JSPI_EXPORTS) \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stackSave','stackRestore']" \
|
||||
--pre-js $(JSPI_SHIM)
|
||||
ASYNC_CORO_LDFLAGS = $(ASYNC_LDFLAGS)
|
||||
ASYNC_SCHED_CTX_LDFLAGS = $(ASYNC_LDFLAGS)
|
||||
else
|
||||
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 -sDYNCALLS=1
|
||||
ASYNC_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
|
||||
ASYNC_CORO_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
|
||||
ASYNC_SCHED_CTX_LDFLAGS = -s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
|
||||
endif
|
||||
CXXFLAGS += $(EH_FLAGS)
|
||||
|
||||
# Base Emscripten flags (for all apps)
|
||||
|
|
@ -64,10 +106,7 @@ CXXFLAGS += $(EH_FLAGS)
|
|||
# - js_writeTextToClipboard, js_readTextFromClipboard, js_clipboardHasText, js_clearClipboard: for clipboard
|
||||
# - js_enumerateFonts: for font enumeration via Local Font Access API
|
||||
BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts']
|
||||
$(ASYNC_LDFLAGS)
|
||||
|
||||
# LDFLAGS for non-GL apps (standalone tests)
|
||||
LDFLAGS_NOGL = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
|
||||
|
|
@ -99,10 +138,7 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
|
|||
|
||||
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
|
||||
COROUTINE_BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['js_writeTextToClipboard','js_readTextFromClipboard','js_clipboardHasText','js_clearClipboard','js_enumerateFonts','emscripten_fiber_swap']
|
||||
$(ASYNC_CORO_LDFLAGS)
|
||||
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
|
||||
|
||||
# The asyncify-races harness must match PRODUCTION asyncify semantics: the KiCad
|
||||
|
|
@ -118,10 +154,7 @@ LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_L
|
|||
# operation" state; hitting one is the bug.
|
||||
LDFLAGS_SCHED_CTX = $(DEBUG_LDFLAGS) $(EH_FLAGS) -sALLOW_MEMORY_GROWTH \
|
||||
-sERROR_ON_UNDEFINED_SYMBOLS=0 \
|
||||
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall']" \
|
||||
-sASYNCIFY=1 \
|
||||
-sASYNCIFY_STACK_SIZE=65536 \
|
||||
-sASYNCIFY_IMPORTS=['emscripten_fiber_swap']
|
||||
$(ASYNC_SCHED_CTX_LDFLAGS)
|
||||
|
||||
# LDFLAGS for the raytracer thread-deadlock repro. Same pthread + pool config as
|
||||
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the 65536 ASYNCIFY
|
||||
|
|
@ -144,6 +177,11 @@ LDFLAGS_REALPOOL = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
|
|||
# depend on them — otherwise editing a shim wouldn't trigger a relink.
|
||||
JS = $(TOOLS_ROOT)/wx.js --pre-js $(TOOLS_ROOT)/wx-dom.js
|
||||
JS_FILES = $(TOOLS_ROOT)/wx.js $(TOOLS_ROOT)/wx-dom.js
|
||||
# The scheduler shim is a pre-js on every jspi link (ASYNC_LDFLAGS): apps
|
||||
# whose objects are up to date must still relink when it changes.
|
||||
ifeq ($(PCBJAM_ASYNC_BACKEND),jspi)
|
||||
JS_FILES += $(JSPI_SHIM)
|
||||
endif
|
||||
HTML = $(TOOLS_ROOT)/template.html
|
||||
|
||||
# wxWidgets library directory - used as dependency to rebuild when libs change
|
||||
|
|
@ -777,7 +815,7 @@ $(S)/asyncify-races/races_test.o: $(S)/asyncify-races/races_test.cpp $(S)/corout
|
|||
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
|
||||
|
||||
$(S)/asyncify-races/races_test.html: $(S)/asyncify-races/races_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB)
|
||||
$(CXX) $^ $(LDFLAGS_RACES) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
$(CXX) $^ $(LDFLAGS_RACES) $(RACES_EXTRA_LDFLAGS) --pre-js $(JS) --shell-file $(HTML) -o $@
|
||||
../../scripts/common/inject-dyncall-shims.sh $(basename $@).js
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -105,7 +105,14 @@ void LogLine( const std::string& aLine )
|
|||
// Park the calling stack until JS resolves the token (races_resolve_token_after).
|
||||
EM_ASYNC_JS( int, races_await_token, ( int aToken ), {
|
||||
Module.__racesWaits = Module.__racesWaits || {};
|
||||
return await new Promise( ( resolve ) => { Module.__racesWaits[aToken] = resolve; } );
|
||||
var p = new Promise( ( resolve ) => { Module.__racesWaits[aToken] = resolve; } );
|
||||
// JSPI: EVERY suspension must route through the scheduler's turnstile —
|
||||
// a raw await's engine-level resume bypasses the SP discipline and never
|
||||
// ends the current window (windowLive wedges the pump).
|
||||
var S = globalThis.__wxScheduler;
|
||||
if( S && S.backend === 'jspi' )
|
||||
return await S.promiseYield( p, 'races-token' );
|
||||
return await p;
|
||||
} );
|
||||
|
||||
// Resolve a parked token after a JS-side delay (independent of the C++ world,
|
||||
|
|
@ -120,6 +127,11 @@ EM_JS( void, races_resolve_token_after, ( int aToken, int aValue, int aDelayMs )
|
|||
|
||||
// Plain parked sleep.
|
||||
EM_ASYNC_JS( int, races_sleep_ms, ( int aMs ), {
|
||||
var S = globalThis.__wxScheduler;
|
||||
if( S && S.backend === 'jspi' ) {
|
||||
await S.sleepYield( aMs ); // turnstile-routed (see races_await_token)
|
||||
return 1;
|
||||
}
|
||||
await new Promise( ( r ) => setTimeout( r, aMs ) );
|
||||
return 1;
|
||||
} );
|
||||
|
|
@ -183,7 +195,12 @@ EM_JS( void, races_mark_done, ( const char* aName ), {
|
|||
// no fiber is queued.
|
||||
EM_JS( int, races_quiescent, (), {
|
||||
try {
|
||||
var stOk = ( typeof Asyncify === 'undefined' ) || Asyncify.state === 0;
|
||||
// JSPI glue still defines an Asyncify object (shared library file)
|
||||
// but with no state machine - Asyncify.state is undefined there, and
|
||||
// that is quiescent-by-construction (suspensions are engine-native).
|
||||
var stOk = ( typeof Asyncify === 'undefined' )
|
||||
|| Asyncify.state === undefined
|
||||
|| Asyncify.state === 0;
|
||||
var nfOk = ( typeof Fibers === 'undefined' ) || !Fibers.nextFiber;
|
||||
return ( stOk && nfOk ) ? 1 : 0;
|
||||
} catch( e ) {
|
||||
|
|
@ -841,6 +858,16 @@ public:
|
|||
|
||||
LogLine( "[ASYNCIFY_RACES] PARAMS only='" + only + "' sleepPark="
|
||||
+ std::to_string( sleepPark ? 1 : 0 ) );
|
||||
|
||||
// JSPI: the ccall'd test levers are promising entries (JSPI_EXPORTS)
|
||||
// that can PARK — the shim must track their activations like the wx
|
||||
// entries, or their windows leak (untracked completion wedges the
|
||||
// resume turnstile until the watchdog clears it).
|
||||
EM_ASM( {
|
||||
if( globalThis.__wxScheduler && globalThis.__wxScheduler.installExportWraps )
|
||||
globalThis.__wxScheduler.installExportWraps(
|
||||
[ 'races_swap_once', 'races_park_token2', 'races_wdt_park_b' ] );
|
||||
} );
|
||||
#endif
|
||||
|
||||
// THE LOAD-BEARING TOPOLOGY: complete a fiber swap cycle during OnInit.
|
||||
|
|
|
|||
58
tests/apps/standalone/jspi-coroutine/boot-check.cjs
Normal file
58
tests/apps/standalone/jspi-coroutine/boot-check.cjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Boot-check a built wx test app under JSPI in bundled Chromium (+ optionally
|
||||
// Firefox with the pref). Usage: node boot-check.cjs <app.html> [firefox]
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
|
||||
const PW = '/Users/V/IdeaProjects/pcbjam-private/pcbjam/tests/node_modules/playwright';
|
||||
const { chromium, firefox } = require(PW);
|
||||
|
||||
const APPS = '/Users/V/IdeaProjects/kicad-wasm-jspi/tests/apps';
|
||||
const page_url = process.argv[2] || 'minimal_test.html';
|
||||
const useFirefox = process.argv[3] === 'firefox';
|
||||
|
||||
const MIME = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.wasm': 'application/wasm', '.data': 'application/octet-stream' };
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const f = path.join(APPS, decodeURIComponent(req.url.split('?')[0]));
|
||||
try {
|
||||
const body = fs.readFileSync(f);
|
||||
res.writeHead(200, {
|
||||
'Content-Type': MIME[path.extname(f)] ?? 'application/octet-stream',
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
});
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
const launcher = useFirefox ? firefox : chromium;
|
||||
const opts = useFirefox
|
||||
? { firefoxUserPrefs: { 'javascript.options.wasm_js_promise_integration': true } }
|
||||
: {};
|
||||
const browser = await launcher.launch(opts);
|
||||
const page = await browser.newPage();
|
||||
const lines = [];
|
||||
page.on('console', (m) => lines.push(m.text()));
|
||||
page.on('pageerror', (e) => lines.push('PAGEERROR: ' + e.message));
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/${page_url}`);
|
||||
await new Promise((r) => setTimeout(r, 12000));
|
||||
// Probe scheduler state + DOM
|
||||
const probe = await page.evaluate(() => ({
|
||||
scheduler: globalThis.__wxScheduler ? globalThis.__wxScheduler.backend : null,
|
||||
dump: globalThis.__wxWaitDump ? globalThis.__wxWaitDump() : null,
|
||||
windows: document.querySelectorAll('.wx-window, [id^=wx]').length,
|
||||
bodyChildren: document.body.children.length,
|
||||
})).catch((e) => ({ error: String(e) }));
|
||||
await browser.close();
|
||||
server.close();
|
||||
console.log('=== console (last 30) ===');
|
||||
for (const l of lines.slice(-30)) console.log(l);
|
||||
console.log('=== probe ===');
|
||||
console.log(JSON.stringify(probe, null, 1));
|
||||
const bad = lines.filter((l) => /PAGEERROR|SuspendError|RuntimeError|abort/i.test(l));
|
||||
console.log(bad.length ? 'BOOT: ERRORS(' + bad.length + ')' : 'BOOT: CLEAN');
|
||||
});
|
||||
58
tests/apps/standalone/jspi-coroutine/browser-run.cjs
Normal file
58
tests/apps/standalone/jspi-coroutine/browser-run.cjs
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
// Quick browser validation of the jspi-coroutine harness in bundled
|
||||
// Chromium 143 (JSPI default-on) and Firefox 144 (JSPI behind pref).
|
||||
// Uses the main checkout's installed Playwright. Proper spec wiring lands in
|
||||
// tests/jspi/ (Phase 6).
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
|
||||
const PW = '/Users/V/IdeaProjects/pcbjam-private/pcbjam/tests/node_modules/playwright';
|
||||
const { chromium, firefox } = require(PW);
|
||||
|
||||
const DIR = __dirname;
|
||||
const MIME = { '.html': 'text/html', '.mjs': 'text/javascript', '.js': 'text/javascript', '.wasm': 'application/wasm' };
|
||||
|
||||
async function runIn(name, launcher, opts) {
|
||||
const browser = await launcher.launch(opts);
|
||||
const page = await browser.newPage();
|
||||
const lines = [];
|
||||
page.on('console', (msg) => {
|
||||
const t = msg.text();
|
||||
if (t.includes('[JSPI_CORO]') || t.includes('[libctx-jspi]')) lines.push(t);
|
||||
});
|
||||
await page.goto(`http://127.0.0.1:${server.address().port}/index.html`);
|
||||
await page.waitForFunction(
|
||||
() => performance.now() > 0, // anchor; real wait below
|
||||
);
|
||||
const deadline = Date.now() + 30000;
|
||||
while (Date.now() < deadline && !lines.some((l) => l.includes('SUMMARY'))) {
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
await browser.close();
|
||||
const summary = lines.find((l) => l.includes('SUMMARY')) ?? 'NO SUMMARY';
|
||||
const fails = lines.filter((l) => l.includes('FAIL') || l.includes('FATAL'));
|
||||
console.log(`${name}: ${summary}`);
|
||||
for (const f of fails) console.log(`${name}: ${f}`);
|
||||
return summary.includes('failed=0');
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
const f = path.join(DIR, req.url === '/' ? 'index.html' : req.url);
|
||||
try {
|
||||
const body = fs.readFileSync(f);
|
||||
res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] ?? 'application/octet-stream' });
|
||||
res.end(body);
|
||||
} catch {
|
||||
res.writeHead(404).end();
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', async () => {
|
||||
let ok = true;
|
||||
ok = (await runIn('chromium', chromium, {})) && ok;
|
||||
ok = (await runIn('firefox', firefox, {
|
||||
firefoxUserPrefs: { 'javascript.options.wasm_js_promise_integration': true },
|
||||
})) && ok;
|
||||
server.close();
|
||||
process.exit(ok ? 0 : 1);
|
||||
});
|
||||
20
tests/apps/standalone/jspi-coroutine/build.sh
Executable file
20
tests/apps/standalone/jspi-coroutine/build.sh
Executable file
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/bash
|
||||
# Ad-hoc build for the jspi-coroutine harness (Makefile.wasm wiring in Phase 3).
|
||||
# Compiles the REAL kicad/thirdparty/libcontext with -DPCBJAM_JSPI.
|
||||
set -eo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ROOT="$(cd ../../../.. && pwd)"
|
||||
EMXX="${EMXX:-$ROOT/tools/emsdk/upstream/emscripten/em++}"
|
||||
LIBCTX="$ROOT/kicad/thirdparty/libcontext"
|
||||
|
||||
"$EMXX" coroutine_jspi_test.cpp "$LIBCTX/libcontext.cpp" \
|
||||
-I"$LIBCTX" \
|
||||
-DPCBJAM_JSPI=1 \
|
||||
-O1 \
|
||||
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 \
|
||||
-sJSPI -sJSPI_EXPORTS=pcbjam_libctx_entry,main \
|
||||
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=node,web \
|
||||
-sALLOW_MEMORY_GROWTH=1 \
|
||||
-o coroutine_jspi_test.mjs
|
||||
|
||||
echo "built: coroutine_jspi_test.mjs"
|
||||
434
tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp
Normal file
434
tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
// jspi-coroutine — validates the JSPI libcontext backend (PCBJAM_JSPI) through
|
||||
// the EXACT protocol coroutine.h drives it with, without linking wx or KiCad.
|
||||
//
|
||||
// MiniCoro below is a compact transcription of COROUTINE<>'s libcontext
|
||||
// mechanics (doCall/jumpIn/jumpOut/callerStub, INVOCATION_ARGS, the
|
||||
// CALL_CONTEXT::Continue root-bounce loop, the finish_fcontext completion
|
||||
// hook) — kicad/include/tool/coroutine.h stays untouched; if that file's
|
||||
// protocol changes, change this mirror too.
|
||||
//
|
||||
// Scenarios ported from the study prototype (.jspi-assets/jspi-proto), plus
|
||||
// backend-specific cases: finished-activation reclaim, mid-body release
|
||||
// census, ghost-jump refusal.
|
||||
//
|
||||
// Output contract (parsed by tests/jspi/jspi-coroutine.spec.ts):
|
||||
// [JSPI_CORO] CASE <name> PASS|FAIL(<detail>)
|
||||
// [JSPI_CORO] SUMMARY passed=<n> failed=<n>
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/em_js.h>
|
||||
#include <libcontext.h>
|
||||
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
#include <functional>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
// --- MiniCoro: coroutine.h's libcontext protocol, minimally ------------------
|
||||
|
||||
struct MiniCoro;
|
||||
|
||||
struct INVOCATION_ARGS
|
||||
{
|
||||
enum { FROM_ROOT = 0, FROM_ROUTINE = 1, CONTINUE_AFTER_ROOT = 2 };
|
||||
int type;
|
||||
MiniCoro* destination;
|
||||
void* context; // CALL_CONTEXT in KiCad; opaque here
|
||||
};
|
||||
|
||||
struct CTX_SLOT
|
||||
{
|
||||
libcontext::fcontext_t ctx = nullptr;
|
||||
};
|
||||
|
||||
struct MiniCoro
|
||||
{
|
||||
using Body = std::function<void( MiniCoro& )>;
|
||||
|
||||
explicit MiniCoro( Body aBody ) : m_body( std::move( aBody ) ) {}
|
||||
|
||||
~MiniCoro()
|
||||
{
|
||||
if( m_caller.ctx )
|
||||
libcontext::release_fcontext( m_caller.ctx );
|
||||
if( m_callee.ctx )
|
||||
libcontext::release_fcontext( m_callee.ctx );
|
||||
}
|
||||
|
||||
bool Call( intptr_t aValue = 0 )
|
||||
{
|
||||
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
|
||||
m_currentValue = aValue;
|
||||
m_callee.ctx = libcontext::make_fcontext( nullptr, 128 * 1024, callerStub );
|
||||
m_running = true;
|
||||
INVOCATION_ARGS* ret = jumpIn( &args );
|
||||
return continueAfterRoot( ret );
|
||||
}
|
||||
|
||||
bool Resume( intptr_t aValue = 0 )
|
||||
{
|
||||
if( !m_running )
|
||||
return false;
|
||||
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
|
||||
m_resumeValue = aValue;
|
||||
INVOCATION_ARGS* ret = jumpIn( &args );
|
||||
return continueAfterRoot( ret );
|
||||
}
|
||||
|
||||
void Yield( intptr_t aValue = 0 )
|
||||
{
|
||||
m_yieldValue = aValue;
|
||||
jumpOut();
|
||||
m_currentValue = m_resumeValue;
|
||||
}
|
||||
|
||||
void RunMainStack( std::function<void()> aFunc )
|
||||
{
|
||||
m_mainFn = std::move( aFunc );
|
||||
m_argsOut = { INVOCATION_ARGS::CONTINUE_AFTER_ROOT, this, nullptr };
|
||||
jumpOutWith( &m_argsOut );
|
||||
m_currentValue = m_resumeValue;
|
||||
}
|
||||
|
||||
bool Running() const { return m_running; }
|
||||
intptr_t CurrentValue() const { return m_currentValue; }
|
||||
intptr_t YieldValue() const { return m_yieldValue; }
|
||||
size_t EntryCount() const { return m_entryCount; }
|
||||
|
||||
// -- protocol internals (transcribed from coroutine.h) --
|
||||
static void callerStub( intptr_t aData )
|
||||
{
|
||||
auto& args = *reinterpret_cast<INVOCATION_ARGS*>( aData );
|
||||
MiniCoro* cor = args.destination;
|
||||
|
||||
++cor->m_entryCount;
|
||||
cor->m_body( *cor );
|
||||
cor->m_running = false;
|
||||
|
||||
// the 3-line JSPI hook coroutine.h carries under PCBJAM_JSPI
|
||||
libcontext::finish_fcontext( cor->m_callee.ctx );
|
||||
|
||||
cor->jumpOut();
|
||||
// JSPI backend: the finishing jumpOut RETURNS (sentinel) and this
|
||||
// frame unwinds, completing the activation.
|
||||
}
|
||||
|
||||
INVOCATION_ARGS* jumpIn( INVOCATION_ARGS* args )
|
||||
{
|
||||
return reinterpret_cast<INVOCATION_ARGS*>(
|
||||
libcontext::jump_fcontext( &m_caller.ctx, m_callee.ctx,
|
||||
reinterpret_cast<intptr_t>( args ) ) );
|
||||
}
|
||||
|
||||
void jumpOut() { jumpOutWith( &m_argsFromRoutine ); }
|
||||
|
||||
void jumpOutWith( INVOCATION_ARGS* args )
|
||||
{
|
||||
intptr_t r = libcontext::jump_fcontext( &m_callee.ctx, m_caller.ctx,
|
||||
reinterpret_cast<intptr_t>( args ) );
|
||||
auto* ret = reinterpret_cast<INVOCATION_ARGS*>( r );
|
||||
// like coroutine.h jumpOut: touch the returned args (sentinel-safe)
|
||||
m_lastContext = ret ? ret->context : nullptr;
|
||||
}
|
||||
|
||||
// CALL_CONTEXT::Continue (coroutine.h:175-183) — service root bounces
|
||||
bool continueAfterRoot( INVOCATION_ARGS* ret )
|
||||
{
|
||||
while( m_running && ret && ret->type == INVOCATION_ARGS::CONTINUE_AFTER_ROOT )
|
||||
{
|
||||
m_mainFn();
|
||||
++m_rootRuns;
|
||||
INVOCATION_ARGS args{ INVOCATION_ARGS::FROM_ROOT, this, nullptr };
|
||||
m_resumeValue = m_rootResumeValue;
|
||||
ret = jumpIn( &args );
|
||||
}
|
||||
return m_running;
|
||||
}
|
||||
|
||||
Body m_body;
|
||||
CTX_SLOT m_caller, m_callee;
|
||||
bool m_running = false;
|
||||
intptr_t m_currentValue = 0, m_resumeValue = 0, m_yieldValue = 0;
|
||||
intptr_t m_rootResumeValue = 77;
|
||||
size_t m_entryCount = 0;
|
||||
int m_rootRuns = 0;
|
||||
void* m_lastContext = nullptr;
|
||||
std::function<void()> m_mainFn;
|
||||
INVOCATION_ARGS m_argsFromRoutine{ INVOCATION_ARGS::FROM_ROUTINE, nullptr, nullptr };
|
||||
INVOCATION_ARGS m_argsOut{ INVOCATION_ARGS::FROM_ROUTINE, nullptr, nullptr };
|
||||
};
|
||||
|
||||
// --- JS census helpers -------------------------------------------------------
|
||||
EM_JS( int, js_live_slot_count, (), {
|
||||
const L = globalThis.__libctxJspi;
|
||||
return L ? Object.keys( L.s ).length : -1;
|
||||
} );
|
||||
EM_JS( int, js_dead_parked, (), {
|
||||
const L = globalThis.__libctxJspi;
|
||||
return L ? L.deadParked : -1;
|
||||
} );
|
||||
EM_JS( void, js_schedule_resume_marker, (), {
|
||||
globalThis.__timerFired = 0;
|
||||
setTimeout( () => { globalThis.__timerFired = 1; }, 10 );
|
||||
} );
|
||||
EM_ASYNC_JS( void, js_wait_ms, ( int ms ), {
|
||||
await new Promise( ( r ) => setTimeout( r, ms ) );
|
||||
} );
|
||||
EM_JS( int, js_timer_fired, (), { return globalThis.__timerFired | 0; } );
|
||||
|
||||
// --- test rig ----------------------------------------------------------------
|
||||
static int g_passed = 0, g_failed = 0;
|
||||
|
||||
static void report( const char* name, bool ok, const std::string& detail = "" )
|
||||
{
|
||||
if( ok )
|
||||
{
|
||||
++g_passed;
|
||||
std::printf( "[JSPI_CORO] CASE %s PASS\n", name );
|
||||
}
|
||||
else
|
||||
{
|
||||
++g_failed;
|
||||
std::printf( "[JSPI_CORO] CASE %s FAIL(%s)\n", name, detail.c_str() );
|
||||
}
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
// 1. first entry runs body exactly once, to first yield
|
||||
{
|
||||
int steps = 0;
|
||||
MiniCoro c( [&]( MiniCoro& me ) { steps++; me.Yield( 10 ); steps++; } );
|
||||
bool alive = c.Call( 5 );
|
||||
report( "first_entry_runs_once",
|
||||
alive && steps == 1 && c.YieldValue() == 10 && c.EntryCount() == 1 );
|
||||
c.Resume(); // let it finish
|
||||
}
|
||||
|
||||
// 2. yield/resume preserves locals across suspensions
|
||||
{
|
||||
std::vector<int> seen;
|
||||
MiniCoro c( [&]( MiniCoro& me ) {
|
||||
int local = 100;
|
||||
me.Yield( local );
|
||||
local += (int) me.CurrentValue();
|
||||
me.Yield( local );
|
||||
local += (int) me.CurrentValue();
|
||||
seen.push_back( local );
|
||||
} );
|
||||
c.Call();
|
||||
bool ok = c.YieldValue() == 100;
|
||||
c.Resume( 11 );
|
||||
ok = ok && c.YieldValue() == 111;
|
||||
c.Resume( 22 );
|
||||
ok = ok && !c.Running() && seen.size() == 1 && seen[0] == 133;
|
||||
report( "yield_resume_preserves_state", ok );
|
||||
}
|
||||
|
||||
// 3. deep recursion preserved across yields (spill-region proof)
|
||||
{
|
||||
std::function<int( MiniCoro&, int )> deep = [&]( MiniCoro& me, int d ) -> int {
|
||||
volatile int frame[32];
|
||||
for( int i = 0; i < 32; i++ ) frame[i] = d * 100 + i;
|
||||
if( d > 0 )
|
||||
{
|
||||
int below = deep( me, d - 1 );
|
||||
for( int i = 0; i < 32; i++ )
|
||||
if( frame[i] != d * 100 + i ) return -1000000;
|
||||
return below + 1;
|
||||
}
|
||||
me.Yield( 1 );
|
||||
me.Yield( 2 );
|
||||
for( int i = 0; i < 32; i++ )
|
||||
if( frame[i] != i ) return -1000000;
|
||||
return 0;
|
||||
};
|
||||
int result = -1;
|
||||
MiniCoro c( [&]( MiniCoro& me ) { result = deep( me, 6 ); } );
|
||||
c.Call();
|
||||
c.Resume();
|
||||
c.Resume();
|
||||
report( "deep_stack_preserved_across_yield", !c.Running() && result == 6,
|
||||
"result=" + std::to_string( result ) );
|
||||
}
|
||||
|
||||
// 4. nested coroutine: child created+run inside parent's body
|
||||
{
|
||||
std::string order;
|
||||
MiniCoro child( [&]( MiniCoro& me ) { order += "c1"; me.Yield(); order += "c2"; } );
|
||||
MiniCoro parent( [&]( MiniCoro& me ) {
|
||||
order += "p1";
|
||||
child.Call();
|
||||
order += "p2";
|
||||
me.Yield();
|
||||
child.Resume();
|
||||
order += "p3";
|
||||
} );
|
||||
parent.Call();
|
||||
bool mid = order == "p1c1p2";
|
||||
parent.Resume();
|
||||
report( "nested_coroutine_call_and_resume",
|
||||
mid && order == "p1c1p2c2p3" && !parent.Running() && !child.Running(),
|
||||
order );
|
||||
}
|
||||
|
||||
// 5. parent yields while child stays suspended; child resumes intact
|
||||
{
|
||||
std::string order;
|
||||
MiniCoro child( [&]( MiniCoro& me ) {
|
||||
int keep = 42;
|
||||
order += "c1";
|
||||
me.Yield();
|
||||
order += ( keep == 42 ) ? "c2" : "cX";
|
||||
} );
|
||||
MiniCoro parent( [&]( MiniCoro& me ) {
|
||||
child.Call();
|
||||
order += "p1";
|
||||
me.Yield(); // parent suspends; child still parked
|
||||
order += "p2";
|
||||
child.Resume(); // child must resume with locals intact
|
||||
order += "p3";
|
||||
} );
|
||||
parent.Call();
|
||||
parent.Resume();
|
||||
report( "nested_parent_yield_preserves_suspend", order == "c1p1p2c2p3", order );
|
||||
}
|
||||
|
||||
// 6. RunMainStack: functor runs on the caller's activation, then resume
|
||||
{
|
||||
std::string order;
|
||||
MiniCoro c( [&]( MiniCoro& me ) {
|
||||
order += "before-root;";
|
||||
me.RunMainStack( [&] { order += "on-root;"; } );
|
||||
order += "after-root(" + std::to_string( me.CurrentValue() ) + ");";
|
||||
} );
|
||||
c.Call();
|
||||
report( "root_bounce_continue_after_root",
|
||||
!c.Running() && c.m_rootRuns == 1
|
||||
&& order == "before-root;on-root;after-root(77);",
|
||||
order );
|
||||
}
|
||||
|
||||
// 7. completion: body runs to the end, Running() flips, activation reclaimed
|
||||
{
|
||||
MiniCoro c( [&]( MiniCoro& me ) { me.Yield( 1 ); } );
|
||||
c.Call();
|
||||
c.Resume();
|
||||
report( "completion", !c.Running() );
|
||||
}
|
||||
|
||||
// 8. resume after finish is refused (ghost contract), no re-entry
|
||||
{
|
||||
int entries = 0;
|
||||
MiniCoro c( [&]( MiniCoro& me ) { entries++; me.Yield(); } );
|
||||
c.Call();
|
||||
c.Resume(); // finishes
|
||||
bool resumed = c.Resume( 99 ); // must refuse: m_running false short-circuits
|
||||
// force a backend-level ghost jump too:
|
||||
intptr_t r = libcontext::jump_fcontext( &c.m_caller.ctx, c.m_callee.ctx, 0 );
|
||||
report( "resume_after_finish_does_not_reenter",
|
||||
!resumed && entries == 1 && r == -1 );
|
||||
}
|
||||
|
||||
// 9. interleaving multiple coroutines
|
||||
{
|
||||
std::string order;
|
||||
MiniCoro a( [&]( MiniCoro& me ) { order += "a1"; me.Yield(); order += "a2"; me.Yield(); order += "a3"; } );
|
||||
MiniCoro b( [&]( MiniCoro& me ) { order += "b1"; me.Yield(); order += "b2"; } );
|
||||
a.Call(); b.Call(); a.Resume(); b.Resume(); a.Resume();
|
||||
report( "interleaving_multiple_coroutines", order == "a1b1a2b2a3", order );
|
||||
}
|
||||
|
||||
// 10. stress: many round trips
|
||||
{
|
||||
int n = 0;
|
||||
MiniCoro c( [&]( MiniCoro& me ) {
|
||||
for( int i = 0; i < 48; i++ ) { n++; me.Yield( i ); }
|
||||
} );
|
||||
c.Call();
|
||||
while( c.Running() )
|
||||
c.Resume();
|
||||
report( "stress_many_round_trips", n == 48, std::to_string( n ) );
|
||||
}
|
||||
|
||||
// 11. transfer values round-trip through yields and resumes
|
||||
{
|
||||
intptr_t got = 0;
|
||||
MiniCoro c( [&]( MiniCoro& me ) {
|
||||
me.Yield( 1234 );
|
||||
got = me.CurrentValue();
|
||||
} );
|
||||
c.Call();
|
||||
bool y = c.YieldValue() == 1234;
|
||||
c.Resume( 4321 );
|
||||
report( "transfer_values_round_trip", y && got == 4321 );
|
||||
}
|
||||
|
||||
// 12. yield INSIDE a C++ catch block under native wasm-EH — the case the
|
||||
// HoistCppCatches binaryen pass existed for
|
||||
{
|
||||
std::string order;
|
||||
MiniCoro c( [&]( MiniCoro& me ) {
|
||||
try
|
||||
{
|
||||
order += "t";
|
||||
throw 42;
|
||||
}
|
||||
catch( int e )
|
||||
{
|
||||
order += "c" + std::to_string( e );
|
||||
me.Yield( e );
|
||||
order += "r" + std::to_string( (int) me.CurrentValue() );
|
||||
}
|
||||
order += "d";
|
||||
} );
|
||||
c.Call();
|
||||
c.Resume( 7 );
|
||||
report( "yield_inside_catch_block_wasm_eh",
|
||||
!c.Running() && order == "tc42r7d", order );
|
||||
}
|
||||
|
||||
// 13. resume driven from a JS timer through the wait import (dispatch shape)
|
||||
{
|
||||
js_schedule_resume_marker();
|
||||
MiniCoro c( [&]( MiniCoro& me ) { me.Yield(); } );
|
||||
c.Call();
|
||||
js_wait_ms( 25 ); // suspends main; timer fires meanwhile
|
||||
bool fired = js_timer_fired() == 1;
|
||||
c.Resume();
|
||||
report( "async_wait_resume_after_timer", fired && !c.Running() );
|
||||
}
|
||||
|
||||
// 14. finished coroutines fully reclaim their JS slots + regions
|
||||
{
|
||||
int before = js_live_slot_count();
|
||||
{
|
||||
MiniCoro c( [&]( MiniCoro& me ) { me.Yield(); } );
|
||||
c.Call();
|
||||
c.Resume();
|
||||
}
|
||||
int after = js_live_slot_count();
|
||||
report( "finished_activation_reclaimed", before == after,
|
||||
std::to_string( before ) + "->" + std::to_string( after ) );
|
||||
}
|
||||
|
||||
// 15. release while suspended mid-body: censused, never resumed
|
||||
{
|
||||
int deadBefore = js_dead_parked();
|
||||
int bodySteps = 0;
|
||||
{
|
||||
MiniCoro c( [&]( MiniCoro& me ) { bodySteps++; me.Yield(); bodySteps++; } );
|
||||
c.Call();
|
||||
// destructor releases while parked mid-body
|
||||
}
|
||||
int deadAfter = js_dead_parked();
|
||||
report( "midbody_release_censused",
|
||||
bodySteps == 1 && deadAfter == deadBefore + 1,
|
||||
"steps=" + std::to_string( bodySteps )
|
||||
+ " dead=" + std::to_string( deadAfter ) );
|
||||
}
|
||||
|
||||
std::printf( "[JSPI_CORO] SUMMARY passed=%d failed=%d\n", g_passed, g_failed );
|
||||
return g_failed == 0 ? 0 : 1;
|
||||
}
|
||||
17
tests/apps/standalone/jspi-coroutine/index.html
Normal file
17
tests/apps/standalone/jspi-coroutine/index.html
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>jspi-coroutine harness</title></head>
|
||||
<body>
|
||||
<script type="module">
|
||||
// ?pt=1 loads the pthread build (needs the COOP/COEP the test server sets).
|
||||
const pt = new URLSearchParams(location.search).get('pt') === '1';
|
||||
const { default: factory } = await import(
|
||||
pt ? './coroutine_jspi_test_pt.mjs' : './coroutine_jspi_test.mjs');
|
||||
try {
|
||||
await factory();
|
||||
} catch (e) {
|
||||
console.log('[JSPI_CORO] FATAL ' + e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
tests/apps/standalone/jspi-coroutine/run.mjs
Normal file
2
tests/apps/standalone/jspi-coroutine/run.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import factory from './coroutine_jspi_test.mjs';
|
||||
const m = await factory();
|
||||
2
tests/apps/standalone/jspi-coroutine/run_pt.mjs
Normal file
2
tests/apps/standalone/jspi-coroutine/run_pt.mjs
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import factory from './coroutine_jspi_test_pt.mjs';
|
||||
const m = await factory();
|
||||
25
tests/apps/standalone/jspi-stack/build.sh
Executable file
25
tests/apps/standalone/jspi-stack/build.sh
Executable file
|
|
@ -0,0 +1,25 @@
|
|||
#!/bin/bash
|
||||
# Ad-hoc build for the jspi-stack harness (Makefile.wasm wiring lands in Phase 3).
|
||||
# Uses the worktree-local emsdk (tools/emsdk, 6.0.6). EH flags mirror production.
|
||||
set -eo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
ROOT="$(cd ../../../.. && pwd)"
|
||||
EMCC="${EMCC:-$ROOT/tools/emsdk/upstream/emscripten/em++}"
|
||||
|
||||
COMMON=(
|
||||
-O1
|
||||
-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1
|
||||
-sJSPI -sJSPI_EXPORTS=activation
|
||||
-sEXPORTED_RUNTIME_METHODS=stackSave,stackRestore,HEAPU8
|
||||
-sMODULARIZE=1 -sEXPORT_ES6=1 -sENVIRONMENT=node,web,worker
|
||||
-sALLOW_MEMORY_GROWTH=1
|
||||
)
|
||||
|
||||
"$EMCC" stack_test.cpp -o stack_test.mjs "${COMMON[@]}" \
|
||||
-sEXPORTED_FUNCTIONS=_activation,_stomp,_stack_current,_stack_base,_stack_end,_main,_malloc,_free
|
||||
|
||||
"$EMCC" stack_test.cpp -o stack_test_pt.mjs "${COMMON[@]}" \
|
||||
-pthread -sPTHREAD_POOL_SIZE=2 -sPTHREAD_POOL_SIZE_STRICT=0 \
|
||||
-sEXPORTED_FUNCTIONS=_activation,_stomp,_stack_current,_stack_base,_stack_end,_main,_malloc,_free,_start_churn,_stop_churn
|
||||
|
||||
echo "built: stack_test.mjs + stack_test_pt.mjs"
|
||||
162
tests/apps/standalone/jspi-stack/driver.mjs
Normal file
162
tests/apps/standalone/jspi-stack/driver.mjs
Normal file
|
|
@ -0,0 +1,162 @@
|
|||
// jspi-stack driver — runs the red/green shadow-stack scenarios (#27364).
|
||||
//
|
||||
// node --experimental-wasm-jspi driver.mjs red
|
||||
// node --experimental-wasm-jspi driver.mjs green-copy # Pyodide-style save/restore
|
||||
// node --experimental-wasm-jspi driver.mjs green-region # per-activation stack region + SP swap
|
||||
//
|
||||
// Output contract (parsed by the future tests/jspi/jspi-stack.spec.ts):
|
||||
// [JSPI_STACK] SCENARIO <name> corruptA=<n> corruptB=<n> corruptNested=<n> verdict=<RED|GREEN|UNEXPECTED>
|
||||
//
|
||||
// RED must observe corruption with mitigation OFF (proves the harness can see
|
||||
// the bug). GREEN legs must be corruption-free across interleaved rounds and
|
||||
// the nested case.
|
||||
|
||||
const DEPTH = 24;
|
||||
const STOMP_DEPTH = 48;
|
||||
const REGION_BYTES = 256 * 1024;
|
||||
|
||||
const mode = process.argv[2] ?? 'red';
|
||||
const variant = process.argv[3] ?? 'single'; // single | pthread
|
||||
const { default: factory } = await import(
|
||||
variant === 'pthread' ? './stack_test_pt.mjs' : './stack_test.mjs');
|
||||
|
||||
// --- gate plumbing -----------------------------------------------------------
|
||||
const gates = new Map(); // id -> resolve
|
||||
let gateHook = null; // (id) => wrap/observe, set per mitigation
|
||||
|
||||
globalThis.__jspiGate = (id) => {
|
||||
const base = new Promise((res) => gates.set(id, res));
|
||||
return gateHook ? gateHook(id, base) : base;
|
||||
};
|
||||
|
||||
function release(id) {
|
||||
const r = gates.get(id);
|
||||
if (!r) throw new Error(`no gate armed for id ${id}`);
|
||||
gates.delete(id);
|
||||
r(0);
|
||||
}
|
||||
|
||||
const m = await factory();
|
||||
const centralBase = m.stackSave();
|
||||
|
||||
// --- mitigations -------------------------------------------------------------
|
||||
// Bookkeeping keyed by activation id. entryTop[id] = SP at promising entry.
|
||||
const entryTop = new Map();
|
||||
const actSp = new Map();
|
||||
const regions = new Map(); // id -> malloc'd base (green-region)
|
||||
const snapshots = new Map(); // id -> Uint8Array copy (green-copy)
|
||||
|
||||
// Wrap a promising-export call according to the active mitigation. Returns the
|
||||
// export's promise. `centralRestore` puts the shared SP back for whatever runs
|
||||
// next on the central stack.
|
||||
function startActivation(id) {
|
||||
if (mode === 'green-region') {
|
||||
const base = regions.get(id) ?? m._malloc(REGION_BYTES);
|
||||
regions.set(id, base);
|
||||
const top = base + REGION_BYTES; // stacks grow down
|
||||
const saved = m.stackSave();
|
||||
m.stackRestore(top);
|
||||
entryTop.set(id, top);
|
||||
const p = m._activation(id, DEPTH);
|
||||
m.stackRestore(saved);
|
||||
return finishActivation(id, p);
|
||||
}
|
||||
entryTop.set(id, m.stackSave());
|
||||
const p = m._activation(id, DEPTH);
|
||||
return finishActivation(id, p);
|
||||
}
|
||||
|
||||
// After an activation fully completes, its epilogue leaves the shared SP at
|
||||
// ITS entry value — restore the central SP before any central-stack wasm runs.
|
||||
function finishActivation(id, p) {
|
||||
return p.then((v) => {
|
||||
m.stackRestore(centralBase);
|
||||
if (mode === 'green-region') {
|
||||
const base = regions.get(id);
|
||||
if (base) { m._free(base); regions.delete(id); }
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'green-copy') {
|
||||
gateHook = (id, base) => {
|
||||
const sp = m.stackSave();
|
||||
const top = entryTop.get(id);
|
||||
snapshots.set(id, new Uint8Array(m.HEAPU8.buffer, sp, top - sp).slice());
|
||||
return base.then((v) => {
|
||||
// restore this activation's spilled bytes + SP right before it resumes
|
||||
new Uint8Array(m.HEAPU8.buffer).set(snapshots.get(id), sp);
|
||||
snapshots.delete(id);
|
||||
m.stackRestore(sp);
|
||||
return v;
|
||||
});
|
||||
};
|
||||
} else if (mode === 'green-region') {
|
||||
gateHook = (id, base) => {
|
||||
actSp.set(id, m.stackSave());
|
||||
return base.then((v) => {
|
||||
m.stackRestore(actSp.get(id)); // point shared SP back into this region
|
||||
return v;
|
||||
});
|
||||
};
|
||||
}
|
||||
// red: gateHook stays null — raw JSPI, no discipline.
|
||||
|
||||
// --- scenarios ---------------------------------------------------------------
|
||||
async function interleavedRound() {
|
||||
// A suspends deep; B suspends below A; A completes (epilogue resets SP above
|
||||
// B's live frames); central stomp scribbles downward; B resumes and verifies.
|
||||
const pA = startActivation(1);
|
||||
const pB = startActivation(2);
|
||||
release(1);
|
||||
const corruptA = await pA;
|
||||
if (mode === 'red') {
|
||||
m._stomp(STOMP_DEPTH);
|
||||
} else {
|
||||
m.stackRestore(centralBase); // central discipline: own SP before central work
|
||||
m._stomp(STOMP_DEPTH);
|
||||
}
|
||||
release(2);
|
||||
const corruptB = await pB;
|
||||
return { corruptA, corruptB };
|
||||
}
|
||||
|
||||
async function nestedCase() {
|
||||
// Second activation starts from the first activation's RESUME path — the
|
||||
// "nested modal over a parked tool body" shape.
|
||||
const pA = startActivation(3);
|
||||
let corruptInner = -1;
|
||||
const innerDone = (async () => {
|
||||
// arm: when A's gate resolves, immediately start B before A's verify walk
|
||||
const pB = startActivation(4);
|
||||
release(4);
|
||||
corruptInner = await pB;
|
||||
})();
|
||||
release(3);
|
||||
const corruptOuter = await pA;
|
||||
await innerDone;
|
||||
return { corruptOuter, corruptInner };
|
||||
}
|
||||
|
||||
let totalA = 0, totalB = 0, totalNested = 0;
|
||||
if (variant === 'pthread') m._start_churn(); // cross-thread allocator churn during parks
|
||||
const ROUNDS = mode === 'red' ? 1 : 3;
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
const { corruptA, corruptB } = await interleavedRound();
|
||||
totalA += corruptA; totalB += corruptB;
|
||||
}
|
||||
const { corruptOuter, corruptInner } = await nestedCase();
|
||||
totalNested = corruptOuter + corruptInner;
|
||||
if (variant === 'pthread') m._stop_churn();
|
||||
|
||||
const corrupted = totalA + totalB + totalNested > 0;
|
||||
const verdict =
|
||||
mode === 'red' ? (corrupted ? 'RED' : 'UNEXPECTED')
|
||||
: (corrupted ? 'UNEXPECTED' : 'GREEN');
|
||||
|
||||
console.log(
|
||||
`[JSPI_STACK] SCENARIO ${mode} corruptA=${totalA} corruptB=${totalB} ` +
|
||||
`corruptNested=${totalNested} verdict=${verdict}`
|
||||
);
|
||||
process.exit(verdict === 'UNEXPECTED' ? 1 : 0);
|
||||
166
tests/apps/standalone/jspi-stack/index.html
Normal file
166
tests/apps/standalone/jspi-stack/index.html
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>jspi-stack harness</title></head>
|
||||
<body>
|
||||
<!--
|
||||
Browser port of driver.mjs — the red/green shadow-stack battery
|
||||
(emscripten #27364). Runs every mode/variant combo sequentially and emits
|
||||
one line per combo:
|
||||
|
||||
[JSPI_STACK] SCENARIO <mode>/<variant> corruptA=<n> corruptB=<n> corruptNested=<n> verdict=<RED|GREEN|UNEXPECTED>
|
||||
|
||||
then [JSPI_STACK] DONE. RED must observe corruption with mitigation OFF
|
||||
(proves the harness can still see the bug); GREEN legs must be
|
||||
corruption-free. Parsed by tests/jspi/jspi-stack.spec.ts.
|
||||
-->
|
||||
<script type="module">
|
||||
const DEPTH = 24;
|
||||
const STOMP_DEPTH = 48;
|
||||
const REGION_BYTES = 256 * 1024;
|
||||
|
||||
async function runScenario(mode, variant) {
|
||||
const { default: factory } = await import(
|
||||
variant === 'pthread' ? './stack_test_pt.mjs' : './stack_test.mjs');
|
||||
|
||||
// --- gate plumbing (fresh per run; the instance reads the global) ------
|
||||
const gates = new Map();
|
||||
let gateHook = null;
|
||||
globalThis.__jspiGate = (id) => {
|
||||
const base = new Promise((res) => gates.set(id, res));
|
||||
return gateHook ? gateHook(id, base) : base;
|
||||
};
|
||||
const release = (id) => {
|
||||
const r = gates.get(id);
|
||||
if (!r) throw new Error(`no gate armed for id ${id}`);
|
||||
gates.delete(id);
|
||||
r(0);
|
||||
};
|
||||
|
||||
const m = await factory();
|
||||
const centralBase = m.stackSave();
|
||||
|
||||
// --- mitigations (same discipline as driver.mjs) -----------------------
|
||||
const entryTop = new Map();
|
||||
const actSp = new Map();
|
||||
const regions = new Map();
|
||||
const snapshots = new Map();
|
||||
|
||||
function startActivation(id) {
|
||||
if (mode === 'green-region') {
|
||||
const base = regions.get(id) ?? m._malloc(REGION_BYTES);
|
||||
regions.set(id, base);
|
||||
const top = base + REGION_BYTES;
|
||||
const saved = m.stackSave();
|
||||
m.stackRestore(top);
|
||||
entryTop.set(id, top);
|
||||
const p = m._activation(id, DEPTH);
|
||||
m.stackRestore(saved);
|
||||
return finishActivation(id, p);
|
||||
}
|
||||
entryTop.set(id, m.stackSave());
|
||||
const p = m._activation(id, DEPTH);
|
||||
return finishActivation(id, p);
|
||||
}
|
||||
|
||||
function finishActivation(id, p) {
|
||||
return p.then((v) => {
|
||||
m.stackRestore(centralBase);
|
||||
if (mode === 'green-region') {
|
||||
const base = regions.get(id);
|
||||
if (base) { m._free(base); regions.delete(id); }
|
||||
}
|
||||
return v;
|
||||
});
|
||||
}
|
||||
|
||||
if (mode === 'green-copy') {
|
||||
gateHook = (id, base) => {
|
||||
const sp = m.stackSave();
|
||||
const top = entryTop.get(id);
|
||||
snapshots.set(id, new Uint8Array(m.HEAPU8.buffer, sp, top - sp).slice());
|
||||
return base.then((v) => {
|
||||
new Uint8Array(m.HEAPU8.buffer).set(snapshots.get(id), sp);
|
||||
snapshots.delete(id);
|
||||
m.stackRestore(sp);
|
||||
return v;
|
||||
});
|
||||
};
|
||||
} else if (mode === 'green-region') {
|
||||
gateHook = (id, base) => {
|
||||
actSp.set(id, m.stackSave());
|
||||
return base.then((v) => {
|
||||
m.stackRestore(actSp.get(id));
|
||||
return v;
|
||||
});
|
||||
};
|
||||
}
|
||||
// red: gateHook stays null — raw JSPI, no discipline.
|
||||
|
||||
async function interleavedRound() {
|
||||
const pA = startActivation(1);
|
||||
const pB = startActivation(2);
|
||||
release(1);
|
||||
const corruptA = await pA;
|
||||
if (mode === 'red') {
|
||||
m._stomp(STOMP_DEPTH);
|
||||
} else {
|
||||
m.stackRestore(centralBase);
|
||||
m._stomp(STOMP_DEPTH);
|
||||
}
|
||||
release(2);
|
||||
const corruptB = await pB;
|
||||
return { corruptA, corruptB };
|
||||
}
|
||||
|
||||
async function nestedCase() {
|
||||
const pA = startActivation(3);
|
||||
let corruptInner = -1;
|
||||
const innerDone = (async () => {
|
||||
const pB = startActivation(4);
|
||||
release(4);
|
||||
corruptInner = await pB;
|
||||
})();
|
||||
release(3);
|
||||
const corruptOuter = await pA;
|
||||
await innerDone;
|
||||
return { corruptOuter, corruptInner };
|
||||
}
|
||||
|
||||
let totalA = 0, totalB = 0, totalNested = 0;
|
||||
if (variant === 'pthread') m._start_churn();
|
||||
const ROUNDS = mode === 'red' ? 1 : 3;
|
||||
for (let i = 0; i < ROUNDS; i++) {
|
||||
const { corruptA, corruptB } = await interleavedRound();
|
||||
totalA += corruptA; totalB += corruptB;
|
||||
}
|
||||
const { corruptOuter, corruptInner } = await nestedCase();
|
||||
totalNested = corruptOuter + corruptInner;
|
||||
if (variant === 'pthread') m._stop_churn();
|
||||
|
||||
const corrupted = totalA + totalB + totalNested > 0;
|
||||
const verdict =
|
||||
mode === 'red' ? (corrupted ? 'RED' : 'UNEXPECTED')
|
||||
: (corrupted ? 'UNEXPECTED' : 'GREEN');
|
||||
console.log(
|
||||
`[JSPI_STACK] SCENARIO ${mode}/${variant} corruptA=${totalA} ` +
|
||||
`corruptB=${totalB} corruptNested=${totalNested} verdict=${verdict}`);
|
||||
}
|
||||
|
||||
const COMBOS = [
|
||||
['red', 'single'],
|
||||
['green-copy', 'single'],
|
||||
['green-region', 'single'],
|
||||
['green-copy', 'pthread'],
|
||||
['green-region', 'pthread'],
|
||||
];
|
||||
try {
|
||||
for (const [mode, variant] of COMBOS) {
|
||||
await runScenario(mode, variant);
|
||||
}
|
||||
console.log('[JSPI_STACK] DONE');
|
||||
} catch (e) {
|
||||
console.log('[JSPI_STACK] FATAL ' + e);
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
131
tests/apps/standalone/jspi-stack/stack_test.cpp
Normal file
131
tests/apps/standalone/jspi-stack/stack_test.cpp
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// jspi-stack — shadow-stack red/green harness (emscripten #27364).
|
||||
//
|
||||
// JSPI switches the NATIVE wasm stack per promising activation, but the C/C++
|
||||
// linear-memory spill stack (__stack_pointer) is shared module state. Two
|
||||
// concurrently-suspended activations therefore interleave their spill frames
|
||||
// in one region, and a completed activation's epilogue resets __stack_pointer
|
||||
// over a still-suspended activation's live frames. This harness makes that
|
||||
// corruption OBSERVABLE (red leg) and proves a mitigation closes it (green
|
||||
// leg). Mitigation policy lives entirely in the JS driver so the same binary
|
||||
// serves both legs; see driver.mjs.
|
||||
//
|
||||
// Wx-free and libcontext-free on purpose: a failure here can only be the
|
||||
// primitive (the sched-context doctrine).
|
||||
|
||||
#include <emscripten.h>
|
||||
#include <emscripten/em_js.h>
|
||||
#include <emscripten/stack.h>
|
||||
#include <cstdint>
|
||||
#include <cstdio>
|
||||
|
||||
// The suspend point. Under -sJSPI every EM_ASYNC_JS import is wrapped in
|
||||
// WebAssembly.Suspending automatically. The driver resolves gates by id, in
|
||||
// whatever order the scenario prescribes.
|
||||
EM_ASYNC_JS(int, js_gate, (int id), {
|
||||
return await globalThis.__jspiGate(id);
|
||||
});
|
||||
|
||||
namespace {
|
||||
|
||||
// Per-frame canary: value depends on activation id, recursion depth, and slot,
|
||||
// so a frame overwritten by ANY other frame (same or different activation)
|
||||
// cannot verify.
|
||||
inline uint32_t canary(int id, int depth, int slot) {
|
||||
return 0x9E3779B9u * (uint32_t)(id * 1000003 + depth * 8191 + slot + 1);
|
||||
}
|
||||
|
||||
constexpr int SLOTS = 64; // 256 B of spill payload per frame
|
||||
|
||||
// Recurse to `depth`, stamping canaries into a stack buffer at every level;
|
||||
// suspend at the bottom; verify every frame's canaries on the way back up.
|
||||
// The buffer is spilled to the shadow stack (address taken via the volatile
|
||||
// pointer, so it cannot live in registers/locals only). noinline keeps one
|
||||
// real spill frame per recursion level.
|
||||
__attribute__((noinline))
|
||||
int canary_frame(int id, int depth) {
|
||||
uint32_t buf[SLOTS];
|
||||
volatile uint32_t* p = buf;
|
||||
for (int i = 0; i < SLOTS; i++) p[i] = canary(id, depth, i);
|
||||
|
||||
int corrupt = 0;
|
||||
if (depth > 0) {
|
||||
corrupt = canary_frame(id, depth - 1);
|
||||
} else {
|
||||
js_gate(id); // park this activation; driver decides when it wakes
|
||||
}
|
||||
|
||||
for (int i = 0; i < SLOTS; i++) {
|
||||
if (p[i] != canary(id, depth, i)) corrupt++;
|
||||
}
|
||||
return corrupt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
extern "C" {
|
||||
|
||||
// A promising activation (must be in JSPI_EXPORTS): recurse `depth` frames,
|
||||
// suspend at the bottom, return the number of corrupted canary words observed
|
||||
// while unwinding. 0 == clean.
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
int activation(int id, int depth) {
|
||||
return canary_frame(id, depth);
|
||||
}
|
||||
|
||||
// Plain (non-promising) export: deep central-stack work that scribbles its own
|
||||
// frames. This is the "other wasm work" that grows down over a suspended
|
||||
// activation's live frames once another activation's epilogue reset the SP.
|
||||
EMSCRIPTEN_KEEPALIVE
|
||||
__attribute__((noinline))
|
||||
int stomp(int depth) {
|
||||
uint32_t buf[SLOTS];
|
||||
volatile uint32_t* p = buf;
|
||||
for (int i = 0; i < SLOTS; i++) p[i] = 0xDEADBEEFu;
|
||||
int acc = (int)p[depth % SLOTS];
|
||||
if (depth > 0) acc ^= stomp(depth - 1);
|
||||
return acc;
|
||||
}
|
||||
|
||||
// Introspection for the driver's mitigation bookkeeping.
|
||||
EMSCRIPTEN_KEEPALIVE uintptr_t stack_current(void) { return emscripten_stack_get_current(); }
|
||||
EMSCRIPTEN_KEEPALIVE uintptr_t stack_base(void) { return emscripten_stack_get_base(); }
|
||||
EMSCRIPTEN_KEEPALIVE uintptr_t stack_end(void) { return emscripten_stack_get_end(); }
|
||||
|
||||
} // extern "C"
|
||||
|
||||
#ifdef __EMSCRIPTEN_PTHREADS__
|
||||
// Production shape: worker threads never suspend, but they churn the shared
|
||||
// allocator while main-thread activations sit suspended. The churn thread
|
||||
// must not perturb suspended activations' spill frames.
|
||||
#include <atomic>
|
||||
#include <thread>
|
||||
#include <cstdlib>
|
||||
|
||||
namespace {
|
||||
std::atomic<bool> g_churn{false};
|
||||
std::thread g_churn_thread;
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
EMSCRIPTEN_KEEPALIVE void start_churn(void) {
|
||||
g_churn = true;
|
||||
g_churn_thread = std::thread([] {
|
||||
while (g_churn) {
|
||||
void* blocks[32];
|
||||
for (auto& b : blocks) b = std::malloc(64 + (rand() % 512));
|
||||
for (auto& b : blocks) std::free(b);
|
||||
}
|
||||
});
|
||||
}
|
||||
EMSCRIPTEN_KEEPALIVE void stop_churn(void) {
|
||||
g_churn = false;
|
||||
if (g_churn_thread.joinable()) g_churn_thread.join();
|
||||
}
|
||||
} // extern "C"
|
||||
#endif
|
||||
|
||||
int main() {
|
||||
// Driver-controlled; nothing to do. Keep the runtime alive for export calls.
|
||||
EM_ASM({ console.log("[JSPI_STACK] READY"); });
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -1,122 +0,0 @@
|
|||
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
|
||||
|
||||
/**
|
||||
* S2 scheduler-core gates (docs/features/async/17 §3d N1/N4, §4 S2).
|
||||
* Runs against the races harness; the scheduler shim is the only runtime
|
||||
* (the legacy handlesleep opt-out was deleted at doc 20 D-1). The self-skip
|
||||
* on shim-less glue is kept as a stale-build guard.
|
||||
*
|
||||
* N1 — single-writer tripwire: Asyncify.currData is an accessor; a pure-JS
|
||||
* write without scheduler authorization beacons (and throws in strict mode).
|
||||
* The meta-test INTRODUCES a stray writer and expects the tripwire to fire —
|
||||
* proving the alarm works, not merely that nobody tripped it.
|
||||
*
|
||||
* N4 — wake-never-rewinds-mid-transition: across the races battery (which
|
||||
* stages overlapping parks, nested modals, out-of-order wakes) the
|
||||
* scheduler's books must be coherent at settle: no queued wake left, every
|
||||
* deferral drained, zero unplanned strays, battery green.
|
||||
*/
|
||||
|
||||
type SchedulerState = {
|
||||
state(): string;
|
||||
strayWrites: number;
|
||||
strictStrays: boolean;
|
||||
readyWakes: unknown[];
|
||||
deferredWakes: number;
|
||||
drainedWakes: number;
|
||||
};
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((log) => log.includes('[ASYNCIFY_RACES] SUMMARY'));
|
||||
}
|
||||
|
||||
async function bootAndSettle(
|
||||
page: import('@playwright/test').Page,
|
||||
testLogger: { consoleLogs: string[] },
|
||||
): Promise<boolean> {
|
||||
await page.goto('/standalone/asyncify-races/races_test.html');
|
||||
const loaded = await tryLoadApp(page, 30000);
|
||||
expect(loaded, 'races harness should load').toBe(true);
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 60000,
|
||||
message: 'battery should emit its SUMMARY line',
|
||||
})
|
||||
.not.toBeNull();
|
||||
return page.evaluate(
|
||||
() => !!(globalThis as unknown as { __wxScheduler?: unknown }).__wxScheduler,
|
||||
);
|
||||
}
|
||||
|
||||
test.describe('S2 scheduler core (scheduler glue)', () => {
|
||||
test('N4: battery leaves coherent books — wakes drained, no strays, battery green', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
const scheduler = await bootAndSettle(page, testLogger);
|
||||
test.skip(!scheduler, 'legacy glue — scheduler core absent');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler
|
||||
.readyWakes.length,
|
||||
),
|
||||
{ timeout: 30000, intervals: [250] },
|
||||
)
|
||||
.toBe(0);
|
||||
|
||||
const books = await page.evaluate(() => {
|
||||
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
|
||||
return {
|
||||
ready: S.readyWakes.length,
|
||||
deferred: S.deferredWakes,
|
||||
drained: S.drainedWakes,
|
||||
strays: S.strayWrites,
|
||||
state: S.state(),
|
||||
};
|
||||
});
|
||||
console.log(`[TEST] scheduler books: ${books.state}`);
|
||||
|
||||
expect(books.ready, 'no wake left queued after settle').toBe(0);
|
||||
expect(books.drained, 'every deferred wake was drained').toBe(books.deferred);
|
||||
expect(books.strays, 'no stray currData writes during the battery').toBe(0);
|
||||
const fails = testLogger.consoleLogs.filter((l) => l.includes('[ASYNCIFY_RACES] FAIL '));
|
||||
expect(fails, 'battery green under the scheduler core').toEqual([]);
|
||||
});
|
||||
|
||||
test('N1 meta: an introduced stray currData write trips the alarm', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
test.setTimeout(180000);
|
||||
const scheduler = await bootAndSettle(page, testLogger);
|
||||
test.skip(!scheduler, 'legacy glue — scheduler core absent');
|
||||
|
||||
const result = await page.evaluate(() => {
|
||||
const S = (globalThis as unknown as { __wxScheduler: SchedulerState }).__wxScheduler;
|
||||
const A = (globalThis as unknown as { Asyncify: { currData: number | null } }).Asyncify;
|
||||
const before = S.strayWrites;
|
||||
const saved = A.currData;
|
||||
A.currData = saved; // value-preserving, still a stray WRITE
|
||||
const counted = S.strayWrites === before + 1;
|
||||
S.strictStrays = true;
|
||||
let threw = false;
|
||||
try {
|
||||
A.currData = saved;
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
S.strictStrays = false;
|
||||
return { counted, threw };
|
||||
});
|
||||
|
||||
expect(result.counted, 'stray write was counted').toBe(true);
|
||||
expect(result.threw, 'strict mode throws on stray write').toBe(true);
|
||||
const beacons = testLogger.consoleLogs.filter((l) => l.includes('stray-currdata-write'));
|
||||
expect(beacons.length, 'stray write beaconed to the console').toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,249 +0,0 @@
|
|||
import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
|
||||
|
||||
/**
|
||||
* Design B D1 gate — scheduler contexts (docs/features/async/20 §6 D1, 21).
|
||||
*
|
||||
* Drives tests/apps/standalone/sched-context/sched_context_test.cpp, which
|
||||
* exercises wasm/sched/context.{h,cpp}: create / yield_park / mark_ready /
|
||||
* drain. The point of the layer is that a resume is something the REGISTRY
|
||||
* knows rather than something a guard guesses (doc 19's disease), so the
|
||||
* battery asserts the invariants, and this spec additionally gates the thing
|
||||
* doc 20 §7 risk 1 demands: contexts must be bounded and MEASURED.
|
||||
*
|
||||
* No production path runs on contexts yet — that starts at D2.
|
||||
*/
|
||||
|
||||
const SCENARIOS = [
|
||||
'create_runs_on_drain',
|
||||
'park_and_resume',
|
||||
'no_park_in_place',
|
||||
'parked_does_not_block',
|
||||
'fifo_order',
|
||||
'one_transition_in_flight',
|
||||
'registry_refusals',
|
||||
'deep_park_sizing',
|
||||
'fiber_nests_in_context',
|
||||
'foreign_stack_refused',
|
||||
'fiber_roundtrip',
|
||||
'fiber_release_suspended',
|
||||
'fiber_and_star_coexist',
|
||||
'star_transfer_call_is_synchronous',
|
||||
'star_transfer_chain',
|
||||
'async_wake',
|
||||
];
|
||||
|
||||
/** Frame count the deep-park scenario recurses to before parking (must match
|
||||
* DEEP_PARK_FRAMES in the harness) — the divisor for the per-frame cost. */
|
||||
const DEEP_PARK_FRAMES = 64;
|
||||
|
||||
/** Memory ceiling for the battery (doc 20 risk 1). The worst scenario holds
|
||||
* ~6 live contexts; 16 leaves headroom without letting a leak hide. */
|
||||
const MAX_PEAK_LIVE = 16;
|
||||
/** Peak bytes the battery may charge to contexts. At 256 KB per context
|
||||
* (128 KB C stack + 128 KB asyncify buffer) 16 contexts = 4 MB. */
|
||||
const MAX_PEAK_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
type Stats = {
|
||||
live: number;
|
||||
peakLive: number;
|
||||
created: number;
|
||||
finished: number;
|
||||
transitions: number;
|
||||
refusals: number;
|
||||
foreignStackRefusals: number;
|
||||
running: number;
|
||||
transitionInFlight: boolean;
|
||||
readyQueued: number;
|
||||
bytes: number;
|
||||
peakBytes: number;
|
||||
perContextBytes: number;
|
||||
cStackBytes: number;
|
||||
asyncifyBytes: number;
|
||||
asyncifyHighWater: number;
|
||||
// Fiber lane (doc 22 Phase A) — libcontext's clients, separate counters so
|
||||
// this battery's star assertions keep meaning what they meant.
|
||||
fiberLive: number;
|
||||
fiberPeakLive: number;
|
||||
fiberCreated: number;
|
||||
fiberReleased: number;
|
||||
fiberSwaps: number;
|
||||
fiberRefusals: number;
|
||||
fiberReleasedSuspended: number;
|
||||
fiberReleasedRunning: number;
|
||||
fiberNonEnterableSwaps: number;
|
||||
fiberRunning: number;
|
||||
fiberBytes: number;
|
||||
fiberPeakBytes: number;
|
||||
fiberAsyncifyHighWater: number;
|
||||
};
|
||||
|
||||
function findLine(logs: string[], marker: string): string | undefined {
|
||||
return logs.find((l) => l.includes(`[SCHED_CTX] ${marker}`));
|
||||
}
|
||||
|
||||
function parseTagged<T>(logs: string[], marker: string): T | null {
|
||||
const line = findLine(logs, marker);
|
||||
if (!line) return null;
|
||||
const json = line.slice(line.indexOf(`[SCHED_CTX] ${marker}`) + `[SCHED_CTX] ${marker}`.length);
|
||||
return JSON.parse(json.trim()) as T;
|
||||
}
|
||||
|
||||
test.describe('Design B D1 — scheduler contexts', () => {
|
||||
test('battery: every context invariant holds', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/sched-context/sched_context_test.html');
|
||||
expect(await tryLoadApp(page, 30000), 'sched-context harness should load').toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findLine(testLogger.consoleLogs, 'SUMMARY') ?? null, {
|
||||
timeout: 30000,
|
||||
message: 'battery should emit SUMMARY (a missing one means a wedge — e.g. a context that never resumed)',
|
||||
})
|
||||
.not.toBeNull();
|
||||
|
||||
const summary = findLine(testLogger.consoleLogs, 'SUMMARY')!;
|
||||
const m = summary.match(/total=(\d+)\s+passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(m, `unparseable summary: ${summary}`).not.toBeNull();
|
||||
const [total, passed, failed] = [Number(m![1]), Number(m![2]), Number(m![3])];
|
||||
|
||||
const fails = testLogger.consoleLogs.filter((l) => l.includes('[SCHED_CTX] FAIL '));
|
||||
expect(fails, `failures: ${fails.join(' || ')}`).toHaveLength(0);
|
||||
expect(failed).toBe(0);
|
||||
expect(total, 'every scenario ran').toBe(SCENARIOS.length);
|
||||
expect(passed).toBe(SCENARIOS.length);
|
||||
|
||||
for (const name of SCENARIOS) {
|
||||
expect
|
||||
.soft(
|
||||
testLogger.consoleLogs.some((l) => l.includes(`[SCHED_CTX] PASS ${name}`)),
|
||||
`scenario ${name} should PASS`,
|
||||
)
|
||||
.toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('memory: contexts are bounded and measured (doc 20 risk 1)', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
await page.goto('/standalone/sched-context/sched_context_test.html');
|
||||
expect(await tryLoadApp(page, 30000)).toBe(true);
|
||||
|
||||
await expect
|
||||
.poll(() => findLine(testLogger.consoleLogs, 'STATS') ?? null, { timeout: 30000 })
|
||||
.not.toBeNull();
|
||||
|
||||
const stats = parseTagged<Stats>(testLogger.consoleLogs, 'STATS')!;
|
||||
console.log(`[TEST] context stats: ${JSON.stringify(stats)}`);
|
||||
|
||||
// --- the ceiling: a leak must fail here, not in a user's tab ------------
|
||||
expect(stats.peakLive, 'peak live contexts within the ceiling').toBeLessThanOrEqual(
|
||||
MAX_PEAK_LIVE,
|
||||
);
|
||||
expect(stats.peakBytes, 'peak context bytes within the budget').toBeLessThanOrEqual(
|
||||
MAX_PEAK_BYTES,
|
||||
);
|
||||
|
||||
// --- no leaks: every context the battery created was destroyed ----------
|
||||
expect(stats.live, 'no context left live after the battery').toBe(0);
|
||||
expect(stats.bytes, 'no context bytes left charged').toBe(0);
|
||||
expect(stats.finished, 'every created context finished').toBe(stats.created);
|
||||
|
||||
// --- the layer did real work (guards against a vacuous pass) -----------
|
||||
expect(stats.created, 'the battery created contexts').toBeGreaterThan(5);
|
||||
expect(stats.transitions, 'the scheduler performed swaps').toBeGreaterThan(
|
||||
stats.created,
|
||||
);
|
||||
|
||||
// --- the invariant: nothing left mid-transition ------------------------
|
||||
expect(stats.transitionInFlight, 'no transition left in flight').toBe(false);
|
||||
expect(stats.running, 'no context left running').toBe(0);
|
||||
expect(stats.readyQueued, 'ready queue drained').toBe(0);
|
||||
|
||||
// --- refusals are EXPECTED here: three scenarios provoke them ----------
|
||||
// (yield_park off a context, destroy while parked, mark_ready twice /
|
||||
// unknown id). Zero would mean those scenarios stopped provoking.
|
||||
expect(stats.refusals, 'illegal operations were refused and counted').toBeGreaterThan(0);
|
||||
|
||||
// --- the D3 blocker, pinned -------------------------------------------
|
||||
// A wait called from a fiber running ON TOP of a context (a KiCad tool
|
||||
// coroutine opening a dialog) must be refused, not allowed to yield
|
||||
// someone else's context — that would save the tool fiber's stack into the
|
||||
// host context's fiber struct. Exactly one scenario provokes it.
|
||||
expect(
|
||||
stats.foreignStackRefusals,
|
||||
'a yield from a foreign stack was refused rather than corrupting the host context',
|
||||
).toBe(1);
|
||||
|
||||
// --- sizing evidence (doc 20 risk 1: derive, don't inherit) ------------
|
||||
// The high-water mark is what a future buffer size must be justified by.
|
||||
// Assert it is both real (>0 — the parks did save frames) and comfortably
|
||||
// inside the buffer, so the number in the log is trustworthy.
|
||||
expect(stats.asyncifyHighWater, 'asyncify use was measured').toBeGreaterThan(0);
|
||||
expect(
|
||||
stats.asyncifyHighWater,
|
||||
'asyncify high-water inside the per-context buffer',
|
||||
).toBeLessThan(stats.asyncifyBytes);
|
||||
// Per-frame cost from the deep-park scenario: the number a future buffer
|
||||
// size must be derived from. The high-water is dominated by that park
|
||||
// (every other scenario parks 1-2 frames deep).
|
||||
//
|
||||
// CAVEAT, do not skip when quoting this number: the harness's frames carry
|
||||
// three locals each, so this is a FLOOR for per-frame cost, not a
|
||||
// production estimate. Real park sites (a lib fetch inside commit.Push →
|
||||
// connectivity → font work) save far more per frame — that is why
|
||||
// libcontext runs a 512 K buffer after a 64 K one silently overflowed.
|
||||
// Treat this as "the measurement apparatus works and these are its units";
|
||||
// the sizing decision needs deep-park numbers from real bridges at D3/D4.
|
||||
const perFrame = stats.asyncifyHighWater / DEEP_PARK_FRAMES;
|
||||
console.log(
|
||||
`[TEST] asyncify sizing: high-water ${stats.asyncifyHighWater}B of ` +
|
||||
`${stats.asyncifyBytes}B buffer ` +
|
||||
`(${((stats.asyncifyHighWater / stats.asyncifyBytes) * 100).toFixed(1)}%); ` +
|
||||
`~${perFrame.toFixed(0)}B per frame over ${DEEP_PARK_FRAMES} frames ` +
|
||||
`→ the ${stats.asyncifyBytes}B buffer holds ~${Math.floor(
|
||||
stats.asyncifyBytes / Math.max(perFrame, 1),
|
||||
)} frames`,
|
||||
);
|
||||
|
||||
// The deep park must actually dominate — otherwise the per-frame number
|
||||
// above is noise from a shallow park and cannot justify any size.
|
||||
expect(
|
||||
stats.asyncifyHighWater,
|
||||
'the deep park (64 frames) drove the high-water mark',
|
||||
).toBeGreaterThan(1024);
|
||||
|
||||
// No buffer-pressure beacon should have fired (>75% use).
|
||||
const pressure = testLogger.consoleLogs.filter((l) => l.includes('BUFFER-PRESSURE'));
|
||||
expect(pressure, `buffer pressure: ${pressure.join(' || ')}`).toHaveLength(0);
|
||||
|
||||
// --- fiber lane (doc 22 Phase A): libcontext semantics over the registry --
|
||||
// The adopted root is the only fiber that outlives the battery (libcontext's
|
||||
// main context never dies); everything else was released.
|
||||
expect(stats.fiberLive, 'only the adopted root fiber remains').toBe(1);
|
||||
// Phase B invariant, stronger than the Phase A one it replaces: once the
|
||||
// pump is quiescent NO fiber is on the CPU — the scheduler is. Under the
|
||||
// star that is what "between transitions" means.
|
||||
expect(stats.fiberRunning, 'no fiber is current when the pump is quiescent').toBe(0);
|
||||
expect(stats.fiberCreated, 'every fiber the battery made').toBeGreaterThanOrEqual(8);
|
||||
expect(stats.fiberReleased, 'all but the adopted root were released').toBe(
|
||||
stats.fiberCreated - 1,
|
||||
);
|
||||
expect(stats.fiberSwaps, 'symmetric swaps and star transfers happened').toBeGreaterThanOrEqual(
|
||||
12,
|
||||
);
|
||||
// Both releases happened mid-suspend — libcontext's refcount-drop shape.
|
||||
expect(stats.fiberReleasedSuspended, 'suspended releases are legal and counted').toBe(2);
|
||||
// One deliberate stale-id swap was refused (use-after-free made loud).
|
||||
expect(stats.fiberRefusals, 'a stale fiber id was refused').toBeGreaterThanOrEqual(1);
|
||||
// THE Phase A tripwires: no swap ever entered a non-enterable fiber, and
|
||||
// no release ever hit a fiber the registry believed was running.
|
||||
expect(stats.fiberNonEnterableSwaps, 'zero swaps into stale rewind state').toBe(0);
|
||||
expect(stats.fiberReleasedRunning, 'zero releases of a running fiber').toBe(0);
|
||||
// The sizing input Phase E reads: a suspended fiber's capture was measured
|
||||
// (sampled before the resume consumes it, when the buffer is non-empty).
|
||||
expect(
|
||||
stats.fiberAsyncifyHighWater,
|
||||
'fiber-lane asyncify use was measured while suspended',
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
55
tests/jspi/jspi-coroutine.spec.ts
Normal file
55
tests/jspi/jspi-coroutine.spec.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { test, expect } from '../e2e/utils/fixtures';
|
||||
|
||||
// Contract battery for the JSPI libcontext coroutine backend
|
||||
// (kicad/thirdparty/libcontext/libcontext.cpp under PCBJAM_JSPI). The harness
|
||||
// (tests/apps/standalone/jspi-coroutine) is a wx-free MiniCoro that mirrors
|
||||
// tool/coroutine.h's protocol exactly — INVOCATION_ARGS, callerStub with the
|
||||
// finish_fcontext hook, jumpIn/jumpOut, CONTINUE_AFTER_ROOT — over the real
|
||||
// libcontext.cpp. 15 cases: create/run/finish, yield chains, nested
|
||||
// call-in-call routed by enterer inference, RunMainStack payload propagation,
|
||||
// ghost-resume refusal (dead tombstones), mid-body release census.
|
||||
//
|
||||
// Output contract: per-case "[JSPI_CORO] CASE <name> PASS|FAIL" then
|
||||
// "[JSPI_CORO] SUMMARY passed=<n> failed=<n>".
|
||||
|
||||
const EXPECTED_PASSES = 15;
|
||||
|
||||
function findSummary(logs: string[]) {
|
||||
return logs.find((l) => l.includes('[JSPI_CORO] SUMMARY'));
|
||||
}
|
||||
|
||||
function assertSummary(logs: string[]) {
|
||||
const summary = findSummary(logs)!;
|
||||
const match = summary.match(/passed=(\d+)\s+failed=(\d+)/);
|
||||
expect(match, `summary parseable: ${summary}`).not.toBeNull();
|
||||
expect(Number(match![1]), 'all cases pass').toBe(EXPECTED_PASSES);
|
||||
expect(Number(match![2]), 'no case fails').toBe(0);
|
||||
const fails = logs.filter((l) => l.includes('[JSPI_CORO] CASE') && l.includes('FAIL'));
|
||||
expect(fails, `FAIL cases: ${fails.join(' || ')}`).toHaveLength(0);
|
||||
const fatal = logs.filter((l) => l.includes('[JSPI_CORO] FATAL'));
|
||||
expect(fatal, `harness fatal: ${fatal.join(' || ')}`).toHaveLength(0);
|
||||
}
|
||||
|
||||
test.describe('JSPI coroutine backend contract battery', () => {
|
||||
test('single-thread build: 15/15 protocol cases pass', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/jspi-coroutine/');
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 60000,
|
||||
message: 'harness should emit its SUMMARY line',
|
||||
})
|
||||
.not.toBeNull();
|
||||
assertSummary(testLogger.consoleLogs);
|
||||
});
|
||||
|
||||
test('pthread build: 15/15 protocol cases pass', async ({ page, testLogger }) => {
|
||||
await page.goto('/standalone/jspi-coroutine/?pt=1');
|
||||
await expect
|
||||
.poll(() => findSummary(testLogger.consoleLogs) ?? null, {
|
||||
timeout: 60000,
|
||||
message: 'pthread harness should emit its SUMMARY line',
|
||||
})
|
||||
.not.toBeNull();
|
||||
assertSummary(testLogger.consoleLogs);
|
||||
});
|
||||
});
|
||||
67
tests/jspi/jspi-stack.spec.ts
Normal file
67
tests/jspi/jspi-stack.spec.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { test, expect } from '../e2e/utils/fixtures';
|
||||
|
||||
// The permanent regression tripwire for the JSPI shadow-stack hazard
|
||||
// (emscripten #27364): JSPI switches the native stack per activation but NOT
|
||||
// the C spill stack (__stack_pointer), so concurrently-suspended activations
|
||||
// corrupt each other unless a discipline is applied. The harness
|
||||
// (tests/apps/standalone/jspi-stack, browser battery in its index.html) runs:
|
||||
//
|
||||
// red/single mitigation OFF → MUST detect corruption (the
|
||||
// harness can still see the bug; a silent red means
|
||||
// the tripwire itself broke)
|
||||
// green-copy/single per-suspension [sp, entrySp] snapshot+restore — the
|
||||
// discipline jspi-scheduler.js applies to wx entries
|
||||
// green-region/single per-activation stack region + SP swap — the
|
||||
// discipline libcontext's JSPI backend applies to
|
||||
// KiCad coroutines
|
||||
// green-copy/pthread both disciplines again with cross-thread allocator
|
||||
// green-region/pthread churn running during the parks
|
||||
//
|
||||
// Output contract per combo:
|
||||
// [JSPI_STACK] SCENARIO <mode>/<variant> corruptA=.. corruptB=.. corruptNested=.. verdict=<RED|GREEN|UNEXPECTED>
|
||||
|
||||
const COMBOS = [
|
||||
{ name: 'red/single', verdict: 'RED' },
|
||||
{ name: 'green-copy/single', verdict: 'GREEN' },
|
||||
{ name: 'green-region/single', verdict: 'GREEN' },
|
||||
{ name: 'green-copy/pthread', verdict: 'GREEN' },
|
||||
{ name: 'green-region/pthread', verdict: 'GREEN' },
|
||||
];
|
||||
|
||||
test.describe('JSPI shadow-stack red/green battery', () => {
|
||||
test('red detects corruption; both mitigations hold, incl. pthread churn', async ({
|
||||
page,
|
||||
testLogger,
|
||||
}) => {
|
||||
test.setTimeout(120000);
|
||||
await page.goto('/standalone/jspi-stack/');
|
||||
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
testLogger.consoleLogs.find((l) => l.includes('[JSPI_STACK] DONE')) ??
|
||||
testLogger.consoleLogs.find((l) => l.includes('[JSPI_STACK] FATAL')) ??
|
||||
null,
|
||||
{
|
||||
timeout: 90000,
|
||||
message: 'battery should emit DONE (FATAL/silence = harness wedge)',
|
||||
},
|
||||
)
|
||||
.not.toBeNull();
|
||||
|
||||
const fatal = testLogger.consoleLogs.filter((l) => l.includes('[JSPI_STACK] FATAL'));
|
||||
expect(fatal, `harness fatal: ${fatal.join(' || ')}`).toHaveLength(0);
|
||||
|
||||
for (const combo of COMBOS) {
|
||||
const line = testLogger.consoleLogs.find((l) =>
|
||||
l.includes(`[JSPI_STACK] SCENARIO ${combo.name} `),
|
||||
);
|
||||
expect.soft(line, `combo ${combo.name} should have run`).toBeTruthy();
|
||||
if (line) {
|
||||
expect
|
||||
.soft(line, `combo ${combo.name} verdict`)
|
||||
.toContain(`verdict=${combo.verdict}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
#include "collab_presence_style.h"
|
||||
#include "pcbjam_theme.h"
|
||||
#include "pcbjam_libs_reload.h"
|
||||
#include "pcbjam_async_policy.h"
|
||||
#include <algorithm>
|
||||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
#include <wx/string.h>
|
||||
#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 );
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
#include <project.h>
|
||||
|
||||
#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).
|
||||
|
|
|
|||
24
wasm/bindings/pcbjam_async_policy.h
Normal file
24
wasm/bindings/pcbjam_async_policy.h
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
#include <nlohmann/json.hpp>
|
||||
#include "open_gate.h"
|
||||
#include "main_stack_runner.h"
|
||||
#include "pcbjam_async_policy.h"
|
||||
#include <eda_draw_frame.h>
|
||||
#include <kiid.h>
|
||||
#include <pcbjam_read_only.h>
|
||||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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 ); } );
|
||||
} );
|
||||
|
||||
|
|
|
|||
|
|
@ -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<boolean> }
|
||||
| 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<never>((_, 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") {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<number>;
|
||||
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<string, unknown>).__wxSchedulerInstalled;
|
||||
delete (globalThis as Record<string, unknown>).__wxScheduler;
|
||||
const g = globalThis as Record<string, unknown>;
|
||||
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<string, unknown>).__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<string, unknown>).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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 4d479cb3095f6b6cbc9550257c8f0c5b1deb102d
|
||||
Subproject commit 8921d7aaa4f3c5491d0e880369d88cdefa98e933
|
||||
Loading…
Reference in a new issue