Race an in-flight page.evaluate against a deadline.
Playwright dispatches page.evaluate with no timeout of its own — neither
actionTimeout nor setDefaultTimeout bounds it, only the whole test's
budget does. So on a frame-starved page (a loaded workstation drops the
viewer from ~30 FPS to ~4) an evaluate can sit unanswered until the test
itself expires, instead of letting the caller fall back.
This bounds ONE wait, not a whole helper. A helper that makes several
bounded calls in sequence takes the SUM of their caps plus whatever its
fallback costs, so its total runtime can legitimately exceed the single
timeout value the caller passed. Read a helper's timeout parameter as
"the cap on each individual wait", never as "the cap on the call".
WHAT STARVES IT is main-thread task starvation, not anything GL-specific,
so no probe is exempt from needing a bound (#1651). Instrumented on an
idle box, a trivial page.evaluate(() => 'ok') went unanswered for its
5 s deadline twelve times in a row while requestAnimationFrame kept
ticking (34 → 157) and the renderer's own counter advanced 426 → 672,
with visibilityState === 'visible' and the WebGL context never lost; an
in-page setInterval(..., 1000) fired twice over a 28 s window inside that
stall. A saturated software-rendering rAF loop starves the lower-priority
task sources — in-page timers and Playwright's Runtime.callFunctionOn
round trip — for tens of seconds while rendering continues throughout.
Corollary for onTimeout: pass a sentinel the in-page function can never
return, so the caller can tell a real answer from a missed deadline, and
decide at the call site what a missed deadline MEANS — a detector whose
answer the test asserts on must say it could not run rather than fall back
to a value that reads as a pass.
The timer is always cleared, so a resolved race leaves no handle keeping the
Node process alive. A rejection that arrives after the deadline is absorbed
by Promise.race (which has already settled) rather than going unhandled.
Unit-tested in src/tests/unit/tests/e2e-helpers-race-evaluate.test.ts.
Prefer a wait helper built on it where one fits; a spec holding its own
page.evaluate calls it directly (see webgl-errors.spec.ts), because a
bare evaluate has no other bound.
Race an in-flight
page.evaluateagainst a deadline.Playwright dispatches
page.evaluatewith no timeout of its own — neitheractionTimeoutnorsetDefaultTimeoutbounds it, only the whole test's budget does. So on a frame-starved page (a loaded workstation drops the viewer from ~30 FPS to ~4) an evaluate can sit unanswered until the test itself expires, instead of letting the caller fall back.This bounds ONE wait, not a whole helper. A helper that makes several bounded calls in sequence takes the SUM of their caps plus whatever its fallback costs, so its total runtime can legitimately exceed the single
timeoutvalue the caller passed. Read a helper'stimeoutparameter as "the cap on each individual wait", never as "the cap on the call".WHAT STARVES IT is main-thread task starvation, not anything GL-specific, so no probe is exempt from needing a bound (#1651). Instrumented on an idle box, a trivial
page.evaluate(() => 'ok')went unanswered for its 5 s deadline twelve times in a row whilerequestAnimationFramekept ticking (34 → 157) and the renderer's own counter advanced 426 → 672, withvisibilityState === 'visible'and the WebGL context never lost; an in-pagesetInterval(..., 1000)fired twice over a 28 s window inside that stall. A saturated software-rendering rAF loop starves the lower-priority task sources — in-page timers and Playwright'sRuntime.callFunctionOnround trip — for tens of seconds while rendering continues throughout. Corollary foronTimeout: pass a sentinel the in-page function can never return, so the caller can tell a real answer from a missed deadline, and decide at the call site what a missed deadline MEANS — a detector whose answer the test asserts on must say it could not run rather than fall back to a value that reads as a pass.The timer is always cleared, so a resolved race leaves no handle keeping the Node process alive. A rejection that arrives after the deadline is absorbed by
Promise.race(which has already settled) rather than going unhandled.Unit-tested in
src/tests/unit/tests/e2e-helpers-race-evaluate.test.ts. Prefer a wait helper built on it where one fits; a spec holding its ownpage.evaluatecalls it directly (seewebgl-errors.spec.ts), because a bare evaluate has no other bound.