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

@ -14,16 +14,17 @@ build KiCad with WASM and run it in a browser.
## Build
- [docs/build.md](build.md) — Docker-based KiCad WASM build system (two-phase build, outputs, memory)
- [docs/build.md](build.md) — Docker-based KiCad WASM build system (single-phase build, outputs, memory)
- [docker/README.md](../docker/README.md) — Docker build environment, branch-specific containers, troubleshooting
- [wasm/README.md](../wasm/README.md) — WASM compatibility layer (overrides/shims without patching KiCad)
## Debugging & Asyncify
## Debugging & WASM runtime
- [docs/debugging/DEBUG.md](debugging/DEBUG.md) — debugging guide: Asyncify stalls vs crashes, shim/codegen coupling, stub-bisection
- [docs/debugging/learning.md](debugging/learning.md) — Asyncify + consecutive modal dialogs: the lock pattern
- [docs/research/threading_1.md](research/threading_1.md) — deep dive: the Asyncify single-slot `currData` collision bug and the fix
- [docs/research/threading_2.md](research/threading_2.md) — external research: JSPI/WasmFX/state-machine alternatives, QEMU analysis
- [docs/debugging/DEBUG.md](debugging/DEBUG.md) — debugging guide: the JSPI runtime's observability (`__wxWaitDump`, beacons), reading a SuspendError, the harnesses
- [docs/features/async/23-jspi-runtime.md](features/async/23-jspi-runtime.md) — the JSPI runtime architecture (scheduler/turnstile, libcontext backend, embind call shapes)
- [docs/debugging/learning.md](debugging/learning.md) — historical: Asyncify + consecutive modal dialogs, the lock pattern (asyncify era)
- [docs/research/threading_1.md](research/threading_1.md) — historical deep dive: the Asyncify single-slot `currData` collision bug and its era's fix
- [docs/research/threading_2.md](research/threading_2.md) — external research that pre-studied the alternatives (JSPI/WasmFX/state machines), QEMU analysis
## Architecture

View file

@ -1,3 +1,5 @@
> **STATUS (2026-08-14):** Part 1 (zero-duration nanosleep guard) landed as 94cac5e and was ported to the JSPI branch. Parts 2-3 (removelist instrumentation) are moot — the JSPI migration removed Asyncify instrumentation entirely.
<!-- STATUS: PLANNED, NOT EXECUTED (saved 2026-08-10). Verification findings herein are real
(measured against the Aug 6 kicad_editor build, emsdk 4.0.2); the shim guard, the test,
and the removelist additions have NOT been applied yet. -->

View file

@ -11,7 +11,7 @@ This document describes how to build KiCad for WebAssembly using the Docker-base
### Host Tools
Binaryen (wasm-opt) is downloaded automatically by the build script. No manual installation needed.
Node.js (for the seconds-long host postprocess step). Everything else runs inside the container.
## Quick Start
@ -36,47 +36,30 @@ Binaryen (wasm-opt) is downloaded automatically by the build script. No manual i
- `build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm` - WASM binary
- `build-wasm/kicad-pcbnew/pcbnew/pcbnew.wasm.map` - Source map (debug builds)
## Two-Phase Build
## Single-Phase Build
The build is split into two phases due to memory requirements:
The build is one pass: `docker/build.sh` compiles, links **and finalizes** the
wasm inside the container. The only host-side step is a postprocess on the
generated glue — `node scripts/common/patch-env-shim.mjs` merges `Module.ENV`
into the runtime's `ENV` (needed for `?trace=` and any future `Module.ENV`
use). It takes seconds and is idempotent.
### Phase 1: Docker Compilation
Compiles KiCad to WASM **without** asyncify transformation. This runs inside Docker with 32GB memory limit.
`--compile-only` / `--postprocess-only` split the two so CI can cache the
expensive compile and re-run just the host tail.
### Phase 2: Host Asyncify
Applies `wasm-opt --asyncify` on the host machine using Binaryen v121 (downloaded automatically to `tools/`). This transformation uses ~20-30GB RAM.
### Suspension: JSPI
**Note:** Binaryen v121 is used because v125 has a regression causing crashes in the asyncify liveness analysis.
Blocking calls — `wxDialog::ShowModal()`, `wxMessageBox()`, clipboard,
sleeps/waits, board loads — must yield to the browser event loop. This is
handled at **link time** by JSPI (JavaScript Promise Integration): every wasm
entry point that can suspend is a promising export
(`-sJSPI -sJSPI_EXPORTS=@scripts/common/jspi-exports.txt`), and the
`jspi-scheduler.js` pre-js supplies the spill-stack and resume-serialization
discipline around it. See
[docs/features/async/23-jspi-runtime.md](features/async/23-jspi-runtime.md).
### Why Asyncify?
Asyncify is an Emscripten transformation that allows WASM code to pause and resume execution. This is required for:
- **Modal dialogs** - `wxDialog::ShowModal()` blocks until user closes the dialog
- **Message boxes** - `wxMessageBox()` waits for user response
- **Clipboard operations** - Browser clipboard API is async
- **Sleep/wait operations** - Any blocking call that needs to yield to the browser
Without asyncify, modal dialogs would freeze the browser because WASM cannot yield control back to JavaScript's event loop.
### How It Works
1. `docker/build.sh` compiles KiCad in Docker (no asyncify flags)
2. Output is copied to `./output/` directory
3. `wasm-opt --asyncify` runs on host, transforming the WASM binary
4. Final output is ready for browser execution
### Technical Details
The asyncify transformation:
- Instruments every function that might be on the call stack during an async operation
- Adds stack save/restore logic to unwind and rewind the WASM stack
- Increases binary size by ~20% (141MB → 171MB for KiCad)
- Uses `asyncify-imports` pattern matching to identify async entry points
Import patterns used:
- `env.invoke_*` - Exception handling trampolines
- `env.__asyncjs__*` - EM_ASYNC_JS functions (like `startModal()`)
There is no post-link binary rewriting: the wasm the container links is the
wasm that ships.
## Docker Architecture
@ -155,17 +138,18 @@ The build system is optimized for fast development iteration:
- **ccache**: Caches compiled objects by hashing preprocessed source
- **wxWidgets**: `configure` runs once, `make` handles file-level dependencies
- **KiCad**: CMake tracks dependencies, only recompiles changed files
- **Asyncify**: Post-processing runs every build (~1 min, irreducible minimum)
- **Host postprocess**: the ENV-shim patch (`patch-env-shim.mjs`) re-runs every build — seconds
### Performance
| Scenario | Time |
|----------|------|
| No changes | ~1.5 min |
| Single file change (KiCad or wxWidgets) | ~1.5 min |
| No changes | seconds |
| Single file change (KiCad or wxWidgets) | dominated by the recompile + relink of that target |
| Full rebuild | ~10 min |
Most time is spent on asyncify post-processing which runs on every build.
There is no fixed per-build post-processing cost: an unchanged tree re-runs
only the host ENV-shim patch.
### Debug vs Release
@ -259,24 +243,30 @@ The WASM port requires compatibility layers for browser execution:
| Directory | Purpose |
|-----------|---------|
| `wasm/kiplatform/` | Platform abstraction (app, UI, printing, etc.) |
| `wasm/libcontext/` | Coroutine/fiber implementation for Asyncify |
| `kicad/thirdparty/libcontext/` | Coroutine backend (JSPI: one promising activation per coroutine) |
| `wasm/stubs/` | Stub implementations (libgit2, curl) |
| `wasm/config/` | Build configuration headers |
## Emscripten Flags
Key flags used in the build:
Key flags used in the build (browser apps; see
`scripts/kicad/build-kicad-target.sh` for the authoritative link surface):
```
-pthread -sUSE_PTHREADS=1 # Threading support
-sASYNCIFY=1 # Async coroutine support
-sALLOW_MEMORY_GROWTH=1 # Dynamic memory
-sINITIAL_MEMORY=256MB # Starting memory
-sMAXIMUM_MEMORY=4GB # Maximum memory
-sLEGACY_GL_EMULATION # OpenGL compatibility
-sMAX_WEBGL_VERSION=2 # WebGL 2.0
-pthread -sUSE_PTHREADS=1 # Threading support
-sJSPI # JSPI suspension
-sJSPI_EXPORTS=@scripts/common/jspi-exports.txt # promising-export census
--pre-js scripts/common/shims/jspi-scheduler.js # scheduler/turnstile shim
-sALLOW_MEMORY_GROWTH=1 # Dynamic memory
-sINITIAL_MEMORY=256MB # Starting memory
-sMAXIMUM_MEMORY=4GB # Maximum memory
-sMAX_WEBGL_VERSION=2 # WebGL 2.0
```
Headless targets (`kicad_tools`, `occ_service`) link **no suspension
backend**: nothing in them may suspend, so they carry none of the three
JSPI-related flags above.
## Testing
After building, run the test suite:

View file

@ -1,294 +1,276 @@
# Debugging guide — KiCad / wxWidgets WASM
# Debugging guide — KiCad / wxWidgets WASM (JSPI runtime)
A practical reference for debugging this project: the kinds of issues WASM +
Asyncify + browser builds throw at you, the tools that actually work here, and
the gotchas of our specific build pipeline. It is **not** a writeup of any one
bug — for a concrete worked example see [§6](#6-a-worked-example) and the
project memory.
A practical reference for debugging this project: how the JSPI runtime is
wired, the observability surfaces built into it, and the recipes that actually
work here. It is **not** a writeup of any one bug — the numbered docs under
[`docs/features/async/`](../features/async/) carry those; the current
architecture is [`23-jspi-runtime.md`](../features/async/23-jspi-runtime.md).
If you're new to this codebase, read [§5 (project gotchas)](#5-project-specific-gotchas)
first — most wasted hours come from not knowing how the split build and the
shim layer behave.
If you're new to this codebase, read [§1](#1-the-runtime-in-one-paragraph) and
then go straight to [§2 (observability)](#2-observability) — most questions of
the form "why is nothing happening" are answered by one `__wxWaitDump()` call.
---
## 1. Classes of issue we hit here
## 1. The runtime, in one paragraph
- **Engine-specific intolerance** — the same `pcbnew.wasm` runs in Firefox but
not Chrome (or vice versa). Usually a V8-vs-SpiderMonkey difference in how an
Asyncify-instrumented or very large function is handled.
- **Silent stalls vs. hard crashes** — execution stops making progress with *no*
exception, trap, or crash report. Distinguishing "crashed" from "hung" from
"stalled" is half the battle (§2.6).
- **Asyncify state problems** — unwind/rewind not completing, instrumentation on
a function that shouldn't have it, or a function too large once instrumented.
- **Shim/codegen coupling**`inject-dyncall-shims.sh` patches Emscripten output
by pattern; a flag change that alters codegen can silently break those patches.
- **Tooling blind spots** — async console delivery, stripped name sections,
Playwright hiding the renderer's stderr (§4).
Suspension is JSPI: every wasm entry point that can park is a
**promising export** (`-sJSPI` + the census in
`scripts/common/jspi-exports.txt`), and every suspension awaits a real JS
promise. One scheduler — `scripts/common/shims/jspi-scheduler.js`, shipped as
a `--pre-js` — owns the discipline around that: it wraps the promising
exports so it always knows which **activation** is executing or suspended,
gives each activation its own **spill-stack region** (JSPI switches the
native stack per activation but *not* the C spill stack — emscripten #27364),
and serializes engine re-entries through a resume **turnstile** so only one
activation's SP can be armed between wasm entries. KiCad tool coroutines run
on the JSPI backend of `kicad/thirdparty/libcontext/` (one promising
activation per coroutine, own region, promise-pair yield/resume) and
integrate with the same turnstile. There is no post-link instrumentation, no
unwind/rewind state machine, and no rewind buffer to corrupt: what used to be
"asyncify state problems" are now ordinary promise/call-shape problems.
---
## 2. Tools & techniques
## 2. Observability
### 2.1 Stub-bisection *(the workhorse)*
Comment out / early-`return` a suspect call, rebuild, and observe a **binary
survives-or-fails** outcome. This is the most reliable signal we have because it
does **not** depend on reading logs (which lag — see §4). Narrow by halving:
disable half the suspects, see which half flips the outcome.
- *When:* you can localize a failure to "before/after some call."
- *Caveat:* at `-O2`, dead-code elimination removes more around an early `return`
than you intend — keep this in mind when a stub "fixes" too much.
### 2.1 `__wxWaitDump()` — the first thing to run
### 2.2 `SHIM_DIAGNOSTICS=1` fast loop *(skip the rebuild)*
The only host-side JS step is `inject-dyncall-shims.sh`. Re-run it on a pristine
`pcbnew.js` while keeping the already-finalized/asyncified `pcbnew.wasm` — JS-only
changes go from a multi-minute rebuild to seconds:
```bash
cp output/pcbnew.pristine.js output/pcbnew.js
SHIM_DIAGNOSTICS=1 ./scripts/common/inject-dyncall-shims.sh output/pcbnew.js
cd tests && npm run setup:kicad
Available on any app page (and printed automatically by the SuspendError
attributor). Returns one object:
| field | meaning |
|---|---|
| `dead` | scheduler shut down (teardown seen) |
| `waitsBegun` / `waitsResolved` | token-wait registry totals (modal, nested, clipboard, lib-bridge …) |
| `earlyWaitResolves` | waits resolved before their waiter parked (legal fast path) |
| `pendingWaits` | unresolved registry entries right now |
| `runningActivations` | promising exports currently on the JS stack |
| `suspendedActivations` | array of `{id, kind, waitKind, token, suspendedMs}` — every parked activation |
| `mutatorsWrapped` / `mutatorsDelivered` / `mutatorQueueDepth` | the embind mutator FIFO (queued while `kicadOpenFileBusy`) |
| `ring` | the last 64 scheduler events (see §2.2) |
Reading it: a wedge usually shows up as an entry in `suspendedActivations`
with a large `suspendedMs` and a `waitKind`/`token` that tells you *what* it
is waiting for; cross-check `pendingWaits` and the ring. `id`s of the form
`"lc<N>"` are libcontext coroutines; negative ids are untracked/anonymous
suspensions (boot-time `main`, foreign yields).
### 2.2 Ring counting recipes
`__wxScheduler._ring` holds `[epochMs, event, a, b]` tuples (last 256; the
dump slices the last 64). Useful counts:
```js
// stale resumes refused by the doc-15 contract (a = 'lc<id>')
__wxScheduler._ring.filter(e => e[1] === 'libctxRefusedResume').length
// turnstile self-heals — should be 0 in a healthy run
__wxScheduler._ring.filter(e => e[1] === 'forceClearWindow')
// wakes dropped at quarantined (released-while-parked) coroutines
__wxScheduler._ring.filter(e => e[1] === 'deadWakeDropped')
// park/resolve balance per wait kind
__wxScheduler._ring.filter(e => e[1] === 'park').map(e => e[2])
```
See the `wasm-build-fast-iteration` project memory.
### 2.3 Logging-only diagnostics module (`scripts/common/shims/diagnostics.js`)
Injected **only** when `SHIM_DIAGNOSTICS=1` (off by default, safe to leave in
tree). Provides hooks that need no rebuild:
- Asyncify lifecycle: `doRewind`, `handleSleep` (unwind/rewind markers).
- Modal lifecycle.
- A **WebGL call tracer** (did any GL call happen before the failure?).
- A **dynCall tracer**: wraps the shim-bound `dynCall_ii`/`dynCall_vi` to log
`ptr`, `getWasmTableEntry(ptr).name` (the function index), and a JS stack for
rare/large table indices. Arm it at the main rewind to bound log volume.
- Periodic asyncify-state monitor (catch "JS task queue stopped pumping").
Other event names you will see: `beginWait`, `resolve`, `wrapped`,
`libctxQuarantine`, `shutdown`.
Output is at `console.log` level (not error/warn). This is the JS-side tracer; the
C++ source diagnostics are separate and flag-gated — see §2.9.
### 2.3 `__libctxJspi` — the coroutine census
### 2.4 Symbolizing wasm function indices
The loaded (post-asyncify) wasm has **no `name` section**, so V8/Firefox report
bare function indices (`func[20736]`). The Asyncify pass **preserves function
indices**, so a symbol map taken from the *pre-asyncify* wasm is still valid:
```bash
# the in-container wasm-opt is a STUB; use the real one
/emsdk/upstream/bin/wasm-opt.real <pre-asyncify pcbnew.wasm> --symbolmap=/tmp/syms.map
# then look up the index, e.g. 20736 -> PCB_EDIT_FRAME::setupUIConditions()
The libcontext JSPI backend keeps its own JS-side census:
```js
Object.keys(__libctxJspi.s).length // live coroutine slots (promise pairs)
__libctxJspi.tops // id -> spill-region top (SP swap target)
__libctxJspi.ghosts // ghost/refused transitions, ever
__libctxJspi.deadParked // coroutines released while parked mid-body
```
Generate the map from a build that still has names (the debug build's
pre-asyncify wasm). See §5 on names/DWARF.
### 2.5 Cross-engine comparison
Run the **same** diagnostics build in Firefox and Chrome and compare state at the
**same dispatch point** (e.g. asyncify `state`/`currData` at the suspect
`dynCall`). If both reach a point with identical state but only one proceeds, you
have isolated an engine-specific bug and can stop looking for a logic error.
Records are tombstoned, never freed C-side, so stale handles / double
releases / ghost resumes are refused loudly instead of corrupting anything —
each refusal bumps `ghosts` and prints a beacon (§2.4).
### 2.4 Beacon vocabulary
Everything the runtime is unhappy about is announced on the console with a
stable prefix. Test helpers count these (`tests/kicad/utils/wait-beacons.ts`);
when debugging by hand, grep the captured console for the prefix.
| beacon | source | meaning |
|---|---|---|
| `[libctx-jspi] ghost/refused transition … reason=<r>` | `libcontext.cpp` | a refused coroutine transition; `reason` is one of `ghost-enter`, `yield-no-cur`, `released-while-parked`, `dead-cur-substituted`, `yield-to-dead-enterer`, `release-of-running-ignored` |
| `[libctx-jspi] coroutine N entry REJECTED: <stack>` | `libcontext.cpp` | the coroutine's entry activation *rejected* (a trap inside the body); the enterer receives the refusal sentinel instead of hanging |
| `[libctx-jspi] REGION OVERFLOW: coroutine N …` | `libcontext.cpp` | the spill-region base canary tripped — a tool body outgrew its region |
| `[wx-scheduler] force-clearing stuck window …` | `jspi-scheduler.js` | turnstile self-heal: some suspension bypassed the shim (untracked raw await) and would otherwise block resumes forever |
| `[wx-scheduler] job tick error: …` | `evtloop.cpp` | a scheduled-job handler threw; containment fired |
| `[wx-scheduler] mailbox tick error: …` | `jspi-scheduler.js` | a delivered mailbox handler threw; `wx_dispatch_abandon` + top-wait resolution keep the app alive |
| `[wx-scheduler] shutdown (<why>) clean` / `… stranded:N` | `jspi-scheduler.js` | teardown contract — a clean exit *says so*; `stranded` counts waits that never resolved (asserted by `e2e/app-quit.spec.ts`) |
| `[wx-scheduler] SuspendError: …` | `jspi-scheduler.js` | see §3 — includes a full dump for targeting |
| `[wx-scheduler] LOST WAKE: …` | `jspi-scheduler.js` | watchdog: an activation parked >30 s on a token wait that is no longer registered |
| `[wx-scheduler] dropping wake for quarantined N` | `jspi-scheduler.js` | a late wake arrived for a released coroutine; refused (never re-enter a freed body) |
| `[wx-timer] retry storm: N retries …` | `timer.cpp` | a timer's `Notify` kept retrying against a held dispatch interlock — something is parked across ticks |
| `[wx-dispatch] ERASED … / NEGATIVE depth …` | `evtloop.cpp` | dispatch-interlock bookkeeping anomaly — depth accounting corrupt, report it |
A healthy run is beacon-silent apart from at most a `shutdown … clean`.
### 2.5 Tooling blind spots (read before trusting output)
- **Console is async**`printf`/`console.*` reaches Playwright via CDP
asynchronously; the *last delivered* line can lag the real failure point.
Prefer state dumps (§2.1) and binary-outcome bisection over "the last log
line".
- **Playwright hides the renderer** — it forces `--disable-breakpad` and only
pipes the *browser* process stderr. To see the renderer's own stderr and a
real crash reason, serve `tests/apps` with the COOP/COEP headers
(`tests/serve.json`, e.g. `npx serve apps -c ../serve.json`) and open the
page in a normal Chrome with crash reporting on.
- **macOS `sample`/`.ips`** see wasm frames as numeric offsets, not C++ names.
**Crash vs. hang vs. stall** — a failure with no exception is not necessarily
a crash. Find the renderer PID and inspect it:
### 2.6 Crash vs. hang vs. stall
A failure with no exception is not necessarily a crash. Find the renderer PID and
inspect it:
```bash
ps -axo pid,%cpu,%mem,command | grep -i 'Google Chrome'
sample <rendererPID> 3 # what is the main thread doing?
```
- **Idle in `CFRunLoop`/`mach_msg2_trap`, ~0% CPU** → a *stall* (event loop alive,
but nothing scheduled to run). Not a deadlock.
- **Blocked on a futex / `Atomics.wait`** → a pthread/lock issue.
- **Spinning at 100%** → an infinite loop.
- **Gone + a `.ips` report** → a real signal crash.
To see the **renderer's own stderr** and a real crash reason, launch system
Chrome **outside Playwright** (Playwright forces `--disable-breakpad` and only
pipes the *browser* process stderr): serve `tests/apps` with the COOP/COEP headers
(`tests/serve.json`) and open the page in a normal Chrome with crash reporting on.
On-load failures need no interaction to reproduce.
- Idle in `CFRunLoop`/`mach_msg2_trap`, ~0% CPU → a *stall* (event loop
alive, nothing scheduled). Under JSPI this almost always means a parked
activation whose wake was lost or refused — go read `__wxWaitDump()` and
the ring.
- Blocked on a futex / `Atomics.wait` → a pthread/lock issue.
- Spinning at 100% → an infinite loop.
- Gone + a `.ips` report → a real signal crash.
### 2.7 Build-flag diagnostics
- `-sASSERTIONS=2` turns silent UB into named errors. **But** it changes
Emscripten codegen and can break `inject-dyncall-shims.sh`'s `sed` patterns
(causing a *different*, red-herring failure), and it implicitly enables
`STACK_OVERFLOW_CHECK`, whose `___set_stack_limits` our host Asyncify pass
strips → pair it with `-sSTACK_OVERFLOW_CHECK=0`. Prefer the §2.3 dynCall
tracer on a normal build when you can.
- `--pass-arg=asyncify-asserts` (added to the `wasm-opt --asyncify` invocation in
`apply-asyncify.sh`) adds Asyncify state-machine runtime checks — use it to
validate the removelist (a wrongly-excluded function that *does* unwind is
otherwise silent corruption).
---
### 2.8 Isolated standalone probes
`tests/apps/standalone/coroutine-pthread/` builds minimal C++ probes with the
*real* libcontext + Asyncify + pthreads + DYNCALLS + the shim, run via
`tests/e2e/coroutine-pthread.spec.ts`. Use these to reproduce a mechanism in
isolation. **Reality check:** an isolated probe often *won't* reproduce a bug
that needs the full app runtime — don't over-trust a green probe.
## 3. Reading a SuspendError
Chromium: `RangeError: Trying to suspend without WebAssembly.promising` (or
similar `Suspend…` wording). Firefox: `No matching WebAssembly.promising`.
Both mean the same **call-shape problem**: a *plain* (non-promising) entry
into wasm reached a suspending import. The suspension has nowhere to go — a
promising activation is created at the *export* boundary, not at the park
site — so the engine throws at the park.
The scheduler's attributor catches these globally and prints
`[wx-scheduler] SuspendError: …` with a full `__wxWaitDump()` — the engine
cannot say *which* export was entered plainly, but the dump (what is wrapped,
what was executing) is exactly the targeting data you need.
Fixes, in order of likelihood:
1. **A missing census entry.** The export can suspend but is not declared:
add it to `scripts/common/jspi-exports.txt` *and* the scheduler wrap list
in `jspi-scheduler.js` *and* `tests/apps/Makefile.wasm`'s
`WX_JSPI_EXPORTS` (three synchronized copies — see doc 23).
2. **A plain embind registration.** Suspending embind exports must be
registered `emscripten::async()` — use `PCBJAM_PARKER_POLICY`
(`wasm/bindings/pcbjam_async_policy.h`).
3. **A genuinely illegal park** — code that must not suspend (a CLI/service
target with no suspension backend, an `emscripten_set_main_loop` callback)
grew a suspending call. Move the work behind a promising export instead.
---
## 4. The harnesses
### 4.1 `tests/apps/standalone/jspi-coroutine` — the coroutine contract battery
A wx-free MiniCoro that mirrors `tool/coroutine.h`'s protocol *exactly*
(INVOCATION_ARGS, callerStub + `finish_fcontext`, jumpIn/jumpOut,
CONTINUE_AFTER_ROOT) over the **real** `kicad/thirdparty/libcontext`. 18
cases: entry/yield/resume/completion, deep-stack preservation, nesting with
enterer inference, RunMainStack, value transfer, yield-inside-catch under
native wasm-EH, timer-driven resume, slot reclaim, ghost-resume refusal
(sentinel-shaped), mid-body release census, phantom-release refusal, and
destroy-while-parked containment.
### 2.9 Source diagnostic logging flags (`--diag=`)
The KiCad C++ source carries built-in diagnostic logging, **off by default**,
enabled per category at build time:
```bash
./docker/build.sh --debug --diag=gal,coroutine,ctor # or: --diag=all
cd tests/apps/standalone/jspi-coroutine
./build.sh # rebuild both variants against the real libcontext
node run.mjs # single-thread build, node
node run_pt.mjs # pthread build
# browser (both variants): tests/jspi/jspi-coroutine.spec.ts
```
| `--diag=` value | covers |
|---|---|
| `gal` | `[DIAG_GAL]` — GAL/WebGL pipeline (paint, context create/lock, init) |
| `coroutine` | `[WASM_FCONTEXT]` fiber switches + `[DIAG_TOOL]`/`[DIAG_DISP]` tool dispatch |
| `ctor` | `[DIAG_CTOR]``PCB_EDIT_FRAME` startup milestones |
- Each value maps to a `-DKICAD_DIAG_*` define that gates the `KI_DIAG_*` macros
in `kicad/include/kicad_wasm_diag.h`. All output goes to **stdout** → it shows
as `[KICAD_OUT]` logs, never `[KICAD_ERR]` errors.
- **Compile-time:** changing `--diag` changes `CMAKE_CXX_FLAGS`, so it forces a
recompile (slow once per flag combo, then ccache-cached). Works with `--debug`
or `--release`.
- Separate from the JS shim tracer (§2.3), which stays `SHIM_DIAGNOSTICS`-gated.
Output contract: `[JSPI_CORO] CASE <name> PASS|FAIL(<detail>)`, then
`[JSPI_CORO] SUMMARY passed=<n> failed=<n>`. If `build.sh` dies inside
emscripten's python driver, point `EMSDK_PYTHON` at a modern interpreter
(≥3.10; 3.13 known-good).
### 4.2 `tests/jspi/suspend-races.spec.ts` — semantic suspension races
The suspension-race scenarios (nested modal LIFO, out-of-order wake
resolution, no-lost-wakes, nested-loop teardown-on-error), run against the
races harness built for JSPI. The scenarios express through public wx +
coroutine APIs, so they are exactly as meaningful under JSPI — only the
failure *modes* they'd catch differ (activation misnesting or a lost wait
token). `tests/jspi/jspi-stack.spec.ts` is the red/green proof of the
spill-stack discipline itself.
### 4.3 Isolated probes, generally
A standalone probe often *won't* reproduce a bug that needs the full app
runtime — don't over-trust a green probe. The reverse recipe still holds
too: stub-bisection (comment out a suspect call, rebuild, observe a binary
survives-or-fails outcome) beats staring at logs, because it does not depend
on console delivery order.
---
## 3. Principles
## 5. Browser notes
1. **Reproduce cleanly first** — a stable engine-X-fails / engine-Y-passes
baseline before changing anything.
2. **Fix the build infra before iterating** — a flaky build wastes every
subsequent experiment.
3. **Narrow by bisection**, with binary outcomes, not by staring at logs.
4. **Turn silent failures into named ones** (assertions, asyncify-asserts) or
into a state comparison across engines.
5. **Know the tooling's blind spots** (§4) before trusting what it shows you.
- **Firefox 153+ is the strict engine.** JSPI is on by default (Playwright
≥1.62 ships FF 153) and a *plain* embind call into a suspending body throws
immediately. Chromium tolerates some shapes FF refuses — the sync
`kicadTestFiberPark*` levers are usable for manual probing **on Chromium
only**. If a suspension bug reproduces on one engine only, suspect a
call-shape difference first (§3), not a logic difference.
- **Firefox runs big promising modules on a slow tier.** Observed as library
enumeration slowness (FootprintEnumerate rows never appearing within 60 s
on the remote read path); tracked upstream (#42199). The firefox leg of
`footprint-browse-remote` is gated on it.
- **COOP/COEP.** SharedArrayBuffer/pthreads need cross-origin isolation
headers; serve `tests/apps` with `tests/serve.json`.
---
## 4. Tooling blind spots (read before trusting output)
## 6. Build-side debugging
- **Console is async**`printf`/`console.*` from WASM reaches Playwright via
CDP asynchronously; the *last delivered* line can lag the real failure point.
Use stub-bisection for ground truth, not "the last log line."
- **No name section** in the shipped wasm → bare indices (§2.4).
- **Asyncify shifts code offsets** — DWARF line info is generated before the host
Asyncify pass rewrites the code, so source-line mapping on the *shipped* wasm is
stale. Asyncify *does* preserve function indices and names.
- **Playwright hides the renderer** — forces `--disable-breakpad`, pipes only the
browser process stderr (§2.6).
- **macOS `sample`/`.ips`** see wasm frames as numeric offsets, not C++ names.
- **The build is single-phase.** `docker/build.sh` compiles, links *and
finalizes* inside the container; the only host step is
`node scripts/common/patch-env-shim.mjs` (merges `Module.ENV` into the
glue's `ENV` so `?trace=` works — seconds). `--compile-only` /
`--postprocess-only` split the two when CI caches the compile. There is no
post-link wasm rewriting to go wrong: what you linked is what runs.
- **Logs + monitor.** Builds redirect all output to
`logs/<script>/<timestamp>.log`; `./scripts/build-monitor.sh` renders a
live stage dashboard off the newest log (`--once` for a snapshot).
- **Docker compose project-name trap.** `build.sh` derives
`COMPOSE_PROJECT_NAME` from the git branch (`kicad-wasm-<branch>`), so each
branch has its own build-cache volume. Any *manual* `docker compose` run
must export the same `COMPOSE_PROJECT_NAME` first or it silently targets a
scratch volume.
- **Source diagnostic logging** (off by default, per-category at build time):
---
```bash
./docker/build.sh --debug --diag=gal,coroutine,ctor # or: --diag=all
```
## 5. Project-specific gotchas
| `--diag=` value | covers |
|---|---|
| `gal` | `[DIAG_GAL]` — GAL/WebGL pipeline (paint, context create/lock, init) |
| `coroutine` | `[WASM_FCONTEXT]` coroutine switches + `[DIAG_TOOL]`/`[DIAG_DISP]` tool dispatch |
| `ctor` | `[DIAG_CTOR]``PCB_EDIT_FRAME` startup milestones |
- **Split build.** `docker/build.sh` compiles + links inside Docker, but the
in-container `wasm-opt` and `wasm-emscripten-finalize` are **stubbed** (they OOM
on the large wasm). The real `wasm-emscripten-finalize` and
`wasm-opt --asyncify` run **on the host** afterward (`apply-finalize.sh`,
`apply-asyncify.sh`). Real binary: `…/upstream/bin/wasm-opt.real`.
- **Per-branch Docker volumes.** The compose project name is derived from the git
branch, so each branch has its own build-cache volume/container. Switching
optimization level (`-O1``-O2`) busts ccache and forces a full recompile.
- **COOP/COEP.** SharedArrayBuffer/pthreads need cross-origin isolation headers;
serve `tests/apps` with `tests/serve.json` (`npx serve apps -c ../serve.json`).
- **The shim layer.** `inject-dyncall-shims.sh` binds bare `dynCall_<sig>` to the
real `DYNCALLS=1` exports and patches several Emscripten empty-stub callbacks by
`sed` pattern — so codegen-changing flags can silently break it.
- **Names / DWARF, concretely.** Neither build keeps a `name` section in the
*runtime* wasm (it carries only `external_debug_info` + `target_features`). The
**debug** build (`-O1 -g -gseparate-dwarf`) puts full DWARF in a ~1.5 GB
`pcbnew.wasm.debug.wasm` sidecar (loaded on demand by DevTools' C/C++ extension);
the **release** build (`-O2`, no `-g`) has neither names nor DWARF. So readable
symbols come from the debug build's DWARF / the §2.4 symbol map, not from the
shipped binary.
---
## 6. A worked example
The **Chrome-only startup stall** (May 2026): V8 could not run the
Asyncify-*instrumented* `PCB_EDIT_FRAME::setupUIConditions()` (a huge function
that never actually unwinds) when it was invoked from the Asyncify-rewound
constructor stack — a silent stall, not a crash; Firefox ran the identical wasm
fine. Found with stub-bisection (§2.1) + the dynCall tracer (§2.3) + symbol map
(§2.4) + cross-engine state comparison (§2.5) + `sample` (§2.6).
A **second instance** of the same family (May 28, 2026) hit the line-drawing
coroutine: V8 stalled at the first instruction of the asyncify-instrumented
`libcontext::wasm_fcontext_entry` trampoline when a new fiber for
`pcbnew.InteractiveDrawing.line` was entered. The `[DIAG_TOOL]` log showed
the activate dispatching and `[WASM_FCONTEXT]` showed `jump-swap` completing,
but `entry-call` (logged on the new fiber's first statement) never fired —
the tool's button visually never toggled, and tests on headed Chrome **could
not reproduce** it (same wasm, different cumulative asyncify state). Trying
to add the trampoline / `COROUTINE::callerStub` to `ASYNCIFY_REMOVE` broke
runtime because both functions sit ON the suspend chain (their callees
`emscripten_fiber_swap` / suspendable tool bodies), so removing them from
instrumentation orphans the rewind — `null function` / `ASM_CONSTS` errors.
The systemic fix (see [§7](#7-debug-vs-production-builds)) is now **committed
default**: run `wasm-opt -O2` as a separate pass after `--asyncify` in
`scripts/common/apply-asyncify.sh`. This shrinks every instrumented function
back under V8's threshold, including the coroutine trampolines that can't be
removelist'd. The legacy `ASYNCIFY_REMOVE` entries (`setupUIConditions`
etc.) are kept as a redundant safety net — under `-O2` they're no longer
required but are harmless.
Details: the `chrome-asyncify-rewind-crash` and `bundle-size-asyncify-optimization`
project memories, and git history of `apply-asyncify.sh`.
---
## 7. Debug vs. production builds
The committed default is the **debug** build (compiled `-g -gseparate-dwarf`,
DWARF sidecar) with `apply-asyncify.sh` running `wasm-opt --asyncify` followed
by `wasm-opt -O2` (May 28, 2026). Result: ~187 MB wasm / ~65 MB gzip, full
source-level debugging. Switch to release (`./docker/build.sh` without
`--debug`) for an even smaller shippable build with no DWARF.
### What the knobs do
Two independent knobs:
- **`-g` (debug info)** — whether a source map exists at all. Debug =
`-g -gseparate-dwarf` (DWARF sidecar); release = none.
- **`-O` (optimization)** — how much the code is rewritten. This is what actually
fixes the "function too big for V8" class of bug, because Asyncify emits
deliberately verbose instrumentation (spills every live local) and **relies on
the optimizer to coalesce it back down**. The Emscripten/Binaryen docs are
emphatic that you must optimize when using Asyncify.
### How the build flow uses both
1. **Docker compile + link** (`./docker/build.sh [--debug]`) produces an
un-finalized, un-asyncified wasm. `--debug` controls only `-g`; the
`-O2` optimisation level is set unconditionally at compile time.
2. **Host post-processing** (`scripts/common/apply-finalize.sh` then
`scripts/common/apply-asyncify.sh`):
- `wasm-opt --asyncify` instruments suspendable functions.
- `wasm-opt -O2` (added May 28, 2026) shrinks every instrumented
function back under V8's per-function locals limit, fixing the
"Chrome-only stall on coroutine entry" class of bug systemically.
Without this pass, large asyncify-instrumented functions like
`PCB_EDIT_FRAME::setupUIConditions()` or libcontext's
`wasm_fcontext_entry` silently stall in Chrome's V8 even though
Firefox runs them fine. The two passes are run separately so peak
RAM stays ~1015 GB (one heavy `wasm-opt` at a time).
3. **Shim injection** (`scripts/common/inject-dyncall-shims.sh`) adds the
asyncify-aware dynCall bindings and the nested-asyncify `handleSleep`
wrapper to `pcbnew.js`.
### The `ASYNCIFY_REMOVE` list (in `apply-asyncify.sh`)
With `-O2` after asyncify, no large function should exceed V8's limit anymore,
so the removelist is mostly a redundant safety net. Two situations still
warrant adding to it:
- A function whose subtree does **not** asyncify-suspend (so removing it is
always safe) and that you're confident never needs to participate in
unwind/rewind. Example: `setupUIConditions()` — registers handlers, never
yields.
- **Don't** add functions on the asyncify-suspend chain (coroutine
trampolines, anything calling `emscripten_fiber_swap` / `EM_ASYNC_JS`):
removing them orphans the rewind path and you get `null function` /
`ASM_CONSTS[code] is not a function` at runtime.
### Measured result (May 2026)
| build | raw wasm | gzip | source-level debugging |
|---|---|---|---|
| debug, asyncify only (old default) | 338 MB | 137 MB | full (DWARF sidecar) |
| debug + asyncify + `-O2` (current default) | **187 MB** | **65 MB** | full (DWARF sidecar) |
| release + asyncify + `-O2` | smaller still | — | none |
The optimized build passes Chrome **and** Firefox `select draw lines` e2e,
fixes the user-reported "line tool doesn't toggle in real Chrome" stall, and
makes the test load+run ~2× faster (smaller wasm parses faster). Tradeoff:
each build now spends an extra ~10 minutes on the `-O2` pass.
Each value maps to a `-DKICAD_DIAG_*` define gating the `KI_DIAG_*` macros
in `kicad/include/kicad_wasm_diag.h`; output goes to stdout
(`[KICAD_OUT]`). Changing `--diag` changes `CMAKE_CXX_FLAGS` → forces a
recompile (ccache-cached per flag combo).
- **Per-branch volumes + optimization level.** Switching `-O1``-O2` busts
ccache and forces a full recompile; plan accordingly.

View file

@ -4,6 +4,8 @@ Status: 2026-07-31 · red/green e2e `tests/kicad/fiber-resume-park.spec.ts` ·
lineage: 14 (open-settle gate), 15 (timer-park lever + the v0.1.20 decode),
drift-trio #10b (fiber buffers, ghost beacons).
> Successor spec: tests/kicad/coroutine-lifecycle.spec.ts (fiber-resume-park.spec.ts was retired with the asyncify backend).
## The bug
`RuntimeError: index out of bounds` / `unreachable executed` on prod board

View file

@ -0,0 +1,228 @@
# 23 — The JSPI runtime (current architecture)
Status: 2026-08-14 · CURRENT · supersedes the TL;DR of this directory's
README ("we are not switching to JSPI" — we did, 2026-08) ·
lineage: 21 (park-site audit = the migration surface), 22 (the absorb plan
this replaced: one owner for every switch — JSPI delivered that owner as the
engine itself), doc 15 (stale-resume refusal), doc 18 (embind mutator/parker
classification).
This is the reference for how suspension works **now**. The numbered docs
0122 are the Asyncify-era investigation log; read them as history.
## 1. The shape of the runtime
Every wasm entry point that can suspend is a **promising export**. A
suspension inside one is a plain `await` in an imported JS function: the
engine parks that activation's native stack and returns a Promise to the JS
caller. No instrumentation pass, no unwind/rewind state machine, no shared
suspension register — the failure family the 0122 docs fought (two
subsystems clobbering one `currData`) is unrepresentable.
What JSPI does **not** solve, and the two shims do:
- **The C spill stack is not switched per activation** (emscripten #27364).
Both shims apply the "green-region" discipline proven red/green by
`tests/apps/standalone/jspi-stack`: every activation gets its own spill
region and the shared `__stack_pointer` is swapped only at window
boundaries.
- **Engine re-entries are not serialized.** The scheduler's resume
turnstile (§3) makes them so.
## 2. The promising-export census — three synchronized copies
The set of promising exports is declared in THREE places that must stay in
sync (a name missing from one produces a SuspendError at runtime, not a
build error — see `docs/debugging/DEBUG.md` §3):
| copy | consumer |
|---|---|
| `scripts/common/jspi-exports.txt` | `-sJSPI_EXPORTS=@…` at link (`build-kicad-target.sh`) |
| `jspi-scheduler.js` `installExportWraps([...])` | activation tracking + spill regions for the same names |
| `tests/apps/Makefile.wasm` `WX_JSPI_EXPORTS` | the wx test apps + races/coroutine harness links |
The census is the wx KEEPALIVE entries that can park (`wx_dom_event`,
`wx_dom_mouse`, `wx_window_*`, `ProcessEvents`, the three ticks), `main`,
and `pcbjam_libctx_entry` (the coroutine entry export). Regenerate by grep,
not from memory. The embind parkers (§5) are a fourth surface but carry
their own declaration (`emscripten::async()`), not a census entry.
## 3. The scheduler/turnstile contract (`jspi-scheduler.js`)
**Windows.** A promising export's execution is a sequence of windows: the
FIRST window runs synchronously from its JS caller (a real JS frame, tracked
by `_actStack` push/pop), each RESUMED window is entered by the engine from
a promise reaction (no JS frame of ours — tracked by `_windowLive`). The
wasm executing at any moment belongs to `_actStack`'s top when non-empty,
else `_windowLive`.
**Suspension records.** Every park lands in `_suspended` (id → record with
`kind`, `waitKind`, `token`, `sp`, `suspendedAt`). Wait kinds: the wx token
waits (`modal`, `nested`, `clipboard`, `font`), the KiCad lib bridge (`lib`,
`fp-lib`), the shim's own yields (`frame`, `sleep`, `promise`), and the
coroutine hooks (`libctx-enter` = an enterer awaiting a yield, `libctx` = a
coroutine parked on its own yield). `__wxWaitDump()` is a live view of all
of it.
**Resume turnstile.** SP swaps happen only at microtask boundaries and for
at most ONE activation between wasm re-entries. Ready resumes queue in
`_resumeReady`; `_pumpResume` arms exactly one (SP → its region, record →
`_windowLive`) and resolves its gate; the engine's re-entry is the only
reaction on that gate. The next pump runs when that window ends — its next
suspension or its completion, both observed. A window stuck armed >2 s while
resumes queue is force-cleared with a beacon (a suspension bypassed the
shim).
**Mutator FIFO.** The doc-18 mutator class (`kicadCollabApply`, saves, theme
flips, …) must not enter wasm while a board load is in flight — the open
activation is suspended mid-load and a mutator entering between its parks
would mutate the board under it. The wrap queues them while
`kicadOpenFileBusy()` is true and drains the FIFO in order, time-boxed,
once it clears. Semantic exclusion; nothing engine-specific about it.
**Mailbox lane.** Timer/wheel callbacks queue via `enqueueAfter` and are
delivered in order from a fresh task through the `_wxWasmMailboxTick`
promising export; a suspension inside a delivered handler parks the tick's
own activation. A throwing handler triggers containment: `wx_dispatch_abandon`
plus resolution of the top `nested`/`modal` waits, so a parked quasi-modal
is never left unresolvable.
## 4. libcontext: ownership, refusal, quarantine
The KiCad coroutine backend (`kicad/thirdparty/libcontext/libcontext.cpp`,
wasm32 platform) runs each coroutine as ONE promising activation with a
promise pair per switch (`yielded` / `resume`). Records are tombstones —
never freed, ~48 B, censused — so every stale-handle path is refused
loudly instead of corrupting memory.
**Ownership rule.** A `COROUTINE` owns exactly one record: `m_callee.ctx`.
`m_caller.ctx` is BORROWED — written by `jump_fcontext`'s symmetric
protocol, it names whoever entered you (or the root). The **2026-08-13
phantom-release bug**: `~CALL_CONTEXT` released the borrowed caller handle;
under the fiber backend that was survivable, under JSPI it killed a LIVE
coroutine's record mid-slice (the "dead tools" bug — every tool dead after
one dialog). Fixed twice over in `db81985`: the destructor releases only
what it owns, and the backend REFUSES release of a running record or of any
record on the current enterer chain (censused as
`release-of-running-ignored`).
**Refusal sentinel.** A refused transition returns a pointer to a static
INVOCATION_ARGS-shaped sentinel (`FROM_ROUTINE`, null destination/context) —
**never raw 1**. `coroutine.h` dereferences jump returns unconditionally,
and a live coroutine CAN legitimately observe a refusal (a nested-dispatch
partner dying mid-flight); the old "unreachable" premise was disproven by a
boot-time OOB. The doc-15 stale-resume contract also survives translation:
`js_libctx_resume` refuses to resume a coroutine parked on a FOREIGN wait
(its turnstile record's `waitKind` isn't `libctx`) — the legitimate wake is
that wait's own resolution — ringing `libctxRefusedResume`.
**Quarantine / destroy-while-parked.** Releasing a coroutine parked
mid-body marks the record dead, bumps `deadParked`, drops its turnstile
record, and hands a parked enterer the sentinel so it un-hangs. A late wake
for a quarantined record is dropped by the pump (never re-enter a freed
body); a stray jump at the corpse gets the sentinel; double release is
idempotent. Contained: the rest of the world keeps scheduling.
## 5. Embind call shapes (the delivery-mechanics table)
How a JS→wasm call may interact with suspension is decided at registration:
| shape | suspension | semantics |
|---|---|---|
| plain embind `function(...)` | **must not suspend** | first park throws SuspendError (strict on Firefox ≥153) |
| `emscripten::async()` (bare) | legal | **rerun hazard**: embind re-executes the invoker when the awaited promise settles — observed during the migration as the triple-poke (one `kicadTestFiberParkPoke()` call landing three body executions). Use only for idempotent bodies, or don't. |
| raw KEEPALIVE export in the JSPI census | legal | one-shot: the body runs once per call, the call returns the activation's promise — the wx entry points' shape |
| `PCBJAM_PARKER_POLICY` + scheduler parker wrap | legal | `async()` under the hood, plus activation tracking, an 8 MB spill region (board parses are deep), and turnstile serialization — `kicadOpenFile` / `kicadOpenFiles` / `kicadLibsReload` |
This table is why the `kicadTestFiberPark*` levers stayed sync-registered:
neither legal shape can deliver a *mid-park* poke (the parker wrap would
defer it — the exact race the levers exist to stage). They are manual
Chromium-only probes; their contracts are pinned by the §6 battery instead.
## 6. The coroutine contract battery (18 cases)
`tests/apps/standalone/jspi-coroutine/coroutine_jspi_test.cpp` — a wx-free
MiniCoro mirroring `tool/coroutine.h`'s protocol exactly over the real
libcontext. Node + browser, single-thread + pthread builds
(`tests/jspi/jspi-coroutine.spec.ts`). What the families pin:
- **Lifecycle** (1, 7, 10, 11): entry runs the body exactly once to first
yield; completion flips `Running()`; values round-trip; 48-yield stress.
- **Spill-stack discipline** (2, 3): locals and a 6-deep recursive frame
survive suspension — the green-region proof at protocol level.
- **Nesting / enterer inference** (4, 5): child-in-parent routing, a parent
yielding over a parked child — direction inferred from the enterer chain.
- **Root bounce** (6): `RunMainStack` runs the functor on the caller's
activation and resumes with the payload (`CONTINUE_AFTER_ROOT`).
- **wasm-EH interplay** (12): yield INSIDE a `catch` block — the case the
HoistCppCatches binaryen pass existed for, now native.
- **Dispatch shape** (13): resume driven from a JS timer through the wait
import.
- **Reclaim + ghosts** (8, 14): finished activations release their JS slots
and regions; a post-finish jump refuses with the SENTINEL (shape-checked).
- **Release semantics** (15, 16, 17, 18): mid-body release censused and
never resumed; release of the RUNNING record refused (the phantom-release
shape); release on the enterer chain refused; destroy-while-parked fully
contained (census +1 exactly once, corpse jumps sentinel, fresh
coroutines unaffected).
## 7. Services under emscripten 6
Emscripten 6 **removed `Module.mainScriptUrlOrBlob`**. The pthread glue now
spawns its workers from `_scriptName` = `self.location.href` — for a
blob-booted service worker that is the *wrapper blob itself*, so every
pthread child re-executes the wrapper. `occ-worker.js` / `ngspice-worker.js`
handle it with the **em-pthread realm trick**: if `globalThis.name ===
"em-pthread"`, just `importScripts(GLUE)` and get out of the way (the glue
tail self-instantiates into pthread-child mode). Without the branch the
wrapper re-boots a whole service per pthread — the observed worker-spawn
storm with the pool never filling.
**KNOWN GAP (CDN cross-origin pthreads, editor path):** `boot.ts` used to
pin the pthread worker script via `mainScriptUrlOrBlob` — same-origin URL
directly, cross-origin CDN base via a same-origin `blob:` that
`importScripts` the CDN glue (with ACAO + CORP headers). With the option
gone, the *editor's* cross-origin pthread spawn path has no equivalent pin;
same-origin serving works. Not fixed in the cleanup — tracked here.
## 8. Exception policy
- **`wxApp::OnExceptionInMainLoop`** (`wxwidgets/src/wasm/app.cpp`): a
throwing event handler must not tear down the app. The wx default exits
the main loop — which reads as a silent clean shutdown mid-session. The
override logs `[wx-app] unhandled exception in event handler: …` and
returns true: the loop lives.
- **JS-side containment**: `wx-dom.js` contains rejections escaping a
dispatch, and both delivery lanes' error paths call
`wx_dispatch_abandon()` + resolve the top `nested`/`modal` waits — a
throwing handler under an open quasi-modal must not strand the parked
modal wait (the doc-19 family under new mechanics).
- **Coroutine traps**: an entry activation that rejects prints
`[libctx-jspi] … entry REJECTED` with the stack, and the enterer receives
the refusal sentinel — a trapped tool body is contained, not amplified.
## 9. Known gaps & upstream issues
- **Firefox slow wasm tier** — big promising modules run slow on FF;
observed as FootprintEnumerate rows never appearing in 60 s (remote read
path). Upstream #42199. The firefox leg of `footprint-browse-remote` is
gated on it.
- **Editor write bridge rot** — symbol/footprint WRITE flows wedge at the
New Symbol/Footprint dialog on both engines (pre-dates the migration; the
web-e2e-rot 01 gap stands). Read paths are green.
- **3D raytracer engine toggle inert** — the toolbar toggle does not engage
the raytracer on the webgl-era wasm; pinned KNOWN-ISSUE in
`tests/kicad/3d-viewer-deadlock.spec.ts`.
## 10. Migration evidence
- **Workflow results**: `migration-evidence/wf-result-11.json` /
`wf-result-12.json` (the durable spike output; the rest of the
`.jspi-assets/` spike tree was scratch and is gone — its ignore rule came
from a global git-excludes file, not this repo's `.gitignore`).
- **The investigation log**: docs [`01`](01-background-and-findings.md)[`22`](22-absorbing-libcontext.md)
in this directory (Asyncify-era; historical).
- **The migration commits**: parent `3f09a46` + `e14faec` + `db81985`
(phases 07, pipeline retirement, ownership fix + suite green) with
`3ee174e` (un-skip sweep), kicad `012d95ecb4`, wxwidgets `1b5f0e31f4`;
the JSPI-only cleanup commit followed on `experiment/jspi`.

View file

@ -1,5 +1,10 @@
# Asyncify `currData` contention in KiCad-WASM — research dossier
> **STATUS (2026-08-14): HISTORICAL.** This directory is the Asyncify-era
> investigation log. The JSPI migration (2026-08) superseded the TL;DR
> conclusion below ("we are not switching to JSPI"). Current architecture:
> [23-jspi-runtime.md](23-jspi-runtime.md).
> **Status:** research / understanding only. No implementation has been chosen.
> Authored 2026-06. All line numbers are against the artifacts current at that time
> (`tests/apps/kicad/pcbnew.js`, `wxwidgets/src/wasm/*.cpp`,
@ -50,11 +55,11 @@ or **hang** (a swap unwinds but is never rewound).
| [`22-absorbing-libcontext.md`](22-absorbing-libcontext.md) | **PLAN (2026-08-06), the current one:** absorb libcontext's wasm backend into the scheduler so ONE handler owns every js↔asyncify↔fiber switch — the cure for the blue screen (a context recovered twice or by the wrong fiber). Diagnosis of why three guard layers cannot fix it, why the D2/D3 phases knotted, phases AF with estimates (~46 wk), gates, and the traps this implementation run paid for. **Start here.** |
| [`17-mailbox-scheduler-plan.md`](17-mailbox-scheduler-plan.md) | **PLAN (2026-08):** the mailbox/scheduler implementation plan — Design B's phasing revised with the JulyAugust guard record (dispatch interlock, open-settle gate, v0.1.28 schedule-don't-dispatch). Test inventory with per-test fate (keep / rewrite / retire / new), 7 steps S0S6 with gates and rollback, ≈57 wk. Supersedes 12/13's phasing; overturns 13 §6f's "no scheduler needed". |
## Where to start (2026-08-06)
## Where to start (2026-08-14)
**Read [`22-absorbing-libcontext.md`](22-absorbing-libcontext.md).** It carries the current
plan, the diagnosis behind it, and the traps the last implementation run paid for.
Prerequisites: [`20`](20-design-b-core-plan.md) §10 (what each phase actually cost) and
**Read [`23-jspi-runtime.md`](23-jspi-runtime.md)** — the current (JSPI)
architecture. The plan that got there is [`22-absorbing-libcontext.md`](22-absorbing-libcontext.md),
with prerequisites [`20`](20-design-b-core-plan.md) §10 (what each phase actually cost) and
[`21`](21-park-site-audit.md) (the migration surface).
<details>

View file

@ -0,0 +1,93 @@
{
"summary": "The coroutine-backend replacement is now designed, prototyped, and sized. A working JSPI backend implementing KiCad's exact COROUTINE<> contract (Call/Resume/KiYield-with-value/RunMainStack/Running, incl. nested FromRoutine Call/Resume) was built as one 833-line file (~200 LOC of backend, the rest test cases ported verbatim from the repo's own coroutine harness) and passes 14/14 cases under emcc -sJSPI + node --experimental-wasm-jspi, including yield-inside-a-C++-catch under native wasm-EH (the case the 407-line HoistCppCatches binaryen fork pass exists for) and resume-from-a-JS-timer via a promising export. Design mapping: each COROUTINE = one WebAssembly.promising activation; KiYield/Resume = a pair of suspending imports exchanging per-coroutine promise pairs; RunMainStack = the existing CONTINUE_AFTER_ROOT bounce reproduced in ~10 LOC. Recommended shape (Option A) keeps coroutine.h (648 LOC) and tool_manager.cpp 100% untouched \u2014 satisfying the fork-divergence rule \u2014 by rewriting only the wasm32 section of thirdparty/libcontext/libcontext.cpp (lines 30\u2013899, ~870 LOC \u2192 ~300\u2013350 LOC) plus a 1-line LIBCONTEXT_HAS_OWN_STACK define; all ~91 Wait() sites in 41 files stay untouched. Net effect: ~2,300\u20133,000 LOC of the hardest-fought code (fiber grace ring, ghost-resume epochs, 801-line asyncify-scheduler.js shim, 242 LOC of post-link asyncify scripts, HoistCppCatches, sched_context fiber lane ~675 LOC) is deleted, and the two production trap classes (stale-rewind \"unreachable executed\", asyncify-buffer overflow corruption) become structurally impossible. Main residual risks: Firefox (repo CI runs firefox e2e) lacks shipped JSPI; JSPI+pthreads needs a spike; every Call/Resume now crosses a microtask checkpoint (was: only when a tool parked).",
"findings": [
{
"title": "Prototype: full COROUTINE contract on JSPI passes 14/14, including KiCad's exact nested/RunMainStack sequences",
"detail": "Built CoroJspi with the same API as the repo's TestCoroutine harness (kicad_coroutine_harness.h) and ported the harness's scenario set verbatim: first_entry_runs_once, yield_resume_preserves_state, deep_stack_preserved_across_yield (6-deep recursion), nested_coroutine_call_and_resume, nested_parent_yield_preserves_suspend (child stays suspended across parent's root yields \u2014 the FromRoutine semantics), root_bounce_continue_after_root (RunMainStack: before-root/on-root/after-root order, rootRuns==1, resume value 77), completion, resume_after_finish_does_not_reenter, interleaving_multiple_coroutines, stress_many_round_trips (96 switches), transfer_values_round_trip, yield_inside_catch_block_wasm_eh, async_wait_loop_timer_dispatch (setTimeout \u2192 promising export \u2192 Resume, KiCad's dispatchInternal pattern), non_promising_entry_traps (negative). All PASS; summary 'passed=14 failed=0'. Backend core is ~200 LOC: 5 EM_ASYNC_JS/EM_JS promise-pair shims (~70 LOC), CoroJspi class (~112 LOC), promising entry export (~19 LOC). Built with emcc 4.0.24-git (project pins 4.0.2): -sJSPI -sJSPI_EXPORTS=jspi_coro_entry,drive_step,main -fwasm-exceptions -O1; run with node v24.9.0 --experimental-wasm-jspi.",
"evidence": "/private/tmp/claude-501/-Users-V-IdeaProjects-pcbjam-private/d222fb80-dbf2-47d6-b738-85cb73ac9c45/scratchpad/jspi-proto/jspi_coroutine_proto.cpp (backend lines 30-245, tests 250-833), build.sh in same dir; harness mirrored: pcbjam/tests/apps/standalone/coroutine/kicad_coroutine_harness.h:14-250 and coroutine_test.cpp:255-945",
"jspi_impact": "win \u2014 the single undemonstrated claim of the study ('each COROUTINE as a promising activation with suspending-import yields') is now demonstrated end-to-end with the contract's own test scenarios."
},
{
"title": "Design mapping: COROUTINE primitives \u2192 JSPI primitives",
"detail": "Call(arg): suspending import js_coro_start synchronously invokes the promising-wrapped entry export (new suspendable stack; runs coroutine body synchronously to first yield per spec 'lets context be a new execution context'), then awaits a 'yielded' promise \u2192 caller's wasm frame resumes with the yield value. KiYield(v): suspending import resolves the caller's 'yielded' promise with v, awaits a fresh 'resume' promise. Resume(v): suspending import resolves 'resume' with v, awaits next 'yielded'. Coroutine finish: SYNC import js_coro_finish resolves 'yielded' before the promising activation unwinds (ordering guarantee). RunMainStack: reproduces coroutine.h's CALL_CONTEXT::Continue loop (coroutine.h:175-183) in ~10 LOC \u2014 yield with a RUNMAIN flag; the caller's handshake loop runs the functor on its own activation then re-resumes. Nested FromRoutine Call/Resume (coroutine.h:312-322, 358-368) collapse to the plain operations: the promise handshake makes the return path structural, so the whole FROM_ROOT/FROM_ROUTINE/CONTINUE_AFTER_ROOT INVOCATION_ARGS protocol (coroutine.h:88-103) and SetMainStack tracking (coroutine.h:537-538, 604-607) disappear. Requirement inherited from the spec: every C++ frame calling Call/Resume must itself be inside a promising activation ('Traps if context's state is not Active[caller]').",
"evidence": "pcbjam/kicad/include/tool/coroutine.h:88-103,175-183,249-276,286-368,518-546; prototype CoroJspi lines 110-245; JSPI spec https://github.com/WebAssembly/js-promise-integration/blob/main/proposals/js-promise-integration/Overview.md ('Traps if context's state is not Active[caller]'; 'Traps if there are any frames of non-WebAssembly functions in frames')",
"jspi_impact": "win \u2014 the protocol emulation layer (INVOCATION_ARGS, main-stack tracking, symmetric-swap bookkeeping) is deleted, not ported."
},
{
"title": "Backend-only swap is confirmed feasible: Option A touches only thirdparty/libcontext, leaving coroutine.h + tool_manager.cpp + all Wait() sites untouched",
"detail": "Production contract sites are exactly: tool_manager.cpp:110 (COROUTINE<int,const TOOL_EVENT&>* cofunc decl), :859 (new COROUTINE), :870 (Call), :592+:808 (Resume), :754 (KiYield via ScheduleWait), :723+:735 (RunMainStack incl. RunOnMainStackIfActiveTool), :873+:729 (Running), :177+:238 (delete cofunc). Only 2 other instantiation sites, both qa: qa/tools/common_tools/tools/coroutines/coroutines.cpp:38, qa/tests/common/test_coroutine.cpp:91. Wait() census: 91 loose-pattern call sites in 41 files (top: pcbnew/tools/drawing_tool.cpp 12, eeschema/tools/sch_drawing_tools.cpp 10, pcb_selection_tool.cpp 5); none change. Option A: rewrite libcontext.cpp's wasm32 section (lines 30-899, ~870 LOC) to the JSPI handshake while keeping the symmetric jump_fcontext/make_fcontext/release_fcontext API (libcontext.h:113-124) \u2014 direction inference (main vs coroutine target) already exists in the current backend (g_main_context, return_to at libcontext.cpp:154-159, 853-854). Estimated replacement: ~300-350 LOC (prototype 200 + jump_fcontext adapter ~60 + id-map ghost contract ~40). Plus 1 line: #define LIBCONTEXT_HAS_OWN_STACK for __EMSCRIPTEN__ (libcontext.h:27 currently #undef) \u2014 coroutine.h then compiles out its mmap+guard-page stack allocation (coroutine.h:399-423, 445-510) with zero KiCad-header edits, since JSPI stacks are engine-managed.",
"evidence": "pcbjam/kicad/common/tool/tool_manager.cpp:110,177,238,592,723,735,754,808,859,870,873; pcbjam/kicad/thirdparty/libcontext/libcontext.h:25-30,113-124; libcontext.cpp:30-899; Wait census: grep over pcbnew/eeschema/common/gerbview/pagelayout_editor/3d-viewer/kicad/qa = 91 sites / 41 files",
"jspi_impact": "win \u2014 satisfies the fork-divergence rule (pcbjam/CLAUDE.md: 'Don't change kicad unless absolutely necessary'); libcontext already carries 12 fork commits and is the established mutation point (coroutine.h has 1 commit since upstream pin 4bfed3f1, tool_manager.cpp has 8)."
},
{
"title": "Files \u00d7 LOC estimate for the switch (coroutine backend workstream only)",
"detail": "REWRITE: kicad/thirdparty/libcontext/libcontext.cpp wasm32 section 870 LOC \u2192 ~300-350 (deletes grace ring lines 266-481, ghost-resume epochs 856-889, divergence beacons 167-214, parked-refusal machinery 738-811). EDIT 1 line: libcontext.h:27. DELETE: scripts/common/asyncify-scheduler.js (801 LOC \u2014 polices Asyncify.currData/handleSleep/exportCallStack, none of which exist under JSPI); scripts/common/apply-asyncify.sh (151) + asyncify-imports.txt (20) + asyncify-removelist.txt (71); binaryen fork's src/passes/HoistCppCatches.cpp (407) + binaryen-hoist-pass/build-wasm-opt.sh \u2014 binaryen fork can return to upstream version_130. EDIT scripts/kicad/build-kicad-target.sh: drop the wasm-opt stub dance (:376-388) and post-link host asyncify (:297), change link flags at :554 (-sASYNCIFY=1 -sASYNCIFY_STACK_SIZE=65536 \u2192 -sJSPI -sJSPI_EXPORTS=<list>), ~30-60 lines. DELETE LATER (after evtloop star migration, separate workstream): wxwidgets sched_context.h fiber lane ~675 LOC (decls 155-256 + impl 1053-1627) of 1627 total; star lanes (yield_park/mark_ready/drain) stay \u2014 they serve wxwidgets/src/wasm/evtloop.cpp (1187 LOC), not the coroutine backend. UNTOUCHED: coroutine.h 648, tool_manager.cpp, 91 Wait() sites/41 files, all tool code. Net: ~-2,300 LOC immediately (-3,000 incl. fiber lane), +~400.",
"evidence": "wc -l outputs: libcontext.cpp 2471 (wasm32 = 30-899), sched_context.h 1627, asyncify-scheduler.js 801, apply-asyncify.sh 151, asyncify-imports.txt 20, asyncify-removelist.txt 71, HoistCppCatches.cpp 407, coroutine.h 648, evtloop.cpp 1187; build-kicad-target.sh:388 ('wasm-opt stub installed (asyncify will run on host)'), :554 (link flags)",
"jspi_impact": "win \u2014 the deleted code is precisely the code the 107 post-9ece9844 commits fought to stabilize (grace ring, epochs, beacons, shim)."
},
{
"title": "Two production trap classes become structurally impossible under JSPI",
"detail": "(1) Stale-rewind traps: the current backend must refuse jumps into a context that is 'asyncify-parked inside handleSleep below a JS turn' because swapping in rewinds STALE fiber data ('finishContextSwitch \u2192 doRewind \u2192 unreachable executed', the 2026-07 production board-load trap) \u2014 libcontext.cpp:738-763; and main swapping out inside its own live wake window writes an unrewindable suspension (libcontext.cpp:813-832, wasm_root_wake_in_flight at :55-57). Under JSPI there IS no saved rewind data: a suspended activation can only be resumed by resolving its own promise, and 'resume into a running activation' cannot be expressed. (2) Asyncify buffer overflow corruption: the 512K per-fiber buffer (libcontext.cpp:69, raised from 64K after a deep collab-apply park overflowed it and 'silently corrupted the saved rewind state' \u2014 :62-68) has no JSPI equivalent; stacks are engine-managed and growable. The use-after-free driver remains (TOOL_MANAGER keeps raw fcontext_t outliving COROUTINE \u2014 libcontext.cpp:268-306) but shrinks from a 16MB grace ring of 512K fibers to a tiny id\u2192state map where a stale id lookup returns the established null/ghost contract (prototype jspi_coro_entry ghost path; same contract as libcontext.cpp:336-350,870-883).",
"evidence": "pcbjam/kicad/thirdparty/libcontext/libcontext.cpp:41-57,62-69,120-151,266-311,336-350,738-832,856-889",
"jspi_impact": "win \u2014 eliminates the two bug classes (plus their guard thicket) that the memory note 'Debug collab persistence at the source' and doc 22 \u00a71 identify as the campaign's core."
},
{
"title": "Semantic delta: every Call/Resume now crosses a microtask checkpoint; JS-side callers of dispatch entries observe early return",
"detail": "Current backend: emscripten_fiber_swap is synchronous within one JS task \u2014 Call() runs the tool to its first Wait() with zero event-loop involvement. JSPI backend: each switch = promise resolve + await, so control returns to the JS caller of the promising export (which receives a pending Promise) and the handshake completes in that task's microtask checkpoint. Consequences: (a) C++ callers observe an UNCHANGED synchronous contract (dispatchInternal reads st->wakeupEvent.PassEvent() and cofunc->Running() immediately after Resume at tool_manager.cpp:808-825 \u2014 works, proven by prototype); (b) only already-queued microtasks can interleave between Resume() and the tool running \u2014 no macrotasks (timers/DOM/rAF) can, since promise reactions drain before the task ends; (c) JS code that calls a wasm dispatch entry and then assumes the dispatch completed synchronously breaks \u2014 but this is ALREADY the situation whenever a tool parks (the entire mailbox/pump architecture exists for it: wxWasmMailboxTick evtloop.cpp:171, wxWasmSchedPump :335, wxWasmMainLoopPump :909). JSPI widens 'sometimes returns early (on park)' to 'always returns a promise'.",
"evidence": "pcbjam/kicad/common/tool/tool_manager.cpp:808-825; pcbjam/wxwidgets/src/wasm/evtloop.cpp:171,335,909; prototype async_wait_loop_timer_dispatch + stress_many_round_trips PASS; JSPI spec Overview.md promising/Suspending semantics",
"jspi_impact": "clash (contained) \u2014 needs an audit of JS callers of the ~18 wx entry exports for post-call assumptions; C++ side unaffected."
},
{
"title": "JSPI trap rule #1 requires enumerating promising entry exports; the inventory is small and already named",
"detail": "Spec: a suspending import 'Traps if context's state is not Active[caller]' \u2014 measured in prototype as 'WebAssembly.SuspendError: trying to suspend without WebAssembly.promising' when entering via a non-promising export and calling emscripten_sleep. So every JS\u2192wasm entry that can transitively reach Wait()/sleep must be in -sJSPI_EXPORTS (emscripten wraps matching wasmExports names with WebAssembly.promising \u2014 libasync.js instrumentWasmExports; wildcards supported, settings.js:950). Inventory: the wx wasm port has exactly 18 EMSCRIPTEN_KEEPALIVE exports (wx_dom_event, wx_dom_mouse, wxWasmMailboxTick, wxWasmSchedPump, wxWasmSchedResolveContextWait, wx_dispatch_abandon, wxWasmSchedAbandon, ProcessEvents, wxWasmSchedInplaceParkBegin/End, wxWasmTopLevelTick, wxWasmMainLoopPump, wx_window_move/close/resize, 3 in app.cpp) \u2014 all named, none via addFunction/dynCall (zero grep hits for dynCall entries in wx wasm cpps; the diagnostics.js dynCall wraps are debug-only shims). The kicad web side has NO direct ccall/Module._ callers (web/pcbjam-shared and web/standalone contain none); all remaining JS entries are embind (see separate finding).",
"evidence": "prototype non_promising_entry_traps output; grep EMSCRIPTEN_KEEPALIVE count=18: wxwidgets/src/wasm/app.cpp:1084-1098, domevents.cpp:88,134, evtloop.cpp:171,335,340,399,410,415,516,527,647,909, toplevel.cpp:441,452,466; /opt/homebrew/Cellar/emscripten/5.0.0/libexec/src/lib/libasync.js:52,65,172-176,478; settings.js:941-960",
"jspi_impact": "neutral\u2192win \u2014 bounded, mechanical migration (one JSPI_EXPORTS list replaces whole-program asyncify instrumentation); the equivalent boundary already had to be maintained as asyncify-imports.txt."
},
{
"title": "Embind entries: 79 JS names / 171 registrations already audited; 3 production PARKERs need async treatment",
"detail": "The team's own audit (doc 18): 79 distinct JS names across 6 bindings blocks in pcbjam/wasm/bindings/*_embind.cpp (pcbnew_embind.cpp alone 2490 LOC); classes: 47 MUTATOR (14 production), 20 PURE-READ (stay sync), 9 TEST-LEVER (6 park-capable), 3 production PARKER: kicadOpenFile, kicadOpenFiles, kicadLibsReload. Under JSPI the PARKERs + park-capable levers must be invoked through promising wrappers (embind's async support / manual WebAssembly.promising of the bound function), PURE-READs stay direct. The audit also notes 'No EMSCRIPTEN_KEEPALIVE JS entries exist in the [kicad] tree' \u2014 the keepalive inventory is wx-side only, confirming the two entry families are disjoint.",
"evidence": "pcbjam/docs/features/async/18-embind-audit.md:9-24; pcbjam/wasm/bindings/{pcbnew,eeschema,gerbview,kicad_editor,calculator,pl_editor}_embind.cpp; build-kicad-target.sh:114-115 (--bind)",
"jspi_impact": "clash (small, pre-audited) \u2014 ~9 entries need promising treatment; emscripten-version support for embind+JSPI must be verified in the spike."
},
{
"title": "HoistCppCatches (407-line binaryen fork pass) and the asyncify removelist are obsoleted; wasm-EH + suspension verified working",
"detail": "Prototype case yield_inside_catch_block_wasm_eh PASSES under -fwasm-exceptions: throw 42 \u2192 catch \u2192 Yield(600) INSIDE the catch \u2192 resume inside catch \u2192 rethrow \u2192 outer catch, all correct. This is exactly the capability HoistCppCatches adds to Asyncify ('lets Asyncify suspend from inside C++ catch blocks under native wasm-EH' \u2014 task context; pass is 407 LOC in the binaryen fork). JSPI has no instrumentation, so it also removes: (a) the removelist (71 lines of functions excluded from instrumentation because asyncify's per-function cost is 'superlinear in try-count' driving 'multi-GB RAM blowup of wasm-opt --asyncify' \u2014 removelist header lines 1-16); (b) the post-link apply-asyncify.sh flow with its pinned self-built wasm-opt (apply-asyncify.sh:8-16,43-45; in-link pass stubbed at build-kicad-target.sh:376-388); (c) ASYNCIFY_STACK_SIZE tuning (65536 at :554; 512K/fiber in libcontext.cpp:69). Caveat: functions formerly on the removelist could never suspend by construction; under JSPI anything reaching a suspending import can. The removelist entries 'never call a suspending import' (its own header) so no behavior change there \u2014 but the mimalloc nanosleep shim (nanosleep_yield.o, build-kicad-target.sh:495-516; memory note 'mimalloc can suspend via nanosleep shim') must be deliberately made non-suspending under JSPI or allocator reentrancy returns without the instrumentation backstop.",
"evidence": "prototype yield_inside_catch_block_wasm_eh PASS; pcbjam/binaryen/src/passes/HoistCppCatches.cpp (407 LOC); pcbjam/scripts/common/asyncify-removelist.txt:1-16; apply-asyncify.sh:8-16,43-45,55-59; build-kicad-target.sh:376-388,495-516,554",
"jspi_impact": "win \u2014 binaryen fork can be retired to upstream; build pipeline loses its heaviest custom step; one deliberate decision needed on the nanosleep shim."
},
{
"title": "The project's original 'we are not switching to JSPI' rationale (doc 03 \u00a73) no longer holds \u2014 2 of 3 premises are false against the current tree, the 3rd needs a spike",
"detail": "Doc 03 \u00a73 gave three reasons: (1) 'it cannot replace the intra-wasm emscripten_fiber_swap tool coroutines (they cross no JS boundary)' \u2014 REFUTED by the prototype: the backend introduces the JS boundary itself in ~200 LOC and all contract semantics survive (the premise was true only if libcontext's symmetric same-activation swap is kept). (2) 'it is incompatible with emscripten_set_main_loop (our entire architecture)' \u2014 STALE: the wx wasm event loop does not use emscripten_set_main_loop (evtloop.cpp:440 comment explicitly notes neither loop uses simulate_infinite_loop; zero API uses in wxwidgets/src/wasm); the architecture is now the mailbox/pump exports. (3) 'combining it with our pthreads/PROXY_TO_PTHREAD build is unsupported' \u2014 HALF-STALE: the kicad link line has -pthread -sUSE_PTHREADS=1 but NO PROXY_TO_PTHREAD (build-kicad-target.sh:532 comment 'no PROXY_TO_PTHREAD; the join runs on the browser main thread'; PROXY_TO_PTHREAD=1 exists only in env.sh:139 PTHREAD_LDFLAGS, not on the kicad link at :554). JSPI+plain-pthreads compatibility in emscripten 4.0.x remains the open item.",
"evidence": "pcbjam/docs/features/async/03-solutions-and-prior-art.md:60-68; pcbjam/wxwidgets/src/wasm/evtloop.cpp:440; pcbjam/scripts/kicad/build-kicad-target.sh:532,554; pcbjam/scripts/common/env.sh:135-140",
"jspi_impact": "win \u2014 the standing decision against JSPI was made against an architecture that no longer exists; the study can supersede it with this evidence."
},
{
"title": "Suspending-import boundary migrates mechanically: asyncify-imports.txt patterns map 1:1 to JSPI's auto-wrapping",
"detail": "asyncify-imports.txt (20 lines) declares the boundary: emscripten built-ins (emscripten_sleep etc.), env.__asyncjs__* (every EM_ASYNC_JS \u2014 e.g. the wx port's park points wxWasmYieldUntilJs evtloop.cpp:194 and wxWasmYieldToBrowser :570), env.emscripten_fiber_swap (dies with the fiber backend), env.js_* (project suspending imports). Under -sJSPI, EM_ASYNC_JS imports are wrapped in WebAssembly.Suspending automatically (original.isAsync path, libasync.js:56-66) and remaining js_* names go in -sJSPI_IMPORTS (same wildcard syntax, settings.js:960). The post-link import list file is deleted; emcc's normal in-link generation returns (apply-asyncify.sh:13-16 documents that the file only exists because the pass runs post-link).",
"evidence": "pcbjam/scripts/common/asyncify-imports.txt:1-20; pcbjam/wxwidgets/src/wasm/evtloop.cpp:194,570; /opt/homebrew/Cellar/emscripten/5.0.0/libexec/src/lib/libasync.js:56-66; settings.js:960; apply-asyncify.sh:13-16",
"jspi_impact": "win \u2014 one config file deleted, no semantic translation needed."
},
{
"title": "JSPI trap rule #2 (no JS frames between promising entry and suspend) \u2014 no violating pattern found in the pump path",
"detail": "Spec: suspension 'Traps if there are any frames of non-WebAssembly functions in frames' (frames = 'the stack frames since caller', i.e. since the promising entry). Violating shape: wasm \u2192 sync JS import \u2192 sync re-entry into wasm \u2192 Wait()/sleep. Searched the wx wasm port: DOM/timer entries all go JS-event-listener \u2192 named export \u2192 wasm (domevents.cpp:88,134), and yields happen via EM_ASYNC_JS imports called directly from wasm frames (evtloop.cpp:194,570); no addFunction/dynCall re-entry chain sits between a pump entry and a park in the sources examined. RunMainStack is NOT a violation: the functor runs on the caller's activation while the coroutine is suspended (prototype root_bounce case). Full certainty needs the spike build, since any EM_ASM that synchronously calls back into a suspending path would trap at runtime \u2014 such sites would surface immediately and loudly (SuspendError), unlike asyncify's silent corruptions. One prototype nuance to carry into the real backend: RunMainStack from a NESTED coroutine must propagate the RUNMAIN payload through to the root handshake loop (~15 LOC) for exact main-stack parity; all 12 current RunMainStack call sites (drawing_tool.cpp:765,1080,1327,1537; board_editor_control.cpp:871,957; sch_drawing_tools.cpp:1288,1798,1814; kicad_manager_control.cpp:269; webview_panel.cpp:286; + wrappers) are reached from root-dispatched tools today.",
"evidence": "JSPI spec Overview.md trap rules; pcbjam/wxwidgets/src/wasm/domevents.cpp:88,134; evtloop.cpp:194,570; RunMainStack sites grep (12 sites); prototype root_bounce_continue_after_root PASS",
"jspi_impact": "neutral \u2014 no known violation; runtime behavior is fail-loud rather than corrupt-quietly."
},
{
"title": "Toolchain/runtime support status measured locally",
"detail": "emcc: project pins EMSCRIPTEN_VERSION=4.0.2 (versions.sh); JSPI flags (-sJSPI, JSPI_EXPORTS settings.js:941-960) present in the 4.0.x line; prototype built on 4.0.24-git (homebrew 5.0.0 keg; needed EMSDK_PYTHON=python3.14 + custom EM_CONFIG because the keg's launcher finds system python 3.9 and /usr/bin/clang \u2014 working config saved at scratchpad/jspi-proto/emconfig.py). node v24.9.0 requires --experimental-wasm-jspi (typeof WebAssembly.Suspending: undefined without flag, function with). Browser reality for this repo: Chromium has JSPI shipped (accepted context), Safari accepted-absent, but the repo's OWN e2e baseline system is per-engine {chromium,firefox} and `npm run test:kicad` is the firefox shortcut (pcbjam/CLAUDE.md) \u2014 Firefox has not shipped JSPI, so the CI/test story under a JSPI build needs an explicit decision (drop firefox lane, dual-build, or wait).",
"evidence": "pcbjam/scripts/common/versions.sh (EMSCRIPTEN_VERSION=4.0.2); settings.js:941-960; measured node output; pcbjam/CLAUDE.md (tests/baseline-screenshots/{chromium,firefox}, npm run test:kicad); scratchpad/jspi-proto/emconfig.py",
"jspi_impact": "clash \u2014 Firefox e2e lane is the concrete casualty nobody has scoped; everything else is available today."
}
],
"open_questions": [
"Firefox: the repo's e2e suite and screenshot baselines are per-engine {chromium,firefox} and CLAUDE.md's default kicad test lane is firefox \u2014 JSPI has not shipped in Firefox release. Decide: drop the firefox lane, keep a dual Asyncify+JSPI build matrix (doubles the build/CI surface and keeps all the code JSPI would delete), or gate the switch on Firefox shipping.",
"JSPI + pthreads in emscripten 4.0.x: the kicad link uses -pthread -sUSE_PTHREADS=1 -sMALLOC=mimalloc with a worker pool (build-kicad-target.sh:554) but no PROXY_TO_PTHREAD. The prototype was single-threaded. A -pthread JSPI spike (raytracer join path, nanosleep shim behavior) is the next experiment; also decide whether the mimalloc nanosleep shim stays suspending or becomes a sync no-op under JSPI (memory note: allocator suspension made removelist entries unsafe).",
"Microtask-checkpoint widening: every Call/Resume now ends the JS entry's synchronous run. Audit the JS callers of the 18 wx keepalive exports (and the pump serialization in evtloop.cpp/mailbox) for code that runs after a wasm dispatch call assuming completion; decide whether promising-export return promises must be chained into the pump to preserve dispatch ordering.",
"Symmetric-API adapter: Option A keeps libcontext's jump_fcontext signature and infers direction (into-coroutine vs yield-to-caller) \u2014 the prototype proved the asymmetric ops but not the adapter itself; ~60 LOC of the rewrite carries the residual risk (nested COROUTINE targets where neither side is g_main_context).",
"RunMainStack from nested coroutines: prototype runs the functor on the immediate resumer's activation; exact KiCad parity (functor always on the true main stack) needs RUNMAIN payload propagation through nested yields (~15 LOC). All 12 current call sites are root-dispatched, so decide whether parity or the simpler semantics is the spec.",
"Embind async-call support in emscripten 4.0.x for the 3 production PARKER entries (kicadOpenFile/kicadOpenFiles/kicadLibsReload) and 6 park-capable test levers: verify the mechanism (embind async policy vs manual WebAssembly.promising of bound functions) in the spike build.",
"Performance: JSPI switch cost is 2 microtask hops vs a synchronous fiber swap. 96 round-trips passed trivially in the prototype but per-memory-note methodology (bench real flows, UI wall-clock) the spike should measure a real tool-drag (move tool emits Wait/Resume per mouse event) before committing.",
"Scope boundary confirmed but unmeasured: sched_context.h's star lanes (yield_park/mark_ready/drain, used by evtloop.cpp's dispatch contexts and wx-wait) are OUTSIDE this gap \u2014 under JSPI they would map to the same promise-pair pattern, but that migration (doc 22 Phases C-E territory) needs its own design + estimate; only the fiber lane (~675 LOC) is deleted by the coroutine swap."
]
}

View file

@ -0,0 +1,74 @@
{
"summary": "First-ever -sJSPI builds of this codebase were spiked successfully: emsdk 6.0.6 (installed side-by-side in scratchpad; repo pins 4.0.2) linked four wx test apps from tests/apps/Makefile.wasm against the prebuilt 4.0.2-era wx static libs, and they RUN in Chromium 143 (JSPI default-on) and Firefox 144 (behind pref). Verified wins: suspend-inside-C++-catch works natively (obsoletes the 407-line HoistCppCatches binaryen fork pass), wasm is 2.14x smaller than the post-link asyncify pipeline output (4.41MB vs 9.46MB on identical input), the ~27s/app wasm-opt post-link stage disappears, embind async() works, pthreads (16 threads + worker-thread emscripten_sleep) work, mimalloc/-gseparate-dwarf/legacy-EH all link and mostly run. Verified clashes: (1) -sDYNCALLS=1 is a hard link error under JSPI (but it exists only to serve the asyncify pipeline, so it can be dropped along with inject-dyncall-shims.sh); (2) every wasm entry export that can transitively suspend must be declared in JSPI_EXPORTS or it throws SuspendError at runtime \u2014 an enumerable, bounded list (9 exports in the tested port, 18 in the current one), and with the list supplied, clipboard copy and a full modal-dialog open/close cycle pass; (3) emscripten fibers \u2014 the backend of KiCad's libcontext coroutines \u2014 are runtime-broken under JSPI (Asyncify.State undefined), making the coroutine layer the single hard migration item; (4) JSPI also legalizes overlapping suspended calls (reentrancy max=2 observed), eliminating the \"cannot start an async operation when one is already in flight\" abort class (reproduced live in the asyncify control run) but making reentrancy-serialization a design responsibility instead of an engine constraint.",
"findings": [
{
"title": "Spike provenance: what was built, with what, against what",
"detail": "emsdk 6.0.6 (latest; list showed 5.0.0..6.0.6) installed side-by-side into the session scratchpad (repo untouched; repo pins EMSCRIPTEN_VERSION=4.0.2). Because the pcbjam-private checkout has no built wx libraries (pcbjam/build-wasm/wxwidgets/lib holds only wx/ config headers; pcbjam/tools/emsdk has no binaries), the spike linked against the sibling full checkout /Users/V/IdeaProjects/kicad-wasm (same app repo, branch main, HEAD a35eeb7e; wxwidgets a61bcf4 = v3.2.6-92, i.e. 37 port commits behind pcbjam's 4d479cb v3.2.6-129; wx libs built with emcc 4.0.2). Flags mirrored tests/apps/Makefile.wasm: EH_FLAGS -fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1 (-sDYNCALLS dropped, see clash), -sALLOW_MEMORY_GROWTH, -sERROR_ON_UNDEFINED_SYMBOLS=0, wx-config cxxflags/libs (which force -pthread into every app), --pre-js wx.js + wx-dom.js, with -sASYNCIFY=1/-sASYNCIFY_STACK_SIZE=65536/-sASYNCIFY_IMPORTS replaced by -sJSPI + -sJSPI_IMPORTS + -sJSPI_EXPORTS. Cross-version object compat verified incidentally: 6.0.6 linker consumed 4.0.2-compiled .a/.o (including EM_JS/EM_ASYNC_JS custom sections) with zero errors. Zero prior JSPI usage in build scripts confirmed (grep JSPI over pcbjam/scripts + Makefile.wasm = 0 hits; docs-only mentions in docs/README.md, docs/wasm-exceptions-experiment.md, docs/research/threading_2.md).",
"evidence": "pcbjam/scripts/common/versions.sh:6; pcbjam/tests/apps/Makefile.wasm:58,66-70,95-98,101-105,119-124; kicad-wasm wx-config output includes -pthread in both --cxxflags and --libs; artifacts in scratchpad jspi-spike/out/",
"jspi_impact": "neutral \u2014 establishes the evidence base; caveat: wx libs tested are 37 commits behind the current port."
},
{
"title": "HARD CLASH (removable): -sDYNCALLS=1 is a fatal link error under -sJSPI",
"detail": "First JSPI link attempt with the Makefile's exact EH_FLAGS died with: AssertionError \"DYNCALLS cannot be used with JSPI\" at makeDynCall (emscripten src/parseTools.mjs:693) while preprocessing libpthread.js \u2014 an internal compiler error, not a graceful diagnostic. The repo passes -sDYNCALLS=1 in both the wx test flags and the production KiCad link. But DYNCALLS exists ONLY to serve the asyncify pipeline: inject-dyncall-shims.sh documents that asyncify-INSTRUMENTED dynCall_* trampolines must be used for unwind/rewind through indirect calls. Under JSPI there is no unwind/rewind instrumentation, so the flag, the wasmExports[\"dynCall_\"+sig] routing, the embind dynCall fallback perl patch, and the -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['$dynCall'] export can all be deleted. Relinking without -sDYNCALLS succeeded immediately.",
"evidence": "error reproduced with emsdk 6.0.6; pcbjam/tests/apps/Makefile.wasm:58; pcbjam/scripts/kicad/build-kicad-target.sh:554; pcbjam/scripts/common/inject-dyncall-shims.sh:13-19,114-122; emscripten src/parseTools.mjs:693",
"jspi_impact": "clash \u2014 but a one-line flag removal plus deletion of a whole shim script; net simplification."
},
{
"title": "HARD CLASH (architectural, bounded): every suspending wasm entry export must be a JSPI_EXPORT; default builds throw SuspendError on first UI interaction",
"detail": "With only default JSPI_EXPORTS (main), clipboard and dialog apps boot but the first button click fails: \"SuspendError: trying to suspend without WebAssembly.promising\" thrown from wasm frames entered via wx_dom_event \u2014 the DOM port dispatches ALL UI events through plain ccall('wx_dom_event',...) (wx-dom.js:46), and under JSPI only exports wrapped by WebAssembly.promising may suspend. Side effect observed: the failed suspension left wxClipboard open (\"wxClipboard::Open() called when already open\" on retry) \u2014 errors mid-suspend corrupt C++ state. FIX VERIFIED: relinking with -sJSPI_EXPORTS=['main','wx_dom_event','wx_dom_mouse','wx_window_close','wx_window_move','wx_window_resize','ProcessEvents'] made both scenarios pass end-to-end: clipboard \"SUCCESS: Copied 30 characters\" (EM_ASYNC_JS js_writeTextToClipboard suspending inside a DOM click handler), and a full modal cycle \u2014 Info dialog rendered as DOM, OK clicked, \"Info dialog closed with result: 4\", main loop resumed (startModal EM_ASYNC_JS at wxwidgets/src/wasm/dialog.cpp:201 suspending main). The migration surface is enumerable: 9 EMSCRIPTEN_KEEPALIVE entries in the tested 92-commit port; 18 in the current 129-commit port (wx_dom_event, wx_dom_mouse, wx_window_close/move/resize, drag/drop handlers, ProcessEvents, wxWasmMailboxTick, wxWasmMainLoopPump, wxWasmSched* family, wxWasmTopLevelTick, wx_dispatch_abandon). JS callers already tolerate promise returns (ProcessEvents is even called with ccall {async:true} today, wx-dom.js:1089). Note: exports listed in JSPI_EXPORTS return Promises to JS; wasm size unchanged by the wrapping.",
"evidence": "runtime errors + fixes captured in results.json/results2.json (scratchpad jspi-spike/); kicad-wasm wxwidgets/build/wasm/wx-dom.js:46,1089; wxwidgets/src/wasm/domevents.cpp:87,116; KEEPALIVE grep over pcbjam/wxwidgets/src/wasm/*.cpp (18 symbols); emscripten settings.js:952 (JSPI_EXPORTS)",
"jspi_impact": "clash \u2014 the central code change of a JSPI port, but bounded, mechanical, and proven working in the spike."
},
{
"title": "WIN (verified): JSPI suspends inside C++ catch blocks natively \u2014 the 407-line HoistCppCatches binaryen fork pass becomes unnecessary",
"detail": "Custom feature harness (compiled+linked wholly with 6.0.6, -pthread, -fwasm-exceptions, -sWASM_LEGACY_EXCEPTIONS=1, -sJSPI): (1) suspend_in_catch \u2014 throw 42, then INSIDE the catch arm call EM_ASYNC_JS js_delay(50) AND emscripten_sleep(10), return e+r \u2192 returned 142 (correct) in Chromium; this is exactly the case Asyncify cannot handle and the reason the binaryen fork exists (apply-asyncify.sh: \"lets Asyncify suspend from inside C++ catch blocks under native wasm-EH\"). (2) throw_across_suspend \u2014 suspend inside try then throw/catch \u2192 20 (correct). (3) sjlj_roundtrip under -sSUPPORT_LONGJMP=wasm \u2192 7 (correct). Consequence: the binaryen submodule fork (version_130 + HoistCppCatches), the wasm-opt stubbing machinery in build-wasm-test.sh/build-kicad-target.sh, apply-asyncify.sh, asyncify-imports.txt (boundary list) and asyncify-removelist.txt (71 lines, RAM-blowup mitigation) are all dead weight under JSPI.",
"evidence": "feature harness results3.json: {suspend_in_catch:{v:142,ok:true},throw_across_suspend:{v:20,ok:true},sjlj_roundtrip:{v:7,ok:true}}; pcbjam/scripts/common/apply-asyncify.sh:6-19; pcbjam/scripts/common/asyncify-removelist.txt (71 lines); harness source scratchpad jspi-spike/feature_harness.cpp",
"jspi_impact": "win \u2014 eliminates the entire custom binaryen fork + post-link pipeline raison d'\u00eatre."
},
{
"title": "HARD CLASH (the big one): emscripten fibers \u2014 the backend of KiCad's libcontext coroutines \u2014 are runtime-broken under JSPI",
"detail": "KiCad's coroutine layer (thirdparty/libcontext/libcontext.cpp:20-24 \"WASM/Emscripten: implement libcontext using Emscripten fibers\", emscripten_fiber_init/swap at :210,:258,:287,:321) drives the tool framework. The coroutine stress harness (tests/apps/standalone/coroutine, links real libcontext) LINKS under -sJSPI without warning \u2014 emscripten 6.0.6 even includes the real Fibers JS implementation, not the abort stub \u2014 but at runtime the FIRST case dies: pageerror \"TypeError: Cannot read properties of undefined (reading 'Normal')\" at generated coroutine_jspi.js:9563 \"if (Asyncify.state === Asyncify.State.Normal)\" \u2014 the JSPI variant of the Asyncify runtime object has no State/state members; Fibers is functionally ASYNCIFY=1-only (upstream guard bug: fibers ship under any ASYNCIFY truthy value). Only 1 of the harness cases even started ([COROUTINE_TEST] CASE first_entry_runs_once, no PASS/SUMMARY ever printed). There is no JSPI fiber backend upstream: JSPI suspends toward JS only; it cannot do arbitrary wasm-stack-to-wasm-stack switching. Migration options are all invasive: rewrite libcontext-on-wasm as promising-export trampolines through JS, restructure KiCad tool coroutines, or thread-backed coroutines.",
"evidence": "pageerror + console in results.json (coroutine_jspi); generated coroutine_jspi.js:9525-9580 (Fibers, Asyncify.State refs); kicad-wasm/kicad/thirdparty/libcontext/libcontext.cpp:20-24,210,258,287,321; emscripten src/lib/libasync.js:577-580 (real fiber impl) vs :625 (abort stub only when ASYNCIFY=0); pcbjam Makefile.wasm:105,124 (fiber_swap in ASYNCIFY_IMPORTS)",
"jspi_impact": "clash \u2014 the single hard blocker; everything else in the spike passed, this did not."
},
{
"title": "WIN (verified): pthreads + JSPI work \u2014 16 real threads, main-thread blocking join, and emscripten_sleep on a worker thread",
"detail": "All wx apps link -pthread (wx-config forces it). threadpool_test (replicates KiCad's BS::priority_thread_pool: create hardware_concurrency std::threads, join all on the main thread) built with -sJSPI -sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency -sPTHREAD_POOL_SIZE_STRICT=0: \"Thread Pool Test PASSED! Created and joined 16 threads successfully\" \u2014 page completed in 414ms wall. Feature harness start_worker_sleep: a detached std::thread called emscripten_sleep(50) then set a flag \u2192 flag observed 1 (worker-thread JSPI suspension works in Chromium 143). Production's no-PROXY_TO_PTHREAD main-thread-main() model (build-kicad-target.sh:532 comment) maps directly onto JSPI's promising main. Reentrancy probe: two overlapping promising calls into the same export ran concurrently (first call saw depth 1, second saw depth 2, reentry_max=2) \u2014 JSPI permits multiple suspended activations per thread. This ELIMINATES the \"Aborted(Assertion failed: We cannot start an async operation when one is already flight)\" failure class \u2014 which the asyncify CONTROL run reproduced live during the clipboard A/B \u2014 but means the scheduler/mailbox serialization (docs/features/async/17-22 machinery) becomes a policy layer, not an engine-enforced constraint: true reentrancy is now the DEFAULT semantic on every suspending entry.",
"evidence": "results2.json threadpool_jspi (PASSED, 414ms, 0 errors); results3.json feature harness {worker_sleep:{flag:1},reentrancy:{v1:1,v2:2,max:2}}; asyncify control abort in results3.json clipboard_asyncify_control; pcbjam/scripts/kicad/build-kicad-target.sh:531-539",
"jspi_impact": "win on threads; mixed on reentrancy \u2014 kills the in-flight abort class the 107 commits fought, but requires an explicit serialization decision per entry point."
},
{
"title": "WIN (verified): embind async bindings are JSPI-native; EM_ASYNC_JS auto-wrapping works, including from 4.0.2-era objects",
"detail": "Feature harness bound embind_async_fn (calls emscripten_sleep + EM_ASYNC_JS) with emscripten::async() policy (wire.h:547 struct async) and embind_sync_fn plainly: Module.embindAsyncFn(10) returned a Promise resolving to 40 (correct), embindSyncFn(4)=5 (correct). Upstream 6.0.6 libembind.js asserts async bindings are ONLY supported with JSPI (\"assert(!isAsync, 'async bindings are only supported with JSPI')\" under ASYNCIFY!=2) \u2014 i.e. the production embind TUs (wasm/bindings/*_embind.cpp, linked at build-kicad-target.sh:554 via --bind) gain a first-class async story only by moving to JSPI; today they rely on Asyncify.currData.then plumbing. EM_ASYNC_JS auto-marking as suspending imports (__asyncjs__ prefix) worked without listing them in JSPI_IMPORTS \u2014 including EM_ASYNC_JS bodies embedded in libwx_wasmu_core-3.2.a objects compiled by emcc 4.0.2 and consumed by the 6.0.6 -sJSPI link (startModal, js_writeTextToClipboard/js_readTextFromClipboard/js_clipboardHasText/js_clearClipboard).",
"evidence": "results3.json {embind_sync:{v:5,ok:true},embind_async:{v:40,ok:true}}; emscripten src/lib/libembind.js:696-698,769-772; system/include/emscripten/wire.h:547,659; kicad-wasm wxwidgets/src/wasm/dialog.cpp:201, clipbrd.cpp:38-147 (EM_ASYNC_JS); tools/emscripten.py:804 (MAIN_MODULE or ASYNCIFY==2 em_js handling)",
"jspi_impact": "win \u2014 embind + EM_ASYNC_JS need no per-function code change beyond the async() policy on suspending bindings."
},
{
"title": "WIN (measured): wasm 2.14x smaller than the post-link asyncify pipeline and the ~27s/app wasm-opt stage disappears",
"detail": "Identical input (minimal_test.o + wx libs, emcc 6.0.6, -O2): plain link (no suspend support) = 3,922,345 B wasm, 1.58s. JSPI link = 4,414,628 B wasm (+12.6% over plain) / 425,457 B js, link 0.7-3.0s, NO post-link steps. Production-style post-link pipeline replicated on the same plain input with the repo's fork wasm-opt (version_130-2-g1d40cf5a8 from kicad-wasm/build-wasm/tools/binaryen-hoist-build/bin): --hoist-cpp-catches 0.23s + --asyncify (asyncify-imports.txt boundary, no removelist, propagate-addlist) 1.03s + -O2 25.8s (1m50s CPU at BINARYEN_CORES=8) = 9,457,996 B (2.14x JSPI, +141% over plain) \u2014 closely matching the shipped 4.0.2-era baseline minimal_test.wasm of 9,589,611 B (Jul 20 build). In-link ASYNCIFY=1 under 6.0.6 for reference: 14,646,061 B / 450,062 B js, 6.1s. Per build-wasm-test.sh's own comment the per-app wasm-opt stage \"dominates the build\" (clean test-suite build 15m36s at -j1); JSPI deletes that stage entirely, plus ASYNCIFY_STACK_SIZE tuning, the imports boundary file, and the 71-line removelist (whose RAM-blowup problem no longer exists). Caveat: JSPI disables wasm import/export minification (link.py:1656 TODO) \u2014 part of the +12.6% over plain.",
"evidence": "size/time numbers from spike runs (scratchpad jspi-spike/out/); kicad-wasm/tests/apps/minimal_test.wasm = 9,589,611 B (Jul 20); pcbjam/scripts/build-wasm-test.sh:7-9,144-146; pcbjam/scripts/common/apply-asyncify.sh:122-137; emscripten tools/link.py:1656-1663",
"jspi_impact": "win \u2014 ~5MB wasm saved per app at minimal_test scale (KiCad editors are 10-17MB+ instrumented today), plus minutes of wasm-opt per app per build."
},
{
"title": "Runtime matrix: Chromium 143 passes everything (except fibers); Firefox 144 works only behind a pref; legacy wasm-EH is fine with JSPI; exnref requires full recompile but is NOT needed",
"detail": "Chromium 143.0.7499.4 (Playwright 1.57 bundled; JSPI on by default since Chrome 137): minimal (21 DOM buttons, click handled), clipboard, dialog modal cycle, threadpool, feature harness \u2014 all pass. Firefox 144.0.2 with javascript.options.wasm_js_promise_integration=true: minimal boots fully (only a deprecation warning about legacy EH 'try' instructions); WITHOUT the pref: clean abort \"Assertion failed: JSPI not supported by current environment\" (emscripten's 'Suspending' in WebAssembly feature check, libasync.js:52) \u2014 JSPI is NOT default-on in Firefox 144. Legacy-EH encoding (-sWASM_LEGACY_EXCEPTIONS=1, required to stay compatible with the 4.0.2-built libraries) runs fine under JSPI in both browsers \u2014 the fork's env.sh:45 claim \"Asyncify can't handle exnref\" simply stops mattering. Attempting -sWASM_LEGACY_EXCEPTIONS=0 at link over legacy-compiled objects produced a wasm that Chrome REJECTS at compile: \"module uses a mix of legacy and new exception handling instructions\" \u2014 the exnref translator does not fully convert 4.0.2-era objects, so an exnref migration means recompiling every TU; it is optional, not a JSPI prerequisite. Also verified linking: -sMALLOC=mimalloc + JSPI and -g -gseparate-dwarf + JSPI both link (dbg: 5,212,199 B wasm + 43,118,462 B .debug.wasm).",
"evidence": "results.json entries chromium/*, firefox+pref, firefox-nopref; browser versions from results2.json (143.0.7499.4 / 144.0.2); emscripten src/lib/libasync.js:52; pcbjam/scripts/common/env.sh:39-46; exnref CompileError text captured verbatim",
"jspi_impact": "win/neutral \u2014 the accepted-risk browser matrix is confirmed empirically: Chrome-family fine, Firefox needs a pref today, no exnref migration required."
},
{
"title": "Flagged non-JSPI issue: mimalloc OOB under emcc 6.0.6 in BOTH asyncify and JSPI builds; also, in-link asyncify no longer crashes on legacy wasm-EH",
"detail": "minimal_test + -sMALLOC=mimalloc hits pageerror \"RuntimeError: memory access out of bounds\" after UI construction in BOTH the -sJSPI build and the -sASYNCIFY=1 in-link 6.0.6 build (and with -sASSERTIONS=1) \u2014 so it is NOT JSPI-specific; note the spike omitted production's mimalloc accompaniments (mallinfo stub, nanosleep-yield shim \u2014 build-kicad-target.sh:519,532; MEMORY.md flags mi_atomic_yield/nanosleep interplay). Needs separate root-causing before any emsdk upgrade regardless of JSPI. Second side-finding: emcc 6.0.6's in-link ASYNCIFY=1 on legacy wasm-EH input LINKS and BOOTS minimal_test (emits warning \"ASYNCIFY=1 is not compatible with -fwasm-exceptions. Parts of the program that mix ASYNCIFY and exceptions will not compile\") \u2014 the \"emsdk-bundled Binaryen crashes asyncifying wasm-EH\" rationale (apply-asyncify.sh:12-16, from emsdk v121 era) is outdated for crash behavior, though upstream still cannot suspend-in-catch and produced a 14.6MB wasm, so it does not replace the fork for the Asyncify path. Clipboard A/B: JSPI build copied 30 chars and pasted 1 char (\"S\"); the asyncify CONTROL (shipped Jul 20 pipeline build) pasted 0 chars AND aborted with the in-flight assertion \u2014 headless-clipboard fidelity is environmental/inconclusive, but JSPI strictly outperformed the control.",
"evidence": "results.json/results3.json mimalloc entries (identical OOB both modes); asyncify-inlink warning text verbatim from build log; results3.json clipboard_asyncify_control vs clipboard_jspi2_again; pcbjam/scripts/kicad/build-kicad-target.sh:519,529-539",
"jspi_impact": "neutral \u2014 mimalloc OOB is an emsdk-upgrade risk independent of JSPI; the in-link-asyncify datapoint slightly weakens the case for keeping the fork even without JSPI."
}
],
"open_questions": [
"Fibers/libcontext replacement design: JSPI cannot stack-switch wasm-to-wasm, and emscripten 6.0.6 has no JSPI fiber backend (Fibers JS references Asyncify.State which doesn't exist under JSPI). What replaces KiCad's coroutine layer \u2014 promising-export trampolines per coroutine, thread-backed coroutines, or restructuring the tool framework? This is the gating item for any JSPI decision and needs its own spike on real pcbnew tool code.",
"Full KiCad-scale JSPI link not yet attempted: pcbjam-private has no built wx/deps sysroot on this machine and the kicad build would need all deps recompiled under a JSPI-capable emsdk (6.0.6 linking 4.0.2 objects worked for wx test apps, so an incremental relink of the existing kicad-kicad_editor objects + sysroot from /Users/V/IdeaProjects/kicad-wasm may be feasible as a next step; embind TUs, GL, OCC untested at scale).",
"Current 129-commit port surface: the spike used the 92-commit wx libs (a61bcf4). The current port's scheduler (wxWasmYieldUntil/yieldwait.h, __wxScheduler.resolveWait EM_JS token parks, 18 KEEPALIVE entries incl. wxWasmSched* family) must map its park import onto an EM_ASYNC_JS/promise-returning import and enlarge JSPI_EXPORTS \u2014 mechanically similar to the verified wx_dom_event fix, but unverified; equally unverified is whether reverting to 9ece9844 (pre-scheduler) is the cheaper JSPI base, which the reentrancy result (overlapping suspended calls are legal by default) makes plausible.",
"mimalloc 'memory access out of bounds' under emcc 6.0.6 (both asyncify and JSPI, with and without assertions) \u2014 root cause needed before any emsdk upgrade; retest with production's mallinfo stub + nanosleep-yield shim linked.",
"Headless clipboard paste fidelity (JSPI pasted 1 char, asyncify control pasted 0 chars and aborted): retest headed/real-Chrome to separate environment flakiness from a possible heap-view-after-growth issue during JSPI suspension.",
"Firefox timeline for default-on JSPI (144.0.2 still requires javascript.options.wasm_js_promise_integration=true) and whether the legacy-EH 'try' deprecation warning there becomes a removal that would force the full exnref recompile.",
"Emscripten labels JSPI 'still experimental' (link.py:1786) and JSPI disables wasm export-name minification (link.py:1656 TODO) \u2014 track upstream stabilization; also confirm behavior on installed stable Chrome 151 (spike browsers were Playwright-bundled Chromium 143/Firefox 144)."
]
}

View file

@ -210,8 +210,8 @@ Two zero-KiCad-edit routes turn the live viewer multi-threaded:
## 6. pthread test coverage
All four apps below compile the **real KiCad** thread-pool source and run on **pristine** KiCad/wx-core.
The specs are named `coroutine-*` so `playwright-coroutine.config.ts` runs them in Firefox + Chrome
(WebKit excluded — §2a).
The specs are named `coroutine-*` so the merged config's `coroutine-firefox` / `coroutine-chrome`
projects (testMatch `/coroutine.*\.spec\.ts$/`) run them in Firefox + Chrome (WebKit excluded — §2a).
| Spec | App | What it proves | native-EH |
|---|---|---|---|