node:net: keep the process alive while an early-unref'd/paused socket is still connecting

Merged
JA
3mo ago
+138-49

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 print CONNECTED; Bun 1.4.0 exits 0 silently before the connection completes. This is what makes every testcontainers program exit during container startup (its Ryuk client calls socket.unref() before .connect()). The same happens for s.connect(); s.unref() and s.connect(); s.pause().

Cause

In Node a pending uv_connect_t keeps the loop alive no matter what the handle's ref state is; unref()/readStop only matter once the socket is established. Bun's native socket instead had a ref_pollref_on_connect flag that made an early unref() skip taking the loop hold for the connect attempt altogether, and an unref() on a connecting handle dropped it immediately. #36619 started applying node:net's recorded unref() to the freshly created (detached) handle, which routed the common "unref before connect" case into that path.

Fix

Native (Listener.rs / socket_body.rs): the connect attempt always holds poll_ref (taken before do_connect, released by every failure path); ref()/unref() on a not-yet-established socket only record the preference and on_open applies it. pause()/resume() on a half-connected socket are likewise left for after the open (uws_sys/socket.rs) — usockets re-arms reads on open without clearing its paused bit, so a pause latched there made every later pause() a no-op and a paused stream buffered without bound once connected.

net.ts: an autoSelectFamily retry handle inherits a mid-connect pause() the same way it inherits unref(), and the read(0) that connect() queued on the next tick is left to afterConnect while still connecting, so a pause() issued mid-connect is not undone by a read queued before it (Node only issues that read from afterConnect).

Tests

test/js/node/net/node-net.test.ts:

  • unref()/pause() around connect(): unref/pause before connect(), right after it, and while the native connect is actually in flight → connected then exit; ref() after unref() (both timings) keeps holding; an in-flight pause() still lets backpressure stop reads. The unref/pause cases print nothing (or hang) on 1.4.0; all match Node's output.
  • unref survives an autoSelectFamily retry no longer needs its 500 ms sentinel timer (the pending connect holds the loop by itself — that sentinel was also why it failed under slow debug/ASAN builds) and gains a pause() variant, which hung before this change.

Also ran node-net-server, node-tls-connect, node-tls-server, node-http, socket-reconnect-live, double-connect, bun/net/socket.test.ts and the node parallel net pause/connect tests locally with no new failures.