Fix debouncer not scheduling timer when wrapped function raises (#94689)

* Fix debouncer not scheduling timer when callback function raises

* test coro as well

* preen
This commit is contained in:
J. Nick Koston 2023-06-15 16:15:49 -10:00 committed by GitHub
parent 34b725bb99
commit 26be0fab78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 137 additions and 11 deletions

View File

@ -90,11 +90,11 @@ class Debouncer(Generic[_R_co]):
if self._timer_task: if self._timer_task:
return return
task = self.hass.async_run_hass_job(self._job) try:
if task: if task := self.hass.async_run_hass_job(self._job):
await task await task
finally:
self._schedule_timer() self._schedule_timer()
async def _handle_timer_finish(self) -> None: async def _handle_timer_finish(self) -> None:
"""Handle a finished timer.""" """Handle a finished timer."""
@ -112,14 +112,13 @@ class Debouncer(Generic[_R_co]):
return return
try: try:
task = self.hass.async_run_hass_job(self._job) if task := self.hass.async_run_hass_job(self._job):
if task:
await task await task
except Exception: # pylint: disable=broad-except except Exception: # pylint: disable=broad-except
self.logger.exception("Unexpected exception from %s", self.function) self.logger.exception("Unexpected exception from %s", self.function)
finally:
# Schedule a new timer to prevent new runs during cooldown # Schedule a new timer to prevent new runs during cooldown
self._schedule_timer() self._schedule_timer()
async def async_shutdown(self) -> None: async def async_shutdown(self) -> None:
"""Cancel any scheduled call, and prevent new runs.""" """Cancel any scheduled call, and prevent new runs."""

View File

@ -6,7 +6,7 @@ from unittest.mock import AsyncMock
import pytest import pytest
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import debounce from homeassistant.helpers import debounce
from homeassistant.util.dt import utcnow from homeassistant.util.dt import utcnow
@ -69,6 +69,133 @@ async def test_immediate_works(hass: HomeAssistant) -> None:
assert debouncer._job.target == debouncer.function assert debouncer._job.target == debouncer.function
async def test_immediate_works_with_passed_callback_function_raises(
hass: HomeAssistant,
) -> None:
"""Test immediate works with a callback function that raises."""
calls = []
@callback
def _append_and_raise() -> None:
calls.append(None)
raise RuntimeError("forced_raise")
debouncer = debounce.Debouncer(
hass,
_LOGGER,
cooldown=0.01,
immediate=True,
function=_append_and_raise,
)
# Call when nothing happening
with pytest.raises(RuntimeError, match="forced_raise"):
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
# Call when cooldown active setting execute at end to True
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
assert debouncer._job.target == debouncer.function
# Canceling debounce in cooldown
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
before_job = debouncer._job
# Call and let timer run out
with pytest.raises(RuntimeError, match="forced_raise"):
await debouncer.async_call()
assert len(calls) == 2
async_fire_time_changed(hass, utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(calls) == 2
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
assert debouncer._job == before_job
# Test calling doesn't execute/cooldown if currently executing.
await debouncer._execute_lock.acquire()
await debouncer.async_call()
assert len(calls) == 2
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
debouncer._execute_lock.release()
assert debouncer._job.target == debouncer.function
async def test_immediate_works_with_passed_coroutine_raises(
hass: HomeAssistant,
) -> None:
"""Test immediate works with a coroutine that raises."""
calls = []
async def _append_and_raise() -> None:
calls.append(None)
raise RuntimeError("forced_raise")
debouncer = debounce.Debouncer(
hass,
_LOGGER,
cooldown=0.01,
immediate=True,
function=_append_and_raise,
)
# Call when nothing happening
with pytest.raises(RuntimeError, match="forced_raise"):
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
# Call when cooldown active setting execute at end to True
await debouncer.async_call()
assert len(calls) == 1
assert debouncer._timer_task is not None
assert debouncer._execute_at_end_of_timer is True
assert debouncer._job.target == debouncer.function
# Canceling debounce in cooldown
debouncer.async_cancel()
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
before_job = debouncer._job
# Call and let timer run out
with pytest.raises(RuntimeError, match="forced_raise"):
await debouncer.async_call()
assert len(calls) == 2
async_fire_time_changed(hass, utcnow() + timedelta(seconds=1))
await hass.async_block_till_done()
assert len(calls) == 2
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
assert debouncer._job.target == debouncer.function
assert debouncer._job == before_job
# Test calling doesn't execute/cooldown if currently executing.
await debouncer._execute_lock.acquire()
await debouncer.async_call()
assert len(calls) == 2
assert debouncer._timer_task is None
assert debouncer._execute_at_end_of_timer is False
debouncer._execute_lock.release()
assert debouncer._job.target == debouncer.function
async def test_not_immediate_works(hass: HomeAssistant) -> None: async def test_not_immediate_works(hass: HomeAssistant) -> None:
"""Test immediate works.""" """Test immediate works."""
calls = [] calls = []