From 0236612798cf7ddf69f7ced9a056abf66fad42d0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:19:43 +0000 Subject: [PATCH 1/3] net(windows): surface the real WSA error from a failed connect() On Windows the node:net connect-error path was reporting ECONNREFUSED (or ENOENT for a path connect) for every failure because the raw WSA code from Winsock was never mapped to the SystemErrno discriminant the errno whitelist compares against: - The async path (loop.c's SEMI_SOCKET SO_ERROR read and context.c's recv probe) passes raw WSA codes (WSAECONNRESET = 10054, WSAEADDRINUSE = 10048) into handle_connect_error, whose whitelist is keyed on POSIX-numbered SystemErrno values (104, 98). Nothing matched, so every async connect failure fell through to ECONNREFUSED. - The synchronous path (connect_finish after do_connect() errs) read the CRT's thread-local _errno(), which Winsock never sets, so it always saw 0 and fell through to the default. - bsd.c's AF_UNIX connect path discarded bsd_do_connect_raw's return value and let closesocket() overwrite WSAGetLastError. Fix: preserve the WSA code across closesocket in bsd.c; read WSAGetLastError() in connect_finish on Windows; normalize any value >= WSABASEERR through SystemErrno::init before the whitelist in handle_connect_error; and remap every whitelisted code (not just ENOENT/ECONNREFUSED) to its UV_* value so err.errno matches Node. Winsock AF_UNIX returns WSAECONNREFUSED for any path with no listening socket, existing or not, where Node (via libuv's uv_pipe_connect, which uses CreateFile) distinguishes ENOENT. connect_finish now refines ECONNREFUSED to ENOENT on the failure path when the target path does not exist. Enables upstream parallel/test-net-client-bind-twice, parallel/test-net-server-reset and parallel/test-net-pipe-connect-errors on Windows. --- packages/bun-usockets/src/bsd.c | 12 ++++- src/runtime/socket/Listener.rs | 23 ++++++++- src/runtime/socket/socket_body.rs | 36 +++++++++++--- test/expectations.txt | 14 ++---- test/js/node/net/node-net.test.ts | 81 +++++++++++++++++++++++++++++++ 5 files changed, 145 insertions(+), 21 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 990ce4d981b2..07a32d4b70a5 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1910,6 +1910,12 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_connect_socket(struct sockaddr_storage *addr, if (rc != 0) { bsd_close_socket(fd); +#ifdef _WIN32 + /* bsd_do_connect_raw returned the WSA error; re-arm it so the Rust + * caller's WSAGetLastError() observes the connect failure rather than + * whatever closesocket() left behind. */ + WSASetLastError(rc); +#endif return LIBUS_SOCKET_ERROR; } return fd; @@ -1924,8 +1930,12 @@ static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_connect_socket_unix(const cha win32_set_nonblocking(fd); - if (bsd_do_connect_raw(fd, (struct sockaddr *)server_address, addrlen) != 0) { + int rc = bsd_do_connect_raw(fd, (struct sockaddr *)server_address, addrlen); + if (rc != 0) { bsd_close_socket(fd); +#ifdef _WIN32 + WSASetLastError(rc); +#endif return LIBUS_SOCKET_ERROR; } diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index a49b90ca412f..1fcbc2c5bf0f 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1565,11 +1565,31 @@ fn connect_finish( // Note: `do_connect` reads `self.connection` directly so no second // borrow is needed here. if socket_ref.do_connect().is_err() { + // The failing syscall is Winsock `bind`/`connect` on Windows (bsd.c + // preserves the WSA code across `closesocket`); `last_errno()` would + // read the CRT's thread-local `_errno()`, which Winsock does not set. + #[cfg(windows)] + let os_errno = { + let mut e = bun_sys::windows::WSAGetLastError().map_or(0, |err| err as c_int); + // Winsock AF_UNIX reports WSAECONNREFUSED for any path that has no + // listening socket, whether or not the file exists. Node (via + // libuv's `uv_pipe_connect`, which uses `CreateFile`) distinguishes + // ENOENT from ENOTSOCK. Refine on the failure path only. + if port.is_none() && e == bun_sys::SystemErrno::ECONNREFUSED as c_int { + if let Some(UnixOrHost::Unix(path)) = socket_ref.connection.get() { + if !bun_sys::exists(path) { + e = bun_sys::SystemErrno::ENOENT as c_int; + } + } + } + e + }; + #[cfg(not(windows))] + let os_errno = bun_sys::last_errno(); let errno = if port.is_none() { // Preserve the real errno from the failed connect(2) on a unix path: // connecting to an existing non-socket file is ENOTSOCK, a // permission-denied path is EACCES, a missing one is ENOENT. - let os_errno = bun_sys::last_errno(); if os_errno == bun_sys::SystemErrno::ENAMETOOLONG as c_int { // libuv reports UV_EINVAL for a pipe path it cannot express. bun_sys::SystemErrno::EINVAL as c_int @@ -1585,7 +1605,6 @@ fn connect_finish( // EADDRNOTAVAIL: address not local, EACCES: privileged port, // EINVAL: address family mismatch); everything else stays // ECONNREFUSED. Mirrors handle_connect_error's whitelist. - let os_errno = bun_sys::last_errno(); if os_errno == bun_sys::SystemErrno::EADDRINUSE as c_int || os_errno == bun_sys::SystemErrno::EADDRNOTAVAIL as c_int || os_errno == bun_sys::SystemErrno::EACCES as c_int diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 11853f20771f..1421c1a7462a 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1109,6 +1109,21 @@ impl NewSocket { ) } else { debug_assert!(errno >= 0); + // On Windows the async connect-error path (loop.c's SEMI_SOCKET + // SO_ERROR read, context.c's recv probe) delivers raw WSA codes + // (WSAECONNRESET = 10054, WSAEADDRINUSE = 10048). The whitelist + // below is keyed on SystemErrno discriminants, so normalize first; + // WSA codes are >= WSABASEERR (10000) and so disjoint from both + // the SystemErrno discriminant space and the MSVC CRT errno values + // the synchronous path passes in. + #[cfg(windows)] + let errno: c_int = if errno >= 10000 { + sys::SystemErrno::init(errno as u32) + .map(|e| e as c_int) + .unwrap_or(sys::SystemErrno::ECONNREFUSED as c_int) + } else { + errno + }; // Unix-path connect errors keep their real code (a non-socket file // is ENOTSOCK, a permission-denied path is EACCES, a missing one is // ENOENT, an inexpressible path is EINVAL); everything else stays @@ -1142,16 +1157,23 @@ impl NewSocket { } else { BunString::static_("ECONNREFUSED") }; + // Node on Windows reports libuv's negative errno (e.g. -4077 for + // ECONNRESET), not the POSIX-style discriminant the whitelist is + // keyed on. Rewrite every recognized code to its UV_* value so + // `err.errno` matches Node; the default arm stays ECONNREFUSED. #[cfg(windows)] let errno_ = { - let mut errno_ = errno_; - if errno_ == sys::SystemErrno::ENOENT as c_int { - errno_ = sys::SystemErrno::UV_ENOENT as c_int; - } - if errno_ == sys::SystemErrno::ECONNREFUSED as c_int { - errno_ = sys::SystemErrno::UV_ECONNREFUSED as c_int; + use sys::SystemErrno as S; + match errno_ { + x if x == S::ENOENT as c_int => S::UV_ENOENT as c_int, + x if x == S::ENOTSOCK as c_int => S::UV_ENOTSOCK as c_int, + x if x == S::EACCES as c_int => S::UV_EACCES as c_int, + x if x == S::EINVAL as c_int => S::UV_EINVAL as c_int, + x if x == S::ECONNRESET as c_int => S::UV_ECONNRESET as c_int, + x if x == S::EADDRINUSE as c_int => S::UV_EADDRINUSE as c_int, + x if x == S::EADDRNOTAVAIL as c_int => S::UV_EADDRNOTAVAIL as c_int, + _ => S::UV_ECONNREFUSED as c_int, } - errno_ }; SystemError { errno: -errno_, diff --git a/test/expectations.txt b/test/expectations.txt index 0e2990c51be7..79e448a8d28f 100644 --- a/test/expectations.txt +++ b/test/expectations.txt @@ -89,12 +89,10 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] [ ASAN ] test/js/web/streams/streams-leak.test.ts [ LEAK ] # Absolute memory usage remains relatively constant when reading and writing to a pipe [ ASAN ] test/cli/run/require-cache.test.ts [ LEAK ] # files transpiled and loaded don't leak file paths > via require() -# Windows-only gaps in named-pipe / socket teardown for ported Node net tests -# (these pass on Linux and macOS): half-close (FIN) handling on named pipes, -# RST delivery surfacing as ECONNRESET, and EADDRINUSE on a second listen on -# the same pipe path. +# Windows-only gap in named-pipe half-close: WindowsNamedPipe::shutdown() / +# on_read_error(EOF) issue a full uv_close instead of uv_shutdown, so the +# final write after the peer half-closes is lost. Passes on Linux and macOS. [ WINDOWS ] test/js/node/test/parallel/test-net-pingpong.js [ FAIL ] # named-pipe half-close (FIN) handling -[ WINDOWS ] test/js/node/test/parallel/test-net-pipe-connect-errors.js [ FAIL ] # named-pipe connect errors are not mapped to ENOENT/EACCES on Windows yet [ WINDOWS ] test/js/node/test/parallel/test-net-server-listen-path.js [ FAIL ] # EADDRINUSE not reported for a second listen on the same pipe path # Same Windows half-close + client-RST gap over a node:http server: the server @@ -104,12 +102,6 @@ test/js/bun/spawn/spawn-maxbuf.test.ts [ FLAKY ] # server.close() (via `await using`) waits forever. The assertion itself passes; # only the teardown hangs. Passes on Linux and macOS. -# The localAddress/localPort bind-before-connect and the SO_ERROR read on a -# connecting socket that was reset during establishment are implemented in the -# POSIX (kqueue/epoll + BSD socket) connect path; Windows connects through -# libuv and needs its own implementation of both. -[ WINDOWS ] test/js/node/test/parallel/test-net-client-bind-twice.js [ FAIL ] # localAddress/localPort binding not implemented for the libuv connect path -[ WINDOWS ] test/js/node/test/parallel/test-net-server-reset.js [ FAIL ] # connect-time RST reports ECONNREFUSED instead of ECONNRESET on the libuv path # Cluster workers sharing a listen port behave differently on Linux, where # SO_REUSEPORT load-balances across the workers' own listeners instead of the # primary distributing accepted connections; the upstream test's expectations diff --git a/test/js/node/net/node-net.test.ts b/test/js/node/net/node-net.test.ts index adeb64cd01ba..25d44333477a 100644 --- a/test/js/node/net/node-net.test.ts +++ b/test/js/node/net/node-net.test.ts @@ -1906,3 +1906,84 @@ it.skipIf(isWindows)("connect({ localPort }) succeeds when the local port has TI target.close(); } }); + +// On Windows the connect-error path receives raw WSA codes (WSAECONNRESET, +// WSAEADDRINUSE) from getsockopt(SO_ERROR) and the pre-connect bind(); these +// must be mapped before the errno whitelist, or every failure degrades to +// ECONNREFUSED. POSIX already reports these correctly. +describe.skipIf(!isWindows)("connect() error codes on Windows", () => { + it("localPort in use reports EADDRINUSE", async () => { + const server1 = createServer(() => {}); + const server2 = createServer(() => {}); + try { + await new Promise((resolve, reject) => { + server1.on("error", reject); + server1.listen(0, "127.0.0.1", resolve); + }); + await new Promise((resolve, reject) => { + server2.on("error", reject); + server2.listen(0, "127.0.0.1", resolve); + }); + const port = (server1.address() as import("node:net").AddressInfo).port; + const localPort = (server2.address() as import("node:net").AddressInfo).port; + const err = await new Promise(resolve => { + const c = connect({ host: "127.0.0.1", port, localAddress: "127.0.0.1", localPort }); + c.on("error", resolve); + c.on("connect", () => { + c.destroy(); + resolve(Object.assign(new Error("connected"), { code: "CONNECTED" })); + }); + }); + expect(err.code).toBe("EADDRINUSE"); + } finally { + server1.close(); + server2.close(); + } + }); + + it("server resetAndDestroy() surfaces ECONNRESET on the client", async () => { + const server = createServer(c => { + c.resetAndDestroy(); + }); + try { + await new Promise((resolve, reject) => { + server.on("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as import("node:net").AddressInfo).port; + const err = await new Promise(resolve => { + const c = connect(port, "127.0.0.1"); + c.on("error", resolve); + c.on("close", hadError => { + if (!hadError) resolve(Object.assign(new Error("clean close"), { code: "NOERR" })); + }); + }); + expect(err.code).toBe("ECONNRESET"); + } finally { + server.close(); + } + }); + + it("connect to a path that is not a socket reports ECONNREFUSED/ENOTSOCK, missing path reports ENOENT", async () => { + const dir = tmpdirSync(); + const regular = join(dir, "not-a-socket.txt"); + fs.writeFileSync(regular, ""); + const missing = join(dir, "does-not-exist"); + + const errFor = (path: string) => + new Promise(resolve => { + const c = createConnection(path); + c.on("error", resolve); + c.on("connect", () => { + c.destroy(); + resolve(Object.assign(new Error("connected"), { code: "CONNECTED" })); + }); + }); + + const regularErr = await errFor(regular); + expect(["ENOTSOCK", "ECONNREFUSED"]).toContain(regularErr.code); + + const missingErr = await errFor(missing); + expect(missingErr.code).toBe("ENOENT"); + }); +}); From af19491a874ee0e180cf4a3f4caf1b24ff8b83e6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:19:27 +0000 Subject: [PATCH 2/3] review: use e_discriminant_to_uv for the UV errno remap; clarify WSA normalization comment; loop.c fallback emits WSAECONNRESET on Windows --- packages/bun-usockets/src/loop.c | 4 ++++ src/runtime/socket/socket_body.rs | 35 +++++++++++-------------------- 2 files changed, 16 insertions(+), 23 deletions(-) diff --git a/packages/bun-usockets/src/loop.c b/packages/bun-usockets/src/loop.c index baba410af040..18faa4f94478 100644 --- a/packages/bun-usockets/src/loop.c +++ b/packages/bun-usockets/src/loop.c @@ -490,7 +490,11 @@ void us_internal_dispatch_ready_poll(struct us_poll_t *p, int error, int eof, in if (error || eof) { connect_error = us_socket_get_error((struct us_socket_t *) p); if (connect_error == 0) { +#ifdef _WIN32 + connect_error = WSAECONNRESET; +#else connect_error = ECONNRESET; +#endif } } us_internal_socket_after_open((struct us_socket_t *) p, connect_error); diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 1421c1a7462a..373aaf141d8b 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1109,13 +1109,14 @@ impl NewSocket { ) } else { debug_assert!(errno >= 0); - // On Windows the async connect-error path (loop.c's SEMI_SOCKET - // SO_ERROR read, context.c's recv probe) delivers raw WSA codes - // (WSAECONNRESET = 10054, WSAEADDRINUSE = 10048). The whitelist - // below is keyed on SystemErrno discriminants, so normalize first; - // WSA codes are >= WSABASEERR (10000) and so disjoint from both - // the SystemErrno discriminant space and the MSVC CRT errno values - // the synchronous path passes in. + // On Windows this sees either SystemErrno discriminants (Rust + // callers) or raw WSA codes (loop.c's SEMI_SOCKET SO_ERROR read, + // context.c's recv probe: WSAECONNRESET = 10054, + // WSAEADDRINUSE = 10048). WSA codes are >= WSABASEERR (10000), + // disjoint from the discriminant space, and are normalized here so + // the whitelist below compares one numbering. The remaining + // uSockets UCRT-numbered literals (ECONNREFUSED/ECONNABORTED) are + // not in the whitelist and correctly fall through to ECONNREFUSED. #[cfg(windows)] let errno: c_int = if errno >= 10000 { sys::SystemErrno::init(errno as u32) @@ -1158,23 +1159,11 @@ impl NewSocket { BunString::static_("ECONNREFUSED") }; // Node on Windows reports libuv's negative errno (e.g. -4077 for - // ECONNRESET), not the POSIX-style discriminant the whitelist is - // keyed on. Rewrite every recognized code to its UV_* value so - // `err.errno` matches Node; the default arm stays ECONNREFUSED. + // ECONNRESET); the whitelist above guarantees `errno_` is a + // discriminant the canonical table covers. #[cfg(windows)] - let errno_ = { - use sys::SystemErrno as S; - match errno_ { - x if x == S::ENOENT as c_int => S::UV_ENOENT as c_int, - x if x == S::ENOTSOCK as c_int => S::UV_ENOTSOCK as c_int, - x if x == S::EACCES as c_int => S::UV_EACCES as c_int, - x if x == S::EINVAL as c_int => S::UV_EINVAL as c_int, - x if x == S::ECONNRESET as c_int => S::UV_ECONNRESET as c_int, - x if x == S::EADDRINUSE as c_int => S::UV_EADDRINUSE as c_int, - x if x == S::EADDRNOTAVAIL as c_int => S::UV_EADDRNOTAVAIL as c_int, - _ => S::UV_ECONNREFUSED as c_int, - } - }; + let errno_ = -sys::windows::libuv::e_discriminant_to_uv(errno_ as u16) + .unwrap_or(sys::windows::libuv::UV_ECONNREFUSED); SystemError { errno: -errno_, message: BunString::static_("Failed to connect").into(), From 2a6a33c22b0f75a00f419a4257af44b49f36ed03 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 2 Aug 2026 15:22:49 +0000 Subject: [PATCH 3/3] style: trim comments --- src/runtime/socket/Listener.rs | 11 ++++------- src/runtime/socket/socket_body.rs | 14 +++----------- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 1fcbc2c5bf0f..6b2dbf01aba5 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -1565,16 +1565,13 @@ fn connect_finish( // Note: `do_connect` reads `self.connection` directly so no second // borrow is needed here. if socket_ref.do_connect().is_err() { - // The failing syscall is Winsock `bind`/`connect` on Windows (bsd.c - // preserves the WSA code across `closesocket`); `last_errno()` would - // read the CRT's thread-local `_errno()`, which Winsock does not set. + // Winsock sets WSAGetLastError, not the CRT `_errno()` that + // `last_errno()` reads. #[cfg(windows)] let os_errno = { let mut e = bun_sys::windows::WSAGetLastError().map_or(0, |err| err as c_int); - // Winsock AF_UNIX reports WSAECONNREFUSED for any path that has no - // listening socket, whether or not the file exists. Node (via - // libuv's `uv_pipe_connect`, which uses `CreateFile`) distinguishes - // ENOENT from ENOTSOCK. Refine on the failure path only. + // Winsock AF_UNIX returns WSAECONNREFUSED whether the path exists + // or not; Node distinguishes ENOENT via `CreateFile`. if port.is_none() && e == bun_sys::SystemErrno::ECONNREFUSED as c_int { if let Some(UnixOrHost::Unix(path)) = socket_ref.connection.get() { if !bun_sys::exists(path) { diff --git a/src/runtime/socket/socket_body.rs b/src/runtime/socket/socket_body.rs index 373aaf141d8b..fcc7598b7e29 100644 --- a/src/runtime/socket/socket_body.rs +++ b/src/runtime/socket/socket_body.rs @@ -1109,14 +1109,9 @@ impl NewSocket { ) } else { debug_assert!(errno >= 0); - // On Windows this sees either SystemErrno discriminants (Rust - // callers) or raw WSA codes (loop.c's SEMI_SOCKET SO_ERROR read, - // context.c's recv probe: WSAECONNRESET = 10054, - // WSAEADDRINUSE = 10048). WSA codes are >= WSABASEERR (10000), - // disjoint from the discriminant space, and are normalized here so - // the whitelist below compares one numbering. The remaining - // uSockets UCRT-numbered literals (ECONNREFUSED/ECONNABORTED) are - // not in the whitelist and correctly fall through to ECONNREFUSED. + // uSockets hands us raw WSA codes (SO_ERROR, the recv probe) on + // Windows; map those onto the SystemErrno numbering the whitelist + // below compares against. #[cfg(windows)] let errno: c_int = if errno >= 10000 { sys::SystemErrno::init(errno as u32) @@ -1158,9 +1153,6 @@ impl NewSocket { } else { BunString::static_("ECONNREFUSED") }; - // Node on Windows reports libuv's negative errno (e.g. -4077 for - // ECONNRESET); the whitelist above guarantees `errno_` is a - // discriminant the canonical table covers. #[cfg(windows)] let errno_ = -sys::windows::libuv::e_discriminant_to_uv(errno_ as u16) .unwrap_or(sys::windows::libuv::UV_ECONNREFUSED);