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
23 changes: 18 additions & 5 deletions packages/bun-usockets/src/bsd.c
Original file line number Diff line number Diff line change
Expand Up @@ -1828,8 +1828,11 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_udp_socket(const char *host, int port, int op
return listenFd;
}

/* Returns 0, a getaddrinfo() error code, or LIBUS_SOCKET_ERROR with the last
* connect() error in errno (WSAGetLastError() on Windows), captured before
* freeaddrinfo() can clobber it. An empty result list reports EAFNOSUPPORT. */
int bsd_connect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd, const char *host, int port) {
struct addrinfo hints, *result;
struct addrinfo hints, *result = NULL;
memset(&hints, 0, sizeof(struct addrinfo));

hints.ai_family = AF_UNSPEC;
Expand All @@ -1844,18 +1847,28 @@ int bsd_connect_udp_socket(LIBUS_SOCKET_DESCRIPTOR fd, const char *host, int por
return gai_error;
}

if (result == NULL) {
return -1;
}
#ifdef _WIN32
int last_error = WSAEAFNOSUPPORT;
#else
int last_error = EAFNOSUPPORT;
#endif

for (struct addrinfo *rp = result; rp != NULL; rp = rp->ai_next) {
if (connect(fd, rp->ai_addr, rp->ai_addrlen) == 0) {
freeaddrinfo(result);
return 0;
}
last_error = LIBUS_ERR;
}

freeaddrinfo(result);
if (result != NULL) {
freeaddrinfo(result);
}
#ifdef _WIN32
WSASetLastError(last_error);
#else
errno = last_error;
#endif
return (int)LIBUS_SOCKET_ERROR;
}

Expand Down
14 changes: 7 additions & 7 deletions src/js/node/dgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@ function doConnect(ex, self, ip, address, port, callback) {
try {
connectFn.$call(state.handle.socket, ip, port);
} catch (e) {
ex = e;
ex = decorateHostPortError(e, "connect", address, port);
}
}

Expand Down Expand Up @@ -1069,7 +1069,7 @@ function doSend(ex, self, ip, list, address, port, callback) {
else err = state.handle.send(req, list, list.length, !!callback);

if (typeof err !== "number") {
if (callback) process.nextTick(callback, decorateSendError(err, address, port));
if (callback) process.nextTick(callback, decorateHostPortError(err, "send", address, port));
return;
}

Expand Down Expand Up @@ -1100,21 +1100,21 @@ function afterQueuedSend(err, sent) {
} else if (typeof err === "number") {
callback(new ExceptionWithHostPort(err, "send", this.address, this.port));
} else {
callback(decorateSendError(err, this.address, this.port));
callback(decorateHostPortError(err, "send", this.address, this.port));
}
}

// Native send failure: keep the original error (its code is already
// Native send/connect failure: keep the original error (its code is already
// platform-correct) and decorate it like Node's ExceptionWithHostPort, which
// omits the host segment when no address/port is known.
function decorateSendError(err, address, port) {
err.syscall = "send";
function decorateHostPortError(err, syscall, address, port) {
err.syscall = syscall;
err.address = address;
if (port) err.port = port;
let details = "";
if (port && port > 0) details = ` ${address}:${port}`;
else if (address) details = ` ${address}`;
err.message = `send ${err.code}${details}`;
err.message = `${syscall} ${err.code}${details}`;
return err;
}

Expand Down
112 changes: 39 additions & 73 deletions src/runtime/socket/udp_socket.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,46 +30,6 @@ use crate::socket::socket_address::inet::{self, INET6_ADDRSTRLEN, sockaddr_in, s

bun_output::declare_scope!(UdpSocket, visible);

/// Local errno-classification shim — `bun_sys::Result`
/// is a plain `core::result::Result` alias in Rust and has no associated
/// `errno_sys` constructor.
///
/// POSIX `getErrno(c_int)` semantics: only `rc == -1` is failure (any other
/// value — including positive packet counts from `us_udp_socket_send` and
/// negative EAI codes from `connect` — is "not a libc errno", so callers
/// handle it themselves).
///
/// Windows semantics: for any non-NTSTATUS
/// integer `rc`, `if (rc != 0) return null` — i.e. *every* non-zero rc
/// (including `-1` / `SOCKET_ERROR`) falls through to the caller's own EAI /
/// WSA handling rather than synthesising an errno. This matters for the
/// `connect()` path (line ~575): `us_udp_socket_connect()` returns a Winsock
/// status, not a CRT errno, so reading `_errno()` here would be the wrong
/// source.
#[inline]
fn errno_sys(rc: c_int, tag: bun_sys::Tag) -> Option<bun_sys::Error> {
#[cfg(windows)]
{
if rc != 0 {
return None;
}
// rc == 0 → read the CRT `_errno()`;
// a zero errno must still yield `None`.
let errno_val = bun_sys::last_errno();
if errno_val == 0 {
return None;
}
return Some(bun_sys::Error::from_code_int(errno_val, tag));
}
#[cfg(not(windows))]
{
if rc != -1 {
return None;
}
Some(bun_sys::Error::from_code_int(bun_sys::last_errno(), tag))
}
}

use bun_core::ip_address;
use bun_sys::net::Address;

Expand Down Expand Up @@ -762,22 +722,7 @@ impl UDPSocket {
// `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref.
let ret = uws::udp::Socket::opaque_mut(this.socket.get().unwrap())
.connect(address_z.as_ptr(), connect.port as u32);
if ret != 0 {
if let Some(sys_err) = errno_sys(ret, bun_sys::Tag::connect) {
return Err(global_this.throw_value(sys_err.to_js(global_this)));
}

if let Some(eai_err) = c_ares::Error::init_eai(ret) {
return Err(global_this.throw_value(
crate::dns_jsc::cares_jsc::error_to_js_with_syscall_and_hostname(
eai_err,
global_this,
b"connect",
address_z.as_bytes(),
)?,
));
}
}
check_connect(global_this, ret, address_z.as_bytes())?;
this.connect_info
.set(Some(ConnectInfo { port: connect.port }));
}
Expand Down Expand Up @@ -1916,9 +1861,8 @@ impl UDPSocket {
return Err(global_this.throw(format_args!("Socket is closed")));
};
// `Socket` is an `opaque_ffi!` ZST — `opaque_mut` is the safe deref.
if uws::udp::Socket::opaque_mut(socket).connect(connect_host.as_ptr(), port as u32) == -1 {
return Err(global_this.throw(format_args!("Failed to connect socket")));
}
let ret = uws::udp::Socket::opaque_mut(socket).connect(connect_host.as_ptr(), port as u32);
check_connect(global_this, ret, connect_host.as_bytes())?;
this.connect_info.set(Some(ConnectInfo { port }));

js::address_set_cached(call_frame.this(), global_this, JSValue::ZERO);
Expand Down Expand Up @@ -2529,15 +2473,22 @@ struct Destination {
address: JSValue,
}

/// `Some` when a usockets call returned its failure status: `-1` on POSIX
/// (other values, such as `us_udp_socket_send`'s packet count, are results),
/// any negative value on Windows.
fn get_us_error<const USE_WSA: bool>(res: c_int, tag: bun_sys::Tag) -> Option<bun_sys::Error> {
#[cfg(windows)]
{
// setsockopt returns 0 on success, but errnoSys considers 0 to be failure on Windows.
// This applies to some other usockets functions too.
if res >= 0 {
return None;
}
let failed = res < 0;
#[cfg(not(windows))]
let failed = res == -1;
failed.then(|| last_socket_error::<USE_WSA>(tag))
}

/// The error the socket call that just failed left behind: `WSAGetLastError()`
/// on Windows (the CRT errno when Winsock has nothing), `errno` elsewhere.
fn last_socket_error<const USE_WSA: bool>(tag: bun_sys::Tag) -> bun_sys::Error {
#[cfg(windows)]
{
if USE_WSA {
// The wrapper (src/sys/windows/mod.rs) already maps `SystemErrno`
// → `E` for us, so `e` is `bun_sys::E` here.
Expand All @@ -2547,17 +2498,32 @@ fn get_us_error<const USE_WSA: bool>(res: c_int, tag: bun_sys::Tag) -> Option<bu
// `bun_windows_sys::ws2_32` (thread-local Winsock error
// slot write — no preconditions).
bun_sys::windows::ws2_32::WSASetLastError(0);
return Some(bun_sys::Error::from_code(e, tag));
return bun_sys::Error::from_code(e, tag);
}
}
}

let errno_val = bun_sys::last_errno();
return Some(bun_sys::Error::from_code_int(errno_val, tag));
}
#[cfg(not(windows))]
{
let _ = USE_WSA;
errno_sys(res, tag)
}
let _ = USE_WSA;
bun_sys::Error::from_code_int(bun_sys::last_errno(), tag)
}

/// Throws for a failed `us_udp_socket_connect`, whose status spans two
/// namespaces: `-1` (`LIBUS_SOCKET_ERROR`) means connect(2) failed and errno /
/// `WSAGetLastError()` hold the reason; any other non-zero value is the
/// getaddrinfo(3) status for `address`. `-1` must be told apart before the EAI
/// mapping sees it: `init_eai(-1)` is ENOTFOUND on Windows.
fn check_connect(global_this: &JSGlobalObject, ret: c_int, address: &[u8]) -> JsResult<()> {
let error = match ret {
0 => return Ok(()),
-1 => last_socket_error::<true>(bun_sys::Tag::connect).to_js(global_this),
eai => crate::dns_jsc::cares_jsc::error_to_js_with_syscall_and_hostname(
// `init_eai` is only `None` for 0, matched above.
c_ares::Error::init_eai(eai).unwrap_or(c_ares::Error::ENOTFOUND),
global_this,
b"connect",
address,
)?,
};
Err(global_this.throw_value(error))
}
48 changes: 48 additions & 0 deletions test/js/bun/udp/dgram.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,54 @@ test.skipIf(isWindows)("connected send() failure reports Node's error shape", as
});
});

// A udp4 socket cannot be connected to an IPv6 address: connect(2) itself
// fails, on every platform and without touching the network. Node reports
// that as an ExceptionWithHostPort; this used to be a bare "Failed to connect
// socket" with no code. Linux and Windows reject the address family, the BSDs
// may reject the sockaddr length first.
const connectFamilyMismatchCodes = ["EAFNOSUPPORT", "EINVAL"];

test("connect() failure reports Node's error shape to the callback", async () => {
const socket = createSocket("udp4");
try {
const err: any = await new Promise(resolve => socket.connect(1, "::1", resolve));
expect(err).toBeInstanceOf(Error);
expect(err.code).toBeOneOf(connectFamilyMismatchCodes);
expect(err.errno).toBeNegative();
expect({ syscall: err.syscall, address: err.address, port: err.port, message: err.message }).toEqual({
syscall: "connect",
address: "::1",
port: 1,
message: `connect ${err.code} ::1:1`,
});
// Like Node, a failed connect() leaves the socket disconnected and usable.
await new Promise<void>((resolve, reject) => socket.connect(1, "127.0.0.1", e => (e ? reject(e) : resolve())));
expect(socket.remoteAddress()).toEqual({ address: "127.0.0.1", family: "IPv4", port: 1 });
} finally {
socket.close();
}
});

test("connect() failure without a callback is emitted as 'error' in Node's shape", async () => {
const socket = createSocket("udp4");
try {
const { promise, resolve } = Promise.withResolvers<any>();
socket.on("error", resolve);
socket.on("connect", () => resolve(new Error("connected unexpectedly")));
socket.connect(1, "::1");
const err = await promise;
expect(err.code).toBeOneOf(connectFamilyMismatchCodes);
expect({ syscall: err.syscall, address: err.address, port: err.port, message: err.message }).toEqual({
syscall: "connect",
address: "::1",
port: 1,
message: `connect ${err.code} ::1:1`,
});
} finally {
socket.close();
}
});

// Every send callback must fire with (null, byteLength) — never (null, 0) or
// EAGAIN — even if the kernel refuses some writes (they queue and drain like
// libuv's uv_udp_try_send → uv_udp_send fallback).
Expand Down
49 changes: 43 additions & 6 deletions test/js/bun/udp/udp_socket.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,12 +78,49 @@ describe("udpSocket()", () => {
expect(exitCode).toBe(0);
});

test("connect with invalid hostname rejects", async () => {
expect(async () =>
udpSocket({
connect: { hostname: "example!!!!!.com", port: 443 },
}),
).toThrow();
// The native connect returns -1 when connect(2) fails and a getaddrinfo code
// when the name does not resolve. On Windows the -1 used to be run through
// the getaddrinfo mapping, which reported every connect(2) failure as a DNS
// ENOTFOUND for an address that had resolved fine.
describe("connect option failures", () => {
async function connectError(connect: { hostname: string; port: number }): Promise<any> {
let socket;
try {
socket = await udpSocket({ connect });
} catch (error) {
return error;
}
socket.close();
throw new Error(`connecting to ${connect.hostname} unexpectedly succeeded`);
}

test("connect with invalid hostname rejects with a DNS error", async () => {
const error = await connectError({ hostname: "example!!!!!.com", port: 443 });
expect(error.name).toBe("DNSException");
expect(error.syscall).toBe("connect");
expect(error.message).toBe(`connect ${error.code} example!!!!!.com`);
});

test("the default (IPv4) socket cannot connect to an IPv6 address", async () => {
const error = await connectError({ hostname: "::1", port: 1 });
expect(error.name).toBe("Error");
// Linux and Windows reject the address family; the BSDs may reject the
// sockaddr length first.
expect(error.code).toBeOneOf(["EAFNOSUPPORT", "EINVAL"]);
expect(error.errno).toBeNegative();
expect(error.syscall).toBe("connect");
});

// Winsock refuses INADDR_ANY as a destination (POSIX kernels rewrite it to
// loopback), so this is a second, different connect(2) error on Windows:
// the reported code comes from the failed call, not from a fixed mapping.
test.if(isWindows)("connecting to 0.0.0.0 reports EADDRNOTAVAIL", async () => {
const error = await connectError({ hostname: "0.0.0.0", port: 1 });
expect(error.name).toBe("Error");
expect(error.code).toBe("EADDRNOTAVAIL");
expect(error.errno).toBeNegative();
expect(error.syscall).toBe("connect");
});
});

// Out-of-range connect.port used to be silently rewritten to 0, so send()
Expand Down
Loading