Fix entity removal leaks in matter (#183864)

This commit is contained in:
Erik Montnemery
2026-10-01 11:04:35 +02:00
committed by GitHub
parent dfea4b8e5e
commit f3a622f7db
4 changed files with 69 additions and 8 deletions
+5 -6
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+62
View File
@@ -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)