The problem
Timer.async_start writes state and fires its events before it stores the expiry callback:
# homeassistant/components/timer/__init__.py, async_start
self._fire_event_and_write_state(event) # dispatches state_changed inline
self._listener = async_track_point_in_utc_time( # only now is the handle stored
self.hass, self._async_finished, self._end
)
async_write_ha_state dispatches state_changed to callback listeners synchronously, and eager task execution runs a triggered automation's action far enough to complete a nested service call before the outer frame resumes. So a consumer that answers the timer's own idle -> active transition with another timer.start runs inside the first call. The inner call finds self._listener still None, arms its own handle, and returns; the outer call then resumes and overwrites self._listener, discarding a live scheduled callback that nobody can cancel.
The orphan fires one duration later against a timer that never expired: the timer goes idle, timer.finished is emitted, and finished_at carries a timestamp that is in the future relative to the event carrying it.
It is then self-sustaining, because _async_finished drops the reference without cancelling:
# _async_finished — every other mutator calls self._listener() first
self._listener = None
When a stale callback runs, that line discards the handle belonging to the live pending listener, minting the next orphan. With a consumer restarting the timer every T seconds, the spurious finishes repeat every duration - T — each lap arriving T earlier than the last.
async_change re-arms after its own write for the same reason, and leaks the same way.
This is not theoretical. On my instance a 1-hour timer restarted every 30 s produced timer.finished at 06:04:30, 07:04:00, 08:03:30, 09:03:00, 10:02:30, 11:02:00, 12:01:30 UTC — exactly 59m30s apart — and consumers reading the timer's state drove a motorised blind open at 03:00 local.
I believe this is the same defect reported in #31372 (2020), which was closed by the stale bot without diagnosis. That report's data matches the same law: a 100 s timer restarted every 20 s fired every 80 s; restarted every 10 s, every 90 s.
The fix is the arming order, in async_start and in async_change: arm the listener before writing state. Measured with a consumer restarting a 100 s timer every 20 s, over ten durations — 12 spurious finishes before, 0 after.
_async_finished needs no change once that lands, and I want to be explicit about it since it looks like the obvious place to patch. Cancelling there instead of dropping does help in isolation — it converts the chain into a single spurious event, 12 down to 1 — but once no orphan is created, there is no stale callback for it to catch, and I could not construct a reachable path where it does any work. I have left it alone rather than add a guard against a bug that is being removed in the same change.
What version of Home Assistant Core has the issue?
2026.8.1 (and dev at 58a3fdb — the component is byte-identical between the two)
What was the last working version of Home Assistant Core?
Unknown — #31372 reports the same behaviour on 0.104.3 through 0.115.2, so likely never.
What type of installation are you running?
Home Assistant OS
Integration causing the issue
Timer
Link to integration documentation on our website
https://www.home-assistant.io/integrations/timer/
Diagnostics information
Not applicable — helper integration.
Example YAML snippet
# A timer that is never allowed to expire, and a consumer that answers its
# own start. `from: null` / `to: null` matters: `match_all` keys off key
# PRESENCE, so this fires on idle -> active but not on the attribute-only
# churn of a restart.
timer:
test1:
duration: '00:01:40'
automation:
- alias: Answer the timer's own transition with a restart
trigger:
- platform: state
entity_id: timer.test1
from: null
to: null
action:
- service: timer.start
target:
entity_id: timer.test1
- alias: Keep it alive, well inside the duration
trigger:
- platform: time_pattern
seconds: /20
action:
- service: timer.start
target:
entity_id: timer.test1
timer.test1 is restarted every 20 s and can never reach its 100 s deadline. It nevertheless emits timer.finished 100 s after the start, and every 80 s thereafter.
Anything in the logs that might be useful for us?
From my recorder, the reentrancy at the instant the orphan is created. The inner timer.start completes 3 ms before the outer call's own timer.started event:
22:55:18.763585 call_service timer.start <- outer call, timer idle
22:55:18.763659 state write: started, finishes_at 23:55:18
22:55:18.766214 automation_triggered, source: state of timer.sleeping_mode
22:55:18.766293 call_service timer.start <- inner call, inside the outer one
22:55:18.766331 event timer.restarted
22:55:18.766627 event timer.started <- outer call's own event, late
And a spurious finish reporting a deadline 30 minutes after the event that carries it:
12:01:30.012731 timer.finished {finished_at: "2026-08-09T12:31:30+00:00"}
Additional information
I have a patch ready with three tests, all deterministic — no real-clock races.
The sharpest test asserts only that one active timer has one live expiry callback:
# hass: HomeAssistant — the harness's running instance, from the `hass` fixture.
# Its `.loop` is the asyncio event loop holding the scheduled callbacks, which
# is what this inspects; `get_scheduled_timer_handles` comes from
# homeassistant.util.async_.
def _scheduled_expiries(hass):
"""Every live scheduled Timer._async_finished callback."""
return [
handle
for handle in get_scheduled_timer_handles(hass.loop)
if not handle.cancelled() and "_async_finished" in repr(handle)
]
# hass: HomeAssistant — as above.
# freezer: FrozenDateTimeFactory — the frozen-clock fixture. This test never
# advances it; requesting it holds the clock still so both handles carry an
# identical, quotable deadline instead of two timestamps microseconds apart.
# The behavioural test is the one that actually ticks.
async def test_reentrant_start_does_not_orphan_the_expiry_callback(hass, freezer):
assert await async_setup_component(hass, "timer", {"timer": {"test1": {"duration": 100}}})
assert await async_setup_component(hass, "automation", RESTART_ON_OWN_START)
await hass.services.async_call(
"timer", "start", {"entity_id": "timer.test1"}, blocking=True
)
await hass.async_block_till_done()
assert len(_scheduled_expiries(hass)) == 1
Against unmodified dev it reports two handles at the same deadline, one of them unreachable:
E AssertionError: assert 2 == 1
All three tests against the three relevant builds:
| Test |
unmodified |
async_start fixed only |
both fixed |
test_reentrant_start_does_not_orphan_the_expiry_callback |
fail |
pass |
pass |
test_timer_kept_alive_by_restarts_never_finishes |
fail |
pass |
pass |
test_change_does_not_orphan_the_expiry_callback |
fail |
fail |
pass |
The middle column is why async_change is included: it is the same defect in a second method and survives fixing async_start alone.
Reaching the async_change path
Worth stating, because it is easy to conclude that hunk is unreachable — I concluded exactly that at first and was wrong. The window opens only for a consumer that reenters synchronously during the write, and async_change's write is attribute-only, so the trigger has to be one that fires on attributes. Live handles before -> after a timer.change, with async_start already fixed:
| Consumer |
|
state trigger, attribute: finishes_at |
1 -> 2 |
state trigger, attribute: remaining |
1 -> 2 |
match-all state trigger, mode: parallel |
1 -> 2 |
match-all state trigger, mode: queued |
1 -> 2 |
match-all state trigger, mode: single |
1 -> 1 |
event trigger on timer.changed |
1 -> 1 |
Under mode: single the automation is still running when its own action rewrites the state, so the reentrant run is dropped and the window stays shut. Since single is the default, this one hides well.
Happy to open the PR if you would like it.
The problem
Timer.async_startwrites state and fires its events before it stores the expiry callback:async_write_ha_statedispatchesstate_changedto callback listeners synchronously, and eager task execution runs a triggered automation's action far enough to complete a nested service call before the outer frame resumes. So a consumer that answers the timer's ownidle -> activetransition with anothertimer.startruns inside the first call. The inner call findsself._listenerstillNone, arms its own handle, and returns; the outer call then resumes and overwritesself._listener, discarding a live scheduled callback that nobody can cancel.The orphan fires one duration later against a timer that never expired: the timer goes
idle,timer.finishedis emitted, andfinished_atcarries a timestamp that is in the future relative to the event carrying it.It is then self-sustaining, because
_async_finisheddrops the reference without cancelling:When a stale callback runs, that line discards the handle belonging to the live pending listener, minting the next orphan. With a consumer restarting the timer every
Tseconds, the spurious finishes repeat everyduration - T— each lap arrivingTearlier than the last.async_changere-arms after its own write for the same reason, and leaks the same way.This is not theoretical. On my instance a 1-hour timer restarted every 30 s produced
timer.finishedat 06:04:30, 07:04:00, 08:03:30, 09:03:00, 10:02:30, 11:02:00, 12:01:30 UTC — exactly 59m30s apart — and consumers reading the timer's state drove a motorised blind open at 03:00 local.I believe this is the same defect reported in #31372 (2020), which was closed by the stale bot without diagnosis. That report's data matches the same law: a 100 s timer restarted every 20 s fired every 80 s; restarted every 10 s, every 90 s.
The fix is the arming order, in
async_startand inasync_change: arm the listener before writing state. Measured with a consumer restarting a 100 s timer every 20 s, over ten durations — 12 spurious finishes before, 0 after._async_finishedneeds no change once that lands, and I want to be explicit about it since it looks like the obvious place to patch. Cancelling there instead of dropping does help in isolation — it converts the chain into a single spurious event, 12 down to 1 — but once no orphan is created, there is no stale callback for it to catch, and I could not construct a reachable path where it does any work. I have left it alone rather than add a guard against a bug that is being removed in the same change.What version of Home Assistant Core has the issue?
2026.8.1 (and
devat 58a3fdb — the component is byte-identical between the two)What was the last working version of Home Assistant Core?
Unknown — #31372 reports the same behaviour on 0.104.3 through 0.115.2, so likely never.
What type of installation are you running?
Home Assistant OS
Integration causing the issue
Timer
Link to integration documentation on our website
https://www.home-assistant.io/integrations/timer/
Diagnostics information
Not applicable — helper integration.
Example YAML snippet
timer.test1is restarted every 20 s and can never reach its 100 s deadline. It nevertheless emitstimer.finished100 s after the start, and every 80 s thereafter.Anything in the logs that might be useful for us?
From my recorder, the reentrancy at the instant the orphan is created. The inner
timer.startcompletes 3 ms before the outer call's owntimer.startedevent:And a spurious finish reporting a deadline 30 minutes after the event that carries it:
Additional information
I have a patch ready with three tests, all deterministic — no real-clock races.
The sharpest test asserts only that one active timer has one live expiry callback:
Against unmodified
devit reports two handles at the same deadline, one of them unreachable:All three tests against the three relevant builds:
async_startfixed onlytest_reentrant_start_does_not_orphan_the_expiry_callbacktest_timer_kept_alive_by_restarts_never_finishestest_change_does_not_orphan_the_expiry_callbackThe middle column is why
async_changeis included: it is the same defect in a second method and survives fixingasync_startalone.Reaching the
async_changepathWorth stating, because it is easy to conclude that hunk is unreachable — I concluded exactly that at first and was wrong. The window opens only for a consumer that reenters synchronously during the write, and
async_change's write is attribute-only, so the trigger has to be one that fires on attributes. Live handles before -> after atimer.change, withasync_startalready fixed:attribute: finishes_atattribute: remainingmode: parallelmode: queuedmode: singletimer.changedUnder
mode: singlethe automation is still running when its own action rewrites the state, so the reentrant run is dropped and the window stays shut. Since single is the default, this one hides well.Happy to open the PR if you would like it.