diff --git a/esphome/components/api/__init__.py b/esphome/components/api/__init__.py index 9bff9f5635..3f7cafb485 100644 --- a/esphome/components/api/__init__.py +++ b/esphome/components/api/__init__.py @@ -233,8 +233,8 @@ def _consume_api_sockets(config: ConfigType) -> ConfigType: # API needs 1 listening socket + typically 3 concurrent client connections # (not max_connections, which is the upper limit rarely reached) - sockets_needed = 1 + 3 - socket.consume_sockets(sockets_needed, "api")(config) + socket.consume_sockets(3, "api")(config) + socket.consume_sockets(1, "api", socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/captive_portal/__init__.py b/esphome/components/captive_portal/__init__.py index 049618219e..6c190814c0 100644 --- a/esphome/components/captive_portal/__init__.py +++ b/esphome/components/captive_portal/__init__.py @@ -76,13 +76,15 @@ def _final_validate(config: ConfigType) -> ConfigType: # Register socket needs for DNS server and additional HTTP connections # - 1 UDP socket for DNS server - # - 3 additional TCP sockets for captive portal detection probes + configuration requests + # - 3 TCP sockets for captive portal detection probes + configuration requests # OS captive portal detection makes multiple probe requests that stay in TIME_WAIT. # Need headroom for actual user configuration requests. # LRU purging will reclaim idle sockets to prevent exhaustion from repeated attempts. + # The listening socket is registered by web_server_base (shared HTTP server). from esphome.components import socket - socket.consume_sockets(4, "captive_portal")(config) + socket.consume_sockets(3, "captive_portal")(config) + socket.consume_sockets(1, "captive_portal", socket.SocketType.UDP)(config) return config diff --git a/esphome/components/esp32/__init__.py b/esphome/components/esp32/__init__.py index ac73bf449e..5483be5c4f 100644 --- a/esphome/components/esp32/__init__.py +++ b/esphome/components/esp32/__init__.py @@ -1263,7 +1263,10 @@ def _configure_lwip_max_sockets(conf: dict) -> None: # Check if user manually specified CONFIG_LWIP_MAX_SOCKETS user_max_sockets = conf[CONF_SDKCONFIG_OPTIONS].get("CONFIG_LWIP_MAX_SOCKETS") - tcp_sockets, udp_sockets = get_socket_counts() + # tcp_listen not used on ESP32 — ESP-IDF defaults MEMP_NUM_TCP_PCB_LISTEN + # to 16 which is already generous, and CONFIG_LWIP_MAX_SOCKETS is a single + # combined VFS socket pool with no separate listening socket limit. + tcp_sockets, udp_sockets, _tcp_listen = get_socket_counts() total_sockets = tcp_sockets + udp_sockets # User specified their own value - respect it but warn if insufficient diff --git a/esphome/components/esp32_camera_web_server/__init__.py b/esphome/components/esp32_camera_web_server/__init__.py index ed1aaa2e07..da260ad7a1 100644 --- a/esphome/components/esp32_camera_web_server/__init__.py +++ b/esphome/components/esp32_camera_web_server/__init__.py @@ -20,8 +20,10 @@ def _consume_camera_web_server_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # Each camera web server instance needs 1 listening socket + 2 client connections - sockets_needed = 3 - socket.consume_sockets(sockets_needed, "esp32_camera_web_server")(config) + socket.consume_sockets(2, "esp32_camera_web_server")(config) + socket.consume_sockets(1, "esp32_camera_web_server", socket.SocketType.TCP_LISTEN)( + config + ) return config diff --git a/esphome/components/esphome/ota/__init__.py b/esphome/components/esphome/ota/__init__.py index 2f637d714d..589f44f85c 100644 --- a/esphome/components/esphome/ota/__init__.py +++ b/esphome/components/esphome/ota/__init__.py @@ -98,7 +98,7 @@ def _consume_ota_sockets(config: ConfigType) -> ConfigType: from esphome.components import socket # OTA needs 1 listening socket (client connections are temporary during updates) - socket.consume_sockets(1, "ota")(config) + socket.consume_sockets(1, "ota", socket.SocketType.TCP_LISTEN)(config) return config diff --git a/esphome/components/libretiny/__init__.py b/esphome/components/libretiny/__init__.py index cc9b26f0d8..e2b3335491 100644 --- a/esphome/components/libretiny/__init__.py +++ b/esphome/components/libretiny/__init__.py @@ -300,6 +300,7 @@ def _configure_lwip(config: dict) -> None: TCP_SND_QUEUELEN ~8 17 20 20 35 17 MEMP_NUM_TCP_SEG 10 16 40 20 =qlen 17 MEMP_NUM_TCP_PCB 5 16 12 10 8 =TCP + MEMP_NUM_TCP_PCB_LISTEN 4 16 4 5 3 dynamic MEMP_NUM_UDP_PCB 4 16 25*** 7**** 7**** =UDP MEMP_NUM_NETCONN 0 10 38 4***** =sum =sum MEMP_NUM_NETBUF 0 2 16 2***** 8 4 @@ -320,11 +321,12 @@ def _configure_lwip(config: dict) -> None: get_socket_counts, ) - raw_tcp, raw_udp = get_socket_counts() + raw_tcp, raw_udp, raw_tcp_listen = get_socket_counts() # Apply platform minimums — ensure headroom for ESPHome's needs tcp_sockets = max(MIN_TCP_SOCKETS, raw_tcp) udp_sockets = max(MIN_UDP_SOCKETS, raw_udp) - listening_tcp = 4 + # Listening sockets — registered by components (api, ota, web_server_base, etc.) + listening_tcp = max(raw_tcp_listen, 2) # at least 2 (api + ota) # TCP_SND_BUF: ESPAsyncWebServer allocates malloc(tcp_sndbuf()) per # response chunk. At 10×MSS=14.6KB (BK default) this causes OOM (#14095). @@ -354,13 +356,14 @@ def _configure_lwip(config: dict) -> None: # Socket counts — auto-calculated from component registrations f"MAX_SOCKETS_TCP={tcp_sockets}", f"MAX_SOCKETS_UDP={udp_sockets}", - # Listening sockets — API + web_server + OTA at most + # Listening sockets — auto-calculated from component registrations f"MAX_LISTENING_SOCKETS_TCP={listening_tcp}", # Queued segment limits — derived from 4×MSS buffer size f"TCP_SND_QUEUELEN={tcp_snd_queuelen}", f"MEMP_NUM_TCP_SEG={memp_num_tcp_seg}", # must be >= queuelen - # PCB pools — 1:1 with socket counts + # PCB pools — active connections + listening sockets f"MEMP_NUM_TCP_PCB={tcp_sockets}", # BK: 12, RTL: 10, LN: 8 + f"MEMP_NUM_TCP_PCB_LISTEN={listening_tcp}", # BK: =MAX_LISTENING, RTL: 5, LN: 3 # UDP PCB pool — includes wifi.lwip_internal (DHCP + DNS) f"MEMP_NUM_UDP_PCB={udp_sockets}", # BK: 25, RTL/LN: 7 via LT # Netconn pool — listening sockets are already counted in tcp_sockets diff --git a/esphome/components/socket/__init__.py b/esphome/components/socket/__init__.py index fdecda8c4a..8fbc0ce302 100644 --- a/esphome/components/socket/__init__.py +++ b/esphome/components/socket/__init__.py @@ -19,9 +19,12 @@ IMPLEMENTATION_BSD_SOCKETS = "bsd_sockets" # Components register their socket needs and platforms read this to configure appropriately KEY_SOCKET_CONSUMERS_TCP = "socket_consumers_tcp" KEY_SOCKET_CONSUMERS_UDP = "socket_consumers_udp" +KEY_SOCKET_CONSUMERS_TCP_LISTEN = "socket_consumers_tcp_listen" -# Recommended minimum socket counts to ensure headroom -# Platforms should apply these (or their own) on top of get_socket_counts() +# Recommended minimum socket counts to ensure headroom. +# Platforms should apply these (or their own) on top of get_socket_counts(). +# TCP: api(4) + ota(1) = 5 base, +5 headroom for web_server/other components. +# UDP: dhcp(1) + dns(1) + mdns(2) + wake_loop(1) = 5 base, +3 headroom. MIN_TCP_SOCKETS = 10 MIN_UDP_SOCKETS = 8 @@ -32,6 +35,7 @@ KEY_WAKE_LOOP_THREADSAFE_REQUIRED = "wake_loop_threadsafe_required" class SocketType(StrEnum): TCP = "tcp" UDP = "udp" + TCP_LISTEN = "tcp_listen" # Legacy aliases @@ -41,6 +45,7 @@ SOCKET_UDP = SocketType.UDP _SOCKET_TYPE_KEYS = { SocketType.TCP: KEY_SOCKET_CONSUMERS_TCP, SocketType.UDP: KEY_SOCKET_CONSUMERS_UDP, + SocketType.TCP_LISTEN: KEY_SOCKET_CONSUMERS_TCP_LISTEN, } @@ -67,8 +72,8 @@ def consume_sockets( return _consume_sockets -def get_socket_counts() -> tuple[int, int]: - """Return (tcp_count, udp_count) of raw registered socket needs. +def get_socket_counts() -> tuple[int, int, int]: + """Return (tcp_count, udp_count, tcp_listen_count) of raw registered socket needs. Platforms call this during code generation to configure lwIP socket limits. All components will have registered their needs by then. @@ -77,8 +82,10 @@ def get_socket_counts() -> tuple[int, int]: """ tcp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_TCP, {}) udp_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_UDP, {}) + tcp_listen_consumers = CORE.data.get(KEY_SOCKET_CONSUMERS_TCP_LISTEN, {}) tcp = sum(tcp_consumers.values()) udp = sum(udp_consumers.values()) + tcp_listen = sum(tcp_listen_consumers.values()) tcp_list = ", ".join( f"{name}={count}" for name, count in sorted(tcp_consumers.items()) @@ -86,14 +93,19 @@ def get_socket_counts() -> tuple[int, int]: udp_list = ", ".join( f"{name}={count}" for name, count in sorted(udp_consumers.items()) ) + tcp_listen_list = ", ".join( + f"{name}={count}" for name, count in sorted(tcp_listen_consumers.items()) + ) _LOGGER.debug( - "Socket counts: TCP=%d (%s), UDP=%d (%s)", + "Socket counts: TCP=%d (%s), UDP=%d (%s), TCP_LISTEN=%d (%s)", tcp, tcp_list or "none", udp, udp_list or "none", + tcp_listen, + tcp_listen_list or "none", ) - return tcp, udp + return tcp, udp, tcp_listen def require_wake_loop_threadsafe() -> None: diff --git a/esphome/components/web_server/__init__.py b/esphome/components/web_server/__init__.py index 9305a2de61..84910b6f90 100644 --- a/esphome/components/web_server/__init__.py +++ b/esphome/components/web_server/__init__.py @@ -144,11 +144,11 @@ def _consume_web_server_sockets(config: ConfigType) -> ConfigType: """Register socket needs for web_server component.""" from esphome.components import socket - # Web server needs 1 listening socket + typically 5 concurrent client connections + # Web server needs typically 5 concurrent client connections # (browser opens connections for page resources, SSE event stream, and POST # requests for entity control which may linger before closing) - sockets_needed = 6 - socket.consume_sockets(sockets_needed, "web_server")(config) + # The listening socket is registered by web_server_base (shared with captive_portal) + socket.consume_sockets(5, "web_server")(config) return config diff --git a/esphome/components/web_server_base/__init__.py b/esphome/components/web_server_base/__init__.py index 7986ac964d..3191f1013a 100644 --- a/esphome/components/web_server_base/__init__.py +++ b/esphome/components/web_server_base/__init__.py @@ -23,10 +23,27 @@ web_server_base_ns = cg.esphome_ns.namespace("web_server_base") WebServerBase = web_server_base_ns.class_("WebServerBase", cg.Component) CONF_WEB_SERVER_BASE_ID = "web_server_base_id" -CONFIG_SCHEMA = cv.Schema( - { - cv.GenerateID(): cv.declare_id(WebServerBase), - } + + +def _consume_web_server_base_sockets(config): + """Register the shared listening socket for the HTTP server. + + web_server_base is the shared HTTP server used by web_server and captive_portal. + The listening socket is registered here rather than in each consumer. + """ + from esphome.components import socket + + socket.consume_sockets(1, "web_server_base", socket.SocketType.TCP_LISTEN)(config) + return config + + +CONFIG_SCHEMA = cv.All( + cv.Schema( + { + cv.GenerateID(): cv.declare_id(WebServerBase), + } + ), + _consume_web_server_base_sockets, )