Skip to content

fix(cache): verify edge cache eligibility before response#2582

Open
james-elicx wants to merge 15 commits into
mainfrom
codex/security-cdn-late-dynamic
Open

fix(cache): verify edge cache eligibility before response#2582
james-elicx wants to merge 15 commits into
mainfrom
codex/security-cdn-late-dynamic

Conversation

@james-elicx

Copy link
Copy Markdown
Member

Summary\n\n- verify streamed App responses before emitting edge-cacheable headers\n- preserve streaming with a bounded, fail-closed verification path\n- distinguish origin, edge, and hybrid page-cache ownership\n- cover deferred dynamic responses and static edge reuse in production fixtures\n\n## Validation\n\n- production cache fixture: 7 passed\n- focused cache/render suites: 348 passed\n- adversarial stream bound and cancellation checks\n- vinext and Cloudflare adapter builds\n- targeted format, lint, and type checks

@pkg-pr-new

pkg-pr-new Bot commented Jul 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2582
npm i https://pkg.pr.new/create-vinext-app@2582
npm i https://pkg.pr.new/@vinext/types@2582
npm i https://pkg.pr.new/vinext@2582

commit: 96d5107

@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

Comment thread tests/app-cdn-cache-production.test.ts Fixed
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 96d5107 against base 0252ea1 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 130.4 KB 130.4 KB ⚫ -0.0%
Client entry size (gzip) vinext 117.9 KB 117.9 KB ⚫ -0.0%
Dev server cold start vinext 2.74 s 2.77 s ⚫ +1.0%
Production build time vinext 3.07 s 3.05 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 99.0 KB 99.1 KB ⚫ +0.2%
Server bundle size (gzip) vinext 166.1 KB 166.3 KB ⚫ +0.1%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Reviewed the CDN edge-cache verification work. The core streaming verifier (verifyCdnCacheCandidateStream) is solid — bounded time/size, fail-closed handoff, lock release on all cancel/error paths, and the BoundedStreamBuffer coalescing is correct across the boundary cases I traced (partial-fill, shrunken final segment, exact-fill). Good test coverage for the adversarial cases.

I did find one real gap that undermines the fix under a specific deploy mode.

Blocking: pending-dynamic guard is bypassed under VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1

applyCdnResponseHeaders short-circuits before delegating to the adapter:

// packages/vinext/src/server/cache-control.ts:34-41
export function applyCdnResponseHeaders(headers: Headers, input: CdnCacheableHeaderInput): void {
  headers.delete("Cache-Control");
  if (shouldUseNextDeployCacheControl() && isSharedCacheControl(input.cacheControl)) {
    headers.set("Cache-Control", BROWSER_REVALIDATE_CACHE_CONTROL); // "public, max-age=0, must-revalidate"
    return; // <-- adapter.buildResponseHeaders() never runs
  }
  const map = getCdnCacheAdapter().buildResponseHeaders(input);
  ...
}

The check only looks at isSharedCacheControl(input.cacheControl) — it ignores input.pendingDynamicCheck. In edge mode, a production MISS is built with Cache-Control: s-maxage=60, stale-while-revalidate (resolveAppPageRscResponsePolicy/buildAppPageRscResponse), and applyPendingDynamicCdnHeaders forwards that value as input.cacheControl with pendingDynamicCheck: true when verification didn't complete. So when the deploy flag is on, the response ships public, max-age=0, must-revalidate for an unverified (potentially late-dynamic) render instead of the adapter's no-store — exactly the leak this PR exists to prevent.

This is reachable: scripts/e2e-deploy.sh:640 exports VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1.

Reproduction (fails on this branch):

setCdnCacheAdapter(new CloudflareCdnCacheAdapter());
process.env.VINEXT_NEXT_DEPLOY_CACHE_CONTROL = "1";
const headers = new Headers({ "Cache-Control": "s-maxage=60, stale-while-revalidate" });
applyCdnResponseHeaders(headers, {
  cacheControl: "s-maxage=60, stale-while-revalidate",
  pendingDynamicCheck: true,
});
// Actual:   "public, max-age=0, must-revalidate"
// Expected: something containing "no-store" (adapter's pending-dynamic response)

Suggested fix: let pendingDynamicCheck take precedence, e.g. gate the next-deploy branch with !input.pendingDynamicCheck so the adapter's pending-dynamic path is always honored. Please add a regression test covering edge adapter + VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 + pendingDynamicCheck: true.

Non-blocking notes

  • CodeQL alert (tests/app-cdn-cache-production.test.ts:58) — false positive. The setTimeout(..., Number(delay) || 75) is a test-only local IO server whose "user-controlled" duration is supplied by the test itself. Safe to dismiss.
  • RSC vs HTML verification guard asymmetry — the RSC path (app-page-render.ts:820) runs verifyCdnCacheCandidateStream on shouldCompleteDynamicUsageBeforeResponse alone, while the HTML path (:1113) also gates on !dynamicUsedDuringRender. This is defensible since the RSC path consumes dynamicUsedDuringBuild after verification and the stream needs draining anyway, but a short comment noting the intentional difference would help future readers.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I reviewed the CDN edge-cache verification changes. The core verifyCdnCacheCandidateStream verifier is well-built — bounded time/size, fail-closed handoff, correct lock release on cancel/error paths, and I verified the BoundedStreamBuffer coalescing is correct across boundary cases (I wrote a standalone harness testing partial-fill, shrunken final segment, and exact-fill). I also reproduced and confirmed there's no unhandled-rejection leak on the cancel-after-handoff path.

One blocking issue found:

  • applyCdnResponseHeaders (cache-control.ts:34-41) short-circuits to BROWSER_REVALIDATE_CACHE_CONTROL under VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 before delegating to the adapter, and only checks isSharedCacheControl — not pendingDynamicCheck. This bypasses the new no-store guard, shipping public, max-age=0, must-revalidate for an unverified (potentially late-dynamic) edge render — the exact leak the PR targets. It's reachable via scripts/e2e-deploy.sh:640. I included a failing reproduction and suggested gating the branch with !input.pendingDynamicCheck.

Non-blocking:

  • The CodeQL "resource exhaustion" alert on the test IO server is a false positive (test-only local server).
  • Minor RSC/HTML verification-guard asymmetry worth a clarifying comment.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

@james-elicx Bonk workflow failed. Check the logs for details.

View workflow run · To retry, trigger Bonk again.

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Re-reviewed after the latest commits. The blocking issue from the previous review is fixed, and the new work holds up under scrutiny.

Previously-blocking issue: resolved

applyCdnResponseHeaders (cache-control.ts:36-43) now gates the VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 short-circuit on !input.pendingDynamicCheck, so an unverified (potentially late-dynamic) edge render no longer ships public, max-age=0, must-revalidate. It falls through to the adapter's no-store pending-dynamic path. Regression test added at tests/cache-control.test.ts:88 ("keeps pending streamed renders no-store in Next deploy mode"). Confirmed passing.

Verifier internals: verified independently

  • BoundedStreamBuffer coalescing — I built a standalone harness testing total-byte and content-order integrity across partial-fill, truncated final segment (remainingCapacity < segmentBytes), and exact-fill boundaries. All cases reassemble correctly. The finalLength = length % segmentBytes computation is sound because a truncated final segment only occurs at length == maxBytes, where maxBytes % segmentBytes == remainingCapacity, which equals the truncated segment's actual size.
  • Lock release / fail-closed — release on complete drain, deadline timeout, oversize, initial-read error, and resumed-read error are all covered by tests/app-page-cdn-verification.test.ts (7 tests) and confirmed. Cancel-after-handoff has no unhandled-rejection leak (.catch(() => {}) on the resume-path cancel).
  • Event-loop starvation — the periodic nextTask() yield plus the elapsed-time checks keep a synchronous infinite stream bounded; the adversarial test confirms it.

Integration coverage: strong

tests/app-cdn-cache-production.test.ts (7 tests, real buildApp() + Workers-like edge) covers the important paths: deferred-personalized HTML and RSC both fail closed to no-store, deferred-but-static reuses the edge cache, revalidate=false edge-caches, deadline timeout / never-settling / oversized all fail closed. This is the right shape of coverage for the PR's goal.

CodeQL alert: now a hard non-issue

tests/app-cdn-cache-production.test.ts:58 no longer feeds the user-supplied delay into setTimeout — it's clamped to delay === "750" ? 750 : 75 (two hardcoded values). Beyond being a test-only local server, the flagged data flow is gone. Safe to dismiss.

Non-blocking notes

  • RSC vs HTML verification-guard asymmetry — the RSC path (app-page-render.ts:820) gates on shouldCompleteDynamicUsageBeforeResponse alone while the HTML path (:1113) also gates on !dynamicUsedDuringRender. This is correct because the RSC path re-reads consumeDynamicUsage() after the drain (:834), so resolveAppPageRscResponsePolicy already reflects any late-detected dynamic usage. A one-line comment noting the intentional difference would help future readers, but it's not required.
  • hybrid page-cache mode — the CdnPageCacheMode union defines hybrid, but no adapter uses it. The render branching (!== "origin" to verify, === "edge" to skip origin write) already produces the documented hybrid semantics (verify + retain origin write), so this is correctly forward-compatible, just currently exercised only by the type/docs.
  • Default-adapter parity — with the default origin adapter, usesResponseEdgeCache/shouldCompleteDynamicUsageBeforeResponse are both false, so there's no verification overhead or behavior change on the common path. Good.

Verified locally: cache-control, app-page-cdn-verification, app-cdn-cache-production, app-page-render, cloudflare-cdn-cache, cdn-cache, cache-adapters-config all pass; format/lint/type checks clean on the changed source files.

No blocking issues. LGTM.

@ask-bonk

ask-bonk Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I re-reviewed the CDN edge-cache verification changes after the latest commits. The blocking issue from the prior review has been fixed and the rest of the PR holds up.

Blocking issue — resolved: applyCdnResponseHeaders (cache-control.ts:36-43) now gates the VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 short-circuit on !input.pendingDynamicCheck, so unverified late-dynamic edge renders no longer leak public, max-age=0, must-revalidate. Regression test added at tests/cache-control.test.ts:88.

Independently verified:

  • BoundedStreamBuffer coalescing is correct across partial-fill, truncated-final-segment, and exact-fill boundaries (built a standalone harness testing byte count + content order; proved finalLength matches the truncated segment size at length == maxBytes).
  • Lock release / fail-closed on all drain/timeout/oversize/error paths, plus no unhandled-rejection on cancel-after-handoff.
  • Event-loop starvation bounded by the periodic yield + elapsed-time checks.

Non-blocking notes: RSC vs HTML guard asymmetry is correct (RSC re-reads dynamic usage post-drain) but could use a comment; hybrid page-cache mode is forward-looking/unused but the branching already implements its documented semantics; the CodeQL alert is now a hard non-issue since the timer duration is clamped to two hardcoded values.

All targeted test suites pass locally and format/lint/type checks are clean. No blocking issues — LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Independently re-reviewed the CDN edge-cache verification work end-to-end (verifier internals, header policy wiring, dev/prod parity, and the fixture-backed integration tests). No blocking issues — the fix is correct and the earlier blocking issue is confirmed resolved.

Verified independently

  • Prior blocking issue is fixed. applyCdnResponseHeaders (cache-control.ts:38) now gates the VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 short-circuit on !input.pendingDynamicCheck, so an unverified late-dynamic edge render falls through to the adapter's no-store path instead of leaking public, max-age=0, must-revalidate. Regression test at tests/cache-control.test.ts passes.

  • BoundedStreamBuffer coalescing is correct. I ran a standalone reassembly harness (byte-count + content-order) across partial-fill, segment-boundary-aligned fills (length % segmentBytes == 0 with a full trailing segment), truncated-final-segment (maxBytes % segmentBytes != 0), and exact-fill at maxBytes, plus clamped-final-segment allocations. All cases reassemble byte-for-byte. The finalLength = length % segmentBytes computation is sound: a short trailing segment only appears when length == maxBytes, where maxBytes % segmentBytes equals that segment's real size.

  • Oversize handoff loses no bytes. The oversize path replays [...buffer.chunks(), outcome.result.value] — the already-read chunk that didn't fit is carried into the resumed stream, then reading continues from the still-locked reader. No double-read, no dropped chunk.

  • Fail-closed / lock release on every path (complete drain, deadline timeout, oversize, initial-read error, resumed-read error) — covered by tests/app-page-cdn-verification.test.ts (7 tests, all pass).

  • No unhandled rejection on the cancel-/never-pulled-after-handoff path. I wrote a targeted test where the deadline wins, the pending first read later rejects, and the resumed stream is never pulled — no unhandledRejection fires, because the Promise.race's pendingRead.then(...) already attaches a handler that marks the read handled.

  • Event-loop starvation is bounded — the periodic nextTask() yield + elapsed-time checks keep a synchronous infinite empty-chunk stream from starving timers; the adversarial test and the /never production fixture both confirm a <500ms bounded return.

  • Dev/prod parity + default-adapter safety. With the default origin adapter, usesResponseEdgeCache/shouldCompleteDynamicUsageBeforeResponse are both false, so there is zero verification overhead or behavior change on the common path. The HTML deferred-cleanup ordering is correct: verifyCdnCacheCandidateStream draining safeHtmlStream to completion triggers deferUntilStreamConsumed's flush()clearRequestContext() and populates dynamicUsedBeforeContextCleanup before the post-drain re-read at app-page-render.ts:1129.

  • RSC vs HTML guard asymmetry is correct. The RSC path (:820) verifies on shouldCompleteDynamicUsageBeforeResponse alone and re-reads consumeDynamicUsage() after the drain (:834) so resolveAppPageRscResponsePolicy reflects late dynamic usage; the HTML path (:1114) additionally gates on !dynamicUsedDuringRender. Both correctly demote to no-store when dynamic usage is detected.

  • Cloudflare adapter fails closed. buildResponseHeaders with pendingDynamicCheck: true now returns Cache-Control: no-store and nulls the edge/tag headers (previously it emitted edge-cacheable CDN-Cache-Control and relied on browser revalidation). This is the core leak fix; tests/cloudflare-cdn-cache.test.ts covers it.

Non-blocking notes

  • formatCacheTag counts tag.length (UTF-16 code units) against byte limits. This is safe only because tags arriving here are already canonicalized via encodeCacheTag to the ASCII range (\t\x20-\x7e), where code-unit length == byte length. It relies on an upstream invariant that isn't locally obvious — a one-line comment noting "tags are pre-encoded ASCII, so .length == byte length" would prevent a future caller from passing raw multi-byte tags and silently overshooting Cloudflare's 8 KB / 1 KB byte budgets.

  • CodeQL "resource exhaustion" alert (tests/app-cdn-cache-production.test.ts:58) — confirmed non-issue. The IO server clamps to delay === "750" ? 750 : 75 (two hardcoded values), and the /slow fixture hardcodes delay=750, so no user-controlled value reaches setTimeout. Test-only local server regardless. Safe to dismiss.

  • hybrid page-cache mode is defined in the CdnPageCacheMode union and documented but unused by any adapter. The render branching (!== "origin" to verify, === "edge" to skip origin write) already yields the documented hybrid semantics (verify + retain origin write), so it's forward-compatible, just currently exercised only by types/docs.

Verification run

cache-control, app-page-cdn-verification, app-cdn-cache-production (7), app-page-render (55) all pass locally; vp check clean on the five changed source files.

LGTM.

@ask-bonk

ask-bonk Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I independently re-reviewed the CDN edge-cache verification changes and found no blocking issues. The core security fix is correct.

