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

@ -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.