Skip to content

Conversation

@mcollina
Copy link
Member

Summary

  • Fixes cache.match() throwing "Body has already been consumed" after garbage collection runs between calls
  • When a cached response's stream becomes unusable (cancelled by FinalizationRegistry), recreates the stream from the stored body source

Root Cause

The bug occurs due to the interaction between the FinalizationRegistry (used to track Response objects) and the cloneBody() function which mutates the original body's stream in-place:

  1. cache.match() calls fromInnerResponse() which creates a temporary Response and registers the cached stream
  2. Then response.clone() is called, which tees the stream and mutates the cached stream to be one of the tee outputs
  3. clone() re-registers this mutated stream with the temporary Response
  4. When the temporary Response is GC'd, the FinalizationRegistry callback cancels the stream
  5. Since the cancelled stream IS the cached stream (mutated in place), subsequent cache.match() calls fail

Fix

The fix checks if the cached stream is unusable but body.source is available (which cache.put() always stores after reading the body bytes), and recreates the stream from source before creating the Response object.

Fixes: #4710

Test plan

  • Added test that reproduces the issue with GC pressure
  • Test passes with the fix
  • Full cache test suite passes
  • Linter passes

🤖 Generated with Claude Code

…after GC

The cached response's stream could be cancelled by the FinalizationRegistry
when a temporary Response object wrapping the inner response was garbage
collected. This caused cache.match() to throw "Body has already been consumed"
after garbage collection ran between calls.

The fix checks if the cached stream is unusable (locked or disturbed) but
the body source is available (which cache.put() always stores), and if so,
recreates the stream from the source before creating the Response object.

Fixes: #4710

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@codecov-commenter
Copy link

codecov-commenter commented Dec 29, 2025

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 93.17%. Comparing base (ec4a84e) to head (2a49e6b).
⚠️ Report is 12 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #4713      +/-   ##
==========================================
+ Coverage   92.85%   93.17%   +0.31%     
==========================================
  Files         109      109              
  Lines       33809    33872      +63     
==========================================
+ Hits        31395    31561     +166     
+ Misses       2414     2311     -103     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@tsctx
Copy link
Member

tsctx commented Jan 4, 2026

diff --git a/lib/web/cache/cache.js b/lib/web/cache/cache.js
index 70a3787a..10decbed 100644
--- a/lib/web/cache/cache.js
+++ b/lib/web/cache/cache.js
@@ -794,9 +794,9 @@ class Cache {
     // 5.5.2
     for (const response of responses) {
       // 5.5.2.1
-      const responseObject = fromInnerResponse(response, 'immutable')
+      const responseObject = fromInnerResponse(cloneResponse(response), 'immutable')
 
-      responseList.push(responseObject.clone())
+      responseList.push(responseObject)
 
       if (responseList.length >= maxResponses) {
         break
diff --git a/lib/web/fetch/response.js b/lib/web/fetch/response.js
index b3f942c3..6b954afa 100644
--- a/lib/web/fetch/response.js
+++ b/lib/web/fetch/response.js
@@ -555,7 +555,8 @@ function fromInnerResponse (innerResponse, guard) {
   setHeadersList(headers, innerResponse.headersList)
   setHeadersGuard(headers, guard)
 
-  if (innerResponse.body?.stream) {
+  // Note: If innerResponse's urlList contains a URL, it is a fetch response.
+  if (innerResponse.urlList.length !== 0 && innerResponse.body?.stream) {
     // If the target (response) is reclaimed, the cleanup callback may be called at some point with
     // the held value provided for it (innerResponse.body.stream). The held value can be any value:
     // a primitive or an object, even undefined. If the held value is an object, the registry keeps

@mcollina
I just avoided Response#clone() and also improved it so that finalizers are not added to anything other than fetch responses. I think this is better, wdyt?

Co-Authored-By: tsctx <91457664+tsctx@users.noreply.github.com>
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
Copy link
Member Author

@tsctx ptal

Comment on lines 796 to 823
// Fix for https://github.com/nodejs/undici/issues/4710
// The cached response's stream may have been cancelled by the FinalizationRegistry
// when a previous Response object wrapping this inner response was garbage collected.
// If the stream is unusable but we have the body source (which cache.put() always
// stores), recreate the stream from the source.
if (response.body != null) {
const { stream, source } = response.body
const streamUnusable = stream.locked || isDisturbed(stream)

if (streamUnusable && (typeof source === 'string' || isBuffer(source))) {
response.body.stream = new ReadableStream({
pull (controller) {
const buffer = typeof source === 'string'
? Buffer.from(source, 'utf-8')
: source

if (buffer.byteLength) {
controller.enqueue(new Uint8Array(buffer))
}

queueMicrotask(() => readableStreamClose(controller))
},
start () {},
type: 'bytes'
})
}
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't need this workaround anymore because the issue has been resolved.

Co-Authored-By: tsctx <91457664+tsctx@users.noreply.github.com>
Signed-off-by: Matteo Collina <hello@matteocollina.com>
@mcollina
Copy link
Member Author

@tsctx ptal

@fabiancook
Copy link

Noting I've been making use of the iterations of changes locally using a patch against the package, has been working great for a larger amount of caches, time, & other used resources.

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.

match throws an error after external resource allocation occurs

6 participants