9.8 KiB
11 — Asyncify can't nest: why the WASM raytracer is single-core
Status: finding + decision. The multi-core raytracer was built, measured (~6–7× on 10 cores), and parked because the only way to make it cooperate with the browser main thread —
emscripten_sleepto yield between work batches — aborts when it runs inside the 3D viewer's already-suspended call stack. Authored 2026-06-18 while landing the 3D viewer (Route C, CPU raytracer). Code:kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp(andimage.cpp,3d_canvas/create_layer_items.cpp). Parked pool:git -C kicad stash(WASM_RAYTRACE_POOL). Repro:tests/apps/standalone/raytrace-threads/+tests/e2e/coroutine-raytrace.spec.ts.
TL;DR
- The native raytracer fans work out across a thread pool and joins with
futures.wait()(render_3d_raytrace_base.cpp:307). Several post-process passes (shading, blur, anti-alias preview, theEfxFilterinimage.cpp, and the zone/segment builds increate_layer_items.cpp) follow the samespawn N threads → main thread sleep_for-busy-wait → joinshape. - The WASM build has no
-sPROXY_TO_PTHREADand runs the renderer on the browser main thread. That topology breaks the native shape two different ways:- A plain join deadlocks.
futures.wait()/std::this_thread::sleep_for()blocks the one thread that the web workers need in order to be scheduled and to post their results back. Nothing progresses. - A "yield instead of block" join aborts. The obvious fix — spawn workers, then
emscripten_sleep(…)on the main thread to pump the event loop instead of busy-waiting — aborts withAborted(invalid state: 1). This is the headline finding:emscripten_sleepcannot nest on an Asyncify context that is already mid-unwind, and the 3D viewer always runs inside such a context.
- A plain join deadlocks.
- Shipped fix: every parallel section runs serially on the calling thread under
#ifdef __EMSCRIPTEN__. The renderer is progressive by design (it drains work up to a per-frame time limit and re-schedules via the render-state machine), so single-core still paints the board — just slower. This trades the ~6–7× for correctness and zero new infrastructure.
The machine: one Asyncify slot, and the viewer is already using it
This is the same single-Asyncify.currData register documented across this dossier
(02-asyncify-internals.md,
07-decisions-and-outcome.md). The relevant property here:
emscripten_sleep(ms)is an Asyncify suspend point. To return control to the browser it callsasyncify_start_unwind, copies the live C stack into the global suspension buffer, setsAsyncify.state = Unwinding (1), and throws out to JS. When the timer fires, JS calls back in andasyncify_start_rewindreplays the stack.- Emscripten asserts that you cannot start a second async operation while one is in
flight —
Asyncify.statemust beNormal (0)at the entry of a new suspend. Starting an unwind whilestate == 1is exactly theinvalid state: 1abort.
The 3D viewer never runs from a clean stack. It renders from inside wx's modal / nested
event-pump (ShowModal, the nested-loop pump — see D5 in
07-decisions-and-outcome.md), and that pump is itself
implemented with an Asyncify suspend (it awaits a JS ProcessEvents ccall). So at the
moment Redraw() runs, the stack is already unwound/suspended once. A worker-join that
calls emscripten_sleep to yield is then a second suspend on the same context →
state is already 1 → abort.
wxGUIEventLoop pump ──emscripten_sleep──► state = Unwinding(1) (the pump is parked here)
└─ ProcessEvents → … → EDA_3D_CANVAS::DoRePaint → raytracer Render()
└─ spawn workers; main thread wants to yield
└─ emscripten_sleep ──► start_unwind while state==1 ──► Aborted(invalid state: 1)
This is not the currData-contention bug (that one is about overlapping distinct
contexts trampling one buffer). This is simpler and more fundamental: you cannot suspend a
context that is already suspended. Asyncify is one level deep, full stop. Fibers each carry
their own buffer, but emscripten_sleep always targets the global one.
What we tried (in order), and why each failed or was rejected
-
Native shape as-is (thread pool +
futures.wait()/sleep_forjoin). → Deadlock. Main thread blocks; workers can't be serviced. Never paints. -
Spawn workers,
emscripten_sleepto yield on the main thread instead of busy-waiting. →Aborted(invalid state: 1)the instant it runs in the real viewer. Worked in a standalone harness (raytrace-threads/) only because there the render is called from a clean stack, not from inside a modal pump — which is precisely why the standalone repro was misleading and the bug only showed up integrated. -
Persistent worker pool + main-thread busy-wait (no
emscripten_sleepat all). → Worked and was fast (~6–7× on 10 cores). Rejected anyway: it busy-waits the browser main thread for the whole render (jank, fans, blocks input), and it adds a standing worker pool + SAB plumbing to maintain. Parked, not deleted — it's ingit -C kicad stash(WASM_RAYTRACE_POOL) behind aWASM_RAYTRACE_POOLgate, with its repro harness. -
Serial on the calling thread (SHIPPED). → No nesting, no busy-wait, no new infra.
processBlocks()/shadeWorker()/blurWorker()/previewWorker()/filterWorker()are each invoked directly under#ifdef __EMSCRIPTEN__; the nativetp.submit_task(...)+futures.wait()path stays for non-WASM. Progressive rendering keeps the UI responsive across frames.
The standalone test suite — and what it does / does NOT prove
tests/e2e/coroutine-raytrace.spec.ts drives the one-binary harness
(tests/apps/standalone/raytrace-threads/, switchable by URL #m=). It maps almost
1:1 onto the ladder above. Polarity rule: a test is green IFF the mechanism genuinely
runs multi-core (workersRan > 1); a freeze or a single-thread fallback is never a green
pass.
#m= |
Variant | Ladder step | Test verdict |
|---|---|---|---|
| 0 | A — detached threads + main-thread busy-wait | step 1 (deadlock) | negative control, test.fail() — held to workersRan > 1, can't meet it (deadlocks → workersRan=0), reported as an expected failure (self-recovers after a 12 s cap) |
| 1 | B1 — detached + emscripten_sleep yield |
step 2 | green (multi-core in isolation) |
| 2 | B2 — persistent pool + emscripten_sleep |
step 2 | green |
| 4 | B1 with stack-local atomics (mirrors the raytracer's shared locals) | step 2 | green |
| 5 | B3 — persistent pool + sleep_for busy-wait (the parked port) |
step 3 | green |
| 3 | C — serial | step 4 | not a standalone pass; used only as the slower baseline in the speedup test |
Critical caveat — the suite reproduces Bug 1, not Bug 2. The harness runs from a clean
OnInit, so mode 0 faithfully reproduces the worker-spawn deadlock (step 1: a
main-thread busy-wait starves the event loop, the on-demand worker is never created). But
no standalone test reproduces the actual current blocker — the Aborted(invalid state: 1) Asyncify-nesting abort (step 2 in the real viewer) — because that requires rendering
from inside an already-suspended modal pump, which a clean stack never does. So the
emscripten_sleep variants (m=1/2/4) are green here yet abort the real viewer; B3 (m=5) is
green here and also works integrated, but is parked for jank (step 3). The suite proves
the threading mechanisms in isolation; it is not a gate on the shipped viewer, which
stays serial. A test with teeth against Bug 2 would need to fake the enclosing suspend
(render inside a modal-pump emscripten_sleep, then attempt the worker-join) — and would
currently be a genuine red.
The open question (what would unpark the multi-core path)
The build already ships emscripten_fiber_swap (it's how tool coroutines and the parked
main loop work — 02, 06).
A fiber owns its own suspension buffer, so a fiber swap is not bound by the single
global-slot assertion the way emscripten_sleep is. So the real question is:
Can the worker-join yield via a nestable mechanism — a fiber swap, or JSPI — instead of
emscripten_sleep, so it can suspend even though the enclosing modal pump is already suspended?
That is unverified and needs its own red test before re-landing. The fiber-first runtime in
06-design-b-fiber-first-runtime.md is the natural home
for it: if modal pumps and render yields were both scheduler-owned fiber contexts, "render
yields while the pump is parked" becomes a normal context switch instead of an illegal nested
unwind. Until then, single-core is the correct answer.
Practical guidance for future work in this area
- Never call
emscripten_sleepfrom code that can run inside a modal / nested event loop. It will abort, not just block. The viewer, dialogs, and progress reporters all qualify. - A standalone harness that calls your code from a clean stack will not reproduce this. The nesting only happens through the wx pump. Test integrated, or fake the enclosing suspend.
- Prefer "drain up to a time budget, then return and let the state machine re-schedule" over "block until done." The raytracer already works this way; lean on it.
- If you need real parallelism, the path is a nestable yield (fiber/JSPI), not
emscripten_sleep, or-sPROXY_TO_PTHREADso the render runs off the main thread (a much larger architectural change for this build).