Skip to content

test(web): run the performance-tracks spec on a controllable engine clock - #3598

Merged
ryansolid merged 1 commit into
nextfrom
test/deflake-performance-tracks-thresholds
Sep 23, 2026
Merged

ryansolid merged 1 commit into
nextfrom
test/deflake-performance-tracks-thresholds

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Why

packages/web/test/performance-tracks.spec.tsx (landed with #3580) failed on the unrelated server-only #3594 and passed on rerun: line 415 expected the click wave's colour primary, got primary-dark. The colour is event.durationMs < 16 ? "primary" : "primary-dark" on the drain's FlushEvent, and durationMs is a wall-clock performance.now() delta the attribution engine stamps — a loaded runner stretched a two-run drain past 16ms.

That was the one that fired, but the spec had several assertions cut against measured wall-clock time. Same approach as #3579: drive the clock, assert exact values, exercise each threshold on both sides. No assertion was loosened or deleted; the colour assertion is exact and now has a dedicated both-sides test.

Timing-dependent assertions found (line numbers as of origin/next)

Line Assertion Wall-clock dependency Now
415–418 wave color: "primary" (the flake) FlushEvent.durationMs < 16 drain costs exactly 2ms → primary; new test pins 0/15 → primary, 16/40 → primary-dark
408 callbacks[1].color === "secondary-light" EffectRunEvent.durationMs < 10 (an empty callback, but still a measured delta) callback costs exactly 0ms; new test pins 0/9 → secondary-light, 10/99 → secondary, 100 → error
407 callbacks[1].start >= rerun.at + rerun.totalMs - 0.001 ordering with a 1µs fudge exact: start === rerun.at + rerun.totalMs
419–420 wave.start <= rerun.at, wave.end >= callbacks[1].end ordering on live stamps exact: start: clicked, end: clicked + 2, end === callbacks[1].end
380, 390 memoCreate.end >= memoCreate.start, firstCallback.start >= effectCreate.start ordering on live stamps exact: create span is mounted → mounted + 2, callback at effectCreate.end
290, 301 toBeCloseTo(start + handlerMs, 6), toBeCloseTo(at + settledMs, 6) float tolerance on live handlerMs/settledMs; the settle span is only painted when settledMs > handlerMs, which a frozen or coarse clock would not guarantee handler costs 3ms, drain 5ms: handlerMs: 3, settledMs: 8, spans exact, ["Handler", "3.00ms"]
605, 615, 618 await setTimeout(10) then landed.end - landed.start >= 8, fallback.end - fallback.start >= 8 real 10ms timer with a 2ms margin clock.advance(10): flight and fallback are exactly took → took + 10, ["In the air", "10.00ms"], ["Shown", "10.00ms"]
665, 672–678, 688–689 await setTimeout(10); hold span color: "warning"; settle toBeCloseTo hold colour is isLongHold ? "error" : isSilentHold ? "warning" : "tertiary" — a hold whose tail reached the default longHolds.infoMs (500ms) on a stalled runner would paint error clock.advance(10): holdMs: 10, tailMs: 10, isLongHold(hold) === false asserted, no "long hold" verdict; new test drives 499ms → warning and 500ms → error (with both verdicts)
714 navigation span.end > span.start strict inequality on a live settledMs router callback costs 4ms: requested → requested + 4, ["Settled", "4.00ms"]
330–352 "cost colours follow self time" title, only the warning branch asserted bySelfTime(selfMs) cuts at 0.5/10/100ms, unexercised new test pins 0.25 → primary-light, 0.5/9 → primary, 10/99 → primary-dark, 100 → error

Not timing-dependent (left alone): the call/frame tests build synthetic events from performance.now() - 50 and assert self-consistently; isSilentHold has no threshold (acknowledgements/paint census only); the holds.infoMs/warnMs check thresholds are set to 0 in the hold tests so the finding fires regardless.

Approach

A beforeEach installs a stand-in performance.now (added to the spec's existing originals list, torn down with the other performance stand-ins) returning a value that only moves when a test calls clock.advance(ms) — inside a memo compute, an effect callback, a click handler, or while a flight is in the air. The engine's now() in core/attribution.ts reads performance.now() on every call, and the adapter paints only from the records' stamps, so this is the whole hook: every duration is the sum of the advances the test made, and thresholds are crossed by choice. Real timers are untouched (setTimeout still drives the async flights; until() polls on Date.now()).

Chosen over vi.useFakeTimers({ toFake: ["performance"] }) because the spec already installs and restores its own performance stand-ins and fake-timers would replace the object underneath them.

Mutation check: shifting the adapter's < 16 to < 17 fails the new wave-colour test at the 16 → primary-dark row and nothing else.

Source-side

None needed. No now injection option was required; no public surface touched. No changeset (tests only).

Verification

  • vitest run test/performance-tracks.spec.tsx × 20 in a loop: 20/20 pass (37 tests each).
  • × 10 under CPU saturation (yes > /dev/null × 12 on 10 cores): 10/10 pass.
  • @solidjs/web client suite: 101 files, 898 tests pass.
  • pnpm --filter @solidjs/web test-types: clean.

…lock

The spec's colour and duration assertions were cut against wall-clock
`performance.now()` deltas the attribution engine stamps on its records —
a wave is `primary` under 16ms, a callback `secondary-light` under 10ms,
a hold `error` from `longHolds.infoMs` — so a loaded CI runner crossed a
threshold at random (#3594: a two-run drain read `>= 16ms` and the wave
came out `primary-dark`).

Every test now runs on a `performance.now()` of its own, frozen unless the
test advances it inside a compute, a callback, a handler, or while a flight
is in the air. Every span's start/end, every record's `handlerMs`,
`settledMs`, `totalMs`, `holdMs`, `tailMs`, `durationMs` and `shownMs`, and
every `Duration`/`Settled`/`Held`/`Self time` property is asserted exactly
against the advances made; the `>=`/`toBeCloseTo`/`- 0.001` tolerances and
the real `setTimeout(10)` waits are gone. Each threshold — the wave's 16ms,
the callback's 10ms/100ms, the self-time palette's 0.5/10/100ms, and the
engine's long-hold verdict at 500ms — is exercised on both sides, at its
exact boundary value.

No source change: the engine's `now()` reads `performance.now()` on every
call and the adapter paints only from the records, so a stand-in on
`performance.now` (installed and torn down like the spec's other
`performance` stand-ins) is the whole hook.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
@changeset-bot

changeset-bot Bot commented Sep 22, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: d0e05c0

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 35799447696

Coverage remained the same at 72.852%

Details

  • Coverage remained the same as the base build.
  • Patch coverage: No coverable lines changed in this PR.
  • No coverage regressions found.

Uncovered Changes

No uncovered changes found.

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 1073
Covered Lines: 827
Line Coverage: 77.07%
Relevant Branches: 824
Covered Branches: 555
Branch Coverage: 67.35%
Branches in Coverage %: Yes
Coverage Strength: 17.56 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Sep 23, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 176 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing test/deflake-performance-tracks-thresholds (d0e05c0) with next (f41c6a4)

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports. ↩

@ryansolid
ryansolid merged commit be83b01 into next Sep 23, 2026
7 checks passed
@ryansolid
ryansolid deleted the test/deflake-performance-tracks-thresholds branch September 23, 2026 00:05
ryansolid added a commit that referenced this pull request Sep 24, 2026
…ned clock

"stays quiet below the hold threshold, and info between the two" raced the
wall clock: a 1ms `await` had to settle under `infoMs: 10`, and on the
coverage-instrumented CI job (12× slower than a local run) it did not. The
engine reads `performance.now()` at the handler's return and at its
promise's settle, so the test now owns that clock (#3598's pattern): it
stands still unless the handler advances it — 9ms for the quiet side, 30ms
for the info side — and both `continuationMs` values are asserted exactly.
Real timers still drive the await; only the stamps are the test's.

Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 24, 2026
…_ASYNC_HANDLER (#3604)

* feat(signals): an interaction waits for its handler's promise; UNTRACKED_ASYNC_HANDLER names the dead click no hold could judge

`onClick={async () => setResult(await save())}` returns a promise and
continues past the interaction frame. The record settled `idle` the moment
the handler returned, so the wait the person experienced — the whole
round-trip — was invisible to every verdict: no root write before the
`await` means no hold opened, and SILENT_HOLD judges holds.

`withInteraction` now hands the handler's return value to the engine
(`AttributionHooks.interactionEnd(returned)`); a thenable keeps the
`InteractionEvent` open until it settles either way, capped at 10s so a
promise that never settles cannot pin a record. The record carries
`continuationMs` (handler return → settle) and `settledMs` covers it, so a
consumer's span spans the wait. Writes in the continuation run with no
frame on the stack and stay unattributed; only the duration is claimed.

At settle, `UNTRACKED_ASYNC_HANDLER` fires when the handler took no other
road: no write before the await (a pending flag or optimistic value is the
acknowledgement, and its hold is SILENT_HOLD's to judge) and no `action()`
step under the frame (an action's steps stay attributed across yields).
Hold thresholds, since it is the same wait; off with `holds: false`. The
message carries the two repairs: make the work an action, or write the
pending state first.

Size: the tier +65 B (the return value through the seam), the engine
+384 B (the wait, the cap timer, the check and its text); caps moved with
notes. RFC 08 and the skill guide document the code; the plan records D1
and D2 as decided.

Co-Authored-By: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>

* test(signals): judge UNTRACKED_ASYNC_HANDLER's threshold on a test-owned clock

"stays quiet below the hold threshold, and info between the two" raced the
wall clock: a 1ms `await` had to settle under `infoMs: 10`, and on the
coverage-instrumented CI job (12× slower than a local run) it did not. The
engine reads `performance.now()` at the handler's return and at its
promise's settle, so the test now owns that clock (#3598's pattern): it
stands still unless the handler advances it — 9ms for the quiet side, 30ms
for the info side — and both `continuationMs` values are asserted exactly.
Real timers still drive the await; only the stamps are the test's.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

* chore(size): re-measure the UNTRACKED_ASYNC_HANDLER engine note at landing

29,408 B against next's 29,018 on the rebased tree (+390 B, 42 B under the
29.45 KB cap); the PR's figures were taken on the pre-#3630 base.

Co-authored-by: Claude via Cursor <noreply@cursor.com>

---------

Co-authored-by: Claude via Cursor <cursoragent@cursor.com>
Co-authored-by: Claude via Cursor <noreply@cursor.com>
ryansolid added a commit that referenced this pull request Sep 24, 2026
…3635)

The "already rejected at hydration" case asserted `waitedMs < 5` on the
wall clock. `waitedMs` is the delta between two `performance.now()`
reads in the runtime — at boundary registration and when the queued
`recover` microtask runs — and on a loaded, coverage-instrumented CI
worker that hop read 5.3ms, 7.3ms and 13.3ms today. A failed assertion
also skipped the test's own `off()`, leaking the listener into the next
test, whose `observed("recovery") === false` check then failed too.

`performance.now()` is now a scripted clock (the #3598 pattern): it
stands still unless the test moves it, so every duration is asserted
exactly — `waitedMs: 0` for the already-rejected case, `30` for the
streaming case where the test advances the clock before rejecting, and
`renderMs: 2` because rendering the children advances it by 2. The
subscription is torn down in `afterEach`, so a failure cannot cascade.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants