diff --git a/esphome/core/application.cpp b/esphome/core/application.cpp index 29d3c9dac9..a8e39ded6e 100644 --- a/esphome/core/application.cpp +++ b/esphome/core/application.cpp @@ -147,14 +147,15 @@ void Application::setup() { clear_setup_priority_overrides(); #endif -#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) -#ifdef USE_ESP32 - // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_ESP32) + // Initialize fast select: saves main loop task handle for xTaskNotifyGive wake. + // Always init on ESP32 — the fast path (rcvevent reads + ulTaskNotifyTake) is used + // unconditionally when USE_SOCKET_SELECT_SUPPORT is enabled. esphome_lwip_fast_select_init(); -#else - // Set up wake socket for waking main loop from tasks - this->setup_wake_loop_threadsafe_(); #endif +#if defined(USE_SOCKET_SELECT_SUPPORT) && defined(USE_WAKE_LOOP_THREADSAFE) && !defined(USE_ESP32) + // Set up wake socket for waking main loop from tasks (non-ESP32 only) + this->setup_wake_loop_threadsafe_(); #endif this->schedule_dump_config(); @@ -642,7 +643,9 @@ void Application::yield_with_select_(uint32_t delay_ms) { ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(delay_ms)); #elif defined(USE_SOCKET_SELECT_SUPPORT) - // Non-ESP32 platforms (LibreTiny bk72xx/rtl87xx): use select() + // Non-ESP32 select() path (LibreTiny bk72xx/rtl87xx, host platform). + // ESP32 is excluded by the #if above — both BSD_SOCKETS and LWIP_SOCKETS on ESP32 + // use LwIP under the hood, so the fast path handles all ESP32 socket implementations. if (!this->socket_fds_.empty()) [[likely]] { // Update fd_set if socket list has changed if (this->socket_fds_changed_) [[unlikely]] { @@ -700,7 +703,7 @@ Application App; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) #ifdef USE_ESP32 void Application::wake_loop_threadsafe() { - // Direct FreeRTOS task notification — ISR-safe, <1 us + // Direct FreeRTOS task notification — <1 us, task context only (NOT ISR-safe) esphome_lwip_wake_main_loop(); } #else // !USE_ESP32 diff --git a/esphome/core/lwip_fast_select.c b/esphome/core/lwip_fast_select.c index 7ae9bd5378..66e1f7c285 100644 --- a/esphome/core/lwip_fast_select.c +++ b/esphome/core/lwip_fast_select.c @@ -130,11 +130,17 @@ void esphome_lwip_hook_socket(int fd) { if (sock == NULL || sock->conn == NULL) return; - // Save original callback once — all LwIP sockets share the same event_callback. + // Save original callback once — all LwIP sockets share the same static event_callback + // (DEFAULT_SOCKET_EVENTCB in sockets.c, used for SOCK_RAW, SOCK_DGRAM, and SOCK_STREAM). if (s_original_callback == NULL) { s_original_callback = sock->conn->callback; } + // Verify assumption: if we already have the original, this socket should have the same one. + // If this fires, LwIP changed to use per-socket callbacks and we need a per-fd array. + LWIP_ASSERT("all sockets must share the same event_callback", + sock->conn->callback == s_original_callback || sock->conn->callback == esphome_socket_event_callback); + // Replace with our wrapper. Atomic on ESP32 (32-bit aligned pointer write). // TCP/IP thread sees either old or new pointer — both are valid. sock->conn->callback = esphome_socket_event_callback; diff --git a/tests/components/socket/test_wake_loop_threadsafe.py b/tests/components/socket/test_wake_loop_threadsafe.py index 45d354f7b3..28b4ee564f 100644 --- a/tests/components/socket/test_wake_loop_threadsafe.py +++ b/tests/components/socket/test_wake_loop_threadsafe.py @@ -1,16 +1,21 @@ from esphome.components import socket -from esphome.const import KEY_CORE, KEY_TARGET_PLATFORM, PLATFORM_ESP8266 +from esphome.const import ( + KEY_CORE, + KEY_TARGET_PLATFORM, + PLATFORM_ESP32, + PLATFORM_ESP8266, +) from esphome.core import CORE -def _setup_non_esp32_platform() -> None: - """Set up CORE.data with a non-ESP32 platform for testing.""" - CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: PLATFORM_ESP8266} +def _setup_platform(platform=PLATFORM_ESP8266) -> None: + """Set up CORE.data with a platform for testing.""" + CORE.data[KEY_CORE] = {KEY_TARGET_PLATFORM: platform} def test_require_wake_loop_threadsafe__first_call() -> None: """Test that first call sets up define and consumes socket.""" - _setup_non_esp32_platform() + _setup_platform() CORE.config = {"wifi": True} socket.require_wake_loop_threadsafe() @@ -39,7 +44,7 @@ def test_require_wake_loop_threadsafe__idempotent() -> None: def test_require_wake_loop_threadsafe__multiple_calls() -> None: """Test that multiple calls only set up once.""" - _setup_non_esp32_platform() + _setup_platform() # Call three times CORE.config = {"openthread": True} socket.require_wake_loop_threadsafe() @@ -83,3 +88,29 @@ def test_require_wake_loop_threadsafe__no_networking_does_not_consume_socket() - udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) assert "socket.wake_loop_threadsafe" not in udp_consumers assert udp_consumers == initial_udp + + +def test_require_wake_loop_threadsafe__esp32_no_udp_socket() -> None: + """Test that ESP32 uses task notifications instead of UDP socket.""" + _setup_platform(PLATFORM_ESP32) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify the define was added + assert CORE.data[socket.KEY_WAKE_LOOP_THREADSAFE_REQUIRED] is True + assert any(d.name == "USE_WAKE_LOOP_THREADSAFE" for d in CORE.defines) + + # Verify no UDP socket was consumed (ESP32 uses FreeRTOS task notifications) + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert "socket.wake_loop_threadsafe" not in udp_consumers + + +def test_require_wake_loop_threadsafe__non_esp32_consumes_udp_socket() -> None: + """Test that non-ESP32 platforms consume a UDP socket for wake notifications.""" + _setup_platform(PLATFORM_ESP8266) + CORE.config = {"wifi": True} + socket.require_wake_loop_threadsafe() + + # Verify UDP socket was consumed + udp_consumers = CORE.data.get(socket.KEY_SOCKET_CONSUMERS_UDP, {}) + assert udp_consumers.get("socket.wake_loop_threadsafe") == 1