From 8caaf53ef0ef4eeacf824d91f2d7031b7fba903f Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Dec 2025 12:53:13 +1300 Subject: [PATCH 1/4] [CI] Update renamed action repo (#12290) --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9ef6b4341c..01689d3697 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -668,7 +668,7 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} cache-key: ${{ needs.common.outputs.cache-key }} - - uses: esphome/action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache + - uses: esphome/pre-commit-action@43cd1109c09c544d97196f7730ee5b2e0cc6d81e # v3.0.1 fork with pinned actions/cache env: SKIP: pylint,clang-tidy-hash - uses: pre-commit-ci/lite-action@5d6cc0eb514c891a40562a58a8e71576c5c7fb43 # v1.1.0 From 78b2ae8a352f4e604277ffd7bb4deeb8737fc925 Mon Sep 17 00:00:00 2001 From: Jesse Hills <3060199+jesserockz@users.noreply.github.com> Date: Fri, 5 Dec 2025 14:00:08 +1300 Subject: [PATCH 2/4] [CI] Trigger generic version notifier job on release (#12292) --- .github/workflows/release.yml | 53 +++++++++++++++++++++++++++++++++-- 1 file changed, 51 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d52595bbb3..51aa1f885e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -219,10 +219,19 @@ jobs: - init - deploy-manifest steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: home-assistant-addon + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_HA_ADDON_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | let description = "ESPHome"; if (context.eventName == "release") { @@ -245,10 +254,19 @@ jobs: needs: [init] environment: ${{ needs.init.outputs.deploy_env }} steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: esphome-schema + - name: Trigger Workflow uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 with: - github-token: ${{ secrets.DEPLOY_ESPHOME_SCHEMA_REPO_TOKEN }} + github-token: ${{ steps.generate-token.outputs.token }} script: | github.rest.actions.createWorkflowDispatch({ owner: "esphome", @@ -259,3 +277,34 @@ jobs: version: "${{ needs.init.outputs.tag }}", } }) + + version-notifier: + if: github.repository == 'esphome/esphome' && needs.init.outputs.branch_build == 'false' + runs-on: ubuntu-latest + needs: + - init + - deploy-manifest + steps: + - name: Generate a token + id: generate-token + uses: actions/create-github-app-token@7e473efe3cb98aa54f8d4bac15400b15fad77d94 # v2.2.0 + with: + app-id: ${{ secrets.ESPHOME_GITHUB_APP_ID }} + private-key: ${{ secrets.ESPHOME_GITHUB_APP_PRIVATE_KEY }} + owner: esphome + repositories: version-notifier + + - name: Trigger Workflow + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + with: + github-token: ${{ steps.generate-token.outputs.token }} + script: | + github.rest.actions.createWorkflowDispatch({ + owner: "esphome", + repo: "version-notifier", + workflow_id: "notify.yml", + ref: "main", + inputs: { + version: "${{ needs.init.outputs.tag }}", + } + }) From 80e881655fbffc6140e9bf13bf1ba93a8fb2439e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 01:14:22 +0000 Subject: [PATCH 3/4] [scheduler] Fix use-after-free when cancelling timeouts from non-main-loop threads (#12288) --- esphome/core/scheduler.cpp | 33 ++++++++++++++------------------- esphome/core/scheduler.h | 8 +++++--- 2 files changed, 19 insertions(+), 22 deletions(-) diff --git a/esphome/core/scheduler.cpp b/esphome/core/scheduler.cpp index 352587bf10..5e313f770f 100644 --- a/esphome/core/scheduler.cpp +++ b/esphome/core/scheduler.cpp @@ -315,7 +315,7 @@ void Scheduler::full_cleanup_removed_items_() { valid_items.push_back(std::move(item)); } else { // Recycle removed items - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } } @@ -400,7 +400,7 @@ void HOT Scheduler::call(uint32_t now) { // Don't run on failed components if (item->component != nullptr && item->component->is_failed()) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); continue; } @@ -413,7 +413,7 @@ void HOT Scheduler::call(uint32_t now) { { LockGuard guard{this->lock_}; if (is_item_removed_(item.get())) { - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -422,7 +422,7 @@ void HOT Scheduler::call(uint32_t now) { // Single-threaded or multi-threaded with atomics: can check without lock if (is_item_removed_(item.get())) { LockGuard guard{this->lock_}; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); this->to_remove_--; continue; } @@ -449,7 +449,7 @@ void HOT Scheduler::call(uint32_t now) { if (executed_item->remove) { // We were removed/cancelled in the function call, recycle and continue this->to_remove_--; - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); continue; } @@ -460,7 +460,7 @@ void HOT Scheduler::call(uint32_t now) { this->to_add_.push_back(std::move(executed_item)); } else { // Timeout completed - recycle it - this->recycle_item_(std::move(executed_item)); + this->recycle_item_main_loop_(std::move(executed_item)); } has_added_items |= !this->to_add_.empty(); @@ -475,7 +475,7 @@ void HOT Scheduler::process_to_add() { for (auto &it : this->to_add_) { if (is_item_removed_(it.get())) { // Recycle cancelled items - this->recycle_item_(std::move(it)); + this->recycle_item_main_loop_(std::move(it)); continue; } @@ -509,7 +509,7 @@ size_t HOT Scheduler::cleanup_() { if (!item->remove) break; this->to_remove_--; - this->recycle_item_(this->pop_raw_locked_()); + this->recycle_item_main_loop_(this->pop_raw_locked_()); } return this->items_.size(); } @@ -562,20 +562,15 @@ bool HOT Scheduler::cancel_item_locked_(Component *component, const char *name_c #endif /* not ESPHOME_THREAD_SINGLE */ // Cancel items in the main heap - // Special case: if the last item in the heap matches, we can remove it immediately - // (removing the last element doesn't break heap structure) + // 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. if (!this->items_.empty()) { - auto &last_item = this->items_.back(); - if (this->matches_item_locked_(last_item, component, name_cstr, type, match_retry)) { - this->recycle_item_(std::move(this->items_.back())); - this->items_.pop_back(); - total_cancelled++; - } - // For other items in heap, we can only mark for removal (can't remove from middle of heap) size_t heap_cancelled = this->mark_matching_items_removed_locked_(this->items_, component, name_cstr, type, match_retry); total_cancelled += heap_cancelled; - this->to_remove_ += heap_cancelled; // Track removals for heap items + this->to_remove_ += heap_cancelled; } // Cancel items in to_add_ @@ -749,7 +744,7 @@ bool HOT Scheduler::SchedulerItem::cmp(const std::unique_ptr &a, : (a->next_execution_high_ > b->next_execution_high_); } -void Scheduler::recycle_item_(std::unique_ptr item) { +void Scheduler::recycle_item_main_loop_(std::unique_ptr item) { if (!item) return; diff --git a/esphome/core/scheduler.h b/esphome/core/scheduler.h index 08e003c9fb..dcf418c14f 100644 --- a/esphome/core/scheduler.h +++ b/esphome/core/scheduler.h @@ -272,8 +272,10 @@ class Scheduler { return is_item_removed_(item) || (item->component != nullptr && item->component->is_failed()); } - // Helper to recycle a SchedulerItem - void recycle_item_(std::unique_ptr item); + // Helper to recycle a SchedulerItem back to the pool. + // IMPORTANT: Only call from main loop context! Recycling clears the callback, + // so calling from another thread while the callback is executing causes use-after-free. + void recycle_item_main_loop_(std::unique_ptr item); // Helper to perform full cleanup when too many items are cancelled void full_cleanup_removed_items_(); @@ -329,7 +331,7 @@ class Scheduler { now = this->execute_item_(item.get(), now); } // Recycle the defer item after execution - this->recycle_item_(std::move(item)); + this->recycle_item_main_loop_(std::move(item)); } // If we've consumed all items up to the snapshot point, clean up the dead space From 637cb3f04a9fc0d1efe3bbd1c229c23e36f79ed2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 5 Dec 2025 01:14:35 +0000 Subject: [PATCH 4/4] [api] Use loop-based reboot timeout check to avoid scheduler heap churn (#12291) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- esphome/components/api/api_server.cpp | 38 ++++++++++++++------------- esphome/components/api/api_server.h | 2 +- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/esphome/components/api/api_server.cpp b/esphome/components/api/api_server.cpp index 4168761c74..565714a4e5 100644 --- a/esphome/components/api/api_server.cpp +++ b/esphome/components/api/api_server.cpp @@ -52,11 +52,6 @@ void APIServer::setup() { #endif #endif - // Schedule reboot if no clients connect within timeout - if (this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); - } - this->socket_ = socket::socket_ip_loop_monitored(SOCK_STREAM, 0); // monitored for incoming connections if (this->socket_ == nullptr) { ESP_LOGW(TAG, "Could not create socket"); @@ -110,16 +105,13 @@ void APIServer::setup() { camera::Camera::instance()->add_listener(this); } #endif -} -void APIServer::schedule_reboot_timeout_() { - this->status_set_warning(); - this->set_timeout("api_reboot", this->reboot_timeout_, []() { - if (!global_api_server->is_connected()) { - ESP_LOGE(TAG, "No clients; rebooting"); - App.reboot(); - } - }); + // Initialize last_connected_ for reboot timeout tracking + this->last_connected_ = App.get_loop_component_start_time(); + // Set warning status if reboot timeout is enabled + if (this->reboot_timeout_ != 0) { + this->status_set_warning(); + } } void APIServer::loop() { @@ -147,15 +139,24 @@ void APIServer::loop() { this->clients_.emplace_back(conn); conn->start(); - // Clear warning status and cancel reboot when first client connects + // First client connected - clear warning and update timestamp if (this->clients_.size() == 1 && this->reboot_timeout_ != 0) { this->status_clear_warning(); - this->cancel_timeout("api_reboot"); + this->last_connected_ = App.get_loop_component_start_time(); } } } if (this->clients_.empty()) { + // Check reboot timeout - done in loop to avoid scheduler heap churn + // (cancelled scheduler items sit in heap memory until their scheduled time) + if (this->reboot_timeout_ != 0) { + const uint32_t now = App.get_loop_component_start_time(); + if (now - this->last_connected_ > this->reboot_timeout_) { + ESP_LOGE(TAG, "No clients; rebooting"); + App.reboot(); + } + } return; } @@ -194,9 +195,10 @@ void APIServer::loop() { } this->clients_.pop_back(); - // Schedule reboot when last client disconnects + // Last client disconnected - set warning and start tracking for reboot timeout if (this->clients_.empty() && this->reboot_timeout_ != 0) { - this->schedule_reboot_timeout_(); + this->status_set_warning(); + this->last_connected_ = App.get_loop_component_start_time(); } // Don't increment client_index since we need to process the swapped element } diff --git a/esphome/components/api/api_server.h b/esphome/components/api/api_server.h index 3089bb1d35..eb495afde7 100644 --- a/esphome/components/api/api_server.h +++ b/esphome/components/api/api_server.h @@ -202,7 +202,6 @@ class APIServer : public Component, #endif protected: - void schedule_reboot_timeout_(); #ifdef USE_API_NOISE bool update_noise_psk_(const SavedNoisePsk &new_psk, const LogString *save_log_msg, const LogString *fail_log_msg, const psk_t &active_psk, bool make_active); @@ -218,6 +217,7 @@ class APIServer : public Component, // 4-byte aligned types uint32_t reboot_timeout_{300000}; + uint32_t last_connected_{0}; // Vectors and strings (12 bytes each on 32-bit) std::vector> clients_;