# 17 — Mailbox/scheduler implementation plan (Design B, post-August revision) > **Status: PLAN (2026-08-05), not started.** Supersedes the *phasing* of > [`12`](12-design-b-asyncify-implementation-plan.md)/[`13`](13-design-b-engineering-spec.md) > with what we learned July–August; the architecture is unchanged from [`06`](06-design-b-fiber-first-runtime.md). > The mailbox = doc 12's `readyQueue + drain()`. Companion visual: the "context switches & > collisions" dossier artifact (fig 3 = target architecture). ## 1. Why reopen this — what changed since June Doc 13 §6f closed the scheduler question: "no Phase-1 scheduler nor Phase-2 root fiber was needed — the while-loop main loop + the pre-existing currData shim suffice." That verdict is overturned by the July–August record. Since it was written we shipped, one collision at a time: - the wx **dispatch interlock** (`wxWasmDispatchDepth` + save/zero/restore around every pump park), - the timer **17 ms park-retry** + retry-storm telemetry, - the **open-settle gate** (`BusyGuard`/`kicadOpenFileBusy`/`waitForOpenSettled` + `pcbjam_open::busy()` refusals), - the **fiber-busy** (`kicadCollabFiberBusy`) and **enumerate** gates, - three **libcontext refusal layers** (`jump-refused-parked`, consume-once, quarantine, ghost-epoch), - the **512 KB** per-fiber buffer bump, - and finally v0.1.28's **schedule-don't-dispatch** fix (`wxWasmScheduleProcessEvents` → `wxWasmTopLevelTick`) for the 68/1 board-load killer. [`16`](16-fiber-resume-guard.md) calls guard-layer iteration "at its ceiling." Every one of those guards is a hand-rolled, site-specific "defer this stimulus until safe" — a scattered mailbox built one trap at a time. This plan builds the real one. **Two constraints, both empirically proven in June (do not relitigate):** 1. **Queue transitions, not calls.** A FIFO of *calls* head-of-line blocks when a handler parks (open, ShowModal, clipboard all do); a *permanent* park deadlocks fiber swaps outright (13 §6b: "cannot stop an async operation in flight"). The queue admits many parked contexts and serializes only unwind/rewind transitions — which forces parkable handlers onto fibers. 2. **All writers or none, per subsystem.** A stray `currData` writer behind the scheduler corrupts silently (12 §risks). Migration is per-subsystem cutover with dev-build assertions on stray writes — never gradual softening of a guard while its collision partner still exists. Also carried forward: **the root/scheduler context must never itself await JS** (13 §6b), and **`ProcessEvents` must be driven wasm-side**, not via `await ccall(...,{async:true})` (Emscripten #13302; 12 §prior-art). v0.1.28's fresh-task tick is the first brick of exactly that. > **S1 lesson (2026-08-05, learned red-first):** the #13302 boundary constrains the MAILBOX > too — delivering messages from inside pump-driven `ProcessEvents` puts a fiber-swapping > handler inside the modal pump's awaited ccall and traps `unreachable` > (`coroutine-nested` / `fiber_create_run_destroy_inside_modal`). Deliveries must enter via > a dedicated plain export (`wxWasmMailboxTick`, self-armed from the shim, 17 ms retry while > the interlock is held) — the mailbox changes *when* a message runs, never the kind of > stack it runs on. This constraint binds every future lane (DOM input, embind) until S3 > removes the awaited-ccall pumps entirely. ## 2. Cutover mechanics - **Build flag `WX_SCHEDULER=1`** producing parallel glue variants, same pattern as `races_test_noheal`/`races_test_nosleepfix`: during migration CI runs both runtimes; the legacy build stays the shippable fallback until step S5. - **Guards become tripwires before they become dead code.** At each step, the superseded guard is *kept* but its firing beacon is asserted to be **zero** across the fuzz/e2e suite. A guard that still fires means the mailbox missed a path. Only a guard proven silent for a full step gets deleted (S5). - **Observability stays on throughout:** `fcsTotal`/`rootHotTotal`, the flight recorder, and `window.__wxAsyncifyDump()` are migration instruments; `rootHotTotal == 0` is a standing assertion from S1 onward. - Injection: lean **emscripten `--js-library`** for the scheduler (13 §7 open decision — resolve in S2; post-link injection stays the fallback since `inject-dyncall-shims.sh` already works). ## 3. Test inventory and fate The dividing line: **outcome-level tests are the regression net and must stay green at every step; mechanism-level tests are migration collateral** — each is retired or rewritten *at the step that removes its mechanism*, never before. ### 3a. KEEP UNCHANGED (the net) | Suite | Why it survives | |---|---| | Full `tests/kicad/` app e2e (~60 specs: `load-pcb`, `eeschema*`, `pcbnew*`, `*-collab`, `presence-*`, `drift-trio*`, `contextmenu-*`, `import-settings-modal-stack`, `3d-viewer*`, `gerbview*`, `roundtrip`, `save-hook`, perf + screenshot baselines) | Assert user-visible outcomes only. These are the gate for **every** step. | | Standalone wx app battery (~70 apps under `tests/apps/standalone/`: `dialog`, `menu`, `popup`, `contextmenu`, `timer`, `clipboard`, `fontenum`, `filedialog`, `wizard`, `raytrace-modal`, `raytrace-threads`, `threadpool*`, …) | Outcome-level wx behavior; the June regressions (coroutines vs menus, 13 §6d) were caught here — keep all. | | `coroutine` / `coroutine-nested` / `coroutine-pthread` | The historical red gate for exactly this work (12 §phase-0). | | `asyncify-races` scenarios asserting **semantics**: `modal_in_modal_in_modal` (LIFO nesting is a semantic — it outlives the resolver-stack mechanism), `out_of_order_sleep_resolution`, `sleep_inside_fiber_inside_modal`, `post_park_fiber_swap`, `long_parked_sleep_clobbered_by_swap`, `wakeup_during_transition`, `unwind_through_promise` | Scenario *intent* is architecture-independent. Harness internals may need touch-ups per step, but the pass criteria stand. | | `tests/web/fatal-overlay.spec.ts`, web suite, `open-flow` outcome cases (settle wait, dialog escape) | Containment and UX-level behavior are orthogonal to the runtime rewrite. | ### 3b. KEEP, BUT REWRITE THE ASSERTION TARGET (drop→deliver flips) These currently assert that a guard **refused/dropped/no-op'd** something. Under the mailbox the same stimulus is **deferred and delivered** — a strictly stronger guarantee, so the expected outcome flips: | Spec | Today asserts | Becomes | |---|---|---| | `kicad/fiber-resume-park.spec.ts` | mid-park `Resume()` is **refused** (`jump-refused-parked` beacon), parked body completes | the resume **message is queued** and the event is **delivered after** the park completes — nothing refused, nothing lost | | `kicad/collab-load-fuzz.spec.ts` | open gate engages, collab entries **no-op**, gate releases | collab/presence messages **queue behind open** and apply afterwards without re-issuing (assert post-open presence bind succeeds) | | `kicad/timer-park-repro.spec.ts` | runtime survives a parking timer; `[wx-timer]` retry beacons appear | runtime survives; timer notify is delivered exactly once, in order; **zero** retry beacons | | `asyncify-races` `nested_quasi_modal_pump_error` | pump rejection doesn't leak the parked `DoRun` | (S4, when the pump dies) wait-resolution **error path cancels the modal and releases the context** — same invariant, no pump | The drop→deliver flip is also a **product change** (events stop being silently swallowed during loads) — note it in the release notes when S4 lands. ### 3c. CANNOT KEEP (assert deleted machinery — retire at the step shown) | Test | Pins | Retires at | |---|---|---| | `races_test_noheal` / `races_test_nosleepfix` builds + their "shim-redundancy pins" specs | the legacy `handlesleep.js`/trampoline-heal shims | S2 — replaced by scheduler-ownership pins (same ablation idea, new subject) | | `open-flow.test.ts` busy-**polling** unit cases (stuck-busy timeout, legacy title-heuristic fallback) | `kicadOpenFileBusy` polling as the settle protocol | S4 (open migration) — open becomes promise/message-based; keep the exported busy probe itself for old-wasm compat (see §6) | | Assertions on interlock diagnostics (`[wx-dispatch] ERASED/NEGATIVE`, depth-restore sites) wherever specs grep for them | the dispatch interlock | S5 | | v0.1.28 tick-error-path specifics (popping `_wxNestedLoopExit` in the `setTimeout` catch) | the pump-driven nested loop | S4 | ### 3d. NEW TESTS (written before the step they gate) - **N1 · Single-writer tripwire** — dev-build assertion that any `currData` write outside a scheduler transition aborts loudly; plus a meta-spec that *introduces* a stray writer (ablation-style) and expects the abort. Gates S2. - **N2 · Message ordering** — enqueue open, then collab/presence/drift messages; assert applied in order after open completes, none lost, none duplicated. Gates S1/S4. - **N3 · No head-of-line blocking** — with an open/modal fiber parked, an input message is processed within a frame-budget bound (this is the regression test for constraint 1). Gates S4. - **N4 · Wake-never-rewinds** — a promise resolving during a live transition only enqueues; generalization of `wakeup_during_transition` to fiber wakes. Gates S2. - **N5 · FIFO fairness under flood** — old parked context is not starved by a stimulus storm (06 §starvation). Gates S2. - **N6 · World-visibility reentrancy** — curated cases where C++ assumed "nothing changes while I block": model mutation landing while `ShowModal` is parked, sibling file restage during a parked open. Driven by the S1 embind audit (§6). Gates S4. - **N7 · EH matrix** — the whole gate under `-fexceptions` **and** `-fwasm-exceptions`, including a modal opened from inside a `catch` (HoistCppCatches composition, 12 §risks). Gates every step. - **N8 · Warm-load pressure** — the Leonardo recipe (REPRO_PROFILE + ≥120 MB ballast, 2–4 warm loads) as a CI-able job asserting `rootHotTotal == 0` and zero trap signatures. Standing from S1. - **N9 · Pthread interplay** — `raytrace-modal` multi-core under the scheduler; unparking the doc-11 single-core limitation is a *stretch goal*, not a gate — but the suite must not regress. ## 4. Implementation steps Each step: gate = full 3a net green (3 engines where applicable) + the step's own tests + N7/N8; rollback = the `WX_SCHEDULER=0` build + a per-step tag. - **S0 · Baseline + scaffolding (2–3 d).** Tag current mains. CI matrix runs both EH models. Add beacon-count extraction to the fuzz/e2e harness (guard-firing counters per run). Stand up the `WX_SCHEDULER` dual-glue build. Write N2 (red), N8. > **Work log 2026-08-05 — S0 mostly landed** on `feature/async-mailbox`: > local `mailbox-s0-baseline` tags in all 6 repos; `WX_SCHEDULER=1` injector path + > `scripts/common/shims/asyncify-scheduler.js` (observation-only skeleton, build marker > `[wx-scheduler] scaffolding installed`; legacy shim stays authoritative until S2; > remember the `.ci-cache-epoch` bump when shim behavior changes); > `tests/kicad/utils/guard-beacons.ts` (per-family counts, occurrence-recovery for > rate-limited beacons, `expectGuardsSilent`, `parseAsyncifyCounters`); > `REPRO_ASSERT=1` in `apps/tests/tools/repro-board-load.ts` (N8: fails on unsettled load, > missing recorder, `rootHotTotal>0`, trap signatures); > `tests/kicad/mailbox-ordering.spec.ts` (N2, `test.fixme` red — add-then-move ordering > probe; un-fixme at S1). **Still open in S0:** the CI workflow matrix for both EH models. - **S1 · Mailbox front-end (≈1 wk).** One queue, drained by `wxWasmTopLevelTick`. Route into it: > **Work log 2026-08-05 — timers routed, dual battery green.** JS FIFO in the shim; > `wx/wasm/private/mailbox.h` + drain in `evtloop.cpp`; `timer.cpp` enqueues on scheduler > builds (runtime-gated on the shim marker; legacy path untouched; 17 ms retry kept as > tripwire). Delivery enters via the dedicated plain export `wxWasmMailboxTick` (see the > S1 lesson above — the first pump-embedded delivery design trapped in `coroutine-nested` > and was fixed red→green). Battery on BOTH variants: wx-chromium 28/28 (timer, dialog, > dialogs, contextmenu, popup) + coroutine-firefox 39/39 (incl. raytrace multicore) + > asyncify-firefox 7/7. App-side `WasmMailbox` wrapper (web/standalone/src/wasm/mailbox.ts, > 7 vitest green) keys on the proxy-safe `kicadOpenFileBusy` probe — PROXY_TO_PTHREAD makes > the window blind to the worker-side interlock; precision moves worker-side at S4. > **Still open in S1:** DOM-input lane, wiring the wrapper into the 14 production mutators > (needs the kicad wasm build + e2e), N2 un-fixme, beacon-silence assertions in fuzz runs. > > **Work log 2026-08-05 (later) — S1 COMPLETE except the CI matrix (deferred to the end > by request).** Wheel lane: parked wheel ticks enqueue a replay instead of dropping > (`app.cpp`; keys/mouse were already queue-shaped via `wxPostEvent`). Embind lane: the > shim wraps the doc-18 production mutators at the Module boundary — busy-window calls > queue and deliver post-settle via a time-boxed (8 ms/16 ms), unkillable pump; not-busy > calls stay byte-compatible sync. Fresh warm-cache docker `kicad_editor` build carries > the C lanes; staged into the harness. Verified: wx dual battery 30+39+7 on BOTH glue > variants; kicad trio dual-contract — legacy (drop semantics + expected retry storms) and > scheduler (N2 delivery/order green, fuzz deliver-flip green, `timerRetry` silence > tripwire ARMED and green = zero retry storms on the C-lane build). Fuzz's deterministic > gate caps queued hammer calls on the scheduler lane (6 iters) — the unbounded replay is > a debug-build assert-flood benchmark (`PCB_VIA::GetWidth` × 800 vias × 170k console > lines), not an ordering test; volume stays in the stress test. Two more recorded traps: > the injector idempotence marker collided with evtloop's EM_JS probe text (sentinel now > `__WX_SCHEDULER_SHIM_SOURCE__`), and a pump whose setTimeout chain can die wedges the > queue forever — the pump body is now exception-proof with a `[wx-scheduler]` beacon. timer `Notify` (replacing the direct callback body; the 17 ms retry stays as tripwire), DOM input (formalizing today's `wxPostEvent`/`CallAfter` deferrals), and a JS-side wrapper for **mutating** embind entries (enqueue + returned promise). Deliverable alongside: the **sync embind audit** (§6). Guards all stay. **Gate:** net green; timer-retry and open-gate/fiber-busy refusal beacons ≈ 0 across fuzz (they may still fire for pump-driven paths — record the residual and its source); N2 green for the wrapped entries. - **S2 · Scheduler core (1–2 wk).** `handlesleep.js` → `asyncify-scheduler.js` (13 §1): context registry, deferred drain with explicit transition-completion signals (wrap `_asyncify_stop_rewind`/`maybeStopUnwind`, never JS `finally`), fiber tracking at `emscripten_fiber_swap` (`fiber+20` buffers), trampoline ownership. **Gate:** races battery green **with the legacy shim ablated**; N1/N4/N5 green; libcontext refusal beacons ≈ 0. > **Work log 2026-08-05 — S2 core LANDED and gated.** The scheduler shim now REPLACES > handlesleep.js on WX_SCHEDULER=1 builds (injector either-or; the S1 "append-after" > ordering is gone). Ported name-identical: capture/restore, wake-window flags > (`__wakingRoot` is read by libcontext EM_JS!), consume-once/quarantine fiber guard, > counters, flight recorder, trampoline heal (external catch-reset wrap). NEW: > **deferred wakes** — a sleep wake arriving mid-transition (state≠Normal or trampoline > live) queues and drains from a clean macrotask (the aliased-wake class is now > structural, not detective); **N1 accessor** — `Asyncify.currData` is a property with a > single-writer tripwire (pure-JS writes need scheduler authorization; wasm-frame writes > pass; strict mode throws), meta-tested by introducing a stray. > **Gates all green:** asyncify-firefox 9/9 (7 races + new N1 meta + N4 books — on glue > with NO legacy shim, so the redundancy pins are now subsumption pins), coroutine 39/39, > wx-chromium 30/30, kicad trio 3/3 on the C-lane build. Injector legacy path verified > unchanged. **Deliberately left for S3:** the formal ctx-Map registry + park/resume > methods (stubs that throw), wasm-side ProcessEvents; **open:** N5 flood spec, both-EH > matrix (CI, deferred). Build-system note: `build-wasm-test.sh` only re-injects freshly > relinked apps — a variant flip without C changes needs the strip+reinject converter > (one-shot python in the work log commit) or a clean build. - **S3 · Root context + wasm-side ProcessEvents (≈1 wk).** The tick resumes the root context which calls `ProcessEvents` on the wasm side — the `await ccall(...,{async:true})` boundary (#13302) is removed. The root context never awaits JS (13 §6b). **Gate:** net green with emphasis on `contextmenu*`, `menu`, `popup`, coroutine apps (the June regression pair, 13 §6d, must both stay green simultaneously). > **Work log 2026-08-05 — S3 LANDED and gated.** All three remaining `await ccall( > 'ProcessEvents',{async:true})` pumps — modal (`dialog.cpp` startModal), nested loop > (`evtloop.cpp` wxWasmRunNestedLoop), popup (`wx-dom.js`) — now drive ProcessEvents as a > PLAIN export call on scheduler builds (runtime-gated on the shim marker; legacy paths > byte-identical). With the v0.1.28 top-level tick, NO pump awaits a suspending export > anymore: the #13302 boundary is gone from the scheduler variant entirely. Overlapping > pump ticks are parked-safe by ProcessEvents' existing gate; a chain that dies mid-park > surfaces via the window error taps instead of the pump's await-catch (containment > unchanged: fatal-screen + `wx_dispatch_abandon`). **Gates:** asyncify 9/9, coroutine > 39/39 (the June pair green simultaneously), wx-chromium modal-heavy 45/45 (+menu, > wizard, filedialog), kicad 6/6 incl. import-settings-modal-stack + contextmenu-scrollbar > on a fresh warm-cache C-lane build. **N5 landed** alongside (scheduler-shim.test.ts: > 500-call flood strict-FIFO, time-box chunking proven, wake-drain FIFO — 3/3). > **Left open:** the doc-12 "root fiber" formalization (main loop as ctx object, > `set_main_loop`-style top-off-asyncify) — not needed for the #13302 goal; revisit at S4 > if the waits migration wants real park/resume methods. - **S4 · Waits migration, one wait at a time (1–2 wk).** Implement `wasm_begin_async_wait`/`wasm_yield_until`/`wasm_resolve_wait` (13 §2). Order, lowest-risk first, each sub-step flipping its own specs (§3b/3c) and deleting its own pump: 1. nested/quasi-modal loop (`wxWasmRunNestedLoop` + `_wxNestedLoopExit` die), 2. popup menu (`wxDomPopupMenuModal` + the wx-dom.js pump die), 3. modal dialog (`startModal` + `_wxModalResolvers` die), 4. clipboard + font enum, 5. **open** last — the biggest semantic flip (gates → ordering; N2/N3/N6 are the gate). > **Work log 2026-08-05 — S4 LANDED and gated (waits 1–3; 4–5 resolved by scoping).** > The doc-13 §2 API exists as `wx/wasm/private/yieldwait.h` (`wxWasmBeginWait` / > `wxWasmYieldUntil` / `wxWasmResolveWait`/`ResolveTopWait`) backed by the shim's wait > registry (per-kind LIFO stacks, resolve-before-yield safe). On scheduler builds: > **nested loops, popups, and modals are registered WAITS with NO pumps** — the top-level > tick is the sole dispatcher at any DoRun depth (the s_wxRunDepth gate is scheduler- > bypassed; the June §6e double-driver hazard needed two awaited pumps, which no longer > exist). Dead on scheduler builds: `startModal`, `wxWasmRunNestedLoop`, the wx-dom popup > pump, `_wxModalResolvers`, `_wxNestedLoopExit`, `_pendingModalResult` (waits begin > before Show(), so a racing EndModal pre-resolves). Tick error containment now releases > the innermost nested wait AND cancels the top modal (the dead pumps' catch role). > Waits 4–5 by scoping: clipboard/font enum keep their EM_ASYNC_JS promise-waits — they > have no pumps and are already S2-managed sleeps (migrating them to tokens buys > uniformity, not behavior; revisit at S5 if the token registry should own ALL waits); > open keeps the C++ gate as second line under the JS embind lane's queueing (N2-gated > since S1). **Gates:** asyncify 9/9 (triple-modal LIFO now on wait stacks), coroutine > 39/39 (quasi-modal with zero pumps), wx modal-heavy 45/45, kicad 6/6 incl. > modal-stack + contextmenu-scrollbar on a fresh warm-cache build. - **S5 · Guard demolition (few d).** Delete: dispatch interlock + save/zero/restore sites, timer retry, `ProcessEvents` Paint-only gate, `s_wxRunDepth` gate, open-settle/fiber-busy/ enumerate gates. Downgrade libcontext refusals to dev-build assertions; keep counters-only beacons. Retire §3c leftovers. **Gate:** net green with `WX_SCHEDULER=1` as the only build; a full-suite run shows zero references to deleted diagnostics. > **Work log 2026-08-05 — S5 executed as DEFAULT-FLIP + LEDGER, with a correction.** > This step's original demolition list over-promised relative to what S1–S4 built: the > §3b fate table assumed handler-fibers ("serialization is the scheduler's job"), but > parks still suspend ordinary dispatch chains in place — so the **dispatch interlock, > the ProcessEvents parked gate, the zeroed-interlock windows around waits, and the C++ > open/fiber-busy gates remain LOAD-BEARING second lines**, not dead weight. Deleting > them now would reopen the fp-selector trap class. What S5 delivered instead: > **WX_SCHEDULER=1 is the DEFAULT build** (injector flip; `WX_SCHEDULER=0` = explicit > legacy opt-out; `.ci-cache-epoch` 9→10), gated on the FULL kicad-firefox suite against > the scheduler build, not just the trio. > **Flip gate result:** full suite 136 passed on BOTH variants (an accidental legacy run > — `setup:kicad` re-syncs `output/` over staged glue, converter beware — gave the > fallback a free full net). Scheduler run: 30 skips (N2 runs there), 2 fails triaged: > `ngspice-probe` bg_run = rerun-passes flake; `occ-probe` glb = fails IDENTICALLY on > legacy → pre-existing local occ_service issue, tracked outside this plan. > > **Demolition ledger — each deletion with its unlock condition:** > 1. **[RESCHEDULED 2026-08-05 → [`20`](20-design-b-core-plan.md) D-1: delete FIRST, on > the branch, not after CI]** Legacy opt-out + `handlesleep.js` + C++ legacy paths (`startModal`, > `wxWasmRunNestedLoop`, popup pump, `_wxModalResolvers`/`_wxNestedLoopExit`, > `emscripten_async_call` timer entry, timer 17 ms retry branch, `s_wxRunDepth` tick > gate): delete TOGETHER, after CI runs scheduler-only across the full matrix > (3 engines × both EH). They're compiled once for both variants — no partial delete. > 2. Dispatch interlock + Paint-only gate + zero/restore windows: delete only when > parkable handlers run on scheduler-owned fiber contexts (the remaining Design-B > step this plan never scoped). Until then they are the mutual-exclusion story. > 3. C++ `pcbjam_open::busy()` / `kicadCollabFiberBusy` gates: keep as second line under > the JS embind lane; revisit only with (2). > 4. libcontext refusals: KEEP ACTIVE (not downgraded) — `jump-refused-parked` fires > BEFORE the doomed unwind write, the one place prevention works (doc 16); the JS > consume-once layer is the backstop, not a replacement. - **S6 · Lifetime (few d).** Cleanup ordering vs the scheduler; teardown deferred to unload/explicit exit; `ScheduleExit` → scheduler wake (12 §phase-4). > **Work log 2026-08-05 — S6 LANDED.** The scheduler latches DEAD when the main loop > exits (DoRun's top-level return → `shutdown("main loop exited")`): queued mutators > reject, queued messages/wakes drop, pumps stop — stranded work beacons > (`shutdown ... stranded:`) instead of surfacing as a post-teardown UAF. > `wxWasmMailboxDeliver` gained ProcessEvents' `!wxTheApp` teardown parity. Unit-gated > (scheduler-shim.test.ts 4/4 incl. shutdown; 11/11 with WasmMailbox) + full battery > (asyncify 9/9, coroutine 39/39, wx modal-heavy 45/45) + kicad 6/6 — all on > DEFAULT-injected glue: the docker postprocess → setup:kicad pipeline now produces > scheduler builds with no manual conversion. Deferred with the ledger: OnExit/ > wxEntryCleanupReal-to-unload reshaping (12 §phase-4's fuller lifetime) — the current > quit flow (wxAppTopWindowClosed → loop exit → cleanup) plus the DEAD latch covers the > teardown-delivery hazard the step exists for. **Effort: ≈ 5–7 weeks** (doc 12 said 4–6 for the runtime alone; the added week is §3's test work, which is where the safety comes from). S1 and S2 each end in a shippable state; S4 is the first step with user-visible semantic changes. ## 5. Risks (delta vs doc 12 — its register still applies) - **The drop→deliver flip changes behavior**, not just internals: events that guards silently swallowed will now arrive late instead of never. N6 exists to find C++ code that can't cope. - **Dual-build drift** — the `WX_SCHEDULER=0` fallback rots if not in CI; keep it in the matrix until S5, then delete it the same week (a permanently-maintained dual runtime is its own bug farm). - **Process traps** (learned the hard way): `npm run test:kicad` is *not* the CI set — gate on the full CI project set; rebuild wx into the consuming apps after every wx change (host-wx staleness); `pnpm install` after pulls. ## 6. Open decisions (resolve at the step noted) - **js-library vs post-link injection** for the scheduler (S2; lean js-library for durability). - **No-scheduler fast path** for plain wx test apps with no fibers (S2; likely yes, gated on "any non-main context registered", 13 §7). - **Sync embind policy** (S1 audit): pure reads that never dispatch/park stay synchronous; everything else becomes a promise-returning message. The audit's deliverable is the exact allowlist, checked by a lint or a dev-build assert. > **DONE 2026-08-05 → [`18-embind-audit.md`](18-embind-audit.md):** 79 exports — 47 mutators > (14 production + 3 saves to wrap; 33 test-only stay direct), 20 pure-reads (the allowlist, > with the model-walk caveat), 9 park-capable. Asymmetries to fix listed there (ungated > pure-reads, pl_editor bare-stack applies, ungated `kicadLibsReload`). - **`kicadOpenFileBusy` compatibility**: `open-flow.ts` feature-detects it and legacy wasm builds rely on the fallback — keep the export, backed by scheduler state, until the web app drops support for pre-scheduler wasm. - **Raytracer unpark** (doc 11): the scheduler makes a nestable yield possible — schedule as a follow-up feature, not part of this plan's gates.