From 19a6530c3c8e37d29a4ce8b82f0f357bfbb83dfc Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Sat, 15 Apr 2023 03:01:21 +0200 Subject: [PATCH] Add ability to shutdown a Debouncer (#91439) * Add ability to shutdown a Debouncer * Use async_create_task --- homeassistant/helpers/debounce.py | 14 ++++++++++- tests/helpers/test_debounce.py | 42 ++++++++++++++++++++++++++++--- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/debounce.py b/homeassistant/helpers/debounce.py index dd536956a83..737d36ff33b 100644 --- a/homeassistant/helpers/debounce.py +++ b/homeassistant/helpers/debounce.py @@ -44,6 +44,7 @@ class Debouncer(Generic[_R_co]): function, f"debouncer cooldown={cooldown}, immediate={immediate}" ) ) + self._shutdown_requested = False @property def function(self) -> Callable[[], _R_co] | None: @@ -62,6 +63,8 @@ class Debouncer(Generic[_R_co]): async def async_call(self) -> None: """Call the function.""" + if self._shutdown_requested: + raise RuntimeError("Debouncer has been shutdown.") assert self._job is not None if self._timer_task: @@ -115,6 +118,12 @@ class Debouncer(Generic[_R_co]): # Schedule a new timer to prevent new runs during cooldown self._schedule_timer() + @callback + def async_shutdown(self) -> None: + """Cancel any scheduled call, and prevent new runs.""" + self._shutdown_requested = True + self.async_cancel() + @callback def async_cancel(self) -> None: """Cancel any scheduled call.""" @@ -137,4 +146,7 @@ class Debouncer(Generic[_R_co]): @callback def _schedule_timer(self) -> None: """Schedule a timer.""" - self._timer_task = self.hass.loop.call_later(self.cooldown, self._on_debounce) + if not self._shutdown_requested: + self._timer_task = self.hass.loop.call_later( + self.cooldown, self._on_debounce + ) diff --git a/tests/helpers/test_debounce.py b/tests/helpers/test_debounce.py index 4c25413d9fb..b54cfa0365d 100644 --- a/tests/helpers/test_debounce.py +++ b/tests/helpers/test_debounce.py @@ -1,20 +1,26 @@ """Tests for debounce.""" +import asyncio from datetime import timedelta +import logging from unittest.mock import AsyncMock +import pytest + from homeassistant.core import HomeAssistant from homeassistant.helpers import debounce from homeassistant.util.dt import utcnow from ..common import async_fire_time_changed +_LOGGER = logging.getLogger(__name__) + async def test_immediate_works(hass: HomeAssistant) -> None: """Test immediate works.""" calls = [] debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=True, function=AsyncMock(side_effect=lambda: calls.append(None)), @@ -68,7 +74,7 @@ async def test_not_immediate_works(hass: HomeAssistant) -> None: calls = [] debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=False, function=AsyncMock(side_effect=lambda: calls.append(None)), @@ -123,7 +129,7 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non debouncer = debounce.Debouncer( hass, - None, + _LOGGER, cooldown=0.01, immediate=True, function=one_function, @@ -174,3 +180,33 @@ async def test_immediate_works_with_function_swapped(hass: HomeAssistant) -> Non assert debouncer._execute_at_end_of_timer is False debouncer._execute_lock.release() assert debouncer._job.target == debouncer.function + + +async def test_shutdown(hass: HomeAssistant) -> None: + """Test shutdown.""" + calls = [] + future = asyncio.Future() + + async def _func() -> None: + await future + calls.append(None) + + debouncer = debounce.Debouncer( + hass, + _LOGGER, + cooldown=0.01, + immediate=False, + function=_func, + ) + + # Ensure shutdown during a run doesn't create a cooldown timer + hass.async_create_task(debouncer.async_call()) + await asyncio.sleep(0.01) + debouncer.async_shutdown() + future.set_result(True) + await hass.async_block_till_done() + assert len(calls) == 1 + assert debouncer._timer_task is None + + with pytest.raises(RuntimeError, match="Debouncer has been shutdown"): + await debouncer.async_call()