Skip to content

Timer: a consumer that restarts a timer on its own state change orphans the expiry callback, causing spurious timer.finished forever after #179192

Description

@x3k6a2

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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Fields

    Priority

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions