The function-level NOLINTBEGIN/NOLINTEND on start_session_main_loop_()
only covers bug paths whose primary or note locations fall inside that
function. clang-analyzer now traces the leak through the caller chain
(AsyncEventSource::loop -> adopt_pending_sessions_main_loop_ ->
start_session_main_loop_ -> ArduinoJson), with notes at lines 497/498
and 530/537 that sit outside the wrapped range. Add a NOLINTNEXTLINE
at the call site in adopt_pending_sessions_main_loop_ so the caller's
bug-path location is covered too.
On ESPHOME_THREAD_SINGLE builds (ESP8266, RP2040), defer() does not
route through defer_queue_ — set_timer_common_ treats it as an ordinary
0-delay set_timeout that stages in to_add_ (scheduler.cpp:197). Since
next_schedule_in only inspects items_[0], any defer posted from a
component's loop() sits in to_add_ invisible to the sleep calculation
until the next scheduler.call() runs process_to_add().
The symmetric fix checks to_add_empty_() on single-threaded and
short-circuits sleep the same way. This also costs one "skipped sleep"
for any non-zero-delay timer added at runtime, but the next tick
produces a correct next_schedule_in reading and the cost is a single
yield — negligible vs. the stall this closes.
Suggested by Copilot review on #15968.
The previous narrow NOLINT only wrapped the sorting_groups JSON loop.
Now that the function is invoked through an extra call layer
(loop() -> adopt_pending_sessions_main_loop_ -> start_session_main_loop_),
clang-analyzer traces a leak path through ws->get_config_json() as
well -- also a false positive inside ArduinoJson's VariantData.
Move the NOLINTBEGIN/NOLINTEND to bracket the entire function body.
On multi-thread platforms (ESP32/LibreTiny/host), defer() items go
directly into a separate defer_queue_ instead of the main items_ heap
(scheduler.cpp:200). next_schedule_in previously only looked at
items_[0], so a pending defer posted from a background task was
invisible to the loop's sleep-duration calculation: if items_[0] fired
seconds away, the main loop would sleep that long on ulTaskNotifyTake
while the defer sat unexecuted.
In practice this rarely bit anyone because concurrent socket activity
usually woke the loop via the lwip fast-select path, but a defer with
no accompanying network traffic could stall for seconds.
Check defer_empty_() (cheap atomic/volatile load, scheduler.h:594)
before the items_ check and return 0 if pending. process_defer_queue_
runs at the top of every scheduler.call(), so "defer pending" == "run
immediately."
Single-threaded platforms funnel defers into to_add_ like any other
timeout, so the fix is gated on !ESPHOME_THREAD_SINGLE.
destroy() already logs the close with the fd on the httpd task, so the
follow-up log when the main loop reaps the session adds no information
and doubles log volume during reconnect storms.
Pre-refactor, on_connect_ ran after the ctor had already sent the
initial ping/config/sorting_groups. Call start_session_main_loop_()
before on_connect_ so the callback still observes a primed session.
Also document at onConnect() that the callback now runs on the main
loop instead of the httpd task.
Keep the hot path in AsyncEventSource::loop() to the atomic load,
branch, and dead-session cleanup. On a real connect, call a
noinline+cold helper (adopt_pending_sessions_main_loop_) to swap the
pending list out, drop any already-disconnected sessions, and invoke
on_connect_ / start_session_main_loop_ on the rest.
If fd_ is already 0 when the main loop adopts an incoming session, the
client closed before we got here. Delete the session immediately instead
of pushing it into sessions_ and firing on_connect_ on a dead socket.
Keep the single-byte std::atomic<bool> as the last member of
AsyncEventSource so it consumes what would otherwise be trailing
padding instead of introducing 3 bytes of interior padding between
pending_mutex_ and on_connect_.
AsyncEventSource::handleRequest() runs on the ESP-IDF httpd task. Prior to
this change it mutated sessions_, event_buffer_, deferred_queue_, and
entities_iterator_ directly: appending the new response to sessions_,
sending the initial ping/config/sorting_groups through try_send_nodefer()
(which writes event_buffer_), and calling entities_iterator_.begin().
The main loop reads/writes the same fields from WebServer::loop() on the
main task, so every new SSE connect raced with any in-flight loop tick.
The httpd path now only performs the HTTP-level setup that requires the
live httpd_req_t (headers, initial chunk, sess_ctx/free_ctx, fd_, send
override) and parks the response on pending_sessions_ under a mutex.
The main loop checks a std::atomic<bool> fast-path gate per tick; when
set it swaps the pending list out under the lock and then — outside the
lock — pushes into sessions_, invokes on_connect_, and calls prime_()
which performs the initial sends and starts entities_iterator_.
sessions_, event_buffer_, deferred_queue_, and entities_iterator_ are
now mutated exclusively from the main loop. fd_ remains the only
cross-thread signal (std::atomic<int> cleared by destroy() on the
httpd/tcpip task).
- mdns_esp8266.cpp: narrow setup()'s listener block and on_ip_state from
USE_MDNS_EVENT_DRIVEN_POLLING to USE_MDNS_WIFI_LISTENER. ESP8266 has no
ethernet so the practical effect is identical, but the tighter guard is
symmetric with mdns_rp2040.cpp and more defensible if the listener macros
ever drift.
- MDNS_POLL_WINDOW_MS 12000 → 15000. The RFC-compliant LEAmDNS phase is ~9s
worst case, but a blocked main loop (long component setup, WiFi scan, flash
writes) can stretch the deadlines between our polls. 6s of margin absorbs
that without materially extending the steady-state quiet period.
- Header comment for MDNS_POLL_WINDOW_MS now breaks down the 9s derivation
with the exact LEAmDNS constants (MDNS_PROBE_DELAY × MDNS_PROBE_COUNT +
MDNS_ANNOUNCE_DELAY × MDNS_ANNOUNCE_COUNT + jitter + debounce hop) so
future library bumps can be justified against the same math.
clang-tidy CI compiles with the raw esp8266-arduino-tidy env's flags — no
Python codegen, and it doesn't pick up the feature defines from defines.h in
the same way a real IDE does. USE_WIFI_IP_STATE_LISTENERS ends up undefined,
the derivation in mdns_component.h doesn't fire, and the class doesn't declare
start_polling_window_ / on_ip_state / the MDNS_POLL_* constants. Removing the
guards in the platform cpps made those definitions dangle.
Keep the listener-specific definitions under #ifdef USE_MDNS_EVENT_DRIVEN_POLLING.
The Python validator still enforces the runtime invariant (wifi or ethernet
must be present on ESP8266/RP2040), so production builds always fire through
the listener path; this is purely about what the tidy compiler sees.