test: 💍 deadlock test + readme update
This commit is contained in:
parent
1b1e7884ee
commit
5d18bd0c45
4 changed files with 235 additions and 81 deletions
149
docs/features/async/11-asyncify-nesting-raytracer.md
Normal file
149
docs/features/async/11-asyncify-nesting-raytracer.md
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
# 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_sleep` to 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`
|
||||
> (and `image.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, the `EfxFilter` in `image.cpp`, and the zone/segment
|
||||
builds in `create_layer_items.cpp`) follow the same `spawn N threads → main thread
|
||||
sleep_for-busy-wait → join` shape.
|
||||
- The WASM build has **no `-sPROXY_TO_PTHREAD`** and runs the renderer on the **browser main
|
||||
thread**. That topology breaks the native shape two different ways:
|
||||
1. **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.
|
||||
2. **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 with `Aborted(invalid state: 1)`**. This is the headline
|
||||
finding: **`emscripten_sleep` cannot nest on an Asyncify context that is already
|
||||
mid-unwind**, and the 3D viewer always runs inside such a context.
|
||||
- **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`](02-asyncify-internals.md),
|
||||
[`07-decisions-and-outcome.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 calls `asyncify_start_unwind`, copies the live C stack into the global suspension
|
||||
buffer, sets `Asyncify.state = Unwinding (1)`, and throws out to JS. When the timer fires,
|
||||
JS calls back in and `asyncify_start_rewind` replays the stack.
|
||||
- Emscripten **asserts that you cannot start a second async operation while one is in
|
||||
flight** — `Asyncify.state` must be `Normal (0)` at the entry of a new suspend. Starting an
|
||||
unwind while `state == 1` is exactly the `invalid state: 1` abort.
|
||||
|
||||
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`](07-decisions-and-outcome.md)), and that pump is itself
|
||||
implemented with an Asyncify suspend (it `await`s 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
|
||||
|
||||
1. **Native shape as-is (thread pool + `futures.wait()` / `sleep_for` join).**
|
||||
→ **Deadlock.** Main thread blocks; workers can't be serviced. Never paints.
|
||||
|
||||
2. **Spawn workers, `emscripten_sleep` to 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.
|
||||
|
||||
3. **Persistent worker pool + main-thread busy-wait (no `emscripten_sleep` at 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 in `git -C kicad stash`
|
||||
(`WASM_RAYTRACE_POOL`) behind a `WASM_RAYTRACE_POOL` gate, with its repro harness.
|
||||
|
||||
4. **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 native `tp.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`](02-asyncify-internals.md), [`06`](06-design-b-fiber-first-runtime.md)).
|
||||
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`](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_sleep` from 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_PTHREAD` so the render runs off the main thread (a
|
||||
much larger architectural change for this build).
|
||||
|
|
@ -43,6 +43,7 @@ or **hang** (a swap unwinds but is never rewound).
|
|||
| [`08-dom-port-regression.md`](08-dom-port-regression.md) | DOM-port regression investigation after rebasing onto the async hardening: traces, symbolized crash, ruled-out Asyncify/table/removelist hypotheses, and current stale-window diagnosis. |
|
||||
| [`09-dom-window-lifetime-hypothesis.md`](09-dom-window-lifetime-hypothesis.md) | Concrete failure story and first fix experiment for the DOM-port stale `wxWindow` hypothesis: destructor ordering, DOM event reentry, and validation plan. |
|
||||
| [`10-resolution-menubar-uaf.md`](10-resolution-menubar-uaf.md) | **RESOLVED:** the regression was a freed `wxMenuBar` left in a live frame's child list by `wxMenuBarBase::Detach()` (DOM-port only — the bar is a real child there). One-line fix in `wxMenuBar::Detach()`; full kicad suite green, zero corruption signatures. |
|
||||
| [`11-asyncify-nesting-raytracer.md`](11-asyncify-nesting-raytracer.md) | **Finding + decision:** the WASM 3D raytracer is single-core because `emscripten_sleep` can't nest on an already-unwinding Asyncify context — yielding to join worker threads aborts with `invalid state: 1` since the viewer renders inside a suspended wx modal pump. Multi-core pool (~6–7×) built + parked; unpark needs a *nestable* yield (fiber/JSPI). |
|
||||
|
||||
## The single decisive next step
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue