2026-08-05 20:50:18 +02:00
# 21 — Park-site audit (doc 20 D0 deliverable)
> **Status: AUDIT (2026-08-05).** The classification doc 20 §6 D0 calls for: every
> Asyncify park site in the tree, classified by **whose stack it suspends** — *tool
> fiber* / *entry stack* / *main loop* — with the routing decision per site. Method as
> doc 18: enumerate every `EM_ASYNC_JS` / `emscripten_sleep` primitive, follow each to
> its callers, record evidence as file:line. Inventory taken AFTER D-1 (the legacy
> twins `startModal` / `wxWasmRunNestedLoop` / the popup pump no longer exist).
## 0. Why the class matters (the doc-19 lens)
- **On-fiber parks** are the * strand class * : a tool fiber that parks mid-body trips the
stale-fiber quarantine, and its legitimate resume can be refused → the fiber never
completes, the dispatch guard it holds never releases, the UI freezes (doc 19 §4).
- **Entry-stack parks** are the * interlock-holder class * : the parked chain holds
`wxWasmDispatchDepth` (or is zeroed around the park by hand), so dispatch degrades to
Paint-only until the promise settles. Recoverable — but it is the reason the
quarantine/consume-once guessing layer exists at all.
- **Main-loop parks** that * complete every tick * are safe by construction
(doc 13 §6c): nothing stays suspended across a dispatch.
D1– D4 give the first two classes their own scheduler contexts; the third stays as-is
unless D5 is taken.
## 1. Inventory (14 production sites, 3 test levers)
Headline: **8 wx sites, 9 KiCad/bridge sites ** (counting the clipboard quartet as 4 and
the ngspice pair as 2); after D-1 no site has a legacy twin — one code path per site.
| # | site | file:line | parks | wake source | route |
|---|---|---|---|---|---|
| W1 | `wxWasmYieldUntilJs` — the wait primitive (modal + nested quasi-modal) | wx `evtloop.cpp:175` | **fiber ** (dialog opened from a tool action — the doc-19 case) or **entry ** (plain handler chain) | wait-registry `resolveWait` | **D3 ** — context yield |
| W2 | `wxWasmYieldToBrowser` — per-frame yield | wx `evtloop.cpp:292` | **main loop ** , completes every frame | rAF | safe (doc 13 §6c); scheduler-owned only if **D5 ** is taken |
| W3 | `wxDomPopupMenuModal` | wx `window.cpp:723` | **fiber ** (canvas context menu runs inside a TOOL_MANAGER coroutine) or entry | `wxShowContextMenu` promise | **D3 ** — same yield as W1 (wait-shaped) |
| W4 | clipboard quartet `js_{write,read,has,clear}*Clipboard` | wx `clipbrd.cpp:38/76/120/147` | **entry or fiber ** (copy/paste actions; `SetData` →`:261` , `GetData` →`:331` ) | `navigator.clipboard` promise | **D4 ** — bridge helper |
| W5 | `js_enumerateFonts` | wx `fontenum.cpp:33` (caller `:119` ) | **entry ** (font enumeration in dialog/startup paths) | Local Font Access promise | **D4 ** |
| K1 | `pcbjam_libs_request_js` — symbol libs | `sch_io_pcbjam_lib.cpp:58` | **entry ** (open flows, busy-gated) and **fiber ** (lazy chooser loads; `kicadLibsReload` is fibered — doc 18 asymmetry 3) | `kicadLibs.request` promise | **D4 ** |
| K2 | `pcbjam_fp_libs_request_js` — footprint libs | `pcb_io_pcbjam_fp.cpp:58` | same shape as K1 (board-open inline preload; fp chooser lazy) | same | **D4 ** |
| K3 | `pcbjam_3d_request_js` — 3D model fetch | `pcbjam_model_fetch.cpp:51` | **entry or fiber ** (3D cache `EnsureModelFile` : viewer open, raytracer prep, STEP export) | 3D provider promise | **D4 ** |
| K4 | `js_occExportRequest` — STEP export | `wasm/stubs/exporter_step_stub.cpp:59` | **entry ** (export dialog flow) | occ-service worker promise | **D4 ** |
| K5 | `js_occLoadModelRequest` — OCE model load | `wasm/stubs/oce_plugin_stub.cpp:64` | **entry or fiber ** (3D cache load path) | occ-service worker promise | **D4 ** |
| K6 | `js_ngspice_request` / `js_ngspice_get_vec` | `wasm/stubs/sharedspice_client.cpp:60/82` | **entry ** (sim frame actions; `get_vec` has the known pre-existing asyncify crash) | ngspice-service worker promise | **D4 ** |
docs 21+22: name the tool-body park site that blocks Phase D
Traced, not inferred: TOOL_MANAGER::RunSynchronousAction spins
`while(synchronousControl == STS_RUNNING) { wxYield(); wxMilliSleep(1); }`
(tool_manager.cpp:370-371), and on wasm wxMilliSleep -> nanosleep ->
__wasm_main_thread_yield_ms is an ASYNCIFY PARK OF THE STACK IT STANDS ON —
in a loop, inside a tool body, with a nested wxYield() dispatch running on
top of the parked stack.
The case closes on its callers: they are exactly the tools whose specs died
at D-on — edit_tool_move_fct (move-with-m, presence-locks move),
sch_drawing_tools (draw wires), the drawing/edit tools behind draw-lines.
The pcbnew spec comments already described the symptom from outside ("the
asyncified pointer-move handler") without naming the park; this is it.
So doc 21's K7 "anywhere" class has ONE caller that blocks Phase D, and it
moves first: the wait must yield the owning context instead of sleeping in
place, with the atomic's transition marking it ready. Recorded with the fix
shape, the upstream-divergence question it raises, and the gate that matters
(the KiCad suite's four canvas-tool specs — the harness has no
RunSynchronousAction and stays green either way).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LBjomQfKyRa3jBdeAKpmTw
2026-08-07 19:28:39 +02:00
| K7 | `__wasm_main_thread_yield_ms` — the nanosleep shadow | `wasm/shims/nanosleep_yield.c:32` | **whatever main-thread stack calls `sleep_for` ** — the "anywhere" class (raytracer join loops today), and * * `TOOL_MANAGER::RunSynchronousAction` 's spin loop, `tool_manager.cpp:370-371` — an in-place park INSIDE a tool body, in a loop, with a nested `wxYield()` dispatch on top of it** (identified 2026-08-07; doc 22 §10 "The tool-body park site, NAMED") | setTimeout | **D4 ** , plus a caller sweep: each `sleep_for` reached on the main thread is a park site of its own. The `RunSynchronousAction` caller is the one that BLOCKS Phase D — it kills four canvas-tool specs at D-on with the blue screen — and it moves first |
2026-08-05 20:50:18 +02:00
| T1 | open-gate test park (`testParkMs` sleeps) | `wasm/bindings/{pcbnew,eeschema,pl_editor,kicad_editor}_embind.cpp` × 8 | **entry ** (deliberate) | timeout | keep — stages the open-window collisions |
| T2 | timer park lever | `wasm/bindings/timer_park.h:73` | mailbox-delivered timer entry (deliberate) | timeout | keep |
| T3 | fiber park lever | `wasm/bindings/fiber_park.h:78` | **fiber mid-body (deliberate — stages exactly the doc-19 strand) ** | timeout | keep; its refusal pin flips red→green at D6 |
Notes:
- W1 absorbed the legacy modal/nested/popup pumps at S4+D-1; it is now the **single **
wait primitive — and exactly the site doc 20 §3 calls out: "API is right,
implementation parks in place." D3 swaps its implementation for a context yield;
nothing above it changes.
- W3 is wait-shaped but bypasses the registry (it awaits the menu promise directly).
D3 should route it through the same context yield as W1 — either by registering a
"popup" wait or by the D4 promise helper; decided at D3, not here.
- K1/K2's **fiber ** lane is the second half of the doc-19 exposure: a chooser's lazy
lib load parks the tool fiber that owns the chooser. Any strand fix that only covers
W1 leaves K1/K2 able to reproduce the same freeze.
- K7 is the only site whose caller set is open-ended (anything reaching `nanosleep` on
the main thread). The D4 gate ("no handleSleep on a fiber stack") is what turns an
unaudited new caller from a silent hazard into a loud assertion failure.
## 2. pthread scoping (doc 20 risk 4, settled for D1)
- Every site above parks the **main browser thread's ** Asyncify state only. No park
site exists on a worker: `nanosleep_yield.c:41` branches workers to
`emscripten_thread_sleep` (real blocking sleep — workers may block).
- The symbol-lib bridge has a second, **non-Asyncify ** path for library pthreads:
`sch_io_pcbjam_lib.cpp:112` (`pcbjam_libs_request_on_main` ) proxies the request to
the main thread via `emscripten_proxy_*` and **blocks the worker ** until
`pcbjam_libs_finish` (`:49` ) releases it; a global lock (`:167-181` ) serializes
concurrent worker loads. This path never touches Asyncify and is OUT of the context
migration's scope — but D1's scheduler must not assume "all lib loads park" either:
a proxied load holds no context.
- Consequence for D1: contexts are a main-thread-only concept; the scheduler registry
needs no cross-thread story. The raytracer interplay (K7) is confined to "main
thread yields while a worker boots" — unchanged by D1– D4.
2026-08-05 21:42:58 +02:00
## 2b. What D1 built (2026-08-05)
`wasm/sched/context.{h,cpp}` — the contexts every site in §1 will migrate onto, with a
**star** topology (contexts yield to the scheduler; only the scheduler resumes) rather
than libcontext's symmetric swap. That is what converts every "route" cell above from
"move the park" into "move the park onto a context whose state is recorded". Nothing in
§1 is wired to it yet: W1/W3 move at D3, the bridges at D4.
Sizing note for the D4 migration: contexts run 128 KB C stack + 128 KB asyncify buffer,
and the layer measures its own high-water use. The D1 harness's synthetic frames cost
~34 B each — a floor, not a production figure. **When each bridge in §1 moves at D4, take
its deep-park high-water from the beacon and size from that**; do not carry the harness
number, and do not inherit libcontext's 512 K by default either.
2026-08-05 20:50:18 +02:00
## 3. What D4's assertion must cover
"No `handleSleep` park happens inside a fiber" (doc 20 §6 D4) must trip on: W1/W3 if
D3 left a path unmigrated, W4, K1, K2, K3, K5, and any K7 caller reached from a tool
coroutine. Sites T1– T3 are exempt by name (they exist to stage parks). The assertion
belongs in the scheduler shim (it already owns `Fibers` bookkeeping and the
`__pendingSleepContexts` list), gated to dev/test builds at D4 and promoted per D6.
## 4. Red spec (the other D0 deliverable)
2026-08-05 21:16:18 +02:00
**Landed** as `tests/kicad/quasimodal-strand.spec.ts` (pcbjam `6ab0843` ): open a
schematic, double-click a symbol → Symbol Properties (quasi-modal on a tool fiber =
W1's fiber lane), arm the parking timer * while the dialog is up * (so its park lands on
top of the opener's open-ended park — structural overlap, not a race), click OK → the
dialog must close and the wait books must balance. RED today by the doc-19 mechanism,
6/6 identical (`closed=false dialogs=1 refused-resumes=1` ); goes green at D3. Marked
`test.fail()` so the battery stays runnable while red — when D3 lands, Playwright flags
"expected to fail but passed", forcing the flip to a plain green pin. A separate GREEN
"staging" test asserts the window is real, so the pin cannot rot into vacuity.
**Audit consequence recorded there:** the strand reproduces on a 2-object fixture — the
warm-load byte volume that dice-loads the 68/1 family is not an ingredient. Two
concurrent parks suffice, which is why D3 (waits on contexts) closes this class and D5
(main as a context) is not required for it.