diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index a6f1558e4a4..d83d67d6e42 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -414,8 +414,27 @@ bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) { optional HOT Scheduler::next_schedule_in(uint32_t now) { // IMPORTANT: This method should only be called from the main thread (loop task). - // It performs cleanup and accesses items_[0] without holding a lock, which is only - // safe when called from the main thread. Other threads must not call this method. + // Accesses items_[0] and the fast-path empty checks without holding a lock, which + // is only safe from the main thread. Other threads must not call this method. + // + // Note: cleanup_() is only invoked on the items_[0] path below. The early returns + // skip it because they don't read items_[0], and Scheduler::call() at the top of + // every loop iteration already performs its own cleanup before the next sleep- + // duration computation happens. + +#ifndef ESPHOME_THREAD_SINGLE + // defer() items live in a separate queue that is drained at the top of every + // loop tick via process_defer_queue_(). If any are pending, the next loop + // iteration has work to do right now -- don't let the caller sleep. + if (!this->defer_empty_()) + return 0; +#else + // On single-threaded builds, defer() routes through set_timeout(..., 0) which + // stages in to_add_. process_to_add() runs at the top of every scheduler.call(), + // so anything in to_add_ becomes runnable on the next iteration; don't sleep. + if (!this->to_add_empty_()) + return 0; +#endif // If no items, return empty optional if (!this->cleanup_())