From 9396db8134391293fe9f0ee0545ecdada5898268 Mon Sep 17 00:00:00 2001 From: Mark Anthony Hernandez Date: Sun, 1 Nov 2026 14:14:24 +0800 Subject: [PATCH] fix(utils): do not chain a promise per chunk while streaming a response --- src/utils.ts | 7 +++++-- test/utils.test.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/utils.ts b/src/utils.ts index d9e8a444..1c4ce15a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -36,14 +36,17 @@ export function writeFromReadableStreamDefaultReader( reader.read().then(flow, handleStreamError) } - function flow({ done, value }: ReadableStreamReadResult): void | Promise { + function flow({ done, value }: ReadableStreamReadResult): void { try { if (done) { writable.end() } else if (!writable.write(value)) { writable.once('drain', onDrain) } else { - return reader.read().then(flow, handleStreamError) + // Do not return this promise. Returning it makes the promise of the current `then` wait for + // the next read, so while `write()` keeps returning `true` every chunk adds a promise to a + // chain that is only released when the stream ends. + reader.read().then(flow, handleStreamError) } } catch (e) { handleStreamError(e) diff --git a/test/utils.test.ts b/test/utils.test.ts index 4432bbf4..d04015e0 100644 --- a/test/utils.test.ts +++ b/test/utils.test.ts @@ -1,4 +1,5 @@ import { Writable } from 'node:stream' +import { queryObjects } from 'node:v8' import { buildOutgoingHttpHeaders, writeFromReadableStream, @@ -132,6 +133,49 @@ describe('writeFromReadableStream', () => { }) }) +describe('writeFromReadableStream with a paced source', () => { + it('should not keep a promise for each chunk while the stream is open', async () => { + const total = 1000 + const encode = new TextEncoder() + const countPromises = () => queryObjects(Promise, { format: 'count' }) + + let sent = 0 + let baseline = 0 + let live = 0 + const stream = new ReadableStream({ + async pull(controller) { + // let the previous chunk be written and its read be settled before producing the next one + await new Promise((resolve) => setImmediate(resolve)) + if (sent === 100) { + baseline = countPromises() + } + if (sent === total - 1) { + live = countPromises() + } + if (sent++ < total) { + controller.enqueue(encode.encode('x')) + } else { + controller.close() + } + }, + }) + + // a large highWaterMark keeps `write()` returning `true`, like a socket that never fills up + const writable = new Writable({ + highWaterMark: 1024 * 1024, + write(_chunk, _encoding, callback) { + callback() + }, + }) + + await writeFromReadableStream(stream, writable) + + expect(baseline).toBeGreaterThan(0) + // before the fix, about one promise per chunk (~900) stayed alive between the two samples + expect(live - baseline).toBeLessThan(100) + }) +}) + describe('readWithoutBlocking', () => { const encode = (body: string) => new TextEncoder().encode(body) it('should return the body for simple text', async () => {