Skip to content

test(web): deterministic artifact recorder — fixture generators on a stepped fake clock - #3906

Open
ryansolid wants to merge 1 commit into
nextfrom
test/deterministic-artifact-recorder
Open

ryansolid wants to merge 1 commit into
nextfrom
test/deterministic-artifact-recorder

Conversation

@ryansolid

Copy link
Copy Markdown
Member

Summary

Every server spec that writes a test/harness/__artifacts__ fixture now renders through one recorder, packages/web/test/server/artifact-recorder.ts, which drives the render on a fake clock stepped 1 ms at a time — the fix #3849 applied to generic-hydration.gen.spec.tsx alone, generalised to every artifact. Tests / tooling only: no packages/*/src change, no changeset.

The nondeterminism, as reproduced

The fixtures' flush points are timers (sleep(5), sleep(15), sleep(60)…) and the stream coalesces whatever settles in one event-loop turn into one chunk (deferFlush in src/server.ts), so on real timers the chunking and the script order are the host's. Verified this week's symptom on untouched next (8d23a5a13): the full server suite 3×, plus the three recorder specs 20× under 12 busy loops on a 10-core host and a concurrent hydrate-suite run.

welcome-status-streamed.json — re-recorded on every run (23/23). The committed rest opened with the 15 ms stats value:

<script>$R[22]($R[19],$R[24]={tokens:42,rate:7});</script>
<template id="sc-parity/welcome-streamed-status#0-2">…42 tokens · 7 tok/s…</template>
<script>$df("…status#0-2"); …</script>
<script>$R[22]($R[14],!0);</script>
<script>$R[21].next([["state"],"done"],[["tokens"],9],[["parts"],2]);</script>   ← the 5 ms usage event
<script>$R[21].return(void 0);</script>
<script>$R[2].next({type:"slot",… progress:"streaming" …});</script>
<script>$R[2].return(void 0);</script>

while this host wrote the 5 ms usage-trace events and the slot chunk first:

<script>$R[21].next([["state"],"done"],[["tokens"],9],[["parts"],2]);</script>
<script>$R[21].return(void 0);</script>
<script>$R[2].next({type:"slot",… progress:"streaming" …});</script>
<script>$R[22]($R[19],$R[33]={tokens:42,rate:7});</script>                      ← the 15 ms stats value
<template id="sc-parity/welcome-streamed-status#0-2">…</template>
<script>$df("…status#0-2"); …</script>
<script>$R[22]($R[14],!0);</script>
<script>$R[2].return(void 0);</script>

(Same shell; $R indices renumber with the order.) Both orders are in next's history — #3849 committed the second, #3860 the first. welcome-status-loaded.json, the same fixture, is committed in the first order and happened to reproduce it here: the first render in the worker is cold enough that the 5 ms chain trails the 15 ms promise.

write-before-resume.json — the two 60 ms boundaries (<Side>, <SyncSide>) are one chunk as committed; on 3/23 runs they were two:

committed: chunks = [ "<template id=\"5\">…data:/a…</template><script>…$df(\"5\")…</script><script>$R[12]($R[9],!0);</script><template id=\"6\">…sync:/a…</template><script>…$df(\"6\");</script><script>$R[12]($R[11],!0);</script>" ]
seen:      chunks = [ "<template id=\"5\">…data:/a…</template><script>…$df(\"5\")…</script><script>$R[12]($R[9],!0);</script>",
                      "<template id=\"6\">…sync:/a…</template><script>…$df(\"6\");</script><script>$R[12]($R[11],!0);</script>" ]

Two timers created microseconds apart straddle a millisecond (Timeout._idleStart is a fresh uv_now), fire in two turns, and deferFlush gets two bursts.

nav-before-resume.json — a third one, caught by the full-suite runs (1/3): the route's 20 ms fragment is committed as chunk 0 and the 60 ms <Side> as chunk 1; a stalled worker wrote them the other way round (the 60 ms timer was created ≥ 40 ms before the 20 ms one got its turn).

generic-hydration-{ab,ba}.json (already on #3849's fake clock) was stable 23/23.

The fix

recordStream(code, options) renders through renderToStream with setTimeout/clearTimeout/setImmediate/clearImmediate faked (vi.useFakeTimers, the same toFake list as #3849) and steps the clock 1 ms at a time with vi.advanceTimersByTimeAsync(1) until the stream ends; it returns { shell, chunks, rest }. deferFlush captured the real setImmediate at module load and keeps it, so each step yields to the real loop and a burst's microtask chain and then its flush run before the next timer fires. Two properties fall out, and both are now pinned by the specs' own assertions:

  • Distinct fixture instants are distinct chunks (generic-hydration's 5 / 15 / 40 ms → three chunks; nav's 20 / 60 ms → two, route first).
  • Timers due at the same instant flush together — sinon's tickAsync queues its own continuation (a real setImmediate) before the burst's deferFlush, so the next same-instant timer fires before the flush, exactly as a real loop runs same-instant timers before its check phase (write-before-resume's two 60 ms boundaries → one chunk, <Side> first).

Every artifact spec (hydration-harness, welcome-status-parity, write-before-resume.gen, nav-before-resume.gen, live-shell-source-3764.gen, generic-hydration.gen, document-shell, document-live-channel-artifact, frame-live-document-artifact, frame-nonlive-document-3666, dynamic-component-parity, dynamic-async-loading-3666, hybrid-memo-handoff, hybrid-store-handoff-3574) now renders through it; their copy-pasted collectChunks are gone (−521 lines). slot-hydration-3567 (sync renderToString) only switches its write to the guard. The recorder's one rule for fixtures — build them inside the recorded code — surfaced on the first full-suite run: makeWelcome() starts its 15 ms stats timer at construction, which the spec did before the recording, on the real clock; the welcome spec now constructs it inside the render.

Artifacts re-recorded (2), and why

  • welcome-status-loaded.json, welcome-status-streamed.json — shell byte-identical; rest moves from the loaded-host order (15 ms stats first) to the fixture's order (5 ms usage events + slot chunk, then the 15 ms stats value and fragment), as shown above. Script-order only, no markup change; both hydrate specs replay rest whole, and welcome-status-{loaded,streamed,lazy}.spec.tsx are green against the new form.

Every other artifact (~150) is byte-identical to what the recorder produces — including write-before-resume.json (one chunk) and nav-before-resume.json (route chunk first), whose committed forms were the natural ones.

The guard

writeArtifact(name, data) is the one write site. Locally it re-records, as before (the artifact is committed so the diff shows what the compiler + runtime changed). Under CI (process.env.CI) the checkout is the committed artifact and nothing changed between it and the run, so a recording that differs from the committed file — or an artifact with no committed file at all — fails the test with the first differing bytes, instead of silently re-recording. That is the loud failure for both "committed a change without re-recording" and "the recorder is nondeterministic for this fixture". JSX_COMPILER=babel runs are an A/B against the native compiler's artifacts and only write (checked: babel and native record byte-identical welcome artifacts). Exercised locally with CI=1: match → pass; a byte flipped in a committed artifact → fails naming the offset; artifact deleted → fails "not committed"; no CI → writes silently.

Stability

Full server suite (161 files) 10/10 under load — 12 busy loops on 10 cores plus a concurrent hydrate-suite run, load average up to 19 — every run green and the hash of all 152 artifacts identical to the baseline each time.

Suites

  • vitest run --config vite.config.server.mjs: 161 files, 1524 passed, 3 expected fail, 2 skipped (and 10/10 under load).
  • vitest run --config vite.config.hydrate.mjs (test/hydration + test/consistency, the artifact readers): 93 files, 463 passed, 16 expected fail (the test.fails pins), 2 skipped — including welcome-status-{loaded,streamed,lazy}, write-before-resume, nav-before-resume, parity-harness, document-shell, and test/consistency/generic/{replay,preload-hold,campaign}.
  • vitest run (client): 136 files, 1272 passed, 1 expected fail.
  • tsc --project tsconfig.test.json: clean.

Public API changes

None.

…stepped fake clock

Every server spec that writes a test/harness/__artifacts__ fixture now
renders through one recorder (test/server/artifact-recorder.ts) that
drives the render on a fake clock stepped 1 ms at a time — the fix #3849
applied to generic-hydration.gen.spec.tsx alone, for every artifact.

Why: the fixtures' flush points are timers and the stream coalesces one
event-loop turn into one chunk (`deferFlush`), so on real timers the
chunking and the script order are the host's. Reproduced on untouched
sources (full server suite, 3 runs, plus 20 targeted runs under 12 busy
loops on 10 cores): welcome-status-{loaded,streamed}.json re-recorded on
every run (the 5 ms usage/slot events vs the 15 ms `stats` promise, in
either order), write-before-resume.json split its one 60 ms chunk into
two on 3/23 runs (two timers created microseconds apart straddling a
millisecond), and nav-before-resume.json once wrote the 60 ms `<Side>`
fragment ahead of the route's 20 ms one.

On the stepped clock every fixture timer fires at its own instant in
creation order; timers due at the same instant flush together, distinct
instants flush separately. With the fixture built inside the recorded
render (welcome's `makeWelcome()` started its 15 ms timer at construction,
on the real clock), the recording is a function of the fixture: 10/10
full-suite runs under load, every artifact byte-identical.

Re-recorded: welcome-status-loaded.json and welcome-status-streamed.json
(shell unchanged; `rest` now carries the 5 ms usage trace events and the
slot chunk before the 15 ms stats value + fragment, the fixture's order —
the committed form was a loaded-host recording). The other ~150 artifacts
are byte-identical to what the recorder produces.

Guard: `writeArtifact` still re-records locally, but under CI (the
checkout IS the committed artifact) a recording that differs from the
committed file — or an artifact with no committed file — fails the test
with the first difference, instead of silently re-recording.
`JSX_COMPILER=babel` runs are an A/B and only write.

Tests / tooling only; no runtime change, no changeset.

Co-authored-by: Cursor <cursoragent@cursor.com>
@changeset-bot

changeset-bot Bot commented Oct 8, 2026

Copy link
Copy Markdown

⚠️ No Changeset found

Latest commit: 8b2ed00

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

@github-actions

github-actions Bot commented Oct 8, 2026

Copy link
Copy Markdown

Size (brotli, eager entry chunk)

scenario head vs base minified vs base minified vs recorded cap lazy chunks (not counted)
signals: core floor (createSignal/Memo/Effect/Root/flush) 7.43 KB 0 B 0 B +15 B 7.45 KB ✅
signals: + createStore 14.69 KB 0 B 0 B 0 B 14.70 KB ✅
signals: + isPending/latest 9.63 KB 0 B 0 B +15 B 9.65 KB ✅
app: render + one signal (the simple-app floor) 9.92 KB 0 B 0 B +15 B 9.93 KB ✅
app: hydrating (no stores) with Show/For/Loading/Errored/lazy 17.89 KB 0 B 0 B +15 B 17.91 KB ✅ lazy-page.js 0.04 KB
app: hydrating + every store primitive family 29.17 KB 0 B 0 B +55 B 29.19 KB ✅ lazy-page.js 0.04 KB
app: CSR with Show/For/Loading/Errored/lazy 12.92 KB 0 B 0 B +15 B 12.96 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier (same app on the observe artifacts) 14.53 KB 0 B 0 B +15 B 14.53 KB ✅ lazy-page.js 0.04 KB
app: CSR, observe tier + attribution engine enabled 28.85 KB 0 B 0 B +15 B 28.89 KB ✅ lazy-page.js 0.04 KB
app: compiled floor (one template, one text hole, one delegated click) 10.12 KB 0 B 0 B +15 B 10.13 KB ✅
app: compiled CSR (JSX todo app: spread/merge/omit, events, class/style, keyed For, Show, Loading + lazy, store) 25.22 KB 0 B 0 B +55 B 25.24 KB ✅ stats.js 0.18 KB
app: compiled hydrating (the same JSX todo app through hydrate(), compiled hydratable) 31.16 KB 0 B 0 B 0 B 31.17 KB ✅ stats.js 0.20 KB
frames: eager client consumer (frames client + transport, lazy codec) 11.11 KB 0 B 0 B 0 B 11.13 KB ✅
page: base server components (hydrating + dynamic + frames + sf reference) 33.91 KB 0 B 0 B +161 B 33.92 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: live server components (base + live/GET + action + isPending/latest) 37.61 KB 0 B 0 B +15 B 37.59 KB ⚠️ over by 20 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread) 35.15 KB 0 B 0 B +15 B 35.13 KB ⚠️ over by 16 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.80 KB, sc-comments.js 0.20 KB, trace.js 8.18 KB, wire.js 0.93 KB
page: compiled live server components (the compiled base page + live/GET + action + isPending/latest) 40.65 KB 0 B 0 B +15 B 40.66 KB ✅ eager (counted): web.js 22.02 KB; assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, regions.js 0.79 KB, sc-comments.js 0.19 KB, trace.js 8.20 KB, wire.js 0.93 KB
page: base + router (base page + @solidjs/router: createRouter, two routes, preload, useNavigate) 46.00 KB 0 B 0 B +15 B 46.02 KB ✅ assets.js 0.78 KB, bind.js 1.83 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.80 KB, server.js 1.02 KB, trace.js 8.19 KB, wire.js 0.93 KB
page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate) 47.33 KB 0 B 0 B +15 B 47.29 KB ⚠️ over by 36 B, 5 B minified headroom assets.js 0.78 KB, bind.js 1.84 KB, decode.js 6.24 KB, lazy-page.js 0.04 KB, regions.js 0.81 KB, server.js 1.02 KB, trace.js 8.20 KB, wire.js 0.94 KB
server: floor (getRequestEvent + isServer) 1.33 KB 0 B 0 B 0 B 1.34 KB ✅
server: renderToString (the server-render floor) 20.40 KB 0 B 0 B +4 B 20.42 KB ✅

⚠️ Over the brotli cap within the minified allowance (passes)

  • page: live server components (base + live/GET + action + isPending/latest): over brotli cap by 20 B; minified 117,456 B vs 117,441 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: compiled base server components (the base page as JSX: templates with class/style/attributes/events, For/Show; no spread): over brotli cap by 16 B; minified 109,461 B vs 109,446 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base
  • page: live + router (live page + @solidjs/router: createRouter, two routes, preload, useNavigate): over brotli cap by 36 B; minified 148,683 B vs 148,668 B recorded with the cap (+15 B) — 5 B of the 20 B minified allowance left; +0 B minified over this PR's base

Bundled with Rolldown (what Vite ships), brotli q11, decimal KB. A scenario fails only when it is over its brotli cap and its minified size is more than 20 B over the minified recorded with the cap; over the cap within that allowance is brotli layout noise and passes with a warning. Caps and their recorded minified in scripts/size/scenarios.js; the floor and page caps in floor-caps.json are frozen (lower only, or Size-Exception: in the PR body). npm run ratchet lowers caps per RC; it never raises one (scripts/size/README.md).

@coveralls

Copy link
Copy Markdown

Coverage Report for CI Build 37747106541

Coverage remained the same at 76.43%

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: 1227
Covered Lines: 996
Line Coverage: 81.17%
Relevant Branches: 958
Covered Branches: 674
Branch Coverage: 70.35%
Branches in Coverage %: Yes
Coverage Strength: 28.39 hits per line

💛 - Coveralls

@codspeed

codspeed Bot commented Oct 8, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 13.51%

⚠️ Different runtime environments detected

Some benchmarks with significant performance changes were compared across different runtime environments,
which may affect the accuracy of the results.

Open the report in CodSpeed to investigate

❌ 1 regressed benchmark
✅ 187 untouched benchmarks

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Benchmark BASE HEAD Efficiency
❌ memo + sync render effect only (reference) 28 ms 32.4 ms -13.51%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing test/deterministic-artifact-recorder (8b2ed00) with next (8d23a5a)

Open in CodSpeed

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