[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:
J. Nick Koston
2026-02-21 17:00:40 -06:00
parent 5b5bc28ba3
commit d630166280
9 changed files with 64 additions and 25 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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,
)