Repository navigation
feat(ppr): captureTimeout + handle-settle capture gate; nameless-ppr coverage (#714, #715) - #716
Merged
Merged
Conversation
…nameless-ppr coverage (closes #714, #715) resolvePprConfig -> ShellCaptureDescriptor -> captureShellHTML maxWaitMs, bounding BOTH the fizz prerender and the deferred-material settle window. The freeze is ORDERED behind full handle-stream convergence: the gate never fires while a top-level push is pending, staged/chained pushes (immediate + slow + Meta chained off the slow one) converge to a single yield, and budget expiry with pending pushes yields a body-less prelude -> sanity-gate refusal — a partial-prefix bake (wrong/missing meta in the snapshot) is unrepresentable. Producer B and the dev /__rsc_shell read-through honor the same knob; the dev fetch bound now models the FULL server envelope (probe + 2x budget + retry margin — the old 2x+10s bound was exceedable even at defaults, aborting healthy captures into spurious MISSes). workerd waitUntil (~30s) documented as ceiling. DSL stores ppr on the synthesized $path_* entry regardless of name (path-helper.ts); the field symptom was a stale-server artifact plus the pre-#705 Accept-negotiation silence. Nameless param-bearing ppr fixtures + MISS->HIT e2e now pin the combination in both apps. Red->green: slow-meta fixture (5.5s push + 1s chained Meta) with the plumb reverted stays eternal-MISS with the once-per-key warning; restored, it HITs with the resolved chained <title> frozen in the stored prelude. The no-knob sibling remains the permanent in-tree red pin. Validated end-to-end in a real storefront (7s deferred meta chain, captureTimeout: 12000 -> HIT at 16s, baked title byte-frozen across HITs, package restored after). Simplify review applied (6): shared normalizeCaptureTimeout (was 3 copies), complete dev fetch-bound formula w/ hoisted DEV_SHELL_PROBE_TIMEOUT_MS, 2 duplicate fixture components deleted, comment triplication collapsed, stale timing comment fixed, duplicate 6.5s live-path test folded into the MISS body read. Skipped (noted): test-local splitPrelude convention; cross-app fixture duplication (different presets/negatives — deliberate). Batteries: router unit 5112; shell-cache+capture-timeout 33 dev/34 prod; cloudflare ppr suites 30 dev/28 prod; typecheck/lint/format/ bucketing/docs-api green.
ivogt
force-pushed
the
feat/ppr-capture-timeout
branch
from
July 7, 2026 04:44
eb751d2 to
4873773
Compare
commit: |
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.
fix(ppr): nameless routes verified + pinned on the shell lane (#714); per-route
captureTimeoutcapture budget for deferred shell material (#715)Closes #714, closes #715.
Problem statement
A production storefront's document routes push deferred shell material —
ctx.use(Meta)(dataPromise.then(...))and top-level handle pushes carrying promises that settle in parts (an immediate part, a slow upstream part, and a Meta title chained off the slow part). Under PPR the intended semantic is that the capture awaits the complete settlement sequence and bakes the settled values into the stored shell. The capture's settle budget was a hard-coded 5 s: any route whose material settles slower was uncapturable forever — eternalx-rango-shell: MISS, backoff, and the once-per-key no-usable-shell warning — with no way to declare "this route's shell material takes ~7 s, wait for it" (#715). Separately, #714 reported that declaringppron a nameless route was silently ignored (no header, no store read, no log).#714 root cause (investigated first, as filed)
The reported name-dependence does not exist in the entry pipeline, and could not be reproduced on main in any shape. The DSL stores the
pproption on the routeEntryDataregardless ofname(src/urls/path-helper.ts:180-182— nameless routes register under a synthesized$path_*manifest key with the option intact), and the serve gate reads only that entry (resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry),src/rsc/rsc-rendering.ts:127). Verified end to end, including in the discovering app itself (rsc-cloudflare-app, experimental.146) with instrumentation at the gate:Nameless ppr flips MISS→HIT on main and on exp.146 across: node dev/production (mini), Cloudflare vite dev / vite preview /
wrangler devon workerd (cloudflare-basic + the real app), host-router with path-based mounts +basename(the real app's exact topology), named/merged/bare/lazyinclude()shapes, and an HMR-added route.What does reproduce the reported symptom exactly — no header, no store read, no capture, no log — is a document GET whose
Acceptlackstext/html(curl's default*/*): on experimental.146 the old negotiation rule served such requests the Flight wire format (text/x-component), and the shell lane is (correctly) skipped for Flight responses. That bypass is name-independent (verified: on exp.146, the named control showed the identical no-header shape under plain curl) and was fixed the same day the issue was filed by #705 (bc054c10, "RSC flight wire format becomes explicit opt-in"), which landed after the .146 publish. Conclusion: the fix bar's preferred path — nameless ppr WORKS — already holds; what was missing was the coverage the issue calls out ("the nameless+ppr combination has zero coverage"). This PR pins it so it can never silently regress:/shell-cache/nameless/:probe,/ppr-nameless/:probe);path()→loadManifestunder the synthesized$path_*key →pprintact →resolvePprConfigresolves (shell-serve-ppr-config.test.ts).#715:
ppr.captureTimeout— before vs. afterEnd-to-end consumer example (the storefront meta pattern)
First document GET: MISS (axis 1). Background capture awaits all three parts, bakes the resolved
<title>and handle values into the stored prelude. Every later GET: HIT — frozen head/meta flushed immediately, the price hole resumed live. If the upstream ever outlasts the budget, the capture refuses (stays MISS, once-per-key warning) — a shell with missing or unsettled head material is never stored.Semantics table
captureTimeoutcaptureTimeoutcaptureTimeoutlarge enough<body>)holdUntilblocks the freeze;SsrRootsuspends at the root until the final snapshot)captureTimeout(0, negative, NaN, ∞, non-number)setTimeout/__rsc_shelldevShellFetchTimeoutMs)waitUntillifetime (~30 s past response)pprtext/htmlAccept)SHELL_CAPTURE_WRITE_BARRIER_MS/SHELL_SNAPSHOT_WRITE_SETTLE_MS/ retry delay / backoff windowsCode-change summary
One knob, one deadline: the resolved option rides the existing descriptor into
captureShellHTML'smaxWaitMs, which already bounds both the fizz prerender and the deferred-material settle window (theholdUntilgate and the quiesce race are inputs to the same deadline) — no second timer to drift.src/urls/pattern-types.ts—PartialPrerenderProps.captureTimeout?: number(ms, default 5000; consumer-facing JSDoc incl. refusal semantics and the workerd ceiling).src/rsc/shell-serve.ts—ResolvedPprConfig.captureTimeout+ exportednormalizeCaptureTimeout(finite ≥ 1 ms passes; anything else → undefined → capture default; the ONE normalizer — the dev endpoint reuses it at its query-string boundary) +DEV_SHELL_PROBE_TIMEOUT_MS(the dev endpoint's pre-flight probe timeout, hoisted so the client fetch bound enumerates the same term). Key block:src/rsc/shell-capture.ts—ShellCaptureDescriptor.captureTimeout;captureAndStoreShellpassesmaxWaitMs: capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS(the default's single owner stays here).src/rsc/rsc-rendering.ts— threadspprConfig.captureTimeoutonto the runtime descriptor AND the dev build-shell read-through options.src/rsc/shell-build-manifest.ts—DevShellLookup.captureTimeout; the/__rsc_shellquery param;devShellFetchTimeoutMssizes the dev fetch bound to the endpoint's FULL sequential worst case:DEV_SHELL_PROBE_TIMEOUT_MS + 2×budget + DEV_SHELL_RETRY_MARGIN_MS(probe + two attempts + retry margin — a bound that omitted the sequential 10 s pre-flight probe could abort a still-healthy capture into a spurious MISS, latent even at defaults: 21 s worst case vs the old 20 s bound).src/vite/router-discovery.ts— the dev endpoint re-normalizescaptureTimeoutat its query-string boundary via the sharednormalizeCaptureTimeout(a garbage param must not reachsetTimeoutas NaN), includes it in the memo key, forwards tocaptureShellForBuild; the pre-flight probe uses the sharedDEV_SHELL_PROBE_TIMEOUT_MS.src/prerender/build-shell-capture.ts,src/vite/discovery/{state,shell-prerender-phase}.ts— producer B parity: the candidate's raw option flows throughresolveBuildPprConfiginto the build capture descriptor.docs/design/ppr-shell-resume.md— new "captureTimeout: the capture settle budget" section (ordering contract, refusal, producer B parity, deliberately-unchanged bounds) + Platform notes documenting the workerdwaitUntillifetime as the physical ceiling; the opt-in section records the ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714 verdict.docs/internal/feature-map.md/feature-file-map.mdupdated in the same PR.Chosen location over alternatives: no new timer primitive and no per-phase budget split — the existing single
maxWaitMsdeadline already expresses "bounded end to end", and the ordering contract (freeze never fires while a tracked top-level push is pending; expiry with pending pushes refuses via the trivial-prelude sanity gate) is enforced by the existingholdUntil/root-suspension structure, now documented and pinned by tests.Test plan
maxWaitMs: SHELL_CAPTURE_MAX_WAIT_MS),/shell-cache/slow-meta(captureTimeout: 10000declared) staysMISSforever and logsShell capture for "…slow-meta?probe=red:shell" produced no usable shell after an in-place retry; with the plumb restored the same route flipsMISS → HITand the stored prelude carries all three resolved parts. Unit red: the newshell-capture.test.tsmaxWaitMs pins fail without the descriptor plumb.shell-serve-ppr-config.test.ts(12 tests — captureTimeout parsing matrix + the nameless$path_*loadManifestround-trip; pins the entry-carries-ppr contract),shell-capture.test.ts(+2: descriptor→maxWaitMs, default 5000),rsc-rendering-shell-ppr.test.ts(+2: descriptor flow, invalid-value normalization; +1 assert).pnpm run test:unit:allgreen (router + every consumer app).e2e/shell-capture-timeout.test.ts, isolatedServer): 3 dev + 3 production — nameless MISS→HIT (ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714); staged slow-meta MISS→HIT with all three parts baked in the stored prelude at one capture seq,<title>= the resolved chained value, values frozen across consecutive HITs, and the live path pinned off the same test's MISS body (the axis-1 render awaits the full sequence); the no-knob sibling stays MISS with the once-per-key warning asserted from the server log. The staged fixture (immediate + 5.5 s + chained +1 s) pins staged resolution, chaining, and meta correctness in one route.e2e/ppr-capture-timeout.test.ts, workerd + KV CFCacheStore): 3 dev + 3 production — nameless MISS→HIT; slow-meta MISS→HIT with the full prelude/title/frozen-seq assertions; a too-short explicit budget (1500 ms vs ~3.5 s material) refuses and stays MISS. The no-knob default-budget negative is test-app-only by design: two 5 s default attempts would park this suite's shared serialized capture queue (capture-queue.ts), and the shared webServer exposes no log handle for the warning assert — flagged here rather than silently skipped.shell-cache+shell-secure+shell-cache-action+prerender-ppr— 46 dev + 46 production, all green; cloudflare-basicppr-shell+ppr-tag-eviction— 33 dev (1 unrelated flaky, passed on retry: strict-mode duplicate element in the bake-lane settled test) + 32 production, green; new suites 3+3 (test-app) and 3+3 (cloudflare). Post-simplify re-gate: rangojs-routershell-cache+shell-capture-timeout33 dev (2 pre-existing shell-cache flaky, passed on retry) + 34 production (1 flaky, passed on retry — fixture pnpm spawn noise); cloudflare-basicppr-capture-timeout+ppr-shell30 dev + 28 production, all green.pnpm run typecheck,pnpm run test:unit:all,pnpm run lint,pnpm run format,pnpm check:e2e-bucketing,pnpm check:docs-api— all green.router.named-routes.gen.tsregenerated in both apps whose routes changed.Notes
gateFlightForCapture'sholdUntilblocks the freeze, andSsrRootReact.usees the handles snapshot at the ROOT —resolvedHandleStreamyields once, after every push (including promises chained off other pushes) resolves — so a budget expiry with pushes pending yields a body-less prelude and the sanity gate refuses. A partial prefix of the settlement sequence is unrepresentable in a stored shell, at any budget. The staged e2e fixtures pin exactly this.waitUntilallows roughly 30 s past response completion; acaptureTimeoutnear it gets the capture killed by the platform (nothing stored, self-healing MISS — rango cannot warn, the isolate is gone). Documented in Platform notes. Build-time captures have no such bound.shell-serve.ts/shell-capture.ts, CF/Vercel envelopehhfield,/shell-cache/headers+/ppr-shell/headersfixtures and suites). During ppr on a NAMELESS route is silently ignored — no shell lane, no header, no warning #714 repro cleanup, agit checkoutoftests/cloudflare-basic/src/urls.tsxreverted Cached-scope header lock: ppr = document-level cache() — ONLY middleware writes headers in cached scenarios; handlers/loaders disallowed loudly #713's uncommitted route wiring in that one file; it was reconstructed from the surviving fixture files and test expectations (imports, the seq/echo middleware,/ppr-shell/headers,/ppr-shell/handler-cookie) and re-verified — all three Cached-scope header lock: ppr = document-level cache() — ONLY middleware writes headers in cached scenarios; handlers/loaders disallowed loudly #713 cloudflare tests pass in dev and production. Worth a second look in review.Simplify review
Applied:
captureTimeoutnormalizer —normalizeCaptureTimeout(value: unknown)exported fromsrc/rsc/shell-serve.ts; the dev/__rsc_shellendpoint (src/vite/router-discovery.ts) re-normalizes its query param through it instead of an inline re-implementation (the redundant?? NaNdropped —Number(null)is 0 and already fails ≥ 1).devShellFetchTimeoutMsomitted the endpoint's sequential 10 s pre-flight probe, so the server's worst case (probe 10 s + 2×budget + retry ≈ 21 s at defaults) exceeded the 20 s client bound — a spurious client abort of a healthy capture. NowDEV_SHELL_PROBE_TIMEOUT_MS + 2×budget + DEV_SHELL_RETRY_MARGIN_MS, with the probe constant hoisted intoshell-serve.tsand imported by BOTH sides (and the default budget imported fromSHELL_CAPTURE_MAX_WAIT_MS, now exported) so no term can drift. Deeper fix noted as a follow-up: the endpoint owns one total deadline; the client bound becomes a plain liveness backstop.ShellSlowMetaPage(byte-identical toShellCachePricePage) deleted, both slow-meta routes point atShellCachePricePage; cloudflare-basicPprSlowMetaPricePagedeleted, the slow/short routes reusePprShellPricePage.maxWaitMsinshell-capture.tsshrunk to one pointer at the canonical spec (PartialPrerenderProps.captureTimeout,urls/pattern-types.ts).shell-cache.defs.ts: "(+2s, ~7.5s total)" corrected to "(+1s, ~6.5s total)" (SLOW_META_CHAIN_EXTRA_MSis 1_000).toMatchasserts on the first response body); ~13 s saved across modes.Skipped (deliberate):
fetchSplitvssplitPreludeduplication — pre-existing per-file convention (3× already in the suites).