From 466c45998829120ef60a6b7afa856ce9624a23f9 Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <snyamathi@gmail.com>
Date: Fri, 15 Aug 2025 11:09:44 -0700
Subject: [PATCH 1/6] Add failing test repro

---
 test/fetch/response.js | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/test/fetch/response.js b/test/fetch/response.js
index 86c57b13658..9bece9d7a1e 100644
--- a/test/fetch/response.js
+++ b/test/fetch/response.js
@@ -313,3 +313,29 @@ test('clone body garbage collection', async () => {
   const cloneBody = ref.deref()
   assert.equal(cloneBody, undefined, 'clone body was not garbage collected')
 })
+
+test('clone body garbage collection should not affect original res', async () => {
+  const { promise, resolve } = Promise.withResolvers()
+  const registry = new FinalizationRegistry(resolve)
+
+  const doFetch = async () => {
+    const res = new Response('hello world')
+
+    // Clone res and consume its body
+    const clone = res.clone()
+    await clone.text()
+
+    // Use finalization registry to know when the *clone* has been reclaimed
+    registry.register(clone, {})
+
+    return res
+  }
+
+  const res = await doFetch()
+
+  global.gc()
+  await promise
+  await setImmediate()
+
+  assert.doesNotThrow(() => res.clone(), 'original body was already consumed')
+})

From 58e82cb428b63825c2b303b594997675d39123b6 Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <snyamathi@gmail.com>
Date: Fri, 15 Aug 2025 11:11:25 -0700
Subject: [PATCH 2/6] fix: register correct stream with finalization registry

---
 lib/web/fetch/body.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js
