From 7bb1ae652059d04f4cf09138bc6f2464e08b93ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 5 Sep 2026 21:12:08 +0200 Subject: [PATCH] [mdns] Guard LEAmDNS main loop calls against lwIP re-entrancy on ESP8266 LEAmDNS update() and close() can yield inside UdpContext::sendTimeout() while a send fails; an mDNS packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides free the same tx pbufs, which corrupts the heap and shows up later as crashes such as the NULL dereference in noise_handshakestate_new during the API handshake. mdns_esp8266.cpp now owns a MDNSResponder subclass (NO_GLOBAL_MDNS) whose receive handler only counts packets while a main loop call is running and drains them from the main loop afterwards. The roaming gate from #18785 and the is_roaming() accessor it needed are no longer required. --- esphome/components/mdns/__init__.py | 2 + esphome/components/mdns/mdns_esp8266.cpp | 72 ++++++++++++++++++------ esphome/components/wifi/wifi_component.h | 4 -- 3 files changed, 56 insertions(+), 22 deletions(-) diff --git a/esphome/components/mdns/__init__.py b/esphome/components/mdns/__init__.py index f039bb69f03..676bb890b3f 100644 --- a/esphome/components/mdns/__init__.py +++ b/esphome/components/mdns/__init__.py @@ -192,6 +192,8 @@ async def to_code(config: ConfigType) -> None: if CORE.using_arduino: if CORE.is_esp8266: cg.add_library("ESP8266mDNS", None) + # mdns_esp8266.cpp owns a guarded MDNSResponder instead of the library global + cg.add_build_flag("-DNO_GLOBAL_MDNS") elif CORE.is_rp2: cg.add_library("LEAmDNS", None) diff --git a/esphome/components/mdns/mdns_esp8266.cpp b/esphome/components/mdns/mdns_esp8266.cpp index 1f0b3c9519d..684c55c4bc8 100644 --- a/esphome/components/mdns/mdns_esp8266.cpp +++ b/esphome/components/mdns/mdns_esp8266.cpp @@ -13,8 +13,56 @@ namespace esphome::mdns { +// Main-loop calls into LEAmDNS (update(), close()) can yield inside UdpContext::sendTimeout(); +// a packet arriving then re-enters LEAmDNS from lwIP on the same UdpContext and both sides free +// the same tx pbufs (#18760). Received packets are only counted during such a call and drained +// from the main loop afterwards. +class GuardedMDNSResponder : public ::esp8266::MDNSImplementation::MDNSResponder { + public: + void update_guarded() { + this->run_guarded_([this]() { this->update(); }); + } + void close_guarded() { + this->run_guarded_([this]() { this->close(); }); + } + + private: + template void run_guarded_(F &&fn) { + UdpContext *ctx = this->m_pUDPContext; + if (ctx == nullptr) { + fn(); + return; + } + // Set every time: a restart replaces the context together with its stock handler + ctx->onRx([this]() { this->on_rx_(); }); + this->pending_rx_ = 0; + this->in_loop_call_ = true; + fn(); + // Still counting here, so packets arriving during a yield in the drain queue behind it + while (this->pending_rx_ > 0) { + this->pending_rx_--; + this->_process(false); + } + this->in_loop_call_ = false; + } + + // lwIP receive callback (SYS context) + void on_rx_() { + if (this->in_loop_call_) { + this->pending_rx_++; + } else { + this->_callProcess(); + } + } + + volatile bool in_loop_call_{false}; + volatile uint8_t pending_rx_{0}; +}; + +static GuardedMDNSResponder mdns_responder; // NOLINT(cppcoreguidelines-avoid-non-const-global-variables) + static void register_esp8266(MDNSComponent *, StaticVector &services) { - MDNS.begin(App.get_name().c_str()); + mdns_responder.begin(App.get_name().c_str()); for (const auto &service : services) { // Strip the leading underscore from the proto and service_type. While it is @@ -30,10 +78,10 @@ static void register_esp8266(MDNSComponent *, StaticVectorset_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { -#ifdef USE_MDNS_WIFI_LISTENER - // MDNS.update() can suspend the loop in UdpContext::sendTimeout() while a send is - // failing (radio off-channel during a roam scan, or mid reconnect); an incoming - // packet then re-enters LEAmDNS from lwIP and corrupts shared UdpContext state. - // Skip the tick while the radio cannot transmit (#18760), but keep polling while - // the AP is serving clients (AP-only or fallback AP with the STA down). - auto *wifi = wifi::global_wifi_component; - if (wifi->is_roaming() || (!wifi->is_connected() && !wifi->is_ap_active())) - return; -#endif - MDNS.update(); - }); + this->set_interval(MDNS_POLL_ID, MDNS_UPDATE_INTERVAL_MS, []() { mdns_responder.update_guarded(); }); this->set_timeout(MDNS_POLL_STOP_ID, MDNS_POLL_WINDOW_MS, [this]() { this->cancel_interval(MDNS_POLL_ID); }); } #endif @@ -81,7 +117,7 @@ void MDNSComponent::on_ip_state(const network::IPAddresses &ips, const network:: #endif void MDNSComponent::on_shutdown() { - MDNS.close(); + mdns_responder.close_guarded(); delay(10); } diff --git a/esphome/components/wifi/wifi_component.h b/esphome/components/wifi/wifi_component.h index 94fdd9bc142..2b1bc62e131 100644 --- a/esphome/components/wifi/wifi_component.h +++ b/esphome/components/wifi/wifi_component.h @@ -476,10 +476,6 @@ class WiFiComponent final : public Component { /// True while a post-connect roaming scan holds the radio off-channel. bool is_roaming_scan_active() const { return this->roaming_state_ == RoamingState::SCANNING; } - /// True while a post-connect roam is in progress (scanning off-channel, reassociating, - /// or recovering from a failed roam). - bool is_roaming() const { return this->roaming_state_ != RoamingState::IDLE; } - #ifdef USE_ESP32 /// esp_netif handle of the station interface, used by network for default-route /// arbitration. nullptr until wifi_lazy_init_() has run.