pcbjam/docs/features/async/migration-evidence/wf-result-11.json
Viktor Vaczi 9c475a804e 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
2026-08-14 09:25:32 +02:00

93 lines
No EOL
27 KiB
JSON

{
"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."
]
}