index 7237749838a..2468b2be492 100644
--- a/lib/web/fetch/body.js
+++ b/lib/web/fetch/body.js
@@ -297,7 +297,7 @@ function cloneBody (instance, body) {
   const [out1, out2] = body.stream.tee()
 
   if (hasFinalizationRegistry) {
-    streamRegistry.register(instance, new WeakRef(out1))
+    streamRegistry.register(instance, new WeakRef(out2))
   }
 
   // 2. Set body’s stream to out1.

From ae44f76849f239d4a4711e9d77472663ed5f691a Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <snyamathi@gmail.com>
Date: Fri, 15 Aug 2025 13:19:07 -0700
Subject: [PATCH 3/6] fix: node21

---
 test/fetch/response.js | 19 ++++++++++---------
 1 file changed, 10 insertions(+), 9 deletions(-)

diff --git a/test/fetch/response.js b/test/fetch/response.js
index 9bece9d7a1e..d5ba3d8b02e 100644
--- a/test/fetch/response.js
+++ b/test/fetch/response.js
@@ -315,8 +315,7 @@ test('clone body garbage collection', async () => {
 })
 
 test('clone body garbage collection should not affect original res', async () => {
-  const { promise, resolve } = Promise.withResolvers()
-  const registry = new FinalizationRegistry(resolve)
+  let ref
 
   const doFetch = async () => {
     const res = new Response('hello world')
@@ -324,18 +323,20 @@ test('clone body garbage collection should not affect original res', async () =>
     // Clone res and consume its body
     const clone = res.clone()
     await clone.text()
-
-    // Use finalization registry to know when the *clone* has been reclaimed
-    registry.register(clone, {})
+    ref = new WeakRef(clone)
 
     return res
   }
 
   const res = await doFetch()
 
-  global.gc()
-  await promise
-  await setImmediate()
+  // wait for garbage collection
+  while (true) {
+    await setImmediate()
+    global.gc()
+    if (!ref.deref()) break
+  }
 
-  assert.doesNotThrow(() => res.clone(), 'original body was already consumed')
+  await setImmediate()
+  assert.doesNotThrow(() => res.clone(), 'Body has already been consumed')
 })

From 14e9662e7113a121da5a218b7ffca6701afa2f59 Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <suneil.nyamathi@yahooinc.com>
Date: Sun, 17 Aug 2025 15:46:31 +0000
Subject: [PATCH 4/6] port changes

---
 lib/web/fetch/body.js         |  4 ----
 lib/web/fetch/response.js     |  5 +++++
 test/fetch/fire-and-forget.js | 41 +++++++++++++++++++++++++++++++++--
 3 files changed, 44 insertions(+), 6 deletions(-)

diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js
index 2468b2be492..b1c553d4e0f 100644
--- a/lib/web/fetch/body.js
+++ b/lib/web/fetch/body.js
@@ -296,10 +296,6 @@ function cloneBody (instance, body) {
   // 1. Let « out1, out2 » be the result of teeing body’s stream.
   const [out1, out2] = body.stream.tee()
 
-  if (hasFinalizationRegistry) {
-    streamRegistry.register(instance, new WeakRef(out2))
-  }
-
   // 2. Set body’s stream to out1.
   body.stream = out1
 
diff --git a/lib/web/fetch/response.js b/lib/web/fetch/response.js
index 3b6af35fbed..c8c5a08242e 100644
--- a/lib/web/fetch/response.js
+++ b/lib/web/fetch/response.js
@@ -240,6 +240,11 @@ class Response {
     // 2. Let clonedResponse be the result of cloning this’s response.
     const clonedResponse = cloneResponse(this[kState])
 
+    // Note: To re-register because of a new stream.
+    if (this[kState].body?.stream) {
+      streamRegistry.register(this, new WeakRef(this[kState].body.stream))
+    }
+
     // 3. Return the result of creating a Response object, given
     // clonedResponse, this’s headers’s guard, and this’s relevant Realm.
     return fromInnerResponse(clonedResponse, getHeadersGuard(this[kHeaders]))
diff --git a/test/fetch/fire-and-forget.js b/test/fetch/fire-and-forget.js
index d356729b14d..56c58ed60b9 100644
--- a/test/fetch/fire-and-forget.js
+++ b/test/fetch/fire-and-forget.js
@@ -1,17 +1,54 @@
 'use strict'
 
 const { randomFillSync } = require('node:crypto')
-const { setTimeout: sleep } = require('timers/promises')
+const { setTimeout: sleep, setImmediate: nextTick } = require('node:timers/promises')
 const { test } = require('node:test')
-const { fetch, Agent, setGlobalDispatcher } = require('../..')
+const { fetch, Request, Response, Agent, setGlobalDispatcher } = require('../..')
 const { createServer } = require('node:http')
 const { closeServerAsPromise } = require('../utils/node-http')
 
 const blob = randomFillSync(new Uint8Array(1024 * 512))
 
+const hasGC = typeof global.gc !== 'undefined'
+
 // Enable when/if FinalizationRegistry in Node.js 18 becomes stable again
 const isNode18 = process.version.startsWith('v18')
 
+// https://github.com/nodejs/undici/issues/4150
+test('test finalizer cloned request', async () => {
+  if (!hasGC) {
+    throw new Error('gc is not available. Run with \'--expose-gc\'.')
+  }
+
+  const request = new Request('http://localhost', { method: 'POST', body: 'Hello' })
+
+  request.clone()
+
+  await nextTick()
+  // eslint-disable-next-line no-undef
+  gc()
+
+  await nextTick()
+  await request.arrayBuffer() // check consume body
+})
+
+test('test finalizer cloned response', async () => {
+  if (!hasGC) {
+    throw new Error('gc is not available. Run with \'--expose-gc\'.')
+  }
+
+  const response = new Response('Hello')
+
+  response.clone()
+
+  await nextTick()
+  // eslint-disable-next-line no-undef
+  gc()
+
+  await nextTick()
+  await response.arrayBuffer() // check consume body
+})
+
 test('does not need the body to be consumed to continue', { timeout: 180_000, skip: isNode18 }, async (t) => {
   const agent = new Agent({
     keepAliveMaxTimeout: 10,

From a4929b329a8451fb04e0e14e911d9542e7336ef1 Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <suneil.nyamathi@yahooinc.com>
Date: Sun, 17 Aug 2025 15:47:55 +0000
Subject: [PATCH 5/6] remove duplicate test

---
 test/fetch/response.js | 27 ---------------------------
 1 file changed, 27 deletions(-)

diff --git a/test/fetch/response.js b/test/fetch/response.js
index d5ba3d8b02e..86c57b13658 100644
--- a/test/fetch/response.js
+++ b/test/fetch/response.js
@@ -313,30 +313,3 @@ test('clone body garbage collection', async () => {
   const cloneBody = ref.deref()
   assert.equal(cloneBody, undefined, 'clone body was not garbage collected')
 })
-
-test('clone body garbage collection should not affect original res', async () => {
-  let ref
-
-  const doFetch = async () => {
-    const res = new Response('hello world')
-
-    // Clone res and consume its body
-    const clone = res.clone()
-    await clone.text()
-    ref = new WeakRef(clone)
-
-    return res
-  }
-
-  const res = await doFetch()
-
-  // wait for garbage collection
-  while (true) {
-    await setImmediate()
-    global.gc()
-    if (!ref.deref()) break
-  }
-
-  await setImmediate()
-  assert.doesNotThrow(() => res.clone(), 'Body has already been consumed')
-})

From e8da2262be08af321ea51d4a6394c21ec33cc51d Mon Sep 17 00:00:00 2001
From: Suneil Nyamathi <suneil.nyamathi@yahooinc.com>
Date: Sun, 17 Aug 2025 16:08:47 +0000
Subject: [PATCH 6/6] be defensive with finalization registry

---
 lib/web/fetch/response.js | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/web/fetch/response.js b/lib/web/fetch/response.js
index c8c5a08242e..3abaa8bd6d5 100644
--- a/lib/web/fetch/response.js
+++ b/lib/web/fetch/response.js
@@ -241,7 +241,7 @@ class Response {
     const clonedResponse = cloneResponse(this[kState])
 
     // Note: To re-register because of a new stream.
-    if (this[kState].body?.stream) {
+    if (hasFinalizationRegistry && this[kState].body?.stream) {
       streamRegistry.register(this, new WeakRef(this[kState].body.stream))
     }
 
