fix(ci): serialize the heavy 3D-viewer e2e specs (dedicated chromium-ci-3d project)
The three heavy 3D-viewer specs (3d-viewer, 3d-viewer-deadlock, 3d-viewer-models)
crashed on CI (run 28604015154): "Target crashed", "browser has been closed", and a
black frozen canvas. Each boots the 3D-enabled pcbnew build, which pre-warms
~hardwareConcurrency*2+8 (~68 on the 30-core VM) Web Workers AND runs a multi-threaded
CPU raytracer over SwiftShader software-WebGL. Under fullyParallel the three spec FILES
run in separate browser processes concurrently, and the simultaneous raytrace tabs
exhaust the shared GPU process's ~16 live-WebGL-context limit / renderer memory. The
pool pre-warm (the deadlock fix, 7630c7e) is correct and present in the tested binary;
per-file isolation prevented in-process Worker accumulation but NOT this cross-process
concurrency — the gap these specs' first CI exposure hit.
Fix (test-orchestration only; no wasm/submodule changes):
- Route the three specs to a dedicated `chromium-ci-3d` Playwright project (own
outputDir=pw-artifacts/kicad-3d so its failure traces survive the later perf run's
wipe; the existing tests/pw-artifacts/** upload already captures it).
- test:kicad:ci runs it as a SECOND `playwright test` pass with --workers=1, so at most
one raytrace tab is alive at a time (the environment in which a single 3D load is
reliably green — docs/features/3d-raytracer/README.md §7). An exit-code-aggregating
sequencer runs both phases and fails if either fails (no silent skip of the 3D pass).
- Cap phase-1 CI workers to 12 as headroom under the WebGL-context cap.
- Log a resource-diagnostic snapshot (hardwareConcurrency, pthread pool, GL-canvas
count, wasm/JS heap) before each 3D interaction so any recurrence is attributable.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
34d2602132
commit
add7c60975
6 changed files with 87 additions and 8 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
import { waitForPcbnew } from './utils/pcbnew-ready';
|
import { waitForPcbnew } from './utils/pcbnew-ready';
|
||||||
import { countGlCanvases, loadBoard, openThreeDViewer } from './utils/threed-viewer';
|
import { countGlCanvases, loadBoard, logThreeDDiag, openThreeDViewer } from './utils/threed-viewer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Regression for the camera-move-on-canvas raytrace DEADLOCK.
|
* Regression for the camera-move-on-canvas raytrace DEADLOCK.
|
||||||
|
|
@ -58,6 +58,7 @@ test.describe('3D viewer camera-move deadlock', () => {
|
||||||
|
|
||||||
// Let the INITIAL raytrace settle through the safe per-frame pump (Workers boot here).
|
// Let the INITIAL raytrace settle through the safe per-frame pump (Workers boot here).
|
||||||
await page.waitForTimeout(5000);
|
await page.waitForTimeout(5000);
|
||||||
|
await logThreeDDiag(page, 'deadlock: after open+settle');
|
||||||
|
|
||||||
// Read the newest glcanvas-* (the 3D viewer) client rect in viewport coords.
|
// Read the newest glcanvas-* (the 3D viewer) client rect in viewport coords.
|
||||||
const canvasRect = () => page.evaluate(() => {
|
const canvasRect = () => page.evaluate(() => {
|
||||||
|
|
@ -181,6 +182,7 @@ test.describe('3D viewer camera-move deadlock', () => {
|
||||||
await page.screenshot({ path: 'test-results/3d-viewer-deadlock.png', scale: 'device' });
|
await page.screenshot({ path: 'test-results/3d-viewer-deadlock.png', scale: 'device' });
|
||||||
|
|
||||||
// Sanity: the board still renders (not blank / crashed) after both moves.
|
// Sanity: the board still renders (not blank / crashed) after both moves.
|
||||||
|
await logThreeDDiag(page, 'deadlock: after moves');
|
||||||
const after = await sampleCanvas();
|
const after = await sampleCanvas();
|
||||||
console.log(`[TEST] 3D render after interaction: ${after.distinctColors} distinct colours`);
|
console.log(`[TEST] 3D render after interaction: ${after.distinctColors} distinct colours`);
|
||||||
expect(after.distinctColors,
|
expect(after.distinctColors,
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { clickMenuBarItem, clickMenuItem } from '../e2e/utils/element-tracker';
|
||||||
import { injectFromSubmodule } from './utils/fs-inject';
|
import { injectFromSubmodule } from './utils/fs-inject';
|
||||||
import { waitForBoardLoaded } from './utils/board-ready';
|
import { waitForBoardLoaded } from './utils/board-ready';
|
||||||
import { waitForPcbnew } from './utils/pcbnew-ready';
|
import { waitForPcbnew } from './utils/pcbnew-ready';
|
||||||
|
import { logThreeDDiag } from './utils/threed-viewer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3D viewer COMPONENT MODELS e2e (docs/features/3d-models): load pic_programmer,
|
* 3D viewer COMPONENT MODELS e2e (docs/features/3d-models): load pic_programmer,
|
||||||
|
|
@ -193,6 +194,7 @@ test.describe('3D viewer component models', () => {
|
||||||
|
|
||||||
// Scene build + progressive raytrace passes.
|
// Scene build + progressive raytrace passes.
|
||||||
await page.waitForTimeout(8000);
|
await page.waitForTimeout(8000);
|
||||||
|
await logThreeDDiag(page, 'models: before screenshot');
|
||||||
await page.screenshot({ path: `test-results/3d-viewer-models-${DEMO.name}.png`, scale: 'css' });
|
await page.screenshot({ path: `test-results/3d-viewer-models-${DEMO.name}.png`, scale: 'css' });
|
||||||
|
|
||||||
// --- bridge assertions -------------------------------------------------
|
// --- bridge assertions -------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { test, expect } from './fixtures';
|
import { test, expect } from './fixtures';
|
||||||
import { waitForPcbnew } from './utils/pcbnew-ready';
|
import { waitForPcbnew } from './utils/pcbnew-ready';
|
||||||
import { DEMO, loadBoard, countGlCanvases, openThreeDViewer } from './utils/threed-viewer';
|
import { DEMO, loadBoard, countGlCanvases, logThreeDDiag, openThreeDViewer } from './utils/threed-viewer';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer
|
* 3D viewer e2e: load a real board in pcbnew, open the native 3D viewer
|
||||||
|
|
@ -318,6 +318,7 @@ test.describe('3D viewer from pcbnew', () => {
|
||||||
const beforeFrame = await frameWidth(winId as string);
|
const beforeFrame = await frameWidth(winId as string);
|
||||||
const beforeGl = await glWidth();
|
const beforeGl = await glWidth();
|
||||||
expect(beforeFrame, 'frame should have a width').toBeGreaterThan(0);
|
expect(beforeFrame, 'frame should have a width').toBeGreaterThan(0);
|
||||||
|
await logThreeDDiag(page, 'resize: before edge drag');
|
||||||
|
|
||||||
// Drag the right edge inward (left) to shrink the frame width.
|
// Drag the right edge inward (left) to shrink the frame width.
|
||||||
const edge = page.locator(`#${winId} .window-resize-e`);
|
const edge = page.locator(`#${winId} .window-resize-e`);
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,37 @@ export function countGlCanvases(page: Page): Promise<number> {
|
||||||
return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
|
return page.evaluate(() => document.querySelectorAll('canvas[id^="glcanvas-"]').length);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resource-diagnostic snapshot for the heavy 3D-viewer specs. On CI these can kill the tab
|
||||||
|
// (renderer OOM, a lost WebGL context when the shared GPU process passes its ~16-context cap,
|
||||||
|
// or a raytracer pthread deadlock) and the failure surfaces identically ("Target crashed" /
|
||||||
|
// black canvas). Logging hardwareConcurrency, the emscripten pthread-pool ledger, the live
|
||||||
|
// GL-canvas count, and the wasm/JS heap sizes right before an interaction makes any recurrence
|
||||||
|
// attributable (GL-context exhaustion vs heap-OOM vs deadlock) — and confirms, on the real CI
|
||||||
|
// VM, what navigator.hardwareConcurrency actually is (the whole 2N+8 pool sizing depends on it).
|
||||||
|
// Best-effort: never throws, so it can't itself fail a spec.
|
||||||
|
export async function logThreeDDiag(page: Page, label: string): Promise<void> {
|
||||||
|
const snap = await page.evaluate(() => {
|
||||||
|
const w = window as unknown as { Module?: Record<string, unknown> };
|
||||||
|
const M = (w.Module ?? {}) as Record<string, unknown>;
|
||||||
|
const P = (M.PThread ?? {}) as { unusedWorkers?: unknown[]; runningWorkers?: unknown[] };
|
||||||
|
const heap = M.HEAPU8 as { length?: number } | undefined;
|
||||||
|
const perf = (performance as unknown as { memory?: Record<string, number> }).memory ?? {};
|
||||||
|
const mb = (b?: number) => (typeof b === 'number' ? Math.round(b / 1048576) : null);
|
||||||
|
return {
|
||||||
|
hardwareConcurrency: navigator.hardwareConcurrency,
|
||||||
|
// emscripten pool ledger: pre-warmed-but-idle + currently-running Workers.
|
||||||
|
pthreadUnused: Array.isArray(P.unusedWorkers) ? P.unusedWorkers.length : null,
|
||||||
|
pthreadRunning: Array.isArray(P.runningWorkers) ? P.runningWorkers.length : null,
|
||||||
|
glCanvases: document.querySelectorAll('canvas[id^="glcanvas-"]').length,
|
||||||
|
canvases: document.querySelectorAll('canvas').length,
|
||||||
|
wasmHeapMB: mb(heap?.length),
|
||||||
|
jsHeapUsedMB: mb(perf.usedJSHeapSize),
|
||||||
|
jsHeapLimitMB: mb(perf.jsHeapSizeLimit),
|
||||||
|
};
|
||||||
|
}).catch((e: unknown) => ({ error: String(e) }));
|
||||||
|
console.log(`[DIAG ${label}] ${JSON.stringify(snap)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Open the 3D viewer (View → 3D Viewer, with an Alt+3 fallback) and wait for the
|
// 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
|
// 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.
|
// itself a wxGLCanvas, so the viewer is detected by the GL-canvas COUNT increasing.
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@
|
||||||
"test:kicad:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox",
|
"test:kicad:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox",
|
||||||
"test:kicad:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed",
|
"test:kicad:chrome": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=chromium --headed",
|
||||||
"test:kicad": "npm run test:kicad:firefox",
|
"test:kicad": "npm run test:kicad:firefox",
|
||||||
"test:kicad:ci": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox --project=chromium-ci",
|
"test:kicad:ci": "npm run setup:kicad && sh -c 'playwright test --config=playwright-kicad.config.ts --project=firefox --project=chromium-ci; a=$?; playwright test --config=playwright-kicad.config.ts --project=chromium-ci-3d --workers=1; b=$?; exit $((a||b))'",
|
||||||
"test:kicad:headed": "npm run test:kicad:chrome",
|
"test:kicad:headed": "npm run test:kicad:chrome",
|
||||||
"test:perf": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=perf --workers=1",
|
"test:perf": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=perf --workers=1",
|
||||||
"test:pcbnew:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox kicad/pcbnew.spec.ts",
|
"test:pcbnew:firefox": "npm run setup:kicad && playwright test --config=playwright-kicad.config.ts --project=firefox kicad/pcbnew.spec.ts",
|
||||||
|
|
|
||||||
|
|
@ -118,6 +118,24 @@ const BIG_MODULE_SPECS = [
|
||||||
"**/occ-probe.spec.ts",
|
"**/occ-probe.spec.ts",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// The heavy 3D-viewer specs each boot the 3D-enabled pcbnew build, which pre-warms
|
||||||
|
// ~hardwareConcurrency*2+8 Web Workers AND runs a multi-threaded CPU raytracer over
|
||||||
|
// SwiftShader software-WebGL. Under fullyParallel these separate spec FILES land in
|
||||||
|
// separate browser processes CONCURRENTLY, and the simultaneous raytrace tabs exhaust
|
||||||
|
// Workers / wasm heap / the shared GPU process's ~16 live-WebGL-context limit → the tab
|
||||||
|
// crashes ("Target crashed" / "browser has been closed" / a black frozen canvas). Per-file
|
||||||
|
// isolation (own worker) prevents in-process Worker accumulation but NOT this cross-process
|
||||||
|
// concurrency. They STAY in BIG_MODULE_SPECS (so firefox keeps ignoring them — the ~190 MB
|
||||||
|
// module OOMs SpiderMonkey/x86), but on CI they run in the dedicated 'chromium-ci-3d' project,
|
||||||
|
// which test:kicad:ci invokes with --workers=1 so at most ONE raytrace tab is alive at a time.
|
||||||
|
// (footprint-3d-preview.spec.ts is a placeholder glob — no such file exists yet — harmless.)
|
||||||
|
const THREE_D_HEAVY_SPECS = [
|
||||||
|
"**/3d-viewer.spec.ts",
|
||||||
|
"**/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
|
// 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
|
// CDP CPU throttling (Chromium-only) and pcbnew needs V8. Excluded from the
|
||||||
// firefox/chromium projects so they don't double-run there.
|
// firefox/chromium projects so they don't double-run there.
|
||||||
|
|
@ -137,10 +155,13 @@ export default defineConfig({
|
||||||
// as playwright.config.ts): the load-pcb post-load clipboard crash that can
|
// as playwright.config.ts): the load-pcb post-load clipboard crash that can
|
||||||
// close the page on Firefox, and the calculator first-run-wizard timing race.
|
// close the page on Firefox, and the calculator first-run-wizard timing race.
|
||||||
retries: process.env.CI ? 2 : 1,
|
retries: process.env.CI ? 2 : 1,
|
||||||
// Run parallel workers on CI too (Playwright default ≈ 50% of cores), same as
|
// Run parallel workers on CI too, same as local — the serial CI run was the dominant
|
||||||
// local — the serial CI run was the dominant wall-clock cost. Cap (e.g. '50%'
|
// wall-clock cost. Capped to a fixed 12 on CI (down from Playwright's default ≈ 50% of
|
||||||
// or a fixed count) if contention OOMs/flakes; retries:2 covers transient.
|
// cores = ~15 on the 30-core VM) as defense-in-depth for the ~16 live-WebGL-context limit
|
||||||
workers: undefined,
|
// in Chromium's shared GPU process: ~15 GAL-canvas tabs already brush that cap. The heavy
|
||||||
|
// 3D-viewer specs (a *second* WebGL context each) no longer run here — they're isolated to
|
||||||
|
// the serial 'chromium-ci-3d' project — so 12 is only headroom, not the primary fix.
|
||||||
|
workers: process.env.CI ? 12 : undefined,
|
||||||
reporter: "html",
|
reporter: "html",
|
||||||
timeout: 180000, // KiCad WASM needs more time to load (3 minutes)
|
timeout: 180000, // KiCad WASM needs more time to load (3 minutes)
|
||||||
|
|
||||||
|
|
@ -210,7 +231,29 @@ export default defineConfig({
|
||||||
// --enable-unsafe-swiftshader: newer Chromium refuses software WebGL in
|
// --enable-unsafe-swiftshader: newer Chromium refuses software WebGL in
|
||||||
// headless without it.
|
// headless without it.
|
||||||
name: "chromium-ci",
|
name: "chromium-ci",
|
||||||
testMatch: BIG_MODULE_SPECS,
|
// Every big-module spec EXCEPT the heavy 3D-viewer ones — those move to the
|
||||||
|
// serial 'chromium-ci-3d' project below so they never raytrace concurrently.
|
||||||
|
testMatch: BIG_MODULE_SPECS.filter((s) => !THREE_D_HEAVY_SPECS.includes(s)),
|
||||||
|
use: {
|
||||||
|
...devices["Desktop Chrome"],
|
||||||
|
viewport: { width: 1280, height: 720 },
|
||||||
|
launchOptions: {
|
||||||
|
args: ["--enable-unsafe-swiftshader"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// CI-only serial carrier for the heavy 3D-viewer specs (see THREE_D_HEAVY_SPECS).
|
||||||
|
// Same runtime as chromium-ci; test:kicad:ci invokes this project in a SEPARATE
|
||||||
|
// `playwright test` pass with --workers=1, so at most one raytrace tab is alive at a
|
||||||
|
// time (the environment in which a single 3D load is reliably green — see
|
||||||
|
// docs/features/3d-raytracer/README.md §6-§7). Own outputDir so its start-of-run
|
||||||
|
// cleanup doesn't wipe chromium-ci's retained traces, and so its own failure traces
|
||||||
|
// survive the later `test:perf` run's pw-artifacts/kicad wipe; the CI upload glob
|
||||||
|
// tests/pw-artifacts/** already captures it, so no workflow change is needed.
|
||||||
|
name: "chromium-ci-3d",
|
||||||
|
testMatch: THREE_D_HEAVY_SPECS,
|
||||||
|
outputDir: process.env.CI ? "pw-artifacts/kicad-3d" : "test-results",
|
||||||
use: {
|
use: {
|
||||||
...devices["Desktop Chrome"],
|
...devices["Desktop Chrome"],
|
||||||
viewport: { width: 1280, height: 720 },
|
viewport: { width: 1280, height: 720 },
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue