Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 35 additions & 4 deletions src/js/node/net.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1925,6 +1925,11 @@ function kConnectDispatch(self, req, opts) {
req.dispatching = true;
const promise = doConnect(self._handle, opts);
req.dispatching = false;
// A handle that refuses the connect dials nothing: doConnect returns the errno.
if (typeof promise === "number") {
traceConnectEnd(req);
return promise;
}
promise.catch(_reason => {
// eat this so there's no unhandledRejection
// we already catch this in connectError and error
Expand Down Expand Up @@ -2310,15 +2315,21 @@ Socket.prototype.connect = function connect(...args) {
connection = socket;
}
if (fd != null) {
doConnect(this._handle, {
const opened = doConnect(this._handle, {
data: this,
fd: fd,
...(options.fdIsRawSocket === true ? { fdIsRawSocket: true } : {}),
socket: SocketHandlers,
// Always half-open natively; see kConnect.
allowHalfOpen: true,
pauseOnConnect,
}).catch(error => {
});
// libuv opens an fd only on a handle that has none.
if (typeof opened === "number") {
this.destroy(new ErrnoException(opened, "open"));
return this;
}
opened.catch(error => {
if (!this.destroyed) {
this.emit("error", error);
this.emit("close", true);
Expand Down Expand Up @@ -3359,6 +3370,8 @@ function internalConnect(self, options, address, port?, addressType?, localAddre
addressType,
);

// A handle that still owns a socket is bound already: libuv reports the EINVAL of its bind(2).
if (ownsSocket(self._handle)) err = uv().UV_EINVAL;
err = checkBindError(err, localPort, self._handle);
if (err) {
const ex = new ExceptionWithHostPort(err, "bind", localAddress, localPort);
Expand Down Expand Up @@ -3413,11 +3426,22 @@ function internalConnect(self, options, address, port?, addressType?, localAddre
}

if (err) {
const ex = new ExceptionWithHostPort(err, "connect", address, port);
const ex = new ExceptionWithHostPort(err, "connect", address, port, boundAddress(self._handle));
self.destroy(ex);
}
}

// Whether a native handle owns a socket: one that is connecting, connected, or not closed yet.
function ownsSocket(handle) {
return handle != null && handle.readyState !== -1;
}

// The "- Local (ip:port)" detail: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1161-L1170
function boundAddress(handle) {
const address = handle?.localAddress;
if (address) return address + ":" + handle.localPort;
}

function internalConnectMultiple(context, canceled?) {
clearTimeout(context[kTimeout]);
const self = context.socket;
Expand All @@ -3443,6 +3467,11 @@ function internalConnectMultiple(context, canceled?) {
const current = context.current++;

if (current > 0) {
// Node closes this socket with no event and dials on: https://github.com/nodejs/node/blob/v26.3.0/lib/net.js#L1201-L1203
if (ownsSocket(self._handle)) {
self.destroy(context.errors[context.errors.length - 1]);
return;
Comment on lines +3475 to +3477

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

}
self[kReinitializeHandle](newDetachedSocket(typeof self[bunTlsSymbol] === "function"));
}

Expand Down Expand Up @@ -3470,6 +3499,8 @@ function internalConnectMultiple(context, canceled?) {
addressType,
);

// As in internalConnect.
if (ownsSocket(self._handle)) err = uv().UV_EINVAL;
err = checkBindError(err, localPort, self._handle);
if (err) {
ArrayPrototypePush.$call(context.errors, new ExceptionWithHostPort(err, "bind", localAddress, localPort));
Expand Down Expand Up @@ -3508,7 +3539,7 @@ function internalConnectMultiple(context, canceled?) {
err = kConnectTcp(self, addressType, req, address, port);

if (err) {
const ex = new ExceptionWithHostPort(err, "connect", address, port);
const ex = new ExceptionWithHostPort(err, "connect", address, port, boundAddress(self._handle));
ArrayPrototypePush.$call(context.errors, ex);

self.emit("connectionAttemptFailed", address, port, addressType, ex);
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/socket/Listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1211,6 +1211,22 @@ impl Listener {
Some((local_addr_bytes.to_vec().into_boxed_slice(), local_port))
};

// Past the last option getter: script cannot give the handle a socket after this check.
let refusal = match (prev_maybe_tcp, prev_maybe_tls) {
// SAFETY: the caller passes a live socket, owned by its JS wrapper.
(Some(prev), _) => unsafe { &*prev }.connect_refusal(&connection),
// SAFETY: as above.
(_, Some(prev)) => unsafe { &*prev }.connect_refusal(&connection),
_ => None,
};
if let Some(errno) = refusal {
#[cfg(windows)]
let uv_errno = uv::e_discriminant_to_uv(errno as u16).unwrap_or(uv::UV_EISCONN);
#[cfg(not(windows))]
let uv_errno = -(errno as c_int);
return Ok(JSValue::js_number_from_int32(uv_errno));
}

#[cfg(windows)]
let mut connection = connection;
#[cfg(windows)]
Expand Down
47 changes: 47 additions & 0 deletions src/runtime/socket/socket_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1388,6 +1388,53 @@ impl<const SSL: bool> NewSocket<SSL> {
socket.close(code);
}

/// The errno that node's handle answers to this `connect()` while it owns a socket. `None`: dial.
pub(crate) fn connect_refusal(
&self,
connection: &super::listener::UnixOrHost,
) -> Option<sys::SystemErrno> {
use super::listener::UnixOrHost;
use sys::SystemErrno as E;
let socket = self.socket.get();
if socket.is_detached() {
return None;
}
// libuv opens an fd only on a handle that has none.
if matches!(connection, UnixOrHost::Fd(_)) {
return Some(E::EBUSY);
}
let is_tcp = matches!(
socket.local_address(&mut [0u8; 64]).map(<[u8]>::len),
Some(4 | 16)
);
if !is_tcp {
// A pipe handle reports what connect(2) says for the new path: EISCONN when it is a socket.
return Some(E::EISCONN);
}
// Node parses the path as an IPv4 address first: https://github.com/nodejs/node/blob/v26.3.0/src/tcp_wrap.cc#L433-L465
if let UnixOrHost::Unix(path) = connection {
if !matches!(
bun_core::ip_address::parse_uv_inet_pton(path),
Some(core::net::IpAddr::V4(_))
) {
return Some(E::EINVAL);
}
}
if !socket.is_established() {
// One connect per handle (ConnectEx says EISCONN): https://github.com/nodejs/node/blob/v26.3.0/deps/uv/src/unix/tcp.c#L288-L289
return Some(if cfg!(windows) {
E::EISCONN
} else {
E::EALREADY
});
}
// Linux answers 0 once for a client socket that has not sent its FIN: `connect_finish` swaps it, as before.
if !self.is_server() && !socket.is_shutdown() {
return None;
}
Some(E::EISCONN)
}

/// 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
Comment on lines +1441 to 1444

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

Expand Down
6 changes: 4 additions & 2 deletions test/js/node/net/node-net.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -516,7 +516,7 @@ describe("net.Socket write", () => {
}),
);

it("should allow reconnecting after end()", async () => {
it("should allow reconnecting after end(), once the socket emitted 'close'", async () => {
const server = new Server(socket => socket.end());
const port = await new Promise<number>(resolve => {
server.once("listening", () => resolve((server.address() as import("node:net").AddressInfo).port));
Expand All @@ -532,7 +532,9 @@ describe("net.Socket write", () => {
socket.once("connect", (...args) => {
socket.write("script\n", err => {
if (err) return reject(err);
socket.end(() => setTimeout(resolve, 3));
// The handle owns its socket until then: a connect() before it is EISCONN.
socket.once("close", resolve);
socket.end();
});
});
socket.connect(port, "127.0.0.1");
Expand Down
Loading
Loading