[core] Free the action/condition tree when an Automation is destroyed - #71
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR makes ESPHome’s C++ automation graph safely destructible by fixing longstanding ownership gaps: actions, action lists, and nested conditions are now properly freed when an Automation is destroyed. It also hardens Scheduler::call() against a use-after-free exposed by these new destruction paths, and removes a latent double-ownership pattern in sprinkler that would become a double-free once action lists own their actions.
Changes:
- Add virtual destructors to
ActionandCondition, and makeActionListan owning container that deletes its chained actions on destruction (copy disabled to prevent double-free). - Reorder scheduler dispatch logic to skip logically-removed items before dereferencing
Component*foris_failed()checks. - Remove redundant
unique_ptrownership of sprinkler actions so theAutomationaction lists are the sole owners.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| esphome/core/scheduler.cpp | Avoid dereferencing cancelled scheduler items (raw Component*) before checking the removal flag. |
| esphome/core/component.cpp | Update ISR-safety comment to clarify runtime-built automation edge cases. |
| esphome/core/base_automation.h | Add destructors to free owned conditions/actions and cancel pending timers in DelayAction/WaitUntilAction. |
| esphome/core/automation.h | Add virtual destructors for base types and implement owning ActionList destructor + disable copying. |
| esphome/components/sprinkler/sprinkler.h | Remove duplicate owning members for actions now owned by Automation action lists. |
| esphome/components/sprinkler/sprinkler.cpp | Allocate sprinkler actions directly into Automation action lists (single owner). |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
zkoalexey
force-pushed
the
fix/automation
branch
from
August 17, 2026 11:38
d9730bf to
93f48ea
Compare
~Automation freed its triggers and nothing else. The action list, every action in it, and every condition hanging off those actions were leaked. Upstream that is harmless -- code-generated automations are new'ed once and never destroyed -- but the JetHome `automations` component rebuilds its runtime object graph on every save from the web editor, and on every remove/reset. core/automation.h gets virtual ~Action() and ~Condition() (deleting through those bases was UB), and an owning ~ActionList() that walks the actions_begin_/next_ chain. Automation::actions_ is a by-value member, so ~Automation now frees the whole tree without a change of its own. ActionList copy/assign are deleted: a copy would free the same chain twice. core/base_automation.h: the owners free what they hold -- IfAction and WhileAction their condition, ForCondition and WaitUntilAction theirs, and the And/Or/Xor/Not group conditions their children. Three hazards the destructors expose, handled here: - DelayAction and WaitUntilAction are Components that arm a scheduler timer against themselves, and Scheduler::SchedulerItem keeps a raw Component* it dereferences (is_failed()) while walking its heap. Freeing one with a timer pending is a use-after-free, not a stale callback. Both destructors cancel first; stop() alone is not enough, since stop_complex() only calls it while num_running_ != 0 and a runtime build can be torn down without any stop() at all. Both timer names are static strings, so both are cancellable. - Cancelling is only half of it. cancel_item_locked_() unlinks an item outright only when it is items_.back(); everywhere else it just marks it, leaving it in the heap still holding that raw Component*. The dispatch loop in call() then read item->component->is_failed() *before* testing the remove flag, so a cancelled item belonging to a freed component was still dereferenced -- and cleanup_() does not necessarily reach it first, since it pops only leading removed items and full compaction needs MAX_LOGICALLY_DELETED_ITEMS. The two checks are now in the same order Scheduler::should_skip_item_() has always used for the defer queue. As a side effect the to_remove_ counter no longer drifts when an item is both removed and owned by a failed component. - sprinkler held its shutdown / resume-or-start actions in unique_ptrs *and* handed the same raw pointers to an Automation's ActionList. With an owning ActionList that is a double free. The ActionList is now the sole owner and the redundant members are gone; nothing else read them. Dormant before this change (a Sprinkler is a codegen global whose Automations outlive the process), so this closes a latent bug rather than one that was firing. Every add_action / add_actions / add_then / add_else call site was audited for a second owner or a non-heap pointer; sprinkler was the only one. Codegen passes only new_Pvariable objects, each into exactly one list. A behavioural no-op for statically generated automations. Known limit, documented at ~ActionList rather than fixed: Trigger can decline deletion via prepare_for_deletion(), Action and Condition cannot, so "an Action/Condition owned by an ActionList must never be registered with App" is a call-site rule. Codegen registers DelayAction, WaitUntilAction and ForCondition, which is safe only because codegen automations are never destroyed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
zkoalexey
force-pushed
the
fix/automation
branch
from
August 26, 2026 15:12
93f48ea to
b2ffac2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
~Automationfreed its triggers and nothing else. The action list, every actionin it, and every condition hanging off those actions were leaked.
Upstream that is harmless — code-generated automations are
newed once and neverdestroyed. It is not harmless here: the
automationscomponent rebuilds itsruntime object graph on every save from the web editor, and on every
remove_automation()/reset_all().The change
core/automation.hvirtual ~Action(),virtual ~Condition()— deleting through those bases wasUB before.
~ActionList()walksactions_begin_/next_and deletes the chain. The listowns what it is given; copy construction/assignment are deleted, since a copy
would free the same chain twice.
Automation::actions_is a by-value member, so~Automationnow frees the whole tree without any change of its own.core/base_automation.h— the owners free what they hold:IfActionandWhileActiontheir condition,ForConditionandWaitUntilActiontheirs, andAndCondition/OrCondition/XorCondition/NotConditiontheir children.then_/else_are by-valueActionLists and free themselves.Three hazards the destructors expose, handled here
Scheduler.
DelayActionandWaitUntilActionareComponents that arm atimer against
this, andScheduler::SchedulerItemkeeps a rawComponent *itdereferences (
is_failed()) while walking its heap. Freeing one with a timerpending is a use-after-free, not merely a stale callback. Both destructors cancel
first.
stop()is not enough on its own:stop_complex()only calls it whilenum_running_ != 0, and a runtime build can be torn down on a path that nevercalls
stop()at all. Both timer names are static strings, so both arecancellable.
The scheduler dereferenced cancelled items. Cancelling is only half of it.
cancel_item_locked_()unlinks an item outright only when it isitems_.back();everywhere else it just marks it, leaving it in the heap still holding that raw
Component *. The dispatch loop incall()then readitem->component->is_failed()before testing the remove flag, so a cancelleditem belonging to a freed component was dereferenced anyway — and
cleanup_()does not reliably get there first, since it pops only leading removed items and
full compaction needs
MAX_LOGICALLY_DELETED_ITEMS. The two checks are now inthe order
Scheduler::should_skip_item_()has always used for the defer queue. Aside effect:
to_remove_no longer drifts when an item is both removed and ownedby a failed component.
sprinklerdouble ownership.SprinklerValveandSprinklerheld theirshutdown / resume-or-start actions in
unique_ptrs and handed the same rawpointers to an
Automation'sActionList. With an owningActionListthat is adouble free. The
ActionListis now the sole owner and the redundant members aregone; nothing else read them. Dormant before this change — a
Sprinkleris acodegen global whose
Automations outlive the process — so this is a latent bugbeing closed, not one that was firing.
Blast radius
A behavioural no-op for statically generated automations: they are never
destroyed, so the new destructors never run for them.
ActionandConditionalready had virtual functions, so the added virtual destructor costs vtable slots
per class, nothing per object.
Every
add_action/add_actions/add_then/add_elsecall site in the treewas audited for a second owner or a non-heap pointer;
sprinklerwas the onlyone. Codegen (
automation.pybuild_action_list/build_automation) only everpasses
new_Pvariableobjects, each into exactly one list.Verification
sprinklercompiled and linked on the host platform fromtests/components/sprinkler/common.yaml(plus astandby_switch, to reach thethird edited call site).
jxd-r6-e1eth-lcd); automations created, updated 240×, and deleted over RESTwith no crash and a flat heap.
esphome-components/tests/cpp/automations/automation_lifetime_test.cpp— probesthat count live instances, a scheduler-item check for
~DelayAction, and anarmed
operator new/deletecounter over a build+destroy cycle. All sevencases fail without this change.
Known limit, documented rather than fixed
Triggercan decline deletion throughprepare_for_deletion();ActionandConditionhave no such hook. So "an Action/Condition owned by an ActionListmust never be registered with
App" is a call-site rule, spelled out at~ActionList. Codegen does registerDelayAction,WaitUntilActionandForCondition— safe only because codegen automations are never destroyed, and atrap for whoever first builds one of those at run time.