From ffe5db8c7dba5c76cacd9d6d50df00d8bb23ac8e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 18 Apr 2026 17:51:13 -0500 Subject: [PATCH] [core] refresh HighFrequencyLoopRequester state at sleep decision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Phase A/B split cached `HighFrequencyLoopRequester::is_high_frequency()` once at the top of Application::loop() and reused that value at the sleep decision after Phase B. If a component calls HighFrequencyLoopRequester::start() from inside its own loop(), the cached value is stale when we pick the sleep length, so the request doesn't take effect until the next tick. Before the decoupling, the sleep decision called the function fresh at the same point in the code, so an in-loop HF request was honored immediately. This caching silently regressed that behavior. The regression matters a lot more now than it would have previously: loop_interval_ is the power-saving knob this PR was written to enable (raised multi-second values are a documented use case), so under the new freedom the worst-case HF-request latency can stretch from ~16 ms to several seconds. Re-read is_high_frequency() fresh at the sleep decision. The gate check above continues to use the cached value — there's no correctness benefit to re-reading it there, and the "one read covers the whole tick" design intent is preserved for the gate. The function is a trivial atomic-read static; a second call is cheaper than the latency it prevents. Flagged by Copilot review on application.h:728. --- esphome/core/application.h | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/esphome/core/application.h b/esphome/core/application.h index 5ac6d020ab..3752731e99 100644 --- a/esphome/core/application.h +++ b/esphome/core/application.h @@ -718,8 +718,18 @@ inline void ESPHOME_ALWAYS_INLINE Application::loop() { // Compute sleep: bounded by time-until-next-component-phase and the // scheduler's next deadline. When a scheduler event wakes us early we // re-enter loop(), Phase A services it, and the component phase stays gated. + // + // Re-read HighFrequencyLoopRequester::is_high_frequency() here instead of + // reusing the cached `high_frequency` captured above: a component calling + // HighFrequencyLoopRequester::start() from within its loop() would + // otherwise sit under the stale value and sleep for up to loop_interval_ + // before the request took effect. That was fine pre-decoupling (the old + // main loop also called the function fresh at the sleep point) but now + // matters much more — loop_interval_ is a power-saving knob documented + // to accept multi-second values, so the stale path could add seconds of + // latency on an HF request. The call is a trivial atomic read. uint32_t delay_time = 0; - if (!high_frequency) { + if (!HighFrequencyLoopRequester::is_high_frequency()) { const uint32_t elapsed_since_phase = now - this->last_loop_; const uint32_t until_phase = (elapsed_since_phase >= this->loop_interval_) ? 0 : (this->loop_interval_ - elapsed_since_phase);