pcbjam/tests/kicad/3d-viewer-deadlock.spec.ts
Viktor Vaczi 63ed1f3c1f e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.

Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.

Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.

One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.

SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.

Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.

CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.

Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:21:54 +02:00

249 lines
15 KiB
TypeScript

import { test, expect } from './fixtures';
import { clickByTooltip, clickToolbarTool, shotPath } from '../e2e/utils/element-tracker';
import { waitForPcbnew } from './utils/pcbnew-ready';
import { countGlCanvases, loadBoard, logThreeDDiag, openThreeDViewer } from './utils/threed-viewer';
/**
* Regression for the camera-move-on-canvas raytrace DEADLOCK.
*
* Since the 3D-webgl port the wasm viewer DEFAULTS to the real OpenGL renderer, which never
* had this deadlock — so this spec explicitly flips the viewer to the raytracer engine (its
* toolbar toggle) before driving it; the raytracer remains user-reachable via that same
* toggle, so the regression it guards is still live product surface.
*
* The 3D viewer's EDA_3D_CANVAS is a wxGLCanvas whose paint (on the raytracer engine) 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', () => {
// CI-skip: this test asserts the wasm main thread stays responsive within ~15s DURING a
// raytrace. That premise only holds on a real GPU (fast render); on CI's software WebGL
// (headless SwiftShader, GPU-less contended VM) a legitimately-slow raytrace is
// indistinguishable from a deadlock, so the test can't run meaningfully there. The
// pthread-Worker-boot deadlock MECHANISM it guards is already covered on CI by the
// standalone wx harnesses (coroutine-pthread-ondemand, raytrace-threads). Runs locally
// on a real GPU.
test.skip(!!process.env.CI, 'raytracer liveness assertions require a real GPU; deadlock '
+ 'mechanism is covered on CI by the standalone coroutine-pthread-ondemand/raytrace-threads harnesses');
// KNOWN ISSUE (2026-07-04, webgl-era wasm build): the "Use raytracing" toolbar toggle is
// INERT — the click lands and a scene reload fires ("Reload time" status updates), but the
// canvas pixels never change (sampled for 20s; this spec's engine-engagement guard below
// caught it). Suspects: EDA_3D_CANVAS::DoRePaint's silent catch(runtime_error) freezing
// the canvas after a raytracer Redraw throw, or the engine toggle writing
// m_boardAdapter.m_Cfg while RenderEngineChanged() reads GetAppSettings<…>() — possibly
// different instances in the merged kicad_editor bundle. Unskip once the toggle works:
// the engagement guard below then validates the engine flip loudly.
test.skip(true, 'KNOWN ISSUE: the raytracer engine toggle is inert on the webgl-era wasm '
+ 'build — the deadlock mechanism cannot be driven until it works (see comment)');
// 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 render settle through the safe per-frame pump (Workers boot here).
await page.waitForTimeout(5000); // eslint-disable-line -- skipped known-issue spec (never runs)
await logThreeDDiag(page, 'deadlock: after open+settle');
// 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;
// One full-frame read on a CPU-backed canvas, then sample in JS. This sampler is
// POLLED every 1.5s during an active raytrace (settleRender) — as 256 per-pixel
// getImageData GPU round-trips per poll it was the worst "GPU stall due to
// ReadPixels" offender on software WebGL (see 3d-viewer.spec.ts).
const ctx = tmp.getContext('2d', { willReadFrequently: true })!;
ctx.drawImage(el, 0, 0);
const img = ctx.getImageData(0, 0, el.width, el.height).data;
const colors = new Set<string>();
let sig = '';
for (let i = 0; i < 16; i++) {
for (let j = 0; j < 16; j++) {
const p = (Math.floor(el.height * j / 16) * el.width
+ Math.floor(el.width * i / 16)) * 4;
colors.add(`${img[p]},${img[p + 1]},${img[p + 2]}`);
sig += `${img[p]}.${img[p + 1]}.${img[p + 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<void>, ms: number): Promise<boolean> => {
let froze = false;
await Promise.race([
fn().catch(() => { /* an in-wasm abort surfaces via abortLines(), not here */ }),
new Promise<void>((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); // eslint-disable-line -- skipped known-issue spec (never runs)
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);
};
// Since the 3D-webgl port (kicad eb13ff3bdc) the wasm viewer defaults to the REAL
// OpenGL renderer; the deadlock mechanism this spec guards is raytracer-specific, so
// flip the viewer to the raytracer via its toolbar toggle. Loud failure if the
// toggle moved — a silent no-op would leave the fast GL renderer making every
// liveness assertion below vacuously green.
const sigOnGl = (await sampleCanvas()).sig;
const toggled = (await clickToolbarTool(page, 'Use raytracing'))
|| (await clickByTooltip(page, 'Render current view using Raytracing'));
expect(toggled, 'the "Use raytracing" toolbar toggle must exist in the 3D viewer').toBe(true);
// Engine cross-check (guards the same false green): with NO input between the two
// samples, only an engine change repaints the canvas differently — the raytraced
// frame is lit/shadowed differently from the GL frame it replaces, a GL re-render
// reproduces the identical pixels, and a no-op leaves the canvas untouched. (Heap
// growth is NOT a usable signal here: mimalloc satisfies the raytracer's buffers
// from already-freed arena pages, so HEAPU8.length stays flat.)
let raytracerEngaged = false;
for (let i = 0; i < 20 && !raytracerEngaged; i++) {
await page.waitForTimeout(1000); // eslint-disable-line -- skipped known-issue spec (never runs)
raytracerEngaged = (await sampleCanvas()).sig !== sigOnGl;
}
expect(raytracerEngaged,
'the canvas did not change after the engine toggle — the raytracer did not engage')
.toBe(true);
// Let the first full raytrace converge before taking the interaction baseline.
await settleRender(25000);
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: shotPath(page, '3d-viewer-deadlock.png'), scale: 'device' });
// Sanity: the board still renders (not blank / crashed) after both moves.
await logThreeDDiag(page, 'deadlock: after 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);
});
});