next.js
1780fa03 - fix: reuse a single drain listener when piping Node streams through gzip (#97698)

Commit
80 days ago
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>
Parents
Loading