jspi cleanup: remove the asyncify-era residue — dead code, conditionals, pipeline scaffolding, stale prose

The runtime is JSPI-only; this removes everything that still pretended
otherwise. Three exhaustive sweeps (C++/JS+build+CI/tests+docs) drove
the inventory; every deletion verified by grep closure + full gates.

Broken-right-now fixes:
- deploy-staging.yml passed the retired opt_level input — the workflow
  could not even start. Removed.
- env.sh carried dead exports with a live -sASYNCIFY=1 inside
  (WASM_LDFLAGS/PTHREAD_LDFLAGS, zero consumers). Removed; the
  WASM_LEGACY_EXCEPTIONS rationale rewritten to the real reason.
- docker/build.sh exported PCBJAM_ASYNC_BACKEND (read nowhere). Gone.

Dead weight removed:
- binaryen submodule (nothing builds or invokes it), wasm-opt-bench
  workflow + scripts/bench/, get-wasm-opt.sh, diagnostics.js (242 lines
  of Asyncify-API-only code), the KICAD_PIPELINE background-postprocess
  scaffolding (existed to parallelize the deleted wasm-opt phase; the
  postprocess is a seconds-long node script and now runs inline),
  build-monitor's dead asyncify rows, sched-context orphan build
  output, dead .gitignore entries, the .jspi-assets spike dir (the two
  wf-result research JSONs moved to docs/features/async/migration-evidence/).
- bindings: fiber_park.h + its 12 embind registrations (broken-if-
  called under JSPI), the kicadOpenFileStart/OPEN_JOB starter route,
  main_stack_runner.h + 5 includes, the always-null context-sleep weak
  hook in nanosleep_yield.c.
- shim: the backend field (installed-flag idempotency instead),
  noteContextWait (dead both sides), the __wxAsyncifyDump alias (+ the
  WasmTool fallback and string-dump normalize branch).
- web: the emscripten-6-ignored mainScriptUrlOrBlob option in boot.ts
  (gerber-demo keeps it: it loads the deployed CDN release, which
  predates emscripten 6 — noted inline).

