From 86651d81971bf46381bd01f050190aab4350daf2 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Tue, 4 Aug 2026 14:43:12 +0000 Subject: [PATCH 1/5] scripted-diff: Rename nUserBind, nBind, nMaxConnections to snake_case -BEGIN VERIFY SCRIPT- sed -i 's/\bnUserBind\b/num_user_p2p_bind/g' src/init.cpp sed -i 's/\bnBind\b/num_p2p_bind/g' src/init.cpp sed -i 's/\bnMaxConnections\b/num_p2p_max_connections/g' src/init.cpp sed -i 's/\buser_max_connection\b/user_p2p_max_connections/g' src/init.cpp -END VERIFY SCRIPT- --- src/init.cpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/init.cpp b/src/init.cpp index c25e07bf65f..13fa5e621a9 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -895,7 +895,7 @@ void InitLogging(const ArgsManager& args) namespace { // Variables internal to initialization process only -int nMaxConnections; +int num_p2p_max_connections; int available_fds; ServiceFlags g_local_services = ServiceFlags(NODE_NETWORK_LIMITED | NODE_WITNESS); int64_t peer_connect_timeout; @@ -1051,8 +1051,8 @@ bool AppInitParameterInteraction(const ArgsManager& args) } // -bind and -whitebind can't be set when not listening - size_t nUserBind = args.GetArgs("-bind").size() + args.GetArgs("-whitebind").size(); - if (nUserBind != 0 && !args.GetBoolArg("-listen", DEFAULT_LISTEN)) { + size_t num_user_p2p_bind = args.GetArgs("-bind").size() + args.GetArgs("-whitebind").size(); + if (num_user_p2p_bind != 0 && !args.GetBoolArg("-listen", DEFAULT_LISTEN)) { return InitError(Untranslated("Cannot set -bind or -whitebind together with -listen=0")); } @@ -1065,20 +1065,20 @@ bool AppInitParameterInteraction(const ArgsManager& args) // plus all manual connections and all bound interfaces. Any remainder will be available for connection sockets // Number of bound interfaces (we have at least one) - int nBind = std::max(nUserBind, size_t(1)); + int num_p2p_bind = std::max(num_user_p2p_bind, size_t(1)); // Maximum number of connections with other nodes, this accounts for all types of outbounds and inbounds except for manual - int user_max_connection = args.GetIntArg("-maxconnections", DEFAULT_MAX_PEER_CONNECTIONS); - if (user_max_connection < 0) { + int user_p2p_max_connections = args.GetIntArg("-maxconnections", DEFAULT_MAX_PEER_CONNECTIONS); + if (user_p2p_max_connections < 0) { return InitError(Untranslated("-maxconnections must be greater or equal than zero")); } const size_t max_private{args.GetBoolArg("-privatebroadcast", DEFAULT_PRIVATE_BROADCAST) ? MAX_PRIVATE_BROADCAST_CONNECTIONS : 0}; // Reserve enough FDs to account for the bare minimum, plus any manual connections, plus the bound interfaces - int min_required_fds = MIN_CORE_FDS + MAX_ADDNODE_CONNECTIONS + nBind; + int min_required_fds = MIN_CORE_FDS + MAX_ADDNODE_CONNECTIONS + num_p2p_bind; // Try raising the FD limit to what we need (available_fds may be smaller than the requested amount if this fails) - available_fds = RaiseFileDescriptorLimit(user_max_connection + max_private + min_required_fds); + available_fds = RaiseFileDescriptorLimit(user_p2p_max_connections + max_private + min_required_fds); // If we are using select instead of poll, our actual limit may be even smaller #ifndef USE_POLL available_fds = std::min(FD_SETSIZE, available_fds); @@ -1087,10 +1087,10 @@ bool AppInitParameterInteraction(const ArgsManager& args) return InitError(strprintf(_("Not enough file descriptors available. %d available, %d required."), available_fds, min_required_fds)); // Trim requested connection counts, to fit into system limitations - nMaxConnections = std::min(available_fds - min_required_fds, user_max_connection); + num_p2p_max_connections = std::min(available_fds - min_required_fds, user_p2p_max_connections); - if (nMaxConnections < user_max_connection) - InitWarning(strprintf(_("Reducing -maxconnections from %d to %d, because of system limitations."), user_max_connection, nMaxConnections)); + if (num_p2p_max_connections < user_p2p_max_connections) + InitWarning(strprintf(_("Reducing -maxconnections from %d to %d, because of system limitations."), user_p2p_max_connections, num_p2p_max_connections)); // ********************************************************* Step 3: parameter-to-internal-flags if (auto result{init::SetLoggingCategories(args)}; !result) return InitError(util::ErrorString(result)); @@ -1470,7 +1470,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) return false; } - LogInfo("Using at most %i automatic connections (%i file descriptors available)", nMaxConnections, available_fds); + LogInfo("Using at most %i automatic connections (%i file descriptors available)", num_p2p_max_connections, available_fds); // Warn about relative -datadir path. if (args.IsArgSet("-datadir") && !args.GetPathArg("-datadir").is_absolute()) { @@ -2137,7 +2137,7 @@ bool AppInitMain(NodeContext& node, interfaces::BlockAndHeaderTipInfo* tip_info) CConnman::Options connOptions; connOptions.m_local_services = g_local_services; - connOptions.m_max_automatic_connections = nMaxConnections; + connOptions.m_max_automatic_connections = num_p2p_max_connections; connOptions.m_full_relay_inbound_percent = std::clamp(args.GetIntArg("-inboundrelaypercent", DEFAULT_FULL_RELAY_INBOUND_PCT), 0, 100); connOptions.uiInterface = &uiInterface; connOptions.m_banman = node.banman.get(); From b3d6d2d1a7ef601e11eace7e000bf9fcee3928b9 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Wed, 8 Jul 2026 21:01:18 -0400 Subject: [PATCH 2/5] http: limit connected clients to 16 --- src/httpserver.cpp | 22 ++++++++++----- src/httpserver.h | 5 ++++ test/functional/interface_http.py | 46 +++++++++++++++++++++++++++++++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/src/httpserver.cpp b/src/httpserver.cpp index d72e9d9fd7e..373251df3d8 100644 --- a/src/httpserver.cpp +++ b/src/httpserver.cpp @@ -949,11 +949,13 @@ void HTTPServer::SocketHandlerListening(const Sock::EventsPerSock& events_per_so } const auto it = events_per_sock.find(sock); if (it != events_per_sock.end() && it->second.occurred & Sock::RecvEvent) { - CService addr_accepted; - - auto sock_accepted{AcceptConnection(*sock, addr_accepted)}; - - if (sock_accepted) { + // Drain all pending connections from this socket up to the limit. + // Stop early if the kernel queue is empty (AcceptConnection returns null) + // or if accepting the last connection brought us to the limit. + while (GetConnectionsCount() < MAX_HTTP_CONNECTIONS) { + CService addr_accepted; + auto sock_accepted{AcceptConnection(*sock, addr_accepted)}; + if (!sock_accepted) break; NewSockAccepted(std::move(sock_accepted), addr_accepted); } } @@ -964,8 +966,14 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const { IOReadiness io_readiness; - for (const auto& sock : m_listen) { - io_readiness.events_per_sock.emplace(sock, Sock::Events{Sock::RecvEvent}); + // If the server is already handling its max connected clients count, + // don't bother checking the listening sockets for new inbound connections. + // Leave them in the kernel's queue until space in the application opens + // up (or the client times out on its own). + if (GetConnectionsCount() < MAX_HTTP_CONNECTIONS) { + for (const auto& sock : m_listen) { + io_readiness.events_per_sock.emplace(sock, Sock::Events{Sock::RecvEvent}); + } } for (const auto& http_client : m_connected) { diff --git a/src/httpserver.h b/src/httpserver.h index c90830eb063..6090959274f 100644 --- a/src/httpserver.h +++ b/src/httpserver.h @@ -41,6 +41,11 @@ inline constexpr int DEFAULT_HTTP_WORKQUEUE=64; inline constexpr int DEFAULT_HTTP_SERVER_TIMEOUT=30; +/** + * Maximum number of connected HTTP clients + */ +inline constexpr int MAX_HTTP_CONNECTIONS = 16; + enum class HTTPRequestMethod { UNKNOWN, GET, diff --git a/test/functional/interface_http.py b/test/functional/interface_http.py index 3e5d1832fb0..ba7bcc75daa 100755 --- a/test/functional/interface_http.py +++ b/test/functional/interface_http.py @@ -131,6 +131,7 @@ class HTTPBasicsTest (BitcoinTestFramework): self.check_null_byte_in_uri() self.check_invalid_http_version() self.check_whitespace_in_headers() + self.check_connection_limit() def check_default_connection(self): @@ -609,5 +610,50 @@ class HTTPBasicsTest (BitcoinTestFramework): assert_equal(response.status, http.client.BAD_REQUEST) + def check_connection_limit(self): + self.log.info("Check connection limits") + + # Disable timeout so the initial batch of clients stays connected + # until the end of the test. + self.restart_node(0, extra_args=["-rpcservertimeout=0"]) + + # Close the persistent HTTP connection to this node by replacing it with + # a new AuthServiceProxy, reducing HTTPServer::GetConnectionsCount() to 0. + # The new AuthServiceProxy won't actually open an HTTP connection until + # it needs to send an RPC (for example, to stop the node at the end of the test). + self.node._rpc = self.node.create_new_rpc_connection(mode="AUTHPROXY") + + MAX_HTTP_CONNECTIONS = 16 + connections = [] + + # Connections all succeed up to the limit + with self.node.assert_debug_log( + expected_msgs = [f"method=invalidrpc_{i}" for i in range(1, MAX_HTTP_CONNECTIONS + 1)] + ): + for i in range(1, MAX_HTTP_CONNECTIONS + 1): + conn = BitcoinHTTPConnection(self.node) + # Each client makes a unique request so it's easy to find in the log + conn.post('/', f'{{"method": "invalidrpc_{i}"}}', connection_header='keep-alive').read() + connections.append(conn) + + # The next connection is over the limit, expect it to timeout + with self.node.assert_debug_log( + expected_msgs = [], + unexpected_msgs = ["method=never_accepted"] + ): + conn = BitcoinHTTPConnection(self.node) + conn.set_timeout(5) + try: + conn.post('/', '{"method": "never_accepted"}', connection_header='keep-alive').read() + assert False, "Connection succeeded unexpectedly" + except TimeoutError: + pass + + # Original 16 clients are still connected + assert_equal(len(connections), MAX_HTTP_CONNECTIONS) + for client in connections: + assert not client.sock_closed() + + if __name__ == '__main__': HTTPBasicsTest(__file__).main() From cc2acebefb049043bfb571832a86718877a9f328 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Thu, 9 Jul 2026 11:18:20 -0400 Subject: [PATCH 3/5] http: configure simultaneous connection limit with -rpcmaxconnections --- doc/release-notes-35182.md | 3 + src/httpserver.cpp | 5 +- src/httpserver.h | 12 +++- src/init.cpp | 1 + test/functional/interface_http.py | 97 +++++++++++++++++++++---------- 5 files changed, 84 insertions(+), 34 deletions(-) diff --git a/doc/release-notes-35182.md b/doc/release-notes-35182.md index 8d2b691880a..c32a922cb2b 100644 --- a/doc/release-notes-35182.md +++ b/doc/release-notes-35182.md @@ -14,3 +14,6 @@ Certain HTTP edge cases will observe different behavior to be more RFC-compliant - "Line Folding" is rejected (whitespace at start of a header line) - Tolerate `%` at the end of requested URLs - Multiple "Content-Length" headers with different values are rejected + +A new configuration option `-rpcmaxconnections` (default `16`) limits the +number of simultaneously connected HTTP clients to the server. diff --git a/src/httpserver.cpp b/src/httpserver.cpp index 373251df3d8..8b0486c43ff 100644 --- a/src/httpserver.cpp +++ b/src/httpserver.cpp @@ -952,7 +952,7 @@ void HTTPServer::SocketHandlerListening(const Sock::EventsPerSock& events_per_so // Drain all pending connections from this socket up to the limit. // Stop early if the kernel queue is empty (AcceptConnection returns null) // or if accepting the last connection brought us to the limit. - while (GetConnectionsCount() < MAX_HTTP_CONNECTIONS) { + while (GetConnectionsCount() < static_cast(m_rpcmaxconnections)) { CService addr_accepted; auto sock_accepted{AcceptConnection(*sock, addr_accepted)}; if (!sock_accepted) break; @@ -970,7 +970,7 @@ HTTPServer::IOReadiness HTTPServer::GenerateWaitSockets() const // don't bother checking the listening sockets for new inbound connections. // Leave them in the kernel's queue until space in the application opens // up (or the client times out on its own). - if (GetConnectionsCount() < MAX_HTTP_CONNECTIONS) { + if (GetConnectionsCount() < static_cast(m_rpcmaxconnections)) { for (const auto& sock : m_listen) { io_readiness.events_per_sock.emplace(sock, Sock::Events{Sock::RecvEvent}); } @@ -1284,6 +1284,7 @@ bool InitHTTPServer() } g_http_server->SetServerTimeout(std::chrono::seconds(gArgs.GetIntArg("-rpcservertimeout", DEFAULT_HTTP_SERVER_TIMEOUT))); + g_http_server->SetMaxConnections(std::max(gArgs.GetArg("-rpcmaxconnections", DEFAULT_MAX_HTTP_CONNECTIONS), 1)); // Bind HTTP server to specified addresses std::vector> endpoints{GetBindAddresses()}; diff --git a/src/httpserver.h b/src/httpserver.h index 6090959274f..ef51c84ea9a 100644 --- a/src/httpserver.h +++ b/src/httpserver.h @@ -44,7 +44,7 @@ inline constexpr int DEFAULT_HTTP_SERVER_TIMEOUT=30; /** * Maximum number of connected HTTP clients */ -inline constexpr int MAX_HTTP_CONNECTIONS = 16; +inline constexpr int DEFAULT_MAX_HTTP_CONNECTIONS = 16; enum class HTTPRequestMethod { UNKNOWN, @@ -316,6 +316,11 @@ public: */ void SetServerTimeout(std::chrono::seconds seconds) { m_rpcservertimeout = seconds; } + /** + * Set the maximum amount of connected HTTPClients (-rpcmaxconnections) + */ + void SetMaxConnections(int max_conn) { m_rpcmaxconnections = max_conn; } + /** * Force-remove all remaining clients from m_connected without waiting for * graceful disconnection. Must only be called after JoinSocketsThreads(). @@ -421,6 +426,11 @@ private: */ bool ClientAllowed(const CNetAddr& netaddr) const; + /** + * Maximum amount of concurrent connections + */ + int m_rpcmaxconnections{DEFAULT_MAX_HTTP_CONNECTIONS}; + /** * Accept a connection. * @param[in] listen_sock Socket on which to accept the connection. diff --git a/src/init.cpp b/src/init.cpp index 13fa5e621a9..5f9e0b92daf 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -746,6 +746,7 @@ void SetupServerArgs(ArgsManager& argsman, bool can_listen_ipc) argsman.AddArg("-rpcdoccheck", strprintf("Throw a non-fatal error at runtime if the documentation for an RPC is incorrect (default: %u)", DEFAULT_RPC_DOC_CHECK), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::RPC); argsman.AddArg("-rpccookiefile=", "Location of the auth cookie. Relative paths will be prefixed by a net-specific datadir location. (default: data dir)", ArgsManager::ALLOW_ANY, OptionsCategory::RPC); argsman.AddArg("-rpccookieperms=", strprintf("Set permissions on the RPC auth cookie file so that it is readable by [owner|group|all] (default: owner [via umask 0077])"), ArgsManager::ALLOW_ANY, OptionsCategory::RPC); + argsman.AddArg("-rpcmaxconnections=", strprintf("The maximum number of connected HTTP clients (default: %d)", DEFAULT_MAX_HTTP_CONNECTIONS), ArgsManager::ALLOW_ANY, OptionsCategory::RPC); argsman.AddArg("-rpcpassword=", "Password for JSON-RPC connections", ArgsManager::ALLOW_ANY | ArgsManager::SENSITIVE, OptionsCategory::RPC); argsman.AddArg("-rpcport=", strprintf("Listen for JSON-RPC connections on (default: %u, testnet3: %u, testnet4: %u, signet: %u, regtest: %u)", defaultBaseParams->RPCPort(), testnetBaseParams->RPCPort(), testnet4BaseParams->RPCPort(), signetBaseParams->RPCPort(), regtestBaseParams->RPCPort()), ArgsManager::ALLOW_ANY | ArgsManager::NETWORK_ONLY, OptionsCategory::RPC); argsman.AddArg("-rpcservertimeout=", strprintf("Timeout during HTTP requests (default: %d)", DEFAULT_HTTP_SERVER_TIMEOUT), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::RPC); diff --git a/test/functional/interface_http.py b/test/functional/interface_http.py index ba7bcc75daa..58ee6289ac4 100755 --- a/test/functional/interface_http.py +++ b/test/functional/interface_http.py @@ -8,6 +8,7 @@ from test_framework.test_framework import BitcoinTestFramework from test_framework.netutil import NETWORK_ERRORS from test_framework.util import assert_equal, str_to_b64str +import concurrent.futures import http.client import socket import threading @@ -615,44 +616,78 @@ class HTTPBasicsTest (BitcoinTestFramework): # Disable timeout so the initial batch of clients stays connected # until the end of the test. - self.restart_node(0, extra_args=["-rpcservertimeout=0"]) + for comment, extra_args, limit in [ + ("default (16)", ["-rpcservertimeout=0", "-rest"], 16), + ("-rpcmaxconnections=128", ["-rpcservertimeout=0", "-rest", "-rpcmaxconnections=128"], 128) + ]: + self.log.info(f"Using connection limit: {comment}") + self.restart_node(0, extra_args=extra_args) - # Close the persistent HTTP connection to this node by replacing it with - # a new AuthServiceProxy, reducing HTTPServer::GetConnectionsCount() to 0. - # The new AuthServiceProxy won't actually open an HTTP connection until - # it needs to send an RPC (for example, to stop the node at the end of the test). - self.node._rpc = self.node.create_new_rpc_connection(mode="AUTHPROXY") + # Close the persistent HTTP connection to this node by replacing it with + # a new AuthServiceProxy, reducing HTTPServer::GetConnectionsCount() to 0. + # The new AuthServiceProxy won't actually open an HTTP connection until + # it needs to send an RPC (for example, to stop the node at the end of the test). + self.node._rpc = self.node.create_new_rpc_connection(mode="AUTHPROXY") - MAX_HTTP_CONNECTIONS = 16 - connections = [] + MAX_HTTP_CONNECTIONS = limit + connections = [] - # Connections all succeed up to the limit - with self.node.assert_debug_log( - expected_msgs = [f"method=invalidrpc_{i}" for i in range(1, MAX_HTTP_CONNECTIONS + 1)] - ): - for i in range(1, MAX_HTTP_CONNECTIONS + 1): + # Connections all succeed up to the limit + with self.node.assert_debug_log( + expected_msgs = [f"method=invalidrpc_{i} " for i in range(1, MAX_HTTP_CONNECTIONS + 1)] + ): + for i in range(1, MAX_HTTP_CONNECTIONS + 1): + conn = BitcoinHTTPConnection(self.node) + # Each client makes a unique request so it's easy to find in the log + conn.post('/', f'{{"method": "invalidrpc_{i}"}}', connection_header='keep-alive').read() + connections.append(conn) + + # The next connection is over the limit, expect it to timeout + with self.node.assert_debug_log( + expected_msgs = [], + unexpected_msgs = ["method=never_accepted"] + ): conn = BitcoinHTTPConnection(self.node) - # Each client makes a unique request so it's easy to find in the log - conn.post('/', f'{{"method": "invalidrpc_{i}"}}', connection_header='keep-alive').read() - connections.append(conn) + conn.set_timeout(5) + try: + conn.post('/', '{"method": "never_accepted"}', connection_header='keep-alive').read() + assert False, "Connection succeeded unexpectedly" + except TimeoutError: + pass + + # All original clients are still connected + assert_equal(len(connections), MAX_HTTP_CONNECTIONS) + for client in connections: + assert not client.sock_closed() + + # Try connecting again, but this time we'll wait for acceptance. + # Because the send is blocking, we'll execute in a background thread. + + def wait_for_send(conn): + return conn.get('/rest/blockhashbyheight/0.json').read() - # The next connection is over the limit, expect it to timeout - with self.node.assert_debug_log( - expected_msgs = [], - unexpected_msgs = ["method=never_accepted"] - ): conn = BitcoinHTTPConnection(self.node) - conn.set_timeout(5) - try: - conn.post('/', '{"method": "never_accepted"}', connection_header='keep-alive').read() - assert False, "Connection succeeded unexpectedly" - except TimeoutError: - pass + conn.set_timeout(None) + executor = concurrent.futures.ThreadPoolExecutor(max_workers=1) + waiting_request = executor.submit( + wait_for_send, + conn + ) - # Original 16 clients are still connected - assert_equal(len(connections), MAX_HTTP_CONNECTIONS) - for client in connections: - assert not client.sock_closed() + # We are waiting + assert not waiting_request.done() + + # Close one of the original connections + popped_client = connections.pop() + popped_client.close_sock() + + # The waiting connection gets processed + delayed_response = waiting_request.result(timeout=5) + assert "0f9188f13cb7b2c71f2a335e3a4fc328bf5beb436012afca590b1a11466e2206" in delayed_response.decode() + + # Close all remaining connections for clean up + for client in connections: + client.close_sock() if __name__ == '__main__': From b08662060db7b13bb98b46d90f1b023be7324530 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 10 Jul 2026 11:49:46 -0400 Subject: [PATCH 4/5] init: account for maximum file descriptors needed by HTTP --- doc/REST-interface.md | 12 -------- doc/release-notes-35182.md | 4 ++- src/init.cpp | 49 ++++++++++++++++++++++++++++++--- test/functional/feature_init.py | 47 +++++++++++++++++++++++++++++++ 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/doc/REST-interface.md b/doc/REST-interface.md index 9b8f11d241c..91fa03995ae 100644 --- a/doc/REST-interface.md +++ b/doc/REST-interface.md @@ -31,18 +31,6 @@ If you front `bitcoind` with a reverse proxy or CDN such as Caddy or nginx with the headers-more module, you can override these defaults there. Keep overrides scoped to responses you know are safe to cache more aggressively. -Limitations ------------ - -There is a known issue in the REST interface that can cause a node to crash if -too many http connections are being opened at the same time because the system runs -out of available file descriptors. To prevent this from happening you might -want to increase the number of maximum allowed file descriptors in your system -and try to prevent opening too many connections to your rest interface at the -same time if this is under your control. It is hard to give general advice -since this depends on your system but if you make several hundred requests at -once you are definitely at risk of encountering this issue. - Supported API ------------- diff --git a/doc/release-notes-35182.md b/doc/release-notes-35182.md index c32a922cb2b..d095d2fb0c6 100644 --- a/doc/release-notes-35182.md +++ b/doc/release-notes-35182.md @@ -16,4 +16,6 @@ Certain HTTP edge cases will observe different behavior to be more RFC-compliant - Multiple "Content-Length" headers with different values are rejected A new configuration option `-rpcmaxconnections` (default `16`) limits the -number of simultaneously connected HTTP clients to the server. +number of simultaneously connected HTTP clients to the server. The application +will now attempt to reserve file descriptors for the HTTP server sockets. If your +system has limited resources, consider using a lower setting. diff --git a/src/init.cpp b/src/init.cpp index 5f9e0b92daf..e4cd2f8ae0c 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -114,6 +114,7 @@ #include #include #include +#include #include #include #include @@ -1075,18 +1076,58 @@ bool AppInitParameterInteraction(const ArgsManager& args) const size_t max_private{args.GetBoolArg("-privatebroadcast", DEFAULT_PRIVATE_BROADCAST) ? MAX_PRIVATE_BROADCAST_CONNECTIONS : 0}; - // Reserve enough FDs to account for the bare minimum, plus any manual connections, plus the bound interfaces - int min_required_fds = MIN_CORE_FDS + MAX_ADDNODE_CONNECTIONS + num_p2p_bind; - // Try raising the FD limit to what we need (available_fds may be smaller than the requested amount if this fails) - available_fds = RaiseFileDescriptorLimit(user_p2p_max_connections + max_private + min_required_fds); + // HTTP server listen sockets: by default two (IPv4 and IPv6 loopback), or one per -rpcbind entry + int num_rpc_bind = std::max(args.GetArgs("-rpcbind").size(), size_t(2)); + // HTTP server connected client sockets + int user_rpc_max_connections = args.GetArg("-rpcmaxconnections", DEFAULT_MAX_HTTP_CONNECTIONS); + if (user_rpc_max_connections < 1) { + return InitError(Untranslated("-rpcmaxconnections must be greater than zero. Use -server=0 to disable HTTP.")); + } + + // Reserve enough FDs to account for the bare minimum, plus any manual connections, plus the bound interfaces. + // Every element is an int >= 0 so summing in int64_t cannot overflow. + // RaiseFileDescriptorLimit() accepts an int so we check that limit before casting. + const int64_t total_fds = int64_t{MIN_CORE_FDS} + + MAX_ADDNODE_CONNECTIONS + + num_p2p_bind + + num_rpc_bind + + user_rpc_max_connections + + user_p2p_max_connections + + static_cast(max_private); + if (total_fds > std::numeric_limits::max()) { + return InitError(Untranslated("Too many file descriptors requested. Try lower values for -rpcmaxconnections " + "or -maxconnections, or fewer settings of " + "-rpcbind, -bind and -whitebind")); + } + + // Subset of total_fds must also be a safe int + int min_required_fds = MIN_CORE_FDS + + MAX_ADDNODE_CONNECTIONS + + num_p2p_bind + + num_rpc_bind + + user_rpc_max_connections; + + // Try raising the FD limit to what the user wants (available_fds may be smaller than the requested amount if this fails) + available_fds = RaiseFileDescriptorLimit(static_cast(total_fds)); // If we are using select instead of poll, our actual limit may be even smaller #ifndef USE_POLL available_fds = std::min(FD_SETSIZE, available_fds); #endif + // The system can't support our bare minimum if (available_fds < min_required_fds) return InitError(strprintf(_("Not enough file descriptors available. %d available, %d required."), available_fds, min_required_fds)); + // The system can support our minimum but not the full amount the user requested. + if (available_fds < total_fds) { + // If the user is requesting extra HTTP connections, abort. They need to change that. + if (user_rpc_max_connections > DEFAULT_MAX_HTTP_CONNECTIONS) { + return InitError(strprintf(_("Not enough file descriptors available. " + "Try reducing -rpcmaxconnections or using the default value of %d"), + DEFAULT_MAX_HTTP_CONNECTIONS)); + } + } + // Trim requested connection counts, to fit into system limitations num_p2p_max_connections = std::min(available_fds - min_required_fds, user_p2p_max_connections); diff --git a/test/functional/feature_init.py b/test/functional/feature_init.py index 18faba33b3b..9277bc4fed8 100755 --- a/test/functional/feature_init.py +++ b/test/functional/feature_init.py @@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor from pathlib import Path import os import platform +import re import shutil import signal import subprocess @@ -362,6 +363,51 @@ class InitTest(BitcoinTestFramework): self.log.info("Testing node startup with fd limit above INT_MAX") self.restart_node_with_fd_limit(1 << 31) + def init_fd_overflow_test(self): + node = self.nodes[1] + if node.running: + self.stop_node(1) + + # A value larger than any possible int saturates to INT_MAX during arg parsing. + # Adding in other file descriptor requirements is guaranteed to overflow, + # so expect an InitError before RaiseFileDescriptorLimit() is called. + self.log.info("Checking -rpcmaxconnections setting that would overflow int is rejected") + node.assert_start_raises_init_error( + extra_args=[f"-rpcmaxconnections={2**64}"], + expected_msg="Error: Too many file descriptors requested.", + match=ErrorMatch.PARTIAL_REGEX + ) + + if self.RLIM_INFINITY is not None: + # Get the platform's file descriptor limit, if possible + import resource + soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE) + + # Lower the hard limit so RaiseFileDescriptorLimit() has a ceiling. + # The hard limit can not be raised again without root privilges, + # so this test should always be left for last in the process. + try: + resource.setrlimit(resource.RLIMIT_NOFILE, (soft, soft)) + except (ValueError, OSError): + self.log.info(f"Skipping rlimit test: cannot reduce hard limit (soft={soft}, hard={hard})") + return + + self.log.info("Checking that large -maxconnections setting gets adjusted for available file descriptors") + # Note this prints a message to the log and stderr but does not abort the process + with node.assert_debug_log(expected_msgs=[f"Reducing -maxconnections from {soft} "]): + self.restart_node(1, extra_args=[f"-maxconnections={soft}"]) + self.stop_node(1, expected_stderr=re.compile(fr"Reducing -maxconnections from {soft} ")) + + # From httpserver.h + DEFAULT_MAX_HTTP_CONNECTIONS = 16 + + self.log.info("Checking -rpcmaxconnections gets blamed if available file descriptors are insufficient") + node.assert_start_raises_init_error( + extra_args=[f"-rpcmaxconnections={DEFAULT_MAX_HTTP_CONNECTIONS + 1}", f"-maxconnections={soft}"], + expected_msg="Not enough file descriptors available. Try reducing -rpcmaxconnections", + match=ErrorMatch.PARTIAL_REGEX + ) + def run_test(self): self.init_pid_test() self.init_stress_test_interrupt() @@ -370,6 +416,7 @@ class InitTest(BitcoinTestFramework): self.init_empty_test() self.init_rlimit_test() self.init_rlimit_large_test() + self.init_fd_overflow_test() if __name__ == '__main__': From bd4b1524eabfb1d9e9924941b7fecccac4daa615 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Fri, 17 Jul 2026 14:46:50 -0400 Subject: [PATCH 5/5] init: do not count file descriptors for HTTPServer if -server=0 --- src/init.cpp | 4 ++++ test/functional/feature_init.py | 10 ++++++++++ 2 files changed, 14 insertions(+) diff --git a/src/init.cpp b/src/init.cpp index e4cd2f8ae0c..7fd8e221bdf 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -1084,6 +1084,10 @@ bool AppInitParameterInteraction(const ArgsManager& args) if (user_rpc_max_connections < 1) { return InitError(Untranslated("-rpcmaxconnections must be greater than zero. Use -server=0 to disable HTTP.")); } + if (!args.GetBoolArg("-server", false)) { + num_rpc_bind = 0; + user_rpc_max_connections = 0; + } // Reserve enough FDs to account for the bare minimum, plus any manual connections, plus the bound interfaces. // Every element is an int >= 0 so summing in int64_t cannot overflow. diff --git a/test/functional/feature_init.py b/test/functional/feature_init.py index 9277bc4fed8..283a67ec774 100755 --- a/test/functional/feature_init.py +++ b/test/functional/feature_init.py @@ -408,6 +408,16 @@ class InitTest(BitcoinTestFramework): match=ErrorMatch.PARTIAL_REGEX ) + # Start without the HTTP server to ensure that -rpcmaxconnections is ignored + with node.assert_debug_log( + expected_msgs = ["net thread start"], + unexpected_msgs = ["Initialized HTTP server"], + timeout = 10 + ): + node.start(extra_args=[f"-rpcmaxconnections={2**64}", "-server=0"]) + # No HTTP server, no RPC `stop` + node.kill_process() + def run_test(self): self.init_pid_test() self.init_stress_test_interrupt()