diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7c1b590b1b0..572b6583d94 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -699,11 +699,20 @@ class ConfigEntry: # has started so we do not block shutdown if not hass.is_stopping: hass.async_create_task( - self.async_setup(hass), + self._async_setup_retry(hass), f"config entry retry {self.domain} {self.title}", eager_start=True, ) + async def _async_setup_retry(self, hass: HomeAssistant) -> None: + """Retry setup. + + We hold the reload lock during setup retry to ensure + that nothing can reload the entry while we are retrying. + """ + async with self.reload_lock: + await self.async_setup(hass) + @callback def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" @@ -1762,11 +1771,22 @@ class ConfigEntries: async def async_reload(self, entry_id: str) -> bool: """Reload an entry. + When reloading from an integration is is preferable to + call async_schedule_reload instead of this method since + it will cancel setup retry before starting this method + in a task which eliminates a race condition where the + setup retry can fire during the reload. + If an entry was not loaded, will just load. """ if (entry := self.async_get_entry(entry_id)) is None: raise UnknownEntry + # Cancel the setup retry task before waiting for the + # reload lock to reduce the chance of concurrent reload + # attempts. + entry.async_cancel_retry_setup() + async with entry.reload_lock: unload_result = await self.async_unload(entry_id) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index b817aaddf5d..d911458e719 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1284,6 +1284,53 @@ async def test_setup_does_not_retry_during_shutdown(hass: HomeAssistant) -> None assert len(mock_setup_entry.mock_calls) == 1 +async def test_reload_during_setup_retrying_waits(hass: HomeAssistant) -> None: + """Test reloading during setup retry waits.""" + entry = MockConfigEntry(domain="test") + entry.add_to_hass(hass) + load_attempts = [] + sleep_duration = 0 + + async def _mock_setup_entry(hass, entry): + """Mock setup entry.""" + nonlocal sleep_duration + await asyncio.sleep(sleep_duration) + load_attempts.append(entry.entry_id) + raise ConfigEntryNotReady + + mock_integration(hass, MockModule("test", async_setup_entry=_mock_setup_entry)) + mock_platform(hass, "test.config_flow", None) + + await hass.async_create_task( + hass.config_entries.async_setup(entry.entry_id), eager_start=True + ) + assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY + + # Now make the setup take a while so that the setup retry + # will still be in progress when the reload request comes in + sleep_duration = 0.1 + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=5)) + await asyncio.sleep(0) + + # Should not raise homeassistant.config_entries.OperationNotAllowed + await hass.config_entries.async_reload(entry.entry_id) + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(minutes=10)) + await asyncio.sleep(0) + + # Should not raise homeassistant.config_entries.OperationNotAllowed + hass.config_entries.async_schedule_reload(entry.entry_id) + await hass.async_block_till_done() + + assert load_attempts == [ + entry.entry_id, + entry.entry_id, + entry.entry_id, + entry.entry_id, + entry.entry_id, + ] + + async def test_create_entry_options( hass: HomeAssistant, manager: config_entries.ConfigEntries ) -> None: