node:net: let the GC collect a handle whose socket is destroyed before it connects
Merged
RO
2mo ago+164+164+012345678901234567890123456789-19-19-01234567890123456789
Problem
- A
node:net/node:tlssocket destroyed before its connect starts keeps itsTCPSocket/TLSSocketwrapper (and itsnet.Socket) alive forever. 1000net.connect()+destroy()per round leave 1001, 2001, 3001TCPSocketobjects after full GC.destroy()during the DNS lookup, an earlyAbortSignal, and aconnect()that throws leak the same way. - So does every failed name lookup: 100
net.connect({ host: "does-not-exist.invalid" })per round leave 101 then 201TCPSocketon stock bun, 1 then 1 with this change. A reconnect loop during a DNS outage grows without bound. - Cause:
new_detached_socket(src/runtime/node/node_net_binding.rs) creates the wrapper throughget_this_value, which holds it strong. Only a connect attempt's teardown downgrades that reference. A handle that never connects reaches no teardown:_handle.close()on a detached socket is a no-op.
Fix
- The fixing line:
new_detached_socketnow creates the idle handle withJsRef::init_weak.net.Socket._handleowns it until a connect starts. connect_finishalready upgraded the reference for each attempt. That moves intoNewSocket::this_value_for_connect, and the Windows named-pipe connect arms now call it too (they did not upgrade before).- Correct because the reference is now strong exactly while native work is pending, like other
JsRefusers (Bun.spawn, Valkey) that start weak. - Verified: new test in
test/js/bun/net/socket-retention.test.tsfails on stock bun 1.4.3-canary (101TCPSocket/ 26TLSSocketretained,protectedObjectTypeCounts100 / 25) and passes on the debug ASAN build (1 / 1, protected 0 / 0). It asserts both the object counts and that noTCPSocket/TLSSocketis held by a strong handle. Other suites in the notes, Linux and Windows.
Related
- Supersedes "Fix 2: never-connected sockets pinned forever" (
a812bee39a) in draft #35538. Same root cause; that commit makesget_this_valuetake a weak reference whenever the socket is detached. This PR instead makes the weak reference explicit at the one creation site and leavesget_this_valueunchanged, andthis_value_for_connectalso pins the two Windows named-pipe connect arms, which anis_detached()gate inget_this_valuewould leave weak duringuv_pipe_connect. That hunk can be dropped from #35538 once this lands. - #34441 recorded the symptom: upstream
test-gc-net-timeout.jsnot collecting all of its sockets. - Upstream
test-gc-net-timeout.js(the testa812bee39avendors) is not added here. It passes with this change on every lane of build 114199 except alpine 3.23 x64, where it timed out in 4 of 4 attempts. It passes here on glibc x64 with a release build of this branch: 5 of 5 runs, 16 of 16 pinned to 1, 2, 4 and 8 CPUs, and withlocalhostforced to two addresses where the first one fails. It observes collection through timing (a socket leaks only when its 10 ms timeout wins the race with the connect), like the tests #35182 deleted. The guard that fails on stock bun every time is thesocket-retention.test.tstest above.
Not covered
destroy()from inside a'connectionAttempt'listener (#42307). net.ts nulls_handlebeforekConnectTcpruns, sodoConnect(null, ...)allocates a fresh native socket that nothing owns, and it stays strong-rooted and connected. Separate root cause insrc/js/node/net.ts, reproduces the same on stock bun, not changed here.- Windows named pipes: a
destroy()before the pipe open completes (traced statically only). The second named-pipe path found here, the attempt ref that a synchronousWindowsNamedPipeContext::connect/openfailure never released (#42309), is fixed on main by #42317. This branch has main merged in, and both changes are in the two named-pipe arms ofListener::connect_inner. - No test fails if the two named-pipe arms in
Listener::connect_innergo back toget_this_value. The upgrade there is what keeps a firstnode:netpipe connect alive acrossuv_pipe_connectwhen JS drops the socket; the Windows leak tests listed below check the opposite direction (nothing stays pinned). - Nothing asserts that
this_valueis strong where a native attempt starts (do_connect,WindowsNamedPipeContext::connect/open). Adebug_assert!there would make every debug net test check it; left for a follow-up.
Background
NewSocket<SSL>(socket_body.rs) is the native side of aTCPSocket/TLSSocket. Itsthis_value: JsRefisStrong(aStrongRootBlockroot) orWeak. The wrapper's finalizer derefs the native socket.Socket.prototype.connectcallsnewDetachedSocket()and stores it in_handlebefore the lookup and theprocess.nextTickthat starts the connect.doConnect(_handle)later reachesconnect_finishwith it asprev.- A detached handle has no
us_socket_t, so closing it dispatches nothing native.
Notes
- Heap snapshot (
generateHeapSnapshotForDebugging) of the retained cells before the fix: eachTCPSockethas exactly one incoming edge,InternalfromStrongRootBlock, which is a GC root. No JS retainer.heapStats().protectedObjectTypeCountscounts the same strong handles, which is what the test asserts on. - Scenarios measured on stock bun 1.4.3-canary (200 sockets each, count after GC): immediate destroy 201,
tls.connect+ destroy 201TLSSocket, destroy during lookup oflocalhost201, destroy in the'lookup'event 201,AbortSignalabort 201,connect()throwingERR_SOCKET_BAD_PORT201. Sockets that connect and then close (destroy after'connect', server-side destroy,end(), ECONNREFUSED) do not leak: 1. The constant 1 is the class's prototype object, whichheapStats()counts under the same name. - With the fix all of the above report 1 on the debug ASAN build, and the reporter's 1000-per-round loop stays at 1.
- The terminal paths that downgrade a connect attempt's reference:
mark_inactiveafteron_close,handle_connect_error, and the semi-socket branch ofclose()/terminate(). - What keeps an unreferenced
net.Socketalive before its connect starts: theprocess.nextTickclosure (literal IP) or the lookup callback (hostname) captures it. Probes: 200net.connect()calls with no JS reference kept,Bun.gc(true)forced between creation and the deferred connect and on a 1 ms interval. All 200 connect, receive data, and close (also withBUN_GARBAGE_COLLECTOR_LEVEL=2). The same probe withhost: "localhost"(with and withoutautoSelectFamily) and with a custom asynclookup: 100 of 100 each. - The test lives in
socket-retention.test.tsbecause that file is the guard for theJsRefretention model and already drives it throughnode:netin a child process. - Suites run on the Linux debug ASAN build:
test/js/bun/net/socket-retention.test.ts,test/js/bun/net/socket.test.ts,test/js/node/net/node-net.test.ts(the 10 failures left are environment failures that stock bun shows too:localhostresolution and no outbound DNS),handle-leak.test.ts,net-mongodb-pattern-leak.test.ts,double-connect.test.ts,socket-reconnect-live.test.ts,connect-autoselectfamily-stale-timer.test.ts,node-tls-connect.test.ts,node-tls-upgrade.test.ts, 53test-net-*and 38test-tls-*Node parallel tests. - Windows x64 debug build: the new test (fails on stock bun there too, 101),
should work with named pipes(700 pipe connections,TCPSocketcount check),should not leak when connect({path}) fails asynchronously while polling for a pipe, theBun.connectnamed-pipe lifecycle tests insocket.test.ts, andnode-tls-namedpipes.test.ts(400 TLS pipe connections,TLSSocketcount at most 3) pass. The Windows runs predate theprotectedObjectTypeCountsassertion and the comment trims, which change no compiled code. cargo check --target x86_64-pc-windows-msvcpasses for the#[cfg(windows)]arms.
no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/net/socket-retention.test.ts