Skip to content

Materialized instance dictionaries are not GC-tracked #133543

Description

@emontnemery

Bug report

Bug description:

Objects can't be garbage collected after accessing __dict__. The issue was noticed on a class with many cached_property which store the cache entries by directly accessing the instance __dict__.

If cached_property is modified by removing this line, the issue goes away:
https://github.com/python/cpython/blob/3.13/Lib/functools.py#L1028

The attached reproduction creates instances of two classes and checks if they are freed or not.

Reproduction of the issue with the attached pytest test cases:

mkdir cached_property_issue
python3 -m venv .venv
source .venv/bin/activate
pip3 install pytest
pytest test_cached_property_issue.py
from __future__ importannotationsfromcollections.abcimportGeneratorfromfunctoolsimportcached_propertyimportgcfromtypingimportAny, Selfimportweakrefimportpytesthass_instances: list[weakref.ref[HackHomeAssistant]] = []
sensor_instances: list[weakref.ReferenceType[HackEntity]] = []
@pytest.fixture(autouse=True, scope="module")defgarbage_collection() ->Generator[None]:
"""Run garbage collection and check instances."""yieldgc.collect()
assert [bool(obj()) forobjinhass_instances] == [False, True]
assert [bool(obj()) forobjinsensor_instances] == [False, True]
classHackEntity:
"""A class with many properties."""entity_id: str|None=Noneplatform=Noneregistry_entry=None_unsub_device_updates=None@cached_propertydefshould_poll(self) ->bool:
returnFalse@cached_propertydefunique_id(self) ->None:
returnNone@cached_propertydefhas_entity_name(self) ->bool:
returnFalse@cached_propertydef_device_class_name(self) ->None:
returnNone@cached_propertydef_unit_of_measurement_translation_key(self) ->None:
returnNone@propertydefname(self) ->None:
returnNone@cached_propertydefcapability_attributes(self) ->None:
returnNone@cached_propertydefstate_attributes(self) ->None:
returnNone@cached_propertydefextra_state_attributes(self) ->None:
returnNone@cached_propertydefdevice_class(self) ->None:
returnNone@cached_propertydefunit_of_measurement(self) ->None:
returnNone@cached_propertydeficon(self) ->None:
returnNone@cached_propertydefentity_picture(self) ->None:
returnNone@cached_propertydefavailable(self) ->bool:
returnTrue@cached_propertydefassumed_state(self) ->bool:
returnFalse@cached_propertydefforce_update(self) ->bool:
returnFalse@cached_propertydefsupported_features(self) ->None:
returnNone@cached_propertydefattribution(self) ->None:
returnNone@cached_propertydeftranslation_key(self) ->None:
returnNone@cached_propertydefoptions(self) ->None:
returnNone@cached_propertydefstate_class(self) ->None:
returnNone@cached_propertydefnative_unit_of_measurement(self) ->None:
returnNone@cached_propertydefsuggested_unit_of_measurement(self) ->None:
returnNone@propertydefstate(self) ->Any:
self._unit_of_measurement_translation_keyself.native_unit_of_measurementself.optionsself.state_classself.suggested_unit_of_measurementself.translation_keyreturnNonedefasync_write_ha_state(self) ->None:
self._verified_state_writable=Trueself.capability_attributesself.availableself.stateself.extra_state_attributesself.state_attributesself.unit_of_measurementself.assumed_stateself.attributionself.device_classself.entity_pictureself.iconself._device_class_nameself.has_entity_nameself.supported_featuresself.force_updatedefasync_on_remove(self) ->None:
self._on_remove= []
defadd_to_platform_start(
self, hass: HackHomeAssistant, platform: HackEntityPlatform
) ->None:
self.hass=hassself.platform=platformclassCompensationSensor(HackEntity):
"""This concrete class won't be garbage collected."""def__init__(self) ->None:
"""Initialize the Compensation sensor."""self.__dict__sensor_instances.append(weakref.ref(self))
self.parallel_updates=Noneself._platform_state=Noneself._state_info= {}
self.async_write_ha_state()
classCompensationSensor2(HackEntity):
"""This concrete class will be garbage collected."""def__init__(self) ->None:
"""Initialize the Compensation sensor."""sensor_instances.append(weakref.ref(self))
self.parallel_updates=Noneself._platform_state=Noneself._state_info= {}
self.async_write_ha_state()
classHackHomeAssistant:
"""Root object of the Home Assistant home automation."""def__new__(cls) ->Self:
"""Set the _hass thread local data."""hass=super().__new__(cls)
hass_instances.append(weakref.ref(hass))
returnhassclassHackEntityPlatform:
"""Manage the entities for a single platform."""def__init__(
self,
hass: HackHomeAssistant,
) ->None:
"""Initialize the entity platform."""self.hass=hassself.entities: dict[str, HackEntity] = {}
defasync_add_entity(
self,
new_entity: HackEntity,
) ->None:
"""Add entities for a single platform async."""entity=new_entityentity.add_to_platform_start(self.hass, self)
entity.unique_identity.entity_id="sensor.test"entity_id=entity.entity_idself.entities[entity_id] =entityentity.async_on_remove()
entity.should_polldeftest_limits1() ->None:
"""Create an object which will be garbage collected."""hass=HackHomeAssistant()
sens=CompensationSensor2()
entity_platform=HackEntityPlatform(hass)
entity_platform.async_add_entity(sens)
deftest_limits2() ->None:
"""Create an object which won't be garbage collected."""hass=HackHomeAssistant()
sens=CompensationSensor()
entity_platform=HackEntityPlatform(hass)
entity_platform.async_add_entity(sens)
deftest_limits3() ->None:
"""Last test, garbage collection check runs after this test."""

CPython versions tested on:

3.13

Operating systems tested on:

Linux

Linked PRs

Metadata

Metadata

Assignees

No one assigned

    Labels

    3.13bugs and security fixesinterpreter-core(Objects, Python, Grammar, and Parser dirs)type-bugAn unexpected behavior, bug, or error

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions