Shield config entry removal from client disconnect (#178207)

This commit is contained in:
Dmitry
2026-08-13 17:40:45 +02:00
committed by GitHub
parent a09bade464
commit 57f2e39028
2 changed files with 63 additions and 1 deletions
@@ -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)
@@ -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)