Skip to content

fix(cache): SWR background revalidation under a derived context — no shared handle-store swap, no shared-arg stamping (#684 plan 010) - #689

Merged
ivogt merged 2 commits into
mainfrom
fix/swr-bg-derived-context
Jul 5, 2026
Merged

ivogt merged 2 commits into
mainfrom
fix/swr-bg-derived-context

Conversation

@ivogt

@ivogt ivogt commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

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 via waitUntil. Production waitUntil is Promise.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 shared requestCtx._handleStore in place (restored only in a finally, i.e. after the entire revalidation, including its origin I/O) and stamped INSIDE_CACHE_EXEC onto 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 plain ctx.set() / ctx.headers.set() in a downstream handler threw INSIDE_CACHE_EXEC — an error blaming code that isn't inside any cache function. The existing tests pinned the hazardous behavior behind queue-style waitUntil mocks that run the background only after the foreground finishes — the one ordering production never has.

Before vs. after

// Layout: stale "use cache" hit serves immediately, revalidation starts in background
const product = await getCachedProduct(ctx); // stale hit -> bg revalidation scheduled

// ...meanwhile, a sibling/downstream handler still rendering this SAME request:
ctx.use(Breadcrumbs)({ label: product.name }); // BEFORE: lost from the document
                                               //         AND baked into the revalidated entry
ctx.set(someVar, value);                       // BEFORE: throws INSIDE_CACHE_EXEC
                                               // AFTER: both just work

What changed

src/cache/cache-runtime.ts, stale-hit branch only:

  • The background body now runs under a derived context — Object.create(requestCtx) with an own _handleStore (the established shell-capture isolation pattern, shell-capture.ts attemptCapture) — passed to the existing runWithRequestContext. 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.
  • The background no longer stamps INSIDE_CACHE_EXEC onto shared args (or requestCtx). 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

During the background revalidation window Before After
Foreground handle push (breadcrumbs, metadata) routed to background store: lost from document, persisted into entry reaches the live store; document correct; entry uncontaminated
Foreground ctx.set() / ctx.headers.*() throws INSIDE_CACHE_EXEC works
Background body's own handle pushes captured + persisted with entry unchanged (isolated store)
Background body reading ambient getRequestContext() shared ctx derived ctx (inherits everything; own _handleStore)
In-fn ctx.set() inside the cached body throws on miss path; stamped on bg throws on miss path (first execution); bg relies on that — documented invariant
Fresh hit / miss paths — untouched

Test plan

  • Red-before-green (cache-runtime-stale.test.ts): 2 failed | 18 passed on the old code → 20 passed with the fix.
    • Rewritten pin: "does NOT stamp INSIDE_CACHE_EXEC on shared tainted args during stale background revalidation" (previously asserted the stamp).
    • New: "background revalidation never mutates the shared context mid-render (production-faithful microtask scheduling)" — uses 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.
    • The mocked runWithRequestContext now threads its ctx (the old passthrough would hide the isolation under test).
  • /simplify ran before push (4 agents): applied — collapsed the bgCtx/bgHandleStore double-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 — importing INSIDE_CACHE_EXEC from taint.js in the new test (the Symbol.for re-derivation is the file's existing idiom at four other sites). Efficiency/reuse angles came back clean.
  • pnpm run typecheck 16/16; pnpm run test:unit:all all packages green (router 4983 + 6 todo); pnpm run lint 0/0; pnpm run format clean.
  • No new e2e: the fix removes a mid-render race whose window is one microtask wide — a browser e2e cannot pin it deterministically; the production-faithful-scheduling unit test is the deterministic equivalent. Existing SWR value-semantics e2e (use-cache suites) are unaffected and cover the consumer-visible caching behavior in dev + production.

Notes

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

pkg-pr-new Bot commented Jul 5, 2026 •

Copy link
Copy Markdown

Open in StackBlitz

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

commit: 174150e

@ivogt
ivogt marked this pull request as ready for review July 5, 2026 14:28
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