From e06e27c9444c8a59e1c1ba0812d57419409fa246 Mon Sep 17 00:00:00 2001 From: Suneil Nyamathi Date: Fri, 15 Aug 2025 04:48:32 +0000 Subject: [PATCH 1/2] fix: memory leak from cloneResponse --- .../next/src/server/lib/clone-response.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/next/src/server/lib/clone-response.ts b/packages/next/src/server/lib/clone-response.ts index d87e4558e41e01..b17148fe901770 100644 --- a/packages/next/src/server/lib/clone-response.ts +++ b/packages/next/src/server/lib/clone-response.ts @@ -1,3 +1,22 @@ +// @ts-ignore https://nodejs.org/api/stream.html#streamreadableisdisturbedstream +import { isDisturbed, isErrored } from 'node:stream' + +// https://github.com/nodejs/undici/blob/c399fdf4246be4992e89efe42aa83062edd0609f/lib/web/fetch/body.js#L32 +const noop = () => {} +const registry = new FinalizationRegistry( + (weakRef: WeakRef) => { + const stream = weakRef.deref() + if ( + stream && + !stream.locked && + !isDisturbed(stream) && + !isErrored(stream as any as NodeJS.ReadableStream) + ) { + stream.cancel('Response object has been garbage collected').then(noop) + } + } +) + /** * Clones a response by teeing the body so we can return two independent * ReadableStreams from it. This avoids the bug in the undici library around @@ -33,6 +52,24 @@ export function cloneResponse(original: Response): [Response, Response] { writable: false, }) + // The Fetch Standard allows users to skip consuming the response body by + // relying on garbage collection to release connection resources. + // https://github.com/nodejs/undici?tab=readme-ov-file#garbage-collection + // + // To cancel the stream you then need to cancel both resulting branches. + // Teeing a stream will generally lock it for the duration, preventing other + // readers from locking it. + // https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream/tee + + // cloned2 is stored in a react cache and cloned for subsequent requests. + // It is the original request, and is is garbage collected by a + // FinalizationRegistry in Undici, but since we're tee-ing the stream + // ourselves, we need to cancel clone1's stream (the response returned from + // our dedupe fetch) when clone1 is reclaimed, otherwise we leak memory. + if (cloned1.body) { + registry.register(cloned1, new WeakRef(cloned1.body)) + } + const cloned2 = new Response(body2, { status: original.status, statusText: original.statusText, From 26140a9e4b2f8801852ab9a8cd2fc7270078dd81 Mon Sep 17 00:00:00 2001 From: Suneil Nyamathi Date: Fri, 15 Aug 2025 10:18:22 -0700 Subject: [PATCH 2/2] Update clone-response.ts --- .../next/src/server/lib/clone-response.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/next/src/server/lib/clone-response.ts b/packages/next/src/server/lib/clone-response.ts index b17148fe901770..5913d8ee7dd1ad 100644 --- a/packages/next/src/server/lib/clone-response.ts +++ b/packages/next/src/server/lib/clone-response.ts @@ -1,21 +1,15 @@ -// @ts-ignore https://nodejs.org/api/stream.html#streamreadableisdisturbedstream -import { isDisturbed, isErrored } from 'node:stream' - -// https://github.com/nodejs/undici/blob/c399fdf4246be4992e89efe42aa83062edd0609f/lib/web/fetch/body.js#L32 const noop = () => {} -const registry = new FinalizationRegistry( - (weakRef: WeakRef) => { + +let registry: FinalizationRegistry> | undefined + +if (globalThis.FinalizationRegistry) { + registry = new FinalizationRegistry((weakRef: WeakRef) => { const stream = weakRef.deref() - if ( - stream && - !stream.locked && - !isDisturbed(stream) && - !isErrored(stream as any as NodeJS.ReadableStream) - ) { + if (stream && !stream.locked) { stream.cancel('Response object has been garbage collected').then(noop) } - } -) + }) +} /** * Clones a response by teeing the body so we can return two independent @@ -66,7 +60,7 @@ export function cloneResponse(original: Response): [Response, Response] { // FinalizationRegistry in Undici, but since we're tee-ing the stream // ourselves, we need to cancel clone1's stream (the response returned from // our dedupe fetch) when clone1 is reclaimed, otherwise we leak memory. - if (cloned1.body) { + if (registry && cloned1.body) { registry.register(cloned1, new WeakRef(cloned1.body)) }