From 05f668dfa7c6aea521f0b38d60f8158006c33bb0 Mon Sep 17 00:00:00 2001 From: Said Turksever Date: Wed, 14 Oct 2026 13:08:35 +0100 Subject: [PATCH] fix(qa-cctv-v2): keep long headful waits under puppeteer's protocolTimeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The headful drain budget is uncapped (camCount * 800 + 30000 ms, ~50 min at N≈3,700), but each page.waitForFunction is one CDP call that puppeteer aborts after its 180 s default protocolTimeout. The .catch then recorded "timed out waiting for drain on the real-GPU sign-off path" after ~3 min. - Poll the drain in <=120 s slices (waitForFunctionSliced) so no single CDP call outlives protocolTimeout, whatever the catalog size. - Launch with protocolTimeout 240 s, headroom above the largest slice and the 60 s fixed waits. - Bound the sibling-owner 3-frame postRender wait at 10 s and report the frame count, so a renderer that stops producing frames fails with evidence instead of hanging the evaluate. - List --disable-backgrounding-occluded-windows explicitly. Puppeteer 25.10 already passes it by default, so this documents the headful contract rather than changing behaviour. Co-Authored-By: Claude Opus 5.5 --- scripts/qa-cctv-v2.mjs | 76 +++++++++++++++++++++++++++++++++--------- 1 file changed, 60 insertions(+), 16 deletions(-) diff --git a/scripts/qa-cctv-v2.mjs b/scripts/qa-cctv-v2.mjs index 08995603f..9234093be 100644 --- a/scripts/qa-cctv-v2.mjs +++ b/scripts/qa-cctv-v2.mjs @@ -139,6 +139,41 @@ function record(name, ok, detail) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +// Every page.waitForFunction / page.evaluate is a single CDP call that +// puppeteer aborts after `protocolTimeout` (default 180s). The headful drain +// budget is uncapped and reaches ~50 minutes on the full ~3,900-camera +// catalog, so long waits are polled in slices of at most WAIT_SLICE_MS and +// protocolTimeout is set with headroom above the largest slice. +const WAIT_SLICE_MS = 120000; +const PROTOCOL_TIMEOUT_MS = 2 * WAIT_SLICE_MS; + +/** + * page.waitForFunction with an overall budget that may exceed + * protocolTimeout: waits in WAIT_SLICE_MS slices until the predicate holds or + * the budget runs out. + * @param {import('puppeteer').Page} page + * @param {Function} predicate - In-page predicate. + * @param {number} budgetMs - Overall budget. + * @returns {Promise} True if the predicate held within the budget. + */ +async function waitForFunctionSliced(page, predicate, budgetMs) { + const deadline = Date.now() + budgetMs; + for (;;) { + const remaining = deadline - Date.now(); + if (remaining <= 0) return false; + const ok = await page + .waitForFunction(predicate, { + timeout: Math.min(remaining, WAIT_SLICE_MS), + }) + .then(() => true) + .catch((err) => { + if (err?.name === 'TimeoutError') return false; + throw err; + }); + if (ok) return true; + } +} + // SwiftShader can spend tens of seconds servicing the hover drillPick emitted // by page.mouse.move(), before the LEFT_DOWN under test even runs. Position the // pointer while ADJUST is disabled, then re-enable it and explicitly render a @@ -364,6 +399,7 @@ async function main() { const chromeExecutable = findChromeExecutable(); const browser = await puppeteer.launch({ headless: HEADFUL ? false : 'new', + protocolTimeout: PROTOCOL_TIMEOUT_MS, ...(chromeExecutable ? { executablePath: chromeExecutable } : {}), args: [ '--no-sandbox', @@ -373,6 +409,9 @@ async function main() { '--disable-web-security', '--disable-background-timer-throttling', '--disable-renderer-backgrounding', + // Puppeteer's defaults already include this; listed so the headful + // run keeps rendering when covered even if those defaults change. + '--disable-backgrounding-occluded-windows', '--window-size=1280,800', ], }); @@ -475,18 +514,15 @@ async function main() { console.log( `Waiting for geometry-load queue to drain (N=${camCount}, budget ${Math.round(drainBudgetMs / 1000)}s)...`, ); - const drained = await page - .waitForFunction( - () => { - const mod = - window.__godsEyeView.dataManager.layers.get('cctv').module; - const ui = mod.getUIState(); - return ui.loading && ui.loading.active === false; - }, - { timeout: drainBudgetMs }, - ) - .then(() => true) - .catch(() => false); + const drained = await waitForFunctionSliced( + page, + () => { + const mod = window.__godsEyeView.dataManager.layers.get('cctv').module; + const ui = mod.getUIState(); + return ui.loading && ui.loading.active === false; + }, + drainBudgetMs, + ); record( `geometry-load queue drains within ${Math.round(drainBudgetMs / 1000)}s (N=${camCount})`, drained ? true : HEADFUL ? false : null, @@ -1358,16 +1394,23 @@ async function main() { }, }); window.__qaCctvSiblingOwner = owner; - await new Promise((resolve) => { + // Bounded: if the browser stops producing frames (headful window + // asleep or hidden), report it instead of hanging the CDP call. + const framesRendered = await new Promise((resolve) => { let frames = 0; + const finish = () => { + clearTimeout(timer); + remove(); + resolve(frames); + }; const remove = scene.postRender.addEventListener(() => { if (++frames < 3) { scene.requestRender(); return; } - remove(); - resolve(); + finish(); }); + const timer = setTimeout(finish, 10000); scene.requestRender(); }); const rect = scene.canvas.getBoundingClientRect(); @@ -1382,6 +1425,7 @@ async function main() { typeof pickedId === 'string' ? pickedId : (pickedId?.id ?? null), ownerId: owner.id, ownsExactObject: picked?.id === owner, + framesRendered, projectionDelta: Math.hypot( projected.x - canvasPoint.x, projected.y - canvasPoint.y, @@ -1395,7 +1439,7 @@ async function main() { siblingTarget.ownsExactObject : null, siblingTarget - ? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta}` + ? `picked=${siblingTarget.pickedId} exactOwner=${siblingTarget.ownsExactObject} projectionΔ=${siblingTarget.projectionDelta} frames=${siblingTarget.framesRendered}/3` : 'no previously verified empty canvas ray was available', ); if (