Skip to content

node:net: defer unref() of a connecting socket to the connect event - #37087

Closed
robobun wants to merge 5 commits into
mainfrom
farm/e7076ac0/net-unref-before-connect
Closed

robobun wants to merge 5 commits into
mainfrom
farm/e7076ac0/net-unref-before-connect

Conversation

@robobun

@robobun robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #37086

Repro

import net from "node:net";

const server = net.createServer(() => {});
server.listen(0, "127.0.0.1", () => {
  const s = new net.Socket();
  s.unref(); // before connect()
  s.connect(server.address().port, "127.0.0.1", () => {
    console.log("CONNECTED");
    s.destroy();
    server.close();
  });
});
server.unref();

Node (and Bun 1.3.14) prints CONNECTED and exits 0. Bun 1.4.0 canary exits 0 silently before the connection completes. This breaks testcontainers: the Ryuk reaper calls socket.unref() before .connect(), so every testcontainers program exits during container startup.

Cause

Regression from #36619, which added if (self[kUserUnrefed]) handle.unref?.() to initSocketHandle so an autoSelectFamily retry handle inherits a prior unref(). But initSocketHandle also runs when connect() creates the initial handle, so a socket unref'd before (or during) connect() released its hold on the event loop while the connection was still pending. Node keeps the loop alive in that window because the pending uv_connect_t is an active request; Bun's handle has no request concept, so an immediate handle.unref() lets the process exit.

Fix

Socket.prototype.unref now defers to the connect event whenever the socket has no handle or is still connecting, and the immediate unref in initSocketHandle is removed. The deferred listener applies to whatever handle is current when connect fires, which covers the autoSelectFamily retry inheritance that the removed line was for. The listener re-checks kUserUnrefed (and ref()'s deferral re-checks symmetrically) so a ref() issued after a pending deferred unref() wins regardless of ordering.

This also fixes the existing unref survives an autoSelectFamily retry test in node-net.test.ts failing under the debug build when the refuse-and-retry takes longer than the test's 500ms sentinel timer: previously the process exited mid-retry without ever connecting.

Verification

test/regression/issue/37086.test.ts covers unref-before-connect, unref-while-connecting, and ref-after-unref ordering. The first two fail on bun 1.4.0 canary (process exits with no output) and pass with this change. Also ran the node net/tls suites (node-net.test.ts, node-net-server.test.ts, double-connect.test.ts, connect-autoselectfamily-stale-timer.test.ts, socket-reconnect-live.test.ts, node-tls-connect.test.ts, node-tls-server.test.ts, node-tls-upgrade.test.ts, node-net-allowHalfOpen.test.js): no new failures vs main.


[review] gate passed · iteration 2 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/37086.test.ts"
bun test v1.4.0 (10c051511)

test/regression/issue/37086.test.ts:
26 |     env: bunEnv,
27 |     stderr: "pipe",
28 |   });
29 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
30 |   expect(stderr).toBe("");
31 |   expect(stdout).toBe("CONNECTED\n");
                      ^
error: expect(received).toBe(expected)

- "CONNECTED
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/workspace/bun/test/regression/issue/37086.test.ts:31:18)
(fail) net.Socket#unref() before connect() does not exit before the connection completes [1262.42ms]
53 |     env: bunEnv,
54 |     stderr: "pipe",
55 |   });
56 |   const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
57 |   expect(stderr).toBe("");
58 |   expect(stdout).toBe("CONNECTED\n");
                      ^
error: expect(received).toBe(expected)

- "CONNECTED
- "
+ ""

- Expected  - 2
+ Received  + 1

      at <anonymous> (/works
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (d4ad783c9)

test/regression/issue/37086.test.ts:
(pass) net.Socket#unref() before connect() does not exit before the connection completes [22.68ms]
(pass) net.Socket#unref() while connecting does not exit before the connection completes [21.97ms]
(pass) net.Socket#ref() after a pre-connect unref() keeps the socket holding the loop [21.67ms]

 3 pass
 0 fail
 9 expect() calls
Ran 3 tests across 1 file. [228.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" "test/regression/issue/37086.test.ts"
bun test v1.4.0 (10c051511)

test/regression/issue/37086.test.ts:
(pass) net.Socket#unref() before connect() does not exit before the connection completes [1390.67ms]
(pass) net.Socket#unref() while connecting does not exit before the connection completes [1378.07ms]
(pass) net.Socket#ref() after a pre-connect unref() keeps the socket holding the loop [1369.22ms]

 3 pass
 0 fail
 9 expect() calls
Ran 3 tests across 1 file. [3.64s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 731ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/129] gen ErrorCode+*.h
[2/129] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[3/129] gen generated_host_exports.rs
generated_host_exports.rs: 92 exports (host=3, lazy=10, generic=79, rust=0); 239 extern-C blocks audited
[4/129] gen cpp.rs (cppbind)
[5/129] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[6/129] gen JS modules (bundle-modules)
Preprocess modules (8959ms)
Bundle modules (61ms)
Postprocesss modules (277ms)
Bundle Functions (731ms)
Generate Code (31ms)

[10.08s] Bundled "src/js" for production
  2571 kb
  193 internal modules
  13 native modules
  84 internal functions across 17 files
[6/128] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core
... (truncated)
diff hotspot
src/js/node/net.ts                  | 30 ++++++++++--
 test/regression/issue/37086.test.ts | 95 +++++++++++++++++++++++++++++++++++++
 2 files changed, 120 insertions(+), 5 deletions(-)

gate history · 3 passed · 0 rejected · iteration 2

evidence per changed file
file                                 reads  edits  tests
src/js/node/net.ts                       4      9      0
test/regression/issue/37086.test.ts      0      1      0

root cause · written by the author bot

When unref() was called before connect(), the recently added kUserUnrefed flag was recorded immediately and consulted by the connect path as soon as the native handle was created, so the handle was unreffed while the connection was still pending and the event loop had nothing keeping it alive, letting the process exit silently before the connect callback ever ran. Node instead defers a pre-connect unref until the connection is established, so the in-flight connection keeps the process alive. The fix restores that behavior by not applying the user's unref to the handle during connection setu…

Socket.prototype.unref applied kUserUnrefed to the handle as soon as it
was created (initSocketHandle), so a socket unref'd before or during
connect() released its hold on the event loop while the connection was
still pending and the process exited silently. Node keeps the loop alive
until the connect completes because the pending uv_connect_t is an
active request; our handle has no request concept, so the unref must
wait for the connect event.

unref() now defers via a flag-aware connect listener whenever the
socket has no handle or is still connecting, which also makes an
autoSelectFamily retry handle inherit the unref (the case the
initSocketHandle line was added for), and a later ref() correctly
cancels a deferred unref.

Fixes #37086
@coderabbitai

coderabbitai Bot commented Aug 7, 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: 3 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: a1289aa5-94e8-4278-8163-2886e498451a

📥 Commits

Reviewing files that changed from the base of the PR and between e3f9e3b and 10c0515.

📒 Files selected for processing (2)
  • src/js/node/net.ts
  • test/regression/issue/37086.test.ts

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

A keep-alive agent calls unref() on its free socket after every request;
when the socket still reports connecting, each call added another
once(connect) listener and tripped MaxListenersExceededWarning
(test-http-client-request-listeners-leak.js). The deferred listener
re-reads kUserUnrefed when connect fires, so one pending listener per
direction is enough.
Comment thread src/js/node/net.ts Outdated
Comment thread src/js/node/net.ts
Comment thread src/jsc/modules/NativeModuleList.h Outdated
Comment thread src/jsc/modules/NativeModuleList.h Outdated
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

CI status: the only red is test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js (SIGABRT, JSC !exception() assertion) on the x64-asan lane, which fails identically on the previous run, passes locally under an ASAN debug build of this branch, and is unrelated to this node:net change (reported separately for main triage). fetch-leak.test.ts passed on retry. The diff itself is green: the regression tests plus the node net/tls suites pass locally.

@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.

No bugs found. The fix is small and the root-cause analysis is convincing, but this changes event-loop ref/unref semantics on net.Socket (deferring handle.unref() past the connecting window and removing the immediate unref from initSocketHandle), which interacts with autoSelectFamily retries, TLS upgrade, and the pause/resume ref bookkeeping — worth a human sanity check.

What was reviewed:

  • Traced unref/ref deferral against all emit("connect") sites and connecting transitions; the deferred listener applies to the final handle after autoSelectFamily retries, so removing the initSocketHandle unref is covered.
  • Checked kPendingDeferred* guards prevent duplicate once listeners and that applyDeferredUnref/applyDeferredRef re-check kUserUnrefed so ordering (unref→ref before connect, and vice versa) resolves to the last call.
  • Ruled out: TLS-over-socket (kupgraded) path never emitting connect for the deferred listener — pre-PR code used the same once("connect", ...) pattern, so no new gap is introduced.
Extended reasoning...

Overview

Fixes a regression from #36619 where socket.unref() before or during connect() released the event loop hold while the connection was still pending, causing the process to exit silently before the connect callback ran (breaks testcontainers). The change touches only src/js/node/net.ts (~30 lines): Socket.prototype.unref now defers to the connect event when !this._handle || this.connecting, guarded by a new kPendingDeferredUnref flag so at most one listener is registered; ref() gets a symmetric kPendingDeferredRef guard; and the immediate handle.unref?.() in initSocketHandle (added by #36619) is removed. Three regression tests are added covering unref-before-connect, unref-while-connecting, and ref-after-unref ordering.

Security risks

None. This is event-loop-liveness bookkeeping in the Node net compat layer — no parsing of untrusted input, no auth/crypto, no new external surface.

Level of scrutiny

Medium-high. The diff is small and the mechanism is clear (Node keeps the loop alive via the pending uv_connect_t; Bun has no request concept so must defer the unref), but ref/unref correctness in node:net is subtle: getting it wrong produces either process hangs or premature exits, and the file already juggles kUserUnrefed, kPausedUnref, kended-driven re-refs, and autoSelectFamily handle swaps. I traced the new deferral against every emit("connect") site and every connecting = false transition — the deferred listener fires on the final handle after retries, so the removed initSocketHandle line is subsumed. The ref() path intentionally does not add || this.connecting (an immediate socket.ref() while connecting is harmless — new handles are ref'd by default — and kUserUnrefed = false neutralizes any pending deferred unref).

Other factors

  • The evidence block shows the new tests fail on the ASAN/debug build without the fix and pass with it; on the release build the loopback connect completed fast enough that the unfixed build also passed, so the regression is timing-dependent — but the fix is not.
  • The full node net/tls suite (node-net.test.ts, node-tls-connect.test.ts, autoSelectFamily tests, etc.) was reported green, and the existing unref survives an autoSelectFamily retry test still covers the case the removed initSocketHandle line was originally added for.
  • The one candidate the bug hunter raised (TLS-over-socket deferring to a connect event that never fires) was ruled out: the pre-PR code used the identical once("connect", ...) deferral, so no new gap.
  • Deferring rather than approving because event-loop-liveness changes in node:net are the kind of thing a maintainer familiar with the #36619 history should eyeball; the change looks correct to me.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. To confirm the two subtle points: the deferred listener fires on whatever handle is current when connect is emitted, so autoSelectFamily retries inherit the unref (the existing "unref survives an autoSelectFamily retry" test covers this and passes under the debug build with this change, where it failed before), and ref() stays immediate while connecting since a fresh handle is ref'd by default and clearing kUserUnrefed neutralizes any pending deferred unref. Ready for a maintainer look; the only CI red is the unrelated worker-terminate assertion noted above.

@robobun

robobun commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator Author

Data point for the existing-test part of this PR: on current main (8326d1b), a linux-x64 debug (ASAN) build fails test/js/node/net/node-net.test.ts "unref survives an autoSelectFamily retry" consistently (it failed on each of my runs, as well as in another run that reported it). The child's stdout is only timer: the refused ::1 attempt plus the 127.0.0.1 retry takes roughly 700ms under ASAN, so the 500ms sentinel fires while the unref'd socket is still connecting and the process exits before connect. Release builds finish the retry in about 20ms and pass.

With this PR's net.ts change applied on top of current main (it merges cleanly), the same test passes under the debug build, since the connecting socket holds the loop until connect and the deferred unref is applied to the retry handle. Without the change it fails, so that test also serves as coverage for the connecting-socket case here. It does not need a separate test-side change.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #39856.

#39856 fixes this at the native layer. The connect attempt holds the event loop until the socket is established, and a ref() or unref() issued before that point is applied when the socket opens. It also covers the pause() variants of the same problem (s.connect(); s.pause()), which this PR does not touch.

Nothing has landed on main yet, so #37086 stays open until #39856 merges.

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.

net.Socket#unref() called before connect() makes the process exit before the connection completes

1 participant