From 7630c7e52190eb56b0dbeb929130a545e45cdab7 Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:59:42 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20=F0=9F=90=9B=20raytrace=20deadlock=20-?= =?UTF-8?q?=20pre-warm=202N+8=20pthread=20Workers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/features/3d-raytracer/README.md | 191 ++++++++++++++++++ .../shared-pool-vs-raw-threads.md | 178 ++++++++++++++++ scripts/kicad/build-kicad-target.sh | 18 +- tests/kicad/3d-viewer-deadlock.spec.ts | 190 +++++++++++++++++ tests/kicad/3d-viewer.spec.ts | 92 +-------- tests/kicad/utils/threed-viewer.ts | 97 +++++++++ tests/playwright-kicad.config.ts | 132 ++++++------ wxwidgets | 2 +- 8 files changed, 745 insertions(+), 155 deletions(-) create mode 100644 docs/features/3d-raytracer/README.md create mode 100644 docs/features/3d-raytracer/shared-pool-vs-raw-threads.md create mode 100644 tests/kicad/3d-viewer-deadlock.spec.ts create mode 100644 tests/kicad/utils/threed-viewer.ts diff --git a/docs/features/3d-raytracer/README.md b/docs/features/3d-raytracer/README.md new file mode 100644 index 0000000..b562579 --- /dev/null +++ b/docs/features/3d-raytracer/README.md @@ -0,0 +1,191 @@ +# 3D viewer raytracer — the camera-move worker deadlock, and the fix + +> **Status:** fixed on the `3d-raytracer` branch (2026-07-01). The 3D viewer's CPU raytracer +> froze the tab (deadlock) on any interaction — move the model, drag the viewer frame, resize it. +> Root cause is an on-demand Web Worker boot deadlock; the fix is a build-flag change +> (`PTHREAD_POOL_SIZE`) plus a wxwidgets wasm-layer defer, with **zero KiCad-submodule changes**. +> +> Related reading: [`shared-pool-vs-raw-threads.md`](shared-pool-vs-raw-threads.md) (upstream's two +> threading patterns, the Worker ledger behind `2N+8`, and the upstream-able structural fix), +> [`../threading/README.md`](../threading/README.md) (the three-layer thread model +> + the deadlock mechanics), [`../async/11-asyncify-nesting-raytracer.md`](../async), +> [`../wasm-exceptions/`](../wasm-exceptions) (native-EH pthreads), and +> [`kicad-3d-viewer-route-c`] (the viewer is a CPU raytracer blitted via a WebGL2 quad). +> +> Artifacts referenced (line numbers against the branch at fix time): +> `kicad/3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp`, +> `kicad/common/thread_pool.cpp`, `scripts/kicad/build-kicad-target.sh`, +> `wxwidgets/src/wasm/app.cpp`, `wxwidgets/src/wasm/toplevel.cpp`, +> `wasm/shims/nanosleep_yield.c`. + +## 1. Symptom + +Open pcbnew, load a board, open the 3D viewer (View → 3D Viewer / Alt+3). The viewer opens and +renders the board fine. Then **interact with it** — rotate the model (left-drag on the 3D canvas), +drag the viewer frame, or resize it — and the **whole tab freezes** (the wasm main thread is stuck; +no further UI, no console progress). Occasionally it surfaces as `Aborted(invalid state: N)` instead +of a hard freeze. + +A previous fix (`wxwidgets` `93d07af586`, "don't synchronously raytrace on 3D-viewer resize") cured +the *resize* case by not running a synchronous repaint of a GL-hosting window. That was one instance +of a broader bug; **camera moves and other interactions still deadlocked.** + +## 2. The threading model (why this is fragile) + +See [`../threading/README.md`](../threading/README.md) for the full picture. The essentials: + +- The WASM build has **no `-sPROXY_TO_PTHREAD`** — `main()`, the wx UI, and the raytracer's + thread **join all run on the browser main thread**. +- `-sPTHREAD_POOL_SIZE` pre-warms a bag of Web Workers at startup; `-sPTHREAD_POOL_SIZE_STRICT=0` + means an empty bag falls back to **on-demand `new Worker()`**. +- KiCad's shared `BS::thread_pool` (`common/thread_pool.cpp`, sized to `hardware_concurrency()`) + creates its long-lived threads at startup and thereby **consumes the entire pre-warmed pool**. +- The raytracer's *preview* and *post-process* passes (`render_3d_raytrace_base.cpp` `renderPreview`, + `postProcessShading`, `postProcessBlurFinish`) spawn their **own** set of raw `std::thread`s and + then **busy-wait-join on the main thread** (`while (threadsFinished < N) std::this_thread::sleep_for(10ms)`). +- The `nanosleep_yield.c` shim turns that main-thread `sleep_for`/`nanosleep` into an + `emscripten_sleep` (an Asyncify unwind) so the event loop *can* be pumped during the join. + +## 3. Root cause — the on-demand Worker boot deadlock + +Because KiCad's thread pool already drained the pre-warmed pool, when a camera move kicks a raytrace, +the raytracer's raw `std::thread`s have **no ready Workers** and must create them on demand. On-demand +creation is asynchronous: `new Worker()` → the Worker posts a *"loaded"* message → **the main thread's +message handler must run to post `{cmd:'run'}`** before the thread actually starts. + +But the main thread is **blocked in the render's busy-wait join** waiting for those very threads to +finish. Circular wait: + +``` +main thread: busy-wait join ── waits for ──▶ raytrace worker thread to run +raytrace worker: not booted ── needs ──▶ main thread back in the event loop to finish new Worker() boot +main thread: won't get there ── because ──▶ it's in the busy-wait join +``` + +→ **hard freeze** (the join never completes), or — when the nested `emscripten_sleep` can't unwind in +the paint context — `Aborted(invalid state)`. + +The `nanosleep_yield` shim is supposed to break this by yielding, and it does in the isolated +`coroutine-pthread-ondemand` harness — but **not** reliably in the real viewer's paint call chain, so +it cannot be relied on here. + +## 4. Why the earlier remedies were not enough + +- **The resize fix (`wx_window_resize`)** and the analogous **mouse-button `Paint()` defer** (below) + only change *where* the raytrace runs (synchronous DOM callback → the yielding per-frame pump). But + the pump raytrace **still deadlocks** if it needs an on-demand boot: a diagnostic that rotates the + camera then polls liveness with **no further interaction** froze ~2–4 s later. Deferring is + necessary-adjacent (avoids jank, mirrors the resize fix) but **insufficient alone**. +- The real lever is to make sure the raytracer's threads **never need an on-demand boot**. + +## 5. The fix (zero KiCad changes) + +Two parts, both outside the `kicad` submodule (KiCad's native multithreading is used as-is): + +### 5a. Primary — pre-warm enough Workers (`scripts/kicad/build-kicad-target.sh`) + +A 3D-viewer session needs KiCad's pool (`N = hardwareConcurrency`) **plus** the raytracer's own +raw-thread pass (`N`) alive at the same time — i.e. ~`2N` Workers. Pre-warm that many so on-demand +creation never happens: + +```sh +# Only 3D-viewer builds pay the extra startup Workers; other apps keep one set. +if [ "${BUILD_3D_VIEWER:-OFF}" = "ON" ]; then + PTHREAD_POOL_EXPR='navigator.hardwareConcurrency*2+8' +else + PTHREAD_POOL_EXPR='navigator.hardwareConcurrency' +fi +# ... -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' ... +``` + +`PTHREAD_POOL_SIZE` is a JS-side pre-warm count baked into the app's `.js` (`var pthreadPoolSize = …`), +so the change is verifiable by inspecting the generated `pcbnew.js` — no wasm change is involved. + +### 5b. Defense + jank — defer the GL-window mouse-button repaint (`wxwidgets/src/wasm/`) + +`wxApp::HandleMouseEvent` forces a **synchronous `wxTheApp->Paint()` after every mouse button +down/up** (for immediate modal-dialog feedback). On the 3D viewer that repaints the GL canvas and runs +the raytracer *nested in the DOM mouse callback*. `wxApp::Paint(bool deferGLCanvasWindows)` now skips +the synchronous repaint of any **non-main** `wxGLCanvas`-hosting window on that path (the 3D viewer, or +a dialog with a 3D preview), deferring it to the yielding per-frame pump — exactly like the resize fix. +`ProcessPendingEvents()` still runs synchronously, so click/selection *logic* stays immediate; only the +GL pixel repaint waits one pump frame. + +- `include/wx/wasm/app.h` — `void Paint(bool deferGLCanvasWindows = false)`. +- `src/wasm/app.cpp` — the guard in `Paint()` (`!IsMainFrame() && wxWasmWindowHostsGLCanvas(win)`), + and `HandleMouseEvent` calls `Paint(/*deferGLCanvasWindows=*/true)`. +- `src/wasm/toplevel.cpp` — `wxWasmWindowHostsGLCanvas()` made non-static (moved out of the + `extern "C"` block so it keeps **C++ linkage**) and shared with `app.cpp`. + +> This part alone does **not** fix the deadlock (see §4). It is kept because it removes a +> multi-hundred-ms synchronous raytrace from the mouse callback (jank) and is a defense-in-depth match +> to the merged resize fix. It also covers dialog-hosted 3D previews (footprint 3D-models tab). + +## 6. The regression test + +`tests/kicad/3d-viewer-deadlock.spec.ts` — an **isolated** spec (own file → own Playwright worker → +own browser process). It: + +1. Boots pcbnew, loads `pic_programmer`, opens the 3D viewer. +2. Does **two camera-rotate drags** on the GL canvas (mirroring "move the model … move it again"), + each followed by a **settle** (polls the canvas pixel signature until it stops changing) so + successive renders don't overlap. +3. After each step asserts **(a) the wasm main thread stays responsive** — a `page.waitForFunction` + liveness probe that times out when the main thread is frozen — and **(b) nothing aborted** + (`Aborted(` / `invalid state` / Asyncify-unwind signatures in the console). +4. Validity guard: the render must actually change after a move (proves the synthetic mouse reached + the canvas), and the board must still render many colours at the end. + +Detection detail: the glcanvas client area is `pointer-events:none`, so a `page.mouse` drag over it +**falls through to the main `#canvas`**, whose Emscripten mousedown/up callback dispatches into +`wxApp::HandleMouseEvent` — the deadlock path. WebGL pixels are read via `drawImage → 2D → getImageData` +(needs `preserveDrawingBuffer=true`), since a CDP screenshot of a WebGL canvas is blank on swiftshader. + +### Why isolated in its own file + +The pre-warmed pool is ~`2N` Workers **per pcbnew load**. Running several heavy 3D-viewer loads in one +browser process (a serial `describe`) accumulates enough Workers that a *later* load's pool is short +and the raytracer deadlocks anyway — so this test flakes as the last of many loads and, because a +serial-group retry re-runs the whole group, retries don't rescue it. A dedicated file → dedicated +worker → a **single** heavy load → reliably first-try green. The four sibling tests +(`tests/kicad/3d-viewer.spec.ts`: open/render, z-index, frame drag/close, edge-resize) stay in their +own file; shared helpers live in `tests/kicad/utils/threed-viewer.ts`. Both specs are routed to +`chromium-ci` via `PCBNEW_FAMILY_SPECS` in `tests/playwright-kicad.config.ts` (pcbnew's ~190 MB wasm +OOMs Firefox's x86 CI engine). + +## 7. Reproduce / verify + +Build a 3D-viewer-enabled pcbnew (`BUILD_3D_VIEWER=ON` is the default in `docker/build.sh`): + +```sh +BINARYEN_OPT_LEVEL=-O1 ./docker/build.sh pcbnew # first build in a fresh worktree: add --build-deps +``` + +Run just the deadlock test (from `tests/`, own worker → reliable): + +```sh +npm run setup:kicad +npx playwright test --config=playwright-kicad.config.ts --project=firefox kicad/3d-viewer-deadlock.spec.ts +``` + +- **Pre-fix** (revert §5a): the test freezes/aborts — "camera-rotate drag froze the wasm main thread". +- **Post-fix**: green (both camera moves stay live, board keeps rendering). + +Manual test in the real editor: `cd web && pnpm dev` (or `pnpm --filter @pcbjam/standalone dev`) → +open http://localhost:3048 → "open a local folder" → `kicad/demos/pic_programmer/` → open the +`.kicad_pcb` → View → 3D Viewer → rotate / drag / resize. The editor symlinks `/wasm` → +`tests/apps/kicad`, so it serves the locally built (fixed) artifacts. + +## 8. Notes / follow-ups + +- The `2N+8` pool costs extra startup Workers **only for 3D-viewer builds**; real users open the + viewer in a single page load, so there is no accumulation for them (the accumulation only bit the + 5-loads-in-one-process test suite, addressed by isolating the test — §6). +- A cleaner long-term fix would make the raytracer's preview/post-process passes reuse KiCad's shared + thread pool instead of spawning their own raw `std::thread`s (no second thread set → the `1x` pool + suffices), but that is an upstream-KiCad change and was deliberately avoided here to keep the fork + close to upstream. **See [`shared-pool-vs-raw-threads.md`](shared-pool-vs-raw-threads.md)** for the + full story: upstream's two threading patterns (the main `renderTracing` pass *already* uses the + shared pool — only the preview/post-process/scene-build passes are raw), the `2N+8` Worker ledger, + why the June-30 threading revert didn't cover this, and the sketch of the upstream port that would + make the deadlock class impossible by construction. diff --git a/docs/features/3d-raytracer/shared-pool-vs-raw-threads.md b/docs/features/3d-raytracer/shared-pool-vs-raw-threads.md new file mode 100644 index 0000000..a2cc08c --- /dev/null +++ b/docs/features/3d-raytracer/shared-pool-vs-raw-threads.md @@ -0,0 +1,178 @@ +# Shared pool vs raw `std::thread` — why the raytracer deadlocks and where `2N+8` comes from + +> Companion to [`README.md`](README.md) (the camera-move deadlock fix). That doc describes *what* +> was fixed; this one explains the underlying architecture: upstream KiCad's **two** threading +> patterns, why only one of them is WASM-hostile, the Worker ledger behind the +> `PTHREAD_POOL_SIZE = hardwareConcurrency*2+8` constant, why the June-30 threading revert did +> **not** already cover this, and the upstream-able follow-up that would make the whole deadlock +> class impossible by construction. +> +> Line numbers are against the branch at fix time (KiCad 10.0.4; the cited KiCad files are +> byte-identical to upstream). + +## 1. Web Workers are the scarce resource — and a missing one can't be waited for + +Every pthread in the browser runs inside a Web Worker. Emscripten obtains one of two ways: + +- **Pre-warmed** (`-sPTHREAD_POOL_SIZE`): Workers created and fully loaded at startup, parked + idle. `pthread_create` against one is a single `postMessage` that the Worker's **own** event + loop receives — the main thread does not participate. +- **On-demand** (the fallback, since `-sPTHREAD_POOL_SIZE_STRICT=0`): `new Worker()` at + `pthread_create` time. The Worker must fetch + instantiate the (~190 MB) module, then post + *"loaded"* back to the **main thread**, whose handler posts *"run"*. The thread only starts + once the main thread returns to its event loop. + +That asymmetry is the whole story. A Worker shortfall is not a slowdown: if the spawner then +blocks the main thread waiting for the spawned threads (the raytracer's busy-wait join), the +on-demand boot handshake can never complete — circular wait, tab frozen. See +[`README.md` §3](README.md) for the deadlock diagram. + +## 2. Upstream KiCad has two threading patterns, side by side + +**Pattern 1 — the shared pool.** `KICAD_SINGLETON::Init()` (`common/singleton.cpp:60-62`) +creates one `BS::priority_thread_pool` (`include/thread_pool.h:31`) with +`hardware_concurrency()` threads (`bs_thread_pool.hpp:1949-1954`) **once, at startup**. The +threads park on a condition variable; subsystems *submit tasks*: +zone fill (`pcbnew/zone_filler.cpp:736,825`), DRC +(`drc/drc_test_provider_copper_clearance.cpp:697`), connectivity (`pcbnew/board.cpp:1159`), +footprint loading (`pcbnew/footprint_info_impl.cpp:218`) — and, notably, **the raytracer's own +main tracing pass**, `renderTracing` +(`3d-viewer/3d_rendering/raytracing/render_3d_raytrace_base.cpp:250-288`): + +```cpp +thread_pool& tp = GetKiCadThreadPool(); +// ... +for( size_t i = 0; i < tp.get_thread_count(); ++i ) + futures.push_back( tp.submit_task( processBlocks ) ); +futures.wait(); +``` + +Running a task = queue-push + condvar-notify to threads whose Workers **already exist**. No +`pthread_create` ever happens after startup. + +**Pattern 2 — raw `std::thread`.** Older 3D-viewer code predating KiCad's pool adoption creates +`std::max( hardware_concurrency(), 2 )` fresh threads **per pass**, detaches them, and busy-waits +(`while( threadsFinished < N ) sleep_for( 10ms )`) on the calling (= browser main) thread: + +| Site | Spawn / join | Runs when | +|---|---|---| +| `renderPreview` | `render_3d_raytrace_base.cpp:818` / `:1401` | **every camera move** (the interaction pass) | +| `postProcessShading` | `render_3d_raytrace_base.cpp:707` / `:727` | end of a full trace | +| `postProcessBlurFinish` | `render_3d_raytrace_base.cpp:756` / `:789` | end of a full trace | +| `BOARD_ADAPTER::createLayers` helpers | `create_layer_items.cpp:1241,1742` | scene build (viewer open / board change) | +| `IMAGE::EfxFilter` | `image.cpp:488` | not on the raytrace hot path | + +The WASM-critical difference between the patterns is **when `pthread_create` runs**: Pattern 1 +calls it at startup, when the main thread is idle and the pre-warmed bag is full. Pattern 2 calls +it at **render time** — mid-interaction, with the main thread about to block in the join. Only +Pattern 2 can ever need an on-demand Worker boot, and it needs it at the worst possible moment. + +It is a wry detail that upstream already ported the *slow* pass (`renderTracing`, the full-quality +progressive trace) to the pool, while the pass that runs on **every camera move** +(`renderPreview`) is still raw — which is exactly why the deadlock bites on interaction. + +## 3. The Worker ledger — deriving `2N+8` + +The invariant the build must satisfy: **pre-warmed Workers ≥ peak simultaneous live pthreads.** +Any shortfall silently switches the excess threads to on-demand boot (`STRICT=0`), i.e. to the +deadlock path. On an 8-core machine (`N = 8`): + +| Consumer | Workers | Held | +|---|---|---| +| KiCad shared pool (Pattern 1, `singleton.cpp:60`) | `N` = 8 | forever (threads never exit) | +| One raw-thread pass in flight (Pattern 2) | `N` = 8 | for the pass duration | +| Margin (`+8`) | 8 | see below | +| **Pre-warmed total** | **`2N+8` = 24** | | + +The `2N` is *derived*: two independent populations, both sized off `hardware_concurrency` by +upstream, provably alive at the same time (the pool threads never exit; a raw pass spawns its full +set before joining). With the old `PTHREAD_POOL_SIZE = N`, the free warm count at render time was +**exactly zero** — every `renderPreview` thread went on-demand, which is why the freeze was 100% +reproducible, not flaky. + +The `+8` is *margin*, not derived. It absorbs: + +- **Recycle lag** — an exited pthread's Worker returns to the warm bag only after the **main + thread** processes its exit message. Back-to-back passes (preview → trace slices → post-process) + can spawn while a few Workers from the previous pass are still in limbo. +- **Stray long-lived threads** outside the pool that each permanently hold a Worker (e.g. the + font-list poller, `common/widgets/font_choice.cpp:100`, if active in a given app). +- Small-core machines, where `2N` alone is a small absolute number. + +Honest assessment: this is a **capacity answer to a structural problem**. The constant chases the +runtime behavior of upstream code we deliberately don't patch — if a future KiCad adds another +raw-thread site or resizes its pool, `2N+8` silently goes stale. The guard is the regression test +(`tests/kicad/3d-viewer-deadlock.spec.ts`), not construction. §5 is the structural fix. + +## 4. "Didn't the June-30 threading revert already fix this?" + +No — it's what *exposed* this, and the distinction matters: + +1. **Route C** (kicad `4ccabfd5c3`, 2026-06-18): multi-threaded wasm didn't survive Asyncify, so + the raytracer passes were forced single-threaded behind `#ifdef __EMSCRIPTEN__` and the shared + pool got an inline-`detach_task` shim. No parallelism → no Worker demand → no deadlock. +2. **Native EH** (root `c1ef489`/`ee01642`, 2026-06-29) removed the Asyncify-rewind crash that + motivated those hacks. +3. **The threading revert** (kicad `4f42d0b328` + root `71807db`, 2026-06-30) restored the four + files **byte-identical to upstream** and linked `wasm/shims/nanosleep_yield.c` as the guard: + the busy-wait join yields via Asyncify so on-demand Workers *can* boot. + +The revert restored upstream's **mixed** state — Pattern 1 for `renderTracing`, Pattern 2 for +everything in the §2 table. It did not (and could not) route the raw passes through the pool, +because **upstream itself never has**. "Original upstream threading" *is* the raw-thread preview +pass. And the revert's guard, the nanosleep yield, turned out to hold only in some call chains: +it boots Workers fine at viewer-open (scene build + first render, reached via the yielding pump), +but not reliably in interaction paint chains — hard freeze, or `Aborted(invalid state)` when the +unwind is illegal ([`README.md` §3–4](README.md)). The June-30 e2e suite was green (63/63) because +no test then dragged **on the GL canvas** — the only drag test moved the DOM titlebar; the +camera-move path had no coverage until `3d-viewer-deadlock.spec.ts`. + +So the layering on this branch is: the revert made upstream threading *run*; the `2N+8` pre-warm +makes the fragile on-demand path *unnecessary*; the shim and the mouse-button `Paint()` defer +remain as fallback layers beneath it. + +## 5. Follow-up — finish the port upstream already started + +The structural fix is to convert the §2 table's raw-thread sites to the pattern `renderTracing` +already demonstrates ~400 lines above them in the same file: submit the same block-consuming +lambdas to `GetKiCadThreadPool()` instead of spawning threads. + +```cpp +// today (renderPreview and friends): spawn + detach + busy-wait +for( size_t ii = 0; ii < parallelThreadCount; ++ii ) + std::thread( [&]() { /* consume blocks via nextBlock.fetch_add */ } ).detach(); +while( threadsFinished < parallelThreadCount ) + std::this_thread::sleep_for( std::chrono::milliseconds( 10 ) ); + +// ported: the renderTracing pattern (render_3d_raytrace_base.cpp:284-288) +thread_pool& tp = GetKiCadThreadPool(); +BS::multi_future futures; +for( size_t i = 0; i < tp.get_thread_count(); ++i ) + futures.push_back( tp.submit_task( /* same lambda */ ) ); +futures.wait(); +``` + +Consequences: + +- **The deadlock class disappears by construction** — no `pthread_create` after startup, so no + on-demand Worker boot can ever be needed, regardless of pool sizing, call chain, or Asyncify + state. Not "unlikely"; impossible. +- `PTHREAD_POOL_SIZE` drops back to plain `navigator.hardwareConcurrency` — no multiplier, no + margin, no staleness risk. The nanosleep shim and the `Paint()` defer become pure + belt-and-braces. +- Native desktop benefits too: no create/destroy churn of ~N OS threads per pass per progressive + frame, and the raytracer becomes consistent with every other KiCad subsystem. + +Wrinkles, all manageable: the main-thread wait can stay `futures.wait()` (proven viable in this +very build — `renderTracing` does it today) or keep the counter + `sleep_for` loop (which the +shim turns into a UI-pumping yield); every §2 call site runs on the main thread, so there is no +nested-submission (pool-task-waiting-on-pool-task) hazard; it's a priority pool if preview passes +ever need to jump the queue. + +**Why it's deferred, not done here:** those exact files were made byte-identical to upstream the +day before this fix (kicad `4f42d0b328`) — re-diverging them reverses that cleanup and walks the +fork away from upstream again (the standing fork policy; `scripts/kicad-diff-stats.sh` polices +it). The right vehicle is an **upstream KiCad merge request** — "port the 3D raytracer's remaining +raw-`std::thread` passes to the shared thread pool, like `renderTracing`" is an upstream-quality +cleanup with native benefits. If it lands, the fork inherits it on the next rebase and the pool +expression collapses back to `N`. diff --git a/scripts/kicad/build-kicad-target.sh b/scripts/kicad/build-kicad-target.sh index dcddbf0..f8ebf06 100755 --- a/scripts/kicad/build-kicad-target.sh +++ b/scripts/kicad/build-kicad-target.sh @@ -441,6 +441,22 @@ NANOSLEEP_YIELD_LINK="${STUBS_BUILD}/nanosleep_yield.o" emcc -c "${PROJECT_ROOT}/wasm/shims/mallinfo_stub.c" -o "${STUBS_BUILD}/mallinfo_stub.o" MALLINFO_STUB_LINK="${STUBS_BUILD}/mallinfo_stub.o" +# Pre-warmed Web Worker pool size (emscripten pthreads). The 3D-viewer CPU raytracer runs +# KiCad's shared thread pool (hardware_concurrency long-lived threads, created at startup) +# AND, for camera-move preview + post-process passes, spawns its OWN set of raw std::thread +# workers — so a 3D-viewer session needs ~2x hardware_concurrency Workers *simultaneously*. +# With only one set pre-warmed, the raytracer's threads fall back to on-demand `new Worker()`, +# whose loaded→run handshake needs the main thread back in the JS event loop — which it can't +# reach while blocked in the render's busy-wait join (no PROXY_TO_PTHREAD; the join runs on the +# browser main thread). That circular wait is the 3D-viewer deadlock: moving the model, dragging +# or resizing the viewer all freeze the tab. Pre-warm enough Workers that on-demand creation +# never happens. Only 3D-viewer builds pay the extra startup Workers; other apps keep one set. +if [ "${BUILD_3D_VIEWER:-OFF}" = "ON" ]; then + PTHREAD_POOL_EXPR='navigator.hardwareConcurrency*2+8' +else + PTHREAD_POOL_EXPR='navigator.hardwareConcurrency' +fi + emcmake cmake "${KICAD_DIR}" \ ${CCACHE_OPTS} \ ${SYM_CONVERTER_CMAKE_FLAG} \ @@ -451,7 +467,7 @@ emcmake cmake "${KICAD_DIR}" \ -DCMAKE_POLICY_VERSION_MINIMUM=3.5 \ -DCMAKE_CXX_FLAGS="${EXTRA_FLAGS} -Xclang -fno-pch-timestamp -pthread -sUSE_ZLIB=1 -DKICAD_USE_PLATFORM_WASM=1${DIAG_DEFINES} -I${SYSROOT}/include -I${STUBS_DIR} -include ${STUBS_DIR}/char_traits_uint16_workaround.h" \ -DCMAKE_C_FLAGS="${EXTRA_FLAGS} -pthread -sUSE_ZLIB=1 -I${SYSROOT}/include -I${STUBS_DIR}" \ - -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread -sUSE_ZLIB=1 -sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536 -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='navigator.hardwareConcurrency' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall'] --bind -L${SYSROOT}/lib ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ + -DCMAKE_EXE_LINKER_FLAGS="${LINKER_DEBUG_FLAGS} -pthread -sUSE_ZLIB=1 -sASYNCIFY=1 -sDYNCALLS=1 -sASYNCIFY_STACK_SIZE=65536 -sUSE_PTHREADS=1 -sMALLOC=mimalloc -sPTHREAD_POOL_SIZE='${PTHREAD_POOL_EXPR}' -sPTHREAD_POOL_SIZE_STRICT=0 -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=256MB -sMAXIMUM_MEMORY=4GB -sMAX_WEBGL_VERSION=2 ${GL3D_LINK_FLAGS} ${NANOSLEEP_YIELD_LINK} ${MALLINFO_STUB_LINK} -sEXPORTED_RUNTIME_METHODS=['ccall','cwrap','UTF8ToString','stringToUTF8','lengthBytesUTF8','dynCall'] -sDEFAULT_LIBRARY_FUNCS_TO_INCLUDE=['\$dynCall'] --bind -L${SYSROOT}/lib ${STUBS_BUILD}/libgit2_stub.a ${STUBS_BUILD}/libcurl_stub.a${APP_STUB_LINK} ${STUBS_BUILD}/libnng_stub.a ${EMBIND_OBJ}" \ -DCMAKE_PREFIX_PATH="${SYSROOT};${WX_BUILD}" \ -DwxWidgets_CONFIG_EXECUTABLE="${WX_BUILD}/wx-config" \ \ diff --git a/tests/kicad/3d-viewer-deadlock.spec.ts b/tests/kicad/3d-viewer-deadlock.spec.ts new file mode 100644 index 0000000..251c761 --- /dev/null +++ b/tests/kicad/3d-viewer-deadlock.spec.ts @@ -0,0 +1,190 @@ +import { test, expect } from './fixtures'; +import { waitForPcbnew } from './utils/pcbnew-ready'; +import { countGlCanvases, loadBoard, openThreeDViewer } from './utils/threed-viewer'; + +/** + * Regression for the camera-move-on-canvas raytrace DEADLOCK. + * + * The 3D viewer's EDA_3D_CANVAS is a wxGLCanvas whose paint runs the multi-threaded CPU + * raytracer. Moving the camera makes the raytracer spawn raw std::thread workers; with no + * PROXY_TO_PTHREAD its join runs on the browser main thread, and KiCad's own thread pool has + * already drained emscripten's pre-warmed Worker pool — so those threads fall back to on-demand + * `new Worker()`, whose boot handshake needs the main thread back in the JS event loop, which it + * can't reach while blocked in the render's busy-wait join. That circular wait is the freeze the + * user hit ("move the model → tab freezes"). The fix pre-warms PTHREAD_POOL_SIZE = + * hardwareConcurrency*2+8 for 3D-viewer builds (scripts/kicad/build-kicad-target.sh) so on-demand + * creation never happens; a wxwidgets wasm-layer change (src/wasm/app.cpp) additionally defers the + * 3D viewer's synchronous mouse-button Paint to the yielding pump (jank + defense, mirroring the + * resize-deadlock fix). + * + * Drives the REAL viewer with two camera-rotate drags (mirroring "move the model … move it + * again"), each followed by a settle for the raytrace to converge, asserting after every step that + * (a) the wasm main thread stays responsive (a deadlock hangs it) and (b) nothing aborted. Pre-fix + * a camera drag freezes or aborts; post-fix both stay live and the board keeps rendering. Frame + * drag/resize of the viewer are covered by 3d-viewer.spec.ts. + * + * ISOLATED in its own spec file (own Playwright worker → own browser process → a SINGLE heavy + * pcbnew load). The pre-warmed pool is ~2x hardwareConcurrency Workers PER load; running several + * 3D-viewer loads in one process (a serial describe) accumulates enough Workers that a later + * load's pool is short and the raytracer deadlocks anyway — so this test must not share a worker + * with the other 3D-viewer tests. + * + * Notes: the glcanvas client area is pointer-events:none, so a page.mouse drag over it falls + * through to the main #canvas whose Emscripten mousedown/up callback dispatches into + * wxApp::HandleMouseEvent — the deadlock path. WebGL pixels are read via drawImage→2D→getImageData + * (preserveDrawingBuffer=true), since a CDP screenshot of a WebGL canvas is blank on swiftshader. + */ +test.describe('3D viewer camera-move deadlock', () => { + // One 187 MB wasm runtime is already heavy; keep this serial and generous. + test.describe.configure({ mode: 'serial' }); + test.setTimeout(240000); + + test('camera-move drag on the 3D canvas does not deadlock the raytracer (regression)', + async ({ page, testLogger }) => { + await page.goto('/kicad/pcbnew.html'); + await waitForPcbnew(page); + await loadBoard(page, testLogger); + + const winsBefore = await page.evaluate(() => + Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id)); + const glBefore = await countGlCanvases(page); + await openThreeDViewer(page, glBefore); + + const winId = await page.evaluate((before: string[]) => { + const all = Array.from(document.querySelectorAll('#window-container [id^="window-"]')).map((e) => e.id); + return all.find((id) => !before.includes(id)) ?? all[all.length - 1] ?? null; + }, winsBefore); + expect(winId, 'the 3D viewer should open a new top-level window').toBeTruthy(); + + // Let the INITIAL raytrace settle through the safe per-frame pump (Workers boot here). + await page.waitForTimeout(5000); + + // Read the newest glcanvas-* (the 3D viewer) client rect in viewport coords. + const canvasRect = () => page.evaluate(() => { + const list = document.querySelectorAll('canvas[id^="glcanvas-"]'); + const el = list[list.length - 1] as HTMLCanvasElement; + const r = el.getBoundingClientRect(); + return { x: r.x, y: r.y, w: r.width, h: r.height }; + }); + + // Sample the viewer canvas backing store: distinct colours (board rendered?) plus a + // coarse pixel signature (did the render change after the camera moved?). + const sampleCanvas = () => page.evaluate(() => { + const list = document.querySelectorAll('canvas[id^="glcanvas-"]'); + const el = list[list.length - 1] as HTMLCanvasElement; + const tmp = document.createElement('canvas'); + tmp.width = el.width; tmp.height = el.height; + const ctx = tmp.getContext('2d')!; + ctx.drawImage(el, 0, 0); + const colors = new Set(); + let sig = ''; + for (let i = 0; i < 16; i++) { + for (let j = 0; j < 16; j++) { + const d = ctx.getImageData(Math.floor(el.width * i / 16), + Math.floor(el.height * j / 16), 1, 1).data; + colors.add(`${d[0]},${d[1]},${d[2]}`); + sig += `${d[0]}.${d[1]}.${d[2]}|`; + } + } + return { distinctColors: colors.size, sig }; + }); + + // Main-thread liveness probe. A pthread-join deadlock hangs the wasm main thread → the + // browser main JS thread is blocked → in-page polling can't run → this times out. + // Returns false on a freeze rather than throwing. + const mainThreadAlive = () => page.waitForFunction(() => { + const r = (window as unknown as { wxElementRegistry?: { findAll: (o: unknown) => unknown[] } }) + .wxElementRegistry; + return !!r && r.findAll({ visible: true }).length > 0; + }, null, { timeout: 15000 }).then(() => true).catch(() => false); + + const abortLines = () => [...testLogger.consoleLogs, ...testLogger.errors].filter((l) => + l.includes('Aborted(') + || l.toLowerCase().includes('invalid state') + || l.toLowerCase().includes('uncaught exception: unwind') + || l.toLowerCase().includes('indirect call to null')); + + // Run an interaction with a hard wall-clock bound: a hard freeze can also hang the CDP + // input dispatch, so this fails fast instead of at the 240s test timeout. + const bounded = async (fn: () => Promise, ms: number): Promise => { + let froze = false; + await Promise.race([ + fn().catch(() => { /* an in-wasm abort surfaces via abortLines(), not here */ }), + new Promise((r) => setTimeout(() => { froze = true; r(); }, ms)), + ]); + return froze; + }; + + // Rotate the camera: left-drag inside the GL region (below the ~28px titlebar). + const rotate = async () => { + const c = await canvasRect(); + const cx = c.x + c.w / 2; + const cy = c.y + c.h / 2 + 40; + await page.mouse.move(cx, cy); + await page.mouse.down(); + await page.mouse.move(cx + 140, cy + 70, { steps: 14 }); + await page.mouse.move(cx - 90, cy + 20, { steps: 10 }); + await page.mouse.up(); + }; + // Wait for the raytrace kicked by a camera move to CONVERGE (canvas signature stable + // across two samples) before the next move, so successive renders don't overlap and + // momentarily demand more pthread Workers than the pool holds. On the fixed build this + // returns in a few polls; a mid-render deadlock is caught by the assertLive that follows. + const settleRender = async (maxMs: number) => { + let prev = ''; + const start = Date.now(); + while (Date.now() - start < maxMs) { + await page.waitForTimeout(1500); + const s = (await sampleCanvas()).sig; + if (s === prev) return; + prev = s; + } + }; + + // After each step: nothing aborted, main thread still live. + const assertLive = async (step: string) => { + expect(abortLines(), `raytrace aborted during "${step}":\n${abortLines().join('\n\n')}`) + .toEqual([]); + expect(await mainThreadAlive(), + `wasm main thread unresponsive after "${step}" → deadlock`).toBe(true); + }; + + const before = await sampleCanvas(); + console.log(`[TEST] 3D render before interaction: ${before.distinctColors} distinct colours`); + + // THE deadlock path: a left-drag on the 3D canvas rotates the model; the terminating + // mouse button events drive wxApp::HandleMouseEvent's synchronous Paint() of the + // wxGLCanvas, running the multi-threaded CPU raytracer. Pre-fix its on-demand pthread + // Worker boot deadlocks the main thread; post-fix the pre-warmed pool covers it and each + // move stays live. Two moves with a settle between mirror the user's "move the model … + // move it again". + let froze = await bounded(rotate, 30000); + expect(froze, 'the first camera-rotate drag froze the wasm main thread (deadlock)').toBe(false); + await assertLive('camera rotate'); + await settleRender(25000); + await assertLive('camera rotate settle'); + + // Validity: the render changed → the synthetic mouse actually reached the 3D canvas + // (guards against a false green where the drag missed the canvas entirely). + const mid = await sampleCanvas(); + expect(mid.sig, + 'the 3D render did not change after the first camera move — the synthetic mouse ' + + 'likely never reached the 3D canvas (invalid repro), or the render stalled') + .not.toBe(before.sig); + + froze = await bounded(rotate, 30000); + expect(froze, 'the second camera-rotate drag froze the main thread').toBe(false); + await assertLive('camera rotate again'); + await settleRender(25000); + await assertLive('camera rotate again settle'); + + await page.screenshot({ path: 'test-results/3d-viewer-deadlock.png', scale: 'device' }); + + // Sanity: the board still renders (not blank / crashed) after both moves. + const after = await sampleCanvas(); + console.log(`[TEST] 3D render after interaction: ${after.distinctColors} distinct colours`); + expect(after.distinctColors, + 'the 3D viewer should still render the board (many colours) after the camera moves') + .toBeGreaterThan(8); + }); +}); diff --git a/tests/kicad/3d-viewer.spec.ts b/tests/kicad/3d-viewer.spec.ts index d206247..5b870cb 100644 --- a/tests/kicad/3d-viewer.spec.ts +++ b/tests/kicad/3d-viewer.spec.ts @@ -1,9 +1,6 @@ -import type { Page } from '@playwright/test'; import { test, expect } from './fixtures'; -import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker'; -import { injectFromSubmodule } from './utils/fs-inject'; -import { waitForBoardLoaded } from './utils/board-ready'; import { waitForPcbnew } from './utils/pcbnew-ready'; +import { DEMO, loadBoard, countGlCanvases, openThreeDViewer } from './utils/threed-viewer'; /** * 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer @@ -21,93 +18,6 @@ import { waitForPcbnew } from './utils/pcbnew-ready'; * models — deferred), the viewer shows copper/silk/mask/edge geometry in 3D. */ -// KiCad 10 stores projects under /home/kicad/documents/kicad/10.0/projects. -const KICAD_VERSION_DIR = '10.0'; -const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`; - -// pic_programmer frames correctly in the default 3D camera (the microwave demo -// has a known board-bounding-box scale bug that projects it off-screen — a -// separate follow-up). Loads cleanly in this harness (see 2D load tests). -const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const; - -async function loadBoard(page: Page, testLogger: { consoleLogs: string[]; errors: string[] }): Promise { - const pcbFilename = `${DEMO.stem}.kicad_pcb`; - const proFilename = `${DEMO.stem}.kicad_pro`; - - await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`, - `${PROJECT_DIR_MEMFS}/${pcbFilename}`); - await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`, - `${PROJECT_DIR_MEMFS}/${proFilename}`); - - expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true); - await page.waitForTimeout(400); - expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true); - - await page.waitForFunction(() => { - const registry = window.wxElementRegistry; - return !!registry && registry.findAll({ visible: true }) - .some((el) => el.typeName === 'wxFileDialog'); - }, null, { timeout: 15000 }); - await page.waitForTimeout(1000); - - const filenameInput = await page.evaluate(() => { - const registry = window.wxElementRegistry; - if (!registry) return null; - const text = registry.findAll({ visible: true }) - .find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); - return text ? { x: text.centerX, y: text.centerY } : null; - }); - expect(filenameInput, 'filename text input should be visible').not.toBeNull(); - if (!filenameInput) throw new Error('filename text input not found'); - - await page.mouse.click(filenameInput.x, filenameInput.y); - await page.waitForTimeout(200); - await page.keyboard.type(pcbFilename); - await page.waitForTimeout(300); - await page.keyboard.press('Enter'); - await page.waitForTimeout(1000); - - const result = await waitForBoardLoaded(page, testLogger, 60000); - console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); -} - -function countGlCanvases(page: Page): Promise { - return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length); -} - -// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the -// secondary frame + its NEW `glcanvas-*` to appear. The main pcbnew board view is -// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing. -// Returns the glcanvas count after opening. `glBefore` is the count beforehand. -async function openThreeDViewer(page: Page, glBefore: number): Promise { - let opened = false; - if (await clickMenuBarItem(page, 'View')) { - await page.waitForTimeout(400); - opened = await clickMenuItem(page, '3D Viewer'); - } - if (!opened) { - console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3'); - await page.keyboard.press('Escape'); - await page.waitForTimeout(200); - await page.keyboard.press('Alt+3'); - } - - await page.waitForFunction(() => { - // A new top-level window div beyond the main pcbnew frame. - return !!document.querySelector('#window-container [id^="window-"]') - || document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0; - }, null, { timeout: 60000 }); - - await page.waitForFunction((before: number) => - document.querySelectorAll('canvas[id^="glcanvas-"]').length > before, - glBefore, { timeout: 60000 }); - - const glAfter = await countGlCanvases(page); - console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`); - expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore); - return glAfter; -} - test.describe('3D viewer from pcbnew', () => { // One 187 MB wasm runtime is already heavy; keep this serial and generous. test.describe.configure({ mode: 'serial' }); diff --git a/tests/kicad/utils/threed-viewer.ts b/tests/kicad/utils/threed-viewer.ts new file mode 100644 index 0000000..8284007 --- /dev/null +++ b/tests/kicad/utils/threed-viewer.ts @@ -0,0 +1,97 @@ +import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import { clickMenuBarItem, clickMenuItem } from '../../e2e/utils/element-tracker'; +import { injectFromSubmodule } from './fs-inject'; +import { waitForBoardLoaded } from './board-ready'; + +// Shared helpers for the 3D-viewer specs (3d-viewer.spec.ts + 3d-viewer-deadlock.spec.ts). + +// KiCad 10 stores projects under /home/kicad/documents/kicad/10.0/projects. +export const KICAD_VERSION_DIR = '10.0'; +export const PROJECT_DIR_MEMFS = `/home/kicad/documents/kicad/${KICAD_VERSION_DIR}/projects`; + +// pic_programmer frames correctly in the default 3D camera (the microwave demo +// has a known board-bounding-box scale bug that projects it off-screen — a +// separate follow-up). Loads cleanly in this harness (see 2D load tests). +export const DEMO = { name: 'pic_programmer', dir: 'pic_programmer', stem: 'pic_programmer' } as const; + +export async function loadBoard( + page: Page, + testLogger: { consoleLogs: string[]; errors: string[] }, +): Promise { + const pcbFilename = `${DEMO.stem}.kicad_pcb`; + const proFilename = `${DEMO.stem}.kicad_pro`; + + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${pcbFilename}`, + `${PROJECT_DIR_MEMFS}/${pcbFilename}`); + await injectFromSubmodule(page, `kicad/demos/${DEMO.dir}/${proFilename}`, + `${PROJECT_DIR_MEMFS}/${proFilename}`); + + expect(await clickMenuBarItem(page, 'File'), 'File menu should be findable').toBe(true); + await page.waitForTimeout(400); + expect(await clickMenuItem(page, 'Open...'), 'Open… menu item should be findable').toBe(true); + + await page.waitForFunction(() => { + const registry = window.wxElementRegistry; + return !!registry && registry.findAll({ visible: true }) + .some((el) => el.typeName === 'wxFileDialog'); + }, null, { timeout: 15000 }); + await page.waitForTimeout(1000); + + const filenameInput = await page.evaluate(() => { + const registry = window.wxElementRegistry; + if (!registry) return null; + const text = registry.findAll({ visible: true }) + .find((el) => el.typeName === 'wxTextCtrl' && el.name === 'text'); + return text ? { x: text.centerX, y: text.centerY } : null; + }); + expect(filenameInput, 'filename text input should be visible').not.toBeNull(); + if (!filenameInput) throw new Error('filename text input not found'); + + await page.mouse.click(filenameInput.x, filenameInput.y); + await page.waitForTimeout(200); + await page.keyboard.type(pcbFilename); + await page.waitForTimeout(300); + await page.keyboard.press('Enter'); + await page.waitForTimeout(1000); + + const result = await waitForBoardLoaded(page, testLogger, 60000); + console.log(`[TEST] ${DEMO.name} board-ready result: ${result}`); +} + +export function countGlCanvases(page: Page): Promise { + return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length); +} + +// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the +// secondary frame + its NEW `glcanvas-*` to appear. The main pcbnew board view is +// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing. +// Returns the glcanvas count after opening. `glBefore` is the count beforehand. +export async function openThreeDViewer(page: Page, glBefore: number): Promise { + let opened = false; + if (await clickMenuBarItem(page, 'View')) { + await page.waitForTimeout(400); + opened = await clickMenuItem(page, '3D Viewer'); + } + if (!opened) { + console.log('[TEST] View → 3D Viewer not found via menu; trying Alt+3'); + await page.keyboard.press('Escape'); + await page.waitForTimeout(200); + await page.keyboard.press('Alt+3'); + } + + await page.waitForFunction(() => { + // A new top-level window div beyond the main pcbnew frame. + return !!document.querySelector('#window-container [id^="window-"]') + || document.querySelectorAll('canvas[id^="glcanvas-"]').length > 0; + }, null, { timeout: 60000 }); + + await page.waitForFunction((before: number) => + document.querySelectorAll('canvas[id^="glcanvas-"]').length > before, + glBefore, { timeout: 60000 }); + + const glAfter = await countGlCanvases(page); + console.log(`[TEST] glcanvas count after opening 3D viewer: ${glAfter}`); + expect(glAfter, 'a new WebGL canvas should appear for the 3D viewer').toBeGreaterThan(glBefore); + return glAfter; +} diff --git a/tests/playwright-kicad.config.ts b/tests/playwright-kicad.config.ts index b550faa..a099ff8 100644 --- a/tests/playwright-kicad.config.ts +++ b/tests/playwright-kicad.config.ts @@ -1,9 +1,9 @@ -import { defineConfig, devices } from '@playwright/test'; -import { execSync } from 'child_process'; -import * as fs from 'fs'; -import * as path from 'path'; +import { defineConfig, devices } from "@playwright/test"; +import { execSync } from "child_process"; +import * as fs from "fs"; +import * as path from "path"; -const PORT_FILE = path.join(__dirname, '.test-port'); +const PORT_FILE = path.join(__dirname, ".test-port"); // NOTE: Chrome headless crashes on ARM Mac due to SwiftShader WebGL bug // (Chromium issues #1416283, #338414704). Firefox headless works reliably. @@ -30,10 +30,10 @@ const PORT_FILE = path.join(__dirname, '.test-port'); // forked with an empty argv), and it imports this config — and so writes the // file — before any worker is spawned. function resolvePort(): number { - const isMainRunner = process.argv.slice(2).includes('test'); + const isMainRunner = process.argv.slice(2).includes("test"); if (!isMainRunner) { try { - const existing = parseInt(fs.readFileSync(PORT_FILE, 'utf-8').trim(), 10); + const existing = parseInt(fs.readFileSync(PORT_FILE, "utf-8").trim(), 10); if (existing > 0 && existing < 65536) { return existing; } @@ -51,8 +51,8 @@ function resolvePort(): number { function findFreePort(): number { try { const result = execSync( - 'python3 -c "import socket; s=socket.socket(); s.bind((\'\',0)); print(s.getsockname()[1]); s.close()"', - { encoding: 'utf-8' } + "python3 -c \"import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()\"", + { encoding: "utf-8" } ); return parseInt(result.trim()); } catch { @@ -68,44 +68,47 @@ const port = resolvePort(); // on arm64, whose denser code fits). V8 handles it, so on CI these specs run // on bundled Chromium (the 'chromium-ci' project) and firefox skips them. const PCBNEW_FAMILY_SPECS = [ - '**/pcbnew.spec.ts', - '**/pcbnew-collab.spec.ts', - '**/load-pcb.spec.ts', - '**/load-pcb-probe.spec.ts', + "**/pcbnew.spec.ts", + "**/pcbnew-collab.spec.ts", + "**/load-pcb.spec.ts", + "**/load-pcb-probe.spec.ts", // Specs added 2026-06-11..06-13 that boot pcbnew (pcbnew.html / // pcbnew-collab.html). Without routing here they ran on Firefox in CI and // timed out at instantiation (the SpiderMonkey/x86 code-budget OOM above). // The last three are parametrized across pl_editor/eeschema/pcbnew; routing // the whole file moves those variants to chromium-ci too (they boot fine on // V8) — only the browser exercising them changes, not whether they run. - '**/appearance.spec.ts', - '**/contextmenu-scrollbar-pcbnew.spec.ts', - '**/dark-mode.spec.ts', - '**/items-bridge.spec.ts', - '**/roundtrip.spec.ts', - '**/save-hook.spec.ts', + "**/appearance.spec.ts", + "**/contextmenu-scrollbar-pcbnew.spec.ts", + "**/dark-mode.spec.ts", + "**/items-bridge.spec.ts", + "**/roundtrip.spec.ts", + "**/save-hook.spec.ts", // boots pcbnew.html — must run on V8 (chromium-ci); on Firefox/x86 CI the // ~190M module OOMs at instantiation and #canvas never appears (run 27626037849). - '**/pcbnew-move.spec.ts', + "**/pcbnew-move.spec.ts", // 3D viewer specs boot pcbnew.html (3D-enabled build) — same V8 routing. - '**/3d-viewer.spec.ts', - '**/3d-viewer-models.spec.ts', - '**/footprint-3d-preview.spec.ts', + "**/3d-viewer.spec.ts", + // Isolated (own file → own worker) so its heavy single load isn't degraded by the + // Worker accumulation of the other 3D-viewer tests sharing a process (see the file header). + "**/3d-viewer-deadlock.spec.ts", + "**/3d-viewer-models.spec.ts", + "**/footprint-3d-preview.spec.ts", ]; // Runtime-perf specs run ONLY on the Chromium 'perf' project below: they need // CDP CPU throttling (Chromium-only) and pcbnew needs V8. Excluded from the // firefox/chromium projects so they don't double-run there. -const PERF_SPECS = ['**/*-perf.spec.ts']; +const PERF_SPECS = ["**/*-perf.spec.ts"]; -const appsDir = 'apps'; +const appsDir = "apps"; export default defineConfig({ - globalSetup: './global-setup.ts', - testDir: './kicad', + globalSetup: "./global-setup.ts", + testDir: "./kicad", // See playwright.config.ts: keep CI's outputDir cleanup off test-results/ so the kicad + // perf runs don't wipe the accumulated screenshots. - outputDir: process.env.CI ? 'pw-artifacts/kicad' : 'test-results', + outputDir: process.env.CI ? "pw-artifacts/kicad" : "test-results", fullyParallel: true, forbidOnly: !!process.env.CI, // 1 local retry absorbs the known under-parallel-load flakes (same rationale @@ -116,47 +119,52 @@ export default defineConfig({ // local — the serial CI run was the dominant wall-clock cost. Cap (e.g. '50%' // or a fixed count) if contention OOMs/flakes; retries:2 covers transient. workers: undefined, - reporter: 'html', - timeout: 180000, // KiCad WASM needs more time to load (3 minutes) + reporter: "html", + timeout: 180000, // KiCad WASM needs more time to load (3 minutes) use: { baseURL: `http://localhost:${port}`, - trace: 'retain-on-failure', - screenshot: 'only-on-failure', + trace: "retain-on-failure", + screenshot: "only-on-failure", }, projects: [ { // Firefox is the default for headless testing (works on ARM Mac) - name: 'firefox', + name: "firefox", // Perf specs always run on the dedicated 'perf' project, never here. On CI // the pcbnew-family specs also move to chromium-ci (see PCBNEW_FAMILY_SPECS). - testIgnore: [...PERF_SPECS, ...(process.env.CI ? PCBNEW_FAMILY_SPECS : [])], + testIgnore: [ + ...PERF_SPECS, + ...(process.env.CI ? PCBNEW_FAMILY_SPECS : []), + ], use: { - ...devices['Desktop Firefox'], + ...devices["Desktop Firefox"], viewport: { width: 1280, height: 720 }, // CI-only prefs: GPU-less CI VMs hit two Firefox blockers (identical on // Hetzner ccx53 and ubicloud-standard-30, runs 27329612719/27330989479). // Gated on CI so local runs keep stock Firefox behavior. - ...(process.env.CI ? { - // Headless Firefox cannot create any GL context on the GPU-less CI - // VMs (blocklist bypass still ends in FEATURE_FAILURE_WEBGL_EXHAUSTED_ - // DRIVERS) — run headed under Xvfb instead, where GLX + Mesa llvmpipe - // provides software WebGL. CI invokes the suite via `xvfb-run`. - headless: false, - launchOptions: { - firefoxUserPrefs: { - // Skip the no-GPU blocklist ("AllowWebgl2:false restricts - // context creation") so the GAL canvas gets a WebGL context. - 'webgl.force-enabled': true, - // pcbnew.wasm (~190M) OOMs the optimizing wasm JIT at compile - // time ("InternalError: out of memory") and the app never boots. - // Baseline-only compilation trades runtime speed for a compile - // that fits in memory. - 'javascript.options.wasm_optimizingjit': false, - }, - }, - } : {}), + ...(process.env.CI + ? { + // Headless Firefox cannot create any GL context on the GPU-less CI + // VMs (blocklist bypass still ends in FEATURE_FAILURE_WEBGL_EXHAUSTED_ + // DRIVERS) — run headed under Xvfb instead, where GLX + Mesa llvmpipe + // provides software WebGL. CI invokes the suite via `xvfb-run`. + headless: false, + launchOptions: { + firefoxUserPrefs: { + // Skip the no-GPU blocklist ("AllowWebgl2:false restricts + // context creation") so the GAL canvas gets a WebGL context. + "webgl.force-enabled": true, + // pcbnew.wasm (~190M) OOMs the optimizing wasm JIT at compile + // time ("InternalError: out of memory") and the app never boots. + // Baseline-only compilation trades runtime speed for a compile + // that fits in memory. + "javascript.options.wasm_optimizingjit": false, + }, + }, + } + : {}), }, }, { @@ -165,10 +173,10 @@ export default defineConfig({ // The bundled Chromium fails with canvas hidden on ARM Mac because of // Chromium issues #1416283, #338414704 (SwiftShader WebGL bug). // Run via: npm run test:kicad:headed - name: 'chromium', + name: "chromium", testIgnore: PERF_SPECS, use: { - channel: 'chrome', + channel: "chrome", viewport: { width: 1280, height: 720 }, }, }, @@ -178,13 +186,13 @@ export default defineConfig({ // (fine on x86 Linux; the SwiftShader bug above is ARM-Mac-specific). // --enable-unsafe-swiftshader: newer Chromium refuses software WebGL in // headless without it. - name: 'chromium-ci', + name: "chromium-ci", testMatch: PCBNEW_FAMILY_SPECS, use: { - ...devices['Desktop Chrome'], + ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, launchOptions: { - args: ['--enable-unsafe-swiftshader'], + args: ["--enable-unsafe-swiftshader"], }, }, }, @@ -194,12 +202,12 @@ export default defineConfig({ // throttle (FPS rose with throttle). --enable-unsafe-swiftshader lets it use // software WebGL headless on CI; harmless with a real GPU locally. Add --headed // locally for real-GPU FPS numbers. - name: 'perf', + name: "perf", testMatch: PERF_SPECS, use: { - ...devices['Desktop Chrome'], + ...devices["Desktop Chrome"], viewport: { width: 1280, height: 720 }, - launchOptions: { args: ['--enable-unsafe-swiftshader'] }, + launchOptions: { args: ["--enable-unsafe-swiftshader"] }, }, }, ], diff --git a/wxwidgets b/wxwidgets index 93d07af..b3f0a09 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit 93d07af58617aafb6df5e8d512227a094d5106ea +Subproject commit b3f0a09d43ebcbe87cdf36b16a156551d4171519