Ensure config entries are not unloaded while their platforms are setting up (#118767)

* Report non-awaited/non-locked config entry platform forwards

Its currently possible for config entries to be reloaded while their platforms
are being forwarded if platform forwards are not awaited or done after the
config entry is setup since the lock will not be held in this case.

In https://developers.home-assistant.io/blog/2022/07/08/config_entry_forwards
we advised to await platform forwards to ensure this does not happen, however
for sleeping devices and late discovered devices, platform forwards may happen
later.

If config platform forwards are happening during setup, they should be awaited

If config entry platform forwards are not happening during setup, instead
async_late_forward_entry_setups should be used which will hold the lock to
prevent the config entry from being unloaded while its platforms are being
setup

* Report non-awaited/non-locked config entry platform forwards

Its currently possible for config entries to be reloaded while their platforms
are being forwarded if platform forwards are not awaited or done after the
config entry is setup since the lock will not be held in this case.

In https://developers.home-assistant.io/blog/2022/07/08/config_entry_forwards
we advised to await platform forwards to ensure this does not happen, however
for sleeping devices and late discovered devices, platform forwards may happen
later.

If config platform forwards are happening during setup, they should be awaited

If config entry platform forwards are not happening during setup, instead
async_late_forward_entry_setups should be used which will hold the lock to
prevent the config entry from being unloaded while its platforms are being
setup

* run with error on to find them

* cert_exp, hold lock

* cert_exp, hold lock

* shelly async_late_forward_entry_setups

* compact

* compact

* found another

* patch up mobileapp

* patch up hue tests

* patch up smartthings

* fix mqtt

* fix esphome

* zwave_js

* mqtt

* rework

* fixes

* fix mocking

* fix mocking

* do not call async_forward_entry_setup directly

* docstrings

* docstrings

* docstrings

* add comments

* doc strings

* fixed all in core, turn off strict

* coverage

* coverage

* missing

* coverage
This commit is contained in:
J. Nick Koston 2024-06-04 20:34:39 -05:00 committed by GitHub
parent 67b3be8432
commit ed0568c655
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
47 changed files with 457 additions and 111 deletions

View file

@ -1178,6 +1178,24 @@ class FlowCancelledError(Exception):
"""Error to indicate that a flow has been cancelled."""
def _report_non_locked_platform_forwards(entry: ConfigEntry) -> None:
"""Report non awaited and non-locked platform forwards."""
report(
f"calls async_forward_entry_setup after the entry for "
f"integration, {entry.domain} with title: {entry.title} "
f"and entry_id: {entry.entry_id}, has been set up, "
"without holding the setup lock that prevents the config "
"entry from being set up multiple times. "
"Instead await hass.config_entries.async_forward_entry_setup "
"during setup of the config entry or call "
"hass.config_entries.async_late_forward_entry_setups "
"in a tracked task. "
"This will stop working in Home Assistant 2025.1",
error_if_integration=False,
error_if_core=False,
)
class ConfigEntriesFlowManager(data_entry_flow.FlowManager[ConfigFlowResult]):
"""Manage all the config entry flows that are in progress."""
@ -2024,15 +2042,32 @@ class ConfigEntries:
async def async_forward_entry_setups(
self, entry: ConfigEntry, platforms: Iterable[Platform | str]
) -> None:
"""Forward the setup of an entry to platforms."""
"""Forward the setup of an entry to platforms.
This method should be awaited before async_setup_entry is finished
in each integration. This is to ensure that all platforms are loaded
before the entry is set up. This ensures that the config entry cannot
be unloaded before all platforms are loaded.
If platforms must be loaded late (after the config entry is setup),
use async_late_forward_entry_setup instead.
This method is more efficient than async_forward_entry_setup as
it can load multiple platforms at once and does not require a separate
import executor job for each platform.
"""
integration = await loader.async_get_integration(self.hass, entry.domain)
if not integration.platforms_are_loaded(platforms):
with async_pause_setup(self.hass, SetupPhases.WAIT_IMPORT_PLATFORMS):
await integration.async_get_platforms(platforms)
if non_locked_platform_forwards := not entry.setup_lock.locked():
_report_non_locked_platform_forwards(entry)
await asyncio.gather(
*(
create_eager_task(
self._async_forward_entry_setup(entry, platform, False),
self._async_forward_entry_setup(
entry, platform, False, non_locked_platform_forwards
),
name=(
f"config entry forward setup {entry.title} "
f"{entry.domain} {entry.entry_id} {platform}"
@ -2043,6 +2078,25 @@ class ConfigEntries:
)
)
async def async_late_forward_entry_setups(
self, entry: ConfigEntry, platforms: Iterable[Platform | str]
) -> None:
"""Forward the setup of an entry to platforms after setup.
If platforms must be loaded late (after the config entry is setup),
use this method instead of async_forward_entry_setups as it holds
the setup lock until the platforms are loaded to ensure that the
config entry cannot be unloaded while platforms are loaded.
"""
async with entry.setup_lock:
if entry.state is not ConfigEntryState.LOADED:
raise OperationNotAllowed(
f"The config entry {entry.title} ({entry.domain}) with entry_id"
f" {entry.entry_id} cannot forward setup for {platforms} "
f"because it is not loaded in the {entry.state} state"
)
await self.async_forward_entry_setups(entry, platforms)
async def async_forward_entry_setup(
self, entry: ConfigEntry, domain: Platform | str
) -> bool:
@ -2051,11 +2105,38 @@ class ConfigEntries:
By default an entry is setup with the component it belongs to. If that
component also has related platforms, the component will have to
forward the entry to be setup by that component.
This method is deprecated and will stop working in Home Assistant 2025.6.
Instead, await async_forward_entry_setups as it can load
multiple platforms at once and is more efficient since it
does not require a separate import executor job for each platform.
If platforms must be loaded late (after the config entry is setup),
use async_late_forward_entry_setup instead.
"""
return await self._async_forward_entry_setup(entry, domain, True)
if non_locked_platform_forwards := not entry.setup_lock.locked():
_report_non_locked_platform_forwards(entry)
else:
report(
"calls async_forward_entry_setup for "
f"integration, {entry.domain} with title: {entry.title} "
f"and entry_id: {entry.entry_id}, which is deprecated and "
"will stop working in Home Assistant 2025.6, "
"await async_forward_entry_setups instead",
error_if_core=False,
error_if_integration=False,
)
return await self._async_forward_entry_setup(
entry, domain, True, non_locked_platform_forwards
)
async def _async_forward_entry_setup(
self, entry: ConfigEntry, domain: Platform | str, preload_platform: bool
self,
entry: ConfigEntry,
domain: Platform | str,
preload_platform: bool,
non_locked_platform_forwards: bool,
) -> bool:
"""Forward the setup of an entry to a different component."""
# Setup Component if not set up yet
@ -2079,6 +2160,12 @@ class ConfigEntries:
integration = loader.async_get_loaded_integration(self.hass, domain)
await entry.async_setup(self.hass, integration=integration)
# Check again after setup to make sure the lock
# is still there because it could have been released
# unless we already reported it.
if not non_locked_platform_forwards and not entry.setup_lock.locked():
_report_non_locked_platform_forwards(entry)
return True
async def async_unload_platforms(
@ -2104,7 +2191,11 @@ class ConfigEntries:
async def async_forward_entry_unload(
self, entry: ConfigEntry, domain: Platform | str
) -> bool:
"""Forward the unloading of an entry to a different component."""
"""Forward the unloading of an entry to a different component.
Its is preferred to call async_unload_platforms instead
of directly calling this method.
"""
# It was never loaded.
if domain not in self.hass.config.components:
return True