From f3a622f7db7253f6ca4cf7b4b106a648d7e5a038 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 1 Oct 2026 10:04:35 +0100 Subject: [PATCH] Fix entity removal leaks in matter (#183864) --- homeassistant/components/matter/entity.py | 11 ++-- homeassistant/components/matter/event.py | 2 +- homeassistant/components/matter/lock.py | 2 +- tests/components/matter/test_entity.py | 62 +++++++++++++++++++++++ 4 files changed, 69 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/matter/entity.py b/homeassistant/components/matter/entity.py index 7636a1a52486..fdf3a04866e0 100644 --- a/homeassistant/components/matter/entity.py +++ b/homeassistant/components/matter/entity.py @@ -112,7 +112,6 @@ class MatterEntity(Entity): self._endpoint = endpoint self._entity_info = entity_info self.entity_description = entity_info.entity_description - self._unsubscribes: list[Callable] = [] # for fast lookups we create a mapping to the attribute paths self._attributes_map: dict[type, str] = {} # The server info is set when the client connects to the server. @@ -213,7 +212,7 @@ class MatterEntity(Entity): continue self._attributes_map[attr_cls] = attr_path sub_paths.append(attr_path) - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.ATTRIBUTE_UPDATED, @@ -222,7 +221,7 @@ class MatterEntity(Entity): ) ) # subscribe to node (availability changes) - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.NODE_UPDATED, @@ -240,7 +239,7 @@ class MatterEntity(Entity): ) if reachable_attr_path not in sub_paths: sub_paths.append(reachable_attr_path) - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.ATTRIBUTE_UPDATED, @@ -259,7 +258,7 @@ class MatterEntity(Entity): ) if parent_reachable_attr_path not in sub_paths: sub_paths.append(parent_reachable_attr_path) - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_event, event_filter=EventType.ATTRIBUTE_UPDATED, @@ -268,7 +267,7 @@ class MatterEntity(Entity): ) ) # subscribe to FeatureMap attribute (as that can dynamically change) - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_featuremap_update, event_filter=EventType.ATTRIBUTE_UPDATED, diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index da1ca2d2eeb0..b962f6b227a2 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -97,7 +97,7 @@ class MatterEventEntity(MatterEntity, EventEntity): await super().async_added_to_hass() # subscribe to NodeEvent events - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_node_event, event_filter=EventType.NODE_EVENT, diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 0b4c4e477968..7cbf33b5e645 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -93,7 +93,7 @@ class MatterLock(MatterEntity, LockEntity): """Subscribe to events.""" await super().async_added_to_hass() # subscribe to NodeEvent events - self._unsubscribes.append( + self.async_on_remove( self.matter_client.subscribe_events( callback=self._on_matter_node_event, event_filter=EventType.NODE_EVENT, diff --git a/tests/components/matter/test_entity.py b/tests/components/matter/test_entity.py index 905a01b2c717..b875aac19a29 100644 --- a/tests/components/matter/test_entity.py +++ b/tests/components/matter/test_entity.py @@ -1,5 +1,6 @@ """Test Matter entity behavior.""" +from typing import Any from unittest.mock import MagicMock from matter_server.client.models.node import MatterNode @@ -581,3 +582,64 @@ async def test_composed_entity_subscribes_to_parent_reachable_attribute( f"{_COMPOSED_PARENT_REACHABLE_ATTR_PATH!r}" " and event_filter=ATTRIBUTE_UPDATED, but none was found." ) + + +@pytest.mark.parametrize( + ("node_fixture", "entity_id", "expected_event_filters"), + [ + pytest.param( + "mock_onoff_light", + "light.mock_onoff_light", + {EventType.ATTRIBUTE_UPDATED, EventType.NODE_UPDATED}, + id="base_entity", + ), + pytest.param( + "mock_generic_switch", + "event.mock_generic_switch_button", + {EventType.ATTRIBUTE_UPDATED, EventType.NODE_UPDATED, EventType.NODE_EVENT}, + id="event", + ), + pytest.param( + "mock_door_lock", + "lock.mock_door_lock", + {EventType.ATTRIBUTE_UPDATED, EventType.NODE_UPDATED, EventType.NODE_EVENT}, + id="lock", + ), + ], +) +async def test_entity_removal_unsubscribes_events( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + matter_client: MagicMock, + node_fixture: str, + entity_id: str, + expected_event_filters: set[EventType], +) -> None: + """Test removing an entity unsubscribes all its Matter client subscriptions.""" + subscriptions: list[tuple[dict[str, Any], MagicMock]] = [] + + def _subscribe_events(**kwargs: Any) -> MagicMock: + unsubscribe = MagicMock() + subscriptions.append((kwargs, unsubscribe)) + return unsubscribe + + matter_client.subscribe_events.side_effect = _subscribe_events + await setup_integration_with_node_fixture(hass, node_fixture, matter_client) + + entity_subscriptions = [ + (kwargs["event_filter"], unsubscribe) + for kwargs, unsubscribe in subscriptions + # Only bound entity methods have an entity_id; other callbacks are skipped. + if getattr(getattr(kwargs["callback"], "__self__", None), "entity_id", None) + == entity_id + ] + assert {event_filter for event_filter, _ in entity_subscriptions} == ( + expected_event_filters + ) + assert not any(unsubscribe.called for _, unsubscribe in entity_subscriptions) + + entity_registry.async_remove(entity_id) + await hass.async_block_till_done() + + assert hass.states.get(entity_id) is None + assert all(unsubscribe.called for _, unsubscribe in entity_subscriptions)