After the Phase A / Phase B split in this PR, an external producer that
called wake_loop_threadsafe() (MQTT RX, USB RX, BLE event, espnow,
camera, mWW, speakers, USB host/CDC, lwip socket, enable_loop_soon_any_context)
only got Phase A — the component phase stayed gated by loop_interval_,
so the producer's component loop() could be delayed by up to
loop_interval_ ms before draining its queued work. That breaks the
long-standing semantic of wake_loop_threadsafe().
Add a wake_request flag set by every wake_loop_* entry point and
exchange-cleared at the gate in Application::loop(). When the flag is
set, force Phase B regardless of loop_interval_.
Storage is conditional on the threading model:
- ESPHOME_THREAD_MULTI_ATOMICS: std::atomic<uint8_t> (uint8_t, not
bool, because GCC on Xtensa generates an indirect call for
atomic<bool> ops — same workaround as scheduler.h)
- ESPHOME_THREAD_SINGLE / ESPHOME_THREAD_MULTI_NO_ATOMICS: volatile
uint8_t (8-bit aligned loads/stores are atomic on every supported
MCU; the platform signal that follows wake_request_set provides the
cross-thread/cross-core memory barrier)
Helpers (wake_request_set / wake_request_take) are always_inline so
IRAM_ATTR call sites stay in IRAM. Set BEFORE the platform signal so the
consumer is guaranteed to see the flag on its next gate check.
Adds an integration test that raises loop_interval_ to 2s, snapshots a
counting component's loop count, spawns a std::thread that calls
App.wake_loop_threadsafe() after 50ms, and asserts the count increments
inside a 500ms observation window. Without the fix the count would not
move for ~2s.
Doc and test updates from a code review of this PR:
- Correct the `tail_us == 0 on Phase A-only ticks` claim in the
Application::loop() comment and the RuntimeStatsCollector::record_loop_active
docstring. `loop_tail_start_us` is set to `loop_before_end_us`, and
`loop_now_us` is sampled later, so `tail_us` on Phase A-only ticks is
the small gate-check + record prefix — tiny but non-zero.
(Also flagged by Copilot on application.h:623 and runtime_stats.h:45.)
- Call out ESP8266 as the floor case in the WDT_FEED_INTERVAL_MS margin
table. Its soft WDT (~1.6 s) is the tightest margin at ~5x, so future
changes to the constant need to preserve comfortable headroom there.
- Tighten the test lower bound at tests/integration/test_loop_interval_decoupling.py
from `2 <= loop_delta <= 6` to `3 <= loop_delta <= 6`. Allowing 2 would
let a >50% slowdown from the 4-in-2s nominal pass as CI jitter, which
undermines the regression signal. 3 keeps the test honest while still
absorbing realistic CI jitter.
- Add a second integration test
(test_loop_interval_default_not_pulled_forward) that covers the inverse
direction: at the default loop_interval_ with a fast scheduler item
(5 ms — well under the old delay_time/2 = 8 ms floor), the component
phase must still run at ~62 Hz, not the pre-fix ~128 Hz. This locks
down the original 128 Hz → 62 Hz regression that motivated the PR.
Restructures Application::loop() into two independent phases to stop the
scheduler from silently pulling the component loop cadence forward.
Before: Application::loop() bounded its sleep by
min(loop_interval_ - elapsed, next_schedule_in())
with a delay_time/2 floor. Any scheduler item due sooner than
loop_interval_/2 dragged the whole component phase with it. On a typical
ESP32 config with default loop_interval_=16ms, combined scheduler
activity from api / esp32_ble / esp32_ble_tracker / debug was keeping
every component's loop() running at ~128 Hz instead of the documented
~62 Hz.
This has become more visible recently as more components convert to
PollingComponent (which uses set_interval internally) and more in-tree
code uses set_interval / set_timeout directly. Adding or removing any
scheduled item silently changed every other component's loop cadence.
App.set_loop_interval() for power savings was also silently defeated.
After:
- Phase A (every tick): drain wake notifications, run scheduler.call(),
feed WDT
- Phase B (gated by loop_interval_ or HighFrequencyLoopRequester):
iterate registered components and update last_loop_
Sleep = min(time-until-next-component-phase, next_schedule_in()). When
a scheduler event wakes us early, Phase A services it and the component
phase stays gated independently. loop_interval_ is now a true minimum
interval between component phases.
The delay_time/2 floor is removed. Any legitimate need to wake faster
than loop_interval_ has proper mechanisms:
- HighFrequencyLoopRequester for sustained fast-loop needs
- Application::wake_loop_threadsafe() from any context (new in 2026.4.0)
for one-shot wake-on-event
Also guards against set_interval(0) misuse — it asks the main loop to
spin forever, which was never the intended API. Warns at creation time
pointing authors at HighFrequencyLoopRequester. set_timeout(0)/defer()
is unaffected; zero-delay one-shots remain legitimate.
Runtime stats: process_pending_stats is now called on every tick (not
just when the component phase runs) so log_interval_ isn't quantized to
the component-phase cadence. Added an inline fast-path gate in
runtime_stats.h that early-outs unless now >= next_log_time_, keeping
Application::loop() slim; the log_stats_ work stays out-of-line.
Ordering constraints preserved:
- defer() callbacks still FIFO before components same-tick (Phase A
runs before Phase B)
- Scheduled items still execute before components when both due
- Scheduled callbacks still run on main thread only
- loop_component_start_time_ is still set fresh at each component's loop
- WDT is still fed at least once per tick