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

@ -25,7 +25,6 @@ jobs:
build:
uses: ./.github/workflows/wasm-build.yml
with:
opt_level: "-O1"
build_3d_viewer: "ON"
run_tests: true
upload_output: true

View file

@ -1,197 +0,0 @@
# Dedicated benchmark of the host-side `wasm-opt -O2` pass (the ~80-min CI
# bottleneck). Same ephemeral Hetzner ccx53 as the main CI, but instead of the
# full build+e2e it builds the asyncified `-O2` INPUT once, caches it as an
# artifact, and replays scripts/bench/o2-config-sweep.sh over it under a matrix
# of allocator/THP/thread configs. Goal: find the env that kills the kernel
# page-management storm (see docs/ci-build-slowness-findings.md).
#
# Manual-only (workflow_dispatch): every run costs a paid ephemeral Hetzner VM,
# so nothing here triggers on push. Dispatch from the Actions tab (the workflow
# must exist on the default branch for that).
#
# Parameters live in scripts/bench/sweep.conf (committed) or the dispatch
# inputs below. To reuse a prior run's fixture (skip the ~40-min build), set
# fixture_run_id to that run's id.
#
# Reuses the main CI's secrets: HCLOUD_TOKEN, HETZNER_RUNNER_PAT.
name: wasm-opt bench
on:
workflow_dispatch:
inputs:
configs: { description: "space-separated config presets (override conf)", required: false, default: "" }
cores: { description: "BINARYEN_CORES (override conf)", required: false, default: "" }
fixture_run_id: { description: "run id to pull the cached fixture from (blank = build it)", required: false, default: "" }
diagnostic: { description: "1 = capture perf/vmstat/irq around first config", required: false, default: "" }
cap_seconds: { description: "windowed-sample seconds per config (0 = run to completion)", required: false, default: "" }
# One Hetzner bench VM at a time. (The main CI uses a different runner; still,
# don't push the ci-hetzner branches while a bench runs — we only have one slot.)
concurrency:
group: wasm-opt-bench
cancel-in-progress: false
jobs:
create-runner:
name: Create Hetzner runner
runs-on: ubuntu-latest
outputs:
label: ${{ steps.create.outputs.label }}
server_id: ${{ steps.create.outputs.server_id }}
steps:
- name: Create ephemeral ccx53 runner
id: create
uses: Cyclenerd/hcloud-github-runner@v1
with:
mode: create
github_token: ${{ secrets.HETZNER_RUNNER_PAT }}
hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
server_type: ccx53
location: nbg1
image: ubuntu-24.04
bench:
name: wasm-opt -O2 config sweep (Hetzner ccx53)
needs: create-runner
runs-on: ${{ needs.create-runner.outputs.label }}
timeout-minutes: 210
env:
KICAD_LOG_NESTED: "1" # live build logs straight to the Actions console
steps:
- name: Checkout (with submodules)
uses: actions/checkout@v4
with:
submodules: recursive
- name: Resolve sweep parameters
id: params
# Pass workflow_dispatch inputs via env (never interpolate ${{ }} into a
# shell body) so a value can't be parsed as shell. They override the
# committed conf when non-empty; the run id is validated numeric before
# it reaches download-artifact.
env:
IN_CONFIGS: ${{ inputs.configs }}
IN_CORES: ${{ inputs.cores }}
IN_FIXTURE_RUN_ID: ${{ inputs.fixture_run_id }}
IN_DIAGNOSTIC: ${{ inputs.diagnostic }}
IN_CAP: ${{ inputs.cap_seconds }}
run: |
set -e
CONF="scripts/bench/sweep.conf"
[ -f "$CONF" ] && . "$CONF" || true
CONFIGS="${IN_CONFIGS:-${CONFIGS_CONF:-baseline}}"
CORES="${IN_CORES:-${CORES_CONF:-}}"
FIXTURE_RUN_ID="${IN_FIXTURE_RUN_ID:-${FIXTURE_RUN_ID_CONF:-}}"
DIAGNOSTIC="${IN_DIAGNOSTIC:-${DIAGNOSTIC_CONF:-0}}"
CAP_SECONDS="${IN_CAP:-${CAP_SECONDS_CONF:-0}}"
if [ -n "$FIXTURE_RUN_ID" ] && ! printf '%s' "$FIXTURE_RUN_ID" | grep -qE '^[0-9]+$'; then
echo "::error::FIXTURE_RUN_ID must be numeric (got '$FIXTURE_RUN_ID')"; exit 1
fi
BUILD_FIXTURE=true; [ -n "$FIXTURE_RUN_ID" ] && BUILD_FIXTURE=false
{
echo "CONFIGS=$CONFIGS"
echo "CORES=$CORES"
echo "FIXTURE_RUN_ID=$FIXTURE_RUN_ID"
echo "DIAGNOSTIC=$DIAGNOSTIC"
echo "CAP_SECONDS=$CAP_SECONDS"
echo "BUILD_FIXTURE=$BUILD_FIXTURE"
} >> "$GITHUB_ENV"
echo "build_fixture=$BUILD_FIXTURE" >> "$GITHUB_OUTPUT"
echo "::notice::configs='$CONFIGS' cores='${CORES:-nproc}' cap=${CAP_SECONDS}s build_fixture=$BUILD_FIXTURE fixture_run_id='${FIXTURE_RUN_ID:-none}' diagnostic=$DIAGNOSTIC"
- name: Show machine
run: |
echo "nproc=$(nproc)"; free -h; df -h /
echo "THP: $(cat /sys/kernel/mm/transparent_hugepage/enabled)"; uname -a
# Allocators + measurement tools. mimalloc is the key data point (Binaryen
# #5561): prefer the distro package, source-build it if not packaged.
- name: Install bench deps (allocators, time, perf, strace)
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update
sudo apt-get install -y time strace libjemalloc2 cmake g++ git curl ca-certificates \
linux-tools-common linux-tools-generic "linux-tools-$(uname -r)" || \
sudo apt-get install -y time strace libjemalloc2 cmake g++ git curl ca-certificates linux-tools-common linux-tools-generic
sudo apt-get install -y libmimalloc2.0 || sudo apt-get install -y libmimalloc-dev || true
if ! ls /usr/lib/$(uname -m)-linux-gnu/libmimalloc.so* >/dev/null 2>&1; then
echo "mimalloc not packaged — building from source"
git clone --depth 1 https://github.com/microsoft/mimalloc /tmp/mimalloc
cmake -S /tmp/mimalloc -B /tmp/mimalloc/out -DCMAKE_BUILD_TYPE=Release >/dev/null
cmake --build /tmp/mimalloc/out -j"$(nproc)" >/dev/null
sudo cp -av /tmp/mimalloc/out/libmimalloc.so* /usr/lib/$(uname -m)-linux-gnu/
fi
echo "allocators present:"; ls -l /usr/lib/$(uname -m)-linux-gnu/libjemalloc.so* /usr/lib/$(uname -m)-linux-gnu/libmimalloc.so* 2>/dev/null || true
# ---- Fixture: build once (asyncify, no -O2) and cache, or download it ----
- name: Install Docker (fixture build only)
if: steps.params.outputs.build_fixture == 'true'
run: |
export DEBIAN_FRONTEND=noninteractive
sudo apt-get install -y ca-certificates curl
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" \
| sudo tee /etc/apt/sources.list.d/docker.list >/dev/null
sudo apt-get update
sudo apt-get install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
- name: Build asyncified fixture (ASYNCIFY_ONLY, no -O2)
if: steps.params.outputs.build_fixture == 'true'
run: |
# Stop after the asyncify pass so the artifact is exactly the -O2 INPUT.
ASYNCIFY_ONLY=1 BINARYEN_CORES="$(nproc)" ./docker/build.sh eeschema --build-deps -j "$(nproc)"
ls -lh output/eeschema.wasm
cp output/eeschema.wasm output/eeschema.asyncified.wasm
- name: Upload fixture artifact
if: steps.params.outputs.build_fixture == 'true'
uses: actions/upload-artifact@v4
with:
name: o2-fixture
path: output/eeschema.asyncified.wasm
retention-days: 7
- name: Download cached fixture
if: steps.params.outputs.build_fixture == 'false'
uses: actions/download-artifact@v4
with:
name: o2-fixture
path: output
run-id: ${{ env.FIXTURE_RUN_ID }}
github-token: ${{ secrets.GITHUB_TOKEN }}
# ---- The sweep ----
- name: Run -O2 config sweep
run: |
FIX="output/eeschema.asyncified.wasm"
[ -f "$FIX" ] || FIX="output/eeschema.wasm"
ls -lh "$FIX"
CONFIGS="${CONFIGS}" CORES="${CORES}" DIAGNOSTIC="${DIAGNOSTIC}" CAP_SECONDS="${CAP_SECONDS}" \
./scripts/bench/o2-config-sweep.sh "$FIX"
- name: Upload bench results
if: always()
uses: actions/upload-artifact@v4
with:
name: o2-bench-results-${{ github.run_id }}
path: bench/o2-results/**
if-no-files-found: warn
delete-runner:
name: Delete Hetzner runner
needs: [create-runner, bench]
runs-on: ubuntu-latest
if: always()
steps:
- name: Delete ephemeral runner
uses: Cyclenerd/hcloud-github-runner@v1
with:
mode: delete
github_token: ${{ secrets.HETZNER_RUNNER_PAT }}
hcloud_token: ${{ secrets.HCLOUD_TOKEN }}
name: ${{ needs.create-runner.outputs.label }}
server_id: ${{ needs.create-runner.outputs.server_id }}

8
.gitignore vendored
View file

@ -72,7 +72,6 @@ wxwidgets-clean/
*.tmp
output/
*.d
/tests/.test-port-coroutine
.playwright-mcp
.claude/worktrees/
@ -80,18 +79,11 @@ output/
# Curated design docs live under docs/features/ and ARE committed.
/features/
# wasm-opt benchmark local artifacts. The harness scripts under scripts/bench/
# ARE committed; the fixtures/results (/bench) and the QEMU VM image
# (scripts/bench/vm) are local-only.
/bench/
/scripts/bench/vm/
# Temporary/disposable prebuilt WASM sets (native-EH + JS-EH × eeschema + pcbnew
# O1 builds) + the standalone perf harness, used to reproduce the runtime-perf
# comparison on demand. Large (~1 GB), never committed.
/benchmark-builds/
/tests/.test-port-asyncify
/memory/
# Wrangler (Cloudflare CLI) local state/cache — created when running R2 deploys.

4
.gitmodules vendored
View file

@ -10,7 +10,3 @@
path = web/pcbjam-shared
url = https://github.com/PCBJam/pcbjam-shared.git
branch = main
[submodule "binaryen"]
path = binaryen
url = https://github.com/PCBJam/binaryen
branch = wasm-port

View file

@ -58,7 +58,7 @@ kicad-wasm/
├── tests/ # Playwright E2E tests
│ ├── e2e/ # Test specs
│ └── apps/ # WASM test applications
├── tools/ # External tools (binaryen)
├── tools/ # External tools (local emsdk install)
└── output/ # Build output (pcbnew.js, pcbnew.wasm)
```
@ -95,8 +95,8 @@ See [docs/build.md](docs/build.md) for detailed build documentation.
#### Creating an isolated worktree (with submodule branches)
For an experiment or feature you can work in a disposable git worktree so the
main checkout stays pristine. This repo has four submodules
(`kicad`, `wxwidgets`, `binaryen`, `web/pcbjam-shared`); a new worktree starts
main checkout stays pristine. This repo has three submodules
(`kicad`, `wxwidgets`, `web/pcbjam-shared`); a new worktree starts
with them empty, so initialize and branch each one:
```bash
@ -106,11 +106,11 @@ git worktree add -b experiment/my-thing ../kicad-wasm-my-thing main
# 2. Check out the submodules INSIDE the worktree (working trees only;
# git objects are shared with the main checkout)
cd ../kicad-wasm-my-thing
git submodule update --init kicad wxwidgets binaryen web/pcbjam-shared
git submodule update --init kicad wxwidgets web/pcbjam-shared
# 3. Create a matching branch in each submodule (they start at detached HEAD)
git checkout -b experiment/my-thing # root already on it via -b above
for sm in kicad wxwidgets binaryen web/pcbjam-shared; do
for sm in kicad wxwidgets web/pcbjam-shared; do
git -C "$sm" checkout -b experiment/my-thing
done
```
@ -119,12 +119,11 @@ Then build from inside the worktree. Use an **isolated** Docker project — do N
set `COMPOSE_PROJECT_NAME` to another branch's project (e.g. `kicad-wasm-main`),
which can collide with other workflows; `docker/build.sh` auto-derives an isolated
project name from the worktree branch. The first build provisions deps
(wxWidgets + OCC) from scratch. To keep the machine responsive / bound wasm-opt
RAM, cap parallelism and skip the slow release optimization:
(wxWidgets + OCC) from scratch. To keep the machine responsive, cap
parallelism:
```bash
KICAD_DOCKER_CPUS=4 BINARYEN_CORES=4 BINARYEN_OPT_LEVEL=-O1 \
./docker/build.sh pcbnew -j 4
KICAD_DOCKER_CPUS=4 ./docker/build.sh pcbnew -j 4
```
Tear down afterward with `git worktree remove ../kicad-wasm-my-thing` (and

View file

@ -16,7 +16,7 @@
# eeschema standalone schematic engine (debug aid; not deployed)
#
# A comma-separated list builds just those apps in order (e.g.
# "calculator,pl_editor" — used to exercise the multi-app pipeline cheaply).
# "calculator,pl_editor" — used to exercise the multi-app path cheaply).
#
# Any extra args are forwarded to scripts/kicad/build-<app>.sh (e.g. -j 8,
# --full, --release, --diag=gal).
@ -24,17 +24,6 @@
# The build is split into two phases:
# 1. Docker: Compile KiCad to WASM (fully finalized; JSPI links in-container)
# 2. Host: ENV merge shim on the glue (patch-env-shim.mjs)
#
# KICAD_PIPELINE=1 (multi-app builds only): run phase 2 of each app in the
# background while the next app compiles in the container. wasm-opt is
# Amdahl-capped at ~4 effective cores, so on a many-core CI box the container
# would otherwise sit idle for the 1-2h of host-side wasm-opt (run 27226030304:
# 103 min of the 4h was tools serialized behind each other's wasm-opt). At most
# KICAD_PIPELINE_JOBS (default 2) postprocesses run concurrently — pcbnew's wasm-opt
# pass peaks ~34 GB RSS, so 2 fits the 128 GB CI box but NOT a dev Mac: leave
# KICAD_PIPELINE unset locally.
#
# Binaryen is downloaded automatically - no prerequisites needed.
# Auto-launch the live progress dashboard in this terminal (handled by logging.sh,
# which owns the TTY before it re-execs us with output redirected). Set KICAD_NO_MONITOR=1
@ -104,13 +93,11 @@ APP_NAME="$1"
shift
# Expand the app argument into APPS[]: "all", a single app, or a comma list.
# kicad_editor first in "all" — the merged image is the largest bundle, so its
# host-side wasm-opt chain is the critical path and must start as early as
# possible (especially with KICAD_PIPELINE=1). pcbnew/eeschema stay buildable as
# standalone debug aids but are not part of "all" (not deployed).
# kicad_tools joined "all" for the runner-image CI (tasks-runner 0001 R2) —
# it finalizes in-container (no host wasm-opt tail), so it never contends
# with the editor's critical path.
# kicad_editor first in "all" — the merged image is the deployed bundle and the
# longest compile, so it starts first and surfaces failures earliest.
# pcbnew/eeschema stay buildable as standalone debug aids but are not part of
# "all" (not deployed). kicad_tools joined "all" for the runner-image CI
# (tasks-runner 0001 R2).
if [[ "$APP_NAME" == "all" ]]; then
APPS=(kicad_editor occ_service ngspice_service calculator pl_editor gerbview kicad_tools)
else
@ -135,15 +122,14 @@ export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-kicad-wasm-${BRANCH_NAME}}"
echo "Using Docker project: ${COMPOSE_PROJECT_NAME}"
echo "Building app: ${APP_NAME}"
# Build phase (cache split). The container compile produces an OPT-INDEPENDENT
# base wasm; the only opt-DEPENDENT work is the final `wasm-opt -O$LEVEL` shrink
# Splitting compile and postprocess lets CI cache the expensive compile once
# and re-run just the host tail — see .github/workflows/.
# Build phase (cache split). The container compile emits the finalized wasm;
# the host tail is only the ENV merge shim on the glue. Splitting compile and
# postprocess lets CI cache the expensive compile once and re-run just the
# host tail — see .github/workflows/.
# (default) both — compile in-container, then host post-process.
# --compile-only — only the in-container compile → base wasm in output/.
# --compile-only — only the in-container compile → wasm+glue in output/.
# --postprocess-only — only the host post-process (ENV merge shim) on the
# existing output/ base wasm; NO container needed
# (build-wasm-opt.sh self-provisions the Binaryen submodule).
# existing output/ glue; NO container needed.
# Extracted here so they are NOT forwarded to the inner build-<app>.sh scripts.
PHASE="both"
_FILTERED=()
@ -252,7 +238,6 @@ compile_app() {
# for headless CLIs like kicad_tools — the gl1 shim needs glm).
docker compose -f docker/docker-compose.yml exec -e EMSDK=/emsdk \
-e BUILD_3D_VIEWER="${BUILD_3D_VIEWER:-}" \
-e PCBJAM_ASYNC_BACKEND="${PCBJAM_ASYNC_BACKEND:-}" \
kicad-wasm-builder \
"/workspace/scripts/kicad/build-${app}.sh" "${ARGS[@]}"
@ -277,128 +262,40 @@ compile_app() {
}
# Phase 2 of one app: host-side post-processing (ENV merge shim). Pure host
# work on output/${app}.* — independent of the
# container, which is what makes it safe to run in the background while the
# next app compiles.
# work on output/${app}.js — no container needed.
postprocess_app() {
local app="$1"
local out_dir="output"
# The headless CLI and the OCC/ngspice services are finalized in-container
# (real tools, small -g0 wasm) and build with ASYNCIFY=0, so they need no
# host post-processing (no dyncall shims, no finalize, no asyncify).
# The headless CLI and the OCC/ngspice services skip the ENV merge shim:
# it exists for the interactive apps' runtime env overrides (?trace=),
# which these targets never read.
if [ "$app" = "kicad_tools" ] || [ "$app" = "occ_service" ] || [ "$app" = "ngspice_service" ]; then
echo "Skipping host post-processing for ${app} (finalized in-container)"
return 0
fi
# JSPI: the app links fully finalized with the real in-container tools —
# no dyncall shims, no host finalize, no asyncify pass. Only the ENV merge
# shim remains: the emscripten glue never merges Module.ENV into the
# ENV merge shim: the emscripten glue never merges Module.ENV into the
# runtime ENV (?trace= would be a silent no-op — see docs/features/libs/0013).
kw_stage env-shim
node ./scripts/common/patch-env-shim.mjs "${out_dir}/${app}.js"
}
# --- Pipelined driver state (KICAD_PIPELINE=1) ---
# One background postprocess per app; logs + rc files land in logs/build/ so the
# interleaved output stays readable and failures survive until the final wait.
PIPELINE_PIDS=()
PIPELINE_APPS_BG=()
PIPELINE_LOG_DIR="logs/build"
PIPELINE_TS="$(date +%Y%m%d-%H%M%S)"
pipeline_running_count() {
local n=0 pid
for pid in "${PIPELINE_PIDS[@]}"; do
kill -0 "$pid" 2>/dev/null && n=$((n + 1))
done
echo "$n"
}
# Launch postprocess_app in the background, capped at KICAD_PIPELINE_JOBS
# concurrent jobs (default 2: pcbnew's wasm-opt pass peaks ~34 GB RSS; two postprocesses
# plus the container compile fit the 128 GB CI box). Portable poll loop instead
# of `wait -n` (absent in macOS bash 3.2).
pipeline_postprocess() {
local app="$1"
local max_jobs="${KICAD_PIPELINE_JOBS:-2}"
while [ "$(pipeline_running_count)" -ge "$max_jobs" ]; do
sleep 10
done
local log_file="${PIPELINE_LOG_DIR}/postprocess-${app}-${PIPELINE_TS}.log"
echo "Pipelining host-side postprocess of ${app} (log: ${log_file})"
(
postprocess_app "$app" >"$log_file" 2>&1
echo $? >"${log_file}.rc"
) &
PIPELINE_PIDS+=($!)
PIPELINE_APPS_BG+=("$app")
}
# Wait for all background postprocesses, replay their logs into the main log,
# and fail if any of them failed.
pipeline_wait_all() {
local failed=0 i pid app log_file rc
for i in "${!PIPELINE_PIDS[@]}"; do
pid="${PIPELINE_PIDS[$i]}"
app="${PIPELINE_APPS_BG[$i]}"
log_file="${PIPELINE_LOG_DIR}/postprocess-${app}-${PIPELINE_TS}.log"
wait "$pid" || true
rc="$(cat "${log_file}.rc" 2>/dev/null || echo 1)"
echo ""
echo "=== Postprocess ${app} (rc=${rc}) — ${log_file} ==="
cat "$log_file" 2>/dev/null || true
if [ "$rc" != "0" ]; then
echo "ERROR: postprocess of ${app} failed (rc=${rc})"
failed=1
fi
done
return "$failed"
}
TOTAL_APPS="${#APPS[@]}"
# Shared pipeline trap: on a failure, kill orphaned background wasm-opt jobs
# (each ~30 GB) and keep the monitor's done/fail marker from the EXIT trap.
_install_pipeline_trap() {
trap '_rc=$?; for p in "${PIPELINE_PIDS[@]}"; do kill "$p" 2>/dev/null || true; done; if [ $_rc -eq 0 ]; then kw_done; else kw_fail $_rc; fi; stop_builder' EXIT
}
if [[ "$PHASE" == "compile" ]]; then
# --compile-only: produce the opt-independent base wasm; no host post-process.
# --compile-only: produce the wasm+glue in output/; no host post-process.
idx=1
for app in "${APPS[@]}"; do
compile_app "$app" "$idx" "$TOTAL_APPS"
idx=$((idx + 1))
done
elif [[ "$PHASE" == "postprocess" ]]; then
# --postprocess-only: pure host post-process on the existing output/ base
# wasm (no container). Parallelize across apps when pipelining.
if [[ "${KICAD_PIPELINE:-0}" == "1" ]] && [ "$TOTAL_APPS" -gt 1 ]; then
mkdir -p "$PIPELINE_LOG_DIR"
_install_pipeline_trap
for app in "${APPS[@]}"; do
pipeline_postprocess "$app"
done
pipeline_wait_all
else
for app in "${APPS[@]}"; do
postprocess_app "$app"
done
fi
elif [[ "${KICAD_PIPELINE:-0}" == "1" ]] && [ "$TOTAL_APPS" -gt 1 ]; then
# both, pipelined: overlap app[i+1]'s container compile with app[i]'s host
# post-process (KICAD_PIPELINE=1).
mkdir -p "$PIPELINE_LOG_DIR"
_install_pipeline_trap
idx=1
# --postprocess-only: pure host post-process on the existing output/ glue
# (no container).
for app in "${APPS[@]}"; do
compile_app "$app" "$idx" "$TOTAL_APPS"
pipeline_postprocess "$app"
idx=$((idx + 1))
postprocess_app "$app"
done
pipeline_wait_all
else
# both, sequential.
idx=1

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

2
kicad

@ -1 +1 @@
Subproject commit 012d95ecb4606a94d198a4348e576b4794be8f66
Subproject commit 0bf6c9c34e08fc2e36dfe97acad55574cbfc8cd1

View file

@ -1,110 +0,0 @@
> **Historical (asyncify era).** These benches timed the Binaryen
> `apply-asyncify` post-link tail, which the JSPI migration deleted — the
> scripts below that reference it are gone. The VM provisioning pieces
> (setup-vm.sh, vm-build.sh, cloud-init) remain useful for any host-side
> build benching.
# wasm-opt allocator/core benchmark
Fast, local feedback loop for the CI perf issue: the host-side `wasm-opt`/asyncify
pass (`scripts/common/apply-asyncify.sh`) is slow on the glibc Linux CI runner
because glibc `malloc` collapses into per-arena lock (`futex`) contention under
many threads. We preload **jemalloc** to fix it. This harness measures the effect
locally instead of paying for a full ~160-min Hetzner CI run per experiment.
**Key idea:** `wasm-opt`/asyncify is a standalone pass over an *already-compiled*
`.wasm` (`docker/build.sh:194`). So we build the eeschema `.wasm` **once** on the
Mac, copy it into a Linux VM, and replay only the optimizer across a matrix of
`{glibc, jemalloc} × core counts`. No KiCad compile happens in the VM.
## Caveats (read before trusting numbers)
- **This Mac is 10 cores / 32 GB.** The local core sweep tops out at ~10 threads.
The CI runner is 32 vCPU, so the *full* 32-thread contention is **not**
reproducible here — the local run shows the **direction** (jemalloc vs glibc,
and how wall-clock scales with cores), not CI's absolute worst case. The final
32-thread pick must still be confirmed on the real runner.
- **aarch64 ≠ CI's x86_64.** The VM is aarch64 (HVF, near-native speed). The
glibc arena-lock pathology is arch-independent, so the **ratios** transfer;
**absolute seconds do not** match the x86_64 AMD runner.
- asyncify peaks ~1015 GB RAM → the VM gets 20 GB; close other heavy apps.
## 1. Build the fixture (once, on the Mac)
```bash
mkdir -p bench
./docker/build.sh eeschema --build-deps # long: full cold build
# Pull the Docker-side raw (pre-finalize) wasm while the builder container is up:
docker compose -f docker/docker-compose.yml cp \
kicad-wasm-builder:/workspace/build-wasm/kicad-eeschema/eeschema/eeschema.wasm \
bench/eeschema.raw.wasm
# Finalize it to match what asyncify actually consumes in the pipeline:
./scripts/common/apply-finalize.sh bench/eeschema.raw.wasm bench/eeschema.finalized.wasm
```
Fallback if `compose cp` fails:
`docker compose -f docker/docker-compose.yml exec kicad-wasm-builder cat <path> > bench/eeschema.raw.wasm`
## 2. Provision and boot the VM (Mac)
```bash
brew install qemu # one-time
./scripts/bench/setup-vm.sh prepare
./scripts/bench/setup-vm.sh run # serial console; quit with Ctrl-a x
```
Wait ~12 min for cloud-init (installs git/curl/time/libjemalloc2/strace).
## 3. Load repo + fixture into the VM
```bash
# from the Mac, in another terminal:
scp -P 2222 -o StrictHostKeyChecking=no bench/eeschema.finalized.wasm bench@localhost:~/
./scripts/bench/setup-vm.sh ssh
# inside the VM:
git clone <this-repo-url> repo && cd repo
git checkout istvanmatejcsok/feat/ci-hetzner-allcores
mkdir -p bench && mv ~/eeschema.finalized.wasm bench/
# get-wasm-opt.sh auto-downloads Binaryen v121 aarch64-linux on first use
```
## 4. Run the benchmark (in the VM)
```bash
STRACE=1 ./scripts/bench/wasm-opt-bench.sh
```
Sweeps `CORES="1 4 8 10"` × `{glibc, jemalloc}`, writing `bench/results.csv`
(wall-clock + peak RSS per cell) and per-cell logs under `bench/results/`. With
`STRACE=1` it also records `futex` syscall share per allocator (expect ~99% on
glibc, far lower with jemalloc). Override the sweep with e.g. `CORES="8 10"`.
## Interpreting
- jemalloc rows should show **lower wall-clock** than glibc, widening as cores rise.
- glibc wall-clock that *stops improving* (or worsens) with more cores = the
arena-lock storm; jemalloc should keep scaling.
- These ratios justify the `LD_PRELOAD` fix; use them (plus one real-runner
confirmation) to choose CI's `BINARYEN_CORES`.
Artifacts (`bench/*.wasm`, `bench/results*`, `scripts/bench/vm/`) are gitignored.
## 5. Full Docker build in the VM (CI dry-run) — vm-build.sh
Verifies CI orchestration changes (docker/build.sh, compose limits, pipelining)
on Linux+Docker without burning a Hetzner slot. The guest is aarch64/HVF:
a *functional* CI proxy, not an x86 performance proxy.
```bash
# one-time: bigger disk + Docker-enabled cloud-init, then boot
VM_DISK=80G ./scripts/bench/setup-vm.sh prepare
./scripts/bench/setup-vm.sh run # leave running in its own terminal
# from the Mac: cold calculator build inside the guest (deps + docker image)
./scripts/bench/vm-build.sh # = calculator --build-deps
# pipeline smoke test (deps already in the guest volume from the previous run)
KICAD_PIPELINE=1 ./scripts/bench/vm-build.sh calculator,pl_editor
```
Prints the guest build wall time at the end; build logs stream through ssh.

View file

@ -1,2 +0,0 @@
instance-id: kicad-wasmopt-bench
local-hostname: kicad-bench

View file

@ -1,42 +0,0 @@
#cloud-config
# NoCloud seed for the wasm-opt benchmark VM. setup-vm.sh substitutes
# __SSH_PUBKEY__ with your ~/.ssh/id_ed25519.pub before building the seed ISO.
hostname: kicad-bench
users:
- name: bench
sudo: "ALL=(ALL) NOPASSWD:ALL"
shell: /bin/bash
# Key auth only: setup-vm.sh requires an SSH pubkey and substitutes it below,
# so no password login is needed. Lock the password and disable SSH password
# auth (below); recover via the substituted key, not a shared password.
lock_passwd: true
ssh_authorized_keys:
- __SSH_PUBKEY__
# Disable SSH password authentication — the seed carries a pubkey.
ssh_pwauth: false
package_update: true
# Docker CE from the official repo (mirrors the CI workflow's install step) so
# scripts/bench/vm-build.sh can run the full docker/build.sh pipeline in-guest.
apt:
sources:
docker.list:
source: "deb [signed-by=$KEY_FILE] https://download.docker.com/linux/ubuntu noble stable"
keyid: 9DC858229FC7DD38854AE2D88D81803C0EBFCD88
packages:
- git
- curl
- ca-certificates
- time
- libjemalloc2
- strace
- rsync
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
runcmd:
# The docker group only exists after the package installs, so the user's
# `groups:` stanza can't grant it — add membership here (applies to new
# ssh sessions, which is all vm-build.sh uses).
- usermod -aG docker bench

View file

@ -1,166 +0,0 @@
#!/bin/bash
# o2-config-sweep.sh — replay wasm-opt over a PREBUILT asyncified fixture under a
# matrix of {Binaryen version, optimization passes, thread count, allocator/THP}.
# RUNS ON THE LINUX CI BOX.
#
# WHY: the on-box diagnostic (docs/ci-build-slowness-findings.md) proved the
# ~80-min `-O2` cost is ~90% FUTEX LOCK CONTENTION inside wasm-opt (Binaryen's
# global type mutex), NOT the allocator and NOT memory management (madvise=0,
# THP compaction=0, identical under glibc and mimalloc). So the levers that can
# move wall-clock are: a NEWER Binaryen (the devs cut this contention after our
# pinned v121) and FEWER threads (less lock contention) — plus reducing the -O2
# work itself (lighter passes). Allocator/THP are dead ends, kept only as controls.
#
# CONFIG NAME GRAMMAR: <preset>[@<cores>]
# preset sets Binaryen version + passes + allocator/THP (see config_env).
# optional @<cores> overrides BINARYEN_CORES for that one cell (sweep threads).
# e.g. "v130-O2@8" = Binaryen 130, -O2, 8 threads.
#
# TWO MODES:
# CAP_SECONDS=0 (default): run to completion → true wall-clock + output size.
# CAP_SECONDS>0: WINDOWED sample — the lock storm is steady-state, so measure
# it over a [60s, CAP-30s] window and kill the pass. Reports, per cell:
# win_sysfrac = system CPU fraction in the window (lock contention; LOWER better)
# win_usercores = REAL-work cores in the window (progress rate; HIGHER better
# → predicts shorter wall for the same pass set)
# win_tlb = TLB-shootdown interrupts in the window
# ~CAP/60 min per cell, so the whole matrix fits one CI run.
#
# Usage: CONFIGS="baseline v130-O2 baseline@8" CAP_SECONDS=600 \
# ./scripts/bench/o2-config-sweep.sh <asyncified.wasm>
# Env: CONFIGS, CORES (default nproc), CAP_SECONDS (default 0), DIAGNOSTIC (1 =
# perf kernel-symbol sample, full mode). Output under /bench/o2-results/.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO="$(cd "${SCRIPT_DIR}/../.." && pwd)"
FIXTURE="${1:?usage: o2-config-sweep.sh <asyncified-fixture.wasm>}"
CONFIGS="${CONFIGS:-baseline}"
CORES_DEFAULT="${CORES:-$(nproc)}"
CAP_SECONDS="${CAP_SECONDS:-0}"
DIAGNOSTIC="${DIAGNOSTIC:-0}"
[[ "$(uname -s)" == "Linux" ]] || { echo "ERROR: run on the Linux CI box." >&2; exit 1; }
[[ -f "${FIXTURE}" ]] || { echo "ERROR: fixture not found: ${FIXTURE}" >&2; exit 1; }
command -v /usr/bin/time >/dev/null || { echo "ERROR: apt-get install -y time" >&2; exit 1; }
ARCH="$(uname -m)"
WASM_OPT_121="$("${REPO}/scripts/common/get-wasm-opt.sh" 2>/dev/null)" # pinned v121
JEMALLOC="$(ls /usr/lib/${ARCH}-linux-gnu/libjemalloc.so.2 2>/dev/null | head -1 || true)"
MIMALLOC="$(ls /usr/lib/${ARCH}-linux-gnu/libmimalloc.so* 2>/dev/null | sort | tail -1 || true)"
OUTDIR="${REPO}/bench/o2-results"; mkdir -p "${OUTDIR}"
CSV="${OUTDIR}/results.csv"
echo "config,cores,ver,flags,thp,mode,wall,wall_s,user_s,sys_s,vol_ctxsw,win_sysfrac,win_usercores,win_tlb,madvise,out_bytes,preload" > "${CSV}"
sudo sysctl -w kernel.perf_event_paranoid=-1 >/dev/null 2>&1 || true
PERF=""; command -v perf >/dev/null 2>&1 && perf stat -e syscalls:sys_enter_madvise -- true >/dev/null 2>&1 && PERF="yes"
echo "perf syscall counting: ${PERF:-NO}"
CLK="$(getconf CLK_TCK 2>/dev/null || echo 100)"
# Resolve (download+cache) a standalone wasm-opt for a Binaryen version. 121 = the
# pinned one from get-wasm-opt.sh; anything else is fetched from GitHub releases.
get_wasm_opt_ver() {
local ver="$1"
if [[ -z "$ver" || "$ver" == "121" ]]; then echo "${WASM_OPT_121}"; return 0; fi
local dir="${REPO}/build-wasm/tools/binaryen-${ver}" bin="${REPO}/build-wasm/tools/binaryen-${ver}/bin/wasm-opt"
if [[ ! -x "$bin" ]]; then
local url="https://github.com/WebAssembly/binaryen/releases/download/version_${ver}/binaryen-version_${ver}-${ARCH}-linux.tar.gz"
mkdir -p "${REPO}/build-wasm/tools"
echo " downloading Binaryen v${ver}..." >&2
curl -fsSL -o "/tmp/binaryen-${ver}.tgz" "$url" || { echo " ERR: download v${ver} failed ($url)" >&2; return 1; }
tar -xzf "/tmp/binaryen-${ver}.tgz" -C "${REPO}/build-wasm/tools" || return 1
mv "${REPO}/build-wasm/tools/binaryen-version_${ver}" "$dir" 2>/dev/null || true
fi
[[ -x "$bin" ]] && echo "$bin" || return 1
}
snap_cpu() { awk '/^cpu /{u=$2;s=$4;t=0;for(i=2;i<=NF;i++)t+=$i;print u,s,t}' /proc/stat; }
snap_tlb() { awk '/TLB/{for(i=2;i<=NF;i++)if($i~/^[0-9]+$/)s+=$i}END{print s+0}' /proc/interrupts; }
snap_vm() { awk '/^compact_stall /{c=$2}/^thp_fault_alloc /{t=$2}END{print c+0,t+0}' /proc/vmstat; }
thp_state(){ grep -oP '\[\K[^]]+' /sys/kernel/mm/transparent_hugepage/enabled 2>/dev/null || echo "?"; }
set_thp() { [[ "$1" == "asis" ]] && return 0
echo "$1"|sudo tee /sys/kernel/mm/transparent_hugepage/enabled >/dev/null 2>&1 || true
echo "$1"|sudo tee /sys/kernel/mm/transparent_hugepage/defrag >/dev/null 2>&1 || true; }
# preset -> BIN_VER, OPTARGS (pass list), PRELOAD, THP_WANT, EXTRA env
BIN_VER=""; OPTARGS=(); PRELOAD=""; THP_WANT="asis"; EXTRA=()
LIGHT=(--flatten --simplify-locals --coalesce-locals --reorder-locals --vacuum)
config_env() {
BIN_VER="121"; OPTARGS=(-O2); PRELOAD=""; THP_WANT="asis"; EXTRA=()
case "$1" in
baseline) ;; # v121 -O2 (current CI = control)
v130-O2) BIN_VER="130" ;; # newer Binaryen, same passes — lock fixed?
v130-O1) BIN_VER="130"; OPTARGS=(-O1) ;;
v121-O1) OPTARGS=(-O1) ;; # lighter passes (less work)
v130-light) BIN_VER="130"; OPTARGS=("${LIGHT[@]}") ;;
v121-light) OPTARGS=("${LIGHT[@]}") ;;
mimalloc-retain) PRELOAD="${MIMALLOC}"; EXTRA=(MIMALLOC_PURGE_DELAY=-1 MIMALLOC_ALLOW_THP=0) ;; # control (proven dead)
thp-off) THP_WANT="never" ;; # control (proven dead)
*) echo "ERROR: unknown preset '$1'" >&2; return 1 ;;
esac
if [[ -n "${PRELOAD}" && ! -e "${PRELOAD}" ]]; then echo "WARN: preload for '$1' missing — SKIP" >&2; return 2; fi
}
field() { grep -F "$1" "$2" 2>/dev/null | tail -1 | sed 's/.*: //' | tr -d ' '; }
run_config() {
local cfg="$1" base cores
base="${cfg%@*}"; if [[ "$cfg" == *"@"* ]]; then cores="${cfg##*@}"; else cores="${CORES_DEFAULT}"; fi
if ! config_env "${base}"; then echo "${cfg},${cores},,,skip,skip,SKIPPED,,,,,,,,,," >> "${CSV}"; return 0; fi
local wopt; wopt="$(get_wasm_opt_ver "${BIN_VER}")" || {
echo " cannot resolve Binaryen v${BIN_VER} — SKIP"; echo "${cfg},${cores},${BIN_VER},,skip,skip,SKIP-NOBIN,,,,,,,,,," >> "${CSV}"; return 0; }
set_thp "${THP_WANT}"; local thp_now; thp_now="$(thp_state)"
local flags="${OPTARGS[*]}"
local timef="${OUTDIR}/${cfg//[@ ]/_}.time" statf="${OUTDIR}/${cfg//[@ ]/_}.perfstat" logf="${OUTDIR}/${cfg//[@ ]/_}.log"
local outw="/tmp/o2-out.wasm"
cp "${FIXTURE}" /tmp/o2-in.wasm
local -a runenv=(BINARYEN_CORES="${cores}" "${EXTRA[@]}"); [[ -n "${PRELOAD}" ]] && runenv+=("LD_PRELOAD=${PRELOAD}")
echo ""; echo "=== ${cfg} (binaryen=$("${wopt}" --version 2>&1 | grep -oE '[0-9]+' | head -1), flags='${flags}', cores=${cores}, THP=${thp_now}, preload=${PRELOAD:-none}, extra=${EXTRA[*]:-none}) ==="
local mode="full" wall wall_s user sys volcsw sysf usercores tlbd madv outsz
sysf=NA; usercores=NA; tlbd=NA; madv=NA
if [[ "${CAP_SECONDS}" -gt 0 ]]; then
mode="cap${CAP_SECONDS}"
( /usr/bin/time -v -o "${timef}" timeout "${CAP_SECONDS}" env "${runenv[@]}" "${wopt}" "${OPTARGS[@]}" /tmp/o2-in.wasm -o "${outw}" > "${logf}" 2>&1 ) &
local rp=$!
sleep 60
local cu0 cs0 ct0 tlb0; read -r cu0 cs0 ct0 <<<"$(snap_cpu)"; tlb0="$(snap_tlb)"
local win=$(( CAP_SECONDS>120 ? CAP_SECONDS-90 : 30 )); sleep "${win}"
local cu1 cs1 ct1 tlb1; read -r cu1 cs1 ct1 <<<"$(snap_cpu)"; tlb1="$(snap_tlb)"
wait "${rp}" 2>/dev/null || true
local dtot=$((ct1-ct0)) dsys=$((cs1-cs0)) duser=$((cu1-cu0))
sysf="$(awk -v s=${dsys} -v t=${dtot} 'BEGIN{print (t>0)?sprintf("%.2f",s/t):"NA"}')"
usercores="$(awk -v u=${duser} -v c=${CLK} -v w=${win} 'BEGIN{print (w>0)?sprintf("%.2f",(u/c)/w):"NA"}')"
tlbd=$((tlb1-tlb0)); wall="cap@${CAP_SECONDS}"; wall_s="${CAP_SECONDS}"
else
if [[ -n "${PERF}" ]]; then
/usr/bin/time -v -o "${timef}" perf stat -o "${statf}" -e syscalls:sys_enter_madvise,syscalls:sys_enter_munmap \
env "${runenv[@]}" "${wopt}" "${OPTARGS[@]}" /tmp/o2-in.wasm -o "${outw}" > "${logf}" 2>&1
madv="$(grep -E 'sys_enter_madvise' "${statf}" 2>/dev/null | awk '{gsub(/,/,"",$1);print $1}' | head -1)"
else
/usr/bin/time -v -o "${timef}" env "${runenv[@]}" "${wopt}" "${OPTARGS[@]}" /tmp/o2-in.wasm -o "${outw}" > "${logf}" 2>&1
fi
if [[ $? -ne 0 ]]; then echo " FAILED"; tail -4 "${logf}"; echo "${cfg},${cores},${BIN_VER},${flags// /+},${thp_now},full,FAILED,,,,,,,,,," >> "${CSV}"; return 0; fi
wall="$(field 'Elapsed (wall clock) time' "${timef}")"
wall_s="$(awk -F: '{if(NF==3)print $1*3600+$2*60+$3;else if(NF==2)print $1*60+$2;else print $1}' <<<"${wall}")"
fi
user="$(field 'User time (seconds)' "${timef}")"; sys="$(field 'System time (seconds)' "${timef}")"
volcsw="$(field 'Voluntary context switches' "${timef}")"; outsz="$(stat -c %s "${outw}" 2>/dev/null || echo NA)"
printf '%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s\n' \
"${cfg}" "${cores}" "${BIN_VER}" "${flags// /+}" "${thp_now}" "${mode}" "${wall}" "${wall_s}" \
"${user}" "${sys}" "${volcsw}" "${sysf}" "${usercores}" "${tlbd}" "${madv}" "${outsz}" "${PRELOAD:-none}" >> "${CSV}"
echo " mode=${mode} wall=${wall} user=${user}s sys=${sys}s vol_ctxsw=${volcsw} out=${outsz}B"
echo " WINDOW sys_frac=${sysf} (lock contention) user_cores=${usercores} (real-work rate) tlb=${tlbd} madvise=${madv}"
rm -f "${outw}" /tmp/o2-in.wasm
}
echo "Fixture: ${FIXTURE} ($(stat -c %s "${FIXTURE}" 2>/dev/null) bytes)"
echo "Configs: ${CONFIGS} CORES_DEFAULT=${CORES_DEFAULT} CAP_SECONDS=${CAP_SECONDS} THP(init)=$(thp_state)"
for cfg in ${CONFIGS}; do run_config "${cfg}"; done
echo ""; echo "=== results (${CSV}) ==="; column -t -s, "${CSV}" || cat "${CSV}"

View file

@ -1,92 +0,0 @@
#!/bin/bash
# Provision a local QEMU aarch64 + HVF Ubuntu 24.04 VM for the wasm-opt benchmark.
# RUNS ON THE macOS HOST (Apple Silicon). See scripts/bench/README.md.
#
# Subcommands:
# prepare download cloud image, build NoCloud seed ISO, copy UEFI vars
# run boot the VM (foreground, serial console; ssh on :2222)
# ssh ssh into the running VM
# (none) = prepare (if needed) then run
#
# Env overrides: VM_CORES (default 10), VM_MEM (default 20G), SSH_PORT (2222),
# VM_DISK (default +30G grow; use VM_DISK=80G for full Docker builds via
# scripts/bench/vm-build.sh — emsdk image + deps + build tree need ~50 GB).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
VMDIR="${SCRIPT_DIR}/vm"
CLOUD_INIT="${SCRIPT_DIR}/cloud-init"
VM_CORES="${VM_CORES:-10}" # Mac has 10 cores; this caps the local sweep
VM_MEM="${VM_MEM:-20G}" # asyncify peaks ~10-15 GB; leave headroom for macOS
VM_DISK="${VM_DISK:-30G}" # disk grow beyond the ~3.5 GB cloud image
SSH_PORT="${SSH_PORT:-2222}"
IMG_URL="https://cloud-images.ubuntu.com/releases/24.04/release/ubuntu-24.04-server-cloudimg-arm64.img"
QEMU_SHARE="$(brew --prefix qemu)/share/qemu"
FW_CODE="${QEMU_SHARE}/edk2-aarch64-code.fd"
FW_VARS_SRC="${QEMU_SHARE}/edk2-arm-vars.fd"
BASE_IMG="${VMDIR}/ubuntu-24.04-arm64.img"
DISK="${VMDIR}/disk.qcow2"
SEED="${VMDIR}/seed.iso"
FW_VARS="${VMDIR}/edk2-vars.fd"
PUBKEY="${HOME}/.ssh/id_ed25519.pub"
prepare() {
mkdir -p "${VMDIR}"
command -v qemu-system-aarch64 >/dev/null || { echo "Install qemu: brew install qemu" >&2; exit 1; }
[[ -f "${PUBKEY}" ]] || { echo "No SSH pubkey at ${PUBKEY}" >&2; exit 1; }
if [[ ! -f "${BASE_IMG}" ]]; then
echo "Downloading Ubuntu 24.04 arm64 cloud image..."
curl -fL -o "${BASE_IMG}" "${IMG_URL}"
fi
# Fresh working disk each prepare: copy the cloud image and grow it (the
# cloud image is ~3.5 GB; binaryen + fixture + scratch need more headroom).
echo "Creating working disk (${DISK}, +${VM_DISK})..."
cp "${BASE_IMG}" "${DISK}"
qemu-img resize "${DISK}" "+${VM_DISK}"
# Writable UEFI vars store (copy of the template).
cp "${FW_VARS_SRC}" "${FW_VARS}"
# Build the NoCloud seed ISO (label must be CIDATA), injecting the pubkey.
echo "Building cloud-init seed ISO..."
local tmp; tmp="$(mktemp -d)"
sed "s|__SSH_PUBKEY__|$(cat "${PUBKEY}")|" "${CLOUD_INIT}/user-data" > "${tmp}/user-data"
cp "${CLOUD_INIT}/meta-data" "${tmp}/meta-data"
rm -f "${SEED}"
hdiutil makehybrid -iso -joliet -default-volume-name CIDATA -o "${SEED}" "${tmp}" >/dev/null
rm -rf "${tmp}"
echo "Prepared. Boot with: $0 run"
}
run() {
[[ -f "${DISK}" && -f "${SEED}" && -f "${FW_VARS}" ]] || { echo "Run '$0 prepare' first." >&2; exit 1; }
echo "Booting VM: ${VM_CORES} vCPU, ${VM_MEM} RAM, ssh -> localhost:${SSH_PORT} (user: bench)"
echo "First boot runs cloud-init (installs packages); wait ~1-2 min before ssh."
echo "Quit the serial console with: Ctrl-a x"
exec qemu-system-aarch64 \
-machine virt -accel hvf -cpu host \
-smp "${VM_CORES}" -m "${VM_MEM}" \
-drive "if=pflash,format=raw,readonly=on,file=${FW_CODE}" \
-drive "if=pflash,format=raw,file=${FW_VARS}" \
-drive "if=virtio,format=qcow2,file=${DISK}" \
-drive "if=virtio,format=raw,file=${SEED}" \
-netdev "user,id=n0,hostfwd=tcp::${SSH_PORT}-:22" \
-device virtio-net,netdev=n0 \
-nographic
}
do_ssh() { exec ssh -p "${SSH_PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null bench@localhost "$@"; }
case "${1:-}" in
prepare) prepare ;;
run) run ;;
ssh) shift; do_ssh "$@" ;;
"") [[ -f "${DISK}" && -f "${SEED}" ]] || prepare; run ;;
*) echo "Usage: $0 {prepare|run|ssh}" >&2; exit 1 ;;
esac

View file

@ -1,33 +0,0 @@
# Parameters for the wasm-opt config sweep (.github/workflows/wasm-opt-bench.yml).
# Edit, commit, push to a bench/** branch to launch a run.
#
# Config grammar: <preset>[@<cores>] (see scripts/bench/o2-config-sweep.sh)
# presets: baseline(v121 -O2) v130-O2 v130-O1 v121-O1 v130-light v121-light
# mimalloc-retain thp-off (last two are proven-dead controls)
# @<cores> overrides BINARYEN_CORES for that cell (thread sweep).
# CAP_SECONDS_CONF: >0 = windowed storm sample (~CAP/60 min/cell, whole matrix in
# one run); 0 = run each to completion (true wall-clock). FIXTURE_RUN_ID_CONF:
# reuse a prior run's cached fixture (blank = build it ~40 min).
#
# VERDICT from run #1 (27197360957): the -O2 cost is ~90% FUTEX LOCK CONTENTION
# in Binaryen (type mutex), NOT allocator/THP (madvise=0, compaction=0, identical
# under glibc & mimalloc; mimalloc-retain saved only 8%). So allocator/THP are
# dead; the levers are NEWER Binaryen (devs fixed this after v121) and FEWER
# threads, plus lighter passes. Run #2 triages those windowed (fast):
# win_sysfrac = lock contention (LOWER better)
# win_usercores = real-work rate (HIGHER better → shorter wall for same passes)
# ---- Run #2: windowed triage (10 min/cell) on the cached run-#1 fixture ----
# baseline = v121 -O2 @32c (control: expect sys_frac~0.88, user_cores~2.6)
# v130-O2 = newer Binaryen @32c (does the version fix the lock?)
# baseline@8 = v121 -O2 @8c (do fewer threads cut contention?)
# v130-O2@8 = newer + fewer threads
# v121-O1 = lighter passes @32c (does less work progress faster?)
CONFIGS_CONF="baseline v130-O2 baseline@8 v130-O2@8 v121-O1"
CORES_CONF=""
FIXTURE_RUN_ID_CONF="27197360957"
DIAGNOSTIC_CONF="0"
CAP_SECONDS_CONF="600"
# ---- Run #3 (planned): full run (CAP_SECONDS_CONF="0") of the winner(s) for true
# wall-clock + output validity, then Chrome e2e. e.g. CONFIGS="v130-O2 v130-O2@8"

View file

@ -1,100 +0,0 @@
#!/bin/bash
# Run a real docker/build.sh build INSIDE the local QEMU Linux VM, as a cheap
# stand-in for the Hetzner CI runner (Linux + Docker + the same scripts), so CI
# orchestration changes can be verified without burning a Hetzner slot.
# RUNS ON THE macOS HOST. The guest is aarch64 under HVF — near-native speed,
# a correct *functional* proxy for CI but NOT an x86 performance proxy.
#
# Usage:
# ./scripts/bench/vm-build.sh [build.sh args...]
# default args: calculator --build-deps
#
# Examples:
# ./scripts/bench/vm-build.sh # cold calculator build
# KICAD_PIPELINE=1 ./scripts/bench/vm-build.sh calculator,pl_editor # pipeline smoke test
#
# Prereqs (one-time):
# VM_DISK=80G ./scripts/bench/setup-vm.sh prepare # 80G: emsdk image + deps + build tree
# ./scripts/bench/setup-vm.sh run # boot in another terminal, wait for cloud-init
#
# Env: SSH_PORT (2222), KICAD_PIPELINE/KICAD_PIPELINE_JOBS (forwarded to the guest),
# VM_BUILD_JOBS (compile -j inside the guest; default = guest nproc).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
SSH_PORT="${SSH_PORT:-2222}"
GUEST="bench@localhost"
GUEST_DIR="kicad-wasm"
SSH_OPTS=(-p "${SSH_PORT}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null)
vssh() { ssh "${SSH_OPTS[@]}" "${GUEST}" "$@"; }
if ! vssh true 2>/dev/null; then
echo "ERROR: VM not reachable on localhost:${SSH_PORT}." >&2
echo "Boot it first: ./scripts/bench/setup-vm.sh run (wait ~2 min for cloud-init)" >&2
exit 1
fi
# Wait until cloud-init finished installing Docker (first boot takes a few min).
echo "Waiting for cloud-init to finish (installs Docker on first boot)..."
vssh "cloud-init status --wait >/dev/null 2>&1 || true"
vssh "docker info >/dev/null 2>&1" || {
echo "ERROR: Docker not usable in the guest. Re-run setup-vm.sh prepare with the" >&2
echo "updated cloud-init (installs docker-ce) and boot a fresh VM." >&2
exit 1
}
# Sync the working tree (incl. submodule content, no .git) into the guest.
# Same exclude set as build.sh's container-sync, plus the VM images themselves.
echo "Syncing repo to ${GUEST}:~/${GUEST_DIR} ..."
rsync -az --delete \
-e "ssh ${SSH_OPTS[*]}" \
--exclude="build-wasm" \
--exclude="output" \
--exclude=".git" \
--exclude="logs" \
--exclude=".idea" \
--exclude="node_modules" \
--exclude="tools/emsdk" \
--exclude="scripts/bench/vm" \
--exclude="tests/test-results" \
--exclude="tests/playwright-report" \
"${PROJECT_ROOT}/" "${GUEST}:${GUEST_DIR}/"
BUILD_ARGS=("$@")
[ ${#BUILD_ARGS[@]} -eq 0 ] && BUILD_ARGS=(calculator --build-deps)
# Append a guest-sized -j unless the caller already passed one.
JOBS_FLAG='-j "${JOBS}"'
[[ " ${BUILD_ARGS[*]} " == *" -j "* ]] && JOBS_FLAG=""
# Guest-side env:
# - COMPOSE_PROJECT_NAME: no .git in the guest tree, so build.sh can't derive a
# branch name — pin the project name explicitly.
# - KICAD_DOCKER_CPUS/MEM: the compose dev-Mac caps (10 CPU / 32G) point at a
# guest that may have fewer cores and definitely has less RAM; size to guest.
# - KICAD_NO_MONITOR/KICAD_LOG_NESTED: no TTY dashboard over ssh, stream output.
REMOTE_CMD=$(cat <<EOF
set -e
cd ${GUEST_DIR}
GUEST_CORES=\$(nproc)
GUEST_MEM_G=\$(awk '/MemTotal/{printf "%d", \$2/1024/1024 - 3}' /proc/meminfo)
export COMPOSE_PROJECT_NAME=kicad-wasm-vm
export KICAD_DOCKER_CPUS=\${GUEST_CORES}
export KICAD_DOCKER_MEM=\${GUEST_MEM_G}G
export KICAD_NO_MONITOR=1 KICAD_LOG_NESTED=1
export KICAD_PIPELINE=${KICAD_PIPELINE:-0} KICAD_PIPELINE_JOBS=${KICAD_PIPELINE_JOBS:-2}
JOBS=${VM_BUILD_JOBS:-\$GUEST_CORES}
echo "=== VM build: \$(uname -m), \${GUEST_CORES} cores, docker mem \${KICAD_DOCKER_MEM}, -j \${JOBS}, args: ${BUILD_ARGS[*]} ==="
time ./docker/build.sh ${BUILD_ARGS[*]} ${JOBS_FLAG}
ls -lh output/
EOF
)
START=$(date +%s)
vssh "${REMOTE_CMD}"
END=$(date +%s)
echo ""
echo "=== VM build wall time: $(( (END - START) / 60 ))m $(( (END - START) % 60 ))s (args: ${BUILD_ARGS[*]}) ==="

View file

@ -91,6 +91,7 @@ BEGIN {
# Canonical per-app stage rows, in order. Sub-stages (wxwidgets-configure /
# -compile) fold into the wxwidgets row via parent[].
ROWN = 0
ord[++ROWN] = "container-sync"; lab["container-sync"] = "Sync source to container"
ord[++ROWN] = "deps"; lab["deps"] = "Dependencies"
ord[++ROWN] = "wxwidgets"; lab["wxwidgets"] = "wxWidgets"
ord[++ROWN] = "kicad-stubs"; lab["kicad-stubs"] = "Stub libraries"
@ -98,10 +99,7 @@ BEGIN {
ord[++ROWN] = "kicad-compile"; lab["kicad-compile"] = "KiCad compile"
ord[++ROWN] = "kicad-bitmaps"; lab["kicad-bitmaps"] = "Bitmap resources"
ord[++ROWN] = "copy-output"; lab["copy-output"] = "Copy output"
ord[++ROWN] = "binaryen"; lab["binaryen"] = "Build Binaryen"
ord[++ROWN] = "dyncall-shims"; lab["dyncall-shims"] = "dynCall shims"
ord[++ROWN] = "finalize"; lab["finalize"] = "Finalize WASM"
ord[++ROWN] = "asyncify"; lab["asyncify"] = "Asyncify"
ord[++ROWN] = "env-shim"; lab["env-shim"] = "ENV merge shim"
for (i = 1; i <= ROWN; i++) ridx[ord[i]] = i
parent["wxwidgets-configure"] = "wxwidgets"
parent["wxwidgets-compile"] = "wxwidgets"
@ -152,12 +150,11 @@ curRawKey == "kicad-compile" {
if ($0 ~ /Configuring KiCad with CMake/) h_phase = "kicad-configure"
if ($0 ~ /\(CMake target:/) h_phase = "kicad-compile"
if ($0 ~ /Building bitmap resources/) h_phase = "kicad-bitmaps"
if ($0 ~ /Applying asyncify transformation/) h_phase = "asyncify"
if (h_phase == "kicad-compile" && match($0, /[0-9]+%\]/)) {
p = substr($0, RSTART, RLENGTH); gsub(/[^0-9]/, "", p); h_pct = p + 0
}
if (h_phase == "kicad-compile" && $0 ~ /Building (C|CXX) object/) h_cc++
if ($0 ~ /Build complete\. Output files/ || $0 ~ /Asyncify complete/) h_done = 1
if ($0 ~ /Build complete\. Output files/) h_done = 1
}
END {
if (markerCount > 0) { render_markers(); }
@ -173,8 +170,6 @@ function render_markers( state, totalEl, pk, curIdx, i, k, st, det, subdet, en
printf "H|-|0|0|%d|%s\n", totalEl, state
if (curRawKey == "container-sync") {
printf "R|active|Sync source to container|%s\n", fmt(termTs - rowStartTs["container-sync"])
} else if (curRawKey == "binaryen") {
printf "R|active|Build Binaryen|%s\n", fmt(termTs - rowStartTs["binaryen"])
} else {
printf "N|waiting for build to start...\n"
}
@ -234,13 +229,12 @@ function render_heuristic( state, order, ph, i, k, st, names) {
}
printf "N|(no progress markers in this log - inferred from log text)\n"
# Reduced ordered phase set we can detect heuristically.
split("wxwidgets kicad-configure kicad-compile kicad-bitmaps asyncify", order, " ")
split("wxwidgets kicad-configure kicad-compile kicad-bitmaps", order, " ")
names["wxwidgets"]="wxWidgets"; names["kicad-configure"]="KiCad configure (CMake)"
names["kicad-compile"]="KiCad compile"; names["kicad-bitmaps"]="Bitmap resources"
names["asyncify"]="Asyncify"
ph = 0
for (i = 1; i <= 5; i++) if (order[i] == h_phase) ph = i
for (i = 1; i <= 5; i++) {
for (i = 1; i <= 4; i++) if (order[i] == h_phase) ph = i
for (i = 1; i <= 4; i++) {
k = order[i]
if (h_done) st = "done"
else if (i < ph) st = "done"

View file

@ -189,8 +189,8 @@ if [ $NEEDS_CONFIGURE -eq 1 ]; then
fi
# Exception model: native WebAssembly exceptions (legacy binary encoding) + wasm setjmp/longjmp,
# single-sourced from scripts/common/env.sh. The catch-arm-hoisting pass (run post-link, see
# build-wasm-test.sh) lets Asyncify suspend from inside C++ catch blocks. See docs/features/wasm-exceptions/.
# single-sourced from scripts/common/env.sh. -sWASM_LEGACY_EXCEPTIONS=1 pins the EH binary
# encoding (exnref is not adopted across our pinned emsdk/browsers). See docs/features/wasm-exceptions/.
WX_EH_FLAGS="$DEPS_EH_FLAGS"
echo "wx EH model flags: ${WX_EH_FLAGS}"

View file

@ -41,8 +41,8 @@ export EMSDK_QUIET=1
# WebAssembly exceptions (legacy binary encoding) are the only build mode. -sSUPPORT_LONGJMP=wasm is
# required because the deps that use setjmp/longjmp (freetype, cairo, OpenCASCADE) must use wasm
# setjmp — emscripten's JS-longjmp implementation cannot coexist with -fwasm-exceptions (it would
# leave emscripten_longjmp undefined). -sWASM_LEGACY_EXCEPTIONS=1 selects the EH binary encoding our
# post-link Asyncify + catch-arm-hoisting pass can consume (Asyncify can't handle exnref).
# leave emscripten_longjmp undefined). -sWASM_LEGACY_EXCEPTIONS=1 pins the legacy EH binary encoding:
# the exnref encoding is not adopted across our pinned emsdk toolchain and target browsers.
export DEPS_EH_FLAGS="-fwasm-exceptions -sSUPPORT_LONGJMP=wasm -sWASM_LEGACY_EXCEPTIONS=1"
# Emscripten SDK setup
@ -123,23 +123,6 @@ elif [ -z "$JOBS" ]; then
export JOBS=1
fi
# Common linker flags for WASM
export WASM_LDFLAGS="\
-sALLOW_MEMORY_GROWTH=1 \
-sINITIAL_MEMORY=256MB \
-sSTACK_SIZE=5MB \
-sASYNCIFY=1 \
-sASYNCIFY_STACK_SIZE=16384 \
-sLEGACY_GL_EMULATION \
-sMAX_WEBGL_VERSION=2"
# Threading flags (when enabled)
export PTHREAD_LDFLAGS="\
-pthread \
-sPROXY_TO_PTHREAD=1 \
-sPTHREAD_POOL_SIZE=navigator.hardwareConcurrency \
-sOFFSCREENCANVAS_SUPPORT=1"
# Create output directories
mkdir -p "$BUILD_ROOT" "$DEPS_ROOT" "$SYSROOT"/{lib,include,share} "$STAMPS_DIR"

View file

@ -1,141 +0,0 @@
#!/bin/bash
# Get path to Binaryen wasm-opt
#
# Usage: ./scripts/common/get-wasm-opt.sh
# Output: Prints path to wasm-opt executable
#
# Prefers the emsdk-bundled Binaryen (tools/emsdk/upstream/bin/) so that the
# wasm-opt and wasm-emscripten-finalize versions match the Emscripten that
# generated the JS glue. A version mismatch between the compiler's Binaryen
# (e.g. v121+72 dev) and a standalone release (v121) corrupts asyncify
# metadata, causing "func is not a function" errors at runtime.
#
# Falls back to downloading standalone Binaryen v130 if emsdk is not installed
# locally (e.g. CI environments that only use Docker).
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# --- Prefer emsdk-bundled Binaryen (matches the Emscripten that compiled the WASM) ---
# Override: set BINARYEN_VERSION in the env to FORCE a specific standalone release
# (skips the emsdk preference). Used to A/B Binaryen versions — e.g. v121 carries a
# wasm::Type lock-contention bug that makes the host-side -O2 pass ~9x slower than
# v130 (see docs/ci-build-slowness-findings.md). Validate any bump with the e2e
# suite: a Binaryen/emsdk skew can corrupt asyncify metadata ("func is not a function").
EMSDK_WASM_OPT="${PROJECT_ROOT}/tools/emsdk/upstream/bin/wasm-opt"
# build-kicad-target.sh replaces emsdk's wasm-opt with a no-op stub (moving the
# real binary to wasm-opt.real) so emcc skips the in-link asyncify. That's meant
# for the container emsdk, but a host-mode run leaves the HOST emsdk stubbed —
# and the stub fakes --version and exits 0, so the host-side asyncify/-O2
# "succeed" while doing NOTHING (broken wasm: "asyncify_stop_unwind is not a
# function"). Always prefer the preserved real binary when it exists.
if [ -x "${EMSDK_WASM_OPT}.real" ]; then
EMSDK_WASM_OPT="${EMSDK_WASM_OPT}.real"
fi
if [ -z "${BINARYEN_VERSION:-}" ] && [ -x "${EMSDK_WASM_OPT}" ]; then
EMSDK_VERSION=$("${EMSDK_WASM_OPT}" --version 2>&1 || true)
echo "Using emsdk-bundled Binaryen: ${EMSDK_VERSION}" >&2
echo "${EMSDK_WASM_OPT}"
exit 0
fi
# --- Fallback: download standalone Binaryen (for CI or environments without local emsdk) ---
echo "emsdk Binaryen not found at ${EMSDK_WASM_OPT}, falling back to standalone download..." >&2
# Default v130: v121 has a wasm::Type lock convoy that makes -O2 ~9x slower on
# many-core Linux (docs/ci-build-slowness-findings.md). v130 output validated by
# the full e2e suite locally (31/31) and Chromium-green on CI run 27226030304.
BINARYEN_VERSION="${BINARYEN_VERSION:-130}"
# BINARYEN_BUILD_FROM_SOURCE=1: compile wasm-opt ourselves instead of using the
# official Linux release tarballs, which are badly built — measured on the
# calculator fixture, identical output sha256: asyncify 4x faster on x86
# (CI run 27276830256: 3:50 -> 0:58) and 13x on aarch64; -O2 equal. The macOS
# tarball is well-built (self-build is ~12% SLOWER there), so this is only
# worth enabling on Linux CI. Needs cmake, ninja, g++, git. ~5 min on 32 cores,
# cached in build-wasm/tools after the first call.
if [[ "${BINARYEN_BUILD_FROM_SOURCE:-0}" == "1" ]]; then
SELF_DIR="${PROJECT_ROOT}/build-wasm/tools/binaryen-${BINARYEN_VERSION}-selfbuilt"
SELF_WASM_OPT="${SELF_DIR}/bin/wasm-opt"
if [ ! -x "${SELF_WASM_OPT}" ]; then
SRC_DIR="${PROJECT_ROOT}/build-wasm/tools/binaryen-src-${BINARYEN_VERSION}"
BUILD_DIR="${PROJECT_ROOT}/build-wasm/tools/binaryen-build-${BINARYEN_VERSION}"
echo "Building Binaryen v${BINARYEN_VERSION} from source (one-time, ~5 min)..." >&2
if [ ! -d "${SRC_DIR}" ]; then
git clone -q --depth 1 --branch "version_${BINARYEN_VERSION}" \
--recurse-submodules --shallow-submodules \
https://github.com/WebAssembly/binaryen.git "${SRC_DIR}" >&2
fi
cmake -S "${SRC_DIR}" -B "${BUILD_DIR}" -G Ninja \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CXX_FLAGS="-Wno-maybe-uninitialized" \
-DCMAKE_INTERPROCEDURAL_OPTIMIZATION=ON -DBUILD_TESTS=OFF >&2
ninja -C "${BUILD_DIR}" wasm-opt wasm-emscripten-finalize >&2
# wasm-opt links lib/libbinaryen.so via rpath $ORIGIN/../lib.
mkdir -p "${SELF_DIR}/bin" "${SELF_DIR}/lib"
cp "${BUILD_DIR}/bin/wasm-opt" "${BUILD_DIR}/bin/wasm-emscripten-finalize" "${SELF_DIR}/bin/"
cp "${BUILD_DIR}"/lib/libbinaryen.* "${SELF_DIR}/lib/" 2>/dev/null || true
echo "Self-built Binaryen installed to ${SELF_DIR}" >&2
fi
SELF_VERSION=$("${SELF_WASM_OPT}" --version 2>&1 | grep -o '[0-9]\+' | head -1)
if [ "${SELF_VERSION}" != "${BINARYEN_VERSION}" ]; then
echo "ERROR: self-built wasm-opt version mismatch (got ${SELF_VERSION}, expected ${BINARYEN_VERSION})" >&2
exit 1
fi
echo "Using self-built Binaryen v${BINARYEN_VERSION} (${SELF_WASM_OPT})" >&2
echo "${SELF_WASM_OPT}"
exit 0
fi
BINARYEN_DIR="${PROJECT_ROOT}/build-wasm/tools/binaryen-${BINARYEN_VERSION}"
WASM_OPT="${BINARYEN_DIR}/bin/wasm-opt"
download_binaryen() {
# Detect platform
local os=$(uname -s)
local arch=$(uname -m)
local platform=""
case "${os}-${arch}" in
Darwin-arm64) platform="arm64-macos" ;;
Darwin-x86_64) platform="x86_64-macos" ;;
Linux-aarch64) platform="aarch64-linux" ;;
Linux-x86_64) platform="x86_64-linux" ;;
*)
echo "ERROR: Unsupported platform: ${os}-${arch}" >&2
exit 1
;;
esac
local url="https://github.com/WebAssembly/binaryen/releases/download/version_${BINARYEN_VERSION}/binaryen-version_${BINARYEN_VERSION}-${platform}.tar.gz"
local tarball="${PROJECT_ROOT}/build-wasm/tools/binaryen-${BINARYEN_VERSION}.tar.gz"
echo "Downloading Binaryen v${BINARYEN_VERSION} for ${platform}..." >&2
mkdir -p "${PROJECT_ROOT}/build-wasm/tools"
curl -L -o "${tarball}" "${url}"
echo "Extracting..." >&2
tar -xzf "${tarball}" -C "${PROJECT_ROOT}/build-wasm/tools"
mv "${PROJECT_ROOT}/build-wasm/tools/binaryen-version_${BINARYEN_VERSION}" "${BINARYEN_DIR}"
rm "${tarball}"
echo "Binaryen v${BINARYEN_VERSION} installed to ${BINARYEN_DIR}" >&2
}
# Download Binaryen if not cached
if [ ! -x "${WASM_OPT}" ]; then
download_binaryen
fi
# Verify version
INSTALLED_VERSION=$("${WASM_OPT}" --version 2>&1 | grep -o '[0-9]\+' | head -1)
if [ "${INSTALLED_VERSION}" != "${BINARYEN_VERSION}" ]; then
echo "WARNING: wasm-opt version mismatch (got ${INSTALLED_VERSION}, expected ${BINARYEN_VERSION})" >&2
echo "Re-downloading..." >&2
rm -rf "${BINARYEN_DIR}"
download_binaryen
fi
# Output path to wasm-opt (this is the only stdout output)
echo "${WASM_OPT}"

View file

@ -1,3 +1,15 @@
# Promising-export census for the KiCad browser apps: every wasm entry export
# whose body can reach a suspension (park) under JSPI. Consumed as
# -sJSPI_EXPORTS=@thisfile by scripts/kicad/build-kicad-target.sh; emscripten's
# symbol-list parser strips lines starting with '#'. Do NOT add blank lines
# (they would parse as empty symbol entries).
# Regenerate by grep, not memory: the wx entries are the EMSCRIPTEN_KEEPALIVE
# exports in wxwidgets/src/wasm/ that can park; pcbjam_libctx_entry is
# libcontext's coroutine entry (kicad/thirdparty/libcontext).
# Kept in sync BY HAND with two siblings:
# - scripts/common/shims/jspi-scheduler.js installExportWraps list (same set
# minus main — the runtime calls main before the wraps exist)
# - tests/apps/Makefile.wasm WX_JSPI_EXPORTS (comma-joined copy)
main
wx_dom_event
wx_dom_mouse

View file

@ -1,242 +0,0 @@
// === Asyncify / fiber / modal diagnostics (LOGGING ONLY — no behavior change) ===
//
// Injected only when inject-dyncall-shims.sh runs with SHIM_DIAGNOSTICS=1.
// Observability for the Chrome/V8 renderer crash on the first coroutine resume
// (the main-context Asyncify rewind). Does NOT swallow or alter any call — it
// logs and traces, then delegates, so the real crash still happens and can be
// observed right up to the faulting point.
(function() {
var modalActive = false;
var glTraceActive = false; // armed at the first main rewind (rewindId===0) below
var glCallSeq = 0;
var glTraceCap = 8000; // safety cap so a non-crashing run can't log forever
var tableLen = function() { return (typeof wasmTable !== "undefined" && wasmTable) ? wasmTable.length : -1; };
var asyncState = function() { return (typeof Asyncify !== "undefined") ? Asyncify.state : "N/A"; };
// 1. Timer scheduling — flag function pointers already out of bounds at schedule time.
if (typeof _emscripten_async_call !== "undefined") {
var __origAsyncCall = _emscripten_async_call;
_emscripten_async_call = function(func, arg, millis) {
var inBounds = func >= 0 && func < tableLen();
console.log("[DIAG_ASYNC_CALL] func=" + func + " arg=" + arg + " millis=" + millis +
" inBounds=" + inBounds + " modalActive=" + modalActive + " state=" + asyncState());
if (!inBounds) { console.log("[DIAG_ASYNC_CALL] OUT OF BOUNDS at schedule time! func=" + func); console.trace(); }
return __origAsyncCall(func, arg, millis);
};
}
// 2. Rewind target selection — which export Asyncify will re-enter on rewind.
if (typeof Asyncify !== "undefined" && Asyncify.setDataRewindFunc) {
var __origSetRewind = Asyncify.setDataRewindFunc.bind(Asyncify);
Asyncify.setDataRewindFunc = function(ptr, forced) {
console.log("[DIAG_REWIND_FUNC] ptr=" + ptr + " forced=" + forced + " state=" + Asyncify.state +
" modalActive=" + modalActive + " callStack=" + JSON.stringify(Asyncify.exportCallStack));
return __origSetRewind(ptr, forced);
};
}
// 3. doRewind — the actual rewind that crashes V8. Log the buffer + saved rewind id
// immediately before re-entering wasm, so the last line before the crash names it.
if (typeof Asyncify !== "undefined" && typeof Asyncify.doRewind === "function") {
var __origDoRewind = Asyncify.doRewind.bind(Asyncify);
var heap32 = function () { return (typeof GROWABLE_HEAP_I32 === "function") ? GROWABLE_HEAP_I32() : HEAP32; };
Asyncify.doRewind = function(ptr) {
var H = heap32();
var rd = function (off) { try { return H[((ptr + off) >> 2)]; } catch (e) { return -999; } };
// asyncify_data layout: [ptr+0]=current stack pos (top of saved data),
// [ptr+4]=stack end, [ptr+8]=rewindId. Saved call-index/locals live below [ptr+0].
var curPos = rd(0), stackEnd = rd(4), rewindId = rd(8);
var name = (Asyncify.callStackIdToName && Asyncify.callStackIdToName[rewindId]) || "?";
var usedBytes = curPos - (ptr + 12);
console.log("[DIAG_DOREWIND] ptr=" + ptr + " rewindId=" + rewindId + " (" + name + ")" +
" curPos=" + curPos + " stackEnd=" + stackEnd + " usedBytes=" + usedBytes +
" state=" + asyncState() + " — re-entering wasm now");
// Arm the WebGL tracer exactly at the main rewind (the crash window: the silent V8
// abort happens right after this rewind returns to main, before coroutine #2/first paint).
if (rewindId === 0 && !glTraceActive) {
glTraceActive = true;
console.log("[DIAG_GL] tracing ARMED at main rewind (rewindId=0)");
}
// Dump the saved call-index chain (first words of the buffer) so we can see the
// depth/shape of what the rewind replays at the crash.
try {
var words = [];
var start = ptr + 12;
for (var a = start; a < curPos && a < start + 256; a += 4) words.push(H[(a >> 2)]);
console.log("[DIAG_DOREWIND] saved-data[" + words.length + "w]: " + JSON.stringify(words));
} catch (e) {}
try {
return __origDoRewind(ptr);
} catch (e) {
console.log("[DIAG_DOREWIND] EXCEPTION during rewind: " + e + " | " + (e && e.stack));
throw e;
}
};
}
// 4. dynCall_vi — log (and trace) out-of-bounds pointers but DO NOT swallow; call through.
if (typeof dynCall_vi === "function") {
var __origDynCallVi = dynCall_vi;
dynCall_vi = function(index, a0) {
if (index < 0 || index >= tableLen()) {
console.log("[DIAG_DYNCALL_VI] OUT OF BOUNDS index=" + index + " tableLen=" + tableLen() +
" modalActive=" + modalActive + " state=" + asyncState());
console.trace();
}
return __origDynCallVi(index, a0);
};
}
// 5. Modal lifecycle: poll the scheduler wait registry ("modal" waits).
// (The legacy Module._endModal hook was deleted at doc 20 D-1.)
if (typeof Module !== "undefined") {
var lastModalWaits = 0;
setInterval(function() {
var S = globalThis.__wxScheduler;
if (!S || typeof S.pendingWaits !== "function") return;
var n = S.pendingWaits("modal");
if (n !== lastModalWaits) {
console.log("[DIAG_MODAL] modal waits " + lastModalWaits + " -> " + n +
", state=" + asyncState());
modalActive = n > 0;
lastModalWaits = n;
}
}, 100);
}
// 6. EM_ASYNC_JS sleeps (wxWasmYieldUntilJs, js_enumerateFonts, clipboard, etc.) — log
// enter/wake so we can see whether an async sleep is NESTED with a fiber swap
// at the crash (the #9153 collision). Logging only; delegates unchanged.
if (typeof Asyncify !== "undefined" && typeof Asyncify.handleSleep === "function") {
var __diagOrigHandleSleep = Asyncify.handleSleep.bind(Asyncify);
var diagSleepId = 0;
Asyncify.handleSleep = function(startAsync) {
var id = ++diagSleepId;
console.log("[DIAG_SLEEP] ENTER id=" + id + " state=" + asyncState() +
" currData=" + ((typeof Asyncify.currData !== "undefined" && Asyncify.currData) || "null"));
return __diagOrigHandleSleep(function(wakeUp) {
return startAsync(function(result) {
console.log("[DIAG_SLEEP] WAKE id=" + id + " state=" + asyncState() +
" currData=" + ((typeof Asyncify.currData !== "undefined" && Asyncify.currData) || "null"));
return wakeUp(result);
});
});
};
}
// 7. WebGL call tracer — pinpoint the exact GL op that crashes Chrome's renderer.
// KiCad runs the GAL on an OffscreenCanvas in the pthread worker (PROXY_TO_PTHREAD +
// OFFSCREENCANVAS_SUPPORT), and this diagnostics code runs in that same worker, so we
// hook getContext where the context is actually created. Each call logs via
// console.error (immediate flush → captured even just before a hard V8 abort), but
// only once glTraceActive is set (at the main rewind), so volume = the crash window.
function wrapGLContext(ctx, kind) {
if (!ctx) return ctx;
try { if (ctx.__diagWrapped) return ctx; ctx.__diagWrapped = true; } catch (e) {}
console.log("[DIAG_GL] context created kind=" + kind);
return new Proxy(ctx, {
get: function(target, prop) {
var val = target[prop];
if (typeof val === "function") {
return function() {
if (glTraceActive && glCallSeq < glTraceCap) {
console.log("[DIAG_GL] #" + (++glCallSeq) + " " + String(prop));
}
return val.apply(target, arguments);
};
}
return val;
}
});
}
function hookGetContext(proto, kind) {
if (!proto || typeof proto.getContext !== "function" || proto.__diagGCHooked) return;
proto.__diagGCHooked = true;
var orig = proto.getContext;
proto.getContext = function(type) {
// Log the ATTEMPT before calling through, so if getContext itself crashes the
// renderer (e.g. a Chrome/ANGLE WebGL-context bug) this is the last line we see.
if (type === "webgl2" || type === "webgl" || type === "experimental-webgl") {
var attrs = "";
try { attrs = JSON.stringify(arguments[1] || {}); } catch (e) {}
console.log("[DIAG_GL] getContext(" + kind + ":" + type + ") attrs=" + attrs + " — calling through now");
}
var ctx = orig.apply(this, arguments);
if (type === "webgl2" || type === "webgl" || type === "experimental-webgl") {
console.log("[DIAG_GL] getContext returned " + (ctx ? "a context" : "NULL"));
try { return wrapGLContext(ctx, kind + ":" + type); } catch (e) { return ctx; }
}
return ctx;
};
}
if (typeof OffscreenCanvas !== "undefined") hookGetContext(OffscreenCanvas.prototype, "offscreen");
if (typeof HTMLCanvasElement !== "undefined") hookGetContext(HTMLCanvasElement.prototype, "html");
// 8. dynCall_ii / dynCall_vi invocation tracer (logging only). The shim routes the
// pthread-entry (ii) and fiber-entry/signal/timer (vi) callbacks through these bound
// instrumented dynCall_<sig>. Wrap them to log each invocation + the function pointer,
// armed at the main rewind (glTraceActive) so volume = the crash window. The LAST line
// before the silent crash names the faulting dispatch + its ptr. Tag thread for context.
var __thr = (typeof ENVIRONMENT_IS_PTHREAD !== "undefined" && ENVIRONMENT_IS_PTHREAD) ? "worker" : "main";
var __fnName = function(ptr) {
try { var f = getWasmTableEntry(ptr); return (f && f.name) ? f.name : "?"; } catch (e) { return "?err"; }
};
try {
if (typeof dynCall_ii === "function") {
var __origDCii = dynCall_ii;
dynCall_ii = function(ptr, a0) {
if (glTraceActive && glCallSeq < glTraceCap)
console.log("[DIAG_DC] " + __thr + " dynCall_ii ptr=" + ptr + " name=" + __fnName(ptr) + " #" + (++glCallSeq));
return __origDCii(ptr, a0);
};
}
} catch (e) {}
try {
if (typeof dynCall_vi === "function") {
var __origDCvi = dynCall_vi;
var __asy = function() {
if (typeof Asyncify === "undefined") return "noAsyncify";
var st = Asyncify.state;
var cd = (Asyncify.currData || 0);
return "state=" + st + " currData=" + cd;
};
dynCall_vi = function(ptr, a0) {
var big = glTraceActive && ptr > 15000; // the rare large-index 'vi' dispatches (incl. the stalling 20078)
if (glTraceActive && glCallSeq < glTraceCap) {
console.log("[DIAG_DC] " + __thr + " dynCall_vi ptr=" + ptr + " name=" + __fnName(ptr) + " arg=" + a0 + " #" + (++glCallSeq));
}
if (big) {
// Asyncify state going IN: if it's non-NORMAL (1=unwinding, 2=rewinding) the
// leftover coroutine state is making the instrumented dispatch misbehave.
console.log("[DIAG_DC_VI] ENTER ptr=" + ptr + " " + __asy());
var r = __origDCvi(ptr, a0);
// If this RETURNED line never appears, the dispatch unwound/stalled and never came back.
console.log("[DIAG_DC_VI] RETURNED ptr=" + ptr + " " + __asy());
return r;
}
return __origDCvi(ptr, a0);
};
}
} catch (e) {}
// 9. Periodic asyncify-state monitor (main thread). After dynCall_vi(20078)=
// setupUIConditions appears to unwind-and-never-rewind, this timer (which still runs
// on the idle event loop) reveals the post-stall Asyncify.state: if it's stuck at
// 1 (UNWINDING) or 2 (REWINDING) with a fixed currData, the app yielded and the
// rewind was never scheduled. Logs only on change + a heartbeat.
if (typeof Asyncify !== "undefined" && __thr === "main") {
var __lastSt = -999, __lastCd = -999, __hb = 0;
setInterval(function() {
var st = Asyncify.state, cd = (Asyncify.currData || 0);
if (st !== __lastSt || cd !== __lastCd) {
console.log("[DIAG_ASTATE] change -> state=" + st + " currData=" + cd);
__lastSt = st; __lastCd = cd;
} else if (st !== 0 && (++__hb % 6 === 0)) {
console.log("[DIAG_ASTATE] STILL state=" + st + " currData=" + cd + " (stuck?)");
}
}, 500);
}
console.log("[DIAG] Asyncify/fiber/modal diagnostics installed (logging only) [" + __thr + "]");
})();
// === End diagnostics ===

View file

@ -1,9 +1,9 @@
// jspi-scheduler.js — the JSPI-era successor of asyncify-scheduler.js.
// jspi-scheduler.js — the wx scheduler shim for the JSPI runtime.
//
// Ships as a --pre-js. Keeps the S4 token-wait registry contract byte-for-byte
// Ships as a --pre-js. Provides the S4 token-wait registry
// (beginWait/waitPromise/resolveWait/resolveTopWait/waitEarlyResolved/
// takeWaitResult/pendingWaits/noteContextWait/shutdown) so every C++ bridge
// and web caller keeps working, and adds the two things JSPI needs:
// takeWaitResult/pendingWaits/shutdown) that every C++ bridge and web
// caller relies on, plus the two things JSPI needs:
//
// 1. ACTIVATION TRACKING. Every promising export the app declares is wrapped
// so the shim always knows which activation is executing synchronously
@ -20,30 +20,21 @@
// into parked frames' locals (stack-allocated wxDialog members mutated
// by a cross-tick EndModal), resurrecting dead state at resume.
//
// Observability: an event ring + live activation table via __wxWaitDump()
// (alias __wxAsyncifyDump kept one release for crash-report consumers).
//
// Asyncify-era machinery that has NO successor here, by design: currData
// single-writer tripwire, deferred-wake queue, stale-fiber quarantine,
// trampoline heal, dyncall shims — the states they policed are
// unrepresentable under JSPI.
// Observability: an event ring + live activation table via __wxWaitDump().
(function () {
"use strict";
if (globalThis.__wxScheduler && globalThis.__wxScheduler.backend === "jspi") {
if (globalThis.__wxSchedulerInstalled) {
return; // idempotent under double injection
}
var RING_CAP = 256;
var S = {
backend: "jspi",
// --- mailbox lane (timers/wheel; ordering machinery, mechanism-free) ----
// Same contract as the asyncify shim: enqueueAfter queues a C callback,
// delivery happens through the dedicated _wxWasmMailboxTick export from a
// fresh task, in order. Under JSPI the tick is a promising export — a
// enqueueAfter queues a C callback; delivery happens through the dedicated
// _wxWasmMailboxTick export from a fresh task, in order. The tick is a
// suspension inside a delivered handler parks the tick's own activation,
// and the rejection path carries the same containment (a throwing handler
// must not leave a parked quasi-modal unresolved).
@ -94,17 +85,12 @@
}, 0);
},
// --- S1 embind lane (contract-identical port from asyncify-scheduler) --
// --- S1 embind lane --
// Mutators (doc 18 classification) must not enter wasm while a load is in
// flight: the open activation is suspended mid-load and a collab-apply /
// save / theme flip entering between its parks would mutate the board
// under it. Semantic exclusion — nothing asyncify-specific about it.
// The FIFO drains, in order, once kicadOpenFileBusy clears.
//
// Retired here, by design: _wrapOpenFile / kicadOpenFileStart (the
// asyncify Phase F starter route). Under JSPI kicadOpenFile is an embind
// async() export — its own promising activation parks legally and the
// call returns a real Promise; no dispatch-context detour exists.
// under it. The exclusion is semantic, independent of the suspension
// mechanism. The FIFO drains, in order, once kicadOpenFileBusy clears.
MUTATOR_NAMES: [
"kicadSetChrome", "kicadSetReadOnly",
"kicadCollabApply", "kicadCollabApplyItems",
@ -181,16 +167,11 @@
},
// The embind PARKERs (kicadOpenFile / kicadOpenFiles / kicadLibsReload,
// registered emscripten::async() under PCBJAM_JSPI): wrap them with the
// registered emscripten::async()): wrap them with the
// same activation tracking as the raw promising exports, so their parks
// (wxWasmYieldUntil inside the load) find a tracked record and get the
// green-copy spill-stack discipline. Embind names live on Module WITHOUT
// green-region spill-stack discipline. Embind names live on Module WITHOUT
// the underscore prefix, hence the separate installer.
// NOT here: the kicadTestFiberPark* levers. They are emscripten::async()
// (a plain embind call into a suspending body throws on strict-JSPI
// Firefox), but the parker wrap's turnstile queueing would DEFER a
// mid-park poke until the park drains — the exact race the levers exist
// to stage. Their suspensions ride the untracked-anon-record path.
PARKER_NAMES: ["kicadOpenFile", "kicadOpenFiles", "kicadLibsReload"],
_wrapParkers: function () {
var wrapped = 0;
@ -252,12 +233,6 @@
return entry.result | 0;
},
// JSPI: context waits do not exist; kept as a loud no-op for transition
// callers (nothing registers them — wxWasmYieldUntil suspends in place).
noteContextWait: function (token) {
console.warn("[wx-scheduler] noteContextWait(" + token + ") under jspi backend");
},
resolveWait: function (token, result) {
var entry = this.waits.get(token);
if (!entry || entry.resolved) return false;
@ -294,7 +269,7 @@
dead: false,
shutdown: function (why) {
this.dead = true;
// S6 teardown contract (same as the asyncify shim): queued-but-
// S6 teardown contract: queued-but-
// undelivered mutators FAIL LOUDLY instead of hanging their callers,
// and undelivered mailbox messages drop — the pumps stop themselves on
// the dead flag.
@ -307,8 +282,8 @@
if (stranded) {
console.warn("[wx-scheduler] shutdown (" + why + ") stranded:" + stranded);
} else {
// teardown-gate contract (e2e/app-quit.spec.ts, ported from the
// asyncify shim): a clean exit must SAY so on the console
// teardown-gate contract (e2e/app-quit.spec.ts): a clean exit must
// SAY so on the console
console.log("[wx-scheduler] shutdown (" + why + ") clean");
}
this._note("shutdown", why, stranded);
@ -751,7 +726,7 @@
return wrapped;
},
// --- observability skeleton (finalized in Phase 7) ---------------------
// --- observability ------------------------------------------------------
_ring: [],
_note: function (ev, a, b) {
this._ring.push([Date.now(), ev, String(a), b | 0]);
@ -768,7 +743,6 @@
});
});
return {
backend: "jspi",
dead: this.dead,
waitsBegun: this.waitsBegun,
waitsResolved: this.waitsResolved,
@ -787,7 +761,7 @@
globalThis.__wxScheduler = S;
globalThis.__wxSchedulerInstalled = true; // wxWasmSchedulerAssertInstalled probe
// --- Phase 7 signals ------------------------------------------------------
// --- diagnostic signals ---------------------------------------------------
// SuspendError attributor: a SuspendError means a PLAIN (non-promising)
// wasm entry tried to park — a missed -sJSPI_EXPORTS/installExportWraps
// entry. The engine cannot say WHICH export, but the live dump (what was
@ -825,8 +799,6 @@
});
}, 10000);
globalThis.__wxWaitDump = function () { return S.dump(); };
// transition alias: crash-report consumers read __wxAsyncifyDump
globalThis.__wxAsyncifyDump = globalThis.__wxWaitDump;
// Self-install the activation wraps once the runtime is up (this file ships
// as a --pre-js, so Module exists here). The name set mirrors the

View file

@ -53,6 +53,8 @@ const INPUTS = [
// the promising-export census.
{ file: "scripts/common/shims/jspi-scheduler.js" },
{ file: "scripts/common/jspi-exports.txt" },
// Single-sources DEPS_EH_FLAGS (the exception-model link flags).
{ file: "scripts/common/env.sh" },
// Per-tool compile recipes (compile flags / emcc link options).
{ dir: "scripts/kicad", match: /^build-.*\.sh$/ },

View file

@ -1,10 +1,9 @@
#!/bin/bash
# Sourced library. Single source of truth for the 5-repo layout:
# Sourced library. Single source of truth for the 4-repo layout:
#
# root = pcbjam main
# ├── kicad (kicad/) wasm-port
# ├── wxwidgets (wxwidgets/) wasm-port
# ├── binaryen (binaryen/) wasm-port
# └── pcbjam-shared (web/pcbjam-shared/) main [MIT contract]
#
# Bash variable names can't contain '-', so pcbjam-shared's KEY is
@ -13,18 +12,16 @@
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
REPOS=(root kicad wxwidgets binaryen pcbjam_shared)
REPOS=(root kicad wxwidgets pcbjam_shared)
PATH_root="$ROOT_DIR"
PATH_kicad="$ROOT_DIR/kicad"
PATH_wxwidgets="$ROOT_DIR/wxwidgets"
PATH_binaryen="$ROOT_DIR/binaryen"
PATH_pcbjam_shared="$ROOT_DIR/web/pcbjam-shared"
MAIN_root="main"
MAIN_kicad="wasm-port"
MAIN_wxwidgets="wasm-port"
MAIN_binaryen="wasm-port"
MAIN_pcbjam_shared="main"
repo_path() {

View file

@ -296,11 +296,10 @@ else
log_info "Building KiCad in RELEASE mode (skipping wasm-opt due to memory limits)"
fi
# Suspension backend: JSPI (native stack switching) since the migration.
# The headless CLIs (kicad_tools, occ_service) get NO suspension backend at
# all — their targets pin -sASYNCIFY=0 and run in node/worker where nothing
# may suspend. coroutine.h/libcontext key on __EMSCRIPTEN__ directly, so no
# ABI define is threaded through the TU flags anymore.
# Suspension backend: JSPI (native stack switching). The headless CLIs
# (kicad_tools, occ_service) get no suspension backend — they run in
# node/workers where nothing may suspend. coroutine.h/libcontext key on
# __EMSCRIPTEN__ directly, so no ABI define is threaded through the TU flags.
# Step 6: Create build directory
mkdir -p "${KICAD_BUILD}"
@ -365,23 +364,11 @@ fi
log_info "Stub libraries built"
# Step 6.2/6.3: wasm-opt + wasm-emscripten-finalize handling.
# For the editor apps these tools OOM on the huge debug wasm, so we stub them in
# the container and run them on the host (docker/build.sh phase 2). The small,
# debug-stripped (-g0) CLI finalizes fine in-container, so for kicad_tools
# we restore/keep the real tools and skip host post-processing entirely.
# EMSDK is needed below (emscripten sysroot paths in the CMake invocation).
if [ -z "${EMSDK}" ]; then
log_error "EMSDK environment variable is not set."
exit 1
fi
EMSDK_WASM_OPT="${EMSDK}/upstream/bin/wasm-opt"
EMSDK_FINALIZE="${EMSDK}/upstream/bin/wasm-emscripten-finalize"
# JSPI has no post-link asyncify pass: every app finalizes in-container with
# the REAL tools. The .real backups exist on containers that ran the retired
# asyncify stub dance — restore them if present.
[ -f "${EMSDK_WASM_OPT}.real" ] && cp "${EMSDK_WASM_OPT}.real" "${EMSDK_WASM_OPT}"
[ -f "${EMSDK_FINALIZE}.real" ] && cp "${EMSDK_FINALIZE}.real" "${EMSDK_FINALIZE}"
# Step 6.5: Verify WASM support is in KiCad fork
# The kicad submodule should already have WASM port detection and kiplatform support
@ -531,16 +518,16 @@ fi
# (scripts/common/jspi-exports.txt: the wx KEEPALIVE entries that can park +
# pcbjam_libctx_entry; regenerate by grep, not memory), the jspi-scheduler
# pre-js, and the runtime methods its spill-stack discipline needs
# (stackSave/stackRestore/HEAPU8). Headless CLIs: nothing — their targets pin
# -sASYNCIFY=0 and nothing in them may suspend.
# (stackSave/stackRestore/HEAPU8). Headless CLIs: nothing — no suspension
# backend; nothing in them may suspend.
case "${APP_NAME}" in
kicad_tools|occ_service)
ASYNC_LINK_FLAGS=""
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8']"
JSPI_LINK_FLAGS=""
JSPI_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8']"
;;
*)
ASYNC_LINK_FLAGS="-sJSPI -sJSPI_EXPORTS=@${PROJECT_ROOT}/scripts/common/jspi-exports.txt --pre-js ${PROJECT_ROOT}/scripts/common/shims/jspi-scheduler.js"
ASYNC_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','stackSave','stackRestore','HEAPU8','HEAP8','HEAP32']"
JSPI_LINK_FLAGS="-sJSPI -sJSPI_EXPORTS=@${PROJECT_ROOT}/scripts/common/jspi-exports.txt --pre-js ${PROJECT_ROOT}/scripts/common/shims/jspi-scheduler.js"
JSPI_RUNTIME_METHODS="-sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','stackSave','stackRestore','HEAPU8','HEAP8','HEAP32']"
;;
esac
@ -556,7 +543,7 @@ emcmake cmake "${KICAD_DIR}" \
-DCMAKE_POLICY_VERSION_MINIMUM=3.5 \
-DCMAKE_CXX_FLAGS="${EXTRA_FLAGS} -Xclang -fno-pch-timestamp -pthread --use-port=zlib -DKICAD_USE_PLATFORM_WASM=1${DIAG_DEFINES} -I${SYSROOT}/include -I${STUBS_DIR} -include ${STUBS_DIR}/char_traits_uint16_workaround.h" \
-DCMAKE_C_FLAGS="${EXTRA_FLAGS} -pthread --use-port=zlib -I${SYSROOT}/include -I${STUBS_DIR}" \
-DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread ${ASYNC_LINK_FLAGS} -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} ${ASYNC_RUNTIME_METHODS} ${EMBIND_LINK_FLAG} -L${SYSROOT}/lib -L${KICAD_BUILD}/common -L${KICAD_BUILD}/common/gal ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \
-DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread ${JSPI_LINK_FLAGS} -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} ${JSPI_RUNTIME_METHODS} ${EMBIND_LINK_FLAG} -L${SYSROOT}/lib -L${KICAD_BUILD}/common -L${KICAD_BUILD}/common/gal ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \
-DCMAKE_SHARED_LINKER_FLAGS="-Wl,--allow-multiple-definition" \
-DCMAKE_MODULE_LINKER_FLAGS="-Wl,--allow-multiple-definition" \
-DZLIB_LIBRARY="${EMSDK}/upstream/emscripten/cache/sysroot/lib/wasm32-emscripten/pic/libz.a" \

View file

@ -292,6 +292,10 @@
// CLASSIC workers via `new Worker(...)`; a cross-origin URL is a SecurityError,
// so for the CDN base we hand emscripten a SAME-ORIGIN blob worker that
// importScripts the cross-origin glue (allowed because the CDN sends CORP).
// NOTE: this page tracks the CDN's LATEST deployed glue, not this repo's
// build — emscripten 6 (the JSPI builds) ignores mainScriptUrlOrBlob, so
// once such a release is deployed this becomes inert (cross-origin pthread
// gap: docs/features/async/23-jspi-runtime.md in pcbjam-private).
function pthreadWorkerScript() {
var abs = new URL(base + "/gerbview.js", location.href);
if (abs.origin === location.origin) return base + "/gerbview.js";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,5 +1,6 @@
// jspi-coroutine — validates the JSPI libcontext backend (PCBJAM_JSPI) through
// the EXACT protocol coroutine.h drives it with, without linking wx or KiCad.
// jspi-coroutine — validates the JSPI libcontext backend (the __EMSCRIPTEN__
// build of kicad/thirdparty/libcontext) through the EXACT protocol
// coroutine.h drives it with, without linking wx or KiCad.
//
// MiniCoro below is a compact transcription of COROUTINE<>'s libcontext
// mechanics (doCall/jumpIn/jumpOut/callerStub, INVOCATION_ARGS, the
@ -109,7 +110,7 @@ struct MiniCoro
cor->m_body( *cor );
cor->m_running = false;
// the 3-line JSPI hook coroutine.h carries under PCBJAM_JSPI
// the completion hook coroutine.h carries under __EMSCRIPTEN__
libcontext::finish_fcontext( cor->m_callee.ctx );
cor->jumpOut();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,7 @@ import { test, expect, tryLoadApp } from '../e2e/utils/fixtures';
// teardown-on-error — so they are exactly as meaningful under JSPI as under
// asyncify; only the failure MODES they'd catch differ (activation misnesting
// or a lost wait token instead of a clobbered rewind buffer). The harness's
// Fibers.* probes self-disable on non-asyncify glue ('n/a').
// asyncify-era Fibers.* probes retired with that backend.
//
// Retired asyncify-mechanism gates, deliberately NOT ported: the
// Asyncify.currData single-writer tripwire (N1) and the deferred-wake books

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
/*
* Shared plumbing for the per-editor collab binding TUs (eeschema_embind.cpp,
* pcbnew_embind.cpp) the frame-type-free half of the bridge: string/JSON
* wire emitters to window.kicadCollab, the CallAfter+COROUTINE fiber idiom,
* wire emitters to window.kicadCollab, the CallAfter+COROUTINE apply queue,
* and the frame-generic test hooks. Header-only (the collab_presence_style.h
* pattern), so the build script needs no extra objects and the merged
* kicad_editor image links it without ODR issues.
@ -28,112 +28,110 @@ namespace pcbjam_collab {
inline std::string toUtf8( const wxString& s ) { return std::string( s.utf8_str() ); }
/**
* Run a body on the editor's main loop AND on a libcontext fiber stack the
* exact context native tool edits run in. Embind ccalls / bare CallAfter
* stacks mis-dispatch asyncify-instrumented virtual calls (invoke_* through a
* stale table type traps, or silently no-ops); commits, GAL overlay work and
* the s-expr formatters must therefore run through this. CallAfter queues
* onto the app's pending-event list (drained every frame by the wasm main
* loop, src/wasm/evtloop.cpp); COROUTINE::Call moves the body to the fiber.
* Run a body on the editor's main loop AND inside a COROUTINE the exact
* context native tool edits run in. CallAfter queues onto the app's
* pending-event list (drained every frame by the wasm main loop,
* src/wasm/evtloop.cpp); COROUTINE::Call moves the body onto its own
* coroutine stack. Commits, GAL overlay work and the s-expr formatters run
* through this.
*
* SERIALIZED (drift-trio finding #10, standalone-hardening 0008 §10): bodies
* run strictly one-at-a-time through a FIFO. The previous per-body
* fire-and-forget coroutine interleaved under load: when a body PARKED
* (asyncify suspension inside commit.Push connectivity/GAL work), the main
* loop kept draining pending events and started the NEXT body a local
* commit and a remote apply then ran interleaved on shared commit/listener
* state (s_applyingRemote is a single global), silently losing applies on the
* actively-editing receiver and, in the worst case, corrupting memory (fuzz
* S10: wasm OOB on an observer). The busy flag is park-safe: an asyncify
* suspension suspends the whole drain loop with the body and rewinds it
* transparently, while any other drain invocation no-ops on the flag; the
* suspended drain's own while-loop picks up whatever queued meanwhile.
* run strictly one-at-a-time through a FIFO. A body that SUSPENDS (a JSPI
* suspension inside commit.Push connectivity/GAL work) returns early from
* COROUTINE::Call while its coroutine is still in flight. Without the queue
* the main loop kept draining pending events and started the NEXT body a
* local commit and a remote apply then ran interleaved on shared
* commit/listener state (s_applyingRemote is a single global), silently
* losing applies on the actively-editing receiver and, in the worst case,
* corrupting memory (fuzz S10: wasm OOB on an observer). The busy flag is
* suspension-safe: while a suspended body is in flight every other drain
* invocation no-ops on the flag, and the body's own coroutine tail
* re-schedules the drain when it completes.
*/
inline std::deque<std::function<void()>>& fiberQueue()
inline std::deque<std::function<void()>>& applyQueue()
{
static std::deque<std::function<void()>> q;
return q;
}
inline bool& fiberBusy()
inline bool& applyBusy()
{
static bool busy = false;
return busy;
}
/* The in-flight body. HEAP-allocated and pinned for the body's whole life:
* when a body PARKS (asyncify suspension inside commit.Push), COROUTINE::Call
* RETURNS EARLY the later asyncify rewind re-enters the fiber through the
* SAME callable at the SAME addresses (dynCall_vi fcontext_entry
* callerStub the wrapper). Stack-local cor/body (the original runOnFiber
* AND the first serialized version) were destroyed on that early return, so
* the rewind called through freed objects "table index is out of bounds"
* at rewind, memory corruption downstream (finding #10b's symbolized stack).
* `done` is the ONLY completion signal; Call() returning is not. */
struct FiberSlot
* when a body SUSPENDS (a JSPI suspension inside commit.Push), COROUTINE::Call
* RETURNS EARLY the later resume re-enters the coroutine through the SAME
* callable at the SAME addresses. Stack-local cor/body (the original
* runOnCoroutine AND the first serialized version) were destroyed on that
* early return, so the resume ran through freed objects memory corruption
* downstream (finding #10b's symbolized stack). `done` is the ONLY completion
* signal; Call() returning is not. */
struct ApplySlot
{
COROUTINE<int, int>* cor = nullptr;
std::function<void()>* body = nullptr;
bool done = false;
};
inline FiberSlot& activeFiberSlot()
inline ApplySlot& activeApplySlot()
{
static FiberSlot s;
static ApplySlot s;
return s;
}
inline wxEvtHandler*& fiberHandler()
inline wxEvtHandler*& applyHandler()
{
static wxEvtHandler* h = nullptr;
return h;
}
inline void drainFibers();
inline void drainApplies();
inline void reapFiber()
inline void reapApply()
{
FiberSlot& slot = activeFiberSlot();
ApplySlot& slot = activeApplySlot();
delete slot.cor;
delete slot.body;
slot.cor = nullptr;
slot.body = nullptr;
slot.done = false;
fiberBusy() = false;
applyBusy() = false;
}
inline void drainFibers()
inline void drainApplies()
{
FiberSlot& slot = activeFiberSlot();
ApplySlot& slot = activeApplySlot();
if( fiberBusy() )
if( applyBusy() )
{
if( !slot.done )
return; // parked body still in flight — its tail re-drains
return; // suspended body still in flight — its tail re-drains
reapFiber(); // completed via rewind since the last drain
reapApply(); // completed since the last drain
}
auto& q = fiberQueue();
auto& q = applyQueue();
while( !q.empty() )
{
fiberBusy() = true;
applyBusy() = true;
slot.done = false;
slot.body = new std::function<void()>( std::move( q.front() ) );
q.pop_front();
slot.cor = new COROUTINE<int, int>( []( int ) -> int
{
FiberSlot& sl = activeFiberSlot();
ApplySlot& sl = activeApplySlot();
( *sl.body )();
sl.done = true;
// If we parked, no drain is pending by the time the rewind
// completes — schedule the reap + next body from the fiber tail
// (CallAfter only queues; safe here).
if( wxEvtHandler* h = fiberHandler() )
h->CallAfter( []() { drainFibers(); } );
// If we suspended, no drain is pending by the time the body
// completes — schedule the reap + next body from the coroutine
// tail (CallAfter only queues; safe here).
if( wxEvtHandler* h = applyHandler() )
h->CallAfter( []() { drainApplies(); } );
return 0;
} );
@ -141,17 +139,17 @@ inline void drainFibers()
slot.cor->Call( 0 );
if( !slot.done )
return; // parked — cor/body stay pinned for the rewind
return; // suspended — cor/body stay pinned for the resume
reapFiber();
reapApply();
}
}
inline void runOnFiber( wxEvtHandler* aHandler, std::function<void()> aBody )
inline void runOnCoroutine( wxEvtHandler* aHandler, std::function<void()> aBody )
{
fiberHandler() = aHandler;
fiberQueue().push_back( std::move( aBody ) );
aHandler->CallAfter( []() { drainFibers(); } );
applyHandler() = aHandler;
applyQueue().push_back( std::move( aBody ) );
aHandler->CallAfter( []() { drainApplies(); } );
}
// ── C++ → JS wire emitters (no-ops without a JS listener) ───────────────────
@ -205,14 +203,14 @@ inline void emitViewport( double aCx, double aCy, double aPxPerIu, int aW, int a
// ── frame-generic test hooks (ysync miss 09) ────────────────────────────────
/** Run Edit>Undo exactly like the UI would (main-loop + fiber stack) —
/** Run Edit>Undo exactly like the UI would (main loop + apply coroutine) —
* exercises the local-ops-only undo policy and the stale-picker UUID guard. */
inline bool testUndo( EDA_BASE_FRAME* aFrame )
{
if( !aFrame )
return false;
runOnFiber( aFrame, [aFrame]() { aFrame->GetToolManager()->RunAction( ACTIONS::undo ); } );
runOnCoroutine( aFrame, [aFrame]() { aFrame->GetToolManager()->RunAction( ACTIONS::undo ); } );
return true;
}

View file

@ -250,7 +250,7 @@ struct CORE
* (center + half-extents, IU) into this canvas contain, never crop:
* the follower's zoom is derived from ITS OWN canvas size, so leaders
* and followers on different monitors see the same world region.
* Fiber like every other view mutation from JS. */
* Apply coroutine like every other view mutation from JS. */
void fitViewport( double aCx, double aCy, double aHalfW, double aHalfH )
{
EDA_DRAW_FRAME* fr = frame();
@ -258,7 +258,7 @@ struct CORE
if( !fr || aHalfW <= 0 || aHalfH <= 0 )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
pcbjam_collab::runOnCoroutine( fr, [this, fr, aCx, aCy, aHalfW, aHalfH]() {
KIGFX::VIEW* view = fr->GetCanvas()->GetView();
const VECTOR2I& sz = view->GetScreenPixelSize();
@ -279,7 +279,7 @@ struct CORE
}
/** kicadCollabSetViewport (0005): pan to a world position (comment panel
* "jump to pin"). Fiber like every other view mutation from JS. */
* "jump to pin"). Apply coroutine like every other view mutation from JS. */
void panTo( double aCx, double aCy )
{
EDA_DRAW_FRAME* fr = frame();
@ -287,7 +287,7 @@ struct CORE
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [this, fr, aCx, aCy]() {
pcbjam_collab::runOnCoroutine( fr, [this, fr, aCx, aCy]() {
fr->GetCanvas()->GetView()->SetCenter( VECTOR2D( aCx, aCy ) );
fr->GetCanvas()->ForceRefresh();
emitViewportIfChanged();
@ -311,7 +311,8 @@ struct CORE
return;
// Screen→world via the non-virtual VIEW::ToWorld (the virtual
// VIEW_CONTROLS::GetMousePosition is an asyncify dispatch risk here).
// VIEW_CONTROLS::GetMousePosition mis-dispatched here under the
// retired asyncify runtime; the direct call stays).
wxPoint p = aEvt.GetPosition();
VECTOR2D world = fr->GetCanvas()->GetView()->ToWorld( VECTOR2D( p.x, p.y ), true );
@ -327,9 +328,9 @@ struct CORE
// ── remote render ─────────────────────────────────────────────────────
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE: the
// first MakeOverlay() view->Add and the items' virtual ViewBBox() need the
// fiber stack (asyncify virtual dispatch — same constraint as the apply).
// Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE via the
// apply queue — serialized with the applies, same constraint as every
// other view mutation from JS.
void redrawOverlay()
{
redrawScheduled = false;
@ -407,7 +408,7 @@ struct CORE
return;
redrawScheduled = true;
pcbjam_collab::runOnFiber( fr, [this]() { redrawOverlay(); } );
pcbjam_collab::runOnCoroutine( fr, [this]() { redrawOverlay(); } );
}
// ── JS entry-point bodies ─────────────────────────────────────────────

View file

@ -60,7 +60,6 @@
#include <pcbjam_remote_lock.h>
#include "collab_common.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "collab_presence_core.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
@ -85,7 +84,7 @@ using json = nlohmann::json;
#ifndef KICAD_MERGED_EMBIND
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -302,10 +301,10 @@ SCH_ITEM* makeItem( const json& j )
else if( type == "SCH_SHAPE" )
{
// Reconstruct from the geometry itemToJson emits. Committing a *new* SCH_SHAPE used to
// trap in SCH_COMMIT::Push's CHT_ADD path (GAL view->Add → an asyncify invoke_viii
// mis-dispatch, "memory access out of bounds") because doApply ran off a fiber stack;
// doApply now runs inside a COROUTINE (kicadCollabApply) so the add dispatches correctly,
// exactly as a native draw does. (0006/0007.) NB FILL_T::NO_FILL == 1, not 0.
// trap in SCH_COMMIT::Push's CHT_ADD path under the retired asyncify runtime because
// doApply ran off the coroutine context; doApply runs inside a COROUTINE
// (kicadCollabApply), serialized with local edits, exactly as a native draw does.
// (0006/0007.) NB FILL_T::NO_FILL == 1, not 0.
SHAPE_T st = (SHAPE_T) j.value( "stype", (int) SHAPE_T::RECTANGLE );
int layer = j.value( "layer", (int) LAYER_NOTES );
int width = j.value( "width", 0 );
@ -577,10 +576,11 @@ void flushDiff()
}
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE: its v2 items
// emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), whose virtual dispatch
// is only reliable on the libcontext fiber stack — on the bare CallAfter stack it traps
// and silently kills the whole flush, legacy emit included (same lesson as doApply, 0007).
// turn) into a single post-settle diff. flushDiff runs inside a COROUTINE via the apply
// queue: its v2 items emit serializes items via SCH_IO_KICAD_SEXPR::Format (itemBlob), and
// queueing it with the applies keeps emits from interleaving with a suspended apply body
// (under the retired asyncify runtime the bare CallAfter stack additionally trapped here —
// same lesson as doApply, 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -589,7 +589,7 @@ void scheduleFlush()
g_flushScheduled = true;
if( SCH_EDIT_FRAME* fr = schFrame() )
pcbjam_collab::runOnFiber( fr, []() { flushDiff(); } );
pcbjam_collab::runOnCoroutine( fr, []() { flushDiff(); } );
else
flushDiff();
}
@ -598,9 +598,9 @@ void scheduleFlush()
// to the child .kicad_sch file and tell the standalone (window.kicadCollab.onSheetCreated),
// so the child is persisted/registered the moment it's created — without waiting for the
// user to enter it or save the project. Otherwise the parent's `(sheet … child)` reference
// dangles for peers / on reload. Deferred onto the fiber stack (CallAfter + COROUTINE):
// SCH_IO_KICAD_SEXPR::Format's virtual dispatch traps on the bare listener/CallAfter stack,
// same as flushDiff/doApply. The sheet is re-resolved by uuid in the deferred body so a
// dangles for peers / on reload. Deferred onto the apply coroutine (CallAfter + COROUTINE),
// same as flushDiff/doApply, so the save serializes with any in-flight apply.
// The sheet is re-resolved by uuid in the deferred body so a
// since-deleted sheet (e.g. an immediate undo) is a no-op rather than a dangling pointer.
void scheduleSheetSave( SCH_SHEET* aSheet )
{
@ -616,7 +616,7 @@ void scheduleSheetSave( SCH_SHEET* aSheet )
std::string childAbs = toUtf8( childFn.GetFullPath() );
std::string uuid = toUtf8( aSheet->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, childAbs, uuid]() {
pcbjam_collab::runOnCoroutine( fr, [fr, childAbs, uuid]() {
KIID kid( wxString::FromUTF8( uuid.c_str() ) );
SCH_ITEM* item = fr->Schematic().ResolveItem( kid, nullptr, /*allowNull*/ true );
@ -834,24 +834,23 @@ void schedulePresenceSelCheck()
namespace {
// SCH_SYMBOL::Move() / SCH_LABEL_BASE::Move() move their child fields (reference, value, …) via
// an inner `field.Move()` — itself a virtual call that mis-dispatches in the apply context, so
// the field text is left behind at its old position while the body moves. Re-move the fields
// with a devirtualized call so the labels follow the symbol on the peer. (The inner call is a
// harmless no-op when it mis-dispatches — the fields stay put — so this doesn't double-move.)
// an inner `field.Move()`. Under the retired asyncify runtime that inner call silently no-oped
// in the apply context — the field text was left behind at its old position while the body
// moved — so the fields are re-moved here with a devirtualized call. Kept as-is through the
// JSPI migration; the collab move/drift suites pin the peer's field positions.
void moveFields( std::vector<SCH_FIELD>& aFields, const VECTOR2I& aDelta )
{
for( SCH_FIELD& field : aFields )
field.SCH_FIELD::Move( aDelta );
}
// Move an item to an absolute position for the `changed` path. SCH_ITEM::Move() is virtual;
// dispatching it through the vtable from the apply/CallAfter context hits the asyncify
// call_indirect mis-dispatch and silently NO-OPS (so symbols/junctions/labels never moved on
// the peer — only SCH_LINE worked, via its direct SetStart/EndPoint path). GetPosition() reads
// fine (it's a plain virtual read; see 0003 / eeschema_collab_asyncify_apply). The fix:
// devirtualize Move() with an explicit class-qualified call, which is statically bound — a
// plain wasm `call`, not an instrumented call_indirect — so it actually executes. Composite
// items additionally need their child fields moved (see moveFields).
// Move an item to an absolute position for the `changed` path. Under the retired asyncify
// runtime the virtual SCH_ITEM::Move(), dispatched from the apply/CallAfter context, silently
// NO-OPED (symbols/junctions/labels never moved on the peer — only SCH_LINE worked, via its
// direct SetStart/EndPoint path; see 0003 / eeschema_collab_asyncify_apply). Move() is
// therefore devirtualized with explicit class-qualified calls — statically bound plain wasm
// calls, correct on any runtime, so they stay. Composite items additionally need their child
// fields moved (see moveFields).
void moveItemTo( SCH_ITEM* aItem, const VECTOR2I& aNewPos )
{
VECTOR2I delta = aNewPos - aItem->GetPosition();
@ -887,9 +886,8 @@ void moveItemTo( SCH_ITEM* aItem, const VECTOR2I& aNewPos )
}
// The actual model mutation, via SCH_COMMIT so connectivity/ERC recompute as for a UI
// edit. (Editor write ops like SCH_ITEM::Move are called through invoke_vii, whose
// asyncify-instrumented dynCall trampoline traps on a stale type — fixed at the JS shim
// layer in scripts/common/shims/dyncall-binding.js.tmpl; see 0003.)
// edit. (0003 decoded an asyncify-era dynCall trap on this path; the shim that healed
// it retired with that runtime.)
void doApply( SCH_EDIT_FRAME* aFrame, const json& aDelta )
{
SCHEMATIC& sch = aFrame->Schematic();
@ -1140,17 +1138,16 @@ void collabTestMove( SCH_EDIT_FRAME* aFrame, SCH_ITEM* aItem, SCH_SCREEN* aScree
// JS → C++. Apply a remote per-item delta by uuid, through SCH_COMMIT so connectivity/
// ERC/hierarchy recompute the same way a UI edit would (0003 §apply).
//
// SCH_COMMIT must run in the editor's Asyncify-rooted main loop — invoking it from this
// embind ccall, or from an emscripten_async_call/setTimeout callback, traps with an
// "indirect call signature mismatch" because those are not the asyncify root (0001 §5).
// wxEvtHandler::CallAfter queues onto the app's pending-event list, which the wasm main
// loop drains every frame via ProcessPendingEvents() (src/wasm/evtloop.cpp) — i.e. the
// exact context real UI edits run in. So defer the whole mutation there.
// SCH_COMMIT must run on the editor's main loop, not on this embind ccall or an
// emscripten_async_call/setTimeout callback: wxEvtHandler::CallAfter queues onto the
// app's pending-event list, which the wasm main loop drains every frame via
// ProcessPendingEvents() (src/wasm/evtloop.cpp) — i.e. the exact context real UI edits
// run in. So defer the whole mutation there.
void schCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a
// half-built schematic mis-dispatch ("indirect call signature mismatch").
// kicadOpenFile chain is suspended mid-load — commits/virtuals would walk
// a half-built schematic mid-mutation.
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
@ -1165,12 +1162,11 @@ void schCollabApply( std::string aJson )
if( !fr )
return;
// Defer to the editor's main-loop context + fiber stack (runOnFiber) so SCH_COMMIT runs
// like a normal edit: SCH_COMMIT::Push's CHT_ADD of a *new* SCH_SHAPE/SCH_SYMBOL
// dispatches GAL virtuals (view->Add → ViewGetLayers) through asyncify-instrumented
// invoke_*; off the fiber stack those mis-dispatch and trap inside KiCad core, which the
// bridge can't devirtualize. On the fiber stack they dispatch correctly. (0007.)
pcbjam_collab::runOnFiber( fr, [fr, delta]() { doApply( fr, delta ); } );
// Defer to the editor's main loop + apply coroutine (runOnCoroutine) so SCH_COMMIT runs
// like a normal edit: a commit body that suspends (connectivity/GAL work) returns early
// from COROUTINE::Call, so applies must serialize with each other and with local edits
// or they interleave on shared commit/listener state. (0007, drift-trio #10.)
pcbjam_collab::runOnCoroutine( fr, [fr, delta]() { doApply( fr, delta ); } );
}
@ -1211,7 +1207,7 @@ void schCollabApplyItems( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
}
@ -1353,7 +1349,7 @@ bool schCollabTestRemoveItem( std::string aId )
SCH_SCREEN* screen = path.LastScreen();
pcbjam_collab::runOnFiber( fr, [fr, item, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, screen]() {
SCH_COMMIT commit( fr );
commit.Remove( item, screen );
commit.Push( wxT( "Collab test remove" ) );
@ -1382,7 +1378,7 @@ bool schCollabTestRotateItem( std::string aId, double aDeg )
SCH_SCREEN* screen = path.LastScreen();
int steps = ( (int) ( aDeg / 90.0 + ( aDeg >= 0 ? 0.5 : -0.5 ) ) % 4 + 4 ) % 4;
pcbjam_collab::runOnFiber( fr, [fr, item, screen, steps]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, screen, steps]() {
SCH_COMMIT commit( fr );
commit.Modify( item, screen );
@ -1427,7 +1423,7 @@ bool schCollabTestSetFieldText( std::string aId, std::string aText )
SCH_SCREEN* screen = path.LastScreen();
wxString text = wxString::FromUTF8( aText.c_str() );
pcbjam_collab::runOnFiber( fr, [fr, sym, screen, text]() {
pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen, text]() {
SCH_COMMIT commit( fr );
commit.Modify( sym, screen );
sym->SetValueFieldText( text );
@ -1440,7 +1436,7 @@ bool schCollabTestSetFieldText( std::string aId, std::string aText )
// ── drift-trio phase B action hooks (standalone-hardening 0008 §5) ───────────
// Creation/mutation primitives for the trio harness's action catalog. Each
// drives a REAL SCH_COMMIT on the fiber stack, so the SCHEMATIC_LISTENER →
// drives a REAL SCH_COMMIT on the apply coroutine, so the SCHEMATIC_LISTENER →
// flushDiff emit path runs exactly as for a UI edit. Names are tool-unique
// (registered outside the KICAD_MERGED_EMBIND guard — same convention as
// kicadCollabTestSetFieldText), so the merged image needs no dispatcher.
@ -1467,7 +1463,7 @@ static std::string schCollabTestCommitAdd( SCH_ITEM* aItem, const wxChar* aMsg )
std::string id = toUtf8( aItem->m_Uuid.AsString() );
wxString msg( aMsg );
pcbjam_collab::runOnFiber( fr, [fr, aItem, screen, msg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aItem, screen, msg]() {
SCH_COMMIT commit( fr );
commit.Add( aItem, screen );
commit.Push( msg );
@ -1549,7 +1545,7 @@ std::string schCollabTestAddSymbol( std::string aLibId, int aX, int aY, std::str
std::string id = toUtf8( sym->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, sym, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, sym, screen]() {
SCH_COMMIT commit( fr );
commit.Add( sym, screen );
commit.Push( wxT( "Collab test add symbol" ) );
@ -1600,7 +1596,7 @@ bool schCollabTestMirrorSchItem( std::string aId, bool aHorizontal )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, aHorizontal]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aHorizontal]() { // re-resolve on the coroutine (S4)
SCH_SHEET_PATH path;
SCH_ITEM* live = fr->Schematic().ResolveItem( KIID( wxString::FromUTF8( aId.c_str() ) ),
&path, /*allowNull*/ true );
@ -1643,7 +1639,7 @@ std::string schCollabTestDuplicateSchItem( std::string aId, int aDx, int aDy )
std::string id = toUtf8( dup->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, dup, screen]() {
pcbjam_collab::runOnCoroutine( fr, [fr, dup, screen]() {
SCH_COMMIT commit( fr );
commit.Add( dup, screen );
commit.Push( wxT( "Collab test duplicate" ) );
@ -2038,9 +2034,9 @@ void kicadSaveSchematic( std::string path )
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
}
EMSCRIPTEN_BINDINGS(eeschema) {
@ -2065,7 +2061,7 @@ EMSCRIPTEN_BINDINGS(eeschema) {
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);
// Yjs collaborative bridge entry points (same contract as pl_editor).

View file

@ -1,180 +0,0 @@
/*
* Test-only repro lever for the DECODED production board-load trap
* (docs/features/async/15-timer-park-repro.md round 3, 2026-07-31):
* resuming a KiCad coroutine while its body is asyncify-parked inside
* handleSleep.
*
* Two suspension protocols share one context on wasm. A coroutine suspended
* by a real yield (fiber_swap) has valid rewind data in its fiber struct; a
* coroutine whose body parked via handleSleep (lib-bridge wait,
* emscripten_sleep) does NOT its live state is in the sleep's buffer,
* invisible to the fiber machinery and to TOOL_MANAGER. Resume() then swaps
* into the STALE fiber data: finishContextSwitch doRewind "unreachable
* executed", and every later entry reads poisoned Asyncify state ("index out
* of bounds"). Impossible natively — a coroutine cannot be suspended without
* yielding.
*
* The lever stages the prod state machine exactly:
* start(parkMs): Call() a coroutine that immediately KiYield()s this
* writes VALID suspension data once and clears the fresh-entry path, the
* state every long-lived tool loop is in.
* prime(): Resume() it legitimately the body then emscripten_sleep()s
* (the internal park; prime's Resume ghost-returns per the epoch
* machinery) and afterwards KiYield()s again.
* poke(): Resume() DURING the sleep the fatal prod operation. Unfixed
* runtime: rewinds the stale suspension the exact prod trap. Fixed
* runtime: the jump is refused (null INVOCATION_ARGS, same contract as
* jump-ghost) and the body completes undisturbed; a later poke() after
* the second KiYield resumes it for real.
*
* Production is inert: nothing runs unless start() is called.
*/
#pragma once
#include <cstdio>
#include <string>
#include <emscripten.h>
#include <tool/coroutine.h>
namespace pcbjam_fiber_park
{
struct State
{
// 0 idle · 1 yielded-once (primed suspension) · 2 in the sleep park ·
// 3 woke, yielded again · 4 resumed past second yield · 5 body returned
int phase = 0;
int parkMs = 0;
int pokes = 0;
// Second coroutine (poisoned-attribution scenario): 0 idle · 1 yielded ·
// 2 completed. Starting it while the FIRST body is asyncify-parked makes
// libcontext attribute the jump's old side to that parked fiber
// (g_current_context is stale), writing a fresh suspension into its
// struct — the exact laundering that let the prod resume bypass the
// swap_suspended guard.
int phase2 = 0;
};
inline State& state()
{
static State s_state;
return s_state;
}
inline COROUTINE<int, int>*& co()
{
static COROUTINE<int, int>* s_co = nullptr;
return s_co;
}
inline int fiberBody( int )
{
state().phase = 1;
co()->KiYield();
state().phase = 2;
if( state().parkMs > 0 )
emscripten_sleep( state().parkMs );
state().phase = 3;
co()->KiYield();
state().phase = 4;
return 0;
}
/** Call() + first KiYield: coroutine now has VALID fiber suspension data. */
inline bool start( int aParkMs )
{
if( co() && co()->Running() )
return false; // one in flight; the spec drives one cycle at a time
delete co();
state() = State();
state().parkMs = aParkMs;
co() = new COROUTINE<int, int>( fiberBody );
co()->Call( 0 );
return state().phase == 1;
}
/** Legitimate Resume into the primed yield; the body then parks. Ghost-returns. */
inline bool prime()
{
if( !co() )
return false;
return co()->Resume();
}
/**
* Resume() regardless of the body's suspension state what TOOL_MANAGER does
* on the next event, unaware the body is asyncify-parked. Counted so the spec
* can correlate pokes with phases.
*/
inline bool poke()
{
if( !co() )
return false;
++state().pokes;
return co()->Resume();
}
inline COROUTINE<int, int>*& co2()
{
static COROUTINE<int, int>* s_co2 = nullptr;
return s_co2;
}
inline int fiberBody2( int )
{
state().phase2 = 1;
co2()->KiYield();
state().phase2 = 2;
return 0;
}
/**
* Start a SECOND coroutine while the first body is asyncify-parked. Because
* g_current_context still points at the parked fiber, libcontext attributes
* this jump's old side to it: the swap writes a fresh (foreign) suspension
* into the PARKED fiber's struct and re-marks it swap_suspended the
* laundering that lets a later Resume bypass the C++ guard. The JS
* stale-rewind guard (handlesleep.js) must still quarantine it.
*/
inline bool startSecond()
{
if( !co() )
return false; // scenario needs the first coroutine in flight
if( co2() && co2()->Running() )
return false;
delete co2();
state().phase2 = 0;
co2() = new COROUTINE<int, int>( fiberBody2 );
co2()->Call( 0 );
return state().phase2 == 1;
}
/** Resume the second coroutine past its yield (cleanup / completion). */
inline bool pokeSecond()
{
if( !co2() )
return false;
return co2()->Resume();
}
inline std::string stateJson()
{
char buf[144];
snprintf( buf, sizeof( buf ),
"{\"phase\":%d,\"pokes\":%d,\"parkMs\":%d,\"running\":%s,\"phase2\":%d}",
state().phase, state().pokes, state().parkMs,
( co() && co()->Running() ) ? "true" : "false", state().phase2 );
return buf;
}
} // namespace pcbjam_fiber_park

View file

@ -25,7 +25,6 @@
#include <wx/app.h>
#include <wx/string.h>
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
using namespace emscripten;
@ -38,8 +37,8 @@ static GERBVIEW_FRAME* gerbFrame()
static bool openFileSet( const std::vector<wxString>& aFiles )
{
// Held across every Asyncify park of the load (open_gate.h): the layer load
// parks, and a wx timer dispatched into a half-built layer set traps.
// Held across every suspension of the load (open_gate.h): the layer load
// suspends, and a wx timer dispatched into a half-built layer set traps.
pcbjam_open::BusyGuard busy;
GERBVIEW_FRAME* frame = gerbFrame();

View file

@ -43,9 +43,7 @@
#include "pcbjam_libs_reload.h"
#include "pcbjam_async_policy.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "timer_park.h"
#include "fiber_park.h"
using namespace emscripten;
@ -141,7 +139,7 @@ bool schCollabTestClearSelection();
// standalone bundles compile from their own binding TU.
static bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -167,43 +165,6 @@ static bool kicadOpenFile( std::string path )
return ok;
}
// Phase F (docs/features/async/22 §10, the awaited-ccall entry class): the
// open body above, driven from a DISPATCH CONTEXT instead of the main stack.
// Run there, every wait inside the load parks the context through the
// registry — the main stack never parks in place, which is the last
// production member of the overlapped-wake class the D-on beacon sweep named.
//
// THE TOKEN IS PASSED IN, NOT RETURNED. Running the job on a dispatch context
// Asyncify-suspends THIS embind frame while the load parks, so any return
// value is delivered as an unwind PLACEHOLDER (0) into a rewind JS discards —
// the same gotcha the fiber-park levers document. So the shim wrapper mints
// the wait token in pure JS (no swap), hands it in here, and awaits its
// promise; this starter returns void and its own placeholder return is
// harmless. The job resolves the token when the load completes.
extern "C" void wxWasmRunOnDispatchContext( void ( *fn )( void* ), void* arg );
extern "C" void wxWasmResolveWait( int aToken, int aResult );
namespace
{
struct OPEN_JOB
{
std::string path;
int token;
};
void kicadOpenFileJob( void* aArg )
{
std::unique_ptr<OPEN_JOB> job( static_cast<OPEN_JOB*>( aArg ) );
const bool ok = kicadOpenFile( job->path );
wxWasmResolveWait( job->token, ok ? 1 : 0 );
}
} // namespace
static void kicadOpenFileStart( int token, std::string path )
{
wxWasmRunOnDispatchContext( &kicadOpenFileJob, new OPEN_JOB{ std::move( path ), token } );
}
// JS-pollable open-in-flight probe (open_gate.h): the web shell defers the
// collab/presence attach until the open chain has truly completed.
static bool kicadOpenFileBusy()
@ -218,7 +179,7 @@ static void kicadTestSetOpenPark( int aMs )
}
// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose
// Notify() Asyncify-parks — the deterministic concurrent-park window.
// Notify() suspends — the deterministic concurrent-suspension window.
static bool kicadTestArmTimerPark( int aDelayMs, int aParkMs )
{
return pcbjam_timer_park::arm( aDelayMs, aParkMs );
@ -229,44 +190,6 @@ static std::string kicadTestTimerParkState()
return pcbjam_timer_park::stateJson();
}
// Test-only (fiber_park.h): Resume() into a foreign-parked coroutine — the
// decoded prod board-load trap family. NOTE: these are sync embind bindings
// for MANUAL probing on Chromium only. Do not build specs on them: the
// mutating levers suspend, and no embind shape delivers that correctly
// (plain registration throws on strict-JSPI Firefox; emscripten::async()
// re-executes its invoker when the awaited promise settles). The runtime
// contracts they staged are pinned by the jspi-coroutine harness (18 cases)
// and tests/kicad/coroutine-lifecycle.spec.ts instead.
static bool kicadTestFiberParkStart( int aParkMs )
{
return pcbjam_fiber_park::start( aParkMs );
}
static bool kicadTestFiberParkPrime()
{
return pcbjam_fiber_park::prime();
}
static bool kicadTestFiberParkPoke()
{
return pcbjam_fiber_park::poke();
}
static std::string kicadTestFiberParkState()
{
return pcbjam_fiber_park::stateJson();
}
static bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
static bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Canvas-only chrome toggle (features/mobile): hide/show every AUI pane
// except the central draw canvas, plus the menubar and status bar, so the GAL
@ -607,29 +530,23 @@ static bool collabTestClearSelection()
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
}
EMSCRIPTEN_BINDINGS(kicad_editor) {
// Fiber-queue idle probe (drift-trio finding #10b): a bare-embind-stack
// save during a parked apply fiber mis-dispatches (table index OOB) — the
// JS side must defer scratch saves while collab fiber work is in flight.
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
// Apply-queue idle probe (drift-trio finding #10b): a scratch save taken
// while a collab apply is in flight (queued, or suspended mid-commit)
// would serialize a half-mutated model — the JS side must defer scratch
// saves until this reads false.
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Programmatic file open (preferred over UI automation from the web app).
function("kicadOpenFile", &kicadOpenFile PCBJAM_PARKER_POLICY);
function("kicadOpenFileStart", &kicadOpenFileStart);
function("kicadOpenFileBusy", &kicadOpenFileBusy);
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
function("kicadTestTimerParkState", &kicadTestTimerParkState);
function("kicadTestFiberParkStart", &kicadTestFiberParkStart);
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
// Canvas-only mobile mode (features/mobile).
function("kicadSetChrome", &kicadSetChrome);

View file

@ -1,86 +0,0 @@
/*
* Main-stack runner: the KiCad half of wx's nested-loop bounce
* (pcbjam docs/features/async/19, 20 D3).
*
* A quasi-modal's nested event loop parks its whole stack for the dialog's
* lifetime. When that stack is a TOOL coroutine's, the park suspends the
* fiber's body where the fiber layer cannot see it: the stale-fiber guard
* quarantines the fiber, then REFUSES its own resume, and the dialog stops
* responding to clicks (only the titlebar x still works, because that path is
* ungated). That is the Symbol Properties hang.
*
* wx detects "this nested loop is about to park on a non-main stack" it can,
* cheaply and exactly, by comparing a frame address against
* emscripten_stack_get_base()/end() but it must not know what a coroutine
* is. So it calls this runner, and KiCad's TOOL_MANAGER moves the loop onto
* the main stack via its own RunMainStack mechanism, which suspends the
* coroutine the legitimate way: a fiber swap the layer records
* (swap_suspended = true), so no quarantine, no refused resume.
*
* This lives in pcbjam's binding layer rather than in KiCad or wx precisely
* because it is the only place that may know about both.
*/
#pragma once
#include <wx/app.h>
#include <eda_base_frame.h>
#include <tool/tool_manager.h>
#include <wx/wasm/private/mainstack.h>
namespace pcbjam_main_stack
{
/**
* Run aFunc on the main stack if a tool coroutine is currently active.
*
* Returns 1 when the body has been run (bounced, or run inline because there
* was no coroutine to bounce off), 0 when there was nothing to run it with
* no frame or no tool manager in which case wx parks in place exactly as it
* did before this hook existed.
*/
inline int run_on_main_stack( void ( *aFunc )( void* ), void* aArg )
{
if( !wxTheApp )
return 0;
auto* frame = dynamic_cast<EDA_BASE_FRAME*>( wxTheApp->GetTopWindow() );
if( !frame )
return 0;
TOOL_MANAGER* toolMgr = frame->GetToolManager();
if( !toolMgr )
return 0;
// RunOnMainStackIfActiveTool runs the body inline when no coroutine is
// running. Either way the body HAS run, so report it as handled — letting
// wx fall through would run the nested loop a second time.
bool ran = false;
toolMgr->RunOnMainStackIfActiveTool(
[aFunc, aArg, &ran]()
{
ran = true;
aFunc( aArg );
} );
return ran ? 1 : 0;
}
/**
* Installs at static-init time. Only stores a function pointer, so it is safe
* before wx exists; every lookup above happens lazily per call, since frames
* come and go. Included by more than one binding TU setting the same pointer
* twice is idempotent.
*/
struct INSTALLER
{
INSTALLER() { wxWasmSetMainStackRunner( &run_on_main_stack ); }
};
inline INSTALLER g_installer;
} // namespace pcbjam_main_stack

View file

@ -1,18 +1,18 @@
/*
* Truthful "kicadOpenFile in flight" signal for the web shell.
*
* kicadOpenFile runs OpenProjectFiles under Asyncify: the embind call unwinds
* back to JS long before the load finishes, and the chain stays parked (and
* resumes, and parks again) across the whole multi-second load. Any bare
* embind entry that walks the model while that chain is parked mid-mutation
* (collab snapshot, presence bind) can virtual-dispatch through a half-built
* item and trap ("indirect call signature mismatch" same class as the wx
* dispatch interlock and the drift-trio #10b fiber-busy probe, but through a
* JS entry neither of those covers).
* kicadOpenFile runs OpenProjectFiles as a suspending (promising) export: the
* embind call hands JS a Promise long before the load finishes, and the chain
* stays suspended (and resumes, and suspends again) across the whole
* multi-second load. Any bare embind entry that walks the model while that
* chain is suspended mid-mutation (collab snapshot, presence bind) reads a
* half-built item graph and can trap same class as the wx dispatch
* interlock and the collab apply-busy probe, but through a JS entry neither
* of those covers.
*
* The guard is RAII on the open's C++ stack frame: an Asyncify unwind does not
* run destructors and a rewind resumes past the constructor, so the count is
* held for the park's entire lifetime and drops exactly when OpenProjectFiles
* The guard is RAII on the open's C++ stack frame: a suspension keeps the
* whole C++ stack (and with it this frame) alive, so the count is held for
* the suspension's entire lifetime and drops exactly when OpenProjectFiles
* truly returns (the same primitive as wxWasmDispatchGuard). A trap escaping
* the open leaves the count stuck the JS poll times out and degrades.
*/
@ -30,21 +30,22 @@ inline int& busyCount()
}
/**
* Held for the whole open. Two counters, same Asyncify-RAII trick:
* Held for the whole open. Two counters, same suspension-RAII trick:
*
* - `busyCount` is OURS: it answers kicadOpenFileBusy() for the web shell and
* gates the collab entries (JS embind reentry).
* - `wxWasmDispatchGuard` enrolls the open in the WX DISPATCH INTERLOCK. This
* matters because `kicadOpenFile` enters through embind, not through a wx
* dispatch entry point, so without it `wxWasmDispatchParked()` reads FALSE
* for the entire load: every park (progress pump, thread-pool futex wait,
* lib bridge) lets the pump dispatch a QUEUED WX TIMER into the half-built
* board src/wasm/timer.cpp fires it because nothing looks parked and
* the handler walks half-mutated widget/board state ("index out of bounds",
* the same signature as the symbol-chooser crash the interlock was built
* for). Holding the guard makes those timers defer (retry 17 ms later)
* until the load truly completes. Paints keep running; the progress
* dialog's own pump is the designed exception (it zeroes the count).
* for the entire load: every suspension (progress pump, thread-pool futex
* wait, lib bridge) lets the pump dispatch a QUEUED WX TIMER into the
* half-built board src/wasm/timer.cpp fires it because nothing looks
* parked and the handler walks half-mutated widget/board state ("index
* out of bounds", the same signature as the symbol-chooser crash the
* interlock was built for). Holding the guard makes those timers defer
* (retry 17 ms later) until the load truly completes. Paints keep running;
* the progress dialog's own pump is the designed exception (it zeroes the
* count).
*/
struct BusyGuard
{
@ -63,11 +64,12 @@ inline bool busy()
/**
* Test-only deterministic park (tests/kicad/collab-load-fuzz.spec.ts): with a
* nonzero value, kicadOpenFile Asyncify-parks for this many ms on entry and
* again after OpenProjectFiles returns busy guard held, model fully loaded.
* Natural in-load parks (thread-pool futex waits) are scheduler-dependent and
* never happen on a fast idle machine, so the guard would be untestable in CI
* without this window. 0 (the default) is a no-op in production.
* nonzero value, kicadOpenFile suspends for this many ms on entry and again
* after OpenProjectFiles returns busy guard held, model fully loaded.
* Natural in-load suspensions (thread-pool futex waits) are
* scheduler-dependent and never happen on a fast idle machine, so the guard
* would be untestable in CI without this window. 0 (the default) is a no-op
* in production.
*/
inline int& testParkMs()
{

View file

@ -15,8 +15,8 @@
*
* Header-only (the collab_common.h pattern); common-code includes only, so the
* merged kicad_editor TU (deliberately eeschema/pcbnew-header-free) can use it.
* Runs on the fiber stack: LoadLibraryEntry Asyncify-suspends in the JS bridge,
* and the tree sync dispatches GAL/tree virtuals that trap off-fiber.
* Runs on the apply coroutine: LoadLibraryEntry suspends in the JS bridge, so
* the reload must serialize with the collab applies and local edits.
*/
#pragma once
@ -52,7 +52,7 @@ inline void reloadLibrary( std::string aKind, std::string aNickname )
const bool fp = aKind == "footprint";
const wxString nick = wxString::FromUTF8( aNickname.c_str() );
pcbjam_collab::runOnFiber( top, [top, fp, nick]()
pcbjam_collab::runOnCoroutine( top, [top, fp, nick]()
{
LIBRARY_MANAGER& mgr = Pgm().GetLibraryManager();
const LIBRARY_TABLE_TYPE type =

View file

@ -42,7 +42,7 @@ namespace pcbjam_theme {
* which have no chrome API. */
inline void ( *g_afterThemeApplied )() = nullptr;
/** Set the chrome appearance FLAG only — no widget traffic, no fiber. Safe
/** Set the chrome appearance FLAG only — no widget traffic, no coroutine. Safe
* from the browser main thread at any point (it writes one bool in shared
* wasm memory); the embedder calls it at onRuntimeInitialized, BEFORE main()
* spawns on the KiCad pthread, so the first widget paint is already themed.
@ -79,7 +79,7 @@ inline void syncChromeAppearance( bool aDark )
}
/** Apply `aTheme` ("pcbjam-dark", "_builtin_default", …) to one frame. Runs
* on the frame's fiber: CommonSettingsChanged reaches tool/view internals
* on the apply coroutine: CommonSettingsChanged reaches tool/view internals
* that must not run from a bare JS callback. Null frame no-ops (the merged
* dispatcher calls every editor, open or not). */
inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
@ -87,7 +87,7 @@ inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
if( !aFrame )
return;
pcbjam_collab::runOnFiber( aFrame, [aFrame, aTheme]() {
pcbjam_collab::runOnCoroutine( aFrame, [aFrame, aTheme]() {
// The shell only ever sends our dark theme name or the builtin
// default, so the chrome appearance rides on that distinction.
const bool dark = aTheme != "_builtin_default";
@ -116,7 +116,7 @@ inline void setColorTheme( EDA_DRAW_FRAME* aFrame, const std::string& aTheme )
// unconditionally reloads colors and recaches the view.
aFrame->CommonSettingsChanged( 0 );
// Queued from INSIDE the fiber body, AFTER CommonSettingsChanged: the
// Queued from INSIDE the coroutine body, AFTER CommonSettingsChanged: the
// menubar rebuild it triggers is itself a CallAfter on this same
// handler, so FIFO puts the re-assert behind the rebuilt (shown)
// menubar.

View file

@ -53,10 +53,8 @@
#include "collab_common.h"
#include "collab_presence_core.h"
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
#include "timer_park.h"
#include "fiber_park.h"
#include "collab_presence_style.h"
#include "pcbjam_theme.h"
#include "pcbjam_libs_reload.h"
@ -89,7 +87,7 @@ using json = nlohmann::json;
#ifndef KICAD_MERGED_EMBIND
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -129,7 +127,7 @@ void kicadTestSetOpenPark( int aMs )
}
// Test-only (timer-park repro, timer_park.h): a one-shot wx timer whose
// Notify() Asyncify-parks — the deterministic concurrent-park window.
// Notify() suspends — the deterministic concurrent-suspension window.
bool kicadTestArmTimerPark( int aDelayMs, int aParkMs )
{
return pcbjam_timer_park::arm( aDelayMs, aParkMs );
@ -140,38 +138,6 @@ std::string kicadTestTimerParkState()
return pcbjam_timer_park::stateJson();
}
// Test-only (fiber-resume-park repro, fiber_park.h): Resume() into an
// asyncify-parked coroutine — the decoded prod board-load trap.
bool kicadTestFiberParkStart( int aParkMs )
{
return pcbjam_fiber_park::start( aParkMs );
}
bool kicadTestFiberParkPrime()
{
return pcbjam_fiber_park::prime();
}
bool kicadTestFiberParkPoke()
{
return pcbjam_fiber_park::poke();
}
std::string kicadTestFiberParkState()
{
return pcbjam_fiber_park::stateJson();
}
bool kicadTestFiberParkStartSecond()
{
return pcbjam_fiber_park::startSecond();
}
bool kicadTestFiberParkPokeSecond()
{
return pcbjam_fiber_park::pokeSecond();
}
// Read-only viewer lock (read-only-viewer): flips the process-global
// PCBJAM_READ_ONLY flag consumed by TOOL_MANAGER (view-only action allowlist)
// and the selection tools (nothing selectable), and mirrors it onto the
@ -205,9 +171,9 @@ bool kicadSetReadOnly( bool aReadOnly )
// trigger; the real change set is a diff of the full model taken after the
// edit's BOARD_COMMIT::Push — connectivity cleanup included — has returned,
// so peers converge by re-applying already-clean geometry). See eeschema 0007.
// - apply = BOARD_COMMIT run inside a CallAfter + COROUTINE fiber stack, the exact
// context native tool edits run in, so GAL view->Add of a freshly-constructed
// item dispatches its asyncify-instrumented virtuals correctly (eeschema 0007).
// - apply = BOARD_COMMIT run inside a CallAfter + COROUTINE, the exact context native
// tool edits run in, serialized with every other apply/local edit through the
// collab_common.h apply queue (eeschema 0007).
//
// Scope of this first commit (0004 §"first PoC", matching eeschema commit-3's first cut):
// position/geometry sync of existing items — changed (move/reshape) and removed work for
@ -232,12 +198,12 @@ bool isTrackType( KICAD_T t )
return t == PCB_TRACE_T || t == PCB_ARC_T || t == PCB_VIA_T;
}
// Read an item's layer WITHOUT the virtual GetLayer(). That virtual mis-dispatches in the
// non-coroutine emit/snapshot context — it returns 0 (F_Cu) for EVERY item (the same asyncify
// call_indirect class as eeschema's Move()), which silently put every collab-added item/track/
// text on the top copper layer on the peer. A class-qualified `BOARD_ITEM::GetLayer()` is a
// statically-bound (direct) call that just reads m_layer, bypassing call_indirect. Zones keep
// their layer in m_layerSet (not m_layer), so use their non-virtual GetFirstLayer().
// Read an item's layer WITHOUT the virtual GetLayer(). Under the retired asyncify runtime
// that virtual mis-dispatched in the non-coroutine emit/snapshot context — it returned 0
// (F_Cu) for EVERY item, silently putting every collab-added item/track/text on the top
// copper layer on the peer. The class-qualified `BOARD_ITEM::GetLayer()` is a statically-
// bound (direct) call that just reads m_layer — correct on any runtime, so it stays. Zones
// keep their layer in m_layerSet (not m_layer), so use their non-virtual GetFirstLayer().
int itemLayer( BOARD_ITEM* aItem )
{
if( aItem->Type() == PCB_ZONE_T )
@ -314,8 +280,9 @@ json itemToJson( BOARD_ITEM* aItem )
}
// Vias and zones reconstruct NATIVELY on `added` (the s-expr clipboard blob's `(kicad_pcb …)`
// envelope parse — used for footprints — is asyncify-fragile in wasm for these, the same wall
// that deferred the eeschema symbol blob). So emit the geometry their makeItem needs.
// envelope parse — used for footprints — proved fragile for these under the retired asyncify
// runtime, the same wall that deferred the eeschema symbol blob; the native path stays as the
// simpler, pinned-by-tests route). So emit the geometry their makeItem needs.
if( aItem->Type() == PCB_VIA_T )
{
auto* via = static_cast<PCB_VIA*>( aItem );
@ -342,7 +309,7 @@ json itemToJson( BOARD_ITEM* aItem )
j["poly"] = pts;
}
// A board-level graphic text (Place→Text) also reconstructs NATIVELY (same asyncify reason as
// A board-level graphic text (Place→Text) also reconstructs NATIVELY (same reason as
// via/zone): emit its size / stroke / angle so makeItem can rebuild it. Footprint child text
// is synced by move, not `added`, so this is only the board PCB_TEXT case.
else if( aItem->Type() == PCB_TEXT_T )
@ -632,7 +599,7 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
item = tr;
}
// Via / zone: reconstruct natively from emitted geometry (the envelope-blob parse is
// asyncify-fragile for these — see itemToJson). The blob is still emitted as a fallback.
// skipped for these — see itemToJson). The blob is still emitted as a fallback.
else if( type == "PCB_VIA" && j.contains( "drill" ) )
{
auto* via = new PCB_VIA( &aBoard );
@ -714,7 +681,7 @@ BOARD_ITEM* makeItem( BOARD& aBoard, const json& j )
// Set an existing item's geometry from a `changed` delta. Tracks reshape via their endpoints
// (independent — like an eeschema wire); everything else moves to an absolute position.
// SetStart/SetEnd/SetPosition run inside the apply COROUTINE (see kicadCollabApply), the same
// fiber context native edits use, so the virtual dispatch resolves correctly.
// context native edits use, serialized through the apply queue.
void applyChanged( BOARD_ITEM* aItem, const json& j )
{
if( isTrackType( aItem->Type() ) && j.contains( "sx" ) )
@ -915,7 +882,7 @@ void flushDiff()
// Attach an s-expr clipboard blob ONLY for types makeItem reconstructs from it
// (footprints, board graphics, …). Tracks/vias/zones/text rebuild NATIVELY from the
// fields itemToJson already emitted, so they need no blob — and skipping it avoids a
// wasted SaveSelection plus the asyncify-fragile envelope parse for those.
// wasted SaveSelection plus the envelope parse for those.
if( live && !isTrackType( live->Type() ) && live->Type() != PCB_ZONE_T
&& live->Type() != PCB_TEXT_T )
withBlob["sexpr"] = blobForItem( board, live );
@ -971,10 +938,11 @@ void flushDiff()
// Coalesce all the listener callbacks of one commit (and any other edits in the same loop
// turn) into a single post-settle diff.
// flushDiff runs inside a COROUTINE: the v2 items emit serializes ROOT items via
// CLIPBOARD_IO Format (blobForItem), whose virtual dispatch is only reliable on the
// libcontext fiber stack — on the bare CallAfter stack it can trap and silently kill
// the whole flush, legacy emit included (same lesson as doApply / eeschema 0007).
// flushDiff runs inside a COROUTINE via the apply queue: the v2 items emit serializes
// ROOT items via CLIPBOARD_IO Format (blobForItem), and running it in the same queue
// as the applies keeps emits from interleaving with a suspended apply body (under the
// retired asyncify runtime the bare CallAfter stack additionally trapped here — same
// lesson as doApply / eeschema 0007).
void scheduleFlush()
{
if( g_flushScheduled )
@ -983,7 +951,7 @@ void scheduleFlush()
g_flushScheduled = true;
if( PCB_EDIT_FRAME* fr = pcbFrame() )
pcbjam_collab::runOnFiber( fr, []() { flushDiff(); } );
pcbjam_collab::runOnCoroutine( fr, []() { flushDiff(); } );
else
flushDiff();
}
@ -1246,10 +1214,9 @@ void doApplyItems( PCB_EDIT_FRAME* aFrame, const json& aWire )
}
// Test/PoC move (the BOARD_COMMIT body for kicadCollabTestMoveFirst). Run inside a COROUTINE by
// the caller: `BOARD_ITEM::Move` is virtual, and dispatched off the app main stack (a bare
// CallAfter) it hits the asyncify call_indirect mis-dispatch and silently NO-OPS — the commit
// dirties the board but the item doesn't move. On the fiber stack (where native tool edits and
// doApply run) it dispatches correctly. (Same lesson as eeschema's devirtualized move.)
// the caller — the context native tool edits and doApply run in, serialized through the apply
// queue. (Under the retired asyncify runtime the virtual `BOARD_ITEM::Move` additionally
// mis-dispatched off that context; same lesson as eeschema's devirtualized move.)
void collabTestMove( PCB_EDIT_FRAME* aFrame, BOARD_ITEM* aItem, int aDx, int aDy )
{
BOARD_COMMIT commit( aFrame );
@ -1344,8 +1311,8 @@ pcbjam_presence::CORE& presenceCore()
// Draw ONE item's selection box under the given style. Exact-geometry
// outline (style shape 5): footprints hug their bounding hull,
// everything else its transformed shape. Runs on the coroutine fiber
// (virtual dispatch), falls back to the bbox on anything that can't
// everything else its transformed shape. Runs on the apply
// coroutine, falls back to the bbox on anything that can't
// produce a polygon.
auto drawItem = [&]( BOARD_ITEM* item, const std::string& name,
const KIGFX::COLOR4D& itemColor,
@ -1447,19 +1414,18 @@ void schedulePresenceSelCheck()
// JS → C++. Apply a remote per-item delta by uuid, through BOARD_COMMIT so connectivity/ratsnest
// recompute the same way a UI edit would (0004 §apply).
//
// BOARD_COMMIT must run in the editor's Asyncify-rooted main loop — invoking it from this embind
// ccall, or from a setTimeout callback, traps with an "indirect call signature mismatch" (those
// aren't the asyncify root). wxEvtHandler::CallAfter queues onto the app's pending-event list,
// drained every frame by the wasm main loop (src/wasm/evtloop.cpp) — the exact context real UI
// edits run in. Additionally run the mutation inside a COROUTINE so it executes on a libcontext
// fiber stack: BOARD_COMMIT::Push's CHT_ADD of a freshly-built item dispatches GAL virtuals
// (view->Add → ViewGetLayers) through asyncify-instrumented invoke_*; off the fiber stack those
// mis-dispatch and trap inside KiCad core, on it they dispatch correctly (eeschema 0007).
// BOARD_COMMIT must run on the editor's main loop, not on this embind ccall or a setTimeout
// callback: wxEvtHandler::CallAfter queues onto the app's pending-event list, drained every
// frame by the wasm main loop (src/wasm/evtloop.cpp) — the exact context real UI edits run in.
// Additionally run the mutation inside a COROUTINE via the collab_common.h apply queue: a
// commit body that suspends (connectivity/GAL work) returns early from COROUTINE::Call, so
// applies must serialize with each other and with local edits or they interleave on shared
// commit/listener state (eeschema 0007, drift-trio #10).
void pcbCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load — commits/virtuals on a
// half-built board mis-dispatch ("indirect call signature mismatch").
// kicadOpenFile chain is suspended mid-load — commits/virtuals would walk
// a half-built board mid-mutation.
// Callers gate on kicadOpenFileBusy; fuzzed by tests/kicad/collab-load-fuzz.spec.ts.
if( pcbjam_open::busy() )
return;
@ -1474,7 +1440,7 @@ void pcbCollabApply( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, delta]() { doApply( fr, delta ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, delta]() { doApply( fr, delta ); } );
}
@ -1495,7 +1461,7 @@ void pcbCollabApplyItems( std::string aJson )
if( !fr )
return;
pcbjam_collab::runOnFiber( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
pcbjam_collab::runOnCoroutine( fr, [fr, wire]() { doApplyItems( fr, wire ); } );
}
@ -1631,9 +1597,9 @@ std::string pcbCollabTestMoveFirst( int aDx, int aDy )
return;
movedId = toUtf8( item->m_Uuid.AsString() );
// Main stack + fiber (runOnFiber), so the virtual Move()
// dispatches instead of no-opping — same wrapping as doApply.
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() {
// Main loop + apply coroutine (runOnCoroutine) — same
// wrapping as doApply.
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDx, aDy]() {
collabTestMove( fr, item, aDx, aDy );
} );
} );
@ -1997,9 +1963,8 @@ std::string kicadCollabTestItemBlob( std::string aId )
// ── ysync-review repro hooks ─────────────────────────────────────────────────
// Local-edit test hooks for the ysync-review repro e2e (docs/features/
// ysync-review on the ysync-review branch): each drives a REAL BOARD_COMMIT on
// the app main stack inside a COROUTINE fiber (the collabTestMove wrapping —
// virtual item mutators mis-dispatch off the fiber stack), so the
// COLLAB_LISTENER → flushDiff emit path runs exactly as for a UI edit. Each
// the app main loop inside the apply COROUTINE (the collabTestMove wrapping),
// so the COLLAB_LISTENER → flushDiff emit path runs exactly as for a UI edit. Each
// returns false when the uuid doesn't resolve, letting the spec distinguish
// "hook missed the item" from "differ missed the edit" (bug 04).
@ -2035,7 +2000,7 @@ bool pcbCollabTestRemoveItem( std::string aId )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item]() {
BOARD_COMMIT commit( fr );
commit.Remove( item );
commit.Push( wxT( "Collab test remove" ) );
@ -2055,7 +2020,7 @@ bool pcbCollabTestRotateItem( std::string aId, double aDeg )
if( !item )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aDeg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDeg]() {
BOARD_COMMIT commit( fr );
commit.Modify( item );
item->Rotate( item->GetPosition(), EDA_ANGLE( aDeg, DEGREES_T ) );
@ -2077,7 +2042,7 @@ bool pcbCollabTestSetPadSize( std::string aId, int aW, int aH )
PAD* pad = static_cast<PAD*>( item );
pcbjam_collab::runOnFiber( fr, [fr, pad, aW, aH]() {
pcbjam_collab::runOnCoroutine( fr, [fr, pad, aW, aH]() {
BOARD_COMMIT commit( fr );
commit.Modify( pad );
pad->SetSize( PADSTACK::ALL_LAYERS, VECTOR2I( aW, aH ) );
@ -2098,7 +2063,7 @@ bool pcbCollabTestMoveEndpoint( std::string aId, int aDx, int aDy )
if( !item || ( !isTrackType( item->Type() ) && item->Type() != PCB_SHAPE_T ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, item, aDx, aDy]() {
pcbjam_collab::runOnCoroutine( fr, [fr, item, aDx, aDy]() {
BOARD_COMMIT commit( fr );
commit.Modify( item );
@ -2121,7 +2086,7 @@ bool pcbCollabTestMoveEndpoint( std::string aId, int aDx, int aDy )
// ── drift-trio phase B action hooks (standalone-hardening 0008 §5) ───────────
// Creation/mutation primitives for the trio harness's action catalog. Each
// drives a REAL BOARD_COMMIT on the fiber stack, so the BOARD_LISTENER →
// drives a REAL BOARD_COMMIT on the apply coroutine, so the BOARD_LISTENER →
// flushDiff emit path runs exactly as for a UI edit. Names are tool-unique
// (registered outside the KICAD_MERGED_EMBIND guard — same convention as
// kicadCollabTestSetPadSize), so the merged image needs no dispatcher.
@ -2140,7 +2105,7 @@ static std::string pcbCollabTestCommitAdd( BOARD_ITEM* aItem, const wxChar* aMsg
std::string id = toUtf8( aItem->m_Uuid.AsString() );
wxString msg( aMsg );
pcbjam_collab::runOnFiber( fr, [fr, aItem, msg]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aItem, msg]() {
BOARD_COMMIT commit( fr );
commit.Add( aItem );
commit.Push( msg );
@ -2221,7 +2186,7 @@ bool pcbCollabTestFlipBoardItem( std::string aId )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
@ -2251,7 +2216,7 @@ bool pcbCollabTestSetFootprintField( std::string aId, std::string aField, std::s
if( !isRef && aField != "Value" )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, text, isRef]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, text, isRef]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* live = testResolve( fr, aId );
if( !live || live->Type() != PCB_FOOTPRINT_T )
@ -2279,7 +2244,7 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
if( !testResolve( fr, aId ) )
return false;
pcbjam_collab::runOnFiber( fr, [fr, aId, aLocked]() { // re-resolve on the fiber (S4)
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aLocked]() { // re-resolve on the coroutine (S4)
BOARD_ITEM* item = testResolve( fr, aId );
if( !item )
@ -2294,7 +2259,7 @@ bool pcbCollabTestSetBoardItemLocked( std::string aId, bool aLocked )
return true;
}
// By-uuid variant of MoveFirst (same fiber + BOARD_COMMIT body).
// By-uuid variant of MoveFirst (same coroutine + BOARD_COMMIT body).
bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
{
PCB_EDIT_FRAME* fr = pcbFrame();
@ -2302,11 +2267,11 @@ bool pcbCollabTestMoveBoardItem( std::string aId, int aDx, int aDy )
if( !testResolve( fr, aId ) )
return false;
// Re-resolve ON the fiber: a remote remove can apply between scheduling
// Re-resolve ON the coroutine: a remote remove can apply between scheduling
// and running, and doApplyItems FREES removed items — a captured pointer
// would be dangling and the commit would resurrect a deleted item
// (drift-trio S4 move-vs-delete). Vanished => the move loses, silently.
pcbjam_collab::runOnFiber( fr, [fr, aId, aDx, aDy]() {
pcbjam_collab::runOnCoroutine( fr, [fr, aId, aDx, aDy]() {
if( BOARD_ITEM* item = testResolve( fr, aId ) )
collabTestMove( fr, item, aDx, aDy );
} );
@ -2330,7 +2295,7 @@ std::string pcbCollabTestDuplicateBoardItem( std::string aId, int aDx, int aDy )
std::string id = toUtf8( dup->m_Uuid.AsString() );
pcbjam_collab::runOnFiber( fr, [fr, dup]() {
pcbjam_collab::runOnCoroutine( fr, [fr, dup]() {
BOARD_COMMIT commit( fr );
commit.Add( dup );
commit.Push( wxT( "Collab test duplicate" ) );
@ -2389,9 +2354,9 @@ std::string Pad_GetPinFunction(PAD* pad) {
return pad->GetPinFunction().ToStdString();
}
static bool kicadCollabFiberBusyProbe()
static bool kicadCollabBusyProbe()
{
return pcbjam_collab::fiberBusy() || !pcbjam_collab::fiberQueue().empty();
return pcbjam_collab::applyBusy() || !pcbjam_collab::applyQueue().empty();
}
EMSCRIPTEN_BINDINGS(pcbnew) {
@ -2436,13 +2401,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) {
function("kicadTestSetOpenPark", &kicadTestSetOpenPark);
function("kicadTestArmTimerPark", &kicadTestArmTimerPark);
function("kicadTestTimerParkState", &kicadTestTimerParkState);
function("kicadTestFiberParkStart", &kicadTestFiberParkStart);
function("kicadTestFiberParkPrime", &kicadTestFiberParkPrime);
function("kicadTestFiberParkPoke", &kicadTestFiberParkPoke);
function("kicadTestFiberParkState", &kicadTestFiberParkState);
function("kicadTestFiberParkStartSecond", &kicadTestFiberParkStartSecond);
function("kicadTestFiberParkPokeSecond", &kicadTestFiberParkPokeSecond);
function("kicadCollabFiberBusy", &kicadCollabFiberBusyProbe);
function("kicadCollabBusy", &kicadCollabBusyProbe);
// Read-only viewer lock (read-only-viewer).
function("kicadSetReadOnly", &kicadSetReadOnly);
// Yjs collaborative bridge entry points (same contract as pl_editor / eeschema).

View file

@ -18,7 +18,6 @@
#include <wx/window.h>
#include <nlohmann/json.hpp>
#include "open_gate.h"
#include "main_stack_runner.h"
#include "pcbjam_async_policy.h"
#include <eda_draw_frame.h>
#include <kiid.h>
@ -40,7 +39,7 @@ using json = nlohmann::json;
// File→Open.
bool kicadOpenFile( std::string path )
{
// Held across every Asyncify park of the load; see open_gate.h.
// Held across every suspension of the load; see open_gate.h.
pcbjam_open::BusyGuard busy;
if( pcbjam_open::testParkMs() > 0 )
@ -315,7 +314,7 @@ void addBlob( DS_DATA_MODEL& aModel, const json& j )
void kicadCollabApply( std::string aJson )
{
// Open-in-flight guard (open_gate.h): never touch the model while a
// kicadOpenFile Asyncify chain is parked mid-load; see kicadOpenFileBusy.
// kicadOpenFile chain is suspended mid-load; see kicadOpenFileBusy.
if( pcbjam_open::busy() )
return;

View file

@ -1,27 +1,24 @@
/*
* Test-only deterministic repro lever for the production board-load trap
* family ("index out of bounds" / "unreachable executed" in doRewind
* gal-refresh-timer investigation, docs/features/async/14-open-settle-gate.md
* lineage).
* Test-only deterministic lever: a SUSPENDING wx-timer handler running
* concurrently with the main loop's per-frame suspension (gal-refresh-timer
* investigation, docs/features/async/14-open-settle-gate.md lineage).
*
* The surviving hypothesis after the v0.1.19 crash log (2026-07-31): a wx
* timer callback is a FRESH JSwasm entry (emscripten_async_call
* TimerCallbackFunc::Run Notify). The main loop is Asyncify-parked in
* wxWasmYieldToBrowser for most of wall-clock time, so a timer handler that
* itself parks creates TWO live Asyncify contexts over the single-slot
* Asyncify.currData the emscripten #9153 family the handlesleep.js shim
* silently repairs. Add a fiber swap (the collab entries run on
* TOOL_MANAGER coroutines emscripten_fiber_swap, which bypasses the shim's
* allocateData accounting entirely) and the prod trap's exact second stack
* (doRewind finishContextSwitch under __asyncjs__wxWasmYieldToBrowser)
* becomes constructible on demand.
* A wx timer callback is a FRESH JSwasm entry (emscripten_async_call
* TimerCallbackFunc::Run Notify). The main loop spends most wall-clock time
* suspended in wxWasmYieldToBrowser, so a timer handler that itself suspends
* puts a second suspended activation in flight over the shared scheduler
* state the concurrency shape the scheduler's turnstile and the dispatch
* interlock must absorb. While the handler is suspended,
* wxWasmDispatchParked() reads true and every other due timer spins the 17 ms
* retry loop the [wx-timer] storm diagnostic window this lever opens on
* demand.
*
* Natural occurrences need a timer handler that parks mid-paint (GAL init /
* lib bridge) scheduler-dependent, never reproduced locally in 10+
* Natural occurrences need a timer handler that suspends mid-paint (GAL init
* / lib bridge) scheduler-dependent, never reproduced locally in 10+
* attempts. This lever makes the window deterministic: arm a one-shot wx
* timer whose Notify() emscripten_sleep()s for a fixed time; the e2e then
* hammers fiber-based collab entries through the window and asserts the
* runtime SURVIVES (red on a build where the hypothesis holds).
* hammers coroutine-based collab entries through the window and asserts the
* runtime SURVIVES.
*
* Production is unaffected: nothing fires unless kicadTestArmTimerPark is
* called.

View file

@ -13,7 +13,7 @@
# stays free to service RPC (bg_halt, status, vector reads) mid-simulation.
# The 4MB/2MB stacks are load-bearing: ngspice's parser overflows emscripten's
# default 64KB stack (found by the Gate-1 smoke; the CIDER deck died parsing
# spinit). ASYNCIFY stays off: nothing in the service suspends.
# spinit). Nothing in the service suspends, so no JSPI.
# PTHREAD_POOL_DELAY_LOAD: boot must not block on the pool — Firefox stalls
# nested-worker spawning under multi-page pressure (the SECOND simulator
# session in an e2e run hung forever in module boot without it); bg_run's
@ -76,4 +76,4 @@ target_link_libraries( ngspice_service PRIVATE
# basename) runs identically to a native install regardless of the prefix the
# dep build happened to use.
set_target_properties( ngspice_service PROPERTIES
LINK_FLAGS "-O2 -g0 --bind -pthread ${NGSPICE_EH_FLAGS} -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=NgspiceService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sPTHREAD_POOL_SIZE=4 -sPTHREAD_POOL_SIZE_STRICT=0 -sPTHREAD_POOL_DELAY_LOAD=1 -sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB --embed-file ${NGSPICE_SYSROOT}/share/ngspice/scripts/spinit@/ngspice/scripts/spinit" )
LINK_FLAGS "-O2 -g0 --bind -pthread ${NGSPICE_EH_FLAGS} -sMODULARIZE=1 -sEXPORT_NAME=NgspiceService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sPTHREAD_POOL_SIZE=4 -sPTHREAD_POOL_SIZE_STRICT=0 -sPTHREAD_POOL_DELAY_LOAD=1 -sSTACK_SIZE=4MB -sDEFAULT_PTHREAD_STACK_SIZE=2MB --embed-file ${NGSPICE_SYSROOT}/share/ngspice/scripts/spinit@/ngspice/scripts/spinit" )

View file

@ -54,18 +54,18 @@ target_link_options( occ_service PRIVATE
"LINKER:--no-whole-archive"
)
# Persistent worker embind module: no asyncify (neither OCC job suspends).
# Persistent worker embind module: neither OCC job suspends, so no JSPI.
# -Oz at link runs wasm-opt -Oz = whole-module dead-code elimination that
# strips the unreachable editor code (same mechanism sym_convert documents
# in eeschema/CMakeLists.txt); -g0 drops debug info so in-container
# finalize doesn't OOM. MODULARIZE factory booted by the JS provider inside
# a dedicated Worker; node kept in ENVIRONMENT so unit tests can drive it.
# These override the browser-oriented values inherited from
# CMAKE_EXE_LINKER_FLAGS (notably -sASYNCIFY=1). The inherited FULL pthread
# pool ('navigator.hardwareConcurrency') is kept deliberately: the exporter
# CMAKE_EXE_LINKER_FLAGS. The inherited FULL pthread pool
# ('navigator.hardwareConcurrency') is kept deliberately: the exporter
# uses GetKiCadThreadPool (step_pcb_model.cpp), and a browser cannot spawn
# workers on demand while the calling thread is blocked inside occExport —
# an undersized pool deadlocks in Chromium.
# --pre-js supplies the wxConfig JS hooks backed by an in-memory store.
set_target_properties( occ_service PROPERTIES
LINK_FLAGS "-Oz -g0 -sASYNCIFY=0 -sMODULARIZE=1 -sEXPORT_NAME=OccService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/occ_service_pre.js" )
LINK_FLAGS "-Oz -g0 -sMODULARIZE=1 -sEXPORT_NAME=OccService -sENVIRONMENT=worker,node -sEXIT_RUNTIME=0 --pre-js ${CMAKE_CURRENT_SOURCE_DIR}/occ_service_pre.js" )

Some files were not shown because too many files have changed in this diff Show more