From 88650e61630c1f6ffe44b11ee766fc9813cd0c9a Mon Sep 17 00:00:00 2001 From: Tyler Rockwood Date: Wed, 17 Jun 2026 09:04:13 -0500 Subject: [PATCH] fix(teeny-request): avoid piping into destroyed streams Stream-mode requests defer pipeline setup until both the consumer starts reading and the fetch response arrives. A response listener can destroy the returned request stream before teeny-request's later response listener runs. retry-request follows this sequence when it discards a retryable response. Passing that destroyed destination to pipeline() rejects with ERR_STREAM_UNABLE_TO_PIPE outside the caller's stream error handling. The unused fetch response body also remains open. Centralize response pipeline setup and recheck the request stream immediately before constructing the pipeline. When the destination is already destroyed, destroy the response body instead so its underlying HTTP resources are released without surfacing an unhandled rejection. Add a regression that reproduces the response-listener ordering and waits for the discarded body to close. The full teeny-request suite passes on Node 18, 22, 24, and 26 with 116 passing tests and 2 existing pending tests. The gts lint check and a retry-request 500-to-200 integration exercise also pass. Fixes #8670 --- core/packages/teeny-request/src/index.ts | 14 ++++++++++---- core/packages/teeny-request/test/index.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/core/packages/teeny-request/src/index.ts b/core/packages/teeny-request/src/index.ts index 931861ad798c..8de26e72f3f0 100644 --- a/core/packages/teeny-request/src/index.ts +++ b/core/packages/teeny-request/src/index.ts @@ -276,13 +276,19 @@ function teenyRequest( const requestStream = streamEvents(new PassThrough()); // eslint-disable-next-line @typescript-eslint/no-explicit-any let responseStream: any; + const pipeResponseStream = () => { + if (requestStream.destroyed) { + responseStream.destroy(); + return; + } + + pipeline(responseStream, requestStream, () => {}); + }; requestStream.once('reading', () => { if (responseStream) { - pipeline(responseStream, requestStream, () => {}); + pipeResponseStream(); } else { - requestStream.once('response', () => { - pipeline(responseStream, requestStream, () => {}); - }); + requestStream.once('response', pipeResponseStream); } }); options.compress = false; diff --git a/core/packages/teeny-request/test/index.ts b/core/packages/teeny-request/test/index.ts index e34d697a2a58..abd047c98eba 100644 --- a/core/packages/teeny-request/test/index.ts +++ b/core/packages/teeny-request/test/index.ts @@ -275,6 +275,23 @@ describe('teeny', () => { }); }); + it('should discard the response if the request stream is destroyed before piping', async () => { + const scope = mockJson(); + const stream = teenyRequest({uri}); + const responseClosed = new Promise((resolve, reject) => { + stream.once('error', reject); + stream.once('response', response => { + response.body.once('error', reject); + response.body.once('close', resolve); + stream.destroy(); + }); + }); + + stream.resume(); + await responseClosed; + scope.done(); + }); + it('should not pipe response stream to user unless they ask for it', async () => { const scope = mockJson(); const stream = teenyRequest({uri}).on('error', err => {