Repository navigation
fix(cache): SWR background revalidation under a derived context — no shared handle-store swap, no shared-arg stamping (#684 plan 010) - #689
Merged
Conversation
…— no shared handle-store swap, no shared-arg stamping (#684 plan 010) Production waitUntil is Promise.resolve().then(fn), so the stale-hit background revalidation's prologue runs one microtask after scheduling — INSIDE the foreground's render window. The prologue swapped the SHARED requestCtx._handleStore (restore only in finally, i.e. after the whole revalidation's I/O) and stamped INSIDE_CACHE_EXEC onto shared tainted args. Consequences, all reproduced with production-faithful scheduling: (a) foreground handle pushes during the window landed in the background's isolated store — lost from the live document; (b) those misrouted pushes were PERSISTED into the revalidated cache entry (cross-contamination); (c) a concurrent foreground ctx.set()/ctx.headers.*() threw INSIDE_CACHE_EXEC. The existing suite pinned the hazardous behavior behind queue-style waitUntil mocks that run the background after the foreground finished — exactly the ordering production does not have. Fix: the background body runs under a DERIVED context (Object.create(requestCtx) with an own _handleStore — the shell-capture isolation pattern). Background pushes land in the isolated store and are captured/persisted as before; the foreground's store is never touched; no restore step exists because nothing shared is mutated. The background no longer stamps shared args: in-fn misuse is caught by the miss path's stamps on the function's FIRST execution (an entry only becomes stale-revalidatable because a stamped miss ran clean) — that invariant is now recorded at the miss-path stamp itself. Tests: the two pinning tests re-pointed to the safe contract; a new production-faithful race test gates the background open mid-window and asserts no swap, no stamp, foreground push reaches the live store, and the revalidated entry carries only background pushes. The mocked runWithRequestContext now threads its ctx (a passthrough would hide the isolation under test). Red-before-green: 2 failed on the old code, 20/20 with the fix.
11 tasks
commit: |
ivogt
marked this pull request as ready for review
July 5, 2026 14:28
5 tasks done
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.
Closes out the plan-010 investigate item of #684 (full trace + original repro in the issue comment).
Problem statement
On a stale
"use cache"hit, the value is served immediately and the function re-runs in the background viawaitUntil. ProductionwaitUntilisPromise.resolve().then(fn)(request-context.ts), so the background prologue runs one microtask after scheduling — while the foreground is still rendering the rest of the page. That prologue swapped the sharedrequestCtx._handleStorein place (restored only in afinally, i.e. after the entire revalidation, including its origin I/O) and stampedINSIDE_CACHE_EXEConto shared tainted args. For a consumer, during that window: breadcrumbs/metadata pushed by any handler rendering after the stale hit silently vanished from the live document AND were persisted into the revalidated cache entry (served to later visitors); and a plainctx.set()/ctx.headers.set()in a downstream handler threwINSIDE_CACHE_EXEC— an error blaming code that isn't inside any cache function. The existing tests pinned the hazardous behavior behind queue-stylewaitUntilmocks that run the background only after the foreground finishes — the one ordering production never has.Before vs. after
What changed
src/cache/cache-runtime.ts, stale-hit branch only:Object.create(requestCtx)with an own_handleStore(the established shell-capture isolation pattern,shell-capture.tsattemptCapture) — passed to the existingrunWithRequestContext. Background pushes land in the isolated store and are captured/persisted with the entry exactly as before; the foreground's store is never touched; the restore step is gone because nothing shared is mutated.INSIDE_CACHE_EXEConto shared args (orrequestCtx). In-fn misuse is caught by the foreground miss path's stamps on the function's first execution — an entry only becomes stale-revalidatable because a stamped miss ran clean and stored it. That load-bearing invariant is now documented at the miss-path stamp itself (per the /simplify altitude review), so a future refactor of the stamp sees the coupling.Semantics table
ctx.set()/ctx.headers.*()INSIDE_CACHE_EXECgetRequestContext()_handleStore)ctx.set()inside the cached bodyTest plan
cache-runtime-stale.test.ts):2 failed | 18 passedon the old code →20 passedwith the fix.Promise.resolve().then(fn)scheduling and gates the background open to inspect the overlap window: shared store not swapped, no stamp, foreground push reaches the live store, revalidated entry carries only the background's push.runWithRequestContextnow threads its ctx (the old passthrough would hide the isolation under test)./simplifyran before push (4 agents): applied — collapsed thebgCtx/bgHandleStoredouble-ternary to one condition; removed the stale header comment ("completed store" / "same pattern as background-revalidation.ts" — both no longer true); added the invariant note at the miss-path stamp. Skipped — importingINSIDE_CACHE_EXECfromtaint.jsin the new test (theSymbol.forre-derivation is the file's existing idiom at four other sites). Efficiency/reuse angles came back clean.pnpm run typecheck16/16;pnpm run test:unit:allall packages green (router 4983 + 6 todo);pnpm run lint0/0;pnpm run formatclean.Notes
_handleStoreswaps (match-middleware/cache-store.ts,background-revalidation.ts) are safe by ordering — they swap only after the response is returned / the foreground drained. Verified during the /simplify altitude pass; no mid-render swap remains insrc/cache/.