Conditionals: all 'backend === jspi' checks reduced to scheduler-
presence checks; races_quiescent re-keyed from Asyncify.state (vacuous)
to real backlog quiescence (resumeReady/mutatorQueue — NOT _windowLive,
which is the probing activation's own window by definition).

Renames (identifiers only, no file renames): ASYNC_LINK_FLAGS→
JSPI_LINK_FLAGS and Makefile ASYNC_LDFLAGS→JSPI_LDFLAGS,
kicadCollabFiberBusy→kicadCollabBusy (embind + web + tests),
collab_common.h fiber*→apply*/coroutine naming, asyncifySignatures→
wasmTrapSignatures (lists byte-identical).

Tests: the two remaining vacuous [wx-asyncify]/fiber-resume-refused
asserts re-keyed to live JSPI beacons; eeschema-load's failure message
no longer sends the developer to a deleted script; wait-beacons' dead
families/parser deleted; lane-0 legacy-glue guards removed (lane 0 is
unconstructible); the embind test.fail re-gated with the JSPI reason
(plain embind invokers cannot suspend — verified still failing);
lint-determinism now scans tests/jspi (166 files clean);
eeschema-collab local-move gated to chromium (~50% flaky on FF even
solo; pcbnew twin covers both engines).

Docs: DEBUG.md rewritten as the JSPI debugging guide; build.md
describes the single-phase build; docs/features/async/README.md
banner-marked historical and repointed at the NEW
23-jspi-runtime.md (current architecture: export census, turnstile,
libcontext ownership + refusal contract, embind call shapes, the
em-pthread service-wrapper trick, exception policy, known gaps).

Gates on the cleaned tree: test:e2e 725 passed / 0 failed (after the
quiescence-probe fix; the 3 other reds were verified contention flakes
solo-green or the documented FF gate), web 76/0, jspi 18/18 both
engines, vitest 295/295 + 17/17, all lints green, live-app census
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016X9eh1s5sTx1o9Em9KBuwR
This commit is contained in:
Viktor Vaczi 2026-08-14 09:25:32 +02:00
commit 9c475a804e
120 changed files with 1522 additions and 2974 deletions

View file

@ -24,7 +24,7 @@ npm test # setup:kicad + the full merged run (same projects as CI)
One merged config (`playwright.config.ts`) drives every wasm suite as
Playwright *projects*; `npm run test:e2e` runs the CI set: `wx-chromium`,
`kicad-firefox`, `kicad-chromium`, `asyncify-firefox`, `coroutine-firefox`.
`kicad-firefox`, `kicad-chromium`, `jspi-firefox`, `coroutine-firefox`.
The KiCad specs (heavier — they need the docker-built KiCad WASM) run on BOTH
engines; `npm run test:kicad` is the firefox-only shortcut. The React web app
suite is separate: `npm run test:web` (see `playwright-web.config.ts`).
@ -59,7 +59,7 @@ tests/
├── apps/ # Built WASM test applications
│ ├── minimal_test.html # Main test app
│ └── standalone/ # Individual component test apps
├── playwright.config.ts # THE merged config (wx / kicad / asyncify / coroutine / perf projects)
├── playwright.config.ts # THE merged config (wx / kicad / jspi / coroutine / perf projects)
└── playwright-web.config.ts # React web-app suite (own server stack)
```
@ -304,13 +304,12 @@ Button positions (relative to canvas):
## Open tasks
- ~~Research: are the Asyncify fiber shims still needed under native-EH?~~
**Resolved at doc 20 D-1** (legacy-runtime deletion): the ablation builds
(`races_test_noheal` / `races_test_nosleepfix`) and their shim-redundancy pins in
`asyncify/asyncify-races.spec.ts` pinned a runtime that no longer exists — the
scheduler shim (`scripts/common/shims/asyncify-scheduler.js`) is the only runtime
and subsumes the handleSleep save/restore; the fiber trampoline self-heal (§3c)
remains injected unconditionally. The green battery runs every scenario against
the scheduler glue.
**Resolved at doc 20 D-1, then mooted by the JSPI migration (2026-08)**: the
ablation builds (`races_test_noheal` / `races_test_nosleepfix`) and their
shim-redundancy pins (in the since-deleted `asyncify/asyncify-races.spec.ts`)
pinned a runtime that no longer exists, and the asyncify scheduler shim they
were measured against retired with the backend. The semantic race battery
lives on in `jspi/suspend-races.spec.ts` against the JSPI runtime.
## Collab e2e — legacy vs v2 bundles, and repro markers

View file

@ -1,9 +1,9 @@
# Testing rules
Determinism rules for the Playwright specs (`tests/e2e`, `tests/kicad`, `tests/web`).
Determinism rules for the Playwright specs (`tests/e2e`, `tests/kicad`, `tests/jspi`, `tests/web`).
Enforced by `npm run lint:determinism` (`tools/lint-determinism.ts`, gating in CI). Run specs
from `tests/` via `npm run test:e2e` (the full CI project set: wx-chromium, kicad-firefox,
kicad-chromium, asyncify-firefox, coroutine-firefox) or `npm run test:kicad` (kicad-firefox
kicad-chromium, jspi-firefox, coroutine-firefox) or `npm run test:kicad` (kicad-firefox
only) — not playwright directly. One spec on one engine:
`npx playwright test --project=kicad-firefox kicad/pcbnew.spec.ts`.

View file

@ -1,5 +1,7 @@
# wxWidgets WASM Test Status
> Mechanism note: everything below predates the 2026-08 JSPI migration; "Asyncify" rows describe the retired backend.
Last updated: 2025-12-04
## Test Summary

View file

@ -57,14 +57,14 @@ CXXFLAGS += -MMD -MP
# whole Asyncify/binaryen post-link pipeline are retired).
EH_FLAGS = -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1
# Promising entry exports: every JS->wasm entry that can transitively suspend
# (census of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the Sched*/abandon
# probes never suspend and stay plain). pcbjam_libctx_entry is libcontext's
# (census of EMSCRIPTEN_KEEPALIVE in wxwidgets/src/wasm; the non-suspending
# wx_dispatch_abandon probe stays 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,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
# wins over the one inside JSPI_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
@ -73,12 +73,12 @@ JSPI_SHIM = $(abspath ../../scripts/common/shims/jspi-scheduler.js)
# through it; without forced inclusion the reference resolves to emscripten's
# throwing stub and the FIRST throwing wx handler aborts the whole runtime
# ("native code called abort()" -> pthread mutex deadlock storm).
ASYNC_LDFLAGS = -sJSPI \
JSPI_LDFLAGS = -sJSPI \
-sJSPI_EXPORTS=$(WX_JSPI_EXPORTS) \
-s "EXPORTED_RUNTIME_METHODS=['HEAPU8','HEAP8','HEAP32','ccall','stackSave','stackRestore']" \
-sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE='$$stringToNewUTF8' \
--pre-js $(JSPI_SHIM)
ASYNC_CORO_LDFLAGS = $(ASYNC_LDFLAGS)
JSPI_CORO_LDFLAGS = $(JSPI_LDFLAGS)
CXXFLAGS += $(EH_FLAGS)
# Base Emscripten flags (for all apps)
@ -86,7 +86,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 \
$(ASYNC_LDFLAGS)
$(JSPI_LDFLAGS)
# LDFLAGS for non-GL apps (standalone tests)
LDFLAGS_NOGL = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
@ -116,9 +116,9 @@ LDFLAGS_PTHREAD = $(DEBUG_LDFLAGS) $(BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE_STRICT=0 \
$(WX_LDFLAGS_NOGL)
# Coroutine harness flags - mirror KiCad's fiber-related runtime needs
# Coroutine harness flags - mirror KiCad's coroutine runtime needs
COROUTINE_BASE_LDFLAGS = $(EH_FLAGS) -sALLOW_MEMORY_GROWTH -sERROR_ON_UNDEFINED_SYMBOLS=0 \
$(ASYNC_CORO_LDFLAGS)
$(JSPI_CORO_LDFLAGS)
LDFLAGS_COROUTINE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) $(WX_LDFLAGS_NOGL)
# The suspend-races harness must match PRODUCTION suspension semantics: the KiCad
@ -129,7 +129,7 @@ LDFLAGS_RACES = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -sASSERTIONS=0 $(WX_L
# LDFLAGS for the raytracer thread-deadlock repro. Same pthread + pool config as
# LDFLAGS_PTHREAD, but built on COROUTINE_BASE_LDFLAGS for the
# stack KiCad actually ships (emscripten_sleep needs Asyncify to yield).
# stack KiCad actually ships (emscripten_sleep suspends via JSPI).
LDFLAGS_RAYTRACE = $(DEBUG_LDFLAGS) $(COROUTINE_BASE_LDFLAGS) -pthread \
-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' \
-sPTHREAD_POOL_SIZE_STRICT=0 \
@ -148,7 +148,7 @@ 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 link (ASYNC_LDFLAGS): apps whose
# The scheduler shim is a pre-js on every link (JSPI_LDFLAGS): apps whose
# objects are up to date must still relink when it changes.
JS_FILES += $(JSPI_SHIM)
HTML = $(TOOLS_ROOT)/template.html
@ -677,7 +677,7 @@ all: $(TP_REAL)/threadpool_real_test.html
# On-demand non-warm Worker test (Phase 2). The real pool (compiled-in thread_pool.cpp)
# consumes the pre-warmed Workers, then raw fly-threads force on-demand creation;
# wasm/shims/nanosleep_yield.c (a strong nanosleep override; -Wl,--wrap crashes wasm-ld)
# makes the main-thread sleep_for join Asyncify-yield so the on-demand Workers boot.
# makes the main-thread sleep_for join yield (a JSPI suspension) so the on-demand Workers boot.
# Reuses threadpool-real's pool stubs.
OD = $(S)/pthread-ondemand
OD_INC = -std=c++20 \
@ -720,7 +720,7 @@ async-preload: $(S)/async-preload/async_preload_test.html
all: $(S)/async-preload/async_preload_test.html
# A raytracer worker-join run inside a wx modal pump. The pump dispatches the work via
# ProcessEvents (ccall async:true) at Asyncify state==Normal, so both joins complete:
# ProcessEvents (ccall async:true) between suspensions, so both joins complete:
# m=0 sleep_for busy-wait, m=1 emscripten_sleep yield.
$(S)/raytrace-modal/raytrace_modal_test.o: $(S)/raytrace-modal/raytrace_modal_test.cpp
$(CXX) -c $(CXXFLAGS) -pthread $< -o $@
@ -750,8 +750,9 @@ $(S)/coroutine/libcontext.o: $(KICAD_ROOT)/thirdparty/libcontext/libcontext.cpp
$(S)/coroutine/coroutine_test.html: $(S)/coroutine/coroutine_test.o $(S)/coroutine/libcontext.o $(WX_CORE_LIB) $(JS_FILES)
$(CXX) $(filter %.o %.a,$^) $(LDFLAGS_COROUTINE) --pre-js $(JS) --shell-file $(HTML) -o $@
# Nested coroutine+modal interaction harness - reproduces Asyncify rewind corruption
# when fiber swaps happen inside a wxDialog::ShowModal event loop (Issue #9153).
# Nested coroutine+modal interaction harness - historically reproduced asyncify rewind
# corruption when coroutine swaps happened inside a wxDialog::ShowModal event loop
# (Issue #9153); now pins the same topologies on the JSPI runtime.
$(S)/coroutine-nested/nested_test.o: $(S)/coroutine-nested/nested_test.cpp $(S)/coroutine/kicad_coroutine_harness.h
$(CXX) -c $(CXXFLAGS) -I$(KICAD_ROOT)/thirdparty/libcontext -I$(S)/coroutine $< -o $@
@ -851,7 +852,7 @@ clean:
.PHONY: all clean menu contextmenu scrollbar clipboard filedialog layout aui toolbar grid dialog timer tree dataview htmlwin stc print dnd propgrid pickers collapsible listctrl infobar dataviewvirtual auinotebook wizard gridedit calendar gridrenderers printpreview bitmapbuttons specialized validators ownerdrawn popup xml wasmedge fontenum textdecor bitmask regions maximize earlysize selectheight uipolish threadpool logerror retinascale coroutine coroutine-nested asyncify-races notebook radiogroups collapse-relayout
# === Coroutine pthread variant — reproduces the KiCad Asyncify-fiber x pthreads crash ===
# === Coroutine pthread variant — historically reproduced the KiCad coroutine x pthreads crash ===
# Same modal-free harness as `coroutine`, but compiled/linked with pthreads to match
# KiCad's runtime (-pthread + PTHREAD_POOL_SIZE). The single-threaded `coroutine` build
# passes in system Chrome; KiCad (pthreads) crashes. This isolates that difference.

View file

@ -1,34 +1,36 @@
// races_test.cpp - Asyncify race-condition red-green harness.
// races_test.cpp - suspension race-condition red-green harness.
//
// Reproduces the KiCad-WASM Asyncify failure modes deterministically so the shim
// fixes stay pinned by tests (see features/async/ research dossier):
// Reproduces the KiCad-WASM suspension failure modes deterministically so the
// scheduler-shim fixes stay pinned by tests (see features/async/ research
// dossier). The scenarios were decoded on the retired asyncify runtime; the
// topologies they stage are runtime-agnostic and now pin the JSPI scheduler's
// turnstile/window contracts:
//
// - The app performs a fiber swap during OnInit BEFORE the main loop parks.
// This is the load-bearing topology detail: it means main() is resumed via
// Fibers.trampoline() when wxGUIEventLoop::DoRun() executes the
// emscripten_set_main_loop(...,1) `throw "unwind"` park, so the throw tears
// through the live trampoline do/while. Without the trampoline self-heal
// shim that wedges Fibers.trampolineRunning=true forever and the FIRST
// post-park fiber swap hangs (the KiCad schematic/PCB tool hang).
// - The app performs a coroutine swap during OnInit BEFORE the main loop
// parks. This is the load-bearing topology detail: the park then lands on
// a stack that already completed a suspension chain (the startup shape
// that historically wedged the asyncify fiber trampoline — the KiCad
// schematic/PCB tool hang).
// coroutine-nested/nested_test.cpp does NOT do a pre-park swap, which is
// why it never reproduced that hang.
//
// - EM_ASYNC_JS sleeps (modal dialogs, token waits) overlapping fiber swaps
// reproduce the single-slot Asyncify.currData clobber family (the KiCad
// clipboard "index out of bounds" crash).
// - EM_ASYNC_JS sleeps (modal dialogs, token waits) overlapping coroutine
// swaps — historically the single-slot Asyncify.currData clobber family
// (the KiCad clipboard "index out of bounds" crash), now the concurrent
// multi-suspension bookkeeping the scheduler's turnstile serializes.
//
// URL parameters:
// ?only=<scenario> run a single scenario instead of the default battery
// (used for scenarios that intentionally wedge/crash)
// ?mode=sleep-park make the LAST pre-park suspension a sleep instead of a
// fiber swap: the park throw then escapes through the
// sleep's wakeUp promise reaction as an unhandled
// "unwind" rejection (scenario unwind_through_promise)
// coroutine swap, so the park arrives out of a sleep
// resume rather than a swap (scenario
// unwind_through_promise)
//
// Output protocol (polled by tests/asyncify/asyncify-races.spec.ts):
// Output protocol (polled by tests/jspi/suspend-races.spec.ts):
// [ASYNCIFY_RACES] CASE <name>
// [ASYNCIFY_RACES] PASS <name> / FAIL <name> :: <detail>
// [ASYNCIFY_RACES] WATCHDOG <name> state=.. currData=.. trampolineRunning=..
// [ASYNCIFY_RACES] WATCHDOG <name> windowLive=.. resumeReady=..
// [ASYNCIFY_RACES] SUMMARY total=N passed=N failed=N
#include "wx/wx.h"
@ -110,7 +112,7 @@ EM_ASYNC_JS( int, races_await_token, ( int aToken ), {
// 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' )
if( S )
return await S.promiseYield( p, 'races-token' );
return await p;
} );
@ -128,7 +130,7 @@ 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' ) {
if( S ) {
await S.sleepYield( aMs ); // turnstile-routed (see races_await_token)
return 1;
}
@ -152,19 +154,19 @@ EM_JS( void, races_schedule_ccall, ( const char* aFunc, int aDelayMs ), {
}, aDelayMs );
} );
// Watchdog: if the scenario hasn't marked itself done in aMs, dump the Asyncify
// state and emit a FAIL line. JS-side, so it fires even when C++ is wedged.
// Watchdog: if the scenario hasn't marked itself done in aMs, dump the
// scheduler state and emit a FAIL line. JS-side, so it fires even when C++ is
// wedged.
EM_JS( void, races_arm_watchdog, ( const char* aName, int aMs ), {
var name = UTF8ToString( aName );
Module.__racesDone = Module.__racesDone || {};
setTimeout( function() {
if( !Module.__racesDone[name] ) {
var st = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'n/a';
var cd = ( typeof Asyncify !== 'undefined' ) ? ( Asyncify.currData || 0 ) : 'n/a';
var tr = ( typeof Fibers !== 'undefined' ) ? Fibers.trampolineRunning : 'n/a';
var nf = ( typeof Fibers !== 'undefined' ) ? Fibers.nextFiber : 'n/a';
console.log( '[ASYNCIFY_RACES] WATCHDOG ' + name + ' state=' + st + ' currData=' + cd
+ ' trampolineRunning=' + tr + ' nextFiber=' + nf );
var S = globalThis.__wxScheduler;
var wl = S ? !!S._windowLive : 'n/a';
var rr = ( S && S._resumeReady ) ? S._resumeReady.length : 'n/a';
console.log( '[ASYNCIFY_RACES] WATCHDOG ' + name + ' windowLive=' + wl
+ ' resumeReady=' + rr );
console.log( '[ASYNCIFY_RACES] FAIL ' + name + ' :: watchdog timeout (suspension never completed)' );
}
}, aMs );
@ -175,34 +177,28 @@ EM_JS( void, races_mark_done, ( const char* aName ), {
Module.__racesDone[UTF8ToString( aName )] = true;
} );
// Quiescence invariant sampled from C++ between scenarios.
//
// Two things are deliberately NOT checked:
// * Fibers.trampolineRunning — this can run on a stack itself resumed via
// Fibers.trampoline(), in which case the guard is legitimately true.
// * Asyncify.currData — under native wasm-EH the top-level event loop is a
// per-frame-yield while-loop (wxWasmYieldToBrowser, an EM_ASYNC_JS rAF
// suspend that re-arms every frame; see wxwidgets/src/wasm/evtloop.cpp). So
// the main stack is asyncify-suspended between frames and currData is
// legitimately churning — it is non-zero while a frame yield is pending, and
// can momentarily hold a freed-but-not-yet-nulled buffer right after a
// concurrent suspension resumes. That is a transient bookkeeping value, NOT a
// leak (the buffers are _malloc/_free'd each frame — addresses are reused),
// so requiring currData==0 here is a stale legacy assumption from the old
// throw-to-park loop. A genuinely stuck suspension is caught by state != 0
// (Suspending/Rewinding never clearing) and by the scenario watchdogs.
// What's left is the real invariant: the asyncify machine is back to Normal and
// no fiber is queued.
// Quiescence invariant sampled from C++ between scenarios, keyed to the JSPI
// scheduler (globalThis.__wxScheduler): between scenarios no resume window may
// still be live (_windowLive) and no resume may sit queued (_resumeReady).
// The main loop's own per-frame suspension (wxWasmYieldToBrowser) does not
// count against either — its window closes when the frame yield's suspension
// completes, before the next C++ code runs. A genuinely stuck suspension is
// additionally caught by the scenario watchdogs. Without a scheduler (the
// raw-await harness builds) there is no shared state to wedge — quiescent by
// construction.
EM_JS( int, races_quiescent, (), {
try {
// 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;
var S = globalThis.__wxScheduler;
if( !S )
return 1;
// NOTE: _windowLive is NOT part of quiescence here — this probe runs
// from INSIDE a tracked activation, whose own window is live by
// definition. A wedge manifests as backlog: queued-but-unarmed
// resumes or a stuck mutator FIFO (the 2s force-clear watchdog keys
// on the same signal).
var rrOk = !S._resumeReady || S._resumeReady.length === 0;
var mqOk = !S.mutatorQueue || S.mutatorQueue.length === 0;
return ( rrOk && mqOk ) ? 1 : 0;
} catch( e ) {
return 0;
}
@ -211,12 +207,11 @@ EM_JS( int, races_quiescent, (), {
EM_JS( void, races_log_state, ( const char* aTag ), {
try {
var tag = UTF8ToString( aTag );
var st = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'n/a';
var cd = ( typeof Asyncify !== 'undefined' ) ? ( Asyncify.currData || 0 ) : 'n/a';
var tr = ( typeof Fibers !== 'undefined' ) ? Fibers.trampolineRunning : 'n/a';
var nf = ( typeof Fibers !== 'undefined' ) ? Fibers.nextFiber : 'n/a';
console.log( '[ASYNCIFY_RACES] STATE ' + tag + ' state=' + st + ' currData=' + cd
+ ' trampolineRunning=' + tr + ' nextFiber=' + nf );
var S = globalThis.__wxScheduler;
var wl = S ? !!S._windowLive : 'n/a';
var rr = ( S && S._resumeReady ) ? S._resumeReady.length : 'n/a';
console.log( '[ASYNCIFY_RACES] STATE ' + tag + ' windowLive=' + wl
+ ' resumeReady=' + rr );
} catch( e ) {}
} );
@ -380,8 +375,8 @@ private:
{
#ifdef __EMSCRIPTEN__
aCtx.Expect( races_quiescent() == 1,
"asyncify machine not quiescent " + aWhere
+ " (state/currData/trampolineRunning/nextFiber - see STATE log)" );
"scheduler not quiescent " + aWhere
+ " (windowLive/resumeReady - see STATE log)" );
if( races_quiescent() != 1 )
races_log_state( ( "non-quiescent-" + aWhere ).c_str() );
@ -870,10 +865,10 @@ public:
} );
#endif
// THE LOAD-BEARING TOPOLOGY: complete a fiber swap cycle during OnInit.
// From here on, main() runs inside Fibers.trampoline()'s do/while; the
// upcoming emscripten_set_main_loop(...,1) park throw will tear through
// that live frame (exactly what KiCad's startup tool burst does).
// THE LOAD-BEARING TOPOLOGY: complete a coroutine swap cycle during
// OnInit, before the main loop parks (exactly what KiCad's startup
// tool burst does). Historically this put main() inside the asyncify
// fiber trampoline when the park throw tore through it.
{
TestCoroutine co( []( TestCoroutine& self ) { self.Yield( 1 ); } );
co.Call( 1 );
@ -884,9 +879,9 @@ public:
#ifdef __EMSCRIPTEN__
if( sleepPark )
{
// Make the LAST pre-park suspension a sleep: main is then resumed
// from the sleep's wakeUp (trampoline frame already closed), and the
// park throw escapes through the wakeUp promise reaction instead.
// Make the LAST pre-park suspension a sleep: main then reaches the
// park out of a sleep resume rather than a swap (historically the
// park throw escaped through the sleep's wakeUp promise reaction).
races_sleep_ms( 30 );
LogLine( "[ASYNCIFY_RACES] PRE-PARK-SLEEP done (sleep-park mode)" );
}

View file

@ -92,33 +92,6 @@ void LogLine( const std::string& aLine )
}
void LogAsyncifyState( const char* aTag )
{
#ifdef __EMSCRIPTEN__
EM_ASM( {
try {
var tag = UTF8ToString( $0 );
var state = ( typeof Asyncify !== 'undefined' ) ? Asyncify.state : 'N/A';
var stackLen = ( typeof Asyncify !== 'undefined' && Asyncify.exportCallStack )
? Asyncify.exportCallStack.length : 'N/A';
var currData = ( typeof Asyncify !== 'undefined' && Asyncify.currData )
? Asyncify.currData : 'null';
var tableLen = ( typeof wasmTable !== 'undefined' && wasmTable )
? wasmTable.length : 'N/A';
console.log( '[COROUTINE_TEST] ASYNCIFY ' + tag +
' state=' + state +
' stackLen=' + stackLen +
' currData=' + currData +
' tableLen=' + tableLen );
} catch (e) {
console.log( '[COROUTINE_TEST] ASYNCIFY ' + UTF8ToString( $0 ) + ' error=' + e );
}
}, aTag );
#else
(void) aTag;
#endif
}
} // namespace
@ -155,7 +128,6 @@ private:
if( aEvent.IsShown() )
{
LogLine( "[COROUTINE_TEST] MODAL-SHOW " + m_tag );
LogAsyncifyState( ( "modal-shown-" + m_tag ).c_str() );
if( !m_externalClose )
m_timer.StartOnce( m_delayMs );
@ -193,7 +165,7 @@ public:
panel,
wxID_ANY,
"Tests the interaction between wxDialog::ShowModal (EM_ASYNC_JS / startModal) and\n"
"libcontext fibers (emscripten_fiber_swap). Reproduces nested Asyncify crashes.\n"
"libcontext coroutines (JSPI). Historically reproduced nested asyncify crashes.\n"
"The suite runs automatically on startup and reports PASS/FAIL per scenario."
);
sizer->Add( description, 0, wxEXPAND | wxALL, 8 );
@ -276,7 +248,6 @@ private:
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "A-pre-modal" );
{
AutoClosingDialog dlg( this, "baselineA", 50 );
@ -284,7 +255,6 @@ private:
ctx.Expect( result == wxID_OK, "modal should return wxID_OK" );
}
LogAsyncifyState( "A-post-modal" );
FinalizeCase( caseName, std::move( ctx ) );
}
@ -296,7 +266,6 @@ private:
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "B-pre-fiber" );
TestCoroutine coroutine( []( TestCoroutine& self ) {
self.Yield( 42 );
@ -309,7 +278,6 @@ private:
running = coroutine.Resume( 2 );
ctx.Expect( !running, "fiber should finish on resume" );
LogAsyncifyState( "B-post-fiber" );
FinalizeCase( caseName, std::move( ctx ) );
}
@ -323,7 +291,6 @@ private:
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S3-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S3", 0 );
dlg->UseExternalClose();
@ -337,7 +304,6 @@ private:
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S3-post-modal" );
m_currentCtx->Expect( result == wxID_OK,
"modal should return wxID_OK (actual: " + std::to_string( result ) + ")" );
@ -350,7 +316,6 @@ private:
void RunScenario3FiberWork()
{
LogAsyncifyState( "S3-timer-enter" );
{
TestCoroutine co( []( TestCoroutine& self ) {
@ -361,16 +326,13 @@ private:
m_currentCtx->Expect( running, "S3: fiber should yield on first call" );
m_currentCtx->Expect( co.LastReturnValue() == 100, "S3: yield value should be 100" );
LogAsyncifyState( "S3-after-call" );
running = co.Resume( 2 );
m_currentCtx->Expect( !running, "S3: fiber should finish on resume" );
LogAsyncifyState( "S3-after-resume" );
}
// Fiber destroyed here
LogAsyncifyState( "S3-after-destroy" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
@ -385,7 +347,6 @@ private:
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S4-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S4", 0 );
dlg->UseExternalClose();
@ -397,7 +358,6 @@ private:
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S4-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S4: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
@ -408,7 +368,6 @@ private:
void RunScenario4MultiSwap()
{
LogAsyncifyState( "S4-timer-enter" );
{
TestCoroutine co( []( TestCoroutine& self ) {
@ -433,7 +392,6 @@ private:
m_currentCtx->Expect( !running, "S4: fiber should finish" );
}
LogAsyncifyState( "S4-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
@ -449,7 +407,6 @@ private:
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S5-pre-modal" );
m_s5Fiber = std::make_unique<TestCoroutine>( []( TestCoroutine& self ) {
self.Yield( 501 );
@ -466,7 +423,6 @@ private:
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S5-post-modal" );
// After modal, resume the fiber
if( m_s5Fiber && m_s5Fiber->Running() )
@ -489,13 +445,11 @@ private:
void RunScenario5Yield()
{
LogAsyncifyState( "S5-timer-enter" );
bool running = m_s5Fiber->Call( 1 );
m_currentCtx->Expect( running, "S5: fiber should yield in modal" );
m_currentCtx->Expect( m_s5Fiber->LastReturnValue() == 501, "S5: yield 501" );
LogAsyncifyState( "S5-fiber-yielded" );
// Do NOT resume; leave the fiber suspended across the modal close.
@ -504,7 +458,8 @@ private:
}
// --- Case 6: fiber_deep_yield_loop_inside_modal ---
// Deep recursive stack with many yields inside a modal. Stresses asyncify buffers.
// Deep recursive stack with many yields inside a modal. Stresses the stack-capture
// machinery under deep frames.
void StartCase_FiberDeepYieldLoop()
{
const std::string caseName = "fiber_deep_yield_loop_inside_modal";
@ -512,7 +467,6 @@ private:
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S6-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S6", 0 );
dlg->UseExternalClose();
@ -524,7 +478,6 @@ private:
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S6-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S6: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
@ -535,7 +488,6 @@ private:
void RunScenario6DeepYield()
{
LogAsyncifyState( "S6-timer-enter" );
{
TestCoroutine co( [ctx = m_currentCtx.get()]( TestCoroutine& self ) {
@ -571,7 +523,6 @@ private:
m_currentCtx->Expect( !running, "S6: deep fiber should finish" );
}
LogAsyncifyState( "S6-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );
@ -585,7 +536,6 @@ private:
Log( wxString::Format( "[COROUTINE_TEST] CASE %s", caseName ) );
CaseContext ctx;
LogAsyncifyState( "S7-pre-modal-A" );
// Modal A (auto-close)
{
@ -594,7 +544,6 @@ private:
ctx.Expect( resultA == wxID_OK, "S7: modal A should return wxID_OK" );
}
LogAsyncifyState( "S7-post-modal-A" );
// Fiber work between modals
{
@ -609,7 +558,6 @@ private:
ctx.Expect( !running, "S7: inter-modal fiber should finish" );
}
LogAsyncifyState( "S7-mid" );
// Modal B (auto-close)
{
@ -618,7 +566,6 @@ private:
ctx.Expect( resultB == wxID_OK, "S7: modal B should return wxID_OK" );
}
LogAsyncifyState( "S7-post-modal-B" );
FinalizeCase( "modal_fiber_modal_sequence", std::move( ctx ) );
@ -634,7 +581,6 @@ private:
m_currentCaseName = caseName;
m_currentCtx = std::make_unique<CaseContext>();
LogAsyncifyState( "S8-pre-modal" );
auto dlg = std::make_unique<AutoClosingDialog>( this, "S8", 0 );
dlg->UseExternalClose();
@ -646,7 +592,6 @@ private:
int result = dlg->ShowModal();
m_activeDialog = nullptr;
LogAsyncifyState( "S8-post-modal" );
m_currentCtx->Expect( result == wxID_OK, "S8: modal should return wxID_OK" );
FinalizeCase( caseName, std::move( *m_currentCtx ) );
@ -658,7 +603,6 @@ private:
void RunScenario8NestedFibers()
{
LogAsyncifyState( "S8-timer-enter" );
{
auto ctx = m_currentCtx.get();
@ -692,7 +636,6 @@ private:
"S8: unexpected sequence: " + JoinVector( sequence ) );
}
LogAsyncifyState( "S8-after-fiber" );
if( m_activeDialog )
m_activeDialog->EndModalExternal( wxID_OK );

View file

@ -1,5 +1,6 @@
// jspi-coroutine — validates the JSPI libcontext backend (PCBJAM_JSPI) through
// the EXACT protocol coroutine.h drives it with, without linking wx or KiCad.
// jspi-coroutine — validates the JSPI libcontext backend (the __EMSCRIPTEN__
// build of kicad/thirdparty/libcontext) 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
@ -109,7 +110,7 @@ struct MiniCoro
cor->m_body( *cor );
cor->m_running = false;
// the 3-line JSPI hook coroutine.h carries under PCBJAM_JSPI
// the completion hook coroutine.h carries under __EMSCRIPTEN__
libcontext::finish_fcontext( cor->m_callee.ctx );
cor->jumpOut();

View file

@ -5,8 +5,8 @@ import { test, expect } from './utils/fixtures';
// KiCad-10 PCBJAM preload shape, with NO KiCad source. Proves native wasm-EH makes the worker-side
// parse-throw safe, and that the proxy round-trip / lazy join / modal-reentrancy all work.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// WebKit skipped for pthread apps (COEP).
// Named coroutine-*: the merged config's coroutine-* projects select by testMatch
// /coroutine.*\.spec\.ts$/ — keep these filenames. WebKit skipped for pthread apps (COEP).
const APP = '/standalone/async-preload/async_preload_test.html';
@ -22,7 +22,7 @@ async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string
await expect.poll( () => testLogger.consoleLogs.some( l => l.includes( needle ) ), { timeout } ).toBe( true );
}
// Fatal native-EH / Asyncify failures we must NOT see.
// Fatal native-EH / suspension-runtime failures we must NOT see.
function fatal( testLogger: { errors: string[] } ) {
return testLogger.errors.filter( e => !e.includes( 'favicon' )
&& /invalid state|table index out of bounds|aborted|unreachable|func is not a function/i.test( e ) );

View file

@ -71,7 +71,7 @@ test.describe('Nested Coroutine+Modal Tests', () => {
expect(failLogs).toHaveLength(0);
expect(passLogs).toHaveLength(EXPECTED_CASES.length);
// Critical: catch the nested-asyncify crash
// Critical: catch the historic nested-suspension crash signature
const indexOobErrors = testLogger.errors.filter((e) =>
e.toLowerCase().includes('index out of bounds')
);

View file

@ -6,9 +6,10 @@ import { test, expect } from './utils/fixtures';
// consumes ALL the pre-warmed Workers at construction; raw fly-threads beyond that count
// must then be created ON DEMAND, whose 'loaded'->'run' handshake needs the main event loop.
// The fix is wasm/shims/nanosleep_yield.c (a strong nanosleep override): the main-thread sleep_for
// join Asyncify-yields so the loop services the handshake and the on-demand Workers boot.
// join suspends to the browser loop so it services the handshake and the on-demand Workers boot.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// Named coroutine-*: the merged config's coroutine-* projects select by testMatch
// /coroutine.*\.spec\.ts$/ — keep these filenames.
// WebKit is skipped for pthread apps (COEP worker-load limitation; doc 10 §2a).
const APP = '/standalone/pthread-ondemand/pthread_ondemand_test.html';

View file

@ -12,7 +12,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (i.e. the main-context rewind survived)',
message: 'should reach [REPRO] DONE (i.e. the main-context suspension chain survived)',
})
.toBe(true);
@ -31,7 +31,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (main rewind through the dynCall chain survived)',
message: 'should reach [REPRO] DONE (the suspension chain through the dynCall boundaries survived)',
})
.toBe(true);
@ -60,9 +60,13 @@ test.describe('Coroutine pthread main() reproduction', () => {
});
// Probe #4: coroutine activated via an embind (--bind) call.
// Known crash repro: the embind-dispatched fiber currently crashes the renderer
// before reaching DONE. Marked as an expected failure until the coroutine/asyncify
// rewind through the embind dispatch is fixed.
// Still an expected failure, for the JSPI-era reason (re-probed 2026-08-14):
// a PLAIN embind invoker is not a promising entry, so the coroutine's first
// suspension inside it cannot suspend the activation — the page never
// reaches DONE. The shipped app never uses this shape: suspending embind
// entries are either emscripten::async() + parker-wrapped (kicadOpenFile)
// or raw KEEPALIVE promising exports. Un-fail only if embind ever grows a
// true one-shot promising registration.
test.fail('embind-activated fiber reaches DONE without renderer crash', async ({ page, testLogger }) => {
await page.goto('/standalone/coroutine-pthread/embind_repro.html');
await tryLoadApp(page, 20000).catch(() => {}); // eslint-disable-line -- best-effort load in a pthread/coroutine runtime probe
@ -70,7 +74,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (rewind through the embind dispatch survived)',
message: 'should reach [REPRO] DONE (the suspension chain through the embind dispatch survived)',
})
.toBe(true);
@ -89,7 +93,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (rewind through the main-loop dynCall_v survived)',
message: 'should reach [REPRO] DONE (the suspension chain through the main-loop dynCall_v survived)',
})
.toBe(true);
@ -107,7 +111,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (rewind of a mid-GL-frame survived)',
message: 'should reach [REPRO] DONE (the mid-GL-frame suspension chain survived)',
})
.toBe(true);
@ -125,7 +129,7 @@ test.describe('Coroutine pthread main() reproduction', () => {
await expect
.poll(() => testLogger.consoleLogs.some((l) => l.includes('[REPRO] DONE')), {
timeout: 30000,
message: 'should reach [REPRO] DONE (GL + pthreads mid-frame rewind survived)',
message: 'should reach [REPRO] DONE (GL + pthreads mid-frame suspension chain survived)',
})
.toBe(true);
});

View file

@ -2,13 +2,13 @@ import { test, expect } from './utils/fixtures';
// A raytracer-style worker-join run inside a wx modal pump. A pass is dispatched from a wxTimer that
// fires while a ShowModal() dialog is open; the modal pump runs ProcessEvents via ccall(async:true),
// so the work runs in a fresh managed Asyncify context at state == Normal. Both join styles complete
// multi-core there:
// so the work runs in a fresh suspendable entry (its own suspender, clean state). Both join styles
// complete multi-core there:
// m=0 busywait : sleep_for join; the pre-warmed pool completes it.
// m=1 yield : emscripten_sleep join; legal at state == Normal, so it suspends and resumes.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox. WebKit
// skipped for pthread apps (COEP).
// Named coroutine-*: the merged config's coroutine-* projects select by testMatch
// /coroutine.*\.spec\.ts$/ — keep these filenames. WebKit skipped for pthread apps (COEP).
const APP = '/standalone/raytrace-modal/raytrace_modal_test.html';
@ -32,7 +32,7 @@ test.describe( 'Raytracer worker-join inside a wx modal pump', () => {
await page.goto( `${APP}#m=0` );
await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=0' );
expect( workersRan( testLogger.consoleLogs ), 'multi-core inside the modal' ).toBeGreaterThan( 1 );
expect( abortErrors( testLogger ), 'no Asyncify abort' ).toHaveLength( 0 );
expect( abortErrors( testLogger ), 'no wasm abort' ).toHaveLength( 0 );
} );
// The in-modal work runs in a fresh ProcessEvents entry, so an emscripten_sleep join is a
@ -46,6 +46,6 @@ test.describe( 'Raytracer worker-join inside a wx modal pump', () => {
/\[wx-scheduler\] (force-clearing stuck window|job tick error)|\[libctx-jspi\] ghost\/refused/.test( l ) ),
'no scheduler anomaly during the in-modal join' ).toHaveLength( 0 );
expect( workersRan( testLogger.consoleLogs ), 'the yield-join completes → multi-core' ).toBeGreaterThan( 1 );
expect( abortErrors( testLogger ), 'no Asyncify abort' ).toHaveLength( 0 );
expect( abortErrors( testLogger ), 'no wasm abort' ).toHaveLength( 0 );
} );
} );

