Skip to content
Merged
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
12 changes: 11 additions & 1 deletion packages/bun-usockets/src/bsd.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions packages/bun-usockets/src/loop.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
20 changes: 18 additions & 2 deletions src/runtime/socket/Listener.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1565,11 +1565,28 @@ fn connect_finish<const IS_SSL: bool>(
// Note: `do_connect` reads `self.connection` directly so no second
// borrow is needed here.
if socket_ref.do_connect().is_err() {
// Winsock sets WSAGetLastError, not the CRT `_errno()` that
// `last_errno()` reads.
Comment thread
robobun marked this conversation as resolved.
#[cfg(windows)]
let os_errno = {
let mut e = bun_sys::windows::WSAGetLastError().map_or(0, |err| err as c_int);
// Winsock AF_UNIX returns WSAECONNREFUSED whether the path exists
// or not; Node distinguishes ENOENT via `CreateFile`.
Comment thread
robobun marked this conversation as resolved.
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
Expand All @@ -1585,7 +1602,6 @@ fn connect_finish<const IS_SSL: bool>(
// 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
Expand Down
23 changes: 13 additions & 10 deletions src/runtime/socket/socket_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,17 @@ impl<const SSL: bool> NewSocket<SSL> {
)
} else {
debug_assert!(errno >= 0);
// uSockets hands us raw WSA codes (SO_ERROR, the recv probe) on
// Windows; map those onto the SystemErrno numbering the whitelist
// below compares against.
Comment thread
robobun marked this conversation as resolved.
#[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
Expand Down Expand Up @@ -1143,16 +1154,8 @@ impl<const SSL: bool> NewSocket<SSL> {
BunString::static_("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;
}
errno_
};
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(),
Expand Down
14 changes: 3 additions & 11 deletions test/expectations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
81 changes: 81 additions & 0 deletions test/js/node/net/node-net.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>((resolve, reject) => {
server1.on("error", reject);
server1.listen(0, "127.0.0.1", resolve);
});
await new Promise<void>((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<NodeJS.ErrnoException>(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<void>((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<NodeJS.ErrnoException>(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<NodeJS.ErrnoException>(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");
});
});
Loading