fix: reuse a single drain listener when piping Node streams through gzip (#97698)
## Summary
On Next 16.3+, App Router HTML and RSC responses are piped with
`pipeNodeReadableToNodeResponse`. Under gzip backpressure it called
`res.once('drain')` on every write that returned `false`, which leaks
one listener per backpressured write onto the zlib stream:
```
MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 drain listeners added to [Gzip]. MaxListeners is 10.
```
The `compression` middleware overrides only `res.on`, forwarding it to
the zlib stream once that stream exists (or buffering it until then). It
leaves `removeListener` pointing at the response, so a `once` listener
is added to the stream but never removed from it. `off` cannot reach it
either.
`createWriterFromResponse`, directly above in the same file, already
uses a single `res.on('drain')` for the response lifetime. This makes
the Node-readable path match: pause on `write() === false`, resume from
one listener, `off` on `close`.
Resuming on that event is correct for both cases. With compression
active the wrapped `res.write` writes into the zlib stream, so `false`
means the zlib stream's buffer is full and its `drain` is the right
signal — the middleware separately wires the socket's `drain` to
`stream.resume()`. With compression inactive the listener stays on the
response, where `off` does remove it.
The pause is latched so a `drain` only resumes a readable that this
function paused, and a `drain` arriving after teardown is ignored.
### Scope
- Distinct from #96173 / `releaseCompressionStream`, which destroys the
zlib stream on **abort**. This warning fires on a **live** stream that
is still flowing: slow clients, large streamed HTML/RSC.
- Deliberately leaves the bare `readable.destroy()` in the `close`
handler alone. That is the separate abort-reason bug in #96704, with
fixes already open in #96715 and #96717.
- Not fixable by bumping the vendored `compression`: as #96173
established, upstream `compression@1.8.1` still has no premature-close
handling, and it is a prebuilt bundle here. `setMaxListeners` or
`compress: false` would only hide this.
- Mentioned as a `compress: false` workaround in #95130.
## Verification
`packages/next/src/server/pipe-readable.test.ts` drives real
backpressure (48 × 64 KiB through a throttled reader) and asserts
exactly one `drain` registration on the zlib stream, no
`MaxListenersExceededWarning`, and a complete, byte-correct payload
after `gunzip`. The payload assertion is what catches a latch that
stalls the stream.
Confirmed it fails without the fix. Measured against the real compiled
function on Next 16.3, patching only the drain handling:
| | before | after |
| --- | --- | --- |
| `drain` registrations on `[Gzip]` | 48 (one per backpressured write) |
**1** |
| `MaxListenersExceededWarning` | 1 | **0** |
| decompressed payload | complete | complete |
At 120 × 64 KiB the before-count scales with writes (120 registrations);
the after-count stays at 1. Separately reproduced end to end on a Next
16.3 standalone production build with `compress: true`: slow-consuming a
large gzipped page emitted the warning, and after this change it did
not, with `Content-Encoding: gzip` still set and valid gzip on the wire.
I could not run `pnpm test` in this checkout, so please treat CI as the
source of truth for harness integration. The assertions themselves were
validated against the real function as described above.
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Jude Gao <jude.gao@vercel.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>