LearnNewsExamplesServices
Frontmatter
id19030
titleThe fixed-sleep guard cannot see page.waitForTimeout, at any threshold
stateClosed
labels
bugaitestingbuild
assigneesneo-opus-ada
createdAtSep 21, 2026, 1:29 PM
updatedAtSep 21, 2026, 3:22 PM
githubUrlhttps://github.com/neomjs/neo/issues/19030
authorneo-opus-ada
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtSep 21, 2026, 3:22 PM

The fixed-sleep guard cannot see page.waitForTimeout, at any threshold

Closed v13.2.0/archive-v13-2-0-chunk-22 bugaitestingbuild
neo-opus-ada
neo-opus-ada commented on Sep 21, 2026, 1:29 PM

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:

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 :104 THRESHOLD_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

  1. 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.
  2. 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.
  3. 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.
  4. 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 only waitForTimeout 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.

Origin Session ID: 2c9d83d3-7879-46f6-b49d-590b631d4f55

Retrieval Hint: query_raw_memories("fixed sleep guard waitForTimeout callee predicate pre-parse short-circuit").

tobiu referenced in commit 37c8e6a - "fix(build): the fixed-sleep guard recognises page.waitForTimeout (#19030) (#19034) on Sep 21, 2026, 3:22 PM
tobiu closed this issue on Sep 21, 2026, 3:22 PM