What I verified independently (not just re-reading prior reviews):

  • Prior blocking issue is fixedapplyCdnResponseHeaders now gates the VINEXT_NEXT_DEPLOY_CACHE_CONTROL=1 short-circuit on !input.pendingDynamicCheck (cache-control.ts:38).
  • BoundedStreamBuffer coalescing — ran a standalone reassembly harness across segment-boundary-aligned fills, truncated-final-segment (maxBytes % segmentBytes != 0), clamped allocations, and exact-fill. All reassemble byte-for-byte.
  • Oversize handoff loses no bytes — the already-read non-fitting chunk is carried into the resumed stream.
  • No unhandled rejection on the deadline-wins/pending-read-rejects/never-pulled path — wrote a targeted test confirming it (the Promise.race's .then marks the read handled).
  • Dev/prod parity — default origin adapter has zero verification overhead; the HTML deferred-cleanup ordering correctly populates dynamic usage before the post-drain re-read.
  • Cloudflare adapter fails closed to no-store when pendingDynamicCheck: true.

Non-blocking notes I added:

  • formatCacheTag counts UTF-16 code units against byte limits — safe only due to the upstream encodeCacheTag ASCII invariant; worth a one-line comment.
  • CodeQL alert confirmed as a non-issue (delay clamped to two hardcoded values).
  • hybrid page-cache mode is defined/documented but unused (forward-compatible).

All targeted suites (cache-control, app-page-cdn-verification, app-cdn-cache-production, app-page-render) pass locally and vp check is clean on the changed source files. LGTM.

github run

@james-elicx james-elicx marked this pull request as draft July 14, 2026 22:45
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Reviewed at head 2d866a0. Heads up: the prior /bigbonk reviews on this PR are now stale. They describe a verifyCdnCacheCandidateStream with a BoundedStreamBuffer, a 100ms deadline, a 1 MB byte cap, periodic event-loop yields, and a "fail-closed handoff." The last commit (2d866a0 "classify edge responses without heuristics") deleted all of that. The verifier is now completeCdnCacheCandidateStream in app-page-cdn-verification.ts, which is an unconditional, unbounded, full-buffer drain:

export async function completeCdnCacheCandidateStream(
  stream: ReadableStream<Uint8Array>,
): Promise<ReadableStream<Uint8Array>> {
  const reader = stream.getReader();
  const chunks: Uint8Array[] = [];
  try {
    for (;;) {
      const result = await reader.read();
      if (result.done) break;
      chunks.push(result.value);
    }
  } finally {
    reader.releaseLock();
  }
  return new ReadableStream({ start(c) { for (const chunk of chunks) c.enqueue(chunk); c.close(); } });
}

The design change (verify by fully materializing the render, matching Next.js static generation) is reasonable and the pendingDynamicCheck fix from the earlier review is intact (cache-control.ts:38, adapter no-store at cdn-adapter.runtime.ts:149-158). But dropping the bounds re-introduces two failure modes the earlier commits on this same PR had explicitly guarded against. Please re-validate against the current code rather than the earlier reviews.

Blocking: unbounded, un-timed buffering hangs / OOMs on any non-force-dynamic edge render

shouldCompleteDynamicUsageBeforeResponse (app-page-render.ts:777-783) fires for any production edge/hybrid response that is not force-dynamic, not draft mode, and has revalidate === null || > 0 — i.e. ordinary static and ISR pages. For every such request, completeCdnCacheCandidateStream now buffers the entire HTML (:1124) or RSC (:821) stream into a Uint8Array[] with no time deadline and no size limit before a single byte is sent to the client.

Two concrete consequences:

  1. No timeout → hang. The /never production test only survives because the page is force-dynamic (so the guard is skipped). A page that is not force-dynamic but has a Suspense boundary awaiting a slow/hanging non-dynamic async op (e.g. a fetch to a stuck upstream, exactly like /slow but never settling) will drain forever. handler() never resolves and the request hangs — no equivalent of the deleted CDN_DYNAMIC_VERIFICATION_DEADLINE_MS. There is no test covering a non-force-dynamic never-settling render; /never sidesteps the new code path entirely.
  2. No size cap → memory pressure + TTFB regression. /large (:249) now explicitly asserts a >1 MB body is fully buffered and edge-cached. On a Worker this means the whole body is held in memory per in-flight request, and TTFB is deferred until the last byte renders. The deleted CDN_DYNAMIC_VERIFICATION_MAX_BYTES = 1 MB cap existed for exactly this. Streaming HTML no longer streams for the common static/ISR case under an edge adapter.

If the intent is truly "materialize like Next.js static generation," that materialization must still be bounded (deadline + size ceiling that demotes to no-store/streams through on exceed), the way the d243ed2/59eddc2 commits did. As written, a slow or large — but perfectly legitimate and static — page is a latency cliff and a hang risk on the primary deploy target (Cloudflare Workers, whose adapter sets pageCacheMode = "edge").

Blocking: drain errors are no longer fail-closed

The old verifier caught initial-read / resumed-read errors and handed the stream back closed. completeCdnCacheCandidateStream only has a finally { reader.releaseLock() } — if reader.read() rejects (a render/stream error surfacing during the drain), the rejection propagates straight out of renderAppPageLifecycle at :821 / :1124. There is no try/catch at either call site. A mid-stream render error that previously produced a (safe, non-cacheable) response can now throw out of the lifecycle. Please confirm the dispatch caller turns this into a safe error response and add a test for "edge candidate whose stream errors mid-drain" — the earlier PR had this covered and the coverage was removed with the rewrite.

Non-blocking

  • RSC vs HTML guard asymmetry (real, worth a comment or alignment). RSC gates on !(peekDynamicUsage?.() ?? false) (:820); HTML gates on !dynamicUsedDuringRender && !scriptNonce && !shellErrorRecovered (:1113-1116). These are genuinely different predicates over "is this already known dynamic," not just a re-read timing difference as the prior review claimed. Please either unify them or add a comment explaining why the RSC path may buffer in a case the HTML path skips (and vice-versa).
  • usesResponseEdgeCache is !== "origin", so hybrid buffers too. The unbounded-drain concern above applies to hybrid mode as well, not just edge. Since hybrid is currently unused, this is latent, but any future hybrid adapter inherits the hang/OOM risk.
  • Stale CodeQL note. The prior reviews dismiss the CodeQL alert by citing a delay === "750" ? 750 : 75 clamp; that clamp is what's in app-cdn-cache-production.test.ts:58 now, so the dismissal still holds — but it's worth re-confirming rather than inheriting from a review written against different code.
  • formatCacheTag UTF-16 vs byte length (cdn-adapter.runtime.ts) — safe only under the upstream encodeCacheTag ASCII invariant; a one-line comment would help. (Carried over from prior review; still applies.)

Net: the pendingDynamicCheck leak fix is correct and well-tested, but the 2d866a0 rewrite traded a bounded verifier for an unbounded blocking drain on the hot path of every static/ISR edge response. I'd restore a deadline + size bound (demote-on-exceed) and re-add fail-closed error handling before merge.

@ask-bonk

ask-bonk Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I reviewed PR #2582 at head 2d866a0 and found the prior /bigbonk reviews are stale — they describe a bounded verifier (BoundedStreamBuffer, 100ms deadline, 1 MB cap, fail-closed handoff) that the final commit 2d866a0 ("classify edge responses without heuristics") deleted. The verifier is now an unbounded full-buffer drain (completeCdnCacheCandidateStream).

Two blocking issues:

  1. Unbounded, un-timed bufferingshouldCompleteDynamicUsageBeforeResponse fires for every non-force-dynamic production edge/hybrid response (ordinary static/ISR pages). It buffers the entire HTML/RSC stream with no time deadline or size limit before sending any bytes. A non-force-dynamic page with a slow/hanging Suspense boundary hangs forever (the /never test only survives because it's force-dynamic and skips the path); /large explicitly asserts >1 MB is buffered → memory pressure + TTFB regression on Cloudflare Workers.

  2. Drain errors no longer fail-closed — the rewrite only has finally { releaseLock() }; a reader.read() rejection propagates straight out of renderAppPageLifecycle at both call sites with no try/catch. The old verifier caught this.

Non-blocking: genuine RSC-vs-HTML guard predicate asymmetry (not just timing), hybrid mode inherits the buffering risk, formatCacheTag UTF-16/byte-length invariant comment, and a note to re-confirm (not inherit) the CodeQL dismissal.

The pendingDynamicCheck leak fix itself is correct and well-tested; the concern is that the final rewrite traded a bounded verifier for an unbounded blocking drain on the hot path.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Reviewed at head d801d02 ("admit streaming responses before edge caching"). Important: the prior /bigbonk review (at 2d866a0) is stale — its two blocking issues have been fixed by the newest commit, which abandons the buffering approach entirely. I re-reviewed against current code. No blocking issues.

The two prior blocking issues are resolved by a redesign, not a patch

The last review flagged that 2d866a0 traded a bounded verifier for an unbounded, un-timed full-buffer drain (completeCdnCacheCandidateStream) on the hot path of every static/ISR edge response — a hang risk (no deadline) and OOM/TTFB risk (no size cap), plus lost fail-closed error handling. Commit d801d02 deletes app-page-cdn-verification.ts and its test outright and replaces the whole strategy with deterministic two-stage admission:

  • The Cloudflare adapter moves from pageCacheMode = "edge" to "hybrid" (cdn-adapter.runtime.ts:130).
  • A fresh render streams immediately with no-store — no drain, no buffering, no deadline needed. pendingDynamicCheck is now always true for the MISS response (the dynamicUsageCheckComplete plumbing is no longer set by the render), so buildResponseHeaders fail-closes to no-store by construction (cdn-adapter.runtime.ts:156).
  • In parallel, the normal ISR finalizer tees the stream and writes to the data cache only if the completed render proved static (app-page-cache-finalizer.ts:169).
  • A subsequent request reads that proven-static artifact from the data cache (cache HIT, no pendingDynamicCheck) and emits edge-cacheable headers (app-page-cache.ts:172), promoting it to Cloudflare's edge.

I verified the previously-flagged failure modes are gone:

  • Slow non-force-dynamic render no longer hangs. The /slow-static fixture (revalidate = 60, 750ms Suspense fetch) now streams the first response with no-store and promotes on the 2nd request (app-cdn-cache-production.test.ts:151, asserts originRequests === 2). The /slow test asserts the handler returns in <500ms (:225). This is precisely the "non-force-dynamic never-settling / slow" gap the prior review said had no coverage — it's now covered and passing.
  • Large body no longer OOMs / defers TTFB. /large (>1 MB) streams no-store on first hit rather than buffering (:259). No MAX_BYTES cap is needed because nothing is buffered before responding.
  • No fail-closed regression. There's no drain to throw from anymore; the MISS response is no-store unconditionally and the origin-write tee runs in waitUntil (a background write that simply doesn't persist if the render errors/never settles — no leak, no thrown lifecycle).

The pendingDynamicCheck deploy-mode leak fix from the earlier review is intact (cache-control.ts:38, gated on !input.pendingDynamicCheck).

Independently verified

  • Security invariant holds. late-dynamic HTML and RSC (cookie-personalized behind Suspense) both stay no-store and are never shared across users (:109, :177, assert originRequests === 2, no cdn-cache-control). The default origin adapter path is unchanged (usesResponseEdgeCache/admission gates are all false), so zero behavior change and zero overhead on the common path.
  • ownsBackgroundRevalidation = true is now correct for hybrid — the data-cache admission copy must regenerate when stale, else edge revalidation would keep re-reading the same stale origin artifact. STALE reads serve edge-cacheable headers (proven-static artifact), which is right.
  • revalidate=false (Infinity) admissionneedsOriginAdmissionForEdge (app-page-render.ts:761) correctly forces RSC capture for force-static pages under hybrid so they can still be admitted; the paired HTML admission writes the RSC artifact so the RSC request promotes with only 1 origin render (:201, asserts originRequests === 1).
  • Tests all green locally: app-cdn-cache-production (8), cloudflare-cdn-cache (15), cache-control (14), app-page-render (56), cdn-cache (16), cache-adapters-config (21). vp check clean on all 6 changed source files.

Non-blocking notes

  • Vestigial dynamicUsageCheckComplete plumbing. After the rewrite, the render no longer passes dynamicUsageCheckComplete to the finalizer, so the field on FinalizeAppPageHtmlCacheResponseOptions / ScheduleAppPageRscCacheWriteOptions and the branch in applyPendingDynamicCdnHeaders (app-page-cache-finalizer.ts:88,93) are now always undefinedpendingDynamicCheck: true. Harmless (correct fail-closed default), but it's dead surface area worth deleting in a follow-up so a future reader doesn't think it's still wired to a completion signal.
  • Data-cache dependency for admission. The hybrid set()/get() now delegate to getDataCacheHandler(). With the default in-memory MemoryCacheHandler on Workers (KV is opt-in per AGENTS.md), the admission artifact lives per-isolate, so edge promotion effectively requires two requests to hit the same warm isolate. That's an acceptable, documented tradeoff (not a correctness bug — it just means more no-store misses before promotion on cold isolates), but a one-line note in the adapter doc comment recommending a durable data cache (KV) for reliable edge promotion would help operators.
  • formatCacheTag counts tag.length (UTF-16 code units) against byte budgets (cdn-adapter.runtime.ts:117-119). Safe only because tags arrive pre-canonicalized to ASCII via encodeCacheTag. A one-line comment noting "tags are pre-encoded ASCII, so .length == byte length" would prevent a future caller from passing raw multi-byte tags and silently overshooting Cloudflare's 8 KB / 1 KB budgets. (Carried from prior reviews; still applies.)
  • CodeQL "resource exhaustion" alert (app-cdn-cache-production.test.ts:58) remains a non-issue — setTimeout(..., delay === "750" ? 750 : 75) clamps to two hardcoded values in a test-only local IO server. Re-confirmed against current code. Safe to dismiss.

Net: the d801d02 redesign is a clear improvement over the buffering approaches on all prior revisions — it preserves streaming, has no hang/OOM/TTFB cliff, fails closed to no-store by construction, and never exposes request-specific output to a shared cache. LGTM.

@ask-bonk

ask-bonk Bot commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I reviewed PR #2582 at the current head d801d02 and found no blocking issues.

Key finding: the prior /bigbonk review is stale. The last review analyzed commit 2d866a0 and flagged two blocking issues (unbounded/un-timed buffering drain that could hang or OOM, plus lost fail-closed error handling). The newest commit d801d02 abandons the buffering approach entirely — it deletes app-page-cdn-verification.ts and replaces the strategy with deterministic two-stage admission:

  • Cloudflare adapter moves from edgehybrid mode.
  • Fresh renders stream immediately with no-store (no drain, no deadline, no size cap needed).
  • The ISR finalizer tees the stream in the background and persists to the data cache only if the render proved static.
  • A subsequent request reads that proven-static artifact and emits edge-cacheable headers, promoting to the edge.

What I verified independently:

  • The two prior blocking failure modes are gone and now have passing test coverage (/slow-static non-force-dynamic slow render, /slow <500ms return, /large >1 MB streamed).
  • Security invariant holds: cookie-personalized late-dynamic HTML/RSC stay no-store, never shared across users.
  • Fail-closed to no-store by construction (pendingDynamicCheck always true on MISS).
  • Default origin adapter path unchanged (zero overhead/behavior change).
  • All targeted suites pass and vp check is clean on the 6 changed source files.

Non-blocking notes: vestigial dynamicUsageCheckComplete plumbing to remove, a data-cache-dependency doc note for reliable edge promotion, the formatCacheTag UTF-16/byte-length comment, and re-confirmed CodeQL dismissal.

The redesign is a clear improvement — LGTM.

github run

@james-elicx james-elicx marked this pull request as ready for review July 14, 2026 23:39
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

error: Your local changes to the following files would be overwritten by checkout:
pnpm-lock.yaml
Please commit your changes or stash them before you switch branches.
Aborting

github run

…te-dynamic

# Conflicts:
#	packages/cloudflare/src/cache/kv-data-adapter.runtime.ts
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Re-reviewed at head 35cc5a0. The prior LGTM was against d801d02; since then the branch merged main and added one new commit — e88a004 fix(cache): preserve edge admission metadata. I reviewed that commit end-to-end. No blocking issues.

What e88a004 changes

It fills three gaps in the two-stage hybrid admission introduced earlier in the PR:

  1. Tag propagation into edge headers. Cache entries now carry their canonical tags back through get()/STALE reads (cache-handler.ts, kv-data-adapter.runtime.ts) and into buildAppPageCachedResponse / buildRouteHandlerCachedResponse / buildPagesCacheResponse, so a promoted (HIT/STALE) artifact emits Cache-Tag and can be purged by revalidateTag. Previously an admitted edge artifact had no tags, so tag purges couldn't reach it.
  2. STALE never edge-caches. CloudflareCdnCacheAdapter.buildResponseHeaders now emits CDN-Cache-Control: public, max-age=0, must-revalidate when cacheState === "STALE" (cdn-adapter.runtime.ts:189-193). Correct: the STALE branch (app-page-cache.ts:465-489) fires a background regen and serves the stale copy with must-revalidate, so the edge won't store stale bytes and the next request promotes the fresh HIT.
  3. Infinity survives KV JSON round-trip. revalidate: Infinity (force-static / revalidate = false) is serialized as the sentinel "infinity" and restored to Infinity (kv-data-adapter.runtime.ts). Before this, JSON.stringify(Infinity) produced null, the validator rejected the entry as malformed, and force-static pages could never be admitted to the edge via KV. This is what makes the revalidate-false fixture's edge promotion actually work on the primary deploy target.

Verified independently

  • Default origin adapter is untouched. DefaultCdnCacheAdapter.buildResponseHeaders ignores cacheState/tags (cdn-cache.ts:164-172), so the new plumbing is a pure no-op on every non-Cloudflare path. Full dev/prod parity, zero overhead on the common path.
  • Malformed tags never reach the header builder. Persisted tags are run through validUniqueTags before propagation; the new test (kv-cache-handler.test.ts, "does not expose malformed persisted tags…") confirms [123, "", "valid", "valid", "bad\ntag", "bad:tag"] collapses to ["valid"], so a poisoned KV entry can't emit a broken/oversized Cache-Tag. formatCacheTag still bounds the total to Cloudflare's budget.
  • Infinity round-trip. handler.set(..., { cacheControl: { revalidate: Infinity, expire: Infinity } })handler.get() returns { revalidate: Infinity, expire: Infinity } and does not compute a bogus finite revalidateAt/expireAt (the Number.isFinite guards at :388/:393 keep both null). Verified by the new "round-trips infinite cache-control metadata" test.
  • STALE header shape. cloudflare-cdn-cache.test.ts asserts a STALE input yields exactly Cache-Control + CDN-Cache-Control = public, max-age=0, must-revalidate plus the Cache-Tag, with no leftover Cloudflare-CDN-Cache-Control. app-page-cache.test.ts covers HIT (still SWR-cacheable) vs STALE (must-revalidate) with tags preserved on both.
  • Security invariant intact. The pendingDynamicCheck fail-closed-to-no-store path (cdn-adapter.runtime.ts:159-166, cache-control.ts:38) is unchanged; the new cacheState/tags fields are only read on the HIT/STALE cache-read path, which by construction is already a proven-static artifact.

Verification run

vp check clean on all 7 changed source files. Locally green: kv-cache-handler (71), app-page-cache, cloudflare-cdn-cache, pages-page-data, app-route-handler-response (208 combined), plus app-cdn-cache-production (8), cache-control (14), cache-adapters-config (21), cdn-cache (16).

Non-blocking notes (carried forward, still apply)

  • formatCacheTag counts tag.length (UTF-16 code units) against byte budgets (cdn-adapter.runtime.ts:116-128). Safe only because tags arrive pre-canonicalized to ASCII via encodeCacheTag and are now additionally filtered by validUniqueTags. A one-line comment noting "tags are pre-encoded ASCII → .length == byte length" would still help a future caller.
  • STALE_EDGE_REVALIDATE and BROWSER_REVALIDATE are identical string constants (cdn-adapter.runtime.ts:71,74). Intentional (distinct rationale in the doc comments), but a future edit could drift them apart silently — worth a note that they're deliberately equal.
  • CI note: the last two /bigbonk runs failed on a dirty pnpm-lock.yaml (vp install mutated the lockfile, blocking the branch checkout). Not a PR defect, but worth keeping the lockfile clean so the review job doesn't abort.

Net: e88a004 is a focused, well-tested completion of the hybrid-admission design — tag purges reach promoted artifacts, stale copies never get edge-cached, and force-static pages round-trip through KV. The core security fix from the earlier reviews is intact. LGTM.

@ask-bonk

ask-bonk Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

The prior LGTM was against d801d02. Since then the branch merged main and added one new commit — e88a004 fix(cache): preserve edge admission metadata — which is what I reviewed. No blocking issues.

e88a004 completes three gaps in the hybrid two-stage edge admission:

  1. Tag propagation — cache entries now carry canonical tags back through get()/STALE reads into the response builders, so a promoted HIT/STALE artifact emits Cache-Tag and is reachable by revalidateTag purges.
  2. STALE never edge-caches — the Cloudflare adapter emits CDN-Cache-Control: max-age=0, must-revalidate for STALE, so the edge won't store stale bytes while the background regen promotes the fresh HIT.
  3. Infinity KV round-triprevalidate: Infinity (force-static) now serializes via an "infinity" sentinel instead of being silently dropped by JSON.stringify and rejected as malformed. This is what makes force-static edge promotion actually work via KV.

Independently verified: default origin adapter is untouched (full parity, zero overhead on the common path); malformed persisted tags are filtered to valid ASCII before hitting Cache-Tag; the Infinity round-trip doesn't compute bogus finite expiry; STALE header shape has no leftover cacheable directives; the pendingDynamicCheck fail-closed security invariant is intact.

vp check clean on all 7 changed source files; all targeted suites pass locally.

Non-blocking: formatCacheTag UTF-16-vs-byte comment (carried forward), STALE_EDGE_REVALIDATE/BROWSER_REVALIDATE are deliberately-equal constants worth a note, and a heads-up that the last two /bigbonk runs aborted on a dirty pnpm-lock.yaml (CI hygiene, not a PR defect).

LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/bigbonk review

Reviewed at head 308b5d5. The prior LGTM was against e88a004; since then two new commits landed — cbb46b7 fix(cache): preserve late RSC tags during admission and 308b5d5 fix(cache): accept colon-delimited KV tags. I reviewed both end-to-end (the second largely reverts the first). No blocking issues.

What changed since the last review

cbb46b7 introduced createCollectedFetchTagsReader() — an ALS-scope-bound reader intended to let waitUntil() cache finalizers read the request's collected fetch tags after the AsyncLocalStorage scope exited. 308b5d5 then reverts that back to getCollectedFetchTags() and instead:

  1. Allows : in cache tags (kv-data-adapter.runtime.ts:117-123) — the validator now rejects only control chars + \, not :. Matches Next.js, which accepts printable colon-delimited tags (e.g. post:hello) and uses them in its own App Router fixtures.
  2. Adds a hashed-key disjointness guard (kv-key.ts:44-54) — any logical key that literally starts with __hash: is now always hashed, so a user tag like __hash:<digest> can't alias the storage key an oversized logical key produces for that same digest.

Verified independently

  • The getCollectedFetchTags() revert is safe. The deferred getPageTags() reads inside the finalizer's cachePromise IIFE (app-page-cache-finalizer.ts:191,308) run after await readStreamAsText(...) / await capturedRscDataPromise, but that IIFE is created synchronously inside finalizeAppPageHtmlCacheResponse/scheduleAppPageRscCacheWrite, which run within the request's runWithFetchDedupe/runWithRequestContext scope (app-page-render.ts:732, :1165). AsyncLocalStorage propagates through await points in a promise chain created inside the scope, so _getState() still resolves to the correct request's currentRequestTags. The createCollectedFetchTagsReader indirection was therefore unnecessary; removing it doesn't reintroduce a leak. Confirmed by the slow-static production test, which uses a lazily-consumed RSC fetch tag and asserts it survives to the edge Cache-Tag header.

  • Colon tags round-trip and invalidate. Tag keys are built as {prefix}__tag:{tag} and never parsed back by :; the only tag→key parse (revalidateByPathPrefix, :502) strips _N_T_ and gates on a leading /, so post:hello is correctly excluded from path-prefix invalidation and can't collide with an entry key (distinct cache: vs __tag: category prefix). kv-cache-handler.test.ts covers colon round-trip + revalidateTag("post:hello") invalidation, and that malformed tags (bad\ntag, bad\tag) are still filtered.

  • The __hash: disjointness guard closes a real (if adversarial) collision. An oversized key hashing to __hash:H, plus a literal user key __hash:H short enough to store verbatim, would previously map to the same KV key. The guard re-hashes the literal, and kv-cache-handler.test.ts asserts a literal __hash:<digest> tag key differs from the oversized key's hashed form.

  • Deploy-time parity. prerender-kv-populate.ts builds keys through the same createKvKeySpace().entryKey, so deploy-time seeded keys stay consistent with the runtime guard. tpr.ts in the diff range came from a main merge (#2606), not this PR.

  • Clean removal — no remaining references to createCollectedFetchTagsReader; the corresponding fetch-cache test was removed with it.

Verification run

vp check clean on the 4 changed source files. Locally green: kv-cache-handler (73), app-cdn-cache-production (8, incl. the colon-tag slow-static assertion), fetch-cache (126), cloudflare-cdn-cache (16), cache-control (14), cdn-cache (16), prerender-kv-populate (7), tpr-kv-keys (12), app-page-render (56), app-page-cache (37).

Non-blocking notes (carried forward, still apply)

  • formatCacheTag counts UTF-16 code units against Cloudflare's byte budgets. Still safe only because tags arrive pre-canonicalized to ASCII via encodeCacheTag and filtered by validUniqueTags. Now that : is permitted content, a one-line comment noting "tags are pre-encoded ASCII → .length == byte length" is a touch more valuable, since a future caller passing raw multi-byte tags is the failure mode.
  • The cbb46b7 doc comment on createCollectedFetchTagsReader correctly identified a real hazard pattern (finalizers reading tags post-drain); the revert relies on ALS propagation through the awaited promise chain holding. That invariant is load-bearing but not locally documented at the finalizer call sites — a short comment there would prevent a future refactor from detaching the cachePromise into a fresh async context and silently dropping tags.

Net: the two new commits are a focused correctness improvement — colon-delimited tags now match Next.js and survive KV round-trip, an adversarial hashed-key collision is closed, and the ALS-reader indirection was correctly removed as unnecessary. The core pendingDynamicCheck fail-closed security fix from the earlier reviews is untouched. LGTM.

Co-authored-by: james-elicx <james-elicx@users.noreply.github.com>
@ask-bonk

ask-bonk Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Review posted to PR #2582.

Summary

I reviewed PR #2582 at head 308b5d5. The prior LGTM was against e88a004; two new commits landed since — cbb46b7 and 308b5d5 (the second largely reverts the first). No blocking issues.

What the new commits do:

  • cbb46b7 added an ALS-scope-bound tag reader (createCollectedFetchTagsReader) for waitUntil finalizers, then 308b5d5 reverts it back to getCollectedFetchTags().
  • 308b5d5 also (1) allows : in cache tags to match Next.js, and (2) adds a kv-key.ts disjointness guard so a literal __hash:<digest> user key can't alias an oversized key's hashed storage key.

What I verified independently:

  • The revert is safe — the deferred getPageTags() reads run inside a cachePromise IIFE created synchronously within the request's runWithFetchDedupe/runWithRequestContext ALS scope, so AsyncLocalStorage propagates through the awaited promise chain and _getState() still resolves the correct request's tags. Proven by the slow-static production test (lazily-consumed RSC fetch tag surviving to the edge Cache-Tag).
  • Colon tags round-trip and invalidate correctly; tag keys are never parsed back by :, and revalidateByPathPrefix correctly excludes them.
  • The __hash: guard closes a real adversarial KV-key collision, with test coverage.
  • Deploy-time key parity via createKvKeySpace is intact; the tpr.ts diff was a main merge, not PR scope.
  • Clean removal of createCollectedFetchTagsReader (no dangling references).

vp check clean on the 4 changed source files; all targeted suites pass locally. Non-blocking notes: the formatCacheTag UTF-16/byte comment (still applies, slightly more relevant now that : is allowed) and a suggestion to document the load-bearing ALS-propagation invariant at the finalizer call sites so a future refactor doesn't silently drop tags.

The core pendingDynamicCheck fail-closed security fix from earlier reviews is untouched. LGTM.

github run

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk

ask-bonk Bot commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

error: Your local changes to the following files would be overwritten by checkout:
pnpm-lock.yaml
Please commit your changes or stash them before you switch branches.
Aborting

github run

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