From 5d18bd0c45c12c15963c4bf6fb74286cf59fd594 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 18 Jun 2026 16:48:00 +0200 Subject: [PATCH] =?UTF-8?q?test:=20=F0=9F=92=8D=20deadlock=20test=20+=20re?= =?UTF-8?q?adme=20update?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../async/11-asyncify-nesting-raytracer.md | 149 ++++++++++++++++++ docs/features/async/README.md | 1 + .../raytrace_threads_test.cpp | 118 +++++++------- tests/e2e/coroutine-raytrace.spec.ts | 48 +++--- 4 files changed, 235 insertions(+), 81 deletions(-) create mode 100644 docs/features/async/11-asyncify-nesting-raytracer.md diff --git a/docs/features/async/11-asyncify-nesting-raytracer.md b/docs/features/async/11-asyncify-nesting-raytracer.md new file mode 100644 index 0000000..a69d9ce --- /dev/null +++ b/docs/features/async/11-asyncify-nesting-raytracer.md @@ -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). diff --git a/docs/features/async/README.md b/docs/features/async/README.md index 98f48ee..6b5d865 100644 --- a/docs/features/async/README.md +++ b/docs/features/async/README.md @@ -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 diff --git a/tests/apps/standalone/raytrace-threads/raytrace_threads_test.cpp b/tests/apps/standalone/raytrace-threads/raytrace_threads_test.cpp index 20c89eb..be7aea9 100644 --- a/tests/apps/standalone/raytrace-threads/raytrace_threads_test.cpp +++ b/tests/apps/standalone/raytrace-threads/raytrace_threads_test.cpp @@ -1,19 +1,23 @@ /** * raytrace_threads_test.cpp * - * Minimal, fast-building reproduction of the KiCad WASM raytracer threading - * deadlock (kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp, - * the post-process passes), PLUS candidate fixes — all in one binary, switchable - * by URL query param so we build ONCE and test every variant. + * Minimal, fast-building proof that the KiCad WASM raytracer's threading + * mechanisms (kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp, + * the post-process passes) genuinely run MULTI-CORE in the browser — several + * candidate mechanisms in one binary, switchable by URL query param so we build + * ONCE and test every variant. + * + * Each variant must demonstrate real parallelism (workersRan > 1). The suite is + * green iff multithreading actually happens; a variant that degrades to a single + * thread fails. Variant A (m=0) is the NAIVE pattern that deadlocks the browser tab + * (see WHY below): it is kept ONLY as a negative control. It is held to the same bar + * (workersRan > 1), which it cannot meet — the spec marks its test `test.fail()` so it + * is reported as an EXPECTED failure, never a green pass. A frozen tab is never a + * passing state. (Variant A self-recovers after a 12s cap so it reports workersRan=0 + * instead of hanging the tab forever.) * * ------------------------------------------------------------------------------ - * THE PATTERN WE'RE REPRODUCING (raytracer post-process, desktop path): - * - * for (i in 0..N) { std::thread t(worker); t.detach(); } // spawn N workers - * while (threadsFinished < N) // ...then WAIT - * std::this_thread::sleep_for(10ms); // by busy-looping - * - * WHY IT DEADLOCKS IN THE BROWSER: + * WHY A MAIN-THREAD BUSY-WAIT DEADLOCKS (the trap these mechanisms avoid): * - std::thread == a Web Worker. Creating one needs a message processed by the * browser MAIN-THREAD event loop. * - KiCad already pre-spawned hardware_concurrency() thread-pool workers at @@ -21,12 +25,14 @@ * (-sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency') are already full. * The raytracer's new threads must therefore be spawned ON DEMAND, which * again needs the event loop. - * - The busy-wait `sleep_for` NEVER returns to the event loop, so those new + * - A busy-wait `sleep_for` NEVER returns to the event loop, so those new * workers are never created -> threadsFinished never advances -> frozen tab. * - * THE FIX: don't busy-wait — YIELD. emscripten_sleep() (Asyncify) hands control - * back to the event loop each tick, so the pending worker spawns complete and - * the workers actually run on real cores. + * THE MECHANISMS THAT WORK: either YIELD — emscripten_sleep() (Asyncify) hands + * control back to the event loop each tick so pending worker spawns complete + * (B1/B2/m4) — or keep a PERSISTENT pre-alive pool whose workers already exist, so + * a main-thread busy-wait still completes because they run on their own cores with + * no event-loop dependency (B3/m5). * * TODO(asyncify-nesting) — IMPORTANT CAVEAT this harness does NOT capture: the * emscripten_sleep variants (B1/B2/m4) pass here but ABORT the real KiCad 3D viewer @@ -36,30 +42,33 @@ * to KiCad is B3 (m=5): a persistent pool + sleep_for busy-wait — NO emscripten_sleep, so * no nesting. That multi-core port is currently PARKED (git -C kicad stash) pending * research into whether a nestable yield (fibers / emscripten_fiber_swap / JSPI) works. + * So this suite validates the threading MECHANISM in isolation, not the shipped viewer + * (which still ships serial). See docs/features/async/11-asyncify-nesting-raytracer.md. * * To reproduce KiCad faithfully we first pre-spawn `park` "parked" workers that * block on a condition variable (zero CPU) to occupy the pool slots — exactly - * what KiCad's singleton thread pool does. Set ?park=0 to show that WITHOUT that - * pre-existing pool, variant A does NOT deadlock (hwc threads fit in hwc slots). + * what KiCad's singleton thread pool does, forcing the on-demand spawns the + * yield-based variants must service. * * ------------------------------------------------------------------------------ * URL params (all optional): - * ?m=0 variant A : detached threads + std::this_thread::sleep_for (EXPECT DEADLOCK) - * ?m=1 variant B1 : detached threads + emscripten_sleep yield (FIX, fresh threads) - * ?m=2 variant B2 : persistent pre-warmed pool + emscripten_sleep (FIX, reused threads) - * ?m=3 variant C : serial on the calling thread (current shipped fallback) + * ?m=0 variant A : detached threads + std::this_thread::sleep_for (NEGATIVE CONTROL: deadlocks) + * ?m=1 variant B1 : detached threads + emscripten_sleep yield (fresh threads) + * ?m=2 variant B2 : persistent pre-warmed pool + emscripten_sleep (reused threads) + * ?m=3 variant C : serial on the calling thread (baseline for the speedup comparison) + * ?m=4 variant B1 with STACK-LOCAL atomics (mirrors the raytracer exactly) + * ?m=5 variant B3 : persistent pool + sleep_for busy-wait (the real ported mechanism; default) * ?park=K parked workers pre-spawned to exhaust the pool (default = hardware_concurrency) * ?work=K worker threads the pass uses (default = hardware_concurrency) * ?blocks=K number of work blocks (default 64) * ?iters=K compute iterations per block (default 4000000; tune so serial ~2-3s) - * ?passes=K how many times to run the pass (default 1; use >1 to show B2 reuse) + * ?passes=K how many times to run the pass (default 1; use >1 to show B2/B3 reuse) * * Console contract (the Playwright spec asserts on these): * [RTPOOL] START m=.. park=.. work=.. blocks=.. iters=.. passes=.. hwc=.. * [RTPOOL] PASS done pass=.. workersRan=.. passMs=.. - * [RTPOOL] DEADLOCK pass=.. ... (variant A) - * [RTPOOL] SUCCESS mode=.. workersRan=.. totalMs=.. - * [RTPOOL] FAIL deadlocked mode=.. totalMs=.. (variant A) + * [RTPOOL] SUCCESS mode=.. workersRan=.. totalMs=.. (workersRan=0 for m=0, the deadlock) + * [RTPOOL] DEADLOCK mode=0: ... (informational; m=0 negative control only) */ #include "wx/wx.h" @@ -223,22 +232,6 @@ static void releaseParkedPool() g_parkCv.notify_all(); } -// ---------------------------------------------------------------------------- -// variant A: detached threads + std::this_thread::sleep_for busy-wait. -// Returns true if a deadlock was detected (workers never finished within 12s). -// ---------------------------------------------------------------------------- -static bool waitBusy( int n ) -{ - auto start = clock_t_::now(); - while( g_threadsFinished.load() < (size_t) n ) - { - std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); - if( std::chrono::duration_cast( clock_t_::now() - start ).count() >= 12 ) - return true; // deadlock: the event loop never ran, workers never started - } - return false; -} - // ---------------------------------------------------------------------------- // variant B1: detached threads + emscripten_sleep yield-poll. // ---------------------------------------------------------------------------- @@ -367,7 +360,7 @@ public: readUrlParams( raw ); auto pick = [&]( int i, int dflt ) { return raw[i] == INT_MIN ? dflt : raw[i]; }; - const int mode = pick( 0, 0 ); + const int mode = pick( 0, 5 ); // default = B3, the real ported mechanism const int park = pick( 1, hwc ); const int work = pick( 2, hwc ); g_numBlocks = pick( 3, 64 ); @@ -385,9 +378,8 @@ public: } auto t0 = clock_t_::now(); - bool deadlocked = false; - for( int pass = 0; pass < passes && !deadlocked; ++pass ) + for( int pass = 0; pass < passes; ++pass ) { auto p0 = clock_t_::now(); @@ -398,12 +390,30 @@ public: workerBody(); break; - case 0: // A — detached threads + busy-wait (expected deadlock) + case 0: // A (NEGATIVE CONTROL) — detached threads + main-thread busy-wait. + // CANNOT multi-core in the browser: the busy-wait starves the event + // loop, so the on-demand worker spawns never run and no worker ever + // executes -> workersRan stays 0. Self-recovers after a 12s cap so the + // harness reports (workersRan=0) instead of hanging the tab forever. + { resetPass(); for( int i = 0; i < work; ++i ) std::thread( workerBody ).detach(); - deadlocked = waitBusy( work ); + + auto dlStart = clock_t_::now(); + while( g_threadsFinished.load() < (size_t) work ) + { + std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + if( std::chrono::duration_cast( + clock_t_::now() - dlStart ).count() >= 12 ) + { + rtlog( "[RTPOOL] DEADLOCK mode=0: workers never spawned within 12s " + "(main-thread busy-wait starved the event loop)" ); + break; + } + } break; + } case 1: // B1 — detached threads + emscripten_sleep yield resetPass(); @@ -479,21 +489,13 @@ public: } long passMs = elapsedMs( p0 ); - if( deadlocked ) - rtlog( "[RTPOOL] DEADLOCK pass=%d workersRan=%d threadsFinished=%d " - "(workers never ran within 12s)", - pass, g_workersRan.load(), (int) g_threadsFinished.load() ); - else - rtlog( "[RTPOOL] PASS done pass=%d workersRan=%d passMs=%ld", - pass, g_workersRan.load(), passMs ); + rtlog( "[RTPOOL] PASS done pass=%d workersRan=%d passMs=%ld", + pass, g_workersRan.load(), passMs ); } long totalMs = elapsedMs( t0 ); - if( deadlocked ) - rtlog( "[RTPOOL] FAIL deadlocked mode=%d totalMs=%ld", mode, totalMs ); - else - rtlog( "[RTPOOL] SUCCESS mode=%d workersRan=%d totalMs=%ld sink=%.3f", - mode, g_workersRan.load(), totalMs, g_sink.load() ); + rtlog( "[RTPOOL] SUCCESS mode=%d workersRan=%d totalMs=%ld sink=%.3f", + mode, g_workersRan.load(), totalMs, g_sink.load() ); releaseParkedPool(); diff --git a/tests/e2e/coroutine-raytrace.spec.ts b/tests/e2e/coroutine-raytrace.spec.ts index 207ba40..78f49d4 100644 --- a/tests/e2e/coroutine-raytrace.spec.ts +++ b/tests/e2e/coroutine-raytrace.spec.ts @@ -1,19 +1,26 @@ import { test, expect } from './utils/fixtures'; -// Reproduction + fix validation for the KiCad WASM raytracer threading deadlock +// Proves the KiCad WASM raytracer's threading mechanisms genuinely run MULTI-CORE // (kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp). One binary, // switchable by URL ?m=: -// m=0 A detached threads + std::this_thread::sleep_for -> DEADLOCK (reproduces the bug) +// m=0 A detached threads + main-thread busy-wait -> DEADLOCK (negative control) // m=1 B1 detached threads + emscripten_sleep yield -> multi-core (works in isolation) // m=2 B2 persistent pool + emscripten_sleep yield -> multi-core (works in isolation) -// m=3 C serial on the calling thread -> the shipped fallback +// m=3 C serial on the calling thread -> baseline for the speedup test only // m=4 B1 with stack-local atomics -> multi-core (locals survive yield) // m=5 B3 persistent pool + sleep_for busy-wait -> multi-core (no emscripten_sleep) // +// POLARITY: each test is green IFF the mechanism actually parallelizes (workersRan > 1) +// and red otherwise. The naive busy-wait (m=0) is kept ONLY as a negative control: it is +// held to the SAME bar (workersRan > 1), cannot meet it (it deadlocks), and is marked +// `test.fail()` so it shows as an EXPECTED failure — never a green pass. A frozen tab is +// never a passing state. (Full deadlock finding: docs/features/async/11-asyncify-nesting-raytracer.md.) +// The serial mode (m=3) is exercised only as the slower baseline inside the speedup test, +// not as a standalone pass (green-on-single-core would be backwards). +// // NOTE: this is a STANDALONE wxWidgets harness — it re-implements the threading patterns -// itself and has NO connection to KiCad's render_3d_raytrace_base.cpp. It passes -// regardless of what the real viewer ships; it exists to prove the deadlock + the fix -// mechanism in seconds (not the ~12-min KiCad build). +// itself and has NO connection to KiCad's render_3d_raytrace_base.cpp. It validates the +// threading MECHANISM in seconds (not the ~12-min KiCad build), not the shipped viewer. // // TODO(asyncify-nesting): the real KiCad 3D viewer currently ships SERIAL (single-core). // The emscripten_sleep variants (B1/B2/m4) pass HERE but ABORT the real viewer with @@ -51,21 +58,13 @@ async function waitForLog( testLogger: { consoleLogs: string[] }, needle: string .toBe( true ); } -test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp repro)', () => { - - test( 'C: serial baseline completes on a single thread', async ( { page, testLogger } ) => { - await page.goto( `${APP}#m=3&${WORK}` ); - await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=3' ); - const r = parseSuccess( testLogger.consoleLogs, 3 )!; - expect( r.workersRan, 'serial runs on exactly one thread' ).toBe( 1 ); - } ); +test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp) — must run multi-core', () => { test( 'B1: detached threads + emscripten_sleep yield → multi-core, no deadlock', async ( { page, testLogger } ) => { await page.goto( `${APP}#m=1&${WORK}` ); await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=1' ); const r = parseSuccess( testLogger.consoleLogs, 1 )!; expect( r.workersRan, 'work should run on multiple worker threads' ).toBeGreaterThan( 1 ); - expect( testLogger.consoleLogs.some( l => l.includes( '[RTPOOL] FAIL' ) ) ).toBe( false ); } ); test( 'B2: persistent pool + yield → multi-core across repeated passes', async ( { page, testLogger } ) => { @@ -86,7 +85,6 @@ test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp repro)', () => await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=4' ); const r = parseSuccess( testLogger.consoleLogs, 4 )!; expect( r.workersRan, 'work runs on multiple threads with local atomics' ).toBeGreaterThan( 1 ); - expect( testLogger.consoleLogs.some( l => l.includes( '[RTPOOL] FAIL' ) ) ).toBe( false ); } ); test( 'B3: persistent pool + sleep_for busy-wait (real raytracer mechanism) → multi-core', async ( { page, testLogger } ) => { @@ -97,7 +95,6 @@ test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp repro)', () => await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=5' ); const r = parseSuccess( testLogger.consoleLogs, 5 )!; expect( r.workersRan, 'busy-wait pool runs on multiple cores' ).toBeGreaterThan( 1 ); - expect( testLogger.consoleLogs.some( l => l.includes( '[RTPOOL] FAIL' ) ) ).toBe( false ); } ); test( 'multi-core (B1) is faster than serial (C)', async ( { page, testLogger } ) => { @@ -115,12 +112,17 @@ test.describe( 'Raytracer threading (render_3d_raytrace_base.cpp repro)', () => expect( parallel.totalMs, 'parallel should beat serial' ).toBeLessThan( serial.totalMs ); } ); - test( 'A: detached threads + busy-wait reproduces the deadlock', async ( { page, testLogger } ) => { - // The main thread busy-waits, never returns to the event loop, so the on-demand - // workers never start. The in-test 12s guard then prints the DEADLOCK marker. + // Negative control. The naive desktop pattern (detached threads + a main-thread + // busy-wait) DEADLOCKS in the browser: the busy-wait starves the event loop, so the + // on-demand workers never spawn and workersRan stays 0. Marked test.fail(): it is held + // to the SAME bar as every other test (workersRan > 1) and is EXPECTED to miss it, so + // it is reported as an expected failure — never a green pass. If this ever PASSES, the + // deadlock got solved and this should graduate into a real test. + test( 'A (negative control): naive detached threads + busy-wait CANNOT run multi-core', async ( { page, testLogger } ) => { + test.fail(); await page.goto( `${APP}#m=0&${WORK}`, { waitUntil: 'domcontentloaded' } ); - await waitForLog( testLogger, '[RTPOOL] DEADLOCK', 30000 ); - expect( testLogger.consoleLogs.some( l => l.includes( '[RTPOOL] SUCCESS' ) ), - 'no pass should succeed under the busy-wait' ).toBe( false ); + await waitForLog( testLogger, '[RTPOOL] SUCCESS mode=0', 20000 ); + const r = parseSuccess( testLogger.consoleLogs, 0 )!; + expect( r.workersRan, 'the naive busy-wait cannot parallelize in the browser' ).toBeGreaterThan( 1 ); } ); } );