Skip to content

Materialized instance dictionaries are not GC-tracked#133543

@emontnemery

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