Skip to content

fix(dev-proxy): stop leaking an error listener and a pending promise per request - #486

Open
Black-Break wants to merge 1 commit into
oss-compass:mainfrom
Black-Break:fix/dev-proxy-listener-leak
Open

Black-Break wants to merge 1 commit into
oss-compass:mainfrom
Black-Break:fix/dev-proxy-listener-leak

Conversation

@Black-Break

Copy link
Copy Markdown

Summary

src/pages/api/development/proxy/index.ts registered proxy.once('error', reject) inside the request handler and returned a new Promise that was never settled. Neither of those could ever happen, so the route leaked one event listener and one pending promise on every single request.

Fixes #.

The bug

http-proxy@1.18.1 routes upstream errors to the callback you pass to proxy.web(), and only emits 'error' when you don't pass one — lib/http-proxy/passes/web-incoming.js:

if (clb) {
    clb(err, req, res, url);
} else {
    server.emit('error', err, req, res, url);
}

The route passes a callback, so 'error' is never emitted. That makes both halves of the original code dead:

  1. proxy.once('error', reject) never fires, so it is never removed. The proxy server is a module-level singleton, so the listener count grows by one per request and trips Node's default maxListeners of 10.

  2. The returned promise never settles. The callback is invoked only on failure (on success the response is piped directly at web-incoming.js:187), and even on failure it only did console.log — it never called resolve or reject. Every request therefore left a pending promise and its closure behind.

Measured with the added test, over 20 requests: listener count went 1 → 20. Node warns at 11.

The fix

  • Register one module-level proxy.on('error', ...) instead of a per-request once. That keeps the count flat, and it still covers the one path that does emit — web() with a missing or invalid target (lib/http-proxy/index.js:69), where an unhandled 'error' event would otherwise crash the process.
  • Return void from the handler. externalResolver: true means Next.js does not wait on the return value, and http-proxy writes the response itself, so there is nothing to await.

Listener count over 20 requests: 1 → 1.

Verification

$ jest --testPathPattern 'development/proxy'
  √ creates a single shared proxy server
  √ keeps exactly one "error" listener regardless of request count
  √ settles its return value instead of leaving a pending promise
Tests: 3 passed, 3 total

Before the fix, both behavioural tests fail on their assertions:

  ● keeps exactly one "error" listener regardless of request count
    Expected: 20
    Received: 1
  ● settles its return value instead of leaving a pending promise
    Expected: "settled"
    Received: Symbol(pending)

Full suite and the other CI gates:

$ jest --ci
Test Suites: 1 failed, 27 passed, 28 total
Tests:       1 failed, 115 passed, 116 total

$ tsc --noEmit     # exit 0
$ next lint        # exit 0, no warnings
$ next build       # exit 0 on Ubuntu (see note)

The single failing test is pre-existing and unrelated: src/modules/intelligent-analysis/server/pathSafety.test.ts asserts against the POSIX path /srv/compass/public/test/..., which cannot resolve on Windows. It fails identically on an unmodified checkout.

next build cannot complete on this Windows machine — it fails with EMFILE: too many open files during Collecting build traces. I confirmed this reproduces on a clean main with zero modifications, so it is environmental and not caused by this change.

…per request

The proxy route registered `proxy.once('error', reject)` inside the handler
and returned a `new Promise` that was never settled.

Neither could ever happen. `proxy.web()` only routes upstream errors to its
callback when one is supplied -- see the `if (clb) { clb(err, ...) } else {
server.emit('error', ...) }` branch in
lib/http-proxy/passes/web-incoming.js -- so the `'error'` event is never
emitted and the `once` listener is never removed. The shared module-level
proxy server therefore gained one listener per request until Node's default
maxListeners of 10 tripped MaxListenersExceededWarning.

On the success path the callback is never invoked at all (the response is
piped directly), so `resolve` was never called and every request left a
pending promise and its closure behind.

Register a single module-level `'error'` listener instead: it keeps the
listener count flat and still covers the one path that does emit -- `web()`
with a missing or invalid target (lib/http-proxy/index.js) -- where an
unhandled `'error'` event would otherwise crash the process. The handler now
returns void, which is what `externalResolver: true` expects anyway.

Listener count over 20 requests: 1 -> 20 before, 1 -> 1 after.

Adds three tests covering the shared server, the flat listener count, and the
handler settling its return value.

Signed-off-by: Black-Break <134817940+Black-Break@users.noreply.github.com>
@vercel

vercel Bot commented Oct 6, 2026

Copy link
Copy Markdown

@Black-Break is attempting to deploy a commit to the codersett's projects Team on Vercel.

A member of the Team first needs to authorize it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant