Repository: vercel/next.js
Pull request: [test] Add regression test for segment cache prefetch memory leak

Follow-up to #89610.

Adds an e2e test that detects linear heap growth caused by unclosed prefetch response streams. The test cycles through mounting/unmounting links to trigger repeated prefetches and LRU evictions, then uses CDP heap profiling with linear regression to verify the heap plateaus rather than growing steadily.

![heap-growth-chart](https://github.com/user-attachments/assets/6151c0e4-7ae1-4526-b094-8d76154be664)


Changed files:
filename: test/e2e/app-dir/segment-cache/memory-pressure/app/memory-pressure/page.tsx
patch: @@ -4,7 +4,7 @@ import { useState } from 'react'
 import { useRouter } from 'next/navigation'
 
 function Tab0() {
-  return 'Intentionally empty'
+  return <p id="tab-0-content">Intentionally empty</p>
 }
 
 function Tab1() {
@@ -111,7 +111,7 @@ export default function MemoryPressure() {
           Tab 2
         </label>
       </div>
-      <div>{tab}</div>
+      <div id="tab-content">{tab}</div>
     </form>
   )
 }

filename: test/e2e/app-dir/segment-cache/memory-pressure/segment-cache-memory-pressure.test.ts
patch: @@ -1,12 +1,13 @@
 import { nextTestSetup } from 'e2e-utils'
 import { createRouterAct } from 'router-act'
+import type { CDPSession, Page, Request as PlaywrightRequest } from 'playwright'
 
 describe('segment cache memory pressure', () => {
   const { next, isNextDev } = nextTestSetup({
     files: __dirname,
   })
   if (isNextDev) {
-    test('disabled in development / deployment', () => {})
+    test('disabled in development', () => {})
     return
   }
 
@@ -53,4 +54,127 @@ describe('segment cache memory pressure', () => {
       }
     )
   })
+
+  it('does not leak memory when repeatedly triggering prefetches', async () => {
+    let cdpSession: CDPSession
+    let page: Page
+    const browser = await next.browser('/memory-pressure', {
+      async beforePageLoad(p: Page) {
+        page = p
+        cdpSession = await page.context().newCDPSession(page)
+        await cdpSession.send('HeapProfiler.enable')
+      },
+    })
+
+    const tab0Radio = await browser.elementByCss(
+      'input[type="radio"][value="0"]'
+    )
+    const tab2Radio = await browser.elementByCss(
+      'input[type="radio"][value="2"]'
+    )
+
+    const totalCycles = 10
+    const measurements: number[] = []
+
+    for (let i = 0; i < totalCycles; i++) {
+      // Switch to Tab 2 (links mount, prefetches are triggered)
+      const prefetchesSettled = waitForPrefetchesToSettle(page)
+      await tab2Radio.click()
+      await browser.waitForElementByCss('#tab-content a')
+      await prefetchesSettled
+
+      // Switch back to Tab 0 (links unmount)
+      await tab0Radio.click()
+      await browser.waitForElementByCss('#tab-0-content')
+
+      measurements.push(await getHeapMB(cdpSession))
+    }
+
+    // Use linear regression on measurements (skipping the first as warmup)
+    // to compute the slope (MB/cycle). If evicted prefetch entries are not
+    // properly garbage-collected, the heap will grow steadily across cycles
+    // instead of plateauing.
+    const afterWarmup = measurements.slice(1)
+    const n = afterWarmup.length
+    let sumX = 0
+    let sumY = 0
+    let sumXY = 0
+    let sumXX = 0
+    for (let j = 0; j < n; j++) {
+      sumX += j
+      sumY += afterWarmup[j]
+      sumXY += j * afterWarmup[j]
+      sumXX += j * j
+    }
+    const growthPerCycle = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX)
+
+    try {
+      expect(growthPerCycle).toBeLessThan(0.1)
+    } catch (e) {
+      throw new Error(
+        `Heap grew ${growthPerCycle.toFixed(3)} MB/cycle.\n` +
+          `Measurements (MB): ${measurements.map((m) => m.toFixed(2)).join(', ')}`
+      )
+    }
+  }, 120_000)
 })
+
+async function getHeapMB(cdpSession: CDPSession): Promise<number> {
+  await cdpSession.send('HeapProfiler.collectGarbage')
+  await cdpSession.send('HeapProfiler.collectGarbage')
+  const { usedSize } = await cdpSession.send('Runtime.getHeapUsage')
+  return usedSize / 1024 / 1024
+}
+
+/**
+ * Returns a promise that resolves once all in-flight prefetch requests have
+ * received responses and no new ones have been initiated for `quietMs`. Must be
+ * called *before* the action that triggers prefetches so that no requests are
+ * missed.
+ */
+function waitForPrefetchesToSettle(page: Page, quietMs = 200): Promise<void> {
+  let inFlight = 0
+  let resolve: () => void
+  let timer: ReturnType<typeof setTimeout> | null = null
+  const promise = new Promise<void>((r) => {
+    resolve = r
+  })
+
+  function done() {
+    page.off('request', onRequest)
+    page.off('requestfinished', onRequestDone)
+    page.off('requestfailed', onRequestDone)
+    resolve()
+  }
+
+  function check() {
+    if (inFlight === 0) {
+      if (timer) {
+        clearTimeout(timer)
+      }
+      timer = setTimeout(done, quietMs)
+    }
+  }
+
+  function onRequest(req: PlaywrightRequest) {
+    if (req.headers()['next-router-segment-prefetch']) {
+      inFlight++
+      if (timer) {
+        clearTimeout(timer)
+      }
+    }
+  }
+
+  function onRequestDone(req: PlaywrightRequest) {
+    if (req.headers()['next-router-segment-prefetch']) {
+      inFlight--
+      check()
+    }
+  }
+
+  page.on('request', onRequest)
+  page.on('requestfinished', onRequestDone)
+  page.on('requestfailed', onRequestDone)
+
+  return promise
+}