Skip to content

Commit 5f522e5

Browse files
authored
Fix cancel propagation in update coordinator and config entry (home-assistant#153504)
1 parent 4f6624d commit 5f522e5

File tree

7 files changed

+101
-13
lines changed

7 files changed

+101
-13
lines changed

homeassistant/components/history_stats/config_flow.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ async def ws_start_preview(
211211

212212
@callback
213213
def async_preview_updated(
214-
last_exception: Exception | None, state: str, attributes: Mapping[str, Any]
214+
last_exception: BaseException | None, state: str, attributes: Mapping[str, Any]
215215
) -> None:
216216
"""Forward config entry state events to websocket."""
217217
if last_exception:

homeassistant/components/history_stats/sensor.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,9 @@ def _process_update(self) -> None:
241241

242242
async def async_start_preview(
243243
self,
244-
preview_callback: Callable[[Exception | None, str, Mapping[str, Any]], None],
244+
preview_callback: Callable[
245+
[BaseException | None, str, Mapping[str, Any]], None
246+
],
245247
) -> CALLBACK_TYPE:
246248
"""Render a preview."""
247249

homeassistant/config_entries.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,7 @@ async def __async_setup_with_context(
754754
error_reason_translation_key = None
755755
error_reason_translation_placeholders = None
756756

757+
result = False
757758
try:
758759
with async_start_setup(
759760
hass, integration=self.domain, group=self.entry_id, phase=setup_phase
@@ -775,8 +776,6 @@ async def __async_setup_with_context(
775776
self.domain,
776777
error_reason,
777778
)
778-
await self._async_process_on_unload(hass)
779-
result = False
780779
except ConfigEntryAuthFailed as exc:
781780
message = str(exc)
782781
auth_base_message = "could not authenticate"
@@ -792,9 +791,7 @@ async def __async_setup_with_context(
792791
self.domain,
793792
auth_message,
794793
)
795-
await self._async_process_on_unload(hass)
796794
self.async_start_reauth(hass)
797-
result = False
798795
except ConfigEntryNotReady as exc:
799796
message = str(exc)
800797
error_reason_translation_key = exc.translation_key
@@ -835,14 +832,39 @@ async def __async_setup_with_context(
835832
functools.partial(self._async_setup_again, hass),
836833
)
837834

838-
await self._async_process_on_unload(hass)
839835
return
836+
837+
except asyncio.CancelledError:
838+
# We want to propagate CancelledError if we are being cancelled.
839+
if (task := asyncio.current_task()) and task.cancelling() > 0:
840+
_LOGGER.exception(
841+
"Setup of config entry '%s' for %s integration cancelled",
842+
self.title,
843+
self.domain,
844+
)
845+
self._async_set_state(
846+
hass,
847+
ConfigEntryState.SETUP_ERROR,
848+
None,
849+
None,
850+
None,
851+
)
852+
raise
853+
854+
# This was not a "real" cancellation, log it and treat as a normal error.
855+
_LOGGER.exception(
856+
"Error setting up entry %s for %s", self.title, integration.domain
857+
)
858+
840859
# pylint: disable-next=broad-except
841-
except (asyncio.CancelledError, SystemExit, Exception):
860+
except (SystemExit, Exception):
842861
_LOGGER.exception(
843862
"Error setting up entry %s for %s", self.title, integration.domain
844863
)
845-
result = False
864+
865+
finally:
866+
if not result and domain_is_integration:
867+
await self._async_process_on_unload(hass)
846868

847869
#
848870
# After successfully calling async_setup_entry, it is important that this function

homeassistant/helpers/update_coordinator.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ def __init__(
131131
self._request_refresh_task: asyncio.TimerHandle | None = None
132132
self._retry_after: float | None = None
133133
self.last_update_success = True
134-
self.last_exception: Exception | None = None
134+
self.last_exception: BaseException | None = None
135135

136136
if request_refresh_debouncer is None:
137137
request_refresh_debouncer = Debouncer(
@@ -492,8 +492,16 @@ async def _async_refresh( # noqa: C901
492492
self.config_entry.async_start_reauth(self.hass)
493493
except NotImplementedError as err:
494494
self.last_exception = err
495+
self.last_update_success = False
495496
raise
496497

498+
except asyncio.CancelledError as err:
499+
self.last_exception = err
500+
self.last_update_success = False
501+
502+
if (task := asyncio.current_task()) and task.cancelling() > 0:
503+
raise
504+
497505
except Exception as err:
498506
self.last_exception = err
499507
self.last_update_success = False

tests/helpers/test_update_coordinator.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,35 @@ async def test_refresh_no_update_method(
334334
await crd.async_refresh()
335335

336336

337+
async def test_refresh_cancelled(
338+
hass: HomeAssistant,
339+
crd: update_coordinator.DataUpdateCoordinator[int],
340+
) -> None:
341+
"""Test that we don't swallow cancellation."""
342+
await crd.async_refresh()
343+
344+
start = asyncio.Event()
345+
abort = asyncio.Event()
346+
347+
async def _update() -> bool:
348+
start.set()
349+
await abort.wait()
350+
return True
351+
352+
crd.update_method = _update
353+
crd.last_update_success = True
354+
355+
task = hass.async_create_task(crd.async_refresh())
356+
await start.wait()
357+
task.cancel()
358+
359+
with pytest.raises(asyncio.CancelledError):
360+
await task
361+
362+
abort.set()
363+
assert crd.last_update_success is False
364+
365+
337366
async def test_update_interval(
338367
hass: HomeAssistant,
339368
freezer: FrozenDateTimeFactory,

tests/test_config_entries.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9727,3 +9727,31 @@ async def async_step_user(
97279727
assert result["next_flow"][0] == config_entries.FlowType.CONFIG_FLOW
97289728
# Verify the target flow exists
97299729
hass.config_entries.flow.async_get(result["next_flow"][1])
9730+
9731+
9732+
async def test_canceled_exceptions_are_propagated(
9733+
hass: HomeAssistant, manager: config_entries.ConfigEntries
9734+
) -> None:
9735+
"""Tests that base exceptions like cancellations are not swallowed."""
9736+
entry = MockConfigEntry(title="test_title", domain="test")
9737+
start = asyncio.Event()
9738+
abort = asyncio.Event()
9739+
9740+
async def _setup(_: HomeAssistant, __: ConfigEntry) -> bool:
9741+
start.set()
9742+
await abort.wait()
9743+
return True
9744+
9745+
mock_integration(hass, MockModule("test", async_setup_entry=_setup))
9746+
mock_platform(hass, "test.config_flow", None)
9747+
9748+
entry.add_to_hass(hass)
9749+
9750+
task = hass.async_create_task(manager.async_setup(entry.entry_id))
9751+
await start.wait()
9752+
task.cancel()
9753+
9754+
with pytest.raises(asyncio.CancelledError):
9755+
await task
9756+
9757+
abort.set()

tests/testing_config/custom_components/test_package_raises_cancelled_error_config_entry/__init__.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,5 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
1313

1414

1515
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
16-
"""Mock an unsuccessful entry setup."""
17-
asyncio.current_task().cancel()
18-
await asyncio.sleep(0)
16+
"""Mock an leaked cancellation, without our own task being cancelled."""
17+
raise asyncio.CancelledError

0 commit comments

Comments
 (0)