Skip to content

Commit d140eb4

Browse files
authored
Protect internal coordinator state (home-assistant#153685)
1 parent 21f24c2 commit d140eb4

File tree

9 files changed

+116
-24
lines changed

9 files changed

+116
-24
lines changed

homeassistant/components/canary/alarm_control_panel.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def async_setup_entry(
3131
for location_id, location in coordinator.data["locations"].items()
3232
]
3333

34-
async_add_entities(alarms, True)
34+
async_add_entities(alarms)
3535

3636

3737
class CanaryAlarm(

homeassistant/components/canary/camera.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,8 +68,7 @@ async def async_setup_entry(
6868
for location_id, location in coordinator.data["locations"].items()
6969
for device in location.devices
7070
if device.is_online
71-
),
72-
True,
71+
)
7372
)
7473

7574

homeassistant/components/canary/sensor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ async def async_setup_entry(
8080
if device_type.get("name") in sensor_type[4]
8181
)
8282

83-
async_add_entities(sensors, True)
83+
async_add_entities(sensors)
8484

8585

8686
class CanarySensor(CoordinatorEntity[CanaryDataUpdateCoordinator], SensorEntity):

homeassistant/helpers/debounce.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33
from __future__ import annotations
44

55
import asyncio
6-
from collections.abc import Callable
6+
from collections.abc import AsyncGenerator, Callable
7+
from contextlib import asynccontextmanager
78
from logging import Logger
9+
from typing import Any
810

911
from homeassistant.core import HassJob, HomeAssistant, callback
1012

