Skip to content

Commit fc73c8f

Browse files
Vizonexbdraco
andauthored
Fix Ref Leak (#162)
Co-authored-by: J. Nick Koston <[email protected]>
1 parent 21ef369 commit fc73c8f

4 files changed

Lines changed: 172 additions & 5 deletions

File tree

CHANGES/162.bugfix.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fixed reference leak caused by ``Py_INCREF`` because Cython has its own reference counter systems -- by :user:`Vizonex`.

src/propcache/_helpers_c.pyx

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ cdef extern from "Python.h":
1616
int PyDict_SetItem(
1717
object dict, object key, PyObject* value
1818
) except -1
19-
void Py_INCREF(PyObject*)
19+
void Py_DECREF(PyObject*)
20+
2021

2122
cdef class under_cached_property:
2223
"""Use as a class method decorator. It operates almost exactly like
@@ -46,8 +47,7 @@ cdef class under_cached_property:
4647
if val == NULL:
4748
val = PyObject_CallOneArg(self.wrapped, inst)
4849
PyDict_SetItem(cache, self.name, val)
49-
else:
50-
Py_INCREF(val)
50+
Py_DECREF(val)
5151
return <object>val
5252

5353
def __set__(self, inst, value):
@@ -97,8 +97,7 @@ cdef class cached_property:
9797
if val is NULL:
9898
val = PyObject_CallOneArg(self.func, inst)
9999
PyDict_SetItem(cache, self.name, val)
100-
else:
101-
Py_INCREF(val)
100+
Py_DECREF(val)
102101
return <object>val
103102

104103
__class_getitem__ = classmethod(GenericAlias)

tests/test_cached_property.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import gc
12
import sys
23
from collections.abc import Callable
34
from operator import not_
@@ -7,6 +8,8 @@
78

89
from propcache.api import cached_property
910

11+
IS_PYPY = hasattr(sys, "pypy_version_info")
12+
1013
if sys.version_info >= (3, 11):
1114
from typing import assert_type
1215

@@ -142,3 +145,82 @@ def prop(self) -> int:
142145

143146
a = A()
144147
assert A.prop.func(a) == 1
148+
149+
150+
@pytest.mark.c_extension
151+
@pytest.mark.skipif(IS_PYPY, reason="PyPy has no C extension")
152+
def test_cached_property_no_refcount_leak(propcache_module: APIProtocol) -> None:
153+
"""Test that cached_property does not leak references."""
154+
155+
class CachedPropertySentinel:
156+
"""A unique object we can track."""
157+
158+
def count_sentinels() -> int:
159+
"""Count the number of CachedPropertySentinel instances in gc."""
160+
gc.collect()
161+
return sum(
162+
1 for obj in gc.get_objects() if isinstance(obj, CachedPropertySentinel)
163+
)
164+
165+
class A:
166+
def __init__(self) -> None:
167+
"""Init."""
168+
169+
@propcache_module.cached_property
170+
def prop(self) -> CachedPropertySentinel:
171+
"""Return a sentinel object."""
172+
return CachedPropertySentinel()
173+
174+
initial_sentinel_count = count_sentinels()
175+
176+
a = A()
177+
178+
# First access - creates and caches the object
179+
result = a.prop
180+
# sys.getrefcount returns 1 higher than actual (the temp ref from the call)
181+
# After first access: result owns 1, __dict__ owns 1, getrefcount call owns 1 = 3
182+
initial_refcount = sys.getrefcount(result)
183+
184+
# Should have exactly 1 Sentinel instance now
185+
assert count_sentinels() == initial_sentinel_count + 1
186+
187+
# Second access - should return the cached object without creating new refs
188+
result2 = a.prop
189+
assert result is result2
190+
# After second access: result owns 1, result2 owns 1, __dict__ owns 1, getrefcount call owns 1 = 4
191+
# Only result2 should add 1
192+
second_refcount = sys.getrefcount(result)
193+
assert second_refcount == initial_refcount + 1
194+
195+
# Still should have exactly 1 Sentinel instance
196+
assert count_sentinels() == initial_sentinel_count + 1
197+
198+
# Third access
199+
result3 = a.prop
200+
assert result is result3
201+
# result2 and result3 each add 1
202+
third_refcount = sys.getrefcount(result)
203+
assert third_refcount == initial_refcount + 2
204+
205+
# Clean up local refs - should be back to just result and __dict__
206+
del result2
207+
del result3
208+
after_cleanup_refcount = sys.getrefcount(result)
209+
assert after_cleanup_refcount == initial_refcount
210+
211+
# Clear the cache and verify no leak when re-fetching
212+
# After clearing: only result owns it
213+
del a.__dict__["prop"]
214+
cleared_refcount = sys.getrefcount(result)
215+
assert cleared_refcount == initial_refcount - 1 # No longer in __dict__
216+
217+
# Re-fetch - this should create a new object, not affect old one
218+
result4 = a.prop
219+
assert result4 is not result # Should be a new object
220+
refetch_refcount = sys.getrefcount(result)
221+
assert refetch_refcount == cleared_refcount # Original object refcount unchanged
222+
223+
# Now we should have 2 Sentinel instances:
224+
# - original in `result`
225+
# - new one in `result4`
226+
assert count_sentinels() == initial_sentinel_count + 2

tests/test_under_cached_property.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import gc
12
import sys
23
from collections.abc import Callable
34
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, TypeVar
@@ -6,6 +7,8 @@
67

78
from propcache.api import under_cached_property
89

10+
IS_PYPY = hasattr(sys, "pypy_version_info")
11+
912
if sys.version_info >= (3, 11):
1013
from typing import assert_type
1114

@@ -165,3 +168,85 @@ def prop(self) -> int:
165168

166169
a = A()
167170
assert A.prop.wrapped(a) == 1
171+
172+
173+
@pytest.mark.c_extension
174+
@pytest.mark.skipif(IS_PYPY, reason="PyPy has no C extension")
175+
def test_under_cached_property_no_refcount_leak(propcache_module: APIProtocol) -> None:
176+
"""Test that under_cached_property does not leak references."""
177+
178+
class UnderCachedPropertySentinel:
179+
"""A unique object we can track."""
180+
181+
def count_sentinels() -> int:
182+
"""Count the number of UnderCachedPropertySentinel instances in gc."""
183+
gc.collect()
184+
return sum(
185+
1
186+
for obj in gc.get_objects()
187+
if isinstance(obj, UnderCachedPropertySentinel)
188+
)
189+
190+
class A:
191+
def __init__(self) -> None:
192+
"""Init."""
193+
self._cache: dict[str, Any] = {}
194+
195+
@propcache_module.under_cached_property
196+
def prop(self) -> UnderCachedPropertySentinel:
197+
"""Return a sentinel object."""
198+
return UnderCachedPropertySentinel()
199+
200+
initial_sentinel_count = count_sentinels()
201+
202+
a = A()
203+
204+
# First access - creates and caches the object
205+
result = a.prop
206+
# sys.getrefcount returns 1 higher than actual (the temp ref from the call)
207+
# After first access: result owns 1, _cache owns 1, getrefcount call owns 1 = 3
208+
initial_refcount = sys.getrefcount(result)
209+
210+
# Should have exactly 1 Sentinel instance now
211+
assert count_sentinels() == initial_sentinel_count + 1
212+
213+
# Second access - should return the cached object without creating new refs
214+
result2 = a.prop
215+
assert result is result2
216+
# After second access: result owns 1, result2 owns 1, _cache owns 1, getrefcount call owns 1 = 4
217+
# Only result2 should add 1
218+
second_refcount = sys.getrefcount(result)
219+
assert second_refcount == initial_refcount + 1
220+
221+
# Still should have exactly 1 Sentinel instance
222+
assert count_sentinels() == initial_sentinel_count + 1
223+
224+
# Third access
225+
result3 = a.prop
226+
assert result is result3
227+
# result2 and result3 each add 1
228+
third_refcount = sys.getrefcount(result)
229+
assert third_refcount == initial_refcount + 2
230+
231+
# Clean up local refs - should be back to just result and _cache
232+
del result2
233+
del result3
234+
after_cleanup_refcount = sys.getrefcount(result)
235+
assert after_cleanup_refcount == initial_refcount
236+
237+
# Clear the cache and verify no leak when re-fetching
238+
# After clearing: only result owns it
239+
del a._cache["prop"]
240+
cleared_refcount = sys.getrefcount(result)
241+
assert cleared_refcount == initial_refcount - 1 # No longer in _cache
242+
243+
# Re-fetch - this should create a new object, not affect old one
244+
result4 = a.prop
245+
assert result4 is not result # Should be a new object
246+
refetch_refcount = sys.getrefcount(result)
247+
assert refetch_refcount == cleared_refcount # Original object refcount unchanged
248+
249+
# Now we should have 2 Sentinel instances:
250+
# - original in `result`
251+
# - new one in `result4`
252+
assert count_sentinels() == initial_sentinel_count + 2

0 commit comments

Comments
 (0)