From 7b0e4c45cdbc33506f58e0a273f39c96e099e3c2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:39:14 +0000 Subject: [PATCH 1/7] Bun.serve: report the error of the listen call that failed A failed listen reached Bun.serve as a bare null socket. on_listen_failed then read thread-local errno, on Linux and Android only, and every other failure became EADDRINUSE with errno 0. uSockets now writes the code of the failing call to *error on every path of a failed listen: socket(), the socket options, bind(), listen(), the poll registration and the unix address. HttpContext::listen forwards it, the uWS listen handler carries it as a second argument, and on_listen_failed decodes it on every platform with SocketGroup::listen_errno, which Bun.listen now uses too. --- packages/bun-usockets/src/bsd.c | 27 +++-- packages/bun-usockets/src/context.c | 12 +- packages/bun-uws/src/App.h | 29 +++-- packages/bun-uws/src/HttpContext.h | 13 +-- src/runtime/server/mod.rs | 112 +++++++++---------- src/runtime/socket/Listener.rs | 15 ++- src/uws_sys/App.rs | 9 +- src/uws_sys/SocketGroup.rs | 16 +++ src/uws_sys/_libusockets.h | 5 +- src/uws_sys/libuwsockets.cpp | 16 +-- test/js/bun/http/serve-listen.test.ts | 90 ++++++++++++++- test/js/bun/http/serve-syscall-fault.test.ts | 80 ++++++++++++- test/js/bun/net/tcp-server.test.ts | 46 +++++++- test/js/node/http/node-http.test.ts | 23 +++- 14 files changed, 376 insertions(+), 117 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index a1190b3a6515..2ef74605cfb3 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; } } @@ -1379,6 +1381,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int if (a->ai_family == AF_INET6) { listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); if (listenFd == LIBUS_SOCKET_ERROR) { + *error = LIBUS_ERR; continue; } @@ -1396,6 +1399,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int if (a->ai_family == AF_INET) { listenFd = bsd_create_socket(a->ai_family, a->ai_socktype, a->ai_protocol, NULL); if (listenFd == LIBUS_SOCKET_ERROR) { + *error = LIBUS_ERR; continue; } @@ -1557,17 +1561,16 @@ static LIBUS_SOCKET_DESCRIPTOR internal_bsd_create_listen_socket_unix(const char listenFd = bsd_create_socket(AF_UNIX, SOCK_STREAM, 0, NULL); if (listenFd == LIBUS_SOCKET_ERROR) { + *error = LIBUS_ERR; 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 +1585,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 +1599,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 +1609,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..91001aa20685 100644 --- a/packages/bun-usockets/src/context.c +++ b/packages/bun-usockets/src/context.c @@ -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-uws/src/App.h b/packages/bun-uws/src/App.h index 6396a840d0d3..e0718ac8b622 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -727,30 +727,41 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: + /* Called once, before listen() returns, with the listen socket and 0, or + * with nullptr and the code of the call that failed: errno, or a WSA / + * Win32 code on Windows. That code is 0 when the host did not resolve, + * because then no call failed. */ + using ListenHandler = MoveOnlyFunction; + /* Host, port, options, callback */ - TemplatedApp &&listen(const std::string &host, int port, int options, MoveOnlyFunction &&handler) { + TemplatedApp &&listen(const std::string &host, int port, int options, ListenHandler &&handler) { if (host.empty()) { return listen(port, options, std::move(handler)); } - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host.c_str(), port, options)) : nullptr); + int error = 0; + us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host.c_str(), port, options, &error)) : nullptr; + handler(ls, ls ? 0 : error); 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); + TemplatedApp &&listen(int port, ListenHandler &&handler) { + return listen(port, 0, std::move(handler)); } /* Port, options, callback */ - TemplatedApp &&listen(int port, int options, MoveOnlyFunction &&handler) { - handler(httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, options)) : nullptr); + TemplatedApp &&listen(int port, int options, ListenHandler &&handler) { + int error = 0; + us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, options, &error)) : nullptr; + handler(ls, ls ? 0 : error); 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); + TemplatedApp &&listen(int options, ListenHandler &&handler, std::string_view path) { + int error = 0; + us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options, &error)) : nullptr; + handler(ls, ls ? 0 : error); return std::move(*this); } diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index 9c041bdf611c..bacffe3f2300 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1282,16 +1282,15 @@ struct HttpContext { return socket; } - /* 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)); + /* Listen to port using this HttpContext. ssl_ctx may be nullptr for plain HTTP. + * On nullptr, *error is what us_socket_group_listen left there. */ + us_listen_socket_t *listen(struct ssl_ctx_st *sslCtx, const char *host, int port, int options, int *error) { + return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), error)); } /* 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/server/mod.rs b/src/runtime/server/mod.rs index 7284a0a2b9c7..ff0fd9ae1dcb 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>) { + pub(crate) fn on_listen( + &mut self, + socket: Option<*mut uws_sys::app::ListenSocket>, + error: c_int, + ) { let Some(socket) = socket else { - return self.on_listen_failed(); + return self.on_listen_failed(error); }; self.listener = Some(socket); // SAFETY: `vm_mut()` is the process-static `*mut VirtualMachine` (non-null @@ -2042,66 +2046,57 @@ impl NewServer { /// `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. + /// `error` is what the uWS listen handler received: 0, or the code of the + /// call that failed (see `SocketGroup::listen_errno`). #[cold] - pub(crate) fn on_listen_failed(&mut self) { + pub(crate) fn on_listen_failed(&mut self, error: c_int) { self.listener = None; let global = self.global_this(); + // 0: no system call failed, the hostname did not resolve. + let errno = (error != 0).then(|| { + uws_sys::SocketGroup::listen_errno(error).unwrap_or(bun_sys::SystemErrno::EUNKNOWN) + }); - 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!( + let err = match &self.config.address { + server_config::Address::Tcp { port, hostname } => { + let port_in_use = || { + bun_core::String::create_format(format_args!( + "Failed to start server. Is port {} in use?", + port + )) + }; + match errno { + None => jsc::SystemError { + message: port_in_use(), + code: bun_core::String::static_("EADDRINUSE"), + syscall: bun_core::String::static_("listen"), + ..Default::default() + }, + Some(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 = port_in_use(); + } else if errno == bun_sys::SystemErrno::EACCES { + let host = hostname + .as_ref() + .map(|h| h.as_bytes()) + .unwrap_or(b"0.0.0.0"); + err.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; + )); + } + err } } - jsc::SystemError { - 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 { + match errno { + None => jsc::SystemError { message: bun_core::String::create_format(format_args!( "Failed to listen on unix socket {}", bun_core::fmt::QuotedFormatter { text: unix } @@ -2109,18 +2104,17 @@ impl NewServer { 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) + }, + Some(errno) => jsc::SystemError::from( + bun_sys::Error::new(errno, bun_sys::Tag::listen) .with_path(unix) .to_system_error(), - ) - .to_error_instance(global), + ), } } }; + let error_instance = err.to_error_instance(global); error_instance.ensure_still_alive(); let _ = global.throw_value(error_instance); } @@ -3432,6 +3426,7 @@ mod trampoline { pub(super) extern "C" fn on_listen( socket: *mut UwsListenSocket, + error: c_int, user_data: *mut c_void, ) { // SAFETY: user_data is the `*mut NewServer<..>` passed to listen_with_config. @@ -3441,16 +3436,17 @@ mod trampoline { } else { Some(socket.cast::>()) }; - server.on_listen(socket); + server.on_listen(socket, error); } pub(super) extern "C" fn on_listen_unix( socket: *mut UwsListenSocket, _domain: *const c_char, _flags: i32, + error: c_int, user_data: *mut c_void, ) { - on_listen::(socket, user_data); + on_listen::(socket, error, user_data); } pub(super) extern "C" fn on_404( diff --git a/src/runtime/socket/Listener.rs b/src/runtime/socket/Listener.rs index 056788e3b556..4b17ea0a3519 100644 --- a/src/runtime/socket/Listener.rs +++ b/src/runtime/socket/Listener.rs @@ -523,8 +523,8 @@ impl Listener { 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 (errno, mapped) = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) || (matches!(connection, UnixOrHost::Fd(_)) && matches!( mapped, @@ -532,9 +532,12 @@ impl Listener { | Some(bun_sys::SystemErrno::EBADF) | Some(bun_sys::SystemErrno::EOPNOTSUPP) )) { - bun_sys::SystemErrno::EINVAL as c_int + ( + bun_sys::SystemErrno::EINVAL as c_int, + Some(bun_sys::SystemErrno::EINVAL), + ) } else { - errno + (errno, mapped) }; if errno != 0 { err.put( @@ -551,11 +554,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..02533b7715a9 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -316,7 +316,7 @@ impl App { pub fn listen_on_unix_socket( &mut self, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), + handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, c_int, *mut c_void), user_data: *mut c_void, domain_name: &ZStr, flags: i32, @@ -489,7 +489,10 @@ pub(crate) type uws_app_t = uws_app_s; pub mod c { use super::*; - pub(crate) type uws_listen_handler = Option; + /// `(listen_socket, error, user_data)`. `error` is the second argument of + /// `uWS::TemplatedApp::ListenHandler`; `SocketGroup::listen_errno` decodes it. + 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 @@ -659,7 +662,7 @@ pub mod c { domain: *const c_char, pathlen: usize, flags: i32, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, *mut c_void), + handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, c_int, *mut c_void), user_data: *mut c_void, ); diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index b7f09b522fcf..9bece8acb9fe 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -214,6 +214,22 @@ impl SocketGroup { } } + /// The errno behind the `err` that a failed `listen`, `listen_unix` or + /// `listen_fd` filled. uSockets stores the platform's own number there: an + /// errno on POSIX, a WSA or Win32 code on Windows. `None` for 0 (no system + /// call failed: the host did not resolve) and for a number the errno table + /// does not name. + pub fn listen_errno(err: c_int) -> Option { + if err == 0 { + return None; + } + // `SystemErrno::init` reads an `i64` as an errno. On Windows it reads + // a `c_int` through the Win32 table, which is the number `err` holds. + #[cfg(not(windows))] + let err = i64::from(err); + bun_errno::SystemErrno::init(err) + } + pub fn connect( &mut self, kind: SocketKind, diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index 395398448b62..1767c9130406 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -123,11 +123,12 @@ typedef struct { uws_websocket_close_handler close; } uws_socket_behavior_t; +/* `error` is uWS::TemplatedApp::ListenHandler's second argument. */ typedef void (*uws_listen_handler)(struct us_listen_socket_t* listen_socket, - void* user_data); + int error, void* user_data); typedef void (*uws_listen_domain_handler)( struct us_listen_socket_t* listen_socket, const char* domain, int options, - void* user_data); + int error, void* user_data); typedef void (*uws_method_handler)(uws_res_t* response, uws_req_t* request, void* user_data); diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index d9dc129a943d..6ac8a819c18a 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -433,9 +433,9 @@ extern "C" uWS::SSLApp *uwsApp = (uWS::SSLApp *)app; uwsApp->listen( hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) + [handler, user_data](struct us_listen_socket_t *listen_socket, int error) { - handler((struct us_listen_socket_t *)listen_socket, user_data); + handler((struct us_listen_socket_t *)listen_socket, error, user_data); }); } else @@ -443,9 +443,9 @@ extern "C" uWS::App *uwsApp = (uWS::App *)app; uwsApp->listen( hostname, port, options, - [handler, user_data](struct us_listen_socket_t *listen_socket) + [handler, user_data](struct us_listen_socket_t *listen_socket, int error) { - handler((struct us_listen_socket_t *)listen_socket, user_data); + handler((struct us_listen_socket_t *)listen_socket, error, user_data); }); } } @@ -457,16 +457,16 @@ extern "C" { 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); }, + options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket, int error) + { handler((struct us_listen_socket_t *)listen_socket, domain, options, error, 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); }, + options, [handler, domain, options, user_data](struct us_listen_socket_t *listen_socket, int error) + { handler((struct us_listen_socket_t *)listen_socket, domain, options, error, user_data); }, {domain, pathlen}); } } diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index ee635d88f979..3fc151491824 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,89 @@ 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 listenError(options: { hostname: string; port: number }) { + try { + serve({ ...options, fetch: () => new Response() }).stop(true); + } catch (e: any) { + const { code, syscall, errno, message } = e; + return { code, syscall, errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, message }; + } + } + + test.skipIf(!hasIPv4)("a port that is in use throws EADDRINUSE", () => { + using occupant = serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); + expect(listenError({ hostname: "127.0.0.1", port: occupant.port! })).toEqual({ + code: "EADDRINUSE", + syscall: "listen", + errno: "EADDRINUSE", + message: `Failed to start server. Is port ${occupant.port} in use?`, + }); + }); + + // 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", + }); + }); + + // At the descriptor limit socket() fails before there is anything to bind. + test.skipIf(isWindows)("the file descriptor limit throws EMFILE", async () => { + using dir = tempDir("serve-listen-emfile", {}); + const fixture = /* js */ ` + const fs = require("fs"); + const held = []; + for (;;) { + try { + held.push(fs.openSync("/dev/null", "r")); + } catch { + break; + } + } + const errors = {}; + const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: process.env.SOCKET_PATH } }; + 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 }; + } + } + 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, SOCKET_PATH: join(String(dir), "emfile.sock") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ + tcp: { code: "EMFILE", syscall: "listen" }, + unix: { code: "EMFILE", syscall: "listen" }, + }), + 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..5acc0587a9c8 100644 --- a/test/js/bun/net/tcp-server.test.ts +++ b/test/js/bun/net/tcp-server.test.ts @@ -1,6 +1,6 @@ import { connect, listen, SocketHandler, TCPSocketListener } from "bun"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "node:path"; type Resolve = (value?: unknown) => void; @@ -312,3 +312,47 @@ it("should not leak memory", async () => { expect(stdout).toBe(""); expect(exitCode).toBe(0); }); + +// At the descriptor limit socket() fails before there is anything to bind. +// The error must name that failure, as every later step of listen() does. +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 held = []; + for (;;) { + try { + held.push(fs.openSync("/dev/null", "r")); + } catch { + break; + } + } + const errors = {}; + const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: process.env.SOCKET_PATH } }; + 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 }; + } + } + 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, SOCKET_PATH: join(String(dir), "emfile.sock") }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stdout: stdout.trim(), stderr }).toEqual({ + stdout: JSON.stringify({ + tcp: { code: "EMFILE", syscall: "listen" }, + unix: { code: "EMFILE", syscall: "listen" }, + }), + 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..bd7ba77b85e8 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,27 @@ 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", + }); + }); + // 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 () => { From 68b9be8ac246e3da157edeb37f975afb110b0bb3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 20:49:55 +0000 Subject: [PATCH 2/7] Format the new listen error tests --- test/js/bun/http/serve-listen.test.ts | 7 ++++++- test/js/node/http/node-http.test.ts | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index 3fc151491824..847e6f99afa4 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -275,7 +275,12 @@ describe("Bun.serve() reports why the listen failed", () => { serve({ ...options, fetch: () => new Response() }).stop(true); } catch (e: any) { const { code, syscall, errno, message } = e; - return { code, syscall, errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, message }; + return { + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + message, + }; } } diff --git a/test/js/node/http/node-http.test.ts b/test/js/node/http/node-http.test.ts index bd7ba77b85e8..c600fcf64484 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -226,7 +226,11 @@ describe("node:http", () => { occupant.close(); await once(occupant, "close"); const { code, syscall, errno } = err; - expect({ code, syscall, errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno }).toEqual({ + expect({ + code, + syscall, + errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, + }).toEqual({ code: "EADDRINUSE", syscall: "listen", errno: "EADDRINUSE", From 9bcbe7ce466a69701573c58afdccb8c84b688f6d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:15:11 +0000 Subject: [PATCH 3/7] usockets: state what a failed listen leaves in *error --- packages/bun-usockets/src/libusockets.h | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/bun-usockets/src/libusockets.h b/packages/bun-usockets/src/libusockets.h index a11ffb59fa34..a56f6bb8252b 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -395,7 +395,11 @@ 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. A host that does not resolve fails no call and + * leaves *error as the caller set it, so callers zero it 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) From a2659f10338dce649ee2640a53352fb22cf554c3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:39:06 +0000 Subject: [PATCH 4/7] usockets: report a busy port as WSAEADDRINUSE for exclusive listeners On Windows, bind() can answer WSAEACCES to a socket with SO_EXCLUSIVEADDRUSE when another socket holds the port. Bun.serve sets that option by default, so a second server on one port with no hostname got EACCES once the real code reached it. --- packages/bun-usockets/src/bsd.c | 8 ++++++++ test/js/bun/http/serve-listen.test.ts | 13 +++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index 2ef74605cfb3..e84aa96d7616 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1197,6 +1197,14 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd #endif if (us_internal_bind_and_listen(listenFd, listenAddr->ai_addr, (socklen_t) listenAddr->ai_addrlen, 512, error)) { +#ifdef _WIN32 + /* bind() can answer WSAEACCES, not WSAEADDRINUSE, to a socket with + * SO_EXCLUSIVEADDRUSE when another socket holds the port (Bun.serve + * twice on one port with no hostname). */ + if ((options & LIBUS_LISTEN_EXCLUSIVE_PORT) && *error == WSAEACCES) { + *error = WSAEADDRINUSE; + } +#endif return LIBUS_SOCKET_ERROR; } diff --git a/test/js/bun/http/serve-listen.test.ts b/test/js/bun/http/serve-listen.test.ts index 847e6f99afa4..b1cb54e57f08 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -270,7 +270,7 @@ test.skipIf(!isLinux)("server.address / server.port do not panic when getsocknam // 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 listenError(options: { hostname: string; port: number }) { + function listenError(options: { hostname?: string; port: number }) { try { serve({ ...options, fetch: () => new Response() }).stop(true); } catch (e: any) { @@ -284,9 +284,14 @@ describe("Bun.serve() reports why the listen failed", () => { } } - test.skipIf(!hasIPv4)("a port that is in use throws EADDRINUSE", () => { - using occupant = serve({ hostname: "127.0.0.1", port: 0, fetch: () => new Response() }); - expect(listenError({ hostname: "127.0.0.1", port: occupant.port! })).toEqual({ + // With no hostname, Windows answers WSAEACCES and not WSAEADDRINUSE to the + // exclusive bind of a busy port. + test.skipIf(!hasIPv4).each([ + ["127.0.0.1", { hostname: "127.0.0.1" }], + ["every address", {}], + ])("a port that is in use on %s throws EADDRINUSE", (_, address) => { + using occupant = serve({ ...address, port: 0, fetch: () => new Response() }); + expect(listenError({ ...address, port: occupant.port! })).toEqual({ code: "EADDRINUSE", syscall: "listen", errno: "EADDRINUSE", From 3f63b2c6ec49271e17e0f4ba9bef6eb4a1bc84f0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 6 Oct 2026 21:46:34 +0000 Subject: [PATCH 5/7] Let the listen error code explain itself Name the arguments of uws_listen_handler in its type. Split SocketGroup::listen_errno by platform so each arm shows what it decodes. Keep the contract of *error in libusockets.h only. --- packages/bun-uws/src/App.h | 5 +---- packages/bun-uws/src/HttpContext.h | 3 +-- src/runtime/server/mod.rs | 4 +--- src/uws_sys/App.rs | 7 +++---- src/uws_sys/SocketGroup.rs | 17 ++++++++--------- src/uws_sys/_libusockets.h | 1 - 6 files changed, 14 insertions(+), 23 deletions(-) diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index e0718ac8b622..6aa88cca4825 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -727,10 +727,7 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: - /* Called once, before listen() returns, with the listen socket and 0, or - * with nullptr and the code of the call that failed: errno, or a WSA / - * Win32 code on Windows. That code is 0 when the host did not resolve, - * because then no call failed. */ + /* (listen socket, 0), or (nullptr, the *error of us_socket_group_listen) */ using ListenHandler = MoveOnlyFunction; /* Host, port, options, callback */ diff --git a/packages/bun-uws/src/HttpContext.h b/packages/bun-uws/src/HttpContext.h index bacffe3f2300..d3b8b97480c5 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1282,8 +1282,7 @@ struct HttpContext { return socket; } - /* Listen to port using this HttpContext. ssl_ctx may be nullptr for plain HTTP. - * On nullptr, *error is what us_socket_group_listen left there. */ + /* 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) { return unrefListenSocket(us_socket_group_listen(&group, socketKind(), sslCtx, host, port, tcpListenOptions(options), socketExtSize(), error)); } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index ff0fd9ae1dcb..1d874c7c6115 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2046,13 +2046,11 @@ impl NewServer { /// `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. - /// `error` is what the uWS listen handler received: 0, or the code of the - /// call that failed (see `SocketGroup::listen_errno`). #[cold] pub(crate) fn on_listen_failed(&mut self, error: c_int) { self.listener = None; let global = self.global_this(); - // 0: no system call failed, the hostname did not resolve. + // 0: the hostname did not resolve, so no system call failed. let errno = (error != 0).then(|| { uws_sys::SocketGroup::listen_errno(error).unwrap_or(bun_sys::SystemErrno::EUNKNOWN) }); diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index 02533b7715a9..e6339837bdc4 100644 --- a/src/uws_sys/App.rs +++ b/src/uws_sys/App.rs @@ -489,10 +489,9 @@ pub(crate) type uws_app_t = uws_app_s; pub mod c { use super::*; - /// `(listen_socket, error, user_data)`. `error` is the second argument of - /// `uWS::TemplatedApp::ListenHandler`; `SocketGroup::listen_errno` decodes it. - pub(crate) type uws_listen_handler = - Option; + pub(crate) type uws_listen_handler = Option< + extern "C" fn(listen_socket: *mut UwsListenSocket, error: c_int, user_data: *mut c_void), + >; pub(crate) type uws_method_handler = Option; // The C++ shim hands the filter the uws_res_t*, which for HTTP server diff --git a/src/uws_sys/SocketGroup.rs b/src/uws_sys/SocketGroup.rs index 9bece8acb9fe..cc362fed6a20 100644 --- a/src/uws_sys/SocketGroup.rs +++ b/src/uws_sys/SocketGroup.rs @@ -214,20 +214,19 @@ impl SocketGroup { } } - /// The errno behind the `err` that a failed `listen`, `listen_unix` or - /// `listen_fd` filled. uSockets stores the platform's own number there: an - /// errno on POSIX, a WSA or Win32 code on Windows. `None` for 0 (no system - /// call failed: the host did not resolve) and for a number the errno table - /// does not name. + /// 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; } - // `SystemErrno::init` reads an `i64` as an errno. On Windows it reads - // a `c_int` through the Win32 table, which is the number `err` holds. + #[cfg(windows)] + { + bun_errno::SystemErrno::init(err.unsigned_abs()) + } #[cfg(not(windows))] - let err = i64::from(err); - bun_errno::SystemErrno::init(err) + { + bun_errno::SystemErrno::init(i64::from(err)) + } } pub fn connect( diff --git a/src/uws_sys/_libusockets.h b/src/uws_sys/_libusockets.h index 1767c9130406..0d0dcc8ff461 100644 --- a/src/uws_sys/_libusockets.h +++ b/src/uws_sys/_libusockets.h @@ -123,7 +123,6 @@ typedef struct { uws_websocket_close_handler close; } uws_socket_behavior_t; -/* `error` is uWS::TemplatedApp::ListenHandler's second argument. */ typedef void (*uws_listen_handler)(struct us_listen_socket_t* listen_socket, int error, void* user_data); typedef void (*uws_listen_domain_handler)( From 7eb81e056e758eb54410975cd1e5aaafdb819c8f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:13:20 +0000 Subject: [PATCH 6/7] Return the error of a failed listen to Bun.serve and Bun.listen The uWS listen functions return the listen socket and take the out-params of us_socket_group_listen: *error, and a new *dns_error for the result of a failed getaddrinfo(). The listen callback and its trampolines are gone. Bun.serve and Bun.listen decode both codes the same way. A hostname that does not resolve throws `getaddrinfo ENOTFOUND `. Bun.listen sets the negative libuv errno. Bun.serve reports WSAEACCES as EADDRINUSE in its own error path, and uSockets keeps the code that Winsock gave. --- packages/bun-usockets/src/bsd.c | 43 +++-- packages/bun-usockets/src/context.c | 4 +- .../src/internal/networking/bsd.h | 2 +- packages/bun-usockets/src/libusockets.h | 9 +- packages/bun-uws/src/App.h | 37 +--- packages/bun-uws/src/HttpContext.h | 4 +- src/runtime/dns_jsc/cares_jsc.rs | 9 + src/runtime/server/mod.rs | 174 ++++++------------ src/runtime/socket/Listener.rs | 28 ++- src/uws_sys/App.rs | 54 +++--- src/uws_sys/SocketGroup.rs | 3 + src/uws_sys/_libusockets.h | 6 - src/uws_sys/libuwsockets.cpp | 48 ++--- test/js/bun/http/serve-epoll-add-fail.test.ts | 147 ++++++++++++++- test/js/bun/http/serve-listen.test.ts | 167 ++++++++++++++--- test/js/bun/net/tcp-server.test.ts | 80 +++++++- test/js/node/http/node-http.test.ts | 21 +++ test/js/node/net/node-net-server.test.ts | 26 +++ 18 files changed, 574 insertions(+), 288 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index e84aa96d7616..df893d6bbbfe 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1197,14 +1197,6 @@ inline __attribute__((always_inline)) LIBUS_SOCKET_DESCRIPTOR bsd_bind_listen_fd #endif if (us_internal_bind_and_listen(listenFd, listenAddr->ai_addr, (socklen_t) listenAddr->ai_addrlen, 512, error)) { -#ifdef _WIN32 - /* bind() can answer WSAEACCES, not WSAEADDRINUSE, to a socket with - * SO_EXCLUSIVEADDRUSE when another socket holds the port (Bun.serve - * twice on one port with no hostname). */ - if ((options & LIBUS_LISTEN_EXCLUSIVE_PORT) && *error == WSAEACCES) { - *error = WSAEADDRINUSE; - } -#endif return LIBUS_SOCKET_ERROR; } @@ -1367,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)); @@ -1379,7 +1371,29 @@ 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 for its first lookup + * and EAI_SYSTEM after that. Both leave 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; } @@ -1387,9 +1401,8 @@ 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) { - *error = LIBUS_ERR; continue; } @@ -1405,9 +1418,8 @@ 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) { - *error = LIBUS_ERR; continue; } @@ -1566,10 +1578,9 @@ 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) { - *error = LIBUS_ERR; return LIBUS_SOCKET_ERROR; } diff --git a/packages/bun-usockets/src/context.c b/packages/bun-usockets/src/context.c index 91001aa20685..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; } 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 a56f6bb8252b..2a5cbe5120c2 100644 --- a/packages/bun-usockets/src/libusockets.h +++ b/packages/bun-usockets/src/libusockets.h @@ -398,12 +398,13 @@ void us_socket_set_first_flight_before_fin(us_socket_r s) nonnull_fn_decl; * accepted sockets. * * On NULL, *error is the code of the call that failed: errno, or a WSA / - * Win32 code on Windows. A host that does not resolve fails no call and - * leaves *error as the caller set it, so callers zero it first. */ + * 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 6aa88cca4825..4d25ff6ba13b 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -727,39 +727,14 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: - /* (listen socket, 0), or (nullptr, the *error of us_socket_group_listen) */ - using ListenHandler = MoveOnlyFunction; - - /* Host, port, options, callback */ - TemplatedApp &&listen(const std::string &host, int port, int options, ListenHandler &&handler) { - if (host.empty()) { - return listen(port, options, std::move(handler)); - } - int error = 0; - us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), host.c_str(), port, options, &error)) : nullptr; - handler(ls, ls ? 0 : error); - return std::move(*this); - } - - /* Port, callback */ - TemplatedApp &&listen(int port, ListenHandler &&handler) { - return listen(port, 0, std::move(handler)); + /* Both return the listen socket, or nullptr with the out-params as + * us_socket_group_listen leaves them. host is nullptr for every address. */ + 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, ListenHandler &&handler) { - int error = 0; - us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen(sslCtxOrNull(), nullptr, port, options, &error)) : nullptr; - handler(ls, ls ? 0 : error); - return std::move(*this); - } - - /* options, callback, path to unix domain socket */ - TemplatedApp &&listen(int options, ListenHandler &&handler, std::string_view path) { - int error = 0; - us_listen_socket_t *ls = httpContext ? trackListenSocket(httpContext->listen_unix(sslCtxOrNull(), path.data(), path.length(), options, &error)) : nullptr; - handler(ls, ls ? 0 : error); - 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 d3b8b97480c5..57d2446362e3 100644 --- a/packages/bun-uws/src/HttpContext.h +++ b/packages/bun-uws/src/HttpContext.h @@ -1283,8 +1283,8 @@ 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) { - 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 */ 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 1d874c7c6115..49a454c7742e 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2023,11 +2023,11 @@ impl NewServer { pub(crate) fn on_listen( &mut self, - socket: Option<*mut uws_sys::app::ListenSocket>, - error: c_int, + listened: Result<*mut uws_sys::app::ListenSocket, uws_sys::app::ListenError>, ) { - let Some(socket) = socket else { - return self.on_listen_failed(error); + 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 @@ -2042,74 +2042,57 @@ 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. + /// Throws the failed listen as a `SystemError`: the lookup error of the + /// hostname, or the error of the call that failed. #[cold] - pub(crate) fn on_listen_failed(&mut self, error: c_int) { + pub(crate) fn on_listen_failed(&mut self, failure: uws_sys::app::ListenError) { self.listener = None; let global = self.global_this(); - // 0: the hostname did not resolve, so no system call failed. - let errno = (error != 0).then(|| { - uws_sys::SocketGroup::listen_errno(error).unwrap_or(bun_sys::SystemErrno::EUNKNOWN) - }); + 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, hostname } => { - let port_in_use = || { - bun_core::String::create_format(format_args!( + 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 - )) - }; - match errno { - None => jsc::SystemError { - message: port_in_use(), - code: bun_core::String::static_("EADDRINUSE"), - syscall: bun_core::String::static_("listen"), - ..Default::default() - }, - Some(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 = port_in_use(); - } else if errno == bun_sys::SystemErrno::EACCES { - let host = hostname - .as_ref() - .map(|h| h.as_bytes()) - .unwrap_or(b"0.0.0.0"); - err.message = bun_core::String::create_format(format_args!( - "permission denied {}:{}", - bstr::BStr::new(host), - port - )); - } - err - } - } - } - server_config::Address::Unix(unix) => { - let unix = unix.as_bytes(); - match errno { - None => 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() - }, - Some(errno) => jsc::SystemError::from( - bun_sys::Error::new(errno, bun_sys::Tag::listen) - .with_path(unix) - .to_system_error(), - ), + )); + } 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); @@ -3165,20 +3148,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 { @@ -3253,16 +3232,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) }; } } @@ -3420,32 +3393,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, - error: c_int, - 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, error); - } - - pub(super) extern "C" fn on_listen_unix( - socket: *mut UwsListenSocket, - _domain: *const c_char, - _flags: i32, - error: c_int, - user_data: *mut c_void, - ) { - on_listen::(socket, error, 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 4b17ea0a3519..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 = uws::SocketGroup::listen_errno(errno); - let (errno, mapped) = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) + let mapped = if mapped == Some(bun_sys::SystemErrno::ENAMETOOLONG) || (matches!(connection, UnixOrHost::Fd(_)) && matches!( mapped, @@ -532,12 +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), - ) + Some(bun_sys::SystemErrno::EINVAL) } else { - (errno, mapped) + mapped }; if errno != 0 { err.put( @@ -545,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", diff --git a/src/uws_sys/App.rs b/src/uws_sys/App.rs index e6339837bdc4..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, c_int, *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,9 +501,6 @@ pub(crate) type uws_app_t = uws_app_s; pub mod c { use super::*; - pub(crate) type uws_listen_handler = Option< - extern "C" fn(listen_socket: *mut UwsListenSocket, error: c_int, user_data: *mut c_void), - >; pub(crate) type uws_method_handler = Option; // The C++ shim hands the filter the uws_res_t*, which for HTTP server @@ -622,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, @@ -661,9 +670,8 @@ pub mod c { domain: *const c_char, pathlen: usize, flags: i32, - handler: extern "C" fn(*mut UwsListenSocket, *const c_char, i32, c_int, *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 cc362fed6a20..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, ) } } @@ -337,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 0d0dcc8ff461..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, - int error, void* user_data); -typedef void (*uws_listen_domain_handler)( - struct us_listen_socket_t* listen_socket, const char* domain, int options, - int error, 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 6ac8a819c18a..e3b7c580ec64 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -423,52 +423,30 @@ 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) + /* Both return the listen socket, or nullptr with the out-params as + * us_socket_group_listen leaves them. */ + 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, int error) - { - handler((struct us_listen_socket_t *)listen_socket, error, 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, int error) - { - handler((struct us_listen_socket_t *)listen_socket, error, 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, int error) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, error, 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, int error) - { handler((struct us_listen_socket_t *)listen_socket, domain, options, error, 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 b1cb54e57f08..0ea302297ab2 100644 --- a/test/js/bun/http/serve-listen.test.ts +++ b/test/js/bun/http/serve-listen.test.ts @@ -270,32 +270,50 @@ test.skipIf(!isLinux)("server.address / server.port do not panic when getsocknam // 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 listenError(options: { hostname?: string; port: number }) { + 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() }).stop(true); - } catch (e: any) { - const { code, syscall, errno, message } = e; - return { - code, - syscall, - errno: typeof errno === "number" && errno < 0 ? getSystemErrorName(errno) : errno, - message, - }; + serve({ ...options, fetch: () => new Response() } as any).stop(true); + } catch (e) { + return shape(e); } } - // With no hostname, Windows answers WSAEACCES and not WSAEADDRINUSE to the - // exclusive bind of a busy port. - test.skipIf(!hasIPv4).each([ - ["127.0.0.1", { hostname: "127.0.0.1" }], - ["every address", {}], - ])("a port that is in use on %s throws EADDRINUSE", (_, address) => { - using occupant = serve({ ...address, port: 0, fetch: () => new Response() }); - expect(listenError({ ...address, port: occupant.port! })).toEqual({ - code: "EADDRINUSE", - syscall: "listen", - errno: "EADDRINUSE", - message: `Failed to start server. Is port ${occupant.port} in use?`, + 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}'`, + }); }); }); @@ -319,11 +337,93 @@ describe("Bun.serve() reports why the listen failed", () => { }); }); + // 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 { @@ -333,13 +433,12 @@ describe("Bun.serve() reports why the listen failed", () => { } } const errors = {}; - const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: process.env.SOCKET_PATH } }; 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 }; + errors[name] = { code: e.code, syscall: e.syscall, errno: e.errno }; } } for (const fd of held) fs.closeSync(fd); @@ -347,16 +446,24 @@ describe("Bun.serve() reports why the listen failed", () => { `; await using proc = Bun.spawn({ cmd: ["/bin/sh", "-c", 'ulimit -n 256 && exec "$@"', "sh", bunExe(), "-e", fixture], - env: { ...bunEnv, SOCKET_PATH: join(String(dir), "emfile.sock") }, + 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]); - expect({ stdout: stdout.trim(), stderr }).toEqual({ - stdout: JSON.stringify({ - tcp: { code: "EMFILE", syscall: "listen" }, - unix: { code: "EMFILE", syscall: "listen" }, - }), + 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/net/tcp-server.test.ts b/test/js/bun/net/tcp-server.test.ts index 5acc0587a9c8..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, isWindows, tempDir } 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; @@ -313,12 +315,29 @@ it("should not leak memory", async () => { 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 error must name that failure, as every later step of listen() does. +// 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 { @@ -328,30 +347,71 @@ it.skipIf(isWindows)("Bun.listen at the file descriptor limit throws EMFILE", as } } const errors = {}; - const addresses = { tcp: { hostname: "127.0.0.1", port: 0 }, unix: { unix: process.env.SOCKET_PATH } }; 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 }; + 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, SOCKET_PATH: join(String(dir), "emfile.sock") }, + 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]); - expect({ stdout: stdout.trim(), stderr }).toEqual({ - stdout: JSON.stringify({ - tcp: { code: "EMFILE", syscall: "listen" }, - unix: { code: "EMFILE", syscall: "listen" }, - }), + 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 c600fcf64484..ee7e0a64d4fa 100644 --- a/test/js/node/http/node-http.test.ts +++ b/test/js/node/http/node-http.test.ts @@ -237,6 +237,27 @@ describe("node:http", () => { }); }); + // 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 From 3354ba74e6f887ec9190c086511b8272a5ded519 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:57:06 +0000 Subject: [PATCH 7/7] Drop comments that repeat the listen contract --- packages/bun-usockets/src/bsd.c | 3 +-- packages/bun-uws/src/App.h | 3 +-- src/runtime/server/mod.rs | 2 -- src/uws_sys/libuwsockets.cpp | 2 -- 4 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/bun-usockets/src/bsd.c b/packages/bun-usockets/src/bsd.c index df893d6bbbfe..0b92c5775e26 100644 --- a/packages/bun-usockets/src/bsd.c +++ b/packages/bun-usockets/src/bsd.c @@ -1377,8 +1377,7 @@ LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int int gai = getaddrinfo(host, port_string, &hints, &result); if (gai != 0) { #ifndef _WIN32 - /* At the descriptor limit glibc answers EAI_NONAME for its first lookup - * and EAI_SYSTEM after that. Both leave the cause in errno. */ + /* 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; diff --git a/packages/bun-uws/src/App.h b/packages/bun-uws/src/App.h index 4d25ff6ba13b..09e5cdccceee 100644 --- a/packages/bun-uws/src/App.h +++ b/packages/bun-uws/src/App.h @@ -727,8 +727,7 @@ struct TemplatedApp { struct ssl_ctx_st *sslCtxOrNull() { return SSL ? sslCtx : nullptr; } public: - /* Both return the listen socket, or nullptr with the out-params as - * us_socket_group_listen leaves them. host is nullptr for every address. */ + /* 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; } diff --git a/src/runtime/server/mod.rs b/src/runtime/server/mod.rs index 49a454c7742e..ebc18f85bdb8 100644 --- a/src/runtime/server/mod.rs +++ b/src/runtime/server/mod.rs @@ -2042,8 +2042,6 @@ impl NewServer { } } - /// Throws the failed listen as a `SystemError`: the lookup error of the - /// hostname, or the error of the call that failed. #[cold] pub(crate) fn on_listen_failed(&mut self, failure: uws_sys::app::ListenError) { self.listener = None; diff --git a/src/uws_sys/libuwsockets.cpp b/src/uws_sys/libuwsockets.cpp index e3b7c580ec64..8f402849a608 100644 --- a/src/uws_sys/libuwsockets.cpp +++ b/src/uws_sys/libuwsockets.cpp @@ -423,8 +423,6 @@ extern "C" } } - /* Both return the listen socket, or nullptr with the out-params as - * us_socket_group_listen leaves them. */ 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)