From 824414c29794a1b21361484f872ab4959d0c14c2 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 24 Aug 2026 19:35:29 +0000 Subject: [PATCH 1/2] fix(fetch): guard stream close after cancellation Signed-off-by: Matteo Collina --- lib/core/util.js | 14 ++++++++++-- test/fetch/readable-stream-from.js | 35 ++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) diff --git a/lib/core/util.js b/lib/core/util.js index f00ddfce0d1..56e2516d0c0 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -661,8 +661,18 @@ function ReadableStreamFrom (iterable) { return iterator.next().then(({ done, value }) => { if (done) { return queueMicrotask(() => { - controller.close() - controller.byobRequest?.respond(0) + try { + controller.close() + controller.byobRequest?.respond(0) + } catch (err) { + // The consumer may have cancelled the stream while + // iterator.next() was still in flight, which closes the + // controller before we get here. + if (!err.message.includes('Controller is already closed') && + !err.message.includes('ReadableStream is already closed')) { + throw err + } + } }) } else { const buf = Buffer.isBuffer(value) ? value : Buffer.from(value) diff --git a/test/fetch/readable-stream-from.js b/test/fetch/readable-stream-from.js index 7b047d31eec..efebcb2f8ad 100644 --- a/test/fetch/readable-stream-from.js +++ b/test/fetch/readable-stream-from.js @@ -27,3 +27,38 @@ test('ReadableStream empty enqueue', async (t) => { const response = new Response(iterable) t.assert.deepStrictEqual(await response.text(), '') }) + +// https://github.com/nodejs/undici/issues/5715 +test('ReadableStream cancellation while iterator.next() is in flight', async (t) => { + let resolveNext + let startNext + const nextStarted = new Promise((resolve) => { + startNext = resolve + }) + const iterable = { + [Symbol.asyncIterator] () { + return { + next () { + startNext() + return new Promise((resolve) => { + resolveNext = resolve + }) + }, + return () { + return Promise.resolve({ done: true, value: undefined }) + } + } + } + } + + const reader = new Response(iterable).body.getReader() + const read = reader.read() + await nextStarted + await reader.cancel() + resolveNext({ done: true, value: undefined }) + t.assert.deepStrictEqual(await read, { done: true, value: undefined }) + + // Let the microtask that closes the controller run. It must not throw if + // cancellation already closed the stream. + await new Promise((resolve) => setImmediate(resolve)) +}) From c73414967f7ef098ff829c9695dbbfcad8e9fa3c Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Mon, 31 Aug 2026 03:06:07 -0700 Subject: [PATCH 2/2] Update lib/core/util.js Co-authored-by: tsctx <91457664+tsctx@users.noreply.github.com> --- lib/core/util.js | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/lib/core/util.js b/lib/core/util.js index 56e2516d0c0..14709525863 100644 --- a/lib/core/util.js +++ b/lib/core/util.js @@ -661,18 +661,7 @@ function ReadableStreamFrom (iterable) { return iterator.next().then(({ done, value }) => { if (done) { return queueMicrotask(() => { - try { - controller.close() - controller.byobRequest?.respond(0) - } catch (err) { - // The consumer may have cancelled the stream while - // iterator.next() was still in flight, which closes the - // controller before we get here. - if (!err.message.includes('Controller is already closed') && - !err.message.includes('ReadableStream is already closed')) { - throw err - } - } + readableStreamClose(controller) }) } else { const buf = Buffer.isBuffer(value) ? value : Buffer.from(value)