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

@ -3,7 +3,7 @@
// third copy: symlink standalone/public/wasm -> the synced artifact dir
// (tests/apps/kicad, populated by tests/scripts/setup-kicad-wasm.sh). Vite then
// serves them at /wasm from the app's own origin — required because KiCad WASM
// (Asyncify build, COEP/cross-origin-isolated document) refuses to load its
// (a COEP/cross-origin-isolated document) refuses to load its
// glue/wasm from a different origin.
//
// No bytes are duplicated; the link points straight at the synced dir.

View file

@ -516,7 +516,7 @@ function installQuitHook(
// there instead of leaving the editor.
//
// Defer the navigation out of the wasm callback: this fires from inside the
// frame's C++ destructor (via EM_ASM under Asyncify), and the teardown keeps
// frame's C++ destructor (via EM_ASM), and the teardown keeps
// running after we return. A cross-document location.assign() started here is
// aborted by that continuing teardown — so hand it to a fresh task once the
// wasm stack has unwound.
@ -1251,18 +1251,15 @@ export function WasmTool({
append(`[fatal] ${kind}: ${msg}`);
append(dumpTrace());
// The scheduler flight recorder: event ring + wait/activation state at
// death — the targeting data for suspension-machinery traps. Canonical
// name is __wxWaitDump (jspi-scheduler); __wxAsyncifyDump is the legacy
// shim's name, kept as a fallback one release. The jspi dump is an
// object, the legacy one a string — normalize.
// death — the targeting data for suspension-machinery traps
// (__wxWaitDump, jspi-scheduler.js).
const dumper = (
window as Window & {
__wxWaitDump?: () => unknown;
__wxAsyncifyDump?: () => unknown;
}
);
const rec = (dumper.__wxWaitDump ?? dumper.__wxAsyncifyDump)?.();
if (rec) append(typeof rec === "string" ? rec : JSON.stringify(rec));
const rec = dumper.__wxWaitDump?.();
if (rec) append(JSON.stringify(rec));
setFatal(msg);
setShowLog(true);
// Arm the React-independent floor too: it stays invisible while our
@ -1279,7 +1276,7 @@ export function WasmTool({
if (!isTerminalError(e.reason, msg)) return;
promote("unhandled rejection", msg);
};
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every asyncify
// With PROXY_TO_PTHREAD, main()/wx/timers — and therefore every wasm
// trap in this family — throw INSIDE a pthread worker. A worker's uncaught
// error fires an ErrorEvent on the Worker OBJECT, never on `window`, so the
// two listeners below can't see the very traps this overlay exists for
@ -1802,7 +1799,7 @@ export function WasmTool({
// Everything below drives BARE embind entries that walk the loaded
// model (collab snapshot/adopt, presence bind, drift). Deferred until
// the open chain settled (openResult) — calling them while the
// kicadOpenFile Asyncify chain is still parked mid-load walks a
// kicadOpenFile activation is still parked mid-load walks a
// half-built model and traps ("indirect call signature mismatch").
const attachCollabAndPresence = async () => {
// Drift detection: while a sheet is collaboratively edited, periodically (every N
@ -1886,7 +1883,7 @@ export function WasmTool({
// STAGGERED out of the settle window (2026-08-02, the ladder
// result): warm sibling-heavy projects crash at exactly this moment
// (V1/V4 fail 2/2 warm, V2/V3 without siblings never do), and the
// asyncify flight recorder places the fatal interleave inside the
// scheduler flight recorder places the fatal interleave inside the
// settle-time wake windows. The restage's room connects + restage
// fetches were the only sibling-specific traffic contending with
// those windows. Nothing here is needed for first paint — the boot

View file

@ -91,9 +91,10 @@ export function initErrorReporting(): void {
// requestAnimationFrame / addEventListener in try/catch to attach
// better stack traces. KiCad-on-Emscripten drives its main loop
// through exactly those (emscripten_async_call → setTimeout, and the
// GAL refresh timer re-arms in a tight loop), so wrapping them adds a
// closure per tick and changes stack identity in the middle of an
// Asyncify-transformed call graph. Not worth it for prettier traces.
// GAL refresh timer re-arms in a tight loop), so wrapping them adds
// a closure per tick on the hottest paths for marginal gain. (Under
// JSPI stack traces are not mangled, so source-map symbolication may
// now be worth revisiting — behavior kept as is.)
i.name !== "BrowserApiErrors" && i.name !== "Breadcrumbs",
),
// Keep breadcrumbs — clicks, navigation and fetches are exactly the "what

View file

@ -51,15 +51,15 @@ export function hasWritableLib(lists: Iterable<LibInfo[]>): boolean {
* `var Module` and publishes `FS`/`wxElementRegistry` onto `window` exactly the
* surface the iframe approach used, only now in the top-level window.
*
* Two browser facts make running in the main document (rather than at /wasm/...)
* work without touching the build:
* - `locateFile` is overridden to resolve `<base>/<file>`, so the .wasm and the
* pthread worker script are fetched from the asset dir regardless of the
* SPA route the user is on.
* - `mainScriptUrlOrBlob` pins the pthread worker to `<base>/<tool>.js`. For a
* same-origin base that's the URL directly; for a cross-origin CDN base it's
* a same-origin blob shim that importScripts the glue (see
* `pthreadWorkerScript` `new Worker(<cross-origin URL>)` is illegal).
* Running in the main document (rather than at /wasm/...) works without touching
* the build because `locateFile` is overridden to resolve `<base>/<file>`, so
* the .wasm and other assets are fetched from the asset dir regardless of the
* SPA route the user is on. Pthread child workers spawn from `_scriptName`
* (the glue's own absolute URL, captured when `<tool>.js` executes), so a
* same-origin asset base just works. KNOWN GAP: a cross-origin (CDN) base
* cannot spawn the pthread workers `new Worker(<cross-origin URL>)` is a
* SecurityError, and emscripten 6 ignores `mainScriptUrlOrBlob` see
* docs/features/async/23-jspi-runtime.md.
*
* Single-instance: the build owns process-global state (one `Module`, one wasm
* memory) so only ONE tool can run per page load. A second boot switching
@ -154,17 +154,18 @@ function loadScript(src: string): Promise<void> {
}
/**
* The pthread worker "script" passed as `Module.mainScriptUrlOrBlob`. KiCad's
* pthreads spawn CLASSIC workers via `new Worker(...)` (see `<tool>.js`
* `allocateUnusedWorker`):
* - SAME-ORIGIN base the plain URL string (the proven local/dev path).
* - CROSS-ORIGIN base (the CDN) a SAME-ORIGIN `blob:` worker that
* `importScripts()` the cross-origin glue. `new Worker(<cross-origin URL>)`
* is a SecurityError, but a `blob:` URL inherits the page origin (legal),
* and a classic worker's `importScripts` MAY load a cross-origin script when
* the CDN sends `Cross-Origin-Resource-Policy: cross-origin` (needed because
* the page is COEP `require-corp`). The `.wasm`/`images.tar.gz` fetches just
* need `ACAO` + `CORP` on the CDN. See docs/features/demo-deploy/0001-*.
* DORMANT nothing consumes this today. It built the value once passed as
* `Module.mainScriptUrlOrBlob`, which emscripten 6 ignores: pthread child
* workers spawn from `_scriptName` instead, which is same-origin-only. Kept
* because the cross-origin (CDN) pthread fix a KNOWN GAP, see
* docs/features/async/23-jspi-runtime.md will need exactly this plumbing:
* a SAME-ORIGIN `blob:` worker that `importScripts()` the cross-origin glue.
* `new Worker(<cross-origin URL>)` is a SecurityError, but a `blob:` URL
* inherits the page origin (legal), and a classic worker's `importScripts` MAY
* load a cross-origin script when the CDN sends
* `Cross-Origin-Resource-Policy: cross-origin` (needed because the page is
* COEP `require-corp`). The `.wasm`/`images.tar.gz` fetches just need
* `ACAO` + `CORP` on the CDN. See docs/features/demo-deploy/0001-*.
*/
/**
* C++ diagnostics that must reach the BROWSER console, not just the in-page log.
@ -485,9 +486,10 @@ async function doBoot(opts: BootOptions): Promise<void> {
}
};
// preRun (seeding tools only): suppress the first-run setup wizard, whose modal
// loop crashes Asyncify in our ephemeral MEMFS. Make all settings providers
// report NeedsUserInput()==false — the wizard's "use defaults" path.
// preRun (seeding tools only): suppress the first-run setup wizard — its modal
// loop is unsupported on our ephemeral MEMFS (nothing it writes would survive
// a reload, and the wizard would re-run every boot). Make all settings
// providers report NeedsUserInput()==false — the wizard's "use defaults" path.
const seedKicadConfig = () => {
const FS = moduleFS();
FS.mkdirTree(KICAD_CONFIG_DIR);
@ -618,9 +620,6 @@ async function doBoot(opts: BootOptions): Promise<void> {
},
// Resolve wasm + pthread worker against the asset base, not the SPA route.
locateFile: (path: string) => `${base}/${path}`,
// Pin the pthread worker script. Same-origin → direct URL; cross-origin CDN
// → a same-origin blob shim that importScripts the glue (see helper above).
mainScriptUrlOrBlob: pthreadWorkerScript(base, bundle, traceMask),
// Own the wasm fetch so we can report download progress (see helper). Streams
// straight into the compiler; passing `module` to the callback lets emscripten
// share it with the pthread workers (this hook fires on the main thread only).

View file

@ -125,10 +125,10 @@ export function startDriftDetection(opts: DriftDetectOptions): DriftDetector {
// unchanged since the last report, or the session cap is spent). Being fully
// synchronous is what lets the session-end check finish during page unload.
function computeDrift(): DriftReportBody | null {
// Defer while collab fiber work is in flight (0008 finding #10b): a save
// on the bare embind stack during a parked apply fiber mis-dispatches
// Defer while collab apply work is in flight (0008 finding #10b): a save
// on the bare embind stack during a parked apply coroutine mis-dispatches
// (table index OOB). The next Y-update trigger retries.
const busy = (opts.mod as { kicadCollabFiberBusy?: () => boolean }).kicadCollabFiberBusy;
const busy = (opts.mod as { kicadCollabBusy?: () => boolean }).kicadCollabBusy;
if (busy?.()) return null;
save(scratchPath);
let text: unknown;

View file

@ -96,8 +96,9 @@ export const TOOL_FRAME: Record<Tool, string | undefined> = {
* Every standalone tool here boots through common/single_top.cpp, which runs
* STARTWIZARD::CheckAndRun() the first-run "KiCad Setup" wizard. It shows
* whenever any provider (SETTINGS / LIBRARIES / PRIVACY) reports
* NeedsUserInput(), which is always true on our ephemeral MEMFS with no config,
* and its modal loop crashes Asyncify. So for all of them we seed a default
* NeedsUserInput() always true on our ephemeral MEMFS with no config, where
* the wizard's modal loop is unsupported (nothing it writes survives a reload,
* so it would re-run every boot). So for all of them we seed a default
* KiCad config before main() (kicad_common.json privacy flags + the lib-tables
* the providers check) so NeedsUserInput() is false and the wizard is skipped.
*/

View file

@ -390,8 +390,9 @@ export function installLibsProvider(
try {
switch (op) {
case "list": {
// Enumerate gate (load-fanout): park this crossing (Asyncify) until
// the caller's precondition — typically the presync — has settled.
// Enumerate gate (load-fanout): park this crossing (the suspended
// C++ caller stays parked) until the caller's precondition —
// typically the presync — has settled.
if (opts?.enumerateGate) await opts.enumerateGate(kind);
// Each plugin parses its own key: footprints / symbols.
const key = kind === "footprint" ? "footprints" : "symbols";
@ -402,7 +403,7 @@ export function installLibsProvider(
// Bracket the whole-library hydrate so the editor can overlay a
// "loading libraries (slow, not hung)" state over the otherwise
// silent multi-second freeze. `true` before the (async) IDB read so
// the overlay can paint while the C++ side is Asyncify-suspended;
// the overlay can paint while the C++ side is suspended;
// `false` once the bytes are framed and about to cross the bridge.
clearTimeout(fatResetTimer);
if (fatTotal === 0) {

View file

@ -12,7 +12,7 @@
// Deliberately NOT here (S4 territory): worker-side interlock precision (a
// mutator can still land while a NON-open chain is parked — those entries keep
// their own C-side guards until S4 moves queuing into the bindings), and any
// change to drift-detect's skip-if-fiber-busy logic (#10b semantics, its own
// change to drift-detect's skip-if-collab-busy logic (#10b semantics, its own
// deliberate contract).
interface BusyProbe {

View file

@ -10,7 +10,7 @@ import { resolveWasmBase } from "./wasm-assets";
* kicad_editor.wasm carries no ngspice: eeschema's NGSPICE class binds to the
* sharedspice client stub (wasm/stubs/sharedspice_client.cpp), whose
* EM_ASYNC_JS bridges suspend the editor and land here. The ngspice_service
* module (own emscripten instance, pthreads, `-sASYNCIFY=0`) boots in a
* module (own emscripten instance, pthreads, no suspension backend) boots in a
* dedicated Worker on the FIRST request a session that never opens the
* simulator never fetches it.
*

View file

@ -18,7 +18,7 @@ import { resolveWasmBase } from "./wasm-assets";
* the SCENEGRAPH serialized in KiCad's binary cache format, which the
* C++ stub rebuilds with S3D::ReadCache.
*
* The occ_service module (own emscripten instance, `-sASYNCIFY=0`) boots in a
* The occ_service module (own emscripten instance, no suspension backend) boots in a
* dedicated Worker on the FIRST request a pcbnew session that never exports
* and never views STEP models never fetches it. Same cross-origin worker rules
* as the pthread workers (boot.ts): a same-origin blob wrapper importScripts

View file

@ -5,7 +5,7 @@ import { openFileInTool } from "./open-flow";
* The programmatic-open settle gate (open_gate.h / kicadOpenFileBusy): the
* shell must not report the open finished and so must not go on to drive
* bare embind entries (collab snapshot, presence bind) while the
* kicadOpenFile Asyncify chain is still parked mid-load. Regression tests for
* kicadOpenFile activation is still parked mid-load. Regression tests for
* the prod "indirect call signature mismatch" trap at board load.
*/
@ -44,7 +44,7 @@ function makeWin(opts: {
Module: {
kicadOpenFile: (p: string) => {
opened.push(p);
return false; // asyncify placeholder return — callers must ignore it
return false; // non-Promise return — readiness comes from the settle probe, not this value
},
...(opts.busy ? { kicadOpenFileBusy: opts.busy } : {}),
},

View file

@ -99,17 +99,16 @@ function hasProgrammaticHook(win: ToolWindow): boolean {
}
/**
* Invoke the programmatic hook. kicadOpenFile suspends mid-load either way:
* under JSPI it is an embind async() export and returns a real Promise for the
* whole load chain; legacy asyncify builds return a falsy placeholder. The
* Invoke the programmatic hook. kicadOpenFile is an embind async() export: it
* suspends mid-load and returns a real Promise for the whole load chain. The
* caller contains the Promise's rejection and gates readiness on the settle
* probe, which is truthful for both shapes.
* probe.
*/
function invokeProgrammaticOpen(
win: ToolWindow,
absPath: string,
log: (m: string) => void,
): unknown {
): Promise<unknown> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mod = win.Module as any;
const ret = mod.kicadOpenFile(absPath);
@ -143,10 +142,10 @@ function inputDialogVisible(win: ToolWindow): boolean {
}
/**
* Wait until the kicadOpenFile Asyncify chain has TRULY completed.
* Wait until the kicadOpenFile load chain has TRULY completed.
*
* kicadOpenFile suspends and unwinds back to JS long before the load finishes;
* for the whole load the chain stays parked mid-mutation of the board/schematic.
* for the whole load its activation stays parked mid-mutation of the board/schematic.
* Any bare embind entry that walks the model during such a park (collab
* snapshot, presence bind) can virtual-dispatch through a half-built item and
* trap with "indirect call signature mismatch" the same reentrancy class the
@ -209,7 +208,8 @@ export async function openFileInTool(
// embind only registers the hook during runtime init — which lands AFTER the
// Emscripten FS is ready, i.e. after driveProjectIntoTool calls us. Probing
// the hook before the frame exists therefore always missed and fell back to
// UI automation (and the wizard's modal loop then crashed Asyncify). Waiting
// UI automation (and the wizard's unsupported modal loop then killed the
// load — see boot.ts seedKicadConfig). Waiting
// for a visible Frame guarantees the runtime is initialized, the hook is
// registered, and a top window exists — so we probe only after this point.
const ready = await waitFor(

View file

@ -1,18 +1,13 @@
/**
* N5 flood/fairness unit gates for the scheduler shim
* (docs/features/async/17 §3d N5; the shim source is scripts/common/shims/
* jspi-scheduler.js the JSPI-era successor of asyncify-scheduler.js
* loaded here against a fake runtime surface).
* jspi-scheduler.js, loaded here against a fake runtime surface).
*
* Doc 06 §starvation: FIFO by default; a stimulus flood must neither reorder
* deliveries nor starve them, and the time-boxed pump must not monopolize the
* thread in one burst. These are unit gates the e2e batteries cover the
* same machinery under the real runtime.
*
* Retired with the asyncify shim (states unrepresentable under JSPI):
* deferred wakes (readyWakes/_scheduleWakeDrain), currData single-writer
* tripwire, state() machine string. The S4 wait-registry gates below are the
* JSPI-era additions.
* same machinery under the real runtime. The S4 wait-registry gates below
* cover the token-wait contract the C++ bridges rely on.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { readFileSync } from "node:fs";
@ -25,7 +20,6 @@ const SHIM_PATH = path.resolve(
);
type SchedulerShape = {
backend: string;
mailbox: unknown[];
mutatorQueue: unknown[];
mutatorsDelivered: number;
@ -70,14 +64,14 @@ function loadShim(opts: { busy: () => boolean }) {
return S;
}
describe("N5: scheduler shim under flood (jspi backend)", () => {
describe("N5: scheduler shim under flood", () => {
beforeEach(() => {
vi.useRealTimers();
});
it("identifies as the jspi backend", () => {
it("installs the scheduler exactly once", () => {
const S = loadShim({ busy: () => false });
expect(S.backend).toBe("jspi");
expect(S).toBeTruthy();
expect(globalThis.__wxSchedulerInstalled).toBe(true);
});