Skip to content

node:net: refuse connect() where node's handle refuses it - #45346

Open
robobun wants to merge 3 commits into
mainfrom
robobun/8b414cec/net-connect-live-handle
Open

robobun wants to merge 3 commits into
mainfrom
robobun/8b414cec/net-connect-live-handle

Conversation

@robobun

@robobun robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A connect() on a net.Socket whose handle still owns a socket resets that connection with no event and dials again. Node v26.3.0 refuses most such calls: EALREADY in flight, EISCONN for an accepted socket and after end(), EINVAL for a path on a TCP socket.
  • connect_finish calls detach_for_reconnect for every handle (src/runtime/socket/Listener.rs:1613, node:net: handle socket.connect() on a socket that still has a live native handle #32739). Found by a fuzz run, not by a user.

Fix

  • Listener::connect_inner asks the handle first (NewSocket::connect_refusal). Where node's handle refuses, ours answers the same errno from the state of its socket. node:net destroys the socket with it and dials nothing.
  • Unchanged: a connected TCP client socket that has not sent its FIN. Node emits 'connect' again there on Linux. That case needs a decision (Notes).
  • Verified: test/js/node/net/socket-reconnect-live.test.ts. Node prints the same for 12 scenarios.
  • Self-reviewed: 37 concerns raised, 29 addressed, 8 left for follow-ups (Notes).

Background

  • libuv allows one connect per TCP handle, then calls connect(2) on the handle's fd. The kernel answers EISCONN for an accepted socket and after a shutdown.
  • Considered: refuse every handle that owns a socket. That destroys sockets that node keeps on Linux.

Downsides

  • A program that calls connect() twice in a row now gets EALREADY, as in node. On 1.4.x it got a second dial (bun crashes when JackdClient becomes out of scope #21524's program is one).
  • For a name with two addresses node closes the socket with no event and dials the next address. This PR stops.
  • Per fresh dial: +2 bytecode and +29 native instructions, no new syscall or allocation. Binary text: +1,255 bytes.
Notes

Which calls are refused

connect() on a handle that still owns a socket. "Node" is v26.3.0 on Linux x64. "Before" is main.

the handle has the call node before this PR
a TCP connect in flight address EALREADY resets, dials again EALREADY (EISCONN on Windows)
a TCP connect in flight, or a TCP connection path EINVAL resets, dials the path EINVAL
an accepted socket address EISCONN resets, dials EISCONN
a socket that sent its FIN (end()) address EISCONN resets, dials again EISCONN
any socket localAddress or localPort bind EINVAL resets, dials again bind EINVAL
a unix socket, connected or in flight path or address what connect(2) says for the new path (EISCONN, ENOENT), one loop turn later closes, dials again EISCONN in the call
any socket { fd } (Bun only) resets, adopts the fd open EBUSY
a connected TCP client socket that has not sent its FIN address 'connect' again on the same connection resets, dials again unchanged

The case this PR leaves, and the question for a maintainer

A connected TCP client socket that has not sent its FIN (also after the peer's FIN, and a TLSSocket). On Linux connect(2) answers 0 for it once, so node emits 'connect' again and keeps the connection. Other kernels answer EISCONN. Bun resets the connection and dials a new one (detach_for_reconnect). The fuzz run compares against node, so it still reports this case.

@Jarred-Sumner, which of these should the follow-up be?

  • (a) Keep the second dial, and close the first connection with a FIN, not a reset. One token: CloseCode::Failure to CloseCode::FastShutdown in detach_for_reconnect (src/runtime/socket/socket_body.rs:1414 on main).
  • (b) Refuse with EISCONN from the state, as this PR does for the other handles. It deletes detach_for_reconnect (about 45 lines). It is node's answer on macOS and Windows. On Linux it destroys a socket that node keeps: a client that calls connect() from its 'end' listener with no 'error' listener exits with an uncaught EISCONN (measured on an earlier draft of this PR).
  • (c) Ask the kernel, as libuv does: call connect(2) on the fd of the handle, and emit 'connect' again when it answers 0. It is node's answer on every kernel. An earlier draft did it with a new uSockets poll type (about 440 lines). A sketch with no poll type is about 90 lines, and it is not built.

The second part of the question is the walk over the addresses of a name (next section): stop, as here, or go on as node does.

Where this PR differs from node

  • A name with two addresses (connect(port) with the default host is one). After a refusal node goes on to the next address: kReinitializeHandle closes the socket of the handle with no event, and the next address is dialed on a new handle. This PR destroys the socket with the last error of the walk and dials nothing. The check is at the one call of kReinitializeHandle in internalConnectMultiple.
  • A unix socket. Node reports the errno of connect(2) for the new path with the request, one loop turn later, and emits 'connectionAttemptFailed'. This PR answers EISCONN in the call, also for a path that is not there (node: ENOENT).
  • Windows. A TCP connect in flight answers EISCONN: libuv has no check of its own there and reports what ConnectEx says (node:net: fail a TCP connect with EINVAL or EALREADY where libuv does #41530 has the same answer). A named pipe answers EISCONN in the call, where libuv reports EBUSY with the request. I ran nothing on Windows or macOS.
  • connect(port); connect({ path }) in one tick. Node's handle is a TCP handle from the first call, so the path is EINVAL. Here the handle has no kind before its first dial: the path is dialed, and the first call then gets EISCONN.
  • A write made while the refused connect() waits for its lookup fails with ERR_SOCKET_CLOSED before 'error'. In node it fails with ERR_SOCKET_CLOSED_BEFORE_CONNECTION after 'error'.
  • The message of a refused connect names the local address, as node's does. A fresh dial that fails in the call still has no - Local (...) part: its handle has no socket left.

Tests

  • socket-reconnect-live.test.ts keeps the three tests of main. The third one ("while the first connect is still in flight") accepted connect or EALREADY. It now expects the refusal, and it checks that the trace span of the refused connect ends. The three now run one at a time: a debug build needs seconds to start each of these processes.
  • The new scenarios are in socket-reconnect-live-fixture.mjs, a file that node runs too. 12 scenarios print the same in node v26.3.0 on Linux, and the test runs them in node where node is installed (not on Windows).
  • Six scenarios pin the case that this PR leaves: the socket still emits 'connect' again.
  • node-net.test.ts: "should allow reconnecting after end()" connected again 3 ms after end(). It now waits for 'close': before that the handle owns the socket, and the call is EISCONN (in node too).
  • On a release build of main (5f5799e), 20 of the 41 tests fail. The 20 that pass are the 12 node rows, the two tests of main that this PR does not touch, and the six scenarios of the case that this PR leaves.

Suites run (debug build, Linux x64)

  • On this branch: test/js/node/net/ (12 files: 329 pass, and the 2 that fail need the public internet). Vendored test-net-*, test-pipe-*, test-socket-*, test-tls-* (354 files: one fails, test-tls-client-allow-partial-trust-chain.js, which needs bun test).
  • On the same change before a rebase (main 7583003): test/js/bun/net/ (11 files), test/js/node/tls/ (28 files), vendored test-http-*, test-https-*, test-http2-* (719 files). What failed there also fails on a debug build of main: tests that need the public internet, test-http-abort-queued, test-http-agent-free-socket-data-guard, test-http-agent-keepalive, test-http-client-timeout-option, test-http-outgoing-end-cork, test-https-timeout, and test-http-client-close-with-default-agent (a 1 s timer, 2 of 5 runs).
  • bun run rust:check-all: 12 targets pass.

Cost (release builds, main 5f5799e and this PR)

  • Syscalls per 1000 fresh net.connect() dials, both builds: 1000 socket, 1000 connect, 3002 epoll_ctl, 2000 setsockopt, 1000 close. Per 1000 Bun.connect() dials, both builds: the same with 3000 setsockopt. Counted with an LD_PRELOAD interposer.
  • Bytecode (BUN_JSC_dumpGeneratedBytecodes=1): kConnectDispatch 26 to 33 instructions, and a fresh dial executes 2 more (is_number, jfalse). Socket.prototype.connect 470 to 484 and internalConnect 151 to 164: a fresh dial executes none of the new ones (they are in the fd, bind and error arms).
  • Native (objdump of bun-profile): a fresh node:net dial executes 29 more instructions and 3 conditional branches in connect_inner (30 and 4 for a TLSSocket), 21 of them in connect_refusal (288 bytes). Bun.connect executes 4 more instructions, 2 branches. connect_inner grows from 2,806 to 2,993 bytes.
  • readyState reads: 0 in 200 fresh net.connect() and 200 fresh tls.connect() dials on both builds (gdb breakpoint count). A dial by a name whose first address fails reads it once per later attempt (50 in 50 dials, 0 on main).
  • Heap after 10,000 fresh dials and two full collections: 373 and 373 more cells on main, 372 and 325 on this PR.
  • size: text 106,006,149 to 106,007,404 bytes (+1,255). The file is 106,178,312 bytes on both.
  • perf, strace, valgrind and bloaty are not on the machine. The instruction counts are from the listings, not from a counter.

Self-review

37 concerns were raised against an earlier draft that refused every handle.

  • 29 addressed: 11 in code or tests, 11 in this text, and 7 by removing the two hunks they were about (a call-time check that kept the state of a TLS session, and a change to the close handlers for a write that waits for 'connect').
  • 5 are about the connected client socket, which this PR does not change. They go to the follow-up.
  • 3 are other bugs: the first three under "Found on the way".

Related open PRs

Found on the way, not in this PR

  • A writable event clears socket.connecting. A connect(hostname) on a live socket then ends with no event when the socket drains before the lookup answers. Handed off.
  • connect({ socket }) on a TLSSocket with a live session replaces its handle with no event: a second 'secureConnect', and two connections. Node throws ERR_MISSING_ARGS. Unchanged here.
  • A client socket whose stream has ended (the peer's FIN) dials again, as before, and the data of the new connection is not delivered. test: wait for 'close' before reconnecting in node-net.test.ts #38980 is about that state.
  • socket.connect({}) throws ERR_MISSING_ARGS and leaves a live socket in connecting. Handed off.

no test proof · iteration 4 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/net/socket-reconnect-live.test.ts, test/js/node/net/node-net.test.ts

A connect() on a net.Socket whose native handle still owns a socket
closed that socket with a reset, with no event, and dialed a new
connection on the same handle. Node refuses most of these calls.

Listener::connect_inner now asks the handle first. For the calls that
node's handle refuses, NewSocket::connect_refusal answers with the same
errno from the state of the socket: EALREADY while a TCP connect is in
flight, EINVAL for a path on a TCP socket, EISCONN for an accepted
socket, for a socket that sent its FIN, and for a unix socket, and
EBUSY for an fd. node:net destroys the socket with that error. Nothing
is closed without an event and nothing is dialed.

A connected TCP client socket that has not sent its FIN keeps what it
did before: node emits 'connect' again for it on Linux.

In net.ts a bind on a handle that owns a socket is EINVAL, and the walk
over the addresses of a name stops when the handle still owns a socket.
@robobun

robobun commented Oct 15, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:54 AM PT - Oct 15th, 2026

✅ @robobun, your commit a6a786991cfceddc2b96c6d55e73dd190565bdc9 passed in Build #126022! 🎉


🧪   To try this PR locally:

bunx bun-pr 45346

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

bun-45346 --bun

@robobun

robobun commented Oct 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft. The code and the tests are final. The cost numbers are still to come (release builds of main and of this branch).

How I reproduced it, on Linux x64:

import net from "node:net";
const server = net.createServer(c => c.on("error", () => {})).listen(0, "127.0.0.1", () => {
  const s = new net.Socket();
  s.on("connect", () => { console.log("connect"); s.destroy(); server.close(); });
  s.on("error", e => { console.log(e.code); server.close(); });
  s.connect(server.address().port, "127.0.0.1");
  s.connect(server.address().port, "127.0.0.1");
});
  • node v26.3.0 prints EALREADY.
  • bun 1.4.3 and main print connect, after a reset of the first connection.
  • This branch prints EALREADY.

test/js/node/net/socket-reconnect-live.test.ts has this and the other refused calls: 40 pass on the branch (debug build), 22 fail on bun 1.4.3-canary.1+367d939d9.

Comment thread src/js/node/net.ts Outdated
Comment on lines +1858 to +1859
// 'e' across the afterConnect(Multiple) / rejected-promise / attempt-timeout /
// refused-by-the-handle paths.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is back to its text on main in a6a7869.

Comment thread src/js/node/net.ts Outdated
Comment on lines +3440 to +3442
// https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1161-L1170
// Node names the local address of every handle whose connect fails in the call. Here a handle whose own
// dial failed has no socket left: only a handle that refused the connect has an address to name.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/js/node/net.ts Outdated
Comment on lines +3473 to +3474
// The handle refused the last attempt, or was never asked (a bind, a blocked address), and still owns
// its socket. Node closes that socket here without an event and dials the next address on a new handle.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/runtime/socket/Listener.rs Outdated
Comment on lines +1214 to +1215
// A `node:net` handle that refuses the connect dials nothing: the caller gets the errno by
// value. Past the last option getter: script cannot give the handle a socket after this.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment on lines +1391 to +1392
/// The errno of a `connect()` that node's handle refuses when it still owns a
/// socket. `None`: the caller dials. Nothing is closed or dialed for an answer.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment on lines +1415 to +1416
// TCPWrap::Connect parses its address as IPv4 before libuv sees the handle:
// https://github.com/nodejs/node/blob/v26.3.0/src/tcp_wrap.cc#L433-L465

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment on lines +1426 to +1427
// One connect per handle: https://github.com/nodejs/node/blob/v26.3.0/deps/uv/src/unix/tcp.c#L288-L289
// libuv has no such check on Windows, where ConnectEx answers EISCONN.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment on lines +1434 to +1435
// A client socket that has not sent its FIN: connect(2) answers 0 for it once on
// Linux, and node emits 'connect' again. `connect_finish` swaps the connection.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

The comment is one line in a6a7869.

@robobun
robobun marked this pull request as ready for review October 15, 2026 15:11
@coderabbitai

coderabbitai Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 12 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 937c3768-c03c-401d-bbb2-02a03778ab36
📥 Commits

Reviewing files that changed from the base of the PR and between a407eae and a6a7869.

📒 Files selected for processing: 6
  • src/js/node/net.ts
  • src/runtime/socket/Listener.rs
  • src/runtime/socket/socket_body.rs
  • test/js/node/net/node-net.test.ts
  • test/js/node/net/socket-reconnect-live-fixture.mjs
  • test/js/node/net/socket-reconnect-live.test.ts
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Comment thread src/js/node/net.ts
Comment on lines +3475 to +3477
if (ownsSocket(self._handle)) {
self.destroy(context.errors[context.errors.length - 1]);
return;

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.

🔴 Callers whose host resolves to two addresses now get an EALREADY/EISCONN error and a destroyed socket where both Node and the base branch deliver 'connect'. At src/js/node/net.ts:3475-3477 the multi-address walk destroys the socket with the last error as soon as the handle refuses one attempt, instead of closing that socket silently and dialing the next address as Node's kReinitializeHandle path does. Fix: on a refusal with addresses still left, close the handle's socket without emitting an event and continue the walk, destroying only when the address list is exhausted. Trigger: net.connect(port) with the default host (localhost -> ::1 and 127.0.0.1) called twice while the first dial is in flight (#21524's program).

Why this was flagged

A net.Socket whose handle still owns a socket calls connect() with a host that the lookup resolves to more than one address, so lookupAndConnect takes internalConnectMultiple (src/js/node/net.ts:3448). The first attempt is refused by NewSocket::connect_refusal (src/runtime/socket/socket_body.rs:1393) with EALREADY or EISCONN, kConnectTcp returns the errno, the error is pushed and internalConnectMultiple recurses (src/js/node/net.ts:3551). With current > 0 the new check at src/js/node/net.ts:3475 sees ownsSocket(self._handle) true and calls self.destroy(context.errors[context.errors.length - 1]) at src/js/node/net.ts:3476, so no further address is dialed. On the base branch the same recursion reached self[kReinitializeHandle] and the next address was dialed; the socket emitted 'connect'. Node v26 also closes the refused handle's socket in kReinitializeHandle and dials the next address, emitting 'connect'. Users with retry code that calls connect() twice on localhost get an uncaught 'error' and a closed socket after merging.

Verification: On the recursion current > 0, and the new check at src/js/node/net.ts:3475-3477 destroys the socket with the EISCONN/EALREADY error instead of the kReinitializeHandle(newDetachedSocket(...)) on line 3479 that would dial the next address. On the base branch this same call ran detach_for_reconnect and dialed the first address, delivering 'connect'.

Comment on lines +1441 to 1444

/// Discard a still-live native socket so this wrapper can be reused for a
/// fresh connect. `node:net` permits `socket.connect()` on an
/// already-connected socket; without this the previous `us_socket_t`'s

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.

🟡 (optional) Programs that call end() and then connect() on the same net.Socket before 'close' now get an EISCONN error and a destroyed socket; on the base branch the socket redialed. connect_refusal at src/runtime/socket/socket_body.rs:1441-1444 answers EISCONN whenever is_shutdown is set. For end(); connect(ip) in one tick, _final queues endNT (src/js/node/net.ts:2588) ahead of the connect's nextTick (net.ts:3175), so shutdown(2) always lands first. Node differs there: libuv defers shutdown(2) to the poll phase, so connect(2) runs first and emits 'connect' again. Fix: gate the EISCONN answer on the FIN having actually been sent (or defer the shutdown like libuv), and keep the original 'should allow reconnecting after end()' test instead of rewriting it to wait for 'close'.

Why this was flagged

Trigger: a client socket calls socket.end() then socket.connect(port, '127.0.0.1') before 'close' (same tick or a few ms later). end() calls _final synchronously, which queues endNT on nextTick (src/js/node/net.ts:2588); endNT calls handle.shutdown() (net.ts:439) which runs shutdown(2) immediately. connect() queues internalConnect on a later nextTick (net.ts:3175), so by the time connect_inner runs the socket is established, client, and is_shutdown, and src/runtime/socket/socket_body.rs:1441-1444 returns EISCONN. internalConnect then destroys the socket with 'connect EISCONN' (net.ts:3430-3431); a program with no 'error' listener exits with an uncaught exception. On the base branch connect_finish called detach_for_reconnect and dialed again, so these programs worked. Node's uv_shutdown only performs shutdown(2) in uv__drain during the next poll phase, after internalConnect's connect(2) has already returned 0 and 'connect' was emitted; Node only answers EISCONN once the FIN was really sent.

Verification: _final queues endNT on nextTick (src/js/node/net.ts:2588). endNT calls handle.shutdown() (net.ts:439). The connect's own nextTick (net.ts:3175) was queued after it, so connect_refusal returns Some(E::EISCONN) at src/runtime/socket/socket_body.rs:1436-1441. On the base branch this path went through connect_finish → detach_for_reconnect and emitted 'connect'.

Comment thread src/runtime/socket/socket_body.rs Outdated
Comment on lines +1435 to +1437
// Linux, and node emits 'connect' again. `connect_finish` swaps the connection.
if !self.is_server() && !socket.is_shutdown() {
return None;

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.

🟣 pre-existing, not blocking: Pre-existing gap the fix leaves open: a cluster worker's accepted socket, or one received over IPC, still has its connection silently reset and redialed by connect(), where node answers EISCONN. The accepted-socket rule at src/runtime/socket/socket_body.rs:1435 keys on is_server(), which is true only for Listener-accepted handles. A worker adopts its accepted connection through socket.connect({ fd, fdIsRawSocket: true }) at src/js/node/net.ts:4469, so its handlers are SocketMode::Client and connect_refusal returns None. Fix: refuse with EISCONN for every handle whose socket was adopted from an fd (cluster workers, Ipc.ts parseHandle 'net.Socket'), not only Listener-mode ones, e.g. record the Fd adoption in a flag and treat it like is_server() here.
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A cluster worker in round-robin mode gets each connection from the primary as an fd; onClusterConnection at src/js/node/net.ts:4459-4469 wraps it with socket.connect({ fd: clientHandle.fd, fdIsRawSocket: true }) and sets socket.isServer = true only on the JS object. Listener::connect_inner builds those handlers with SocketMode::Client (src/runtime/socket/Listener.rs:1149), so NewSocket::is_server() is false for that handle. When the worker's code later calls connect(port, host) on such an accepted socket, connect_refusal reaches src/runtime/socket/socket_body.rs:1435: the socket is established, not shutdown, and not is_server(), so it returns None and connect_finish runs detach_for_reconnect (src/runtime/socket/Listener.rs:1630), which closes the accepted connection with CloseCode::Failure and dials a new one with no event. The same applies to a net.Socket received over IPC (src/js/builtins/Ipc.ts:69-70). Node's handle for such a socket is a TCP wrap over a kernel socket in SS_CONNECTED state, and connect(2) answers EISCONN. The base branch behaves the same way, so the consequence is unchanged there.

Verification: The base already resets and redials; the new gate does not reach the fd-adopted accepted socket. onClusterConnection adopts it via socket.connect({ fd, fdIsRawSocket: true }) (src/js/node/net.ts:4469) with Client-mode handlers (Listener.rs:1149); a later connect(port, host) reaches connect_refusal (socket_body.rs:1435-1437), which returns None, and detach_for_reconnect (Listener.rs:1630) resets it, where connect(2) answers EISCONN.

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Still open from earlier reviews (3):

  • 🔴 src/js/node/net.ts:3477 — Callers whose host resolves to two addresses now get an EALREADY/EISCONN error and a destroyed socket where both Node a…
  • Also unresolved: 2 minor or pre-existing.

If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.

Comment on lines +178 to +179
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");

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.

🟡 nit (optional): when the node fixture process fails, maintainers see only a bare stderr or exit-code mismatch and lose the other outputs needed to diagnose it. inProcessOf at test/js/node/net/socket-reconnect-live.test.ts:178-179 asserts stderr and exitCode in two separate expects, so the first failing one hides the rest. Fix: assert one combined object, e.g. expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }) before parsing stdout, as the subprocess convention asks, and keep stdout for the JSON parse.

Why this was flagged

The node-side rows of describe.each spawn the fixture once through inProcessOf (test/js/node/net/socket-reconnect-live.test.ts:170-182) and cache the result in fromNode. At lines 178-179 the helper runs expect(stderr).toBe("") and then expect(exitCode).toBe(0) as separate assertions. When node prints a warning or crashes, the first expect throws with only stderr, and the exit code and stdout are never shown in the failure, so the twelve node tests all fail with one truncated message. The repository review rules ask subprocess tests to assert a combined { stdout, stderr, exitCode } object so a failure reports every output at once. The base branch has no node run of this fixture, so this is new test code only.

Verification: nit. The node fixture process writes to stderr or exits non-zero. inProcessOf at test/js/node/net/socket-reconnect-live.test.ts:179 and 181 asserts expect(stderr).toBe("") and expect(exitCode).toBe(0) as separate expects. The first failing expect throws, so a stderr mismatch hides exitCode and stdout. Sibling tests use the combined shape the REVIEW.md subprocess rule asks for.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants