Repository navigation
feat(ppr): default capture budget 5s → 15s + debugger disables the deadline - #717
Merged
Merged
Conversation
…adline under an attached debugger 5s spuriously refused legitimately-slow deferred shell material — a real storefront's ~7s chained-meta pattern could never capture without the per-route knob. Captures are background work (waitUntil): a larger budget costs latency-to-HIT only, never a served response. Framework survey backs the shape: Next.js runs 60s/page (cache-fill guard 54s = 0.9x clamp) under a 300s+ platform ceiling — proportionally the same budget-eats-half-the-ceiling-per-attempt geometry as 15s under workerd's ~30s; Waku and vinext have NO budget at all and hang forever / stall-with-silent-error-row respectively on never-settling material — the failure mode this design refuses. Ceiling math (canonical doc on the new leaf constant): the guaranteed two-attempt envelope needs 2xbudget + retry + store I/O <= ~30s -> budget <= ~14s; 15s sits just past it — attempt 1 always gets the full budget, worst case the in-place retry is platform-truncated on workerd, degrading to the existing best-effort re-capture. Node/dev/build unaffected. captureTimeout remains for tightening below the default. Also steals Next's debugger nicety (export/worker.ts precedent): with a debugger attached (NODE_OPTIONS/execArgv --inspect substring, or a live inspector.url() — covers VS Code auto-attach's flag-less bootloader and runtime SIGUSR1 attach), the capture deadline becomes never-resolving so paused execution can't burn the budget into refusal+backoff. Non- production only (bare NODE_ENV token, build-folded; gate matches isDevMode semantics so NODE_ENV-unset dev servers qualify); production keeps the budget unconditionally. Simplify review applied (4): SHELL_CAPTURE_MAX_WAIT_MS extracted to a leaf constants module (one canonical home for value + ceiling math; the ssr mirror constant deleted; pointers elsewhere), the dev gate bug (=== "development" missed NODE_ENV-unset debug servers), nameless-route poll windows resized for the new default (were sized for 5s — zero retry headroom at 15s) + test.setTimeout to clear the 30s playwright cap, duplicate NODE_OPTIONS test folded into stronger variants. Skipped with rationale: dropping the inspector.url() probe (auto-attach/runtime attach need it; memoization would break attach-to-running-server). The default-budget refusal negative stays meaningful via an explicit captureTimeout: 1500 on the slow-meta-default fixture (route/name unchanged — no gen churn, stable warning regex). Batteries: router unit 5122 + rsc 68; shell-cache+capture-timeout 36 dev / 36 production; cloudflare ppr-capture-timeout 5 dev / 301+2 production (1 pre-existing documented flake, retry-passed); typecheck/ lint/format/bucketing green.
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.
feat(ppr): raise the shell-capture default budget 5s -> 15s; disable it under an attached debugger (non-production)
Problem statement
A consumer whose route shell carries legitimately slow deferred material — e.g. a storefront pushing
ctx.use(Meta)(dataPromise.then(...))chains that settle at ~7s — got an eternalx-rango-shell: MISSwith the once-per-key no-usable-shell warning, unless they discovered and declaredppr.captureTimeoutby hand. The 5s default was tuned as a safety bound, but capture is background work (waitUntil): the budget costs latency-to-HIT only, never a served response, so a tight default bought nothing and spuriously refused real-world shells. Separately, debugging capture code in dev was near-impossible: a breakpoint pauses script but not wall-clock, so the budget expired mid-inspection and pushed the key into refusal/backoff.Before vs. after
End-to-end consumer example
Semantics
ppr.captureTimeoutper-route knobwaitUntilceiling (~30s past response)NODE_ENVunset/test/development) + debugger attached (--inspect*/ active inspector)--inspectin NODE_OPTIONSCeiling math (now documented on the const): workerd
waitUntil≈ 30s ≥ 2×budget + in-place retry delay + store I/O → a guaranteed two-attempt envelope needs budget ≤ ~14s. 15s deliberately sits just past that: the second attempt is best-effort on workerd in the worst case, and the first attempt — which succeeds on a warm graph — always gets the full budget.Code changes
packages/rangojs-router/src/rsc/shell-capture-constants.ts— leaf module (imports nothing) owningSHELL_CAPTURE_MAX_WAIT_MS = 15_000with the ONE canonical doc block (raise rationale + ceiling math). Both graphs import it:rsc/shell-capture.tsre-exports under the existing name, soshell-build-manifest.ts's dev fetch envelopedevShellFetchTimeoutMskeeps its import site and auto-scales — verified, no change needed; descriptor JSDoc updated.packages/rangojs-router/src/ssr/index.tsx— the mirroredDEFAULT_SHELL_CAPTURE_MAX_WAIT_MS+ copied rationale are DELETED;captureShellHTML's fallback imports the leaf constant directly (the two values can no longer drift; runtime always passesmaxWaitMs). NEWisDebuggerAttached()(exported for tests): gated on bareprocess.env.NODE_ENV !== "production"(the build define folds exactly that token — same dev signal asisDevMode— and it covers NODE_ENV-unset dev servers, the common debug case), probesNODE_OPTIONS--inspectsubstring (catches--inspect-brk/ports),process.execArgv, then a try/caught dynamicimport("node:inspector")+inspector.url()— guarded so workerd resolves tofalseinstead of breaking. At the deadline seam the attached case swapscreateCancelableTimeout(maxWaitMs)for a never-resolving promise (no timer, so it can't hold the event loop open). Next.js precedent:packages/next/src/export/worker.ts.packages/rangojs-router/src/rsc/shell-serve.ts,src/urls/pattern-types.ts— JSDoc default + ceiling notes (captureTimeoutpublic option doc states 15000 and the workerd ~30s physical ceiling, pointing at the design doc for the derivation).packages/rangojs-router/docs/design/ppr-shell-resume.md,docs/internal/feature-map.md,docs/internal/feature-file-map.md— default + ceiling math updated (the derivation lives once, in the design doc's Cost model, which names the leaf const as the canonical in-code doc); file map gains the new constants module.Location rationale: the value lives in a LEAF module (
shell-capture-constants.ts) because both the rsc graph andssr/index.tsxneed it and ssr must not pull the capture orchestration graph for one number;shell-capture.tsre-exports it so existing import sites keep working. The debugger check lives at the ONE deadline seam incaptureShellHTMLso every producer (runtime, producer B, dev endpoint) inherits it.Test plan
pnpm run typecheck— 16/16 green.pnpm run test:unit:all— router 5122 passed (330 files) + rsc 68; all consumer apps green.shell-capture.test.ts— default pin updated: "defaults maxWaitMs to 15000 when no captureTimeout is declared" pins the descriptor→captureShellHTMLdefault.shell-handlers.test.tsxisDebuggerAttachedsuite (4 cases):--inspect-brk=PORTsubstring true in dev, NODE_ENV-unset +--inspecttrue (the non-production gate), production+--inspectfalse (prod keeps the budget, pinned with an explicitNODE_ENV=production), dev with no flags false.shell-serve-ppr-config.test.tsaudited — pins explicit values only (10000/8500/9000), no default literal.--project=dev/production --no-deps,shell-cache+shell-capture-timeout): 36 passed each./shell-cache/slow-meta-default(~6.5s material) would now CAPTURE under 15s, so the fixture declares an explicitcaptureTimeout: 1500(mirroring cloudflare's/ppr-short-meta), keeping the refusal contract pinned — budget expiry with pushes pending → MISS forever, no partial bake, once-per-key warning — deterministically and fast (wait constant 12s → 6s). Path/name keep the historical-defaultsuffix (stable warning regex, no.gen.tschurn); title now "budget shorter than the settlement sequence refuses…"./shell-cache/slow-meta(knob 10000) unchanged — still pins that a declared budget admits staged multi-part settlement.CI=true,ppr-capture-timeout+ppr-shell.test.ts): dev 30 passed, production 327 passed / 2 skipped (production project pulls its dev dependency unfiltered).pnpm run lint(0 warnings),pnpm run format,pnpm check:e2e-bucketing— green..gen.tschanges (no route add/remove/rename).Notes
capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MSfeeds onemaxWaitMs).process.env.NODE_ENV !== "production"gates it before any probe (the build define folds the token to a literal), so a production worker launched with--inspectinNODE_OPTIONSkeeps its safety bound, while dev servers that never set NODE_ENV — the common debug case — are covered. On workerd thenode:inspectorimport rejects and is caught; env/execArgv reads are optional-chained. Not e2e-tested (per scope) — unit-covered.tests/cloudflare-basic/e2e/dev-warmup.setup.ts): the cold-graph math there was written against the 5s budget; comment now dates it and notes the 15s consequence (warmToHit's 20s window fits zero full two-attempt cycles — the warmup is more load-bearing, not less).*-settled-fasttwice (strict-mode locator violation, duplicate DOM element on a HIT). Base run: attempt-1 failure + retry pass; one diff run failed 3/3, the rerun passed 30/30. Worth its own issue — the duplicate-element symptom looks like a real serve-overlay bug, independent of budgets.Simplify review
4-agent /simplify protocol run on the diff; findings applied and re-gated (typecheck 16/16; router vitest 330 files / 5122 passed + 6 todo; rsc suite 68 passed; lint 0 warnings; format clean; router e2e
shell-cache+shell-capture-timeoutdev 36 / production 36; cloudflare-basicppr-capture-timeoutdev 5 / production 301 passed + 2 skipped + 1 flaky — the production project pulls its dev dependency unfiltered; the flaky test is the pre-existing bake-lane duplicate-element flake documented in Notes, in the dev dependency suite, and passed on retry).Applied:
src/rsc/shell-capture-constants.ts(imports nothing) ownsSHELL_CAPTURE_MAX_WAIT_MS = 15_000and the ONE canonical doc block (raise rationale + ceiling math).rsc/shell-capture.tsimports + re-exports under the existing name (shell-build-manifest.ts's envelope import site verified unchanged);ssr/index.tsx's mirroredDEFAULT_SHELL_CAPTURE_MAX_WAIT_MS+ copied rationale DELETED in favor of the leaf import — the values can no longer drift.pattern-types.tscaptureTimeoutJSDoc drops the 6-line envelope paragraph (keeps default 15s + workerd ~30s physical ceiling + design-doc pointer);ppr-shell-resume.mdkeeps the derivation once (Cost model, which now names the leaf const as the canonical in-code doc; the §captureTimeout intro parenthetical condensed to a pointer).feature-file-map.mdgains the new file. Precedent:cache/cf/cf-cache-constants.ts(same extract-constants-leaf + re-export pattern).isDebuggerAttachedgate changed fromglobalThis.process?.env?.NODE_ENV !== "development"to bareprocess.env.NODE_ENV !== "production"— the build define folds exactly the bare token, it aligns withisDevMode, and it covers dev servers that never set NODE_ENV (the common debug case). Unit tests updated: NODE_ENV-unset +--inspectnow pins TRUE; the production case pinsNODE_ENV=productionexplicitly. The rest of the probe chain kept unchanged (see Skipped).shell-capture-timeout.test.tstoPass 15s → 40s;ppr-capture-timeout.test.tstoPass 20s → 40s; one-line comment at each: the window must fit attempt + in-place retry at the 15s default on a cold graph. Deviation from the finding as written: also addedtest.setTimeout(60_000)to both tests — both playwright configs cap tests at 30s locally (60s CI), so a 40s poll inside a 30s test budget could never run to completion.--inspect-brk=9230variant (pins the substring match); the bare--inspecttwin's slot now carries the new NODE_ENV-unset case, so the suite stays at 4 tests with strictly more coverage.Skipped (with rationale):
node:inspectorprobe fromisDebuggerAttached— kept: VS Code auto-attach injects a NODE_OPTIONS bootloader with no--inspectsubstring, and a runtime SIGUSR1 attach carries no flag at all;inspector.url()is the only probe that sees either. The async hop is negligible per-capture, and memoizing it would break attach-to-a-running-server.