mirror of
https://github.com/esphome/esphome.git
synced 2026-02-28 18:04:19 -07:00
[socket] Track TCP listening sockets separately via SocketType.TCP_LISTEN
Components now register their listening sockets explicitly instead of relying on a hardcoded count. web_server_base registers the shared HTTP listener (used by both web_server and captive_portal). The libretiny lwIP config derives MEMP_NUM_TCP_PCB_LISTEN dynamically from these registrations. Also fixes captive_portal to correctly register its DNS socket as UDP instead of TCP. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user