View file

@ -22,18 +22,19 @@ import { test, expect } from './utils/fixtures';
// itself and has NO connection to KiCad's render_3d_raytrace_base.cpp. It validates the
// threading MECHANISM in seconds (not the ~12-min KiCad build), not the shipped viewer.
//
// TODO(asyncify-nesting): the real KiCad 3D viewer currently ships SERIAL (single-core).
// The emscripten_sleep variants (B1/B2/m4) pass HERE but ABORT the real viewer with
// `Aborted(invalid state: 1)`: the viewer renders inside the wx modal/event-pump, which is
// already mid-Asyncify-unwind, and emscripten_sleep can't nest on that context. This
// harness runs from a clean OnInit, so it never hits that nesting — a reminder that an
// isolated repro can be faithful to the *threading* yet miss the *Asyncify context*.
// The B3/persistent-pool design (m=5) avoids emscripten_sleep entirely and was the one
// ported into KiCad — but it's currently PARKED (`git -C kicad stash`) pending research
// into whether a nestable yield (fibers / emscripten_fiber_swap / JSPI) is possible.
// TODO(raytrace-multicore): the real KiCad 3D viewer still ships SERIAL (single-core) —
// its engine toggle is inert. The old blocker was asyncify's nesting limit: the viewer
// renders inside the wx modal/event-pump and an emscripten_sleep join could not nest on
// that context (`Aborted(invalid state: 1)`) — which this clean-OnInit harness never hit,
// a reminder that an isolated repro can be faithful to the *threading* yet miss the
// *suspension context*. JSPI answered that blocking question (nested suspension is legal),
// so porting the B3/persistent-pool design (m=5, proven below) into the viewer is now a
// concrete work item rather than research. (An earlier KiCad-side port sits in a local
// `git -C kicad stash`.)
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox
// (same engines as the KiCad app), and the default config runs it in bundled Chromium.
// Named coroutine-*: the merged config's coroutine-* projects select by testMatch
// /coroutine.*\.spec\.ts$/ (real Chrome + Firefox, same engines as the KiCad app;
// the wx-chromium project also runs it in bundled Chromium) — keep these filenames.
const APP = '/standalone/raytrace-threads/raytrace_threads_test.html';
// Modest, fixed work so each pass is ~1-2s serial (enough to show a clear speedup).
@ -77,7 +78,7 @@ test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp) — must run m
`pass ${p} should complete (pool reused)` ).toBe( true );
} );
test( 'B1-local: stack-local atomics (mirrors raytracer) survive Asyncify yields', async ( { page, testLogger } ) => {
test( 'B1-local: stack-local atomics (mirrors raytracer) survive suspension yields', async ( { page, testLogger } ) => {
// The real raytracer shares stack-local atomics (threadsFinished/nextBlock) with
// its workers. This proves emscripten_sleep's unwind/rewind doesn't lose the
// workers' concurrent writes to those C-stack locals (which would hang forever).
@ -89,7 +90,8 @@ test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp) — must run m
test( 'B3: persistent pool + sleep_for busy-wait (real raytracer mechanism) → multi-core', async ( { page, testLogger } ) => {
// This is the exact mechanism ported into the raytracer: pre-alive workers, NO
// emscripten_sleep (so no Asyncify nesting), main-thread busy-wait that still
// emscripten_sleep (so no suspension nesting — the asyncify-era constraint
// that shaped it), main-thread busy-wait that still
// completes because the workers run on their own cores.
await page.goto( `${APP}#m=5&passes=3&${WORK}` );
await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=5' );

View file

@ -9,12 +9,13 @@ import { test, expect } from './utils/fixtures';
// pool's persistent pthread workers.
//
// The real pool is mode-a/b-safe by construction (persistent workers -> no on-demand spawn;
// futex busy-wait join -> no Asyncify nesting). The only native-EH risk is mode-c: a task
// that THROWS on a worker (caught by submit_task's promise wrapper ON the worker drives
// Asyncify under -fexceptions). So mode 6 is the decisive native-EH proof; modes 0-5 prove
// futex busy-wait join -> no suspension nesting). The only native-EH risk was mode-c: a task
// that THROWS on a worker (caught by submit_task's promise wrapper ON the worker, which
// drove asyncify under -fexceptions). So mode 6 is the decisive native-EH proof; modes 0-5 prove
// real multi-core (workersRan>1) across the API surface. Green => we can drop the shim.
//
// Named coroutine-* so playwright-coroutine.config.ts runs it in real Chrome + Firefox.
// Named coroutine-*: the merged config's coroutine-* projects select by testMatch
// /coroutine.*\.spec\.ts$/ — keep these filenames.
// WebKit is skipped for pthread apps (COEP worker-load limitation; doc 10 §2a).
const APP = '/standalone/threadpool-real/threadpool_real_test.html';
@ -53,9 +54,10 @@ test.describe( 'Real BS::thread_pool (GetKiCadThreadPool) — multi-core under n
}
// mode-c: a task throws ON a worker; submit_task's promise wrapper catches it on the
// worker (drives Asyncify under -fexceptions -> "func is not a function" crash) and
// rethrows on main. Native wasm-EH decouples exceptions from Asyncify, so this must
// complete cleanly. (Red under JS-EH, green under native-EH — the contrast IS the proof.)
// worker (under -fexceptions this drove asyncify -> "func is not a function" crash) and
// rethrows on main. Native wasm-EH decouples exceptions from the suspension machinery,
// so this must complete cleanly. (Red under JS-EH, green under native-EH — the contrast
// IS the proof.)
test( 'mode 6: throw on a worker is safe under native-EH and rethrows on main', async ( { page, testLogger } ) => {
await page.goto( `${APP}#m=6` );
// A worker throw is a mode-c crash under JS-EH and only safe under native wasm-EH, so this

View file

@ -246,7 +246,7 @@ test.describe('Modal dialog border + drag (pcbjam #22)', () => {
// Grab the bottom-right corner and grow the dialog in small steps, sampling the
// modal canvas immediately after each move. A resize legitimately reassigns
// canvas.width/height (clears it); inside the modal's Asyncify pump the repaint
// canvas.width/height (clears it); inside the modal's suspended pump the repaint
// that should refill it is deferred until the next input event, so with the bug
// the canvas stays transparent and the black .window div shows through.
const startX = hbox!.x + hbox!.width / 2;

View file

@ -1841,7 +1841,7 @@ const SHOT_OPTS = { scale: 'css', animations: 'disabled', caret: 'hide' } as con
* 1. Fast rAF convergence hash every animation frame until `stableFrames` are identical (~48ms):
* cheap, catches high-frequency motion (animations).
* 2. Wide confirmation then re-hash `confirmFrames` times, each `interval` ms apart (~500ms), so a
* SLOW async repaint (e.g. a file list arriving after an asyncify readdir) that a few 16ms frames
* SLOW async repaint (e.g. a file list arriving after a suspended readdir) that a few 16ms frames
* would sail past forces a reset back to phase 1.
* Resolves once both phases pass, or when `timeout` elapses genuinely-animating states (timers,
* mid-slide) never converge and are captured at the deadline, exactly as the old raw screenshots did.

View file

@ -8,7 +8,7 @@ import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
// 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').
// asyncify-era Fibers.* probes retired with that backend.
//
// Retired asyncify-mechanism gates, deliberately NOT ported: the
// Asyncify.currData single-writer tripwire (N1) and the deferred-wake books

View file

@ -92,14 +92,14 @@ test.describe('3D viewer from pcbnew', () => {
const aborts = allLines.filter((l) => l.includes('Aborted('));
expect(aborts, `WASM aborted while opening the 3D viewer:\n${aborts.join('\n\n')}`).toEqual([]);
const asyncifySignatures = [
const wasmTrapSignatures = [
'index out of bounds', 'indirect call to null', 'uncaught exception: unwind',
'invalid state', 'is not a function',
];
const asyncifyErrors = allLines.filter((l) =>
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)));
expect(asyncifyErrors,
`Asyncify corruption surfaced opening the 3D viewer:\n${asyncifyErrors.join('\n\n')}`)
const wasmTrapErrors = allLines.filter((l) =>
wasmTrapSignatures.some((sig) => l.toLowerCase().includes(sig)));
expect(wasmTrapErrors,
`wasm trap surfaced opening the 3D viewer:\n${wasmTrapErrors.join('\n\n')}`)
.toEqual([]);
// The 3D viewer stub logs this when the real viewer is NOT compiled in —

View file

@ -4,33 +4,30 @@ import { test, expect } from "./fixtures";
/**
* Collab-entry-during-load gate test + fuzz (docs/features/async/14-open-settle-gate.md).
*
* The prod trap: `kicadOpenFile` runs `OpenProjectFiles` under Asyncify; on a
* slow machine the chain parks mid-load (thread-pool futex waits), and any bare
* embind entry that walks the model during such a park (the collab seed
* snapshot, an adopt apply) can virtual-dispatch through half-mutated state and
* trap with "indirect call signature mismatch". The fix is two-layered: the
* shell defers the attach on `kicadOpenFileBusy` (open-flow.ts), and the
* The prod trap (an asyncify-era discovery; the surface is unchanged):
* `kicadOpenFile` suspends while `OpenProjectFiles` runs; on a slow machine
* the chain parks mid-load (thread-pool futex waits), and any bare embind
* entry that walks the model during such a park (the collab seed snapshot, an
* adopt apply) can virtual-dispatch through half-mutated state and trap with
* "indirect call signature mismatch". The fix is two-layered: the shell
* defers the attach on `kicadOpenFileBusy` (open-flow.ts), and the
* snapshot/apply entries themselves early-return while the open is in flight
* (open_gate.h guards).
*
* Natural in-load parks are scheduler-dependent on a fast idle machine the
* whole open runs synchronously and NO window exists so the deterministic
* test arms `kicadTestSetOpenPark`: kicadOpenFile then Asyncify-parks for a
* fixed time on entry AND after OpenProjectFiles returns (model fully loaded,
* gate still closed). Hammering the entries inside that window asserts the
* guard contract sharply:
* test arms `kicadTestSetOpenPark`: kicadOpenFile then suspends for a fixed
* time on entry AND after OpenProjectFiles returns (model fully loaded, gate
* still closed). Hammering the entries inside that window asserts the
* contract sharply (docs/features/async/17 §3b):
* - `kicadOpenFileBusy()` reads true during the parks, false after;
* - mid-load snapshots return the EMPTY delta (an unguarded build would
* return the full board deterministic red);
* - mid-load applies are DROPPED (the probe segment must not move);
* - mid-load applies are QUEUED by the scheduler's embind lane and
* DELIVERED in order after settle (legacy glue DROPPED them; that drop
* contract retired with it);
* - after settle the entries work normally (guard released).
*
* VARIANT CONTRACT (docs/features/async/17 §3b): on scheduler glue the
* shim's embind lane queues busy-window mutators and delivers them after
* settle, so the "applies are DROPPED" assertions flip to "applies are
* DELIVERED in order" assertSettledContract branches on the lane's
* presence. The busy-window and release assertions hold for both variants.
*
* The second test is the scheduler-dependent stress fuzz (spinning-worker CPU
* starvation to force real futex-wait parks, hammering throughout the load).
* It is skipped unless PCBJAM_FUZZ_STRESS=1: engagement of the window is not
@ -39,7 +36,6 @@ import { test, expect } from "./fixtures";
*/
const SEG_TARGET = "fa220000-0000-0000-0000-00000000cafe"; // apply probe
const PROBE_HOME = "10000000,10000000"; // its on-disk position (IU)
/** Deterministic large board (~13k items) — a realistic snapshot/apply load. */
function bigBoard(): string {
@ -235,19 +231,15 @@ async function openAndHammer(
let iterations = 0;
let maxBusySnapshotItems = 0;
const errors: string[] = [];
// Scheduler glue QUEUES busy-window entries for post-settle delivery
// The scheduler QUEUES busy-window entries for post-settle delivery
// (doc 17 §3b) — an unbounded hammer would replay hundreds of heavy
// applies/snapshots afterwards (each Push walks connectivity across the
// fixture's 800 vias; on a debug build every via prints an assert — a
// 170k-line console flood that drowns the drain). The deterministic
// contract needs delivery + order, not volume: cap the queued calls and
// keep observing the busy window. Legacy glue keeps the full hammer
// (drop semantics make it free). Volume lives in the STRESS test.
const lane =
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
?.mutatorsWrapped ?? 0) > 0;
const maxEntryIters = lane ? 6 : Infinity;
// Every Asyncify park of the open chain hands the event loop to this
// keep observing the busy window. Volume lives in the STRESS test.
const maxEntryIters = 6;
// Every suspension of the open chain hands the event loop to this
// timer — exactly how the prod shell's collab attach interleaved.
while (performance.now() - t0 < 120000) {
if (!w.Module.kicadOpenFileBusy()) break;
@ -289,42 +281,35 @@ async function openAndHammer(
}, opts);
}
/** Post-settle asserts shared by both tests: guard dropped applies + released. */
/** Post-settle asserts shared by both tests: queued applies delivered + gate released. */
async function assertSettledContract(page: Page, stats: FuzzStats): Promise<void> {
expect(stats.settled, "kicadOpenFileBusy cleared after the load").toBe(true);
expect(stats.errors, "no traps while hammering entries mid-load").toEqual([]);
// Guard held: no mid-load snapshot ever saw the model. On scheduler glue a
// busy-window snapshot returns a Promise (typeof !== "string" — the hammer
// skips it), so this assertion holds for both variants.
// Guard held: no mid-load snapshot ever saw the model. A busy-window
// snapshot returns a Promise (typeof !== "string" — the hammer skips it),
// so a nonzero count here means a synchronous walk leaked through the gate.
expect(stats.maxBusySnapshotItems, "mid-load snapshots returned the empty delta").toBe(0);
await expect.poll(() => page.title(), { timeout: 30000 }).toMatch(/fuzz/i);
// Variant contract (docs/features/async/17 §3b). Legacy glue: the gate
// DROPPED the mid-load applies — the probe never moved. Scheduler glue
// (scheduler shim embind lane, doc 18): the same applies were QUEUED
// and DELIVERED after settle, in order — the probe sits where the hammer's
// deltas moved it. Same stimulus, the drop→deliver flip is the assertion.
const schedulerLane = await page.evaluate(
() =>
((globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } }).__wxScheduler
?.mutatorsWrapped ?? 0) > 0,
);
if (schedulerLane) {
// The hammer queued hundreds of calls (each mid-load snapshot delivers as
// a FULL board walk now, not the gate's empty delta) — wait for the
// time-boxed pump to drain the backlog before asserting final state.
await expect
.poll(
() =>
page.evaluate(
() =>
(globalThis as unknown as { __wxScheduler: { mutatorQueue: unknown[] } })
.__wxScheduler.mutatorQueue.length,
),
{ timeout: 240000, intervals: [1000] },
)
.toBe(0);
}
// Delivery contract (docs/features/async/17 §3b): the embind lane QUEUED
// the mid-load applies and delivers them after settle, in order — the probe
// sits where the hammer's deltas moved it. (Legacy glue DROPPED them and
// the probe stayed home; that drop contract retired with the flip.)
//
// The hammer queued hundreds of calls (each mid-load snapshot delivers as
// a FULL board walk now, not the gate's empty delta) — wait for the
// time-boxed pump to drain the backlog before asserting final state.
await expect
.poll(
() =>
page.evaluate(
() =>
(globalThis as unknown as { __wxScheduler: { mutatorQueue: unknown[] } })
.__wxScheduler.mutatorQueue.length,
),
{ timeout: 240000, intervals: [1000] },
)
.toBe(0);
const HAMMER_TARGET = "55000000,55000000"; // both hammer deltas move the probe here
await expect
.poll(
@ -332,7 +317,7 @@ async function assertSettledContract(page: Page, stats: FuzzStats): Promise<void
page.evaluate((id) => (window.Module as unknown as Mod).kicadCollabGetPos(id), SEG_TARGET),
{ timeout: 10000, intervals: [200] },
)
.toBe(schedulerLane ? HAMMER_TARGET : PROBE_HOME);
.toBe(HAMMER_TARGET);
// Guard released: the snapshot now walks the real, fully-loaded board…
const itemCount = await page.evaluate(
@ -376,8 +361,8 @@ test.describe("collab entries during a parked board load (open_gate)", () => {
page,
testLogger,
}) => {
// Scheduler-glue runs replay the whole hammer backlog after settle (the
// drain-wait in assertSettledContract) — budget for it on top of the load.
// The post-settle pump replays the whole hammer backlog (the drain-wait
// in assertSettledContract) — budget for it on top of the load.
test.setTimeout(420000);
await bootHarness(page);

View file

@ -6,9 +6,9 @@ import { clickMenuBarItem, clickMenuItemByText } from "../e2e/utils/element-trac
/**
* JSPI coroutine lifecycle in the REAL editor successor to
* fiber-resume-park.spec.ts (which pinned the retired Asyncify rewind guard;
* its beacon string `fiber-resume-refused` no longer exists, making its
* engagement assert vacuous).
* fiber-resume-park.spec.ts (which pinned the retired asyncify rewind guard;
* that guard's refused-resume beacon string no longer exists, which made the
* old spec's engagement assert vacuous).
*
* The prod-shaped gate for the August 2026 ownership bug: coroutine.h's
* ~CALL_CONTEXT released a BORROWED context record (the live enterer of a

View file

@ -383,8 +383,8 @@ for (const ops of [SCH_OPS, PCB_OPS]) {
// ── S8: user-save during a peer's burst (pcbnew) ─────────────────────────────
// Ctrl+S drives the FULL save flow (the real writer + the C++→JS onSave
// notification chokepoint) while remote applies land — asyncify contention
// between the save fiber and the apply fibers is exactly the surface.
// notification chokepoint) while remote applies land — suspension contention
// between the save coroutine and the apply coroutines is exactly the surface.
test.describe("drift trio scenarios — pcbnew S8 save interplay", () => {
test.describe.configure({ timeout: 900000 });

View file

@ -146,7 +146,7 @@ for (const [cfg, label, act] of S1) {
// 1. A moves the first item (the seeder's emit half — bug 01 regression
// surface: seed()'s snapshotItems registered A's listener). The hooks
// run on a fiber, so first poll A's OWN pos until the move landed
// run on a coroutine, so first poll A's OWN pos until the move landed
// (two-tab's green precondition), then compare the peers against it.
const uuids = [...cfg.fixture.matchAll(/\(uuid "([0-9a-f-]{36})"\)/g)].map((m) => m[1]!);
const before: Record<string, string> = {};
@ -333,7 +333,7 @@ for (const [cfg, label, catalog] of [
for (const step of catalog) {
await test.step(step.name, async () => {
const actor = step.actor === "A" ? trio.A : trio.B;
// The hooks commit on a fiber: settleConverged alone can pass on the
// The hooks commit on a coroutine: settleConverged alone can pass on the
// PRE-action state (all tabs still equal) and the sweep then reads
// legitimate mid-propagation state as drift. Gate on the actor's own
// save changing first, so convergence is convergence ON the edit.

View file

@ -8,7 +8,7 @@ import { test, expect } from "./fixtures";
*
* eeschema reuses the same wire contract + generic JS reconciler as pl_editor; the new
* code is the C++ adapter native SCHEMATIC_LISTENER emit + SCH_COMMIT apply, the latter
* run inside a COROUTINE so SCH_ITEM::Move has the Asyncify/fiber (tool-coroutine) context
* run inside a COROUTINE so SCH_ITEM::Move has the tool-coroutine context
* it requires. Coverage:
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
* - apply (single page): kicadCollabApply moves/removes by uuid (deferred via CallAfter
@ -182,8 +182,8 @@ test.describe("eeschema collab bridge — single page", () => {
.toBe(true);
// added: a SCH_SHAPE (rectangle). Committing a newly-constructed shape used to trap in
// SCH_COMMIT::Push's CHT_ADD (GAL view->Add of a new shape → asyncify invoke_viii
// mis-dispatch) when doApply ran off a fiber stack; doApply now runs inside a COROUTINE, so
// SCH_COMMIT::Push's CHT_ADD (GAL view->Add of a new shape → an asyncify-era invoke_viii
// mis-dispatch) when doApply ran off a bare stack; doApply now runs inside a COROUTINE, so
// the add dispatches like a native draw. stype 1 = SHAPE_T::RECTANGLE, fill 1 = NO_FILL.
await page.evaluate(
(rectId) =>
@ -224,8 +224,16 @@ test.describe("eeschema collab bridge — single page", () => {
test.describe("eeschema collab bridge — two tabs (BroadcastChannel)", () => {
// SKIP headless for the same reason as the single-page apply test (harness open=false →
// SCH_COMMIT no-ops). Verified working in the real web app.
// re-enabled 2026-08-13: passes on the JSPI build (flaked once under 2-worker trio load; green solo)
// re-enabled 2026-08-13 on the JSPI build; chromium is solid, Firefox is
// ~50% flaky even solo (the observer tab's move sometimes never lands
// within 15s — same shape as the FF FootprintEnumerate slowness; suspected
// slow-wasm-tier upstream #42199). Gated to chromium 2026-08-14; the
// pcbnew-collab twin covers both engines.
test("a local move propagates A→B", async ({ context, testLogger }) => {
test.skip(
test.info().project.name.includes("firefox"),
"flaky on Firefox (~50% even solo): observer move misses the 15s window — chromium covers this; pcbnew twin runs both engines",
);
const channel = `ee-collab-e2e-${test.info().workerIndex}`;
const bundle = path.resolve(__dirname, "../apps/kicad/collab-bundle.js");

View file

@ -2,31 +2,25 @@ import { test, expect } from './fixtures';
import { stableShot } from '../e2e/utils/element-tracker';
/**
* Eeschema schematic-LOAD regression test (fiber / Asyncify trampoline shim).
* Eeschema schematic-LOAD regression test (JSPI load-chain gate).
*
* This guards the fix in scripts/common/inject-dyncall-shims.sh
* ("3c. Fiber trampoline self-heal").
* Pins the programmatic load chain end to end: kicadOpenFile (an embind async
* export that suspends via JSPI while OpenProjectFiles runs) must complete a
* real schematic load. Opening a schematic calls SCH_EDIT_FRAME::SetScreen()
* -> m_toolManager->RunAction(selectionClear), which rides a tool coroutine
* so a regression anywhere in the chain (the async export, the scheduler
* ring, the libcontext JSPI backend) shows up here as a load that suspends
* and never resumes: the editor title stays "untitled".
*
* Background: KiCad's tool framework runs action handlers in coroutines that
* switch stacks via emscripten_fiber_swap. The emscripten fiber glue gates its
* context switch on `Fibers.trampolineRunning` and resets that flag at the end of
* `Fibers.trampoline()`. At startup `emscripten_set_main_loop(...,1)` throws
* "unwind" to establish the main loop; KiCad does that from inside a tool
* coroutine, so the throw propagates THROUGH the trampoline and skips the reset.
* The flag then stays `true` forever, `Fibers.trampoline()` becomes a permanent
* no-op, and EVERY fiber swap after startup silently fails to switch contexts.
*
* Opening a schematic calls SCH_EDIT_FRAME::SetScreen() ->
* m_toolManager->RunAction(selectionClear), which performs such a fiber swap. So
* without the shim, OpenProjectFiles() suspends in selectionClear and never
* resumes: the load hangs and the editor title stays "untitled".
*
* The shim wraps the trampoline loop in try/finally so the flag is always reset.
* With it, the load completes and the title switches to the opened file.
* Historical note: this spec originally guarded the asyncify-era fiber
* trampoline self-heal shim, whose absence hung exactly this chain. The shim
* and its injector are gone with the JSPI migration; the spec stays as the
* canonical schematic-load gate because the failure mode (a suspended load
* chain that never resumes) is mechanism-independent.
*
* Assertion strategy: open a minimal (text-free) schematic via the programmatic
* Module.kicadOpenFile() hook and poll the editor title. GREEN once it shows the
* file name; RED (poll timeout) if the load hangs because the shim is missing.
* file name; RED (poll timeout) if the load hangs.
*
* The schematic holds a few wires + junctions (a box with a crossbar) so a dev
* can eyeball a screenshot and immediately see whether it rendered. It uses ONLY
@ -65,7 +59,7 @@ type EmscriptenFS = {
type KicadModule = { kicadOpenFile(path: string): unknown };
test.describe('Eeschema schematic load', () => {
test('opens a .kicad_sch via kicadOpenFile and finishes loading (fiber shim regression)', async ({
test('opens a .kicad_sch via kicadOpenFile and finishes loading (load-chain regression)', async ({
page,
}) => {
await page.goto('/kicad/eeschema.html');
@ -96,8 +90,8 @@ test.describe('Eeschema schematic load', () => {
expect(await page.title()).toMatch(/untitled/i);
// Write a minimal, version-compatible schematic into MEMFS and open it.
// kicadOpenFile runs OpenProjectFiles under Asyncify: it suspends and
// returns a placeholder, so we ignore the return and poll the title.
// kicadOpenFile is a promising export: it suspends via JSPI and hands
// back a Promise, so we ignore the return and poll the title.
const openedPath = await page.evaluate((content) => {
const w = window as unknown as { FS: EmscriptenFS; Module: KicadModule };
const dir = '/home/kicad/documents';
@ -113,15 +107,16 @@ test.describe('Eeschema schematic load', () => {
}, SAMPLE_SCH);
expect(openedPath).toContain('regression.kicad_sch');
// With the fiber trampoline self-heal shim the load completes and the
// title switches to the opened file. WITHOUT it, the selectionClear fiber
// swap hangs and the title stays "untitled" -> this poll times out (RED).
// On a healthy build the load completes and the title switches to the
// opened file. If the selectionClear coroutine never resumes, the title
// stays "untitled" -> this poll times out (RED).
await expect
.poll(async () => page.title(), {
message:
'Schematic load did not complete (title stayed "untitled"). ' +
'The fiber trampoline self-heal shim (inject-dyncall-shims.sh "3c") is ' +
'likely missing or broken.',
'Suspect the JSPI load chain: the kicadOpenFile embind async export, ' +
'the scheduler ring, or a refused coroutine transition — the ' +
'[wx-scheduler]/[libctx-jspi] console beacons say which.',
timeout: 30000,
intervals: [500],
})

View file

@ -188,7 +188,7 @@ test.describe('eeschema simulator', () => {
const corruption = all.filter((l) =>
l.includes('index out of bounds') || l.includes('indirect call to null')
|| l.includes('uncaught exception: unwind'));
expect(corruption, 'no asyncify corruption').toHaveLength(0);
expect(corruption, 'no wasm trap').toHaveLength(0);
});
test('a second run after the first succeeds (engine reset path)', async ({ page, testLogger }) => {

View file

@ -12,8 +12,9 @@ import { clickByTooltip, findByTooltip } from "../e2e/utils/element-tracker";
*
* - The text tool froze the app. createNewText shows DIALOG_TEXT_PROPERTIES via
* ShowQuasiModal, whose nested wxGUIEventLoop::DoRun re-entered emscripten_set_main_loop
* (simulate_infinite_loop "unwind"), which can't be nested/resumed. Fixed by pumping
* nested event loops through Asyncify (wxwidgets/src/wasm/evtloop.cpp).
* (simulate_infinite_loop "unwind"), which can't be nested/resumed. Fixed by running
* nested event loops as suspending waits (wxwidgets/src/wasm/evtloop.cpp; asyncify
* then, JSPI now).
*/
const SAMPLE_SCH = `(kicad_sch
@ -114,10 +115,10 @@ test.describe("eeschema core UI (wasm)", () => {
// The quasi-modal dialog must appear (previously the nested event loop threw "unwind").
// Poll for it instead of a fixed 1500ms "let the dialog open" sleep.
await expect.poll(dialogsOpen, { timeout: 8000, intervals: [300] }).toBeGreaterThan(0);
// App must stay responsive while it's up (Asyncify suspend, not a frozen main thread).
// App must stay responsive while it's up (JSPI suspend, not a frozen main thread).
expect(await page.evaluate(() => 1 + 1).then(() => true).catch(() => false)).toBe(true);
// Escape must close it — exercises the Asyncify resume (ShowQuasiModal returns).
// Escape must close it — exercises the suspension resume (ShowQuasiModal returns).
await page.keyboard.press("Escape");
await expect.poll(dialogsOpen, { timeout: 8000, intervals: [300] }).toBe(0);

View file

@ -251,7 +251,7 @@ test.describe('Eeschema WASM', () => {
// ONE place in the converted suite that still uses a fixed delay: a wire vertex
// commit produces no JS-observable signal (no registry entry, and click(start)
// makes no pixel change to settle on), so we cannot poll a real condition — and
// the asyncify WASM event loop needs wall-clock time to process each click as a
// the suspending WASM event loop needs wall-clock time to process each click as a
// discrete mouse event (proven: replacing these with canvas-stability waits, which
// return in ~3 frames, leaves the wire uncommitted). Making this deterministic
// needs a KiCad-side "tool operation idle" hook (see the render-idle plan);

View file

@ -8,13 +8,13 @@ import { clickMenuBarItem, clickMenuItemByText } from '../e2e/utils/element-trac
* (a quasi-modal running a NESTED event loop, opened from the place-footprint
* TOOL COROUTINE) click Cancel the whole UI freezes.
*
* Root cause (recorder, live demo board): the nested loop parks IN PLACE on the
* tool coroutine's fiber stack (`inplace-park-on-fiber-stack`), then the resume
* that would close it is refused by the stale-fiber quarantine
* (`fiber-resume-refused: … asyncify-parked mid-body`) and dropped the
* doc-19 disease. It was masked by the mainstack bounce until Phase F4 removed
* it; the footprint chooser has no automated coverage so the removal went
* unnoticed. This spec is that coverage.
* Root cause (recorder, live demo board, found asyncify-era): the nested loop
* parked IN PLACE on the tool coroutine's stack, then the resume that would
* close it was refused by the stale-fiber quarantine
* and dropped the doc-19 disease. It was masked by
* the mainstack bounce until Phase F4 removed it; the footprint chooser had no
* automated coverage so the removal went unnoticed. This spec is that coverage,
* kept as the dead-app liveness gate for the chooser's nested wait.
*
* Mechanics that matter: the chooser is a wxFrame (not a wxDialog), so detect
* it by a second top-level frame + a "nested" scheduler wait; drive the canvas
@ -138,8 +138,8 @@ test.describe('Add Footprint chooser close (doc-19 dead-app repro)', () => {
expect(cancel, 'Cancel button found').not.toBeNull();
await synthClick(page, cancel!.x, cancel!.y);
// The chooser must close AND the app must stay alive. On the broken
// build the loop stalls here (the dropped fiber resume).
// The chooser must close AND the app must stay alive. On a broken
// build the loop stalls here (a dropped coroutine resume).
await page
.waitForFunction(
(n) =>

View file

@ -124,9 +124,9 @@ test.describe('gerbview WASM', () => {
});
expect(opened.hook, 'gerbview exposes kicadOpenFiles (gerbview_embind.cpp)').toBe(true);
// NOT the return value: OpenProjectFiles parks under Asyncify, so the
// embind call unwinds and hands back a falsy placeholder long before the
// load finishes (same reason open-flow.ts ignores kicadOpenFile's bool).
// NOT the return value: OpenProjectFiles suspends via JSPI, so the
// embind call hands back a Promise long before the load finishes
// (same reason open-flow.ts ignores kicadOpenFile's return).
// The truthful completion signal is the open-gate probe.
await expect.poll(
async () => page.evaluate(() => {

View file

@ -219,8 +219,9 @@ const PCB: ToolCfg = {
// pcbnew v2-apply scope (ysync 0008 Stage C): FOOTPRINT blobs are the proven
// path (bare-footprint parse + replace-by-uuid with children — the 0004
// containment win). Track/via/zone/text APPLY via the (kicad_pcb …) envelope
// is the codebase's documented asyncify-fragile parse — those types remain on
// the legacy scalar apply until that's solved (tracked in 0008 status).
// rode the parse that was asyncify-fragile in wasm (healthy under JSPI —
// roundtrip.spec.ts pins it); those types still ship on the legacy scalar
// apply (tracked in 0008 status).
changed: {
// Replace the footprint wholesale from its own snapshot blob, moved.
fromSnapshotUuid: "66666666-0000-0000-0000-000000000001",
@ -267,8 +268,8 @@ for (const cfg of [PL, SCH, PCB]) {
// 3. A genuine local edit emits an items wire carrying the touched item.
// Run this BEFORE the applies: TestMoveFirst moves the FIRST screen item,
// which must be a fixture wire/track (the proven off-fiber move path) — an
// apply-added text would no-op the virtual Move (known asyncify quirk).
// which must be a fixture wire/track (the proven off-coroutine move path) —
// an apply-added text would no-op the virtual Move (a quirk found asyncify-era).
// Skipped when the harness can't drive the tool's emit (see ToolCfg).
if (cfg.localEdit) {
const editedUuid = (await page.evaluate(cfg.localEdit)) as string;

View file

@ -203,7 +203,7 @@ test.describe('PCB load probe', () => {
// Give the file dialog generous time to render — wxGenericFileDialog
// populates its file list by scanning the directory, which on MEMFS
// is fast but goes through the Asyncify loop.
// is fast but goes through the suspending event loop.
await page.waitForTimeout(3000); // eslint-disable-line -- diagnostic one-shot; intentional state-capture interval
await page.screenshot({ path: shotPath(page, 'probe-02-after-open-click.png'), scale: 'css' });

View file

@ -150,8 +150,9 @@ function runLoadPcbTest(demo: DemoCfg): void {
// If we ever need to dismiss post-load wxMessageDialogs (missing
// libs etc.), do it INSIDE waitForBoardLoaded so the dismiss
// side-effect lives with the polling loop — calling page.evaluate
// from the test driver hangs once the post-load asyncify clipboard
// runtime error breaks the wasm event loop. ───────────────────
// from the test driver hangs if a runtime error breaks the wasm
// event loop (the asyncify-era post-load clipboard error was the
// proven case). ─────────────────────────────────────────────────
// ── Wait for the load to complete (no dialogs visible). ───────
const result = await waitForBoardLoaded(page, testLogger, 60000);
@ -179,24 +180,24 @@ function runLoadPcbTest(demo: DemoCfg): void {
`WASM aborted during ${demo.name} load:\n${aborts.join('\n\n')}`,
).toEqual([]);
// ── Clean-console gate: NO asyncify corruption may surface anywhere in
// the load — not before, not after the board renders. The formerly
// tolerated post-load clipboard/unwind RuntimeErrors are fixed
// (sync clipboard IsSupported in wx; "unwind" sentinel handling in
// the scheduler shim; see docs/features/asyncify-arbiter/).
const asyncifySignatures = [
// ── Clean-console gate: NO wasm trap may surface anywhere in the
// load — not before, not after the board renders. (Historical: the
// once-tolerated post-load clipboard/unwind RuntimeErrors were
// fixed in the asyncify era — sync clipboard IsSupported in wx,
// "unwind" sentinel handling — see docs/features/asyncify-arbiter/.)
const wasmTrapSignatures = [
'index out of bounds',
'indirect call to null',
'uncaught exception: unwind',
'invalid state',
'is not a function',
];
const asyncifyErrors = allLines.filter((l) =>
asyncifySignatures.some((sig) => l.toLowerCase().includes(sig)),
const wasmTrapErrors = allLines.filter((l) =>
wasmTrapSignatures.some((sig) => l.toLowerCase().includes(sig)),
);
expect(
asyncifyErrors,
`Asyncify corruption surfaced during ${demo.name} load:\n${asyncifyErrors.join('\n\n')}`,
wasmTrapErrors,
`wasm trap surfaced during ${demo.name} load:\n${wasmTrapErrors.join('\n\n')}`,
).toEqual([]);
});
}

View file

@ -2,19 +2,16 @@ import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
/**
* N2 message ordering under a parked open (scheduler-build target semantics).
* N2 message ordering under a parked open (scheduler semantics).
* docs/features/async/17-mailbox-scheduler-plan.md §3d N2, §3b.
*
* Legacy glue (open_gate, doc 14): collab entries issued while `kicadOpenFile`
* is asyncify-parked are DROPPED collab-load-fuzz.spec.ts asserts that drop
* contract on legacy builds, and it is correct for the guard architecture.
*
* The mailbox flips dropdeliver: a mutating entry issued during the open
* becomes a queued message, applied IN ORDER after the open completes. GREEN
* since S1's embind lane the scheduler shim wraps the audited mutators
* (doc 18) at the Module boundary, queueing busy-window calls and delivering
* after settle with promise-returned results. S4 moves queueing worker-side.
* Self-skips on legacy glue (the lane is a build variant until S5).
* Legacy glue (open_gate, doc 14) DROPPED collab entries issued while
* `kicadOpenFile` was parked; the mailbox flipped dropdeliver, and the
* scheduler's embind lane is the only glue now: a mutating entry issued
* during the open becomes a queued message, applied IN ORDER after the open
* completes the shim wraps the audited mutators (doc 18) at the Module
* boundary, queueing busy-window calls and delivering after settle with
* promise-returned results.
*
* Ordering probe: apply A ADDS a segment, apply B MOVES that same segment.
* B can only land if A landed first the single final-position check proves
@ -87,15 +84,6 @@ test.describe("mailbox N2: entries during a parked open are delivered in order",
void testLogger;
await bootHarness(page);
// The delivery contract under test is the scheduler build's embind lane;
// on legacy glue the open gate drops both applies by design.
const lane = await page.evaluate(() => {
const s = (globalThis as unknown as { __wxScheduler?: { mutatorsWrapped: number } })
.__wxScheduler;
return s ? s.mutatorsWrapped : 0;
});
test.skip(lane === 0, "legacy glue — embind lane absent (drop contract in collab-load-fuzz)");
const issued = await page.evaluate(async ({ newSeg, board }) => {
const w = window as unknown as { FS: FS; Module: Mod };
const dir = "/home/kicad/documents";

View file

@ -9,7 +9,7 @@ import { test, expect } from "./fixtures";
* pcbnew reuses the same wire contract + generic JS reconciler as pl_editor/eeschema; the new
* code is the C++ adapter a native BOARD_LISTENER trigger + post-settle snapshot-diff emit,
* and a BOARD_COMMIT apply run inside a COROUTINE (so a freshly-built item's GAL view->Add has
* the Asyncify/fiber context it needs, exactly as eeschema). Coverage:
* the tool-coroutine context it needs, exactly as eeschema). Coverage:
* - snapshot (read): kicadCollabSnapshot reflects items by uuid/type/position.
* - apply (single page): kicadCollabApply moves/removes/adds tracks by uuid (deferred via
* CallAfter + coroutine, so poll for the result).
@ -202,7 +202,8 @@ test.describe("pcbnew collab bridge — single page", () => {
// `added` reconstruction of a footprint, via and zone. The emit side attaches BOTH the full
// itemToJson fields AND an s-expr clipboard blob; makeItem then reconstructs a footprint from
// the bare `(footprint …)` blob, and a via/zone NATIVELY from the geometry fields (the
// `(kicad_pcb …)` envelope parse is asyncify-fragile in wasm for those). Round-trip each: read
// `(kicad_pcb …)` envelope parse was asyncify-fragile in wasm for those; healthy under JSPI —
// roundtrip.spec.ts pins it — the native path stays as the lean route). Round-trip each: read
// its full snapshot item + blob, delete it, re-add, confirm it returns at the same position.
for (const [label, id, type] of [
["footprint", FP1, "FOOTPRINT"],

View file

@ -10,7 +10,7 @@ import { stableShot } from '../e2e/utils/element-tracker';
* deterministically without UI automation, and
* 2. the seeded KiCad config that skips the first-run STARTWIZARD (the harness
* now seeds it in preRun, matching the web app's boot.ts) without it the
* wizard's modal loop crashes Asyncify and no file can load.
* wizard's modal loop wedges the boot and no file can load.
*
* Strategy mirrors eeschema-load.spec.ts: write a minimal .kicad_wks into MEMFS,
* call Module.kicadOpenFile(), and poll the editor title. GREEN once it shows the

View file

@ -124,7 +124,7 @@ test.describe('pl_editor WASM', () => {
);
// The dialog object exists in the registry as soon as C++ constructs it, but the
// directory enumeration (MEMFS readdir → asyncify suspend) hasn't returned yet so
// directory enumeration (MEMFS readdir → JSPI suspend) hasn't returned yet so
// the inner file list isn't painted. stableShot's stabilization waits for the
// list to finish painting — deterministically replacing the old waitForTimeout(600)
// that used to catch the dialog as a black rectangle.

View file

@ -447,7 +447,7 @@ test("fitViewport applies a world rect (contain) — GetViewport round trip", as
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
}, target);
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
// The fit is CallAfter+coroutine scheduled — poll the transform until it lands.
await expect
.poll(
async () => {

View file

@ -540,7 +540,7 @@ test("fitViewport applies a world rect (contain) — GetViewport round trip", as
m.kicadCollabFitViewport(t.cx, t.cy, t.halfW, t.halfH);
}, target);
// The fit is CallAfter+fiber scheduled — poll the transform until it lands.
// The fit is CallAfter+coroutine scheduled — poll the transform until it lands.
await expect
.poll(
async () => {

View file

@ -325,7 +325,7 @@ async function openSyncDialog(page: import('@playwright/test').Page): Promise<vo
);
}
/** Close the currently-open modal dialog (Escape unwinds the wx modal loop). */
/** Close the currently-open modal dialog (Escape resolves the wx modal wait). */
async function closeDialog(page: import('@playwright/test').Page): Promise<void> {
await page.keyboard.press('Escape');
await waitUntil(

View file

@ -6,12 +6,12 @@ import { test, expect } from "./fixtures";
*
* The user-visible bug: Symbol Properties (any quasi-modal opened from a tool
* action) stops responding OK/Cancel click, nothing happens, only the
* titlebar × closes it. Mechanism (doc 19 §4): the tool fiber that owns the
* dialog parks mid-body in the quasi-modal wait; a concurrent park's wake
* aliases over its live sleep buffer (`aliased-wake-live`), the stale-fiber
* guard quarantines it, and the fiber's own legitimate resume is then REFUSED
* (`fiber-resume-refused`) and dropped. The fiber never completes, the
* dispatch guard it holds never releases, every later click defers forever.
* titlebar × closes it. Mechanism (doc 19 §4, asyncify-era vocabulary): the
* tool fiber that owned the dialog parked mid-body in the quasi-modal wait; a
* concurrent park's wake aliased over its live sleep buffer, the stale-fiber
* guard quarantined it, and the fiber's own legitimate resume was then
* REFUSED and dropped. The fiber never completed, the dispatch guard it held
* never released, every later click deferred forever.
*
* Staging: the strand needs concurrent parks over the dialog's parked fiber.
* The deterministic lever is the parking timer (wasm/bindings/timer_park.h):
@ -299,8 +299,8 @@ test.describe("quasi-modal strand (doc 19)", () => {
await okButtonCenter(page);
// RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08), and
// RE-KEYED for JSPI (2026-08-13): the `[wx-asyncify] concurrent-park|…`
// beacons retired with the asyncify scheduler, which made the old filter
// RE-KEYED for JSPI (2026-08-13): the asyncify scheduler's concurrent-park
// beacon family retired with it, which made the old filter
// vacuous. The post-migration invariant is the same — the dialog opens,
// the timer fires and its park survives (asserted above) — and the
// observable JSPI failure modes of an overlap are ghost/refused
@ -351,22 +351,29 @@ test.describe("quasi-modal strand (doc 19)", () => {
{ timeout: 15000 },
)
.then(() => true, () => false);
const refusedSoFar = testLogger.consoleLogs.filter((l) =>
l.includes("fiber-resume-refused"),
const anomaliesSoFar = testLogger.consoleLogs.filter((l) =>
/\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test(
l,
),
).length;
console.log(
`[STRAND] red outcome: closed=${closed} dialogs=${await dialogCount(page)} ` +
`refused-resumes=${refusedSoFar}`,
`anomalies=${anomaliesSoFar}`,
);
// Desired end state 1: the dialog closes.
expect(closed, "OK closed the quasi-modal dialog").toBe(true);
// Desired end state 2: no refused fiber resume anywhere in the run.
const refused = testLogger.consoleLogs.filter((l) =>
l.includes("fiber-resume-refused"),
// Desired end state 2: no ghost/refused transition, scheduler anomaly or
// rejected coroutine entry anywhere in the run (the old refused-resume
// beacon retired with the asyncify scheduler; these are the JSPI
// equivalents of a dropped resume).
const anomalies = testLogger.consoleLogs.filter((l) =>
/\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test(
l,
),
);
expect(refused, `refused resumes: ${refused.join(" || ")}`).toHaveLength(0);
expect(anomalies, `anomalies: ${anomalies.join(" || ")}`).toHaveLength(0);
// Desired end state 3: the wait books balance — the quasi-modal's
// "nested" wait was resolved and consumed, nothing left parked.

View file

@ -503,13 +503,11 @@ test.describe("round trip: file → yjs → file", () => {
expect(hasAbort(testLogger), "no WASM abort").toBe(false);
});
// REMAINING KNOWN GAP (ysync 0008 status, known limit 1 — tracked, not a test
// bug): pcbnew track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope
// parse, the codebase's documented asyncify-fragile path (even a verbatim
// SaveSelection envelope for a segment dies silently in the commit). The full
// fixture (via + gr_text + segments) therefore still loses those items on the
// rebuild side. Un-fixme when the envelope parse is solved. Run with
// --grep-invert skipped to see the live diff.
// FORMER KNOWN GAP (ysync 0008 status, known limit 1): pcbnew
// track/via/zone/text APPLY rides the `(kicad_pcb …)` envelope parse, which
// was asyncify-fragile in wasm (even a verbatim SaveSelection envelope for a
// segment died silently in the commit) and lost those items on the rebuild
// side. Healthy under JSPI — this test is the pin.
test( // re-enabled 2026-08-13: the asyncify-fragile envelope parse is gone with JSPI — passes both engines
"pcbnew preserves items through a yjs round trip",
async ({ context, testLogger }) => {

View file

@ -3,36 +3,34 @@ import { test, expect } from "./fixtures";
import { expectGuardsSilent } from "./utils/wait-beacons";
/**
* Timer-park concurrent-Asyncify repro (gal-refresh-timer investigation).
* Timer-park concurrency repro (gal-refresh-timer investigation).
*
* The prod trap ("index out of bounds" + "unreachable executed" in doRewind,
* v0.1.1719, still un-reproduced naturally): a wx timer callback is a FRESH
* JSwasm entry (emscripten_async_call TimerCallbackFunc::Run Notify()),
* and the main loop spends most wall-clock time Asyncify-parked inside
* wxWasmYieldToBrowser. A timer handler that itself parks therefore creates
* TWO live Asyncify contexts over the single-slot `Asyncify.currData` the
* emscripten #9153 family that the scheduler shim
* (scripts/common/shims/asyncify-scheduler.js) silently repairs. The collab entries add the third ingredient: they run on
* TOOL_MANAGER coroutines (emscripten_fiber_swap), which bypass the shim's
* allocateData accounting entirely and `finishContextSwitch` is exactly
* where the prod trap's second stack dies.
* The prod trap this spec was built to chase ("index out of bounds" +
* "unreachable executed" in doRewind, v0.1.1719, never reproduced naturally)
* was an asyncify-era disease: a wx timer callback is a FRESH JSwasm entry
* (emscripten_async_call TimerCallbackFunc::Run Notify()), and a timer
* handler that itself parked could overlap the main loop's in-place yield
* park two live suspension contexts over asyncify's single-slot state (the
* emscripten #9153 family). The collab entries added the third ingredient:
* they ran on TOOL_MANAGER fibers, which bypassed the old shim's accounting
* entirely. Under JSPI every suspending entry owns its own suspender, so the
* collision class is structurally gone; this spec pins that it STAYS gone.
*
* The natural trigger needs a timer handler that parks mid-paint
* (scheduler-dependent; never hit locally). `kicadTestArmTimerPark` makes the
* window deterministic: a one-shot wx timer whose Notify() emscripten_sleep()s
* for a fixed time. Three escalating cycles:
*
* 1. timer park alone (timer chain × main-loop yield park)
* 2. + collab entry hammering (adds fiber swaps through the window)
* 1. timer park alone (timer chain suspends across frame yields)
* 2. + collab entry hammering (adds coroutine switches through the window)
* 3. same again (interleaving lottery, second draw)
*
* The spec asserts the runtime SURVIVES every cycle on a build where the
* hypothesis holds this is deterministically RED, and after the real fix it
* is the regression gate.
* The spec asserts the runtime SURVIVES every cycle and that no scheduler or
* libcontext anomaly beacon fires anywhere in the run.
*
* RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08): the staged
* overlap needed the main loop's per-frame in-place park, which D5 removed.
* The final assert now pins ZERO observable concurrent-park windows the
* The final assert now pins ZERO observable anomaly beacons the
* post-migration invariant instead of demanding the overlap engage.
*/
@ -150,10 +148,10 @@ async function openAndSettle(page: Page, content: string): Promise<void> {
/**
* One repro cycle in-page: arm the parking timer, then poll its state until
* the park completes optionally hammering the fiber-based collab entries
* the park completes optionally hammering the coroutine-based collab entries
* through the window (the prod settle fan-out shape). Every embind entry here
* runs while the timer chain is Asyncify-parked and the main loop's yield
* park keeps cycling: the exact concurrent-context interleaving under test.
* runs while the timer chain is suspended mid-Notify(): the exact
* concurrent-entry interleaving under test.
*/
async function armAndRide(
page: Page,
@ -176,7 +174,7 @@ async function armAndRide(
stats.armed = true;
const t0 = performance.now();
// Bound = park length + generous rewind budget; exits on completion.
// Bound = park length + generous resume budget; exits on completion.
while (performance.now() - t0 < parkMs + 20000) {
try {
const st = JSON.parse(m.kicadTestTimerParkState()) as {
@ -197,8 +195,8 @@ async function armAndRide(
// Heap growth mid-park (prod trace: `stage:done … GREW +187MB`): growth
// detaches every JS heap view; a stale view held across it is one of
// the few mechanisms that yields a bad function-table index LATER.
// 256 MB per shot, deliberately leaked — the asyncify buffers of the
// parked chains live in linear memory on both sides of the boundary.
// 256 MB per shot, deliberately leaked — the suspended chains' coroutine
// stacks live in linear memory on both sides of the boundary.
if (stats.fired && growHeap && !stats.grewBytes) {
const alloc = (
m as unknown as { ___libc_malloc?: (n: number) => number }
@ -234,7 +232,7 @@ async function armAndRide(
}, opts);
}
test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent currData)", () => {
test.describe("timer Notify() suspends during the main-loop frame yield (concurrent parks)", () => {
test("runtime survives a parking timer handler, alone and under fiber hammering", async ({
page,
testLogger,
@ -270,7 +268,7 @@ test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent c
}
// The runtime is still fully functional: snapshots walk the board and a
// real apply lands (a poisoned Asyncify state fails one of these first).
// real apply lands (a poisoned suspension state fails one of these first).
const itemCount = await page.evaluate(
() =>
JSON.parse((window.Module as unknown as Mod).kicadCollabSnapshotItems()).added.length,
@ -331,21 +329,23 @@ test.describe("timer Notify() Asyncify-park during main-loop yield (concurrent c
);
if (cLane) expectGuardsSilent(testLogger.consoleLogs, ["timerRetry"]);
// RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08). This
// lever staged "timer park × MAIN-LOOP YIELD PARK", and D5 removed the
// main loop's per-frame in-place park — the overlap is structurally
// impossible now, so "the shim observed the window" (>0) can never pass
// again. The pin flips to the invariant the migration exists to
// establish: the lever runs, the park survives (asserted above), and NO
// concurrent-park window is observable at all.
// RE-PINNED AT THE FLIP (docs/features/async/22 §10, 2026-08-08), and
// RE-KEYED for JSPI (2026-08-14): the asyncify scheduler's concurrent-park
// beacon family retired with it, which made the old filter
// vacuous. The invariant stands — the lever runs and the park survives
// (asserted above) — and the observable JSPI failure modes of an overlap
// are ghost/refused transitions, a stuck-window force-clear, a job-tick
// trap, or a refused coroutine entry.
const overlapLines = testLogger.consoleLogs.filter((l) =>
/\[wx-asyncify\] (concurrent-park|aliased-wake-live|overlapped-wake)/.test(l),
/\[libctx-jspi\] ghost\/refused transition|\[wx-scheduler\] (force-clearing stuck window|job tick error)|entry REJECTED/.test(
l,
),
);
console.log(`[TEST] overlap beacons: ${overlapLines.length} line(s)`);
for (const l of overlapLines.slice(0, 10)) console.log(`[TEST] ${l}`);
expect(
overlapLines.length,
"no concurrent-park window is observable post-flip",
"no ghost/stuck-window/job-tick/rejected-entry anomaly is observable post-flip",
).toBe(0);
});
});

View file

@ -323,16 +323,16 @@ export async function closeTrio(trio: Trio): Promise<void> {
// ── Per-tab probes ───────────────────────────────────────────────────────────
/** Silent save-to-MEMFS + read back no onSave side effects. Defers while
* collab fiber work is in flight: a bare-embind-stack save during a parked
* collab coroutine work is in flight: a bare-embind-stack save during a parked
* apply mis-dispatches (finding #10b) the wait is JS-side, so it is safe. */
export function modelText(page: Page, cfg: ToolCfg): Promise<string> {
return page.evaluate(
async ({ saveFn, ext }) => {
const w = window as unknown as {
FS: FSApi;
Module: Mod & { kicadCollabFiberBusy?: () => boolean };
Module: Mod & { kicadCollabBusy?: () => boolean };
};
for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) {
for (let i = 0; i < 200 && w.Module.kicadCollabBusy?.(); i++) {
await new Promise((r) => setTimeout(r, 25));
}
const out = `/home/kicad/documents/_dump.${ext}`;
@ -371,9 +371,9 @@ export function drift(page: Page, cfg: ToolCfg): Promise<DriftSummary | null> {
async ({ saveFn, ext }) => {
const w = window as unknown as {
KicadCollabV2: { driftReport(f: string, p: string): DriftSummary | null };
Module: { kicadCollabFiberBusy?: () => boolean };
Module: { kicadCollabBusy?: () => boolean };
};
for (let i = 0; i < 200 && w.Module.kicadCollabFiberBusy?.(); i++) {
for (let i = 0; i < 200 && w.Module.kicadCollabBusy?.(); i++) {
await new Promise((r) => setTimeout(r, 25));
}
return w.KicadCollabV2.driftReport(saveFn, `/home/kicad/documents/_drift.${ext}`);

View file

@ -1,14 +1,14 @@
// 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
// only trusted once the guard it replaces is provably silent across the suite.
// This module turns a TestLogger's consoleLogs into per-family counts so specs can
// assert `expectGuardsSilent(...)` at the step that claims a family.
// Every anti-collision guard announces itself on the console when it fires.
// A superseded guard is kept as a TRIPWIRE: the mailbox is only trusted once
// the guard it replaces is provably silent across the suite. This module turns
// a TestLogger's consoleLogs into per-family counts so specs can assert
// `expectGuardsSilent(...)` at the step that claims a family.
//
// Rate-limiting caveat: [wx-asyncify] and [collab-fcontext] beacons print the first
// 10 occurrences, then every 100th, embedding "(occurrence N)". `linesSeen` is what
// Rate-limiting caveat: the [wx-dispatch] ERASED beacon prints the first 10
// occurrences, then every 100th, embedding "(occurrence N)". `linesSeen` is what
// reached the console; `estimatedTotal` recovers the true count from the highest
// occurrence number when present (else it equals linesSeen). Assertions on SILENCE
// are exact either way: zero fires = zero lines.
@ -24,32 +24,18 @@ export interface GuardBeaconCounts {
timerRetry: BeaconFamilyCount;
// wx dispatch interlock bookkeeping anomalies (evtloop.cpp)
dispatchAnomaly: BeaconFamilyCount;
// asyncify-scheduler.js shim: nested-park / wake-aliasing / stale-fiber refusals
wxAsyncify: BeaconFamilyCount;
// libcontext swap-layer refusals + hot-main beacons ([collab-fcontext])
libcontext: BeaconFamilyCount;
// open-settle gate giving up (open-flow.ts)
openSettleFailed: BeaconFamilyCount;
// jspi-scheduler.js turnstile/containment beacons (JSPI builds)
// jspi-scheduler.js turnstile/containment beacons
wxScheduler: BeaconFamilyCount;
// libcontext JSPI backend ghost/refused-transition census
libctxJspi: BeaconFamilyCount;
// scheduler build marker — identifies the dual-glue variant, not a guard
schedulerBuild: boolean;
}
const FAMILY_PATTERNS: Record<
Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>,
RegExp
> = {
const FAMILY_PATTERNS: Record<keyof GuardBeaconCounts, RegExp> = {
timerRetry: /\[wx-timer\] retry storm/,
dispatchAnomaly: /\[wx-dispatch\] (ERASED|NEGATIVE)/,
wxAsyncify:
/\[wx-asyncify\] (concurrent-park|reentrant-state|aliased-wake-live|overlapped-wake|fiber-resume-refused)/,
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|job tick error|untracked promising entry|activation stack imbalance|resume window misnested)/,
libctxJspi: /\[libctx-jspi\] ghost\/refused/,
@ -66,19 +52,12 @@ export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
const counts: GuardBeaconCounts = {
timerRetry: emptyFamily(),
dispatchAnomaly: emptyFamily(),
wxAsyncify: emptyFamily(),
libcontext: emptyFamily(),
openSettleFailed: emptyFamily(),
wxScheduler: emptyFamily(),
libctxJspi: emptyFamily(),
schedulerBuild: false,
};
for (const line of consoleLines) {
if (line.includes('[wx-scheduler] scaffolding installed')) {
counts.schedulerBuild = true;
continue;
}
for (const family of Object.keys(FAMILY_PATTERNS) as Array<
keyof typeof FAMILY_PATTERNS
>) {
@ -94,24 +73,11 @@ export function countGuardBeacons(consoleLines: string[]): GuardBeaconCounts {
return counts;
}
// Last-seen fcsTotal/rootHotTotal from a __wxAsyncifyDump()/STATE line, if any.
// rootHotTotal must stay 0 post-v0.1.28 — the standing N8 assertion.
export function parseAsyncifyCounters(
consoleLines: string[]
): { fcsTotal: number; rootHotTotal: number } | null {
let result: { fcsTotal: number; rootHotTotal: number } | null = null;
for (const line of consoleLines) {
const m = /fcsTotal=(\d+) rootHotTotal=(\d+)/.exec(line);
if (m) result = { fcsTotal: parseInt(m[1], 10), rootHotTotal: parseInt(m[2], 10) };
}
return result;
}
// Assert the named guard families never fired. Throws with the offending sample
// lines so the log points straight at the collision the mailbox failed to absorb.
export function expectGuardsSilent(
consoleLines: string[],
families: Array<Exclude<keyof GuardBeaconCounts, 'schedulerBuild'>>
families: Array<keyof GuardBeaconCounts>
): void {
const counts = countGuardBeacons(consoleLines);
const noisy = families

View file

@ -350,7 +350,7 @@ for (const [cfg, label] of [
}) => {
// TWO kicad_editor instances exceed Firefox's per-content-process wasm
// budget (the 2nd tab's #canvas never appears, even serial/isolated —
// same SpiderMonkey wall playwright-kicad.config.ts documents for x86
// same SpiderMonkey wall the merged playwright.config.ts documents for x86
// CI, hit at 2× on ARM). V8 handles it: runs on chromium-ci in CI and
// --project=chromium locally.
// 2026-08-13: FF wasm-budget skip retired — the JSPI build fits two

View file

@ -5,17 +5,17 @@ import * as path from 'path';
// THE merged Playwright config for every suite that runs against the static
// `apps` server: the wx widget suite (e2e/), the KiCad editor suite (kicad/),
// the asyncify race harness (asyncify/) and the coroutine harness
// the JSPI harness suite (jspi/) and the coroutine harness
// (e2e/coroutine*). One config = one invocation = one webServer, one port file
// and ONE start-of-run outputDir wipe — which is what retired the old
// per-suite pw-artifacts/{wx,asyncify,kicad} redirect dance (each sequential
// per-suite pw-artifacts redirect dance (each sequential
// CI invocation used to wipe the previous suite's artifacts).
//
// The web-app suite (web/) stays in playwright-web.config.ts: it runs against
// the React editor + backend stack (`pnpm --dir ../web dev`), not this server.
//
// CI runs: npm run test:e2e (wx-chromium, kicad-firefox, kicad-chromium,
// asyncify-firefox, coroutine-firefox)
// jspi-firefox, coroutine-firefox)
// npm run test:perf (perf project, non-gating, separate invocation)
// Local-only projects (system Chrome / WebKit) are listed at the bottom.

View file

@ -18,7 +18,7 @@ import * as fs from 'fs';
import * as path from 'path';
const TESTS_ROOT = path.resolve(__dirname, '..');
const SPEC_DIRS = ['kicad', 'e2e', 'web'];
const SPEC_DIRS = ['kicad', 'e2e', 'jspi', 'web'];
type Rule = {
name: string;

View file

@ -27,14 +27,6 @@ async function canvasCenter(page: Page): Promise<{ x: number; y: number }> {
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
// TODO: re-enable and fix — flaky-red on CI web-firefox (run 29605741796, the
// very commit that dropped its expected-fail marker): the no-fp-index path
// trips the crash-free gate below with "[wxWasm] modal event pump error -
// cancelling modal: RuntimeError: index out of bounds" — the known
// nested-modal-inside-doRewind asyncify pump limitation (historical: the
// legacy startModal pump was deleted at doc 20 D-1; modals are scheduler
// waits now — re-evaluate against the scheduler runtime; see also
// docs/features/ngspice-split/README.md "The editor side").
test( // re-enabled 2026-08-13: the chooser fp-selector flow revived on the JSPI build (both engines)
'symbol chooser footprint selector populates and preview renders (eeschema)', async ({ page }) => {
test.setTimeout(420000);
@ -207,7 +199,8 @@ test( // re-enabled 2026-08-13: the chooser fp-selector flow revived on the JSP
// Sources without a published footprint index (the remote/example backend)
// answer the index op null; the WASM side then intentionally leaves the
// selector default-only instead of lazily fat-loading every lib inside the
// modal pump (which crashes Asyncify — see filterFootprints in pcbnew.cpp).
// modal pump (a guard in filterFootprints, pcbnew.cpp — the fat-load crashed
// the asyncify-era pump, and staying lean inside a modal is still right).
// In that mode the meaningful assertions are: the chooser survived with the
// dual-seeded fp-lib-table, and no modal-pump/runtime error fired.
const indexAnswered = fpCalls.some((c) => c[0] === 'index' && c[4] === 'ok');

View file

@ -14,8 +14,8 @@ import { clickByTooltip, waitForWxApp, focusCanvas, stableShot } from '../e2e/ut
* window.kicadLibs.request("save", , "footprint") on the main thread
* (EM_ASYNC_JS), captured onto window.__pcbjamSaved. Assert: the body is
* well-formed fork-native s-expr (version 20251028), and the app stays live
* (no abort / no OOM respawn) i.e. the editor-as-tool + main-thread Asyncify
* save both work. THIS IS THE GATE before the backend (0009-A) is built.
* (no abort / no OOM respawn) i.e. the editor-as-tool + the main-thread
* suspending save both work. THIS IS THE GATE before the backend (0009-A) is built.
*/
async function bootFootprintEditor(page: Page): Promise<void> {
@ -145,7 +145,7 @@ test.fixme(
'no file-times error',
).toBe(false);
// App stayed live: no abort, no OOM respawn (the main-thread Asyncify save gate).
// App stayed live: no abort, no OOM respawn (the main-thread suspending-save gate).
expect(logs.some((l) => l.includes('Aborted(')), 'no WASM abort').toBe(false);
expect(new URL(page.url()).searchParams.get('oomRetry'), 'no OOM respawn').toBeNull();

View file

@ -6,7 +6,7 @@ import { clickMenuBarItem, clickMenuItemByText } from '../e2e/utils/element-trac
*
* A tool switch (Tools "Switch to PCB Editor", ExecuteFile
* window.kicadWebOpenTool) is a hard location.assign that pushes a history
* entry. Quit therefore cannot rely on history unwinding: after
* entry. Quit therefore cannot rely on stepping history back: after
* project schematic switch-to-pcb, one history step back is the schematic
* editor, not the project page. Quit must navigate to the project overview
* explicitly (WasmTool installQuitHook), wherever the session wandered first.