From 51fec761ab92b1777def48c1124c91d202a5c09e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:11:40 +0000 Subject: [PATCH 1/3] fix(node): stop crashing when a web stream is cancelled with an error before its first read `toWebReadableStream`'s cancel destroys non-request sources with the cancel reason, and `destroy(err)` emits `error` on the next tick. The source's async iterator only attaches its error listener once the first `pull` runs, so cancelling with an `Error` before any read left the event unhandled and crashed the process. Attach a no-op `error` listener before destroying, as `destroyNodeHttpBody` already does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011tc83hdkAdsVixQ5ywKLMD --- packages/node/src/utils.test.ts | 11 +++++++++++ packages/node/src/utils.ts | 4 ++++ 2 files changed, 15 insertions(+) diff --git a/packages/node/src/utils.test.ts b/packages/node/src/utils.test.ts index bb1608b..0822e76 100644 --- a/packages/node/src/utils.test.ts +++ b/packages/node/src/utils.test.ts @@ -651,6 +651,17 @@ describe('toWebReadableStream', () => { expect(source.destroyed).toBe(true) // cancellation still tears the source down }) + it('does not throw when a raw buffer stream is cancelled with an error before any read', async () => { + const source = new Readable({ read() {} }) + const reason = new Error('cancelled') + + // No pull has run yet, so the iterator has not attached its error listener. + const crashes = await recordUncaught(() => toWebReadableStream(source).cancel(reason)) + + expect(crashes).toEqual([]) + expect(source.errored).toBe(reason) + }) + it('keeps an aborted HTTP/1 upload from crashing the process', async () => { const { handled, crashes } = await runUploadServer('http1', 25) diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index 19086c4..dc65000 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -55,6 +55,10 @@ export function toWebReadableStream(stream: Readable): ReadableStream {}) } else { + // The canceller discarded the stream, so its `error` is expected. Before + // the first pull the iterator has no listener yet, and an unhandled + // `error` from `destroy` would crash the process. + stream.on('error', () => {}) stream.destroy(reason instanceof Error ? reason : undefined) } }, From a21bac365345793b41413caf0a6def070065c492 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:13:17 +0000 Subject: [PATCH 2/3] test(node): cover a request torn down while its cancelled body drains The drain's `catch` was only reached by chance timing in the aborted-upload tests, so function coverage of `utils.ts` flickered below 100%. Destroy a server request mid-drain to hit it deterministically. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011tc83hdkAdsVixQ5ywKLMD --- packages/node/src/utils.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/node/src/utils.test.ts b/packages/node/src/utils.test.ts index 0822e76..7000f67 100644 --- a/packages/node/src/utils.test.ts +++ b/packages/node/src/utils.test.ts @@ -765,4 +765,16 @@ describe('toWebReadableStream', () => { expect(received).toBe('ok') expect(request.rstCode).toBe(http2.constants.NGHTTP2_NO_ERROR) }) + + it('ignores a request torn down while its cancelled body drains', async () => { + const req = new http.IncomingMessage(new net.Socket()) + req.method = 'POST' // a server request, so cancel drains it + + const crashes = await recordUncaught(async () => { + await toWebReadableStream(req).cancel() + req.destroy() // e.g. the client aborts mid-drain + }) + + expect(crashes).toEqual([]) + }) }) From bcad86fd77bcc88adf395ac5287ebfce4050a9d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 02:23:24 +0000 Subject: [PATCH 3/3] fix(node): listen for source errors from the moment the web stream is created The async iterator only attaches its `error` listener on its first `next()`, so the gap isn't specific to cancel: a source that errors in the same tick it is wrapped also crashed the process. Attach the no-op listener next to the iterator instead of in the cancel branch; the first read still rejects with the source's error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_011tc83hdkAdsVixQ5ywKLMD --- packages/node/src/utils.test.ts | 24 ++++++++++++++++++++---- packages/node/src/utils.ts | 5 +---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/node/src/utils.test.ts b/packages/node/src/utils.test.ts index 7000f67..3eee7e0 100644 --- a/packages/node/src/utils.test.ts +++ b/packages/node/src/utils.test.ts @@ -651,17 +651,33 @@ describe('toWebReadableStream', () => { expect(source.destroyed).toBe(true) // cancellation still tears the source down }) - it('does not throw when a raw buffer stream is cancelled with an error before any read', async () => { + it('does not throw when cancelled with an error before any read', async () => { const source = new Readable({ read() {} }) const reason = new Error('cancelled') - // No pull has run yet, so the iterator has not attached its error listener. const crashes = await recordUncaught(() => toWebReadableStream(source).cancel(reason)) expect(crashes).toEqual([]) expect(source.errored).toBe(reason) }) + it('does not throw when the source errors in the tick it is wrapped', async () => { + const source = new Readable({ read() {} }) + const error = new Error('failed') + + const crashes = await recordUncaught(async () => { + const body = await new Promise((resolve) => { + setImmediate(() => { + resolve(toWebReadableStream(source)) + source.destroy(error) + }) + }) + await expect(body.getReader().read()).rejects.toBe(error) + }) + + expect(crashes).toEqual([]) + }) + it('keeps an aborted HTTP/1 upload from crashing the process', async () => { const { handled, crashes } = await runUploadServer('http1', 25) @@ -768,11 +784,11 @@ describe('toWebReadableStream', () => { it('ignores a request torn down while its cancelled body drains', async () => { const req = new http.IncomingMessage(new net.Socket()) - req.method = 'POST' // a server request, so cancel drains it + req.method = 'POST' const crashes = await recordUncaught(async () => { await toWebReadableStream(req).cancel() - req.destroy() // e.g. the client aborts mid-drain + req.destroy() }) expect(crashes).toEqual([]) diff --git a/packages/node/src/utils.ts b/packages/node/src/utils.ts index dc65000..459a183 100644 --- a/packages/node/src/utils.ts +++ b/packages/node/src/utils.ts @@ -28,6 +28,7 @@ import type { NodeHttpResponse } from './types' */ export function toWebReadableStream(stream: Readable): ReadableStream> { const iterator = stream[Symbol.asyncIterator]() + stream.on('error', () => {}) let canceled = false return new ReadableStream({ @@ -55,10 +56,6 @@ export function toWebReadableStream(stream: Readable): ReadableStream {}) } else { - // The canceller discarded the stream, so its `error` is expected. Before - // the first pull the iterator has no listener yet, and an unhandled - // `error` from `destroy` would crash the process. - stream.on('error', () => {}) stream.destroy(reason instanceof Error ? reason : undefined) } },