diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 6a0e092008..c91051ba20 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -59,6 +59,8 @@ CONF_BATCH_DELAY = "batch_delay" CONF_CUSTOM_SERVICES = "custom_services" CONF_HOMEASSISTANT_SERVICES = "homeassistant_services" CONF_HOMEASSISTANT_STATES = "homeassistant_states" +CONF_LISTEN_BACKLOG = "listen_backlog" +CONF_MAX_CONNECTIONS = "max_connections" def validate_encryption_key(value): @@ -158,6 +160,29 @@ CONFIG_SCHEMA = cv.All( cv.Optional(CONF_ON_CLIENT_DISCONNECTED): automation.validate_automation( single=True ), + # Connection limits to prevent memory exhaustion on resource-constrained devices + # Each connection uses ~500-1000 bytes of RAM plus system resources + # Platform defaults based on available RAM and network stack implementation: + cv.SplitDefault( + CONF_LISTEN_BACKLOG, + esp8266=1, # Limited RAM (~40KB free), LWIP raw sockets + esp32=4, # More RAM (520KB), BSD sockets + rp2040=1, # Limited RAM (264KB), LWIP raw sockets like ESP8266 + bk72xx=4, # Moderate RAM, BSD-style sockets + rtl87xx=4, # Moderate RAM, BSD-style sockets + host=4, # Abundant resources + ln882x=4, # Moderate RAM + ): cv.int_range(min=1, max=10), + cv.SplitDefault( + CONF_MAX_CONNECTIONS, + esp8266=4, # ~40KB free RAM, each connection uses ~500-1000 bytes + esp32=8, # 520KB RAM available + rp2040=4, # 264KB RAM but LWIP constraints + bk72xx=8, # Moderate RAM + rtl87xx=8, # Moderate RAM + host=8, # Abundant resources + ln882x=8, # Moderate RAM + ): cv.int_range(min=1, max=20), } ).extend(cv.COMPONENT_SCHEMA), cv.rename_key(CONF_SERVICES, CONF_ACTIONS), @@ -176,6 +201,10 @@ async def to_code(config): cg.add(var.set_password(config[CONF_PASSWORD])) cg.add(var.set_reboot_timeout(config[CONF_REBOOT_TIMEOUT])) cg.add(var.set_batch_delay(config[CONF_BATCH_DELAY])) + if CONF_LISTEN_BACKLOG in config: + cg.add(var.set_listen_backlog(config[CONF_LISTEN_BACKLOG])) + if CONF_MAX_CONNECTIONS in config: + cg.add(var.set_max_connections(config[CONF_MAX_CONNECTIONS])) # Set USE_API_SERVICES if any services are enabled if config.get(CONF_ACTIONS) or config[CONF_CUSTOM_SERVICES]: diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index dd6eb950a6..7fbe0e27f3 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -87,7 +87,7 @@ void APIServer::setup() { return; } - err = this->socket_->listen(4); + err = this->socket_->listen(this->listen_backlog_); if (err != 0) { ESP_LOGW(TAG, "Socket unable to listen: errno %d", errno); this->mark_failed(); @@ -140,9 +140,19 @@ void APIServer::loop() { while (true) { struct sockaddr_storage source_addr; socklen_t addr_len = sizeof(source_addr); + auto sock = this->socket_->accept_loop_monitored((struct sockaddr *) &source_addr, &addr_len); if (!sock) break; + + // Check if we're at the connection limit + if (this->clients_.size() >= this->max_connections_) { + ESP_LOGW(TAG, "Max connections (%d), rejecting %s", this->max_connections_, sock->getpeername().c_str()); + // Immediately close - socket destructor will handle cleanup + sock.reset(); + continue; + } + ESP_LOGD(TAG, "Accept %s", sock->getpeername().c_str()); auto *conn = new APIConnection(std::move(sock), this); @@ -206,8 +216,10 @@ void APIServer::loop() { void APIServer::dump_config() { ESP_LOGCONFIG(TAG, "Server:\n" - " Address: %s:%u", - network::get_use_address().c_str(), this->port_); + " Address: %s:%u\n" + " Listen backlog: %u\n" + " Max connections: %u", + network::get_use_address().c_str(), this->port_, this->listen_backlog_, this->max_connections_); #ifdef USE_API_NOISE ESP_LOGCONFIG(TAG, " Noise encryption: %s", YESNO(this->noise_ctx_->has_psk())); if (!this->noise_ctx_->has_psk()) { diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 627870af1d..b9049c1700 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -44,6 +44,8 @@ class APIServer : public Component, public Controller { void set_reboot_timeout(uint32_t reboot_timeout); void set_batch_delay(uint16_t batch_delay); uint16_t get_batch_delay() const { return batch_delay_; } + void set_listen_backlog(uint8_t listen_backlog) { this->listen_backlog_ = listen_backlog; } + void set_max_connections(uint8_t max_connections) { this->max_connections_ = max_connections; } // Get reference to shared buffer for API connections std::vector &get_shared_buffer_ref() { return shared_write_buffer_; } @@ -189,8 +191,12 @@ class APIServer : public Component, public Controller { // Group smaller types together uint16_t port_{6053}; uint16_t batch_delay_{100}; + // Connection limits - these defaults will be overridden by config values + // from cv.SplitDefault in __init__.py which sets platform-specific defaults + uint8_t listen_backlog_{4}; + uint8_t max_connections_{8}; bool shutting_down_ = false; - // 5 bytes used, 3 bytes padding + // 7 bytes used, 1 byte padding #ifdef USE_API_NOISE std::shared_ptr noise_ctx_ = std::make_shared(); diff --git a/esphome/components/socket/lwip_raw_tcp_impl.cpp b/esphome/components/socket/lwip_raw_tcp_impl.cpp index 2d64a275df..3377682474 100644 --- a/esphome/components/socket/lwip_raw_tcp_impl.cpp +++ b/esphome/components/socket/lwip_raw_tcp_impl.cpp @@ -9,7 +9,7 @@ #include "lwip/tcp.h" #include #include -#include +#include #include "esphome/core/helpers.h" #include "esphome/core/log.h" @@ -50,12 +50,18 @@ class LWIPRawImpl : public Socket { errno = EBADF; return nullptr; } - if (accepted_sockets_.empty()) { + if (this->accepted_socket_count_ == 0) { errno = EWOULDBLOCK; return nullptr; } - std::unique_ptr sock = std::move(accepted_sockets_.front()); - accepted_sockets_.pop(); + // Take from front for FIFO ordering + std::unique_ptr sock = std::move(this->accepted_sockets_[0]); + // Shift remaining sockets forward + for (uint8_t i = 1; i < this->accepted_socket_count_; i++) { + this->accepted_sockets_[i - 1] = std::move(this->accepted_sockets_[i]); + } + this->accepted_socket_count_--; + LWIP_LOG("Connection accepted by application, queue size: %d", this->accepted_socket_count_); if (addr != nullptr) { sock->getpeername(addr, addrlen); } @@ -494,9 +500,18 @@ class LWIPRawImpl : public Socket { // nothing to do here, we just don't push it to the queue return ERR_OK; } + // Check if we've reached the maximum accept queue size + if (this->accepted_socket_count_ >= MAX_ACCEPTED_SOCKETS) { + LWIP_LOG("Rejecting connection, queue full (%d)", this->accepted_socket_count_); + // Abort the connection when queue is full + tcp_abort(newpcb); + // Must return ERR_ABRT since we called tcp_abort() + return ERR_ABRT; + } auto sock = make_unique(family_, newpcb); sock->init(); - accepted_sockets_.push(std::move(sock)); + this->accepted_sockets_[this->accepted_socket_count_++] = std::move(sock); + LWIP_LOG("Accepted connection, queue size: %d", this->accepted_socket_count_); return ERR_OK; } void err_fn(err_t err) { @@ -587,7 +602,20 @@ class LWIPRawImpl : public Socket { } struct tcp_pcb *pcb_; - std::queue> accepted_sockets_; + // Accept queue - holds incoming connections briefly until the event loop calls accept() + // This is NOT a connection pool - just a temporary queue between LWIP callbacks and the main loop + // 3 slots is plenty since connections are pulled out quickly by the event loop + // + // Memory analysis: std::array<3> vs original std::queue implementation: + // - std::queue uses std::deque internally which on 32-bit systems needs: + // 24 bytes (deque object) + 32+ bytes (map array) + heap allocations + // Total: ~56+ bytes minimum, plus heap fragmentation + // - std::array<3>: 12 bytes fixed (3 pointers × 4 bytes) + // Saves ~44+ bytes RAM per listening socket + avoids ALL heap allocations + // Used on ESP8266 and RP2040 (platforms using LWIP_TCP implementation) + static constexpr size_t MAX_ACCEPTED_SOCKETS = 3; + std::array, MAX_ACCEPTED_SOCKETS> accepted_sockets_; + uint8_t accepted_socket_count_ = 0; // Number of sockets currently in queue bool rx_closed_ = false; pbuf *rx_buf_ = nullptr; size_t rx_buf_offset_ = 0;