From 57f2e39028cc7f3f8300bf65fac2548d83ab390b Mon Sep 17 00:00:00 2001 From: Dmitry <45711841+darkdi@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:40:45 +0300 Subject: [PATCH] Shield config entry removal from client disconnect (#178207) --- .../components/config/config_entries.py | 13 ++++- .../components/config/test_config_entries.py | 51 +++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index f569f7ca421c..6c13944e7814 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -1,5 +1,6 @@ """Http views to control the config manager.""" +from asyncio import shield from collections.abc import Callable from http import HTTPStatus import logging @@ -110,8 +111,18 @@ class ConfigManagerEntryResourceView(HomeAssistantView): hass = request.app[KEY_HASS] + # Shield the removal from cancellation on connection drop, otherwise the + # entry is dropped from memory but never saved or cleaned up. The task is + # created through hass so a strong reference is held for its lifetime, + # which keeps it from being garbage collected once the request handler + # has gone away. + remove_task = hass.async_create_task( + hass.config_entries.async_remove(entry_id), + f"config entry remove {entry_id}", + ) + try: - result = await hass.config_entries.async_remove(entry_id) + result = await shield(remove_task) except config_entries.UnknownEntry: return self.json_message("Invalid entry specified", HTTPStatus.NOT_FOUND) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 3077c55f0609..71dfad0a1e81 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -1,5 +1,6 @@ """Test config entries API.""" +import asyncio from collections.abc import Generator from http import HTTPStatus from typing import Any @@ -13,6 +14,7 @@ import voluptuous as vol from homeassistant import config_entries as core_ce, data_entry_flow, loader from homeassistant.components.config import DOMAIN, config_entries +from homeassistant.components.http import KEY_HASS from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS from homeassistant.core import HomeAssistant, callback @@ -269,6 +271,55 @@ async def test_remove_entry(hass: HomeAssistant, client: TestClient) -> None: assert len(hass.config_entries.async_entries()) == 0 +async def test_remove_entry_survives_client_disconnect( + hass: HomeAssistant, hass_admin_user: MockUser +) -> None: + """Test a client disconnect does not truncate the removal. + + The HTTP runner is created with handler_cancellation=True, so a disconnect + cancels the request handler. Removal deletes the entry from memory before + awaiting the integration, so an unshielded cancel leaves the entry on disk + and its registry rows behind. + """ + entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + removing = asyncio.Event() + disconnected = asyncio.Event() + original_async_remove = core_ce.ConfigEntry.async_remove + + async def blocking_async_remove(self: core_ce.ConfigEntry, *args: Any) -> None: + """Stall inside the removal, so the cancel lands on this await.""" + removing.set() + await disconnected.wait() + await original_async_remove(self, *args) + + view = config_entries.ConfigManagerEntryResourceView() + request = Mock() + request.__getitem__ = Mock(side_effect={"hass_user": hass_admin_user}.__getitem__) + request.app = {KEY_HASS: hass} + + with ( + patch.object(core_ce.ConfigEntry, "async_remove", blocking_async_remove), + patch.object(hass.config_entries, "_async_schedule_save") as mock_schedule_save, + ): + task = hass.async_create_task(view.delete(request, entry.entry_id)) + await removing.wait() + + # The client goes away mid-removal. + task.cancel() + disconnected.set() + + with pytest.raises(asyncio.CancelledError): + await task + + await hass.async_block_till_done() + + # The rest of the removal must still have run. + assert mock_schedule_save.called + assert hass.config_entries.async_entries() == [] + + async def test_reload_entry(hass: HomeAssistant, client: TestClient) -> None: """Test reloading an entry via the API.""" entry = MockConfigEntry(domain="test", state=core_ce.ConfigEntryState.LOADED)