Found by the positive control that #19028's AC-3 demanded. That AC exists because a tier with zero current hits has unfalsifiable coverage, so widening the scan to it had to be proved rather than assumed. The control failed, and what it found is larger than the scan root.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-21T11:35Z; no equivalent found.
The Problem
Two probes, same file, same line, at the head of #19028's branch with the component tier already scanned:
await page.waitForTimeout(3000) -> guard GREEN, exit 0
await new Promise(r => setTimeout(r, 3000)) -> guard RED, exit 1, site named
buildScripts/util/check-fixed-sleeps.mjs matches setTimeout call shapes only. Playwright's page.waitForTimeout has never been visible to it — not in the component and e2e tiers it has just started scanning, and not in the unit tier it has scanned since it shipped.
This is not a threshold question. These are waits at or above the guard's own THRESHOLD_MS = 1000, inside its own declared scope, that it cannot report:
tier
waitForTimeout(n) where n ≥ 1000
files
unit
0
0
component
4
2
e2e
49
22
53 sites across 24 files, and the guard reports OK over all of them. Full census at dev, top of the distribution:
sites
delays (ms)
file
8
2500, 1200, 1000, 1200, 1000, 1200, 1000, 1200
e2e/core/RefreshDomCorpseRepro.spec.mjs
5
1000, 1000, 2000, 3000, 3000
e2e/rendering/LivePreviewMultiWindow.spec.mjs
4
2500, 1000, 1500, 1500
e2e/dashboard/DockAutoHideRevealNL.spec.mjs
4
2000, 1000, 1200, 1200
e2e/grid/LockedDnDDuplication.spec.mjs
3
1300 ×3
component/menu/List.spec.mjs
3
1000, 5000, 1000
e2e/grid/RowPinning.spec.mjs
3
1200, 1600, 1600
e2e/grid/RowSlotRemapDuplication.spec.mjs
3
1000, 5000, 2500
e2e/grid/ThumbDragPause.spec.mjs
The remaining 16 files carry one or two each. Tier split: component 4, e2e 49, unit 0.
87.8 seconds if every one elapses — and the quantifier matters in one direction only. Not every spec is reached in every job, so the figure is an upper bound on a full run rather than a per-run cost. But unlike a failure deadline, page.waitForTimeout is unconditional: there is no happy path on which it costs nothing. Every site that is reached is paid in full, every time. That is the opposite of the two deadlines dispositioned in #19031, and it is why this population belongs in the guard's accounting rather than outside it.
The practical consequence is that the guard's clean exit reads as "this tree has no unexplained second-scale sleeps", and for the dominant Playwright idiom that has never been what it measured.
The Architectural Reality
buildScripts/util/check-fixed-sleeps.mjs:173 — if (node.callee?.type !== 'Identifier' || node.callee.name !== 'setTimeout') return NaN;. page.waitForTimeout is a MemberExpression callee, so it returns NaN and is never a site.
:288 — if (!source.includes('setTimeout') && !source.includes('\\u')) continue;. A file whose only waits are waitForTimeout is skipped before parsing, so the AST never sees it. The two narrowings compound.
:99 — JUSTIFICATIONS = ['wall-clock-under-test:', 'out-waits:'], and :104THRESHOLD_MS = 1000. Both apply unchanged to whatever shapes the detector admits.
buildScripts/util/check-fixed-sleeps-baseline.json — three entries, all unit setTimeout timers under test.
The {file, text, count} baseline shape is shape-agnostic, so it can grandfather waitForTimeout sites without change.
The Fix
Widen the callee predicate to admit a MemberExpression ending in waitForTimeout, alongside the bare setTimeout identifier. Keep it conservative: match the method name, not an arbitrary receiver, so an unrelated x.waitForTimeout on a non-page object is still counted rather than silently exempted.
Widen the pre-parse short-circuit at :288 in the same edit, or the new predicate never runs on the files that need it most. These two are one change; splitting them ships a matcher that cannot reach its inputs.
Baseline the 53 pre-existing sites so the change is green on arrival, and state the count in the PR. Unlike #19028's three, 53 is past the threshold where dispositioning belongs in the same PR — the baseline is the right mechanism here and burning it down is separate work.
Update the module docblock, which currently states this narrowing as deliberate; it stops being true.
Acceptance Criteria
AC-1 — a page.waitForTimeout(n) with n ≥ THRESHOLD_MS and no justification marker fails the guard, and the same call carrying out-waits: or wall-clock-under-test: passes.
AC-2 — a spec file containing onlywaitForTimeout waits and no setTimeout token is parsed rather than skipped. This is its own criterion because the pre-parse short-circuit is a second, independent gate, and satisfying AC-1 alone leaves it closed.
AC-3 — the pre-existing sites are in the baseline, the PR states the counts, and the guard exits 0 on arrival. Two numbers, two populations, and the ticket originally named only one: the baseline keys on {file, text}, so 53 occurrences collapse into 40 entries — nine lines are byte-identical repeats inside one file, four of them in RefreshDomCorpseRepro.spec.mjs alone. The CLI prints 40 and the census counts 53; both are true and neither is "the number of sites". The PR states entries and occurrences.
AC-4 — positive controls in both shapes, shown in the PR: an unjustified waitForTimeout reds, an unjustified setTimeout still reds. The second is the regression control — the existing detector must not be broken by widening it.
AC-5 — the module docblock states which call shapes are matched, replacing the sentence that currently records this narrowing as intended.
Out of Scope
The sub-second threshold.THRESHOLD_MS = 1000 stays. The wait that produced this chain — waitForTimeout(400) in #19024 — is below it and is still not caught after this ticket. That is a third, separate decision with 385 calls behind it, and it needs its own evidence about what an unexplained sub-second wait costs.
Burning down the baseline this ticket creates.
setInterval, requestIdleCallback and other timing primitives; no incident behind them.
Avoided Traps
Fixing the callee predicate without the pre-parse short-circuit is the trap, and it is the likelier one because :173 is where the logic looks like it lives. A file of pure waitForTimeout waits never reaches line 173 at all.
The second trap is reading this ticket as "the guard was broken". It was narrow, the narrowing was never written down, and its clean exit was read — by me, in a review — as coverage it did not have. The fix is the matcher; the lesson is that a guard's silence is only as wide as its predicate.
Related
#19028 — widened the scan roots; its AC-3 positive control is what found this.
#19024 — the review that started the chain, whose 400 ms wait remains uncaught after this.
Claimed by @neo-opus-ada 2026-09-21T11:55Z. Not a first issue — it needs the two-gate fix plus a 53-entry baseline judgement. Sequencing: it edits check-fixed-sleeps.mjs, which PR #19031 also edits, so the code half waits for that merge rather than stacking; the census above is the half that is independent of it and is done.
Context
Found by the positive control that #19028's AC-3 demanded. That AC exists because a tier with zero current hits has unfalsifiable coverage, so widening the scan to it had to be proved rather than assumed. The control failed, and what it found is larger than the scan root.
Live latest-open sweep: checked the latest 20 open issues at 2026-09-21T11:35Z; no equivalent found.
The Problem
Two probes, same file, same line, at the head of #19028's branch with the component tier already scanned:
buildScripts/util/check-fixed-sleeps.mjsmatchessetTimeoutcall shapes only. Playwright'spage.waitForTimeouthas never been visible to it — not in the component and e2e tiers it has just started scanning, and not in the unit tier it has scanned since it shipped.This is not a threshold question. These are waits at or above the guard's own
THRESHOLD_MS = 1000, inside its own declared scope, that it cannot report:waitForTimeout(n)where n ≥ 100053 sites across 24 files, and the guard reports
OKover all of them. Full census atdev, top of the distribution:e2e/core/RefreshDomCorpseRepro.spec.mjse2e/rendering/LivePreviewMultiWindow.spec.mjse2e/dashboard/DockAutoHideRevealNL.spec.mjse2e/grid/LockedDnDDuplication.spec.mjscomponent/menu/List.spec.mjse2e/grid/RowPinning.spec.mjse2e/grid/RowSlotRemapDuplication.spec.mjse2e/grid/ThumbDragPause.spec.mjsThe remaining 16 files carry one or two each. Tier split: component 4, e2e 49, unit 0.
87.8seconds if every one elapses — and the quantifier matters in one direction only. Not every spec is reached in every job, so the figure is an upper bound on a full run rather than a per-run cost. But unlike a failure deadline,page.waitForTimeoutis unconditional: there is no happy path on which it costs nothing. Every site that is reached is paid in full, every time. That is the opposite of the two deadlines dispositioned in #19031, and it is why this population belongs in the guard's accounting rather than outside it.The practical consequence is that the guard's clean exit reads as "this tree has no unexplained second-scale sleeps", and for the dominant Playwright idiom that has never been what it measured.
The Architectural Reality
buildScripts/util/check-fixed-sleeps.mjs:173—if (node.callee?.type !== 'Identifier' || node.callee.name !== 'setTimeout') return NaN;.page.waitForTimeoutis aMemberExpressioncallee, so it returnsNaNand is never a site.:288—if (!source.includes('setTimeout') && !source.includes('\\u')) continue;. A file whose only waits arewaitForTimeoutis skipped before parsing, so the AST never sees it. The two narrowings compound.:99—JUSTIFICATIONS = ['wall-clock-under-test:', 'out-waits:'], and:104THRESHOLD_MS = 1000. Both apply unchanged to whatever shapes the detector admits.buildScripts/util/check-fixed-sleeps-baseline.json— three entries, all unitsetTimeouttimers under test.The
{file, text, count}baseline shape is shape-agnostic, so it can grandfatherwaitForTimeoutsites without change.The Fix
MemberExpressionending inwaitForTimeout, alongside the baresetTimeoutidentifier. Keep it conservative: match the method name, not an arbitrary receiver, so an unrelatedx.waitForTimeouton a non-page object is still counted rather than silently exempted.:288in the same edit, or the new predicate never runs on the files that need it most. These two are one change; splitting them ships a matcher that cannot reach its inputs.Acceptance Criteria
page.waitForTimeout(n)with n ≥THRESHOLD_MSand no justification marker fails the guard, and the same call carryingout-waits:orwall-clock-under-test:passes.waitForTimeoutwaits and nosetTimeouttoken is parsed rather than skipped. This is its own criterion because the pre-parse short-circuit is a second, independent gate, and satisfying AC-1 alone leaves it closed.{file, text}, so 53 occurrences collapse into 40 entries — nine lines are byte-identical repeats inside one file, four of them inRefreshDomCorpseRepro.spec.mjsalone. The CLI prints 40 and the census counts 53; both are true and neither is "the number of sites". The PR states entries and occurrences.waitForTimeoutreds, an unjustifiedsetTimeoutstill reds. The second is the regression control — the existing detector must not be broken by widening it.Out of Scope
THRESHOLD_MS = 1000stays. The wait that produced this chain —waitForTimeout(400)in #19024 — is below it and is still not caught after this ticket. That is a third, separate decision with 385 calls behind it, and it needs its own evidence about what an unexplained sub-second wait costs.setInterval,requestIdleCallbackand other timing primitives; no incident behind them.Avoided Traps
Fixing the callee predicate without the pre-parse short-circuit is the trap, and it is the likelier one because
:173is where the logic looks like it lives. A file of purewaitForTimeoutwaits never reaches line 173 at all.The second trap is reading this ticket as "the guard was broken". It was narrow, the narrowing was never written down, and its clean exit was read — by me, in a review — as coverage it did not have. The fix is the matcher; the lesson is that a guard's silence is only as wide as its predicate.
Related
Claimed by @neo-opus-ada 2026-09-21T11:55Z. Not a first issue — it needs the two-gate fix plus a 53-entry baseline judgement. Sequencing: it edits
check-fixed-sleeps.mjs, which PR #19031 also edits, so the code half waits for that merge rather than stacking; the census above is the half that is independent of it and is done.Origin Session ID: 2c9d83d3-7879-46f6-b49d-590b631d4f55
Retrieval Hint:
query_raw_memories("fixed sleep guard waitForTimeout callee predicate pre-parse short-circuit").