@@ -36,6 +38,7 @@ def __init__(
3638
self._timer_task: asyncio.TimerHandle | None = None
3739
self._execute_at_end_of_timer: bool = False
3840
self._execute_lock = asyncio.Lock()
41+
self._execute_lock_owner: asyncio.Task[Any] | None = None
3942
self._background = background
4043
self._job: HassJob[[], _R_co] | None = (
4144
None
@@ -46,6 +49,22 @@ def __init__(
4649
)
4750
self._shutdown_requested = False
4851

52+
@asynccontextmanager
53+
async def async_lock(self) -> AsyncGenerator[None]:
54+
"""Return an async context manager to lock the debouncer."""
55+
if self._execute_lock_owner is asyncio.current_task():
56+
raise RuntimeError("Debouncer lock is not re-entrant")
57+
58+
if self._execute_lock.locked():
59+
self.logger.debug("Debouncer lock is already acquired, waiting")
60+
61+
async with self._execute_lock:
62+
self._execute_lock_owner = asyncio.current_task()
63+
try:
64+
yield
65+
finally:
66+
self._execute_lock_owner = None
67+
4968
@property
5069
def function(self) -> Callable[[], _R_co] | None:
5170
"""Return the function being wrapped by the Debouncer."""
@@ -98,7 +117,7 @@ async def async_call(self) -> None:
98117
if not self._async_schedule_or_call_now():
99118
return
100119

101-
async with self._execute_lock:
120+
async with self.async_lock():
102121
# Abort if timer got set while we're waiting for the lock.
103122
if self._timer_task:
104123
return
@@ -122,7 +141,7 @@ async def _handle_timer_finish(self) -> None:
122141
if self._execute_lock.locked():
123142
return
124143

125-
async with self._execute_lock:
144+
async with self.async_lock():
126145
# Abort if timer got set while we're waiting for the lock.
127146
if self._timer_task:
128147
return

homeassistant/helpers/update_coordinator.py

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -128,10 +128,10 @@ def __init__(
128128
logger,
129129
cooldown=REQUEST_REFRESH_DEFAULT_COOLDOWN,
130130
immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE,
131-
function=self.async_refresh,
131+
function=self._async_refresh,
132132
)
133133
else:
134-
request_refresh_debouncer.function = self.async_refresh
134+
request_refresh_debouncer.function = self._async_refresh
135135

136136
self._debounced_refresh = request_refresh_debouncer
137137

@@ -277,7 +277,8 @@ def __wrap_handle_refresh_interval(self) -> None:
277277
async def _handle_refresh_interval(self, _now: datetime | None = None) -> None:
278278
"""Handle a refresh interval occurrence."""
279279
self._unsub_refresh = None
280-
await self._async_refresh(log_failures=True, scheduled=True)
280+
async with self._debounced_refresh.async_lock():
281+
await self._async_refresh(log_failures=True, scheduled=True)
281282

282283
async def async_request_refresh(self) -> None:
283284
"""Request a refresh.
@@ -295,6 +296,16 @@ async def _async_update_data(self) -> _DataT:
295296
async def async_config_entry_first_refresh(self) -> None:
296297
"""Refresh data for the first time when a config entry is setup.
297298
299+
Will automatically raise ConfigEntryNotReady if the refresh
300+
fails. Additionally logging is handled by config entry setup
301+
to ensure that multiple retries do not cause log spam.
302+
"""
303+
async with self._debounced_refresh.async_lock():
304+
await self._async_config_entry_first_refresh()
305+
306+
async def _async_config_entry_first_refresh(self) -> None:
307+
"""Refresh data for the first time when a config entry is setup.
308+
298309
Will automatically raise ConfigEntryNotReady if the refresh
299310
fails. Additionally logging is handled by config entry setup
300311
to ensure that multiple retries do not cause log spam.
@@ -364,7 +375,8 @@ async def _async_setup(self) -> None:
364375

365376
async def async_refresh(self) -> None:
366377
"""Refresh data and log errors."""
367-
await self._async_refresh(log_failures=True)
378+
async with self._debounced_refresh.async_lock():
379+
await self._async_refresh(log_failures=True)
368380

369381
async def _async_refresh( # noqa: C901
370382
self,

tests/components/canary/test_sensor.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
)
2020
from homeassistant.core import HomeAssistant
2121
from homeassistant.helpers import device_registry as dr, entity_registry as er
22-
from homeassistant.helpers.entity_component import async_update_entity
2322
from homeassistant.util.dt import utcnow
2423

2524
from . import init_integration, mock_device, mock_location, mock_reading
@@ -126,8 +125,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None:
126125

127126
future = utcnow() + timedelta(seconds=30)
128127
async_fire_time_changed(hass, future)
129-
await async_update_entity(hass, entity_id)
130-
await hass.async_block_till_done()
128+
await hass.async_block_till_done(wait_background_tasks=True)
131129

132130
state2 = hass.states.get(entity_id)
133131
assert state2
@@ -142,8 +140,7 @@ async def test_sensors_attributes_pro(hass: HomeAssistant, canary) -> None:
142140

143141
future += timedelta(seconds=30)
144142
async_fire_time_changed(hass, future)
145-
await async_update_entity(hass, entity_id)
146-
await hass.async_block_till_done()
143+
await hass.async_block_till_done(wait_background_tasks=True)
147144

148145
state3 = hass.states.get(entity_id)
149146
assert state3

tests/components/hassio/test_init.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -922,7 +922,7 @@ async def test_coordinator_updates(
922922
supervisor_client.refresh_updates.assert_not_called()
923923

924924
async_fire_time_changed(hass, dt_util.now() + timedelta(minutes=20))
925-
await hass.async_block_till_done()
925+
await hass.async_block_till_done(wait_background_tasks=True)
926926

927927
# Scheduled refresh, no update refresh call
928928
supervisor_client.refresh_updates.assert_not_called()
@@ -944,7 +944,7 @@ async def test_coordinator_updates(
944944
async_fire_time_changed(
945945
hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY)
946946
)
947-
await hass.async_block_till_done()
947+
await hass.async_block_till_done(wait_background_tasks=True)
948948
supervisor_client.refresh_updates.assert_called_once()
949949

950950
supervisor_client.refresh_updates.reset_mock()

tests/components/playstation_network/test_init.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -157,8 +157,8 @@ async def test_trophy_title_coordinator_auth_failed(
157157

158158
freezer.tick(timedelta(days=1))
159159
async_fire_time_changed(hass)
160-
await hass.async_block_till_done()
161-
await hass.async_block_till_done()
160+
await hass.async_block_till_done(wait_background_tasks=True)
161+
await hass.async_block_till_done(wait_background_tasks=True)
162162

163163
flows = hass.config_entries.flow.async_progress()
164164
assert len(flows) == 1
@@ -194,8 +194,8 @@ async def test_trophy_title_coordinator_update_data_failed(
194194

195195
freezer.tick(timedelta(days=1))
196196
async_fire_time_changed(hass)
197-
await hass.async_block_till_done()
198-
await hass.async_block_till_done()
197+
await hass.async_block_till_done(wait_background_tasks=True)
198+
await hass.async_block_till_done(wait_background_tasks=True)
199199

200200
runtime_data: PlaystationNetworkRuntimeData = config_entry.runtime_data
201201
assert runtime_data.trophy_titles.last_update_success is False
@@ -254,8 +254,8 @@ async def test_trophy_title_coordinator_play_new_game(
254254

255255
freezer.tick(timedelta(days=1))
256256
async_fire_time_changed(hass)
257-
await hass.async_block_till_done()
258-
await hass.async_block_till_done()
257+
await hass.async_block_till_done(wait_background_tasks=True)
258+
await hass.async_block_till_done(wait_background_tasks=True)
259259

260260
assert len(mock_psnawpapi.user.return_value.trophy_titles.mock_calls) == 2
261261

tests/helpers/test_update_coordinator.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Tests for the update coordinator."""
22

3+
import asyncio
34
from datetime import datetime, timedelta
45
import logging
56
from unittest.mock import AsyncMock, Mock, patch
@@ -405,6 +406,70 @@ async def test_update_interval_not_present(
405406
assert crd.data is None
406407

407408

409+
async def test_update_locks(
410+
hass: HomeAssistant,
411+
freezer: FrozenDateTimeFactory,
412+
crd: update_coordinator.DataUpdateCoordinator[int],
413+
) -> None:
414+
"""Test update interval works."""
415+
start = asyncio.Event()
416+
block = asyncio.Event()
417+
418+
async def _update_method() -> int:
419+
start.set()
420+
await block.wait()
421+
block.clear()
422+
return 0
423+
424+
crd.update_method = _update_method
425+
426+
# Add subscriber
427+
update_callback = Mock()
428+
crd.async_add_listener(update_callback)
429+
430+
assert crd.update_interval
431+
432+
# Trigger timed update, ensure it is started
433+
freezer.tick(crd.update_interval)
434+
async_fire_time_changed(hass)
435+
await start.wait()
436+
start.clear()
437+
438+
# Trigger direct update
439+
task = hass.async_create_background_task(crd.async_refresh(), "", eager_start=True)
440+
freezer.tick(timedelta(seconds=60))
441+
async_fire_time_changed(hass)
442+
443+
# Ensure it has not started
444+
assert not start.is_set()
445+
446+
# Unblock interval update
447+
block.set()
448+
449+
# Check that direct update starts
450+
await start.wait()
451+
start.clear()
452+
453+
# Request update. This should not be blocking
454+
# since the lock is held, it should be queued
455+
await crd.async_request_refresh()
456+
assert not start.is_set()
457+
458+
# Unblock second update
459+
block.set()
460+
# Check that task finishes
461+
await task
462+
463+
# Check that queued update starts
464+
freezer.tick(timedelta(seconds=60))
465+
async_fire_time_changed(hass)
466+
await start.wait()
467+
start.clear()
468+
469+
# Unblock queued update
470+
block.set()
471+
472+
408473
async def test_refresh_recover(
409474
crd: update_coordinator.DataUpdateCoordinator[int], caplog: pytest.LogCaptureFixture
410475
) -> None:

0 commit comments

Comments
 (0)