nuxt-concurrent-ssr-leak-repro

Reproduction + instrumentation harness for the cross-request tryUseNuxtApp leak under concurrent SSR/prerender (nuxt/nuxt#35011)

0
0
0
JavaScript
public

Nuxt concurrent SSR — tryUseNuxtApp context leak repro

Minimal reproduction harness for the cross-request nuxtApp leak in tryUseNuxtApp under concurrent SSR / prerender. Companion to https://github.com/nuxt/nuxt/pull/35011.

TL;DR — the numbers

ASYNC_CONTEXT=on   leaks: 1000 / 13600 calls   (~7.4% of all tryUseNuxtApp calls returned the wrong nuxtApp)
ASYNC_CONTEXT=off  leaks: 1680 / 13600 calls   (~12.4%)

Both deterministically reproduce the cross-request leak. With experimental.asyncContext: true, Nuxt’s withAsyncContext build-plugin reduces the leak rate but does not eliminate it — the underlying ordering bug in tryUseNuxtApp still fires whenever Vue’s currentInstance was leaked from a foreign route’s <App> setup.

The bug, in one breath

tryUseNuxtApp consults Vue’s module-global currentInstance before the Nuxt ALS context. Vue’s compiler transform for top-level await in <script setup> (withAsyncContext) restores currentInstance on resume but never re-unsets it, so it leaks to other concurrently-rendering requests. Nuxt then returns the foreign request’s nuxtApp.

// packages/nuxt/src/app/nuxt.ts (current)
export function tryUseNuxtApp (id?: string): NuxtApp | null {
  let nuxtAppInstance
  if (hasInjectionContext()) {
    nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt   // ← reads leaked module-global first
  }
  nuxtAppInstance ||= getNuxtAppCtx(id).tryUse()                   // ← ALS fallback never reached when above hits
  return nuxtAppInstance || null
}

What the repro does

The minimum ingredients for a deterministic concurrent-SSR leak are surprisingly specific:

  • app.vue has top-level await in <script setup> → triggers Vue’s withAsyncContext, leaving currentInstance set after __restore().
  • A short post-restore busy-loop (~8ms) in app.vue widens the window during which other concurrent renders’ resumed microtasks can read the leaked global.
  • pages/r/[id].vue has its own await new Promise(resolve => setTimeout(resolve, 100 + Math.random() * 800)) — variable per-route delay so renders stagger.
  • <NuxtIsland name="Slow"> × 2 per page — the island’s own server render is another long-running async setup that holds currentInstance for hundreds of ms (this is what makes the race fire reliably; without islands the timing windows close too quickly).
  • A Nuxt plugin (plugins/route-marker.ts) that does await Promise.resolve() × 50 — many short yields, each a chance to land in another route’s leak window.
  • 200 routes prerendered with nitro.prerender.concurrency: 32.

This roughly mirrors what @nuxt/ui docs hits in production: heavy content/component rendering, OG image islands, layouts with async data, and a plugin chain that touches useNuxtApp() after awaits. The repro is intentionally synthetic to keep it small, but the failure mode is identical.

Layout

File Role
app.vue Top-level await useFetch + post-restore busy-loop (the leak vector)
pages/r/[id].vue 200 routes, each with async setup + 2 <NuxtIsland>
components/islands/Slow.server.vue Slow async island (50–250ms) — multiplies in-flight renders
plugins/route-marker.ts Async plugin that yields many times then re-resolves useNuxtApp()
server/api/nav.ts Backing endpoint for app.vue’s useFetch
patches/nuxt@4.4.4.patch Instruments tryUseNuxtApp with leak counters and persists stats to /tmp/nuxt-ctx-stats.json
nuxt.config.ts SSR build + prerender; ASYNC_CONTEXT env toggles experimental.asyncContext

Run

pnpm install

# experimental.asyncContext: true (Nuxt's withAsyncContext shim active)
ASYNC_CONTEXT=on pnpm repro

# experimental.asyncContext: false (Vue's stock withAsyncContext)
ASYNC_CONTEXT=off pnpm repro

cat /tmp/nuxt-ctx-stats.json

This is nuxt build (full SSR build + prerender), not nuxt generate — matching @nuxt/ui docs’ actual deployment shape. The leak is at prerender time, identical to what would happen in runtime SSR under request concurrency.

What the stats mean

{
  "total":     13600,  // every server-side tryUseNuxtApp call
  "ciHit":      5798,  // calls where Vue's currentInstance had a $nuxt
  "alsHit":    10203,  // calls where Nuxt's ALS had a $nuxt
  "ciOnly":     3397,  // ci-set, ALS empty (Vue render path with no plugin scope active)
  "alsOnly":    7802,  // ALS-set, ci empty (plugin chain or mid-await body)
  "leaks":      1000   // BOTH set, BUT they referred to DIFFERENT nuxtApp instances ← the bug
}

The 1000 leaks are calls where ALS correctly knew which request was active, but tryUseNuxtApp returned a foreign route’s $nuxt from currentInstance instead. Each leak prints a line like:

[NUXT-CTX-LEAK] returned <App>'s nuxtApp (url=/r/47) but ALS had url=/r/12

Confirming the handoff finding from the @nuxt/ui docs build: the leaked instance is almost always <App>, leaked through Vue’s withAsyncContext after the top-level await in app.vue.

Apply the upstream fix

Patch node_modules/.pnpm/nuxt@4.4.4*/node_modules/nuxt/dist/app/nuxt.js:

 export function tryUseNuxtApp(id) {
-  let nuxtAppInstance;
+  let nuxtAppInstance = getNuxtAppCtx(id).tryUse();
-  if (hasInjectionContext()) {
+  if (!nuxtAppInstance && hasInjectionContext()) {
     nuxtAppInstance = getCurrentInstance()?.appContext.app.$nuxt;
   }
-  nuxtAppInstance ||= getNuxtAppCtx(id).tryUse();
   return nuxtAppInstance || null;
 }

After this reorder, the ALS-bound nuxtApp is authoritative and the cross-request mismatch is structurally impossible. leaks should drop to 0 (with asyncContext: true).

Caveat — experimental.asyncContext: false

Even with the reorder, experimental.asyncContext: false is still vulnerable to a different leak vector (unctx’s sync module-global being clobbered across awaits by other concurrent runWithContext calls). The tryUseNuxtApp reorder fixes the Vue-currentInstance path; full request isolation requires ALS to be active server-side. Strong follow-up: default experimental.asyncContext: true for server builds.

v0.3.3[beta]