Repository navigation
fix(router): dev PPR capture backoff cap + cloudflare-basic ppr warmup (#652 item 3) - #659
Merged
Merged
Conversation
commit: |
ivogt
force-pushed
the
fix/ppr-capture-backoff-dev
branch
from
July 4, 2026 10:19
be08e6f to
ecb613e
Compare
ivogt
added a commit
that referenced
this pull request
Jul 4, 2026
…p, fix snapshot-test flake (#652 item 3) Three coordinated changes unblocking cloudflare-basic-e2e on cold CI runners (failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own first CI run): 1. Dev backoff cap (runtime). The refused-capture backoff window is exponential per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in production (a genuinely ineligible route should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold module graph that warms on the very attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`, matching handler.ts / server-action.ts / progressive-enhancement.ts. 2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold runners need more because the dev module graph is compiled ON DEMAND during the capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s). Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev project already depends on) with a ppr warmup: GET every ppr fixture route under an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot before any real test's warm window starts. Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed. warmToHit windows and runtime constants are otherwise untouched. 3. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values AS FRESH" builds the seeded store and the expected value from two separate snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a millisecond tick between the calls fails the deep-equal (seen on the #659 run). Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the file was audited: no other assertion deep-equals across two Date.now() instants. Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain unification) remain open.
…dget, snapshot-test flake (#652 item 3) Four coordinated changes unblocking cloudflare-basic-e2e on cold CI runners (failures on the main run for 2586ea9, the PR #657 runs, and PR #659's own first CI run): 1. Dev backoff cap (runtime). The refused-capture backoff window is exponential per consecutive failure, `min(BASE * 2^(failures-1), ceiling)`; the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count, and once the window exceeds ~16s it outlasts warmToHit's 20s poll: every request is skipped as backed-off, an eternal MISS. The ceiling is now mode-dependent: 60s in production (a genuinely ineligible route should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold module graph that warms on the very attempt that failed. Dev signal is `process.env.NODE_ENV !== "production"`, matching handler.ts / server-action.ts / progressive-enhancement.ts. 2. PPR dev warmup (test-side, the sufficient fix). PR #659's first CI run showed the cap alone is not sufficient: one probe cycle is ~12s (5s attempt + 5s in-place retry + 2s capped backoff), so the 20s window fits ~2 cycles and today's cold runners need more because the dev module graph is compiled ON DEMAND during the capture render, repeatedly blowing the 5s budget (physics hole failed 3x at ~20s). Extend tests/cloudflare-basic's existing dev-warmup setup project (which the dev project already depends on) with a ppr warmup: GET every ppr fixture route under an isolated `?probe=warmup` shell key (module-graph warm only, no test-visible entry pre-stored), then poll each capturable fixture to x-rango-shell HIT, proving the full capture pipeline (Flight render, fizz prerender+abort, putShell) is hot before any real test's warm window starts. Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed. warmToHit windows and runtime constants are otherwise untouched. 3. CI budget (the 600s owner). cloudflare-basic's playwright globalTimeout was `10 * 60 * 1000` on CI; the merged PPR stack grew the sequential dev -> production -> hmr run past 10m, so healthy runs brushed the cap and any flake retry tipped it over — Playwright killed the run mid-flight ("Timed out waiting 600s for the test suite to run"), failing whichever test was executing (the 0ms hmr-warmup skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were passing). Raised to 14m, and the cloudflare-basic-e2e job's timeout-minutes from 15 to 20 so the workflow cap contains ~3-4m setup + the 14m playwright budget. vite-rsc-demo and rangojs-router keep their 600s globalTimeout: their jobs shard/split, and recent CI runs show per-job playwright time of ~3-5m — nowhere near the cap. 4. shell-snapshot.test.ts 1ms flake (also blocking CI). "serves snapshot values AS FRESH" builds the seeded store and the expected value from two separate snapshotOf() calls, and segData embeds `Date.now() + 60_000` as expiresAt — a millisecond tick between the calls fails the deep-equal (seen on the #659 run). Pin the clock with fake timers (the shell-cache.test.ts pattern). The rest of the file was audited: no other assertion deep-equals across two Date.now() instants. Production backoff ceiling/growth, retry-in-place, the in-flight set, and the e2e warm-window lengths are unchanged. #652 items 1-2 (barrier narrowing, drain unification) remain open.
ivogt
force-pushed
the
fix/ppr-capture-backoff-dev
branch
from
July 4, 2026 10:37
ecb613e to
8647800
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On a cold CI runner the
[dev] ppr-shell.test.tssuite hard-fails:warmToHitpollsx-rango-shellfor 20s and never seesHIT, on the test attempt AND both Playwright retries (the backoff state lives in the dev-server process and persists across retries). Four independent CI occurrences: the main run for SHA2586ea9c(jobcloudflare-basic-e2e), the PR #657 runs, and — after the first iteration of this PR — #659's own first CI run (physics holefailed 3x at ~20s each).Three compounding causes, fixed together here, plus an unrelated CI-blocking unit flake:
(a) Backoff arithmetic. The refused-capture backoff window is exponential in the consecutive-failure count —
min(BASE * 2^(failures-1), ceiling)— and the ceiling was a flat 60s in every mode. On a cold runner each cold re-probe climbs the count (1s → 2s → 4s → 8s → 16s → …); once the window exceeds ~16s it outlasts the 20s poll — every request inside it hitsisCaptureBackedOffand is skipped → eternal MISS, even though the modules are warm by then.(b) Cold module graph outruns the capture budget. #659's first CI run proved the dev cap alone is necessary but not sufficient: one probe cycle ≈ 5s attempt (
SHELL_CAPTURE_MAX_WAIT_MS) + 5s in-place retry + 2s capped backoff ≈ 12s, so the 20s window fits only ~2 cycles — and today's cold runners need more, because the dev module graph is compiled on demand during the capture render, repeatedly blowing the 5s budget.(c) CI budget — the 600s owner.
tests/cloudflare-basic/playwright.config.tssetglobalTimeout: 10 * 60 * 1000on CI. The merged PPR shell stack grew the sequential dev → production → hmr playwright run past 10 minutes, so healthy runs brushed the cap and any flake retry tipped it over — Playwright then killed the run mid-flight with its own "Timed out waiting 600s for the test suite to run", failing whichever test happened to be executing. This mechanism explains the remaining CI deaths that were NOT eternal-MISS shaped: the 0ms hmr-warmup "failure" skip-cascade, the mid-hmr kill at 10:31:06 while hmr tests were passing, and contributed to the main2586ea9crun (where the warmToHit retries' wall time cascaded into the cap).(d)
shell-snapshot.test.ts1ms flake. The same #659 run also failedSeededShellStore > serves snapshot values AS FRESH—expiresAt ...881 vs ...880. The test builds the seeded store and the expected value from two separatesnapshotOf()calls, andsegDataembedsDate.now() + 60_000; a millisecond tick between the calls fails the deep-equal.This is item 3 of #652. Items 1–2 (barrier narrowing, drain unification) remain open — #652 must stay open after this PR merges (referenced here without closing keywords, deliberately).
Fix (four coordinated changes)
1. Dev backoff cap (runtime safety net)
The backoff ceiling is now mode-dependent: 60s in production (a genuinely ineligible route — no
loading(), a cookie-reading handler — should be re-probed rarely), ~2s in dev, where the dominant no-shell cause is a cold graph that warms on the very attempt that failed.The dev signal matches the rest of the RSC runtime (
handler.ts,server-action.ts,progressive-enhancement.ts) — no new env probe.2. cloudflare-basic PPR dev warmup (the sufficient fix, test-side)
Warm the module graph BEFORE the ppr suite runs, so the first real capture attempt runs against a hot graph and completes inside its 5s budget on attempt 1. Mechanism: extend the suite's existing
dev-warmupsetup project (tests/cloudflare-basic/e2e/dev-warmup.setup.ts), which thedevproject already depends on — the same patternpackages/rangojs-router/playwright.config.tsuses. No config change needed; the new setup test is picked up by the project'stestMatch.The warmup:
src/urls.tsx(/ppr-shell,/ppr-shell/stream,/ppr-shell/no-hole,/ppr-drift,/ppr-blog,/ppr-blog/getting-started-with-rsc) withAccept: text/html, compiling each route's page/loader modules AND the capture-only path (loader mask,captureShellHTML, fizz prerender) via the background capture each GET schedules;?probe=warmupquery on every GET — the shell key includes the query, so no test-visible shell entry is ever pre-stored and every test's own MISS→HIT sequence is untouched;/ppr-shell,/ppr-shell/stream,/ppr-drift,/ppr-blog) tox-rango-shell: HIT, proving the full capture pipeline is hot — not merely requested./ppr-shell/no-holeis excluded (it can never HIT; the suite pins that negative);setup.describe.configure({ mode: "serial" })) so local 2-worker runs cannot race the optimizer churn.Cap + warmup together: the warmup makes first attempts succeed; the cap remains the safety net for anything cold the warmup missed.
warmToHitwindows and all other runtime constants are untouched. The production project needs nothing — build output is always warm (CI consistently passes prod).(No nonce ppr fixture exists in cloudflare-basic's
urls.tsx; the route list above is the completeppr-declared set.)3. CI budget: 14m playwright globalTimeout, 20m job cap
tests/cloudflare-basic/playwright.config.ts—globalTimeoutraised from 10m to 14m on CI, with a sizing comment: the PPR stack grew the suite past the old 10m (and the ppr warmup adds dev-time work); 14m playwright budget + ~3-4m of job setup (install, router build, browser deps) fits the workflow job cap..github/workflows/e2e.ymlcloudflare-basic-e2e—timeout-minutesraised from 15 to 20 ("20 (was 15): ..." comment mirroring the rangojs-router job's "30 (was 20)" style), so the workflow cap comfortably contains setup + the new playwright budget.The other two configs with the same
600000pattern were checked against recent CI runs and deliberately left unchanged — their jobs shard/split, so no single playwright run is near the cap:vite-rsc-demo(playwright.config.ts:14)rangojs-router(playwright.config.ts:41)cloudflare-basic(Measured on the main
2586ea9crun 28694625718 and this PR's run 28703125311.)4. Deterministic
shell-snapshot.test.tsPin the clock with
vi.useFakeTimers()+setSystemTime(thesrc/cache/__tests__/shell-cache.test.tspattern) in the racy test, so bothsnapshotOf()calls see the sameDate.now(). The whole file was audited for siblings: no other assertion deep-equals values derived from twoDate.now()instants (the othersnapshotOf/segDatauses assert partial fields or compare a single instance;settleWrites is boundedintentionally uses real timers with a generous bound).Consumer-observable end-to-end
A consumer declaring PPR on an eligible route:
On a cold dev worker the first document GET is
x-rango-shell: MISSand schedules a background capture. Before this change, on a slow machine the exponential backoff could freeze the route on MISS for up to 60s. After, a cold-but-eligible route re-probes every ~2s in dev and flips toHITas soon as the modules warm. No API change; only the dev-mode recovery cadence changes. The warmup is internal to this repo's e2e suite.Semantics
min(1000·2^(N-1), 60000)min(1000·2^(N-1), 60000)Code change
packages/rangojs-router/src/rsc/shell-capture.ts— addREFUSED_CAPTURE_DEV_MAX_MS(2s),isDevMode(),refusedCaptureCeilingMs();markCaptureBackoffclamps to the mode ceiling. Backoff internals exported for unit tests (same convention as the existingrunShellCapturetest export). This is the single layer owning the backoff lifecycle, so the cap lives here.tests/cloudflare-basic/e2e/dev-warmup.setup.ts— the ppr warmup setup test (see above). Chosen over a new Playwright project or global-setup because thedev-warmupproject →devdependency already exists and itstestMatchpicks the file up with zero config churn.packages/rangojs-router/src/cache/__tests__/shell-snapshot.test.ts— fake timers in the racy test.packages/rangojs-router/src/rsc/__tests__/shell-capture.test.ts— newrefused-capture backoff policydescribe (see test plan).packages/rangojs-router/docs/design/ppr-shell-resume.md— "Refused-capture backoff" section documents the mode-dependent ceiling and the cold-CI failure.Test plan
shell-capture.test.ts(34 tests). New pins:REFUSED_CAPTURE_DEV_MAX_MShowever high the failure count climbs.warmToHitcadence — capture fails at t0, one request per second for 20s, every probe also cold and re-marking. Dev cap: ≥8 probes total, ≥1 in the last 4s. The sibling prod-mode test asserts the same sequence freezes the tail to zero probes — the eternal MISS being fixed — making the dev pin non-vacuous.shell-snapshot.test.ts(14 tests): the previously-flaky deep-equal now runs under a pinned clock; contract unchanged (seeded values served AS FRESH,shouldRevalidate: false, real store untouched).--project=devwith the warmup dependency enforced (dev-warmup → dev): 16 passed, twice consecutively;--project=production --no-deps: 14 passed. (An intermediate local repro that ran warmup CONCURRENTLY with the dev tests via--no-depsflaked ppr-blog — an artifact of stripping the project dependency, impossible on CI where dev-warmup always completes first.)shell-cache+shell-cache-action+shell-secure— 42 passed (dev+prod).test:unit:all.pnpm run typecheckpnpm run test:unit:all(297 router unit files + all consumer app suites + RSC suites)pnpm run lintpnpm run formatpnpm check:e2e-bucketingplaywright test --list --project=productionloads the cloudflare-basic config and resolves the full project/dependency chain.Notes
?probe=warmupisolation), so the suite's MISS-first assertions (?probe=miss,?probe=seq) and the no-hole negative are untouched.renders and hydrates on the live path, and soft-navduplicate-ppr-priceflake (4th sighting) is tracked on PPR capture pipeline hardening: narrow the write barrier, unify bounded drains, tame the backoff-vs-CI flake #652's comments and is out of scope here.