test: 💍 deadlock test + readme update

This commit is contained in:
Istvan Matejcsok 2026-06-18 16:48:00 +02:00
commit 5d18bd0c45
4 changed files with 235 additions and 81 deletions

View 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 (~67× 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 ~67× 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 (~67× 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).

View file

@ -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 (~67×) built + parked; unpark needs a *nestable* yield (fiber/JSPI). |
## The single decisive next step

View file

@ -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<std::chrono::seconds>( 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<std::chrono::seconds>(
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,19 +489,11 @@ 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 );
}
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() );

View file

@ -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 );
} );
} );