[esphome.ota] Filter fast-select wake hook to OTA listener netconn only

The inline OTA wake hook was firing on every NETCONN_EVT_RCVPLUS across every
monitored socket (API client data packets, mDNS queries, web server, etc.).
Each false fire paid two volatile stores + memw barriers to mark OTA
pending-enable, only for OTA::loop() to run a wake-up tick and re-disable
itself because there was no actual listener activity.

Add a compare-against-listener filter in esphome_socket_event_callback so the
wake hook only fires when `conn` matches the OTA listen socket's netconn.
Non-match sockets now cost only a pointer load + one branch (~3 instructions)
instead of the full ~10-instruction hook body.

Plumbing:
- lwip_fast_select.[ch]: new s_ota_listener_conn global +
  esphome_fast_select_set_ota_listener_sock() setter, used in the callback.
- BSDSocketImpl / LwIPSocketImpl: new public get_cached_sock() accessor (only
  under USE_LWIP_FAST_SELECT) mirroring the existing get_fd() pattern.
- ESPHomeOTAComponent::setup(): after registering the wake component,
  install the listener filter with this->server_->get_cached_sock().

Raw TCP (ESP8266/RP2040) is unaffected — that path wakes from
LWIPRawListenImpl::accept_fn_, which only fires for the specific listener
pcb it was registered on, so the filtering is implicit there.
This commit is contained in:
J. Nick Koston
2026-04-10 17:14:00 -10:00
parent 5f04cff8bc
commit 01beb56899
5 changed files with 63 additions and 14 deletions
@@ -15,6 +15,9 @@
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/util.h"
#ifdef USE_LWIP_FAST_SELECT
#include "esphome/core/lwip_fast_select.h"
#endif
#include <cerrno>
#include <cstdio>
@@ -69,6 +72,14 @@ void ESPHomeOTAComponent::setup() {
// Register for socket wake notifications. loop() disables itself on its first
// idle tick — no need to disable_loop() here explicitly.
App.set_ota_wake_component(this);
#ifdef USE_LWIP_FAST_SELECT
// Install the listener filter so the fast-select RCVPLUS wake hook only fires for
// events on this listener's netconn (i.e. new incoming connections). Without this,
// every RCVPLUS across all monitored sockets (API client data, mDNS, etc.) would
// pay the inline hook's two volatile stores + memw barriers to mark OTA
// pending-enable, even though OTA would just re-disable itself on the next tick.
esphome_fast_select_set_ota_listener_sock(this->server_->get_cached_sock());
#endif
}
void ESPHomeOTAComponent::dump_config() {
@@ -118,6 +118,15 @@ class BSDSocketImpl {
int get_fd() const { return this->fd_; }
#ifdef USE_LWIP_FAST_SELECT
// Cached lwip_sock pointer captured at construction. Used by OTA to register its
// listener netconn with the fast-select wake hook filter so the hook only fires on
// OTA-relevant events. Returns nullptr for non-monitored sockets.
struct lwip_sock *get_cached_sock() const {
return this->cached_sock_;
}
#endif
protected:
int fd_{-1};
#ifdef USE_LWIP_FAST_SELECT
@@ -84,6 +84,13 @@ class LwIPSocketImpl {
int get_fd() const { return this->fd_; }
#ifdef USE_LWIP_FAST_SELECT
// See BSDSocketImpl::get_cached_sock() — same purpose, same semantics.
struct lwip_sock *get_cached_sock() const {
return this->cached_sock_;
}
#endif
protected:
int fd_{-1};
#ifdef USE_LWIP_FAST_SELECT
+28 -14
View File
@@ -161,16 +161,24 @@ _Static_assert(offsetof(struct lwip_sock, rcvevent) == ESPHOME_LWIP_SOCK_RCVEVEN
static netconn_callback s_original_callback = NULL;
#ifdef ESPHOME_USE_OTA
// Extern wake hook for the OTA component (implemented in application.cpp). Called from the
// TCP/IP task on every NETCONN_EVT_RCVPLUS — not just OTA's listener, so this can be a false
// wake from an unrelated monitored socket. OTA::loop() handles that by disabling itself again
// when there is no pending work. The hook only marks the OTA component as pending loop-enable;
// it does not itself wake the main task (the caller below already does that).
// NOTE: ESPHOME_USE_OTA (not USE_OTA) because USE_OTA only lives in defines.h, and this .c
// file cannot include defines.h — macros.h → Arduino.h would break the C compile under
// Arduino builds. ota/__init__.py emits -DESPHOME_USE_OTA as a build flag specifically so
// this file can see it without a name collision with the defines.h USE_OTA entry.
extern void esphome_wake_ota_component_any_context(void);
// OTA listener netconn, captured via esphome_fast_select_set_ota_listener_sock() at OTA
// setup(). The wake hook only fires when the callback's `conn` argument matches this
// pointer — i.e. only for RCVPLUS events on the OTA listen socket (new accepts). Avoids
// paying the inline wake hook's cost (two volatile stores + memw barriers) on every API
// client data packet, mDNS query, etc.
static struct netconn *s_ota_listener_conn = NULL;
// Inline wake hook defined in esphome/core/wake.h. ESPHOME_USE_OTA (not USE_OTA) because
// USE_OTA only lives in defines.h, and this .c file cannot include defines.h — macros.h →
// Arduino.h would break the C compile under Arduino builds. ota/__init__.py emits
// -DESPHOME_USE_OTA as a build flag so this file can see it without a name collision with
// the defines.h USE_OTA entry.
#include "esphome/core/wake.h"
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) {
s_ota_listener_conn = (sock != NULL) ? sock->conn : NULL;
}
#else
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock) { (void) sock; }
#endif
// Wrapper callback: calls original event_callback + notifies main loop task.
@@ -188,10 +196,16 @@ static void esphome_socket_event_callback(struct netconn *conn, enum netconn_evt
// already wake the main loop through the RCVPLUS path.
if (evt == NETCONN_EVT_RCVPLUS) {
#ifdef ESPHOME_USE_OTA
// Mark the OTA component pending-enable BEFORE xTaskNotifyGive — the flags must be
// visible before we wake the main task, otherwise the main loop could run a full
// iteration without seeing the pending-enable request.
esphome_wake_ota_component_any_context();
// Filter: only mark OTA pending-enable when the event is for OTA's listen socket.
// Without this, every RCVPLUS (API client data, mDNS, etc.) would pay the inline
// wake hook's two volatile stores + memw barriers. The setter that installs
// s_ota_listener_conn is called from OTA setup(); until then the pointer is NULL
// and the filter skips the wake work entirely, which is the correct idle behavior.
// MUST happen before xTaskNotifyGive below — the flags have to be visible before
// the main task wakes, or the main loop could run a full iteration and miss them.
if (conn == s_ota_listener_conn) {
esphome_wake_ota_component_any_context();
}
#endif
TaskHandle_t task = esphome_main_task_handle;
if (task != NULL) {
+8
View File
@@ -53,6 +53,14 @@ static inline bool esphome_lwip_socket_has_data(struct lwip_sock *sock) {
/// The sock pointer must have been obtained from esphome_lwip_get_sock().
void esphome_lwip_hook_socket(struct lwip_sock *sock);
/// Filter the inline OTA wake hook in the fast-select callback so it only fires for
/// RCVPLUS events on this specific listener's netconn. Without this, every monitored
/// socket's RCVPLUS (API client data, web server, mDNS, etc.) would mark OTA
/// pending-enable and force its loop to run a wake-up tick only to re-disable itself.
/// Captured at OTA setup(); stays pointing at the listener for the device's lifetime.
/// Pass NULL to clear the filter (wake fires on every RCVPLUS, pre-filter behavior).
void esphome_fast_select_set_ota_listener_sock(struct lwip_sock *sock);
/// Set or clear TCP_NODELAY on a socket's tcp_pcb directly.
/// Must be called with the TCPIP core lock held (LwIPLock in C++).
/// This bypasses lwip_setsockopt() overhead (socket lookups, switch cascade,