Skip to content

feat(ppr): default capture budget 5s → 15s + debugger disables the deadline - #717

Merged
ivogt merged 1 commit into
mainfrom
feat/capture-budget-15s
Jul 7, 2026
Merged

ivogt merged 1 commit into
mainfrom
feat/capture-budget-15s

Conversation

@ivogt

@ivogt ivogt commented Jul 7, 2026

Copy link
Copy Markdown
Collaborator

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 eternal x-rango-shell: MISS with the once-per-key no-usable-shell warning, unless they discovered and declared ppr.captureTimeout by 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

// BEFORE — ~7s meta chain: eternal MISS unless the knob is declared
path("/pdp/:id", ProductPage, {
  name: "product",
  ppr: { ttl: 600, swr: 120, captureTimeout: 10_000 }, // required
});

// AFTER — the 15s default admits it; no knob needed
path("/pdp/:id", ProductPage, {
  name: "product",
  ppr: { ttl: 600, swr: 120 },
});

End-to-end consumer example

// src/urls.tsx — a PDP whose title is derived from product data
function ProductLayout(ctx: HandlerContext) {
  const product = fetchProduct(ctx.params.id); // ~4s upstream
  // Meta chained off the data promise: settles at ~5s, then +2s enrichment
  ctx.use(Meta)(
    product.then(async (p) => ({ title: `${p.name} — ${await enrich(p)}` })),
  );
  return (
    <main>
      <Outlet />
    </main>
  );
}

export const urlpatterns = urls(({ path, layout, loader, loading }) => [
  layout(ProductLayout, () => [
    path("/pdp/:id", ProductPage, { name: "product", ppr: { ttl: 600 } }, () => [
      loader(PriceLoader),
      loading(<PriceSkeleton />),
    ]),
  ]),
]);
// First GET: MISS (axis 1). Background capture awaits the full ~7s settlement,
// bakes the resolved <title> into the stored prelude; next GET: HIT.
// Under the old 5s default this route could never capture.

Semantics

Case Before (5s default) After (15s default)
Deferred material settles <= 5s, no knob captures captures (unchanged)
Deferred material ~5-15s, no knob REFUSED — eternal MISS + warning captures
Deferred material > budget (pushes pending at expiry) refuses, no partial bake, once-per-key warning unchanged — refusal ordering, warning, and "never store a partial head" are untouched
ppr.captureTimeout per-route knob raise above 5s unchanged knob; now also useful to TIGHTEN below 15s
Invalid knob values (non-finite, < 1ms) fall back to default unchanged
workerd waitUntil ceiling (~30s past response) 2×5s + retry fits comfortably attempt 1 always gets its full 15s; worst case (attempt 1 consumed all 15s) the in-place retry may be platform-truncated — degrades to the existing best-effort contract, a later request re-captures
Node / dev / build-time (Prerender+ppr) captures budget is the only bound unchanged (no platform ceiling)
Non-production (NODE_ENV unset/test/development) + debugger attached (--inspect* / active inspector) budget expires while paused → refusal/backoff budget disabled (never-resolving deadline, Next.js precedent)
Production + --inspect in NODE_OPTIONS budget enforced unchanged — budget enforced unconditionally

Ceiling 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

  • NEW packages/rangojs-router/src/rsc/shell-capture-constants.ts — leaf module (imports nothing) owning SHELL_CAPTURE_MAX_WAIT_MS = 15_000 with the ONE canonical doc block (raise rationale + ceiling math). Both graphs import it: rsc/shell-capture.ts re-exports under the existing name, so shell-build-manifest.ts's dev fetch envelope devShellFetchTimeoutMs keeps its import site and auto-scales — verified, no change needed; descriptor JSDoc updated.
  • packages/rangojs-router/src/ssr/index.tsx — the mirrored DEFAULT_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 passes maxWaitMs). NEW isDebuggerAttached() (exported for tests): gated on bare process.env.NODE_ENV !== "production" (the build define folds exactly that token — same dev signal as isDevMode — and it covers NODE_ENV-unset dev servers, the common debug case), probes NODE_OPTIONS --inspect substring (catches --inspect-brk/ports), process.execArgv, then a try/caught dynamic import("node:inspector") + inspector.url() — guarded so workerd resolves to false instead of breaking. At the deadline seam the attached case swaps createCancelableTimeout(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 (captureTimeout public 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.
  • Fixtures/tests: see test plan.

Location rationale: the value lives in a LEAF module (shell-capture-constants.ts) because both the rsc graph and ssr/index.tsx need it and ssr must not pull the capture orchestration graph for one number; shell-capture.ts re-exports it so existing import sites keep working. The debugger check lives at the ONE deadline seam in captureShellHTML so 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→captureShellHTML default.
    • NEW shell-handlers.test.tsx isDebuggerAttached suite (4 cases): --inspect-brk=PORT substring true in dev, NODE_ENV-unset + --inspect true (the non-production gate), production+--inspect false (prod keeps the budget, pinned with an explicit NODE_ENV=production), dev with no flags false.
    • shell-serve-ppr-config.test.ts audited — pins explicit values only (10000/8500/9000), no default literal.
  • Router e2e, dev AND production (--project=dev/production --no-deps, shell-cache + shell-capture-timeout): 36 passed each.
    • The default-budget refusal negative was re-anchored: /shell-cache/slow-meta-default (~6.5s material) would now CAPTURE under 15s, so the fixture declares an explicit captureTimeout: 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 -default suffix (stable warning regex, no .gen.ts churn); 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.
  • cloudflare-basic e2e, dev AND production (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.
  • No .gen.ts changes (no route add/remove/rename).

Notes

  • No suite runs a no-knob refusal anymore — it would need >15s material and ~30s waits per attempt. The default VALUE is pinned at the unit seam; refusal SEMANTICS are pinned by the explicit-budget negatives in both apps (the refusal path is budget-source-agnostic: capture.captureTimeout ?? SHELL_CAPTURE_MAX_WAIT_MS feeds one maxWaitMs).
  • Debugger bypass is non-production by design: bare 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 --inspect in NODE_OPTIONS keeps its safety bound, while dev servers that never set NODE_ENV — the common debug case — are covered. On workerd the node:inspector import rejects and is caught; env/execArgv reads are optional-chained. Not e2e-tested (per scope) — unit-covered.
  • dev-warmup scar tissue updated (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).
  • Pre-existing flake observed while gating (NOT this change; reproduced on the base commit with the identical command): the "bake lane, nested promise settled inside the window" test in both apps intermittently renders *-settled-fast twice (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-timeout dev 36 / production 36; cloudflare-basic ppr-capture-timeout dev 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:

  1. Budget constant extracted to a leaf module. NEW src/rsc/shell-capture-constants.ts (imports nothing) owns SHELL_CAPTURE_MAX_WAIT_MS = 15_000 and the ONE canonical doc block (raise rationale + ceiling math). rsc/shell-capture.ts imports + re-exports under the existing name (shell-build-manifest.ts's envelope import site verified unchanged); ssr/index.tsx's mirrored DEFAULT_SHELL_CAPTURE_MAX_WAIT_MS + copied rationale DELETED in favor of the leaf import — the values can no longer drift. pattern-types.ts captureTimeout JSDoc drops the 6-line envelope paragraph (keeps default 15s + workerd ~30s physical ceiling + design-doc pointer); ppr-shell-resume.md keeps 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.md gains the new file. Precedent: cache/cf/cf-cache-constants.ts (same extract-constants-leaf + re-export pattern).
  2. F1 (real bug): debugger gate missed NODE_ENV-unset dev servers. isDebuggerAttached gate changed from globalThis.process?.env?.NODE_ENV !== "development" to bare process.env.NODE_ENV !== "production" — the build define folds exactly the bare token, it aligns with isDevMode, and it covers dev servers that never set NODE_ENV (the common debug case). Unit tests updated: NODE_ENV-unset + --inspect now pins TRUE; the production case pins NODE_ENV=production explicitly. The rest of the probe chain kept unchanged (see Skipped).
  3. F5 (latent flake): nameless MISS→HIT poll windows were sized for the old 5s default. shell-capture-timeout.test.ts toPass 15s → 40s; ppr-capture-timeout.test.ts toPass 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 added test.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.
  4. Duplicate NODE_OPTIONS-substring unit tests folded. Kept the stronger --inspect-brk=9230 variant (pins the substring match); the bare --inspect twin's slot now carries the new NODE_ENV-unset case, so the suite stays at 4 tests with strictly more coverage.

Skipped (with rationale):

  • Dropping the node:inspector probe from isDebuggerAttached — kept: VS Code auto-attach injects a NODE_OPTIONS bootloader with no --inspect substring, 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.

…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.
@pkg-pr-new

pkg-pr-new Bot commented Jul 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@rangojs/router@717

commit: ca24800

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.

1 participant