|
| 1 | +import gc |
1 | 2 | import sys |
2 | 3 | from collections.abc import Callable |
3 | 4 | from typing import TYPE_CHECKING, Any, Protocol, TypedDict, TypeVar |
|
6 | 7 |
|
7 | 8 | from propcache.api import under_cached_property |
8 | 9 |
|
| 10 | +IS_PYPY = hasattr(sys, "pypy_version_info") |
| 11 | + |
9 | 12 | if sys.version_info >= (3, 11): |
10 | 13 | from typing import assert_type |
11 | 14 |
|
@@ -165,3 +168,85 @@ def prop(self) -> int: |
165 | 168 |
|
166 | 169 | a = A() |
167 | 170 | 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