pcbjam/docs/features/async/21-park-site-audit.md
Gergő Törcsvári f6171a7d34
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-10 10:14:18 +02:00

9.5 KiB
Raw Blame History

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

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

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.

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

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.