Repository: koajs/koa

Issue:
[bug] TCP memory leak caused by aborted client connections when streaming data with node-fetch
## Describe the bug

**Node.js version:** v18.16.0

**OS version:** Amazon Linux 2 x86_64

**Description:** Providing node-fetch response body stream into `ctx.body` leads to TCP memory leak when client aborts data download half way through.

## Actual behavior

TCP memory keeps increasing with every aborted request and never goes down

## Expected behavior

TCP memory to recover after streaming is complete/aborted

## Code to reproduce
See https://github.com/AtnesNess/koa-node-fetch-issue

## Checklist

- [+] I have searched through GitHub issues for similar issues.
- [+] I have completely read through the README and documentation.
- [+] I have tested my code with the latest version of Node.js and this package and confirmed it is still not working.



Discussion:
What I found is that stream is being [destroyed](https://github.com/koajs/koa/blob/master/lib/response.js#L175) on client connection abort. However, node-fetch stream abortion doesn't destroy the socket. I could see that by checking heap dump, [this](https://github.com/node-fetch/node-fetch/blob/2.x/src/index.js#L358) reference is not being cleared out with garbage collector since the request is not aborted. Ideally [this function](https://github.com/node-fetch/node-fetch/blob/2.x/src/index.js#L109) should be called on stream destroy.


Seems like the issue in node-fetch is outlined here https://github.com/node-fetch/node-fetch/issues/1762.

To conclude this issue is reproducible only when koa and nodeFetchV2 used together
most likely, koa's stream pipelining could use some updates as node has introduced new pipelining features that may handle the aborts better
do you know if this happens with native fetch?



Patch:
diff --git a/lib/application.js b/lib/application.js
index ff3245385..cd6504496 100644
--- a/lib/application.js
+++ b/lib/application.js
@@ -304,10 +304,18 @@ function respond (ctx) {
 
   if (Buffer.isBuffer(body)) return res.end(body)
   if (typeof body === 'string') return res.end(body)
-  if (body instanceof Blob) { return Stream.Readable.from(body.stream()).pipe(res) }
-  if (body instanceof ReadableStream) { return Stream.Readable.from(body).pipe(res) }
-  if (body instanceof Response) { return Stream.Readable.from(body?.body || '').pipe(res) }
-  if (isStream(body)) return body.pipe(res)
+
+  let stream = null
+  if (body instanceof Blob) stream = Stream.Readable.from(body.stream())
+  else if (body instanceof ReadableStream) stream = Stream.Readable.from(body)
+  else if (body instanceof Response) stream = Stream.Readable.from(body?.body || '')
+  else if (isStream(body)) stream = body
+
+  if (stream) {
+    return Stream.pipeline(stream, res, err => {
+      if (err && ctx.app.listenerCount('error')) ctx.onerror(err)
+    })
+  }
 
   // body: json
   body = JSON.stringify(body)
diff --git a/lib/response.js b/lib/response.js
index c516e0e13..d842a38a7 100644
--- a/lib/response.js
+++ b/lib/response.js
@@ -149,6 +149,13 @@ module.exports = {
       this.remove('Content-Type')
       this.remove('Content-Length')
       this.remove('Transfer-Encoding')
+
+      const shouldDestroyOriginal = original && isStream(original)
+      if (shouldDestroyOriginal) {
+        // Ignore errors during cleanup to prevent unhandled exceptions when destroying the stream
+        original.once('error', () => {})
+        destroy(original)
+      }
       return
     }
 
@@ -176,7 +183,6 @@ module.exports = {
     if (isStream(val)) {
       onFinish(this.res, destroy.bind(null, val))
       if (original !== val) {
-        val.once('error', err => this.ctx.onerror(err))
         // overwriting
         if (original != null) this.remove('Content-Length')
       }


Test patch:
diff --git a/__tests__/application/respond.test.js b/__tests__/application/respond.test.js
index 6b508000c..79fe87f6f 100644
--- a/__tests__/application/respond.test.js
+++ b/__tests__/application/respond.test.js
@@ -571,6 +571,48 @@ describe('app.respond', () => {
         .expect('content-type', 'application/octet-stream')
         .expect(Buffer.from('hello'))
     })
+
+    it('should handle ReadableStream with chunks', async () => {
+      const app = new Koa()
+
+      app.use(async ctx => {
+        const stream = new ReadableStream({
+          start (controller) {
+            controller.enqueue(new TextEncoder().encode('Hello '))
+            controller.enqueue(new TextEncoder().encode('World'))
+            controller.close()
+          }
+        })
+        ctx.body = stream
+      })
+
+      return request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'application/octet-stream')
+        .expect(Buffer.from('Hello World'))
+    })
+
+    it('should handle ReadableStream with custom headers', async () => {
+      const app = new Koa()
+
+      app.use(async ctx => {
+        ctx.type = 'text/plain'
+        ctx.body = new ReadableStream({
+          start (controller) {
+            controller.enqueue(new TextEncoder().encode('test content'))
+            controller.close()
+          }
+        })
+      })
+
+      const res = await request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'text/plain; charset=utf-8')
+
+      assert.strictEqual(res.text, 'test content')
+    })
   })
 
   describe('when .body is a Response', () => {
@@ -601,6 +643,98 @@ describe('app.respond', () => {
         .expect('content-type', 'application/octet-stream')
         .expect(Buffer.from([]))
     })
+
+    it('should respond with body content', async () => {
+      const app = new Koa()
+
+      app.use(ctx => {
+        ctx.body = new Response('Hello World', { status: 200, headers: { 'Content-Type': 'text/plain' } })
+      })
+
+      const res = await request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'text/plain')
+
+      assert.strictEqual(res.text, 'Hello World')
+    })
+
+    it('should handle Response from fetch() with JSON', async () => {
+      const app = new Koa()
+
+      app.use(async ctx => {
+        const jsonData = JSON.stringify({ message: 'Hello from fetch', timestamp: Date.now() })
+        const response = new Response(jsonData, {
+          status: 200,
+          headers: {
+            'Content-Type': 'application/json',
+            'X-Custom-Header': 'custom-value'
+          }
+        })
+        ctx.body = response
+      })
+
+      const res = await request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'application/json')
+
+      const body = JSON.parse(res.text)
+      assert.strictEqual(body.message, 'Hello from fetch')
+      assert(body.timestamp)
+    })
+
+    it('should handle Response from fetch() with streaming body', async () => {
+      const app = new Koa()
+
+      app.use(async ctx => {
+        const stream = new ReadableStream({
+          start (controller) {
+            controller.enqueue(new TextEncoder().encode('Streaming '))
+            controller.enqueue(new TextEncoder().encode('response '))
+            controller.enqueue(new TextEncoder().encode('from fetch'))
+            controller.close()
+          }
+        })
+
+        const response = new Response(stream, {
+          status: 200,
+          headers: {
+            'Content-Type': 'text/plain'
+          }
+        })
+        ctx.body = response
+      })
+
+      const res = await request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'text/plain')
+
+      assert.strictEqual(res.text, 'Streaming response from fetch')
+    })
+
+    it('should handle Response from fetch() with Blob body', async () => {
+      const app = new Koa()
+
+      app.use(async ctx => {
+        const blob = new Blob(['Hello from Blob'], { type: 'text/plain' })
+        const response = new Response(blob, {
+          status: 200,
+          headers: {
+            'Content-Type': 'text/plain'
+          }
+        })
+        ctx.body = response
+      })
+
+      const res = await request(app.callback())
+        .get('/')
+        .expect(200)
+        .expect('content-type', 'text/plain')
+
+      assert.strictEqual(res.text, 'Hello from Blob')
+    })
   })
 
   describe('when .body is a Stream', () => {
@@ -692,6 +826,125 @@ describe('app.respond', () => {
     })
   })
 
+  describe('when using pipeline for streams', () => {
+    it('should handle stream errors when error listener exists', async () => {
+      const app = new Koa()
+      const PassThrough = require('stream').PassThrough
+
+      let errorCaught = false
+      app.once('error', err => {
+        assert(err.message === 'stream error')
+        errorCaught = true
+      })
+
+      app.use(ctx => {
+        const stream = new PassThrough()
+        ctx.body = stream
+
+        setImmediate(() => {
+          stream.emit('error', new Error('stream error'))
+        })
+      })
+
+      await request(app.callback())
+        .get('/')
+        .catch(() => {})
+
+      await new Promise(resolve => setTimeout(resolve, 50))
+      assert(errorCaught, 'Error should have been caught')
+    })
+
+    it('should not crash when stream errors and no error listener exists', async () => {
+      const app = new Koa()
+      const PassThrough = require('stream').PassThrough
+
+      app.use(ctx => {
+        const stream = new PassThrough()
+        ctx.body = stream
+
+        setImmediate(() => {
+          stream.emit('error', new Error('stream error'))
+        })
+      })
+
+      await request(app.callback())
+        .get('/')
+        .catch(() => {})
+
+      await new Promise(resolve => setTimeout(resolve, 50))
+    })
+
+    it('should handle ReadableStream errors when error listener exists', async () => {
+      const app = new Koa()
+
+      let errorCaught = false
+      app.once('error', err => {
+        assert(err.message === 'readable stream error')
+        errorCaught = true
+      })
+
+      app.use(ctx => {
+        const readable = new ReadableStream({
+          start (controller) {
+            controller.enqueue(new TextEncoder().encode('data'))
+            controller.error(new Error('readable stream error'))
+          }
+        })
+        ctx.body = readable
+      })
+
+      await request(app.callback())
+        .get('/')
+        .catch(() => {})
+
+      await new Promise(resolve => setTimeout(resolve, 50))
+      assert(errorCaught, 'Error should have been caught')
+    })
+
+    it('should cleanup streams on client abort', async () => {
+      const app = new Koa()
+      const PassThrough = require('stream').PassThrough
+      const http = require('http')
+
+      let streamDestroyed = false
+
+      app.use(ctx => {
+        const stream = new PassThrough()
+        stream.on('close', () => {
+          streamDestroyed = true
+        })
+        ctx.body = stream
+
+        setImmediate(() => {
+          stream.write('some data')
+        })
+      })
+
+      const server = app.listen()
+
+      await new Promise((resolve) => {
+        const req = http.request({
+          port: server.address().port,
+          path: '/'
+        })
+
+        req.on('response', (res) => {
+          res.on('data', () => {
+            req.destroy()
+            setTimeout(() => {
+              server.close()
+              resolve()
+            }, 50)
+          })
+        })
+
+        req.end()
+      })
+
+      assert(streamDestroyed, 'Stream should be destroyed on client abort')
+    })
+  })
+
   describe('when .body is an Object', () => {
     it('should respond with json', () => {
       const app = new Koa()
diff --git a/__tests__/response/body.test.js b/__tests__/response/body.test.js
index 25de696a9..92ff7e9fb 100644
--- a/__tests__/response/body.test.js
+++ b/__tests__/response/body.test.js
@@ -117,14 +117,14 @@ describe('res.body=', () => {
       assert.strictEqual('application/octet-stream', res.header['content-type'])
     })
 
-    it('should add error handler to the stream, but only once', () => {
+    it('should not add error handler to stream (handled by pipeline)', () => {
       const res = response()
       const body = new Stream.PassThrough()
       assert.strictEqual(body.listenerCount('error'), 0)
       res.body = body
-      assert.strictEqual(body.listenerCount('error'), 1)
+      assert.strictEqual(body.listenerCount('error'), 0)
       res.body = body
-      assert.strictEqual(body.listenerCount('error'), 1)
+      assert.strictEqual(body.listenerCount('error'), 0)
     })
   })