Skip to content

node:net: remove the listen({ signal }) abort listener when the server closes - #37957

Closed
robobun wants to merge 1 commit into
mainfrom
farm/12e1fc32/net-server-listen-signal-listener-leak
Closed

robobun wants to merge 1 commit into
mainfrom
farm/12e1fc32/net-server-listen-signal-listener-leak

Conversation

@robobun

@robobun robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • net.createServer().listen({ signal }) registers an 'abort' listener on the signal and never removes it, so server.close() leaves the listener (and through its closure, the whole Server) reachable from the signal for as long as the signal lives.
  • With one long-lived signal (a process-wide shutdown controller) and listen/close churn, listeners and retained servers grow without bound: 2000 listen+close cycles leave 2000 'abort' listeners on the signal and about +126 MB RSS on a release build. Node leaves 0.
  • A later abort() on that signal also calls close() on every server that was already closed, and each of them emits a second 'close' event.
  • Cause: addServerAbortSignalOption() in src/js/node/net.ts does a bare signal.addEventListener("abort", onAborted) with no matching removal.

Fix

  • addServerAbortSignalOption() now registers through internal/abort_listener's addAbortListener() (a once listener returning a disposable) and disposes it in server.once("close", ...), which is what node's lib/net.js does and what net.Socket already does for connect({ signal }) in this file.
  • The abort path is unchanged: while the server is listening there is exactly one listener, and aborting still closes the server (covered by the existing "should call abort with signal" test). After 'close' the listener is gone, so nothing holds the server and a later abort is a no-op for it.
  • Verified with test/js/node/net/node-net-server.test.ts (new test "listen({ signal }) removes its abort listener from the signal once the server closes"): fails on the current release (1, 2, 2, 3, 3 listeners accumulate), passes with this change (1, 0, 1, 0, 1, 0, and 3 close events for 3 servers).
  • Also ran test/js/node/test/parallel/test-net-server-listen-options-signal.js, test-net-server-listen-options.js, test-net-server-close.js, test-net-server-async-dispose.mjs, test-net-server-call-listen-multiple-times.js, test-net-server-try-ports.js, and test/js/node/net/server.spec.ts against the debug build: all pass.

Background

  • listen({ signal }) lets an AbortSignal close a listening server: aborting the signal calls server.close(). The signal holds its listeners strongly, so a listener that closes over a server keeps the server alive until either the listener is removed or the signal itself is collected.
  • internal/abort_listener is a small helper mirroring node's events.addAbortListener(): it adds the 'abort' listener with once: true and returns an object whose [Symbol.dispose]() removes the listener, so callers can detach it when the thing it protects goes away first.
  • tls.Server shares this listen() implementation, so it gets the same fix.
Reproduction (release bun 1.4.0 vs node v26.3.0)
import net from "node:net";
import { getEventListeners } from "node:events";

const ac = new AbortController();
const { signal } = ac;
const counts = [];
let closeEvents = 0;
for (let i = 0; i < 5; i++) {
  const srv = net.createServer();
  srv.on("close", () => closeEvents++);
  await new Promise(r => srv.listen({ port: 0, host: "127.0.0.1", signal }, r));
  counts.push(getEventListeners(signal, "abort").length);
  await new Promise(r => srv.close(r));
  counts.push(getEventListeners(signal, "abort").length);
}
ac.abort();
await new Promise(r => setTimeout(r, 50));
console.log(JSON.stringify({ counts, closeEvents }));
bun 1.4.0:        {"counts":[1,1,2,2,3,3,4,4,5,5],"closeEvents":10}
node v26.3.0:     {"counts":[1,0,1,0,1,0,1,0,1,0],"closeEvents":5}
bun + this patch: {"counts":[1,0,1,0,1,0,1,0,1,0],"closeEvents":5}

no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/node/net/node-net-server.test.ts

@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 2 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 1d31e730-11c5-4201-897c-3e932a953017

📥 Commits

Reviewing files that changed from the base of the PR and between f426a8e and 4603836.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/js/node/net/node-net-server.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:16 PM PT - Aug 12th, 2026

❌ @robobun, your commit 4603836 has some failures in Build #93707 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37957

That installs a local version of the PR into your bun-37957 executable, so you can run:

bun-37957 --bun

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed, not needed. main already has this fix through #43557 (5d5f03f). The closing comment below has the check.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — direct port of Node's addServerAbortSignalOption cleanup, mirroring the pattern already used for connect({ signal }) in the same file.

What was reviewed:

  • Confirmed addAbortListener is the existing lazy module-level var (net.ts:259) and internal/abort_listener returns a disposable whose [Symbol.dispose] doesn't depend on this, so passing it bare to once('close', ...) is safe.
  • Checked the abort-then-close ordering: once: true removes the listener on abort, and the subsequent 'close' → dispose call is a no-op via removeEventListener?.().
  • Test awaits real events (no sleeps), asserts exact listener counts and close-event count, and per the PR description fails on the unfixed release build.
Extended reasoning...

Overview

Four-line change to addServerAbortSignalOption() in src/js/node/net.ts: instead of a bare signal.addEventListener('abort', onAborted) that is never removed, it now goes through internal/abort_listener's addAbortListener() (a once listener returning a disposable) and detaches it via self.once('close', disposable[Symbol.dispose]). This is byte-for-byte what Node's lib/net.js does and what net.Socket#connect({ signal }) already does at net.ts:1755-1759 in this same file. A new test in node-net-server.test.ts verifies listener counts go 1,0,1,0,1,0 across three listen/close cycles on a shared signal and that a subsequent abort() does not re-emit 'close'.

Security risks

None. This is listener-lifecycle bookkeeping in a Node compat module — no auth, crypto, parsing of untrusted input, or filesystem/network boundary changes.

Level of scrutiny

Low. The diff is tiny, mechanical, and copies an established in-file pattern verbatim. I verified the addAbortListener module-level lazy var already exists, that the disposable's [Symbol.dispose] closes over removeEventListener rather than reading this (so the EventEmitter receiver binding is harmless), and that the abort→close ordering doesn't double-remove (the once: true listener self-removes on abort; the later dispose is removeEventListener?.() which no-ops). The already-aborted branch (process.nextTick(onAborted)) is unchanged.

Other factors

The test is well-constructed per the repo's review guidance: it awaits 'listening'/'close' events rather than sleeping, asserts exact arrays with toEqual, covers both the leak (listener count) and the observable symptom (duplicate 'close' events after abort()), and the PR description demonstrates it fails on release Bun 1.4.0 and matches Node v26.3.0 with the fix. The existing "should call abort with signal" test in the same file continues to cover the abort-while-listening path. tls.Server inherits this listen() so it's fixed transitively. No CODEOWNERS entry covers this path.

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: main already has this fix.

#43557 (5d5f03f) moved addServerAbortSignalOption() to src/js/internal/net/server_abort_signal.ts. That version registers the listener with addAbortListener() and disposes it on the server's 'close'. This PR makes the same change. #43557 also added a test for this behaviour to test/js/node/net/node-net-server.test.ts: "should stop listening to the signal once the server has closed".

I checked main at 5c6d4e2 with a debug build:

  • The test from this PR, ported onto main, passes. The full file gives 28 pass, 0 fail.
  • The script from the report leaves 0 'abort' listeners after 2000 listen and close cycles.
  • net.Server, tls.Server, http.Server and https.Server all give the same listener counts as Node v26.3.0.

On a build from before #43557 (367d939), the test fails and the script leaves 2000 listeners.

#46522 also lists this PR as already fixed on main. Reopen this PR if something is missing.

@robobun robobun closed this Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants