Files
esphome/esphome/core/scheduler.cpp
T
J. Nick Koston da5c31cb27 [core] Scheduler::next_schedule_in: return 0 when defer queue is non-empty
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.
2026-04-24 03:26:21 -05:00

956 lines
42 KiB
C++

#include "scheduler.h"
#include "application.h"
#include "esphome/core/defines.h"
#include "esphome/core/hal.h"
#include "esphome/core/helpers.h"
#include "esphome/core/log.h"
#include "esphome/core/progmem.h"
#include <algorithm>
#include <cinttypes>
#include <cstring>
namespace esphome {
static const char *const TAG = "scheduler";
// Memory pool configuration constants
// Pool size of 5 matches typical usage patterns (2-4 active timers)
// - Minimal memory overhead (~250 bytes on ESP32)
// - Sufficient for most configs with a couple sensors/components
// - Still prevents heap fragmentation and allocation stalls
// - Complex setups with many timers will just allocate beyond the pool
// See https://github.com/esphome/backlog/issues/52
static constexpr size_t MAX_POOL_SIZE = 5;
// Maximum number of logically deleted (cancelled) items before forcing cleanup.
// Set to 5 to match the pool size - when we have as many cancelled items as our
// pool can hold, it's time to clean up and recycle them.
static constexpr uint32_t MAX_LOGICALLY_DELETED_ITEMS = 5;
// max delay to start an interval sequence
static constexpr uint32_t MAX_INTERVAL_DELAY = 5000;
#if defined(ESPHOME_LOG_HAS_VERBOSE) || defined(ESPHOME_DEBUG_SCHEDULER)
// Helper struct for formatting scheduler item names consistently in logs
// Uses a stack buffer to avoid heap allocation
// Uses ESPHOME_snprintf_P/ESPHOME_PSTR for ESP8266 to keep format strings in flash
struct SchedulerNameLog {
char buffer[20]; // Enough for "id:4294967295" or "hash:0xFFFFFFFF" or "(null)"
// Format a scheduler item name for logging
// Returns pointer to formatted string (either static_name or internal buffer)
const char *format(Scheduler::NameType name_type, const char *static_name, uint32_t hash_or_id) {
using NameType = Scheduler::NameType;
if (name_type == NameType::STATIC_STRING) {
if (static_name)
return static_name;
// Copy "(null)" to buffer to keep it in flash on ESP8266
ESPHOME_strncpy_P(buffer, ESPHOME_PSTR("(null)"), sizeof(buffer));
return buffer;
} else if (name_type == NameType::HASHED_STRING) {
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("hash:0x%08" PRIX32), hash_or_id);
return buffer;
} else if (name_type == NameType::NUMERIC_ID) {
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("id:%" PRIu32), hash_or_id);
return buffer;
} else { // NUMERIC_ID_INTERNAL
ESPHOME_snprintf_P(buffer, sizeof(buffer), ESPHOME_PSTR("iid:%" PRIu32), hash_or_id);
return buffer;
}
}
};
#endif
// Uncomment to debug scheduler
// #define ESPHOME_DEBUG_SCHEDULER
#ifdef ESPHOME_DEBUG_SCHEDULER
// Helper to validate that a pointer looks like it's in static memory
static void validate_static_string(const char *name) {
if (name == nullptr)
return;
// This is a heuristic check - stack and heap pointers are typically
// much higher in memory than static data
uintptr_t addr = reinterpret_cast<uintptr_t>(name);
// Create a stack variable to compare against
int stack_var;
uintptr_t stack_addr = reinterpret_cast<uintptr_t>(&stack_var);
// If the string pointer is near our stack variable, it's likely on the stack
// Using 8KB range as ESP32 main task stack is typically 8192 bytes
if (addr > (stack_addr - 0x2000) && addr < (stack_addr + 0x2000)) {
ESP_LOGW(TAG,
"WARNING: Scheduler name '%s' at %p appears to be on the stack - this is unsafe!\n"
" Stack reference at %p",
name, name, &stack_var);
}
// Also check if it might be on the heap by seeing if it's in a very different range
// This is platform-specific but generally heap is allocated far from static memory
static const char *static_str = "test";
uintptr_t static_addr = reinterpret_cast<uintptr_t>(static_str);
// If the address is very far from known static memory, it might be heap
if (addr > static_addr + 0x100000 || (static_addr > 0x100000 && addr < static_addr - 0x100000)) {
ESP_LOGW(TAG, "WARNING: Scheduler name '%s' at %p might be on heap (static ref at %p)", name, name, static_str);
}
}
#endif /* ESPHOME_DEBUG_SCHEDULER */
// A note on locking: the `lock_` lock protects the `items_` and `to_add_` containers. It must be taken when writing to
// them (i.e. when adding/removing items, but not when changing items). As items are only deleted from the loop task,
// iterating over them from the loop task is fine; but iterating from any other context requires the lock to be held to
// avoid the main thread modifying the list while it is being accessed.
// Calculate random offset for interval timers
// Extracted from set_timer_common_ to reduce code size - only needed for intervals, not timeouts
uint32_t Scheduler::calculate_interval_offset_(uint32_t delay) {
uint32_t max_offset = std::min(delay / 2, MAX_INTERVAL_DELAY);
// Multiply-and-shift: uniform random in [0, max_offset) without floating point
return static_cast<uint32_t>((static_cast<uint64_t>(random_uint32()) * max_offset) >> 32);
}
// Check if a retry was already cancelled in items_ or to_add_
// Extracted from set_timer_common_ to reduce code size - retry path is cold and deprecated
// Remove before 2026.8.0 along with all retry code
bool Scheduler::is_retry_cancelled_locked_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id) {
for (auto *container : {&this->items_, &this->to_add_}) {
for (auto *item : *container) {
if (item != nullptr && this->is_item_removed_locked_(item) &&
this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT,
/* match_retry= */ true, /* skip_removed= */ false)) {
return true;
}
}
}
return false;
}
// Common implementation for both timeout and interval
// name_type determines storage type: STATIC_STRING uses static_name, others use hash_or_id
void HOT Scheduler::set_timer_common_(Component *component, SchedulerItem::Type type, NameType name_type,
const char *static_name, uint32_t hash_or_id, uint32_t delay,
std::function<void()> &&func, bool is_retry, bool skip_cancel) {
if (delay == SCHEDULER_DONT_RUN) {
// Still need to cancel existing timer if we have a name/id
if (!skip_cancel) {
LockGuard guard{this->lock_};
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false,
/* find_first= */ true);
}
return;
}
// An interval of 0 means "fire every tick forever," which is misuse: the
// item would always be due, causing Scheduler::call() to spin and starve
// the main loop (WDT reset in the field). Coerce to 1ms so existing code
// using update_interval=0ms as a pseudo-loop() continues to work at ~1kHz,
// and warn so authors can migrate to HighFrequencyLoopRequester which is
// the intended mechanism for running fast in the main loop. Zero-delay
// timeouts (defer) remain legitimate one-shots and are not affected.
if (type == SchedulerItem::INTERVAL && delay == 0) [[unlikely]] {
ESP_LOGE(TAG, "[%s] set_interval(0) would spin main loop - coercing to 1ms (use HighFrequencyLoopRequester)",
component ? LOG_STR_ARG(component->get_component_log_str()) : LOG_STR_LITERAL("?"));
delay = 1;
}
// Take lock early to protect scheduler_item_pool_ access and retry-cancelled check
LockGuard guard{this->lock_};
// For retries, check if there's a cancelled timeout first - before allocating an item.
// Skip check for anonymous retries (STATIC_STRING with nullptr) - they can't be cancelled by name
// Skip check for defer (delay=0) - deferred retries bypass the cancellation check
if (is_retry && delay != 0 && (name_type != NameType::STATIC_STRING || static_name != nullptr) &&
type == SchedulerItem::TIMEOUT &&
this->is_retry_cancelled_locked_(component, name_type, static_name, hash_or_id)) {
#ifdef ESPHOME_DEBUG_SCHEDULER
SchedulerNameLog skip_name_log;
ESP_LOGD(TAG, "Skipping retry '%s' - found cancelled item",
skip_name_log.format(name_type, static_name, hash_or_id));
#endif
return;
}
// Create and populate the scheduler item
SchedulerItem *item = this->get_item_from_pool_locked_();
item->component = component;
item->set_name(name_type, static_name, hash_or_id);
item->type = type;
// Use destroy + placement-new instead of move-assignment.
// GCC's std::function::operator=(function&&) does a full swap dance even when the
// target is empty. Since recycled/new items always have an empty callback, we can
// destroy the empty one (no-op) and move-construct directly, saving ~40 bytes of
// swap/destructor code on Xtensa.
item->callback.~function();
new (&item->callback) std::function<void()>(std::move(func));
// Reset remove flag - recycled items may have been cancelled (remove=true) in previous use
this->set_item_removed_(item, false);
item->is_retry = is_retry;
// Determine target container: defer_queue_ for deferred items, to_add_ for everything else.
// Using a pointer lets both paths share the cancel + push_back epilogue.
auto *target = &this->to_add_;
#ifndef ESPHOME_THREAD_SINGLE
// Special handling for defer() (delay = 0, type = TIMEOUT)
// Single-core platforms don't need thread-safe defer handling
if (delay == 0 && type == SchedulerItem::TIMEOUT) {
// Put in defer queue for guaranteed FIFO execution
target = &this->defer_queue_;
} else
#endif /* not ESPHOME_THREAD_SINGLE */
{
// Only non-defer items need a timestamp for scheduling
const uint64_t now_64 = millis_64();
// Type-specific setup
if (type == SchedulerItem::INTERVAL) {
item->interval = delay;
// first execution happens immediately after a random smallish offset
uint32_t offset = this->calculate_interval_offset_(delay);
item->set_next_execution(now_64 + offset);
#ifdef ESPHOME_LOG_HAS_VERBOSE
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Scheduler interval for %s is %" PRIu32 "ms, offset %" PRIu32 "ms",
name_log.format(name_type, static_name, hash_or_id), delay, offset);
#endif
} else {
item->interval = 0;
item->set_next_execution(now_64 + delay);
}
#ifdef ESPHOME_DEBUG_SCHEDULER
this->debug_log_timer_(item, name_type, static_name, hash_or_id, type, delay, now_64);
#endif /* ESPHOME_DEBUG_SCHEDULER */
}
// Common epilogue: atomic cancel-and-add (unless skip_cancel is true or anonymous)
// Anonymous items (STATIC_STRING with nullptr) can never match anything, so skip the scan.
if (!skip_cancel && (name_type != NameType::STATIC_STRING || static_name != nullptr)) {
this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, /* match_retry= */ false,
/* find_first= */ true);
}
target->push_back(item);
if (target == &this->to_add_) {
this->to_add_count_increment_locked_();
}
#ifndef ESPHOME_THREAD_SINGLE
else {
this->defer_count_increment_locked_();
}
#endif
}
void HOT Scheduler::set_timeout(Component *component, const char *name, uint32_t timeout,
std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::STATIC_STRING, name, 0, timeout,
std::move(func));
}
void HOT Scheduler::set_timeout(Component *component, const std::string &name, uint32_t timeout,
std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::HASHED_STRING, nullptr, fnv1a_hash(name),
timeout, std::move(func));
}
void HOT Scheduler::set_timeout(Component *component, uint32_t id, uint32_t timeout, std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::TIMEOUT, NameType::NUMERIC_ID, nullptr, id, timeout,
std::move(func));
}
bool HOT Scheduler::cancel_timeout(Component *component, const std::string &name) {
return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::TIMEOUT);
}
bool HOT Scheduler::cancel_timeout(Component *component, const char *name) {
return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::TIMEOUT);
}
bool HOT Scheduler::cancel_timeout(Component *component, uint32_t id) {
return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::TIMEOUT);
}
void HOT Scheduler::set_interval(Component *component, const std::string &name, uint32_t interval,
std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::HASHED_STRING, nullptr, fnv1a_hash(name),
interval, std::move(func));
}
void HOT Scheduler::set_interval(Component *component, const char *name, uint32_t interval,
std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::STATIC_STRING, name, 0, interval,
std::move(func));
}
void HOT Scheduler::set_interval(Component *component, uint32_t id, uint32_t interval, std::function<void()> &&func) {
this->set_timer_common_(component, SchedulerItem::INTERVAL, NameType::NUMERIC_ID, nullptr, id, interval,
std::move(func));
}
bool HOT Scheduler::cancel_interval(Component *component, const std::string &name) {
return this->cancel_item_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), SchedulerItem::INTERVAL);
}
bool HOT Scheduler::cancel_interval(Component *component, const char *name) {
return this->cancel_item_(component, NameType::STATIC_STRING, name, 0, SchedulerItem::INTERVAL);
}
bool HOT Scheduler::cancel_interval(Component *component, uint32_t id) {
return this->cancel_item_(component, NameType::NUMERIC_ID, nullptr, id, SchedulerItem::INTERVAL);
}
// Suppress deprecation warnings for RetryResult usage in the still-present (but deprecated) retry implementation.
// Remove before 2026.8.0 along with all retry code.
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wdeprecated-declarations"
struct RetryArgs {
// Ordered to minimize padding on 32-bit systems
std::function<RetryResult(uint8_t)> func;
Component *component;
Scheduler *scheduler;
// Union for name storage - only one is used based on name_type
union {
const char *static_name; // For STATIC_STRING
uint32_t hash_or_id; // For HASHED_STRING or NUMERIC_ID
} name_;
uint32_t current_interval;
float backoff_increase_factor;
Scheduler::NameType name_type; // Discriminator for name_ union
uint8_t retry_countdown;
};
void retry_handler(const std::shared_ptr<RetryArgs> &args) {
RetryResult const retry_result = args->func(--args->retry_countdown);
if (retry_result == RetryResult::DONE || args->retry_countdown <= 0)
return;
// second execution of `func` happens after `initial_wait_time`
// args->name_ is owned by the shared_ptr<RetryArgs>
// which is captured in the lambda and outlives the SchedulerItem
const char *static_name = (args->name_type == Scheduler::NameType::STATIC_STRING) ? args->name_.static_name : nullptr;
uint32_t hash_or_id = (args->name_type != Scheduler::NameType::STATIC_STRING) ? args->name_.hash_or_id : 0;
args->scheduler->set_timer_common_(
args->component, Scheduler::SchedulerItem::TIMEOUT, args->name_type, static_name, hash_or_id,
args->current_interval, [args]() { retry_handler(args); },
/* is_retry= */ true);
// backoff_increase_factor applied to third & later executions
args->current_interval *= args->backoff_increase_factor;
}
void HOT Scheduler::set_retry_common_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->cancel_retry_(component, name_type, static_name, hash_or_id);
if (initial_wait_time == SCHEDULER_DONT_RUN)
return;
#ifdef ESPHOME_LOG_HAS_VERY_VERBOSE
{
SchedulerNameLog name_log;
ESP_LOGVV(TAG, "set_retry(name='%s', initial_wait_time=%" PRIu32 ", max_attempts=%u, backoff_factor=%0.1f)",
name_log.format(name_type, static_name, hash_or_id), initial_wait_time, max_attempts,
backoff_increase_factor);
}
#endif
if (backoff_increase_factor < 0.0001) {
ESP_LOGE(TAG, "set_retry: backoff_factor %0.1f too small, using 1.0: %s", backoff_increase_factor,
(name_type == NameType::STATIC_STRING && static_name) ? static_name : "");
backoff_increase_factor = 1;
}
auto args = std::make_shared<RetryArgs>();
args->func = std::move(func);
args->component = component;
args->scheduler = this;
args->name_type = name_type;
if (name_type == NameType::STATIC_STRING) {
args->name_.static_name = static_name;
} else {
args->name_.hash_or_id = hash_or_id;
}
args->current_interval = initial_wait_time;
args->backoff_increase_factor = backoff_increase_factor;
args->retry_countdown = max_attempts;
// First execution of `func` immediately - use set_timer_common_ with is_retry=true
this->set_timer_common_(
component, SchedulerItem::TIMEOUT, name_type, static_name, hash_or_id, 0, [args]() { retry_handler(args); },
/* is_retry= */ true);
}
void HOT Scheduler::set_retry(Component *component, const char *name, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->set_retry_common_(component, NameType::STATIC_STRING, name, 0, initial_wait_time, max_attempts, std::move(func),
backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id) {
return this->cancel_item_(component, name_type, static_name, hash_or_id, SchedulerItem::TIMEOUT,
/* match_retry= */ true);
}
bool HOT Scheduler::cancel_retry(Component *component, const char *name) {
return this->cancel_retry_(component, NameType::STATIC_STRING, name, 0);
}
void HOT Scheduler::set_retry(Component *component, const std::string &name, uint32_t initial_wait_time,
uint8_t max_attempts, std::function<RetryResult(uint8_t)> func,
float backoff_increase_factor) {
this->set_retry_common_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name), initial_wait_time,
max_attempts, std::move(func), backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry(Component *component, const std::string &name) {
return this->cancel_retry_(component, NameType::HASHED_STRING, nullptr, fnv1a_hash(name));
}
void HOT Scheduler::set_retry(Component *component, uint32_t id, uint32_t initial_wait_time, uint8_t max_attempts,
std::function<RetryResult(uint8_t)> func, float backoff_increase_factor) {
this->set_retry_common_(component, NameType::NUMERIC_ID, nullptr, id, initial_wait_time, max_attempts,
std::move(func), backoff_increase_factor);
}
bool HOT Scheduler::cancel_retry(Component *component, uint32_t id) {
return this->cancel_retry_(component, NameType::NUMERIC_ID, nullptr, id);
}
#pragma GCC diagnostic pop // End suppression of deprecated RetryResult warnings
optional<uint32_t> 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.
#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;
#endif
// If no items, return empty optional
if (!this->cleanup_())
return {};
SchedulerItem *item = this->items_[0];
const auto now_64 = this->millis_64_from_(now);
const uint64_t next_exec = item->get_next_execution();
if (next_exec < now_64)
return 0;
return next_exec - now_64;
}
void Scheduler::full_cleanup_removed_items_() {
// We hold the lock for the entire cleanup operation because:
// 1. We're rebuilding the entire items_ list, so we need exclusive access throughout
// 2. Other threads must see either the old state or the new state, not intermediate states
// 3. The operation is already expensive (O(n)), so lock overhead is negligible
// 4. No operations inside can block or take other locks, so no deadlock risk
LockGuard guard{this->lock_};
// Compact in-place: move valid items forward, recycle removed ones
size_t write = 0;
for (size_t read = 0; read < this->items_.size(); ++read) {
if (!is_item_removed_locked_(this->items_[read])) {
if (write != read) {
this->items_[write] = this->items_[read];
}
++write;
} else {
this->recycle_item_main_loop_(this->items_[read]);
}
}
this->items_.erase(this->items_.begin() + write, this->items_.end());
// Rebuild the heap structure since items are no longer in heap order
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
this->to_remove_clear_locked_();
}
#ifndef ESPHOME_THREAD_SINGLE
void Scheduler::compact_defer_queue_locked_() {
// Rare case: new items were added during processing - compact the vector
// This only happens when:
// 1. A deferred callback calls defer() again, or
// 2. Another thread calls defer() while we're processing
//
// Move unprocessed items (added during this loop) to the front for next iteration
//
// SAFETY: Compacted items may include cancelled items (marked for removal via
// cancel_item_locked_() during execution). This is safe because should_skip_item_()
// checks is_item_removed_() before executing, so cancelled items will be skipped
// and recycled on the next loop iteration.
size_t remaining = this->defer_queue_.size() - this->defer_queue_front_;
for (size_t i = 0; i < remaining; i++) {
this->defer_queue_[i] = this->defer_queue_[this->defer_queue_front_ + i];
}
// Use erase() instead of resize() to avoid instantiating _M_default_append
// (saves ~156 bytes flash). Erasing from the end is O(1) - no shifting needed.
this->defer_queue_.erase(this->defer_queue_.begin() + remaining, this->defer_queue_.end());
}
void HOT Scheduler::process_defer_queue_slow_path_(uint32_t &now) {
// Process defer queue to guarantee FIFO execution order for deferred items.
// Previously, defer() used the heap which gave undefined order for equal timestamps,
// causing race conditions on multi-core systems (ESP32, BK7200).
// With the defer queue:
// - Deferred items (delay=0) go directly to defer_queue_ in set_timer_common_
// - Items execute in exact order they were deferred (FIFO guarantee)
// - No deferred items exist in to_add_, so processing order doesn't affect correctness
// Single-core platforms don't use this queue and fall back to the heap-based approach.
//
// Note: Items cancelled via cancel_item_locked_() are marked with remove=true but still
// processed here. They are skipped during execution by should_skip_item_().
// This is intentional - no memory leak occurs.
//
// We use an index (defer_queue_front_) to track the read position instead of calling
// erase() on every pop, which would be O(n). The queue is processed once per loop -
// any items added during processing are left for the next loop iteration.
// Merge lock acquisitions: instead of separate locks for move-out and recycle (2N+1 total),
// recycle each item after re-acquiring the lock for the next iteration (N+1 total).
// The lock is held across: recycle → loop condition → move-out, then released for execution.
SchedulerItem *item;
this->lock_.lock();
// Reset counter and snapshot queue end under lock
this->defer_count_clear_locked_();
size_t defer_queue_end = this->defer_queue_.size();
if (this->defer_queue_front_ >= defer_queue_end) {
this->lock_.unlock();
return;
}
while (this->defer_queue_front_ < defer_queue_end) {
// Take ownership of the item, leaving nullptr in the vector slot.
// This is safe because:
// 1. The vector is only cleaned up by cleanup_defer_queue_locked_() at the end of this function
// 2. Any code iterating defer_queue_ MUST check for nullptr items (see mark_matching_items_removed_locked_)
// 3. The lock protects concurrent access, but the nullptr remains until cleanup
item = this->defer_queue_[this->defer_queue_front_];
this->defer_queue_[this->defer_queue_front_] = nullptr;
this->defer_queue_front_++;
this->lock_.unlock();
// Execute callback without holding lock to prevent deadlocks
// if the callback tries to call defer() again
if (!this->should_skip_item_(item)) {
now = this->execute_item_(item, now);
}
this->lock_.lock();
this->recycle_item_main_loop_(item);
}
// Clean up the queue (lock already held from last recycle or initial acquisition)
this->cleanup_defer_queue_locked_();
this->lock_.unlock();
}
#endif /* not ESPHOME_THREAD_SINGLE */
uint32_t HOT Scheduler::call(uint32_t now) {
#ifndef ESPHOME_THREAD_SINGLE
this->process_defer_queue_(now);
#endif /* not ESPHOME_THREAD_SINGLE */
// Extend the caller's 32-bit timestamp to 64-bit for scheduler operations
const auto now_64 = this->millis_64_from_(now);
this->process_to_add();
// Track if any items were added to to_add_ during callbacks
bool has_added_items = false;
#ifdef ESPHOME_DEBUG_SCHEDULER
static uint64_t last_print = 0;
if (now_64 - last_print > 2000) {
last_print = now_64;
std::vector<SchedulerItem *> old_items;
ESP_LOGD(TAG, "Items: count=%zu, pool=%zu, now=%" PRIu64, this->items_.size(), this->scheduler_item_pool_.size(),
now_64);
// Cleanup before debug output
this->cleanup_();
while (!this->items_.empty()) {
SchedulerItem *item;
{
LockGuard guard{this->lock_};
item = this->pop_raw_locked_();
}
SchedulerNameLog name_log;
bool is_cancelled = is_item_removed_(item);
ESP_LOGD(TAG, " %s '%s/%s' interval=%" PRIu32 " next_execution in %" PRIu64 "ms at %" PRIu64 "%s",
item->get_type_str(), LOG_STR_ARG(item->get_source()),
name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval,
item->get_next_execution() - now_64, item->get_next_execution(), is_cancelled ? " [CANCELLED]" : "");
old_items.push_back(item);
}
ESP_LOGD(TAG, "\n");
{
LockGuard guard{this->lock_};
this->items_ = std::move(old_items);
// Rebuild heap after moving items back
std::make_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
}
}
#endif /* ESPHOME_DEBUG_SCHEDULER */
// Cleanup removed items before processing
// First try to clean items from the top of the heap (fast path)
this->cleanup_();
// If we still have too many cancelled items, do a full cleanup
// This only happens if cancelled items are stuck in the middle/bottom of the heap
if (this->to_remove_count_() >= MAX_LOGICALLY_DELETED_ITEMS) {
this->full_cleanup_removed_items_();
}
// IMPORTANT: This loop uses index-based access (items_[0]), NOT iterators.
// This is intentional — fired intervals are pushed back into items_ via
// push_back() + push_heap() below, which may reallocate the vector's storage.
// Index-based access is safe across reallocations because we re-read items_[0]
// at the top of each iteration. Do NOT convert this to a range-based for loop
// or iterator-based loop, as that would break when items are added.
while (!this->items_.empty()) {
// Don't copy-by value yet
SchedulerItem *item = this->items_[0];
if (item->get_next_execution() > now_64) {
// Not reached timeout yet, done for this call
break;
}
// Don't run on failed components
if (item->component != nullptr && item->component->is_failed()) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
continue;
}
// Check if item is marked for removal
// This handles two cases:
// 1. Item was marked for removal after cleanup_() but before we got here
// 2. Item is marked for removal but wasn't at the front of the heap during cleanup_()
#ifdef ESPHOME_THREAD_MULTI_NO_ATOMICS
// Multi-threaded platforms without atomics: must take lock to safely read remove flag
{
LockGuard guard{this->lock_};
if (is_item_removed_locked_(item)) {
this->recycle_item_main_loop_(this->pop_raw_locked_());
this->to_remove_decrement_locked_();
continue;
}
}
#else
// Single-threaded or multi-threaded with atomics: can check without lock
if (is_item_removed_(item)) {
LockGuard guard{this->lock_};
this->recycle_item_main_loop_(this->pop_raw_locked_());
this->to_remove_decrement_locked_();
continue;
}
#endif
#ifdef ESPHOME_DEBUG_SCHEDULER
{
SchedulerNameLog name_log;
ESP_LOGV(TAG, "Running %s '%s/%s' with interval=%" PRIu32 " next_execution=%" PRIu64 " (now=%" PRIu64 ")",
item->get_type_str(), LOG_STR_ARG(item->get_source()),
name_log.format(item->get_name_type(), item->get_name(), item->get_name_hash_or_id()), item->interval,
item->get_next_execution(), now_64);
}
#endif /* ESPHOME_DEBUG_SCHEDULER */
// Warning: During callback(), a lot of stuff can happen, including:
// - timeouts/intervals get added, potentially invalidating vector pointers
// - timeouts/intervals get cancelled
now = this->execute_item_(item, now);
LockGuard guard{this->lock_};
// Only pop after function call, this ensures we were reachable
// during the function call and know if we were cancelled.
SchedulerItem *executed_item = this->pop_raw_locked_();
if (this->is_item_removed_locked_(executed_item)) {
// We were removed/cancelled in the function call, recycle and continue
this->to_remove_decrement_locked_();
this->recycle_item_main_loop_(executed_item);
continue;
}
if (executed_item->type == SchedulerItem::INTERVAL) {
executed_item->set_next_execution(now_64 + executed_item->interval);
// Push directly back into the heap instead of routing through to_add_.
// This is safe because:
// 1. We're on the main loop and already hold the lock
// 2. The item was already popped from items_ via pop_raw_locked_() above
// 3. The while loop uses index-based access (items_[0]), not iterators,
// so push_back() reallocation cannot invalidate our iteration
// 4. push_heap() restores the heap invariant before the next iteration
// peeks at items_[0]
// This avoids the to_add_ detour and the overhead of
// process_to_add_slow_path_() (lock acquisition, vector iteration, clear).
this->items_.push_back(executed_item);
std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
} else {
// Timeout completed - recycle it
this->recycle_item_main_loop_(executed_item);
}
has_added_items |= !this->to_add_.empty();
}
if (has_added_items) {
this->process_to_add();
}
#ifdef ESPHOME_DEBUG_SCHEDULER
// Verify no items were leaked during this call() cycle.
// All items must be in items_, to_add_, defer_queue_, or the pool.
// Safe to check here because:
// - process_defer_queue_ has already run its cleanup_defer_queue_locked_(),
// so defer_queue_ contains no nullptr slots inflating the count.
// - The while loop above has finished, so no items are held in local variables;
// every item has been returned to a container (items_, to_add_, or pool).
// Lock needed to get a consistent snapshot of all containers.
{
LockGuard guard{this->lock_};
this->debug_verify_no_leak_();
}
#endif
// execute_item_() advances `now` as items fire; return it so the caller
// stays monotonic with last_wdt_feed_.
return now;
}
void HOT Scheduler::process_to_add_slow_path_() {
LockGuard guard{this->lock_};
for (auto *&it : this->to_add_) {
if (is_item_removed_locked_(it)) {
// Recycle cancelled items
this->recycle_item_main_loop_(it);
it = nullptr;
continue;
}
this->items_.push_back(it);
std::push_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
}
this->to_add_.clear();
this->to_add_count_clear_locked_();
}
bool HOT Scheduler::cleanup_slow_path_() {
// We must hold the lock for the entire cleanup operation because:
// 1. We're modifying items_ (via pop_raw_locked_) which requires exclusive access
// 2. We're decrementing to_remove_ which is also modified by other threads
// (though all modifications are already under lock)
// 3. Other threads read items_ when searching for items to cancel in cancel_item_locked_()
// 4. We need a consistent view of items_ and to_remove_ throughout the operation
// Without the lock, we could access items_ while another thread is reading it,
// leading to race conditions
LockGuard guard{this->lock_};
while (!this->items_.empty()) {
SchedulerItem *item = this->items_[0];
if (!this->is_item_removed_locked_(item))
break;
this->to_remove_decrement_locked_();
this->recycle_item_main_loop_(this->pop_raw_locked_());
}
return !this->items_.empty();
}
Scheduler::SchedulerItem *HOT Scheduler::pop_raw_locked_() {
std::pop_heap(this->items_.begin(), this->items_.end(), SchedulerItem::cmp);
SchedulerItem *item = this->items_.back();
this->items_.pop_back();
return item;
}
// Helper to execute a scheduler item
uint32_t HOT Scheduler::execute_item_(SchedulerItem *item, uint32_t now) {
App.set_current_component(item->component);
WarnIfComponentBlockingGuard guard{item->component, now};
item->callback();
uint32_t end = guard.finish();
// Feed the watchdog after each scheduled item (both main heap and defer
// queue paths go through here). A run of back-to-back callbacks cannot
// starve the wdt. The inline fast path is a load + sub + branch — nearly
// free when the 3 ms rate limit hasn't elapsed.
App.feed_wdt_with_time(end);
return end;
}
// Common implementation for cancel operations - handles locking
bool HOT Scheduler::cancel_item_(Component *component, NameType name_type, const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type, bool match_retry) {
LockGuard guard{this->lock_};
// Public cancel path uses default find_first=false to cancel ALL matches because
// DelayAction parallel mode (skip_cancel=true) can create multiple items with the same key.
return this->cancel_item_locked_(component, name_type, static_name, hash_or_id, type, match_retry);
}
// Helper to cancel matching items - must be called with lock held.
// When find_first=true, stops after the first match and exits across containers
// (used by set_timer_common_ where cancel-before-add guarantees at most one match).
// When find_first=false, cancels ALL matches across all containers (needed for
// public cancel path where DelayAction parallel mode can create duplicates).
// name_type determines matching: STATIC_STRING uses static_name, others use hash_or_id
size_t Scheduler::mark_matching_items_removed_slow_locked_(std::vector<SchedulerItem *> &container,
Component *component, NameType name_type,
const char *static_name, uint32_t hash_or_id,
SchedulerItem::Type type, bool match_retry,
bool find_first) {
size_t count = 0;
for (auto *item : container) {
if (this->matches_item_locked_(item, component, name_type, static_name, hash_or_id, type, match_retry)) {
this->set_item_removed_(item, true);
if (find_first)
return 1;
count++;
}
}
return count;
}
bool HOT Scheduler::cancel_item_locked_(Component *component, NameType name_type, const char *static_name,
uint32_t hash_or_id, SchedulerItem::Type type, bool match_retry,
bool find_first) {
// Early return if static string name is invalid
if (name_type == NameType::STATIC_STRING && static_name == nullptr) {
return false;
}
size_t total_cancelled = 0;
#ifndef ESPHOME_THREAD_SINGLE
// Mark items in defer queue as cancelled (they'll be skipped when processed)
if (type == SchedulerItem::TIMEOUT) {
total_cancelled += this->mark_matching_items_removed_locked_(this->defer_queue_, component, name_type, static_name,
hash_or_id, type, match_retry, find_first);
if (find_first && total_cancelled > 0)
return true;
}
#endif /* not ESPHOME_THREAD_SINGLE */
// Cancel items in the main heap
// We only mark items for removal here - never recycle directly.
// The main loop may be executing an item's callback right now, and recycling
// would destroy the callback while it's running (use-after-free).
// Only the main loop in call() should recycle items after execution completes.
{
size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_type, static_name,
hash_or_id, type, match_retry, find_first);
total_cancelled += heap_cancelled;
this->to_remove_add_locked_(heap_cancelled);
if (find_first && total_cancelled > 0)
return true;
}
// Cancel items in to_add_
total_cancelled += this->mark_matching_items_removed_locked_(this->to_add_, component, name_type, static_name,
hash_or_id, type, match_retry, find_first);
return total_cancelled > 0;
}
bool HOT Scheduler::SchedulerItem::cmp(SchedulerItem *a, SchedulerItem *b) {
// High bits are almost always equal (change only on 32-bit rollover ~49 days)
// Optimize for common case: check low bits first when high bits are equal
return (a->next_execution_high_ == b->next_execution_high_) ? (a->next_execution_low_ > b->next_execution_low_)
: (a->next_execution_high_ > b->next_execution_high_);
}
// Recycle a SchedulerItem back to the pool for reuse.
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
// This protects scheduler_item_pool_ from concurrent access by other threads
// that may be acquiring items from the pool in set_timer_common_().
void Scheduler::recycle_item_main_loop_(SchedulerItem *item) {
if (item == nullptr)
return;
if (this->scheduler_item_pool_.size() < MAX_POOL_SIZE) {
// Clear callback to release captured resources
item->callback = nullptr;
this->scheduler_item_pool_.push_back(item);
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Recycled item to pool (pool size now: %zu)", this->scheduler_item_pool_.size());
#endif
} else {
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Pool full (size: %zu), deleting item", this->scheduler_item_pool_.size());
#endif
delete item;
#ifdef ESPHOME_DEBUG_SCHEDULER
this->debug_live_items_--;
#endif
}
}
#ifdef ESPHOME_DEBUG_SCHEDULER
void Scheduler::debug_log_timer_(const SchedulerItem *item, NameType name_type, const char *static_name,
uint32_t hash_or_id, SchedulerItem::Type type, uint32_t delay, uint64_t now) {
// Validate static strings in debug mode
if (name_type == NameType::STATIC_STRING && static_name != nullptr) {
validate_static_string(static_name);
}
// Debug logging
SchedulerNameLog name_log;
const char *type_str = (type == SchedulerItem::TIMEOUT) ? "timeout" : "interval";
if (type == SchedulerItem::TIMEOUT) {
ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()),
name_log.format(name_type, static_name, hash_or_id), type_str, delay);
} else {
ESP_LOGD(TAG, "set_%s(name='%s/%s', %s=%" PRIu32 ", offset=%" PRIu32 ")", type_str, LOG_STR_ARG(item->get_source()),
name_log.format(name_type, static_name, hash_or_id), type_str, delay,
static_cast<uint32_t>(item->get_next_execution() - now));
}
}
#endif /* ESPHOME_DEBUG_SCHEDULER */
// Helper to get or create a scheduler item from the pool
// IMPORTANT: Caller must hold the scheduler lock before calling this function.
Scheduler::SchedulerItem *Scheduler::get_item_from_pool_locked_() {
if (!this->scheduler_item_pool_.empty()) {
SchedulerItem *item = this->scheduler_item_pool_.back();
this->scheduler_item_pool_.pop_back();
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Reused item from pool (pool size now: %zu)", this->scheduler_item_pool_.size());
#endif
return item;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
ESP_LOGD(TAG, "Allocated new item (pool empty)");
#endif
auto *item = new SchedulerItem();
#ifdef ESPHOME_DEBUG_SCHEDULER
this->debug_live_items_++;
#endif
return item;
}
#ifdef ESPHOME_DEBUG_SCHEDULER
bool Scheduler::debug_verify_no_leak_() const {
// Invariant: every live SchedulerItem must be in exactly one container.
// debug_live_items_ tracks allocations minus deletions.
size_t accounted = this->items_.size() + this->to_add_.size() + this->scheduler_item_pool_.size();
#ifndef ESPHOME_THREAD_SINGLE
accounted += this->defer_queue_.size();
#endif
if (accounted != this->debug_live_items_) {
ESP_LOGE(TAG,
"SCHEDULER LEAK DETECTED: live=%" PRIu32 " but accounted=%" PRIu32 " (items=%" PRIu32 " to_add=%" PRIu32
" pool=%" PRIu32
#ifndef ESPHOME_THREAD_SINGLE
" defer=%" PRIu32
#endif
")",
static_cast<uint32_t>(this->debug_live_items_), static_cast<uint32_t>(accounted),
static_cast<uint32_t>(this->items_.size()), static_cast<uint32_t>(this->to_add_.size()),
static_cast<uint32_t>(this->scheduler_item_pool_.size())
#ifndef ESPHOME_THREAD_SINGLE
,
static_cast<uint32_t>(this->defer_queue_.size())
#endif
);
assert(false);
return false;
}
return true;
}
#endif
} // namespace esphome