Cancel previous Debouncer timer handle in _schedule_timer (#170339)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Raman Gupta
2026-05-12 12:32:58 +02:00
committed by GitHub
co-authored by Claude Opus 4.7 Martin Hjelmare Copilot Autofix powered by AI
parent 3393598d91
commit a215b82bd9
2 changed files with 74 additions and 5 deletions
+6 -5
View File
@@ -193,8 +193,9 @@ class Debouncer[_R_co]:
@callback
def _schedule_timer(self) -> None:
"""Schedule a timer."""
if not self._shutdown_requested:
self._timer_task = self.hass.loop.call_later(
self.cooldown, self._on_debounce
)
"""Schedule a timer, cancelling any previously-scheduled handle."""
if self._shutdown_requested:
return
if self._timer_task is not None:
self._timer_task.cancel()
self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce)
+68
View File
@@ -578,3 +578,71 @@ async def test_shutdown_releases_parent_class(hass: HomeAssistant) -> None:
# Debouncer shutdown releases the class
debouncer.async_shutdown()
assert my_class_weak_ref() is None
async def test_schedule_timer_cancels_previous_handle(hass: HomeAssistant) -> None:
"""Ensure _schedule_timer cancels any previously-scheduled handle."""
# Use a large cooldown so the scheduled timer can't fire mid-test on a slow
# event loop; the timer is only inspected and cancelled, never awaited.
debouncer = debounce.Debouncer(
hass,
_LOGGER,
cooldown=3600.0,
immediate=True,
function=AsyncMock(),
)
debouncer._schedule_timer()
first_handle = debouncer._timer_task
assert first_handle is not None
assert not first_handle.cancelled()
debouncer._schedule_timer()
second_handle = debouncer._timer_task
assert second_handle is not None
assert second_handle is not first_handle
assert first_handle.cancelled()
debouncer.async_shutdown()
async def test_concurrent_async_call_does_not_orphan_timer(
hass: HomeAssistant,
) -> None:
"""Concurrent async_call during in-flight execution must not orphan a timer."""
started = asyncio.Event()
can_finish = asyncio.Event()
async def slow_function() -> None:
started.set()
await can_finish.wait()
# Use a large cooldown so the T1 timer scheduled below can't fire before
# the in-flight call completes; cancellation is verified deterministically.
debouncer = debounce.Debouncer(
hass,
_LOGGER,
cooldown=3600.0,
immediate=True,
function=slow_function,
)
in_flight = hass.async_create_task(debouncer.async_call())
await started.wait()
assert debouncer._timer_task is None
# The concurrent call hits the locked-immediate branch and schedules T1.
await debouncer.async_call()
first_timer = debouncer._timer_task
assert first_timer is not None
assert not first_timer.cancelled()
# Letting the in-flight call complete schedules T2.
can_finish.set()
await in_flight
second_timer = debouncer._timer_task
assert second_timer is not None
assert second_timer is not first_timer
assert first_timer.cancelled()
debouncer.async_shutdown()