diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index a1190b3a6515..0b92c5775e26 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1173,6 +1173,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd ) { if (bsd_set_reuse(listenFd, options) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } @@ -1189,6 +1190,7 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd if (listenAddr->ai_family == AF_INET6) { int enabled = (options & LIBUS_SOCKET_IPV6_ONLY) != 0; if (setsockopt(listenFd, IPPROTO_IPV6, IPV6_V6ONLY, &enabled, sizeof(enabled)) != 0) { + *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } } @@ -1357,7 +1359,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_bound_socket(const char *host, int port, int return fd; } -LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error) { +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error, int* dns_error) { bsd_winsock_ensure(); struct addrinfo hints, *result; memset(&hints, 0, sizeof(struct addrinfo)); @@ -1369,7 +1371,28 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int char port_string[16]; snprintf(port_string, 16, "%d", port); - if (getaddrinfo(host, port_string, &hints, &result)) { +#ifndef _WIN32 + errno = 0; +#endif + int gai = getaddrinfo(host, port_string, &hints, &result); + if (gai != 0) { +#ifndef _WIN32 + /* At the descriptor limit glibc answers EAI_NONAME or EAI_SYSTEM, with the cause in errno. */ + if (errno != 0 && (gai == EAI_SYSTEM || errno == EMFILE || errno == ENFILE)) { + *error = errno; + return LIBUS_SOCKET_ERROR; + } +#endif + if (host && strchr(host, ':')) { + /* An IPv6 literal needs no lookup. It fails when its %zone names no interface. */ +#ifdef _WIN32 + *error = WSAEINVAL; +#else + *error = EINVAL; +#endif + return LIBUS_SOCKET_ERROR; + } + *dns_error = gai; return LIBUS_SOCKET_ERROR; } @@ -1377,7 +1400,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int struct addrinfo *listenAddr; for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET6) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } @@ -1394,7 +1417,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int for (struct addrinfo *a = result; a != NULL; a = a->ai_next) { if (a->ai_family == AF_INET) { - listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); + listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, error); if (listenFd == LIBUS_SOCKET_ERROR) { continue; } @@ -1554,20 +1577,18 @@ static LIBUS_SOCKET_DESCRIPTOR bsd_create_unix_socket_address(const char *path, static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(const char* path, int options, struct sockaddr_un* server_address, size_t addrlen, int* error) { LIBUS_SOCKET_DESCRIPTOR listenFd = LIBUS_SOCKET_ERROR; - listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL); + listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, error); if (listenFd == LIBUS_SOCKET_ERROR) { return LIBUS_SOCKET_ERROR; } if (us_internal_bind_and_listen(listenFd, (struct sockaddr *) server_address, (socklen_t) addrlen, 512, error)) { - #if defined(_WIN32) - int shouldSimulateENOENT = WSAGetLastError() == WSAENETDOWN; - #endif bsd_close_socket(listenFd); #if defined(_WIN32) - if (shouldSimulateENOENT) { - SetLastError(ERROR_PATH_NOT_FOUND); + /* bind() answers WSAENETDOWN when the directory of the path does not exist. */ + if (*error == WSAENETDOWN) { + *error = ERROR_PATH_NOT_FOUND; } #endif return LIBUS_SOCKET_ERROR; @@ -1582,9 +1603,13 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l size_t addrlen = 0; if (bsd_create_unix_socket_address(path, len, &dirfd_workaround_for_unix_path_len, &server_address, &addrlen)) { /* The path could not be expressed as a sockaddr_un (the basename - * exceeds sun_path even with the dirfd workaround); surface the errno - * so the caller can report something better than a codeless failure. */ - if (error && errno) *error = errno; + * exceeds sun_path even with the dirfd workaround). That helper + * reports through SetLastError on Windows and errno elsewhere. */ +#if defined(_WIN32) + *error = (int) GetLastError(); +#else + *error = errno; +#endif return LIBUS_SOCKET_ERROR; } @@ -1592,7 +1617,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l if (dirfd_workaround_for_unix_path_len != -1) { if (__pthread_fchdir(dirfd_workaround_for_unix_path_len) != 0) { close(dirfd_workaround_for_unix_path_len); - errno = ENAMETOOLONG; + *error = ENAMETOOLONG; return LIBUS_SOCKET_ERROR; } } @@ -1602,10 +1627,8 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t l #if defined(__APPLE__) if (dirfd_workaround_for_unix_path_len != -1) { - int saved_errno = errno; __pthread_fchdir(-1); close(dirfd_workaround_for_unix_path_len); - errno = saved_errno; } #elif defined(__linux__) if (dirfd_workaround_for_unix_path_len != -1) { diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 2cd6206381f7..97d94e51e190 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -373,8 +373,8 @@ static void us_internal_init_listen_socket(struct us_listen_socket_t *ls, struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *host, int port, int options, int socket_ext_size, int *error) { - LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket(host, port, options, error); + const char *host, int port, int options, int socket_ext_size, int *error, int *dns_error) { + LIBUS_SOCKET_DESCRIPTOR listen_socket_fd = bsd_create_listen_socket(host, port, options, error, dns_error); if (listen_socket_fd == LIBUS_SOCKET_ERROR) { return 0; } @@ -382,14 +382,10 @@ struct us_listen_socket_t *us_socket_group_listen(struct us_socket_group_t *grou struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_listen_socket_t)); us_poll_init(p, listen_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { - /* EPOLL_CTL_ADD failed (e.g. ENOSPC at fs.epoll.max_user_watches). - * Report via both the out-param and thread-local errno: Bun.listen - * reads *error, Bun.serve reads errno. */ - int saved_errno = errno; + /* EPOLL_CTL_ADD failed (e.g. ENOSPC at fs.epoll.max_user_watches). */ + *error = errno; bsd_close_socket(listen_socket_fd); us_poll_free(p, group->loop); - *error = saved_errno; - errno = saved_errno; return 0; } @@ -448,11 +444,9 @@ struct us_listen_socket_t *us_socket_group_listen_unix(struct us_socket_group_t struct us_poll_t *p = us_create_poll(group->loop, 0, sizeof(struct us_listen_socket_t)); us_poll_init(p, listen_socket_fd, POLL_TYPE_SEMI_SOCKET); if (us_poll_start_rc(p, group->loop, LIBUS_SOCKET_READABLE) != 0) { - int saved_errno = errno; + *error = errno; bsd_close_socket(listen_socket_fd); us_poll_free(p, group->loop); - *error = saved_errno; - errno = saved_errno; return 0; } diff --git a/packages/bun-usockets/src/internal/networking/bsd.h b/packages/bun-usockets/src/internal/networking/bsd.h index e269a5ca81c5..bcd3c4a8bdd1 100644 --- a/packages/bun-usockets/src/internal/networking/bsd.h +++ b/packages/bun-usockets/src/internal/networking/bsd.h @@ -231,7 +231,7 @@ int bsd_send_is_transient_error(); // return LIBUS_SOCKET_ERROR or the fd that represents listen socket // listen both on ipv6 and ipv4 -LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error); +LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options, int* error, int* dns_error); LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket_unix(const char *path, size_t pathlen, int options, int* error); diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index a11ffb59fa34..2a5cbe5120c2 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -395,11 +395,16 @@ void us_socket_set_first_flight_before_fin(us_socket_r s) nonnull_fn_decl; /* ── Listen ─────────────────────────────────────────────────────────────── * The listener owns: an embedded group for accepted sockets, the SSL_CTX * (borrowed ref, optional), the SNI tree (optional), and the kind to stamp on - * accepted sockets. */ + * accepted sockets. + * + * On NULL, *error is the code of the call that failed: errno, or a WSA / + * Win32 code on Windows. When the lookup of `host` failed instead, *error is + * as the caller set it and *dns_error is what getaddrinfo() returned. The two + * number spaces overlap, so callers zero both first. */ struct us_listen_socket_t *us_socket_group_listen(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, - const char *host, int port, int options, int socket_ext_size, int *error) - __attribute__((nonnull(1, 8))); /* ssl_ctx, host nullable */ + const char *host, int port, int options, int socket_ext_size, int *error, int *dns_error) + __attribute__((nonnull(1, 8, 9))); /* ssl_ctx, host nullable */ struct us_listen_socket_t *us_socket_group_listen_unix(us_socket_group_r group, unsigned char kind, struct ssl_ctx_st *ssl_ctx, const char *path, size_t pathlen, int options, int socket_ext_size, int *error) diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 6396a840d0d3..09e5cdccceee 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -727,31 +727,13 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: - /* Host, port, options, callback */ - TemplatedApp &&listen(const std::string &host, int port, int options, MoveOnlyFunction &&handler) { - if (host.empty()) { - return listen(port, options, std::move(handler)); - } - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host.c_str(), port, options)) : nullptr); - return std::move(*this); - } - - /* Port, callback */ - TemplatedApp &&listen(int port, MoveOnlyFunction &&handler) { - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, 0)) : nullptr); - return std::move(*this); + /* host is nullptr for every address. The out-params are those of us_socket_group_listen. */ + us_listen_socket_t *listen(const char *host, int port, int options, int *error, int *dnsError) { + return httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host, port, options, error, dnsError)) : nullptr; } - /* Port, options, callback */ - TemplatedApp &&listen(int port, int options, MoveOnlyFunction &&handler) { - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, options)) : nullptr); - return std::move(*this); - } - - /* options, callback, path to unix domain socket */ - TemplatedApp &&listen(int options, MoveOnlyFunction &&handler, std::string_view path) { - handler(httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options)) : nullptr); - return std::move(*this); + us_listen_socket_t *listen_unix(std::string_view path, int options, int *error) { + return httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options, error)) : nullptr; } void setOnSocketClosed(HttpContextData::OnSocketClosedCallback onClose) { diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 9c041bdf611c..57d2446362e3 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1283,15 +1283,13 @@ struct HttpContext { } /* Listen to port using this HttpContext. ssl_ctx may be nullptr for plain HTTP. */ - us_listen_socket_t *listen(struct ssl_ctx_st *sslCtx, const char *host, int port, int options) { - int error = 0; - return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), &error)); + us_listen_socket_t *listen(struct ssl_ctx_st *sslCtx, const char *host, int port, int options, int *error, int *dnsError) { + return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), error, dnsError)); } /* Listen to unix domain socket using this HttpContext */ - us_listen_socket_t *listen_unix(struct ssl_ctx_st *sslCtx, const char *path, size_t pathlen, int options) { - int error = 0; - return unrefListenSocket(us_socket_group_listen_unix(&group, socketKind(), sslCtx, path, pathlen, options, socketExtSize(), &error)); + us_listen_socket_t *listen_unix(struct ssl_ctx_st *sslCtx, const char *path, size_t pathlen, int options, int *error) { + return unrefListenSocket(us_socket_group_listen_unix(&group, socketKind(), sslCtx, path, pathlen, options, socketExtSize(), error)); } }; diff --git a/src/runtime/dns_jsc/cares_jsc.rs b/src/runtime/dns_jsc/cares_jsc.rs index 53c71c072f45..7cccb199bcf3 100644 --- a/src/runtime/dns_jsc/cares_jsc.rs +++ b/src/runtime/dns_jsc/cares_jsc.rs @@ -845,6 +845,15 @@ pub(crate) fn error_to_js_with_syscall_and_hostname( Ok(instance) } +/// The `getaddrinfo ` error for `gai`, a non-zero return of getaddrinfo(3). +pub(crate) fn getaddrinfo_error(gai: c_int, hostname: &[u8]) -> Option { + if gai == 0 { + return None; + } + c_ares::Error::init_eai(gai) + .map(|error| system_error_with_syscall_and_hostname(error, b"getaddrinfo", hostname)) +} + /// Thrown before uSockets' synchronous `getaddrinfo` can block on a name that can never resolve. pub(crate) fn not_a_hostname_error(global_this: &JSGlobalObject, hostname: &[u8]) -> JSValue { system_error_with_syscall_and_hostname(c_ares::Error::ENOTFOUND, b"getaddrinfo", hostname) diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 7284a0a2b9c7..ebc18f85bdb8 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2021,9 +2021,13 @@ impl NewServer { } } - pub(crate) fn on_listen(&mut self, socket: Option<*mut uws_sys::app::ListenSocket>) { - let Some(socket) = socket else { - return self.on_listen_failed(); + pub(crate) fn on_listen( + &mut self, + listened: Result<*mut uws_sys::app::ListenSocket, uws_sys::app::ListenError>, + ) { + let socket = match listened { + Ok(socket) => socket, + Err(failure) => return self.on_listen_failed(failure), }; self.listener = Some(socket); // SAFETY: `vm_mut()` is the process-static `*mut VirtualMachine` (non-null @@ -2038,89 +2042,58 @@ impl NewServer { } } - /// Build the bind/listen failure as a `SystemError` (so JS sees - /// `err.code`/`err.syscall`) and `globalThis.throwValue` it. The BoringSSL - /// error-stack drain is still TODO; the EADDRINUSE/ - /// EACCES paths below cover the node:http `server.listen` error contract. #[cold] - pub(crate) fn on_listen_failed(&mut self) { + pub(crate) fn on_listen_failed(&mut self, failure: uws_sys::app::ListenError) { self.listener = None; let global = self.global_this(); - let error_instance = match &self.config.address { - server_config::Address::Tcp { - port, - hostname: _hostname, - } => { - // Rust's `target_os = "linux"` excludes - // Android, so match both explicitly. - #[cfg(any(target_os = "linux", target_os = "android"))] - { - let errno = bun_sys::last_error(); - if errno == bun_sys::E::EACCES { - let host = _hostname - .as_ref() - .map(|h| h.as_bytes()) - .unwrap_or(b"0.0.0.0"); - let err = jsc::SystemError { - message: bun_core::String::create_format(format_args!( - "permission denied {}:{}", - bstr::BStr::new(host), - port - )), - code: bun_core::String::static_("EACCES"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - }; - let _ = global.throw_value(err.to_error_instance(global)); - return; - } - // e.g. ENOSPC from epoll_ctl(EPOLL_CTL_ADD). Linux-only because - // on other platforms errno is not reliably preserved through - // the C++/callback chain to here; see PR #30364. - if errno != bun_sys::E::SUCCESS && errno != bun_sys::E::EADDRINUSE { - let err = jsc::SystemError::from( - bun_sys::Error::from_code(errno, bun_sys::Tag::listen) - .to_system_error(), - ); - let _ = global.throw_value(err.to_error_instance(global)); - return; - } - } - jsc::SystemError { - message: bun_core::String::create_format(format_args!( + let hostname = match &self.config.address { + server_config::Address::Tcp { hostname, .. } => hostname.as_ref().map(|h| h.as_bytes()), + server_config::Address::Unix(_) => None, + }; + if let Some(err) = + crate::dns_jsc::cares_jsc::getaddrinfo_error(failure.dns_error, hostname.unwrap_or(b"")) + { + let _ = global.throw_value(err.to_error_instance(global)); + return; + } + + let errno = uws_sys::SocketGroup::listen_errno(failure.error) + .unwrap_or(bun_sys::SystemErrno::EUNKNOWN); + let err = match &self.config.address { + server_config::Address::Tcp { port, .. } => { + // Winsock has no privileged ports: WSAEACCES means the port is held or in an excluded range. + #[cfg(windows)] + let errno = if errno == bun_sys::SystemErrno::EACCES { + bun_sys::SystemErrno::EADDRINUSE + } else { + errno + }; + let mut err = jsc::SystemError::from( + bun_sys::Error::new(errno, bun_sys::Tag::listen).to_system_error(), + ); + if errno == bun_sys::SystemErrno::EADDRINUSE { + err.message = bun_core::String::create_format(format_args!( "Failed to start server. Is port {} in use?", port - )), - code: bun_core::String::static_("EADDRINUSE"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - } - .to_error_instance(global) - } - server_config::Address::Unix(unix) => { - let unix = unix.as_bytes(); - match bun_sys::last_error() { - bun_sys::E::SUCCESS => jsc::SystemError { - message: bun_core::String::create_format(format_args!( - "Failed to listen on unix socket {}", - bun_core::fmt::QuotedFormatter { text: unix } - )), - code: bun_core::String::static_("EADDRINUSE"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - } - .to_error_instance(global), - e => jsc::SystemError::from( - bun_sys::Error::from_code(e, bun_sys::Tag::listen) - .with_path(unix) - .to_system_error(), - ) - .to_error_instance(global), + )); + } else if errno == bun_sys::SystemErrno::EACCES { + err.message = bun_core::String::create_format(format_args!( + "permission denied {}:{}", + bstr::BStr::new(hostname.unwrap_or(b"0.0.0.0")), + port + )); } + err } + server_config::Address::Unix(unix) => jsc::SystemError::from( + bun_sys::Error::new(errno, bun_sys::Tag::listen) + .with_path(unix.as_bytes()) + .to_system_error(), + ), }; + let error_instance = err.to_error_instance(global); error_instance.ensure_still_alive(); let _ = global.throw_value(error_instance); } @@ -3173,20 +3146,16 @@ impl NewServer { loop { attempt += 1; if tcp { - // SAFETY: app is a live uws handle owned by this server. No - // `&*this` is live across this call; the trampoline's - // `&mut *this` is the sole borrow while it runs. - unsafe { - (*app).listen_with_config( - Some(trampoline::on_listen::), - this.cast::(), - uws_app_c::uws_app_listen_config_t { - port: port as c_int, - host, - options, - }, - ); - } + // SAFETY: app is a live uws handle owned by this server. + let listened = unsafe { + (*app).listen_with_config(uws_app_c::uws_app_listen_config_t { + port: port as c_int, + host, + options, + }) + }; + // SAFETY: `this` is the live boxed server; no other borrow is live. + unsafe { (*this).on_listen(listened) }; } if Self::HAS_H3 { @@ -3261,16 +3230,10 @@ impl NewServer { // SAFETY: ptr/len reference `config.address`'s ZBox; NUL // sentinel at `ptr[len]` holds for ZStr::from_raw. let z = unsafe { bun_core::ZStr::from_raw(ptr, len) }; - // SAFETY: app is a live uws handle owned by this server. No - // `&*this` is live across this call. - unsafe { - (*app).listen_on_unix_socket( - trampoline::on_listen_unix::, - this.cast::(), - z, - options, - ); - } + // SAFETY: app is a live uws handle owned by this server. + let listened = unsafe { (*app).listen_on_unix_socket(z, options) }; + // SAFETY: `this` is the live boxed server; no other borrow is live. + unsafe { (*this).on_listen(listened) }; } } @@ -3428,30 +3391,7 @@ impl NewServer { // the bodies downcast `user_data` and forward into the typed method. mod trampoline { use super::*; - use bun_uws_sys::{ListenSocket as UwsListenSocket, Request as UwsRequest, uws_res}; - - pub(super) extern "C" fn on_listen( - socket: *mut UwsListenSocket, - user_data: *mut c_void, - ) { - // SAFETY: user_data is the `*mut NewServer<..>` passed to listen_with_config. - let server = unsafe { bun_ptr::callback_ctx::>(user_data) }; - let socket = if socket.is_null() { - None - } else { - Some(socket.cast::>()) - }; - server.on_listen(socket); - } - - pub(super) extern "C" fn on_listen_unix( - socket: *mut UwsListenSocket, - _domain: *const c_char, - _flags: i32, - user_data: *mut c_void, - ) { - on_listen::(socket, user_data); - } + use bun_uws_sys::{Request as UwsRequest, uws_res}; pub(super) extern "C" fn on_404( res: *mut uws_res, diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 056788e3b556..6b4331e64e6b 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -463,6 +463,7 @@ impl Listener { this_ref.secure_ctx.get().as_ref().map(|p| p.as_ptr()); let mut errno: c_int = 0; + let mut dns_error: c_int = 0; let listen_socket: *mut uws_sys::ListenSocket = match &mut connection { UnixOrHost::Host { host, port } => { let hostz = bun_core::ZBox::from_bytes(&host[..]); @@ -476,6 +477,7 @@ impl Listener { socket_flags, size_of::<*mut c_void>() as c_int, &mut errno, + &mut dns_error, ) }); if !ls.is_null() { @@ -518,13 +520,18 @@ impl Listener { UnixOrHost::Unix(u) => u, UnixOrHost::Fd(_) => b"", }; + log!("Failed to listen {} {}", errno, dns_error); + if let Some(err) = + crate::dns_jsc::cares_jsc::getaddrinfo_error(dns_error, hostname_bytes) + { + return Err(cx.global().throw_value(err.to_error_instance(cx.global()))); + } let err = cx.global().create_error_instance(format_args!( "Failed to listen at {}", bstr::BStr::new(hostname_bytes) )); - log!("Failed to listen {}", errno); - let mapped = bun_sys::SystemErrno::init(errno as i64); - let errno = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) + let mapped = uws::SocketGroup::listen_errno(errno); + let mapped = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) || (matches!(connection, UnixOrHost::Fd(_)) && matches!( mapped, @@ -532,9 +539,9 @@ impl Listener { | Some(bun_sys::SystemErrno::EBADF) | Some(bun_sys::SystemErrno::EOPNOTSUPP) )) { - bun_sys::SystemErrno::EINVAL as c_int + Some(bun_sys::SystemErrno::EINVAL) } else { - errno + mapped }; if errno != 0 { err.put( @@ -542,7 +549,15 @@ impl Listener { b"syscall", BunString::static_("listen").to_js(cx.global())?, ); - err.put(cx.global(), b"errno", JSValue::js_number(errno as f64)); + let js_errno = match mapped { + Some(code) => { + bun_sys::Error::new(code, bun_sys::Tag::listen) + .to_system_error() + .errno + } + None => errno, + }; + err.put(cx.global(), b"errno", JSValue::js_number(js_errno as f64)); err.put( cx.global(), b"address", @@ -551,11 +566,11 @@ impl Listener { if let Some(p) = port { err.put(cx.global(), b"port", JSValue::js_number(p as f64)); } - if let Some(str_) = bun_sys::SystemErrno::init(errno as i64) { + if let Some(code) = mapped { err.put( cx.global(), b"code", - BunString::static_(<&'static str>::from(str_)).to_js(cx.global())?, + BunString::static_(<&'static str>::from(code)).to_js(cx.global())?, ); } } diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 9ec9afb62143..c49e691cd8d6 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -295,44 +295,49 @@ impl App { pub fn listen_with_config( &mut self, - handler: c::uws_listen_handler, - user_data: *mut c_void, config: c::uws_app_listen_config_t, - ) { - // Callers supply the C-ABI shim directly. + ) -> Result<*mut ListenSocket, ListenError> { + let mut failure = ListenError::default(); // SAFETY: self is a valid app; config.host (if non-null) is NUL-terminated and outlives the call. - unsafe { + let socket = unsafe { c::uws_app_listen_with_config( Self::SSL_FLAG, std::ptr::from_mut::(self).cast::(), config.host, u16::try_from(config.port).expect("int cast"), config.options, - handler, - user_data, + &raw mut failure.error, + &raw mut failure.dns_error, ) + }; + if socket.is_null() { + Err(failure) + } else { + Ok(socket.cast::>()) } } pub fn listen_on_unix_socket( &mut self, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), - user_data: *mut c_void, domain_name: &ZStr, flags: i32, - ) { - // Callers supply the C-ABI shim directly. + ) -> Result<*mut ListenSocket, ListenError> { + let mut failure = ListenError::default(); // SAFETY: self is a valid app; domain_name is NUL-terminated. - unsafe { + let socket = unsafe { c::uws_app_listen_domain_with_options( Self::SSL_FLAG, std::ptr::from_mut::(self).cast::(), domain_name.as_ptr().cast(), domain_name.len(), flags, - handler, - user_data, + &raw mut failure.error, ) + }; + if socket.is_null() { + Err(failure) + } else { + Ok(socket.cast::>()) } } @@ -446,6 +451,13 @@ impl App { // directly. } +/// The two out-params of `us_socket_group_listen` after it returned null. +#[derive(Clone, Copy, Default)] +pub struct ListenError { + pub error: c_int, + pub dns_error: c_int, +} + /// Opaque listen socket handle, parameterized by SSL to match `App`. #[repr(C)] pub struct ListenSocket { @@ -489,7 +501,6 @@ pub(crate) type uws_app_t = uws_app_s; pub mod c { use super::*; - pub(crate) type uws_listen_handler = Option; pub(crate) type uws_method_handler = Option; // The C++ shim hands the filter the uws_res_t*, which for HTTP server @@ -620,9 +631,9 @@ pub mod c { host: *const c_char, port: u16, options: i32, - handler: uws_listen_handler, - user_data: *mut c_void, - ); + error: *mut c_int, + dns_error: *mut c_int, + ) -> *mut UwsListenSocket; pub(crate) fn uws_num_subscribers( ssl: i32, app: *mut uws_app_t, @@ -659,9 +670,8 @@ pub mod c { domain: *const c_char, pathlen: usize, flags: i32, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), - user_data: *mut c_void, - ); + error: *mut c_int, + ) -> *mut UwsListenSocket; pub(crate) safe fn uws_app_clear_routes(ssl_flag: c_int, app: &mut uws_app_t); } diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index b7f09b522fcf..6a738bb0d091 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -149,6 +149,7 @@ impl SocketGroup { options: c_int, socket_ext_size: c_int, err: &mut c_int, + dns_err: &mut c_int, ) -> *mut ListenSocket { // SAFETY: forwarding to C; all pointers are valid or null as documented. unsafe { @@ -161,6 +162,7 @@ impl SocketGroup { options, socket_ext_size, err, + dns_err, ) } } @@ -214,6 +216,21 @@ impl SocketGroup { } } + /// Decodes the `err` of a failed `listen*`: an errno, or a Windows error code. + pub fn listen_errno(err: c_int) -> Option { + if err == 0 { + return None; + } + #[cfg(windows)] + { + bun_errno::SystemErrno::init(err.unsigned_abs()) + } + #[cfg(not(windows))] + { + bun_errno::SystemErrno::init(i64::from(err)) + } + } + pub fn connect( &mut self, kind: SocketKind, @@ -322,6 +339,7 @@ unsafe extern "C" { options: c_int, socket_ext_size: c_int, err: *mut c_int, + dns_err: *mut c_int, ) -> *mut ListenSocket; fn us_socket_group_listen_unix( group: *mut SocketGroup, diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index 395398448b62..63fbab5eb551 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -123,12 +123,6 @@ typedef struct { uws_websocket_close_handler close; } uws_socket_behavior_t; -typedef void (*uws_listen_handler)(struct us_listen_socket_t* listen_socket, - void* user_data); -typedef void (*uws_listen_domain_handler)( - struct us_listen_socket_t* listen_socket, const char* domain, int options, - void* user_data); - typedef void (*uws_method_handler)(uws_res_t* response, uws_req_t* request, void* user_data); typedef void (*uws_filter_handler)(uws_res_t* response, int, void* user_data); diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index d9dc129a943d..8f402849a608 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -423,52 +423,28 @@ extern "C" } } - void uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, - uint16_t port, int32_t options, - uws_listen_handler handler, void *user_data) + struct us_listen_socket_t *uws_app_listen_with_config(int ssl, uws_app_t *app, const char *host, + uint16_t port, int32_t options, + int *error, int *dns_error) { - std::string hostname = host && host[0] ? std::string(host, strlen(host)) : ""; - if (ssl) + if (host && !host[0]) { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + host = nullptr; } - else + if (ssl) { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->listen( - hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) - { - handler((struct us_listen_socket_t *)listen_socket, user_data); - }); + return ((uWS::SSLApp *)app)->listen(host, port, options, error, dns_error); } + return ((uWS::App *)app)->listen(host, port, options, error, dns_error); } - /* callback, path to unix domain socket */ - void uws_app_listen_domain_with_options(int ssl, uws_app_t *app, const char *domain, size_t pathlen, int options, uws_listen_domain_handler handler, void *user_data) + struct us_listen_socket_t *uws_app_listen_domain_with_options(int ssl, uws_app_t *app, const char *domain, size_t pathlen, int options, int *error) { if (ssl) { - uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; - uwsApp->listen( - options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, user_data); }, - {domain, pathlen}); - } - else - { - uWS::App *uwsApp = (uWS::App *)app; - uwsApp->listen( - options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, user_data); }, - {domain, pathlen}); + return ((uWS::SSLApp *)app)->listen_unix({domain, pathlen}, options, error); } + return ((uWS::App *)app)->listen_unix({domain, pathlen}, options, error); } void uws_app_domain(int ssl, uws_app_t *app, const char *server_name) diff --git a/test/js/bun/http/serve-epoll-add-fail.test.ts b/test/js/bun/http/serve-epoll-add-fail.test.ts index 4632ff9d5943..b1e7934c0417 100644 --- a/test/js/bun/http/serve-epoll-add-fail.test.ts +++ b/test/js/bun/http/serve-epoll-add-fail.test.ts @@ -1,33 +1,40 @@ // An LD_PRELOAD shim makes epoll_ctl(EPOLL_CTL_ADD) fail with ENOSPC (what the // kernel returns when fs.epoll.max_user_watches is exhausted) so Bun.serve / // Bun.listen must throw and accepted connections must be closed, not parked. +// The shim also fails two calls that come before it in a listen: getaddrinfo() +// for two fixed names, and one setsockopt(). import { afterAll, beforeAll, expect, test } from "bun:test"; import { bunEnv, bunExe, isLinux, tempDir } from "harness"; import net from "node:net"; +import { networkInterfaces } from "node:os"; import { join } from "node:path"; +import { getSystemErrorName } from "node:util"; const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); // FAIL_EPOLL_ADD=listener: listening TCP sockets (SO_ACCEPTCONN). // FAIL_EPOLL_ADD=accepted: connected SOCK_STREAM. FAIL_EPOLL_ADD=udp: SOCK_DGRAM. -// Non-socket fds (timerfd, eventfd) always pass through. +// FAIL_EPOLL_ADD=none: no socket. Non-socket fds (timerfd, eventfd) always pass through. +// FAIL_SETSOCKOPT=v6only or reuseport: that option fails with EPERM. const SHIM_C = /* c */ ` #define _GNU_SOURCE #include #include +#include +#include #include #include #include #include static int (*real_epoll_ctl)(int, int, int, struct epoll_event *); -static int mode = -1; // 0 = listener, 1 = accepted, 2 = udp +static int mode = -1; // 0 = listener, 1 = accepted, 2 = udp, 3 = none int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) { if (!real_epoll_ctl) { real_epoll_ctl = (int (*)(int, int, int, struct epoll_event *)) dlsym(RTLD_NEXT, "epoll_ctl"); const char *m = getenv("FAIL_EPOLL_ADD"); - mode = (m && strcmp(m, "udp") == 0) ? 2 : (m && strcmp(m, "accepted") == 0) ? 1 : 0; + mode = (m && strcmp(m, "none") == 0) ? 3 : (m && strcmp(m, "udp") == 0) ? 2 : (m && strcmp(m, "accepted") == 0) ? 1 : 0; } if (op == EPOLL_CTL_ADD) { int acceptconn = 0, type = 0; @@ -46,6 +53,72 @@ int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event) { } return real_epoll_ctl(epfd, op, fd, event); } + +int getaddrinfo(const char *node, const char *service, const struct addrinfo *hints, struct addrinfo **res) { + static int (*real)(const char *, const char *, const struct addrinfo *, struct addrinfo **); + if (!real) real = (int (*)(const char *, const char *, const struct addrinfo *, struct addrinfo **)) dlsym(RTLD_NEXT, "getaddrinfo"); + if (node && strcmp(node, "eai-noname.test") == 0) return EAI_NONAME; + if (node && strcmp(node, "eai-again.test") == 0) return EAI_AGAIN; + return real(node, service, hints, res); +} + +int setsockopt(int fd, int level, int name, const void *value, socklen_t len) { + static int (*real)(int, int, int, const void *, socklen_t); + if (!real) real = (int (*)(int, int, int, const void *, socklen_t)) dlsym(RTLD_NEXT, "setsockopt"); + const char *fail = getenv("FAIL_SETSOCKOPT"); + if (fail && ((strcmp(fail, "v6only") == 0 && level == IPPROTO_IPV6 && name == IPV6_V6ONLY) || + (strcmp(fail, "reuseport") == 0 && level == SOL_SOCKET && name == SO_REUSEPORT))) { + errno = EPERM; + return -1; + } + return real(fd, level, name, value, len); +} +`; + +// Every way to listen on a TCP hostname, for the two names that the shim refuses. +const DNS_FIXTURE = /* js */ ` +const shape = ({ code, syscall, hostname, message }) => ({ code, syscall, hostname, message }); +const out = {}; +for (const hostname of ["eai-noname.test", "eai-again.test"]) { + try { + Bun.serve({ hostname, port: 0, fetch: () => new Response() }).stop(true); + out["Bun.serve " + hostname] = "listening"; + } catch (e) { + out["Bun.serve " + hostname] = shape(e); + } + try { + Bun.listen({ hostname, port: 0, socket: { data() {} } }).stop(true); + out["Bun.listen " + hostname] = "listening"; + } catch (e) { + out["Bun.listen " + hostname] = shape(e); + } + for (const name of ["http", "net"]) { + out[name + " " + hostname] = await new Promise(resolve => { + const server = require("node:" + name).createServer(); + server.on("error", e => resolve(shape(e))); + server.listen(0, hostname, () => server.close(() => resolve("listening"))); + }); + } +} +console.log(JSON.stringify(out)); +`; + +const SETSOCKOPT_FIXTURE = /* js */ ` +const address = JSON.parse(process.env.ADDRESS); +const out = {}; +try { + Bun.serve({ ...address, fetch: () => new Response() }).stop(true); + out.serve = "listening"; +} catch (e) { + out.serve = { code: e.code, syscall: e.syscall, errno: e.errno }; +} +try { + Bun.listen({ ...address, socket: { data() {} } }).stop(true); + out.listen = "listening"; +} catch (e) { + out.listen = { code: e.code, syscall: e.syscall, errno: e.errno }; +} +console.log(JSON.stringify(out)); `; const SERVE_FIXTURE = /* js */ ` @@ -147,6 +220,8 @@ beforeAll(async () => { "fetch.js": FETCH_FIXTURE, "connect.js": CONNECT_FIXTURE, "udp.js": UDP_FIXTURE, + "dns.js": DNS_FIXTURE, + "setsockopt.js": SETSOCKOPT_FIXTURE, }); shimPath = join(String(dir), "shim.so"); await using ccProc = Bun.spawn({ @@ -165,16 +240,18 @@ afterAll(() => { dir?.[Symbol.dispose](); }); -function shimEnv(mode: "listener" | "accepted" | "udp") { +type ShimMode = "listener" | "accepted" | "udp" | "none"; + +function shimEnv(mode: ShimMode) { const existing = bunEnv.LD_PRELOAD; return { ...bunEnv, LD_PRELOAD: existing ? `${shimPath}:${existing}` : shimPath, FAIL_EPOLL_ADD: mode }; } -async function runWithShim(script: string, mode: "listener" | "accepted" | "udp" = "listener") { +async function runWithShim(script: string, mode: ShimMode = "listener", env: Record = {}) { await using proc = Bun.spawn({ cmd: [bunExe(), script], cwd: String(dir), - env: shimEnv(mode), + env: { ...shimEnv(mode), ...env }, stdout: "pipe", stderr: "pipe", }); @@ -208,7 +285,7 @@ test.concurrent.skipIf(!isLinux || !cc)( const result = JSON.parse(line); expect(result).toEqual({ ok: false, - errno: 28, // ENOSPC + errno: -28, // ENOSPC code: "ENOSPC", syscall: "listen", message: expect.any(String), @@ -332,3 +409,59 @@ test.concurrent.skipIf(!isLinux || !cc)( expect(exitCode).toBe(0); }, ); + +test.concurrent.skipIf(!isLinux || !cc)( + "a listen on a hostname that getaddrinfo() refuses throws the getaddrinfo error", + async () => { + const { stdout, stderr, exitCode } = await runWithShim("dns.js", "none"); + const notFound = { + code: "ENOTFOUND", + syscall: "getaddrinfo", + hostname: "eai-noname.test", + message: "getaddrinfo ENOTFOUND eai-noname.test", + }; + const again = { + code: "EAI_AGAIN", + syscall: "getaddrinfo", + hostname: "eai-again.test", + message: "getaddrinfo EAI_AGAIN eai-again.test", + }; + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ + out: { + "Bun.serve eai-noname.test": notFound, + "Bun.listen eai-noname.test": notFound, + "http eai-noname.test": notFound, + "net eai-noname.test": notFound, + "Bun.serve eai-again.test": again, + "Bun.listen eai-again.test": again, + "http eai-again.test": again, + "net eai-again.test": again, + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }, +); + +const hasIPv6 = Object.values(networkInterfaces()) + .flat() + .some(network => network?.family === "IPv6"); + +test.concurrent.skipIf(!isLinux || !cc).each([ + ["IPV6_V6ONLY", "v6only", { hostname: "::1", port: 0 }, hasIPv6], + ["SO_REUSEPORT", "reuseport", { hostname: "127.0.0.1", port: 0, reusePort: true }, true], +] as const)("a listen whose setsockopt(%s) fails throws that error", async (_, option, address, possible) => { + // Without IPv6 the socket() call for ::1 fails first. + if (!possible) return; + const { stdout, stderr, exitCode } = await runWithShim("setsockopt.js", "none", { + FAIL_SETSOCKOPT: option, + ADDRESS: JSON.stringify(address), + }); + const out = JSON.parse(stdout || "{}"); + for (const error of Object.values(out)) { + if (error.errno < 0) error.errno = getSystemErrorName(error.errno); + } + const refused = { code: "EPERM", syscall: "listen", errno: "EPERM" }; + expect({ out, stderr }).toEqual({ out: { serve: refused, listen: refused }, stderr: "" }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index ee635d88f979..0ea302297ab2 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -1,9 +1,11 @@ import { file, serve } from "bun"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; +import { readFileSync } from "node:fs"; import type { NetworkInterfaceInfo } from "node:os"; import { networkInterfaces } from "node:os"; import { join } from "node:path"; +import { getSystemErrorName } from "node:util"; const networks = Object.values(networkInterfaces()).flat() as NetworkInterfaceInfo[]; const hasIPv4 = networks.some(({ family }) => family === "IPv4"); @@ -264,3 +266,206 @@ test.skipIf(!isLinux)("server.address / server.port do not panic when getsocknam expect(out.url).not.toContain("65535"); expect(out.url).not.toContain(":-1"); }); + +// A failed listen throws the error of the call that failed. `errno` is the +// negative libuv number that util.getSystemErrorName() takes, as in node. +describe("Bun.serve() reports why the listen failed", () => { + function shape({ code, syscall, errno, message }: any) { + return { + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + message, + }; + } + function listenError(options: object) { + try { + serve({ ...options, fetch: () => new Response() } as any).stop(true); + } catch (e) { + return shape(e); + } + } + + describe.each([ + ["http", {}], + ["https", { tls }], + ] as const)("%s", (_, protocol) => { + // Winsock answers WSAEACCES, not WSAEADDRINUSE, to some of these binds. + test.skipIf(!hasIPv4).each([ + ["127.0.0.1", { hostname: "127.0.0.1" }, {}], + ["every address", {}, {}], + ["every address with reusePort", {}, { reusePort: true }], + ["every address in production mode", {}, { development: false }], + ])("a port that is in use on %s throws EADDRINUSE", (_, address, mode) => { + using occupant = serve({ ...protocol, ...address, port: 0, fetch: () => new Response() }); + expect(listenError({ ...protocol, ...address, ...mode, port: occupant.port })).toEqual({ + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + message: `Failed to start server. Is port ${occupant.port} in use?`, + }); + }); + + test("a unix socket in a directory that does not exist throws ENOENT", () => { + const unix = isWindows ? "C:\\notfound\\listen.sock" : "/notfound/listen.sock"; + expect(listenError({ ...protocol, unix })).toEqual({ + code: "ENOENT", + syscall: "listen", + errno: "ENOENT", + message: `ENOENT: no such file or directory, listen '${unix}'`, + }); + }); + }); + + // 192.0.2.0/24 is TEST-NET-1 (RFC 5737). No interface has such an address, + // so bind() refuses it, unless Linux is configured to bind non-local addresses. + const bindsNonLocal = + isLinux && + (() => { + try { + return readFileSync("/proc/sys/net/ipv4/ip_nonlocal_bind", "utf8").trim() === "1"; + } catch { + return false; + } + })(); + test.skipIf(!hasIPv4 || bindsNonLocal)("an address that is not on this machine throws EADDRNOTAVAIL", () => { + expect(listenError({ hostname: "192.0.2.1", port: 0 })).toEqual({ + code: "EADDRNOTAVAIL", + syscall: "listen", + errno: "EADDRNOTAVAIL", + message: "EADDRNOTAVAIL: address not available, listen", + }); + }); + + // An IPv6 literal never reaches the resolver. What an unknown zone does is + // only known for Linux: getaddrinfo() refuses it. + test.skipIf(!isLinux).each(["fe80::1%nope0", "[fe80::1%nope0]"])( + "the IPv6 literal %p with a zone that names no interface throws EINVAL", + hostname => { + expect(listenError({ hostname, port: 0 })).toEqual({ + code: "EINVAL", + syscall: "listen", + errno: "EINVAL", + message: "EINVAL: invalid argument, listen", + }); + }, + ); + + // The report in #30363: Bun.serve on 127.0.0.1:80 as a user that may not + // bind it. The kernel refuses this bind before it looks at who holds the port. + const isRoot = process.getuid?.() === 0; + // root may bind any port, so the fixture gives root up. + const asNobody = isRoot ? { uid: 65534, gid: 65534 } : {}; + const refusesPort80 = (() => { + // Windows has no privileged ports. + if (isWindows) return false; + if (isLinux) { + // Linux lets every user bind a port at or above this one. + try { + if (Number(readFileSync("/proc/sys/net/ipv4/ip_unprivileged_port_start", "utf8")) <= 80) return false; + } catch {} + } + if (!isRoot) return true; + // The fixture needs a bun that the unprivileged user can execute. + try { + return Bun.spawnSync({ cmd: [bunExe(), "--version"], env: bunEnv, ...asNobody }).exitCode === 0; + } catch { + return false; + } + })(); + test.skipIf(!refusesPort80)("a privileged port throws EACCES", async () => { + const fixture = /* js */ ` + const { getSystemErrorName } = require("util"); + const shape = ({ code, syscall, errno, message }) => ({ code, syscall, errno: errno < 0 ? getSystemErrorName(errno) : errno, message }); + const out = {}; + try { + Bun.serve({ hostname: "127.0.0.1", port: 80, fetch: () => new Response() }).stop(true); + out.serve = "listening"; + } catch (e) { + out.serve = shape(e); + } + const server = require("http").createServer(); + server.on("error", e => { + out.http = shape(e); + console.log(JSON.stringify(out)); + }); + server.listen(80, "127.0.0.1", () => { + out.http = "listening"; + console.log(JSON.stringify(out)); + server.close(); + }); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + ...asNobody, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const denied = { + code: "EACCES", + syscall: "listen", + errno: "EACCES", + message: "permission denied 127.0.0.1:80", + }; + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ out: { serve: denied, http: denied }, stderr: "" }); + expect(exitCode).toBe(0); + }); + + // At the descriptor limit socket() fails before there is anything to bind. + // The unix path is relative: an absolute temporary path can be longer than + // sun_path, and the long path code opens the directory first. + test.skipIf(isWindows)("the file descriptor limit throws EMFILE", async () => { + using dir = tempDir("serve-listen-emfile", {}); + const fixture = /* js */ ` + const fs = require("fs"); + const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: "emfile.sock" } }; + if (process.env.HAS_IPV6) addresses.tcp6 = { hostname: "::1", port: 0 }; + // glibc needs a descriptor to look a name up, and reports that through errno. + if (process.platform === "linux") addresses.localhost = { hostname: "localhost", port: 0 }; + const held = []; + for (;;) { + try { + held.push(fs.openSync("/dev/null", "r")); + } catch { + break; + } + } + const errors = {}; + for (const [name, address] of Object.entries(addresses)) { + try { + Bun.serve({ ...address, fetch: () => new Response() }).stop(true); + errors[name] = "listening"; + } catch (e) { + errors[name] = { code: e.code, syscall: e.syscall, errno: e.errno }; + } + } + for (const fd of held) fs.closeSync(fd); + console.log(JSON.stringify(errors)); + `; + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'ulimit -n 256 && exec "$@"', "sh", bunExe(), "-e", fixture], + env: { ...bunEnv, HAS_IPV6: hasIPv6 ? "1" : "" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const errors = Object.entries(JSON.parse(stdout || "{}")).map(([name, error]) => [ + name, + typeof error === "string" ? error : shape({ ...(error as object), message: undefined }), + ]); + const emfile = { code: "EMFILE", syscall: "listen", errno: "EMFILE", message: undefined }; + expect({ errors: Object.fromEntries(errors), stderr }).toEqual({ + errors: { + tcp: emfile, + unix: emfile, + ...(hasIPv6 ? { tcp6: emfile } : {}), + ...(isLinux ? { localhost: emfile } : {}), + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/http/serve-syscall-fault.test.ts b/test/js/bun/http/serve-syscall-fault.test.ts index ac1a02895c08..614e2295570a 100644 --- a/test/js/bun/http/serve-syscall-fault.test.ts +++ b/test/js/bun/http/serve-syscall-fault.test.ts @@ -1,6 +1,7 @@ import { socketFaultInjection as fault } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tls as certs, isWindows } from "harness"; +import { bunEnv, bunExe, tls as certs, isWindows, tempDir } from "harness"; +import { join } from "node:path"; const skip = !fault.available() || isWindows; @@ -152,4 +153,81 @@ describe.skipIf(skip)("Bun.serve under injected syscall faults", () => { expect(proc.signalCode).toBeNull(); expect(proc.exitCode).toBe(0); }); + + // The listen socket is bound by then, so the injected errno is the only + // failure there is. Bun.serve must throw that one: its code, and in `errno` + // the negative libuv number that util.getSystemErrorName() takes. + test("poll_start → Bun.serve throws the error of the failed registration", async () => { + using dir = tempDir("serve-listen-fault", {}); + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + /* js */ ` + const { socketFaultInjection: fault } = require("bun:internal-for-testing"); + const { errno } = require("os").constants; + const { getSystemErrorName } = require("util"); + const tcp = { hostname: "127.0.0.1", port: 0 }; + const unix = { unix: process.env.SOCKET_PATH }; + const out = {}; + for (const [label, name, address] of [ + ["tcp EACCES", "EACCES", tcp], + ["tcp EADDRINUSE", "EADDRINUSE", tcp], + ["tcp ENOBUFS", "ENOBUFS", tcp], + ["unix ENOBUFS", "ENOBUFS", unix], + ]) { + fault.set({ syscall: "poll_start", action: "errno", errno: errno[name], repeat: 1 }); + try { + Bun.serve({ ...address, fetch: () => new Response() }).stop(true); + out[label] = "listening"; + } catch (e) { + out[label] = { + code: e.code, + syscall: e.syscall, + errno: e.errno < 0 ? getSystemErrorName(e.errno) : e.errno, + message: e.message.replace(process.env.SOCKET_PATH, ""), + }; + } finally { + fault.clear(); + } + } + console.log(JSON.stringify(out)); + `, + ], + env: { ...bunEnv, BUN_DEBUG_QUIET_LOGS: "1", SOCKET_PATH: join(String(dir), "fault.sock") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ out: JSON.parse(stdout || "null"), stderr }).toEqual({ + out: { + "tcp EACCES": { + code: "EACCES", + syscall: "listen", + errno: "EACCES", + message: "permission denied 127.0.0.1:0", + }, + "tcp EADDRINUSE": { + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + message: "Failed to start server. Is port 0 in use?", + }, + "tcp ENOBUFS": { + code: "ENOBUFS", + syscall: "listen", + errno: "ENOBUFS", + message: "ENOBUFS: no buffer space available, listen", + }, + "unix ENOBUFS": { + code: "ENOBUFS", + syscall: "listen", + errno: "ENOBUFS", + message: "ENOBUFS: no buffer space available, listen ''", + }, + }, + stderr: "", + }); + expect(exitCode).toBe(0); + }); }); diff --git a/test/js/bun/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index f82d0d3dbcce..a14d7039d248 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,7 +1,9 @@ import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; +import { networkInterfaces } from "node:os"; import { join } from "node:path"; +import { getSystemErrorName } from "node:util"; type Resolve = (value?: unknown) => void; type Reject = (reason?: any) => void; @@ -312,3 +314,105 @@ it("should not leak memory", async () => { expect(stdout).toBe(""); expect(exitCode).toBe(0); }); + +// `errno` of a failed Bun.listen is the negative libuv number, as in node. +function listenErrors(stdout: string) { + return Object.fromEntries( + Object.entries(JSON.parse(stdout || "{}")).map(([name, error]: [string, any]) => [ + name, + typeof error === "string" + ? error + : { ...error, errno: error.errno < 0 ? getSystemErrorName(error.errno) : error.errno }, + ]), + ); +} + +// At the descriptor limit socket() fails before there is anything to bind. +// The unix path is relative: an absolute temporary path can be longer than +// sun_path, and the long path code opens the directory first. +it.skipIf(isWindows)("Bun.listen at the file descriptor limit throws EMFILE", async () => { + using dir = tempDir("listen-emfile", {}); + const fixture = /* js */ ` + const fs = require("fs"); + const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: "emfile.sock" } }; + if (process.env.HAS_IPV6) addresses.tcp6 = { hostname: "::1", port: 0 }; + // glibc needs a descriptor to look a name up, and reports that through errno. + if (process.platform === "linux") addresses.localhost = { hostname: "localhost", port: 0 }; + const held = []; + for (;;) { + try { + held.push(fs.openSync("/dev/null", "r")); + } catch { + break; + } + } + const errors = {}; + for (const [name, address] of Object.entries(addresses)) { + try { + Bun.listen({ ...address, socket: { data() {} } }).stop(true); + errors[name] = "listening"; + } catch (e) { + errors[name] = { code: e.code, syscall: e.syscall, errno: e.errno }; + } + } + for (const fd of held) fs.closeSync(fd); + console.log(JSON.stringify(errors)); + `; + const hasIPv6 = Object.values(networkInterfaces()) + .flat() + .some(network => network?.family === "IPv6"); + await using proc = Bun.spawn({ + cmd: ["/bin/sh", "-c", 'ulimit -n 256 && exec "$@"', "sh", bunExe(), "-e", fixture], + env: { ...bunEnv, HAS_IPV6: hasIPv6 ? "1" : "" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const emfile = { code: "EMFILE", syscall: "listen", errno: "EMFILE" }; + expect({ errors: listenErrors(stdout), stderr }).toEqual({ + errors: { + tcp: emfile, + unix: emfile, + ...(hasIPv6 ? { tcp6: emfile } : {}), + ...(isLinux ? { localhost: emfile } : {}), + }, + stderr: "", + }); + expect(exitCode).toBe(0); +}); + +// Relative paths, so the length of the temporary directory does not decide +// which error the path gets. +it("Bun.listen reports why a unix path cannot be bound", async () => { + using dir = tempDir("listen-unix-errors", {}); + const fixture = /* js */ ` + const paths = { missingDirectory: "missing/listen.sock", tooLong: Buffer.alloc(300, "a").toString() }; + const errors = {}; + for (const [name, unix] of Object.entries(paths)) { + try { + Bun.listen({ unix, socket: { data() {} } }).stop(true); + errors[name] = "listening"; + } catch (e) { + errors[name] = { code: e.code, syscall: e.syscall, errno: e.errno }; + } + } + console.log(JSON.stringify(errors)); + `; + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", fixture], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ errors: listenErrors(stdout), stderr }).toEqual({ + errors: { + missingDirectory: { code: "ENOENT", syscall: "listen", errno: "ENOENT" }, + tooLong: { code: "EINVAL", syscall: "listen", errno: "EINVAL" }, + }, + stderr: "", + }); + expect(exitCode).toBe(0); +}); diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index 0c37765ee35c..ee7e0a64d4fa 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -29,7 +29,7 @@ import { tmpdir } from "node:os"; import * as path from "node:path"; import { Duplex, duplexPair, PassThrough, Writable } from "node:stream"; import { connect as tlsConnect } from "node:tls"; -import { inspect } from "node:util"; +import { getSystemErrorName, inspect } from "node:util"; import tunnel from "tunnel"; import { run as runHTTPProxyTest } from "./node-http-proxy.js"; const { describe, expect, it, beforeAll, afterAll, createDoneDotAll, mock, test } = createTest(import.meta.path); @@ -212,6 +212,52 @@ describe("node:http", () => { }); }); + // err.errno is the negative libuv number of the call that failed, the one + // util.getSystemErrorName() takes. + it("a listen() error carries the errno of the failed bind", async () => { + const occupant = createServer(); + occupant.listen(0); + await once(occupant, "listening"); + const { port } = occupant.address() as AddressInfo; + + const server = createServer(); + server.listen(port); + const [err] = (await once(server, "error")) as [NodeJS.ErrnoException]; + occupant.close(); + await once(occupant, "close"); + const { code, syscall, errno } = err; + expect({ + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + }).toEqual({ + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + }); + }); + + // An IPv6 literal is bound as it is, with no lookup. What a zone that names + // no interface does is only known for Linux: the bind is an invalid argument. + it.skipIf(process.platform !== "linux")( + "listen() on an IPv6 literal with an unknown zone emits EINVAL", + async () => { + const server = createServer(); + server.listen(0, "fe80::1%nope0"); + const [err] = (await once(server, "error")) as [NodeJS.ErrnoException]; + const { code, syscall, errno } = err; + expect({ + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + }).toEqual({ + code: "EINVAL", + syscall: "listen", + errno: "EINVAL", + }); + }, + ); + // vite's port auto-increment (#27406): the callback of the failed listen() belongs to the // server, not to that attempt, so the retry from the 'error' handler calls it. it("calls the listen() callback after a retry from the EADDRINUSE 'error' handler", async () => { diff --git a/test/js/node/net/node-net-server.test.ts b/test/js/node/net/node-net-server.test.ts index 574dca0c9c4b..7923f90b2589 100644 --- a/test/js/node/net/node-net-server.test.ts +++ b/test/js/node/net/node-net-server.test.ts @@ -3,6 +3,7 @@ import { bunEnv, bunExe, tempDir } from "harness"; import { AddressInfo, createServer, Server, Socket } from "net"; import { createTest } from "node-harness"; import { once } from "node:events"; +import { getSystemErrorName } from "node:util"; import { tmpdir } from "os"; import { join } from "path"; @@ -259,6 +260,31 @@ describe("net.createServer listen", () => { expect(order).toEqual(["error:EADDRINUSE", "nextTick"]); }); + // err.errno is the negative libuv number of the call that failed, the one + // util.getSystemErrorName() takes. + it("a listen() error carries the errno of the failed bind", async () => { + const occupant: Server = createServer(); + occupant.listen(0); + await once(occupant, "listening"); + const { port } = occupant.address() as AddressInfo; + + const server: Server = createServer(); + server.listen(port); + const [err] = (await once(server, "error")) as [NodeJS.ErrnoException]; + occupant.close(); + await once(occupant, "close"); + const { code, syscall, errno } = err; + expect({ + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + }).toEqual({ + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + }); + }); + // How vite, get-port and friends probe for a free port: listen, then close() // from the 'listening' handler. A peer that connects in between must be reset // by the kernel when the listening fd closes, not accepted into the closing