From 32e0ca886d68a64a053bbb2dfa36734955dea87e Mon Sep 17 00:00:00 2001 From: vaggelisd Date: Tue, 14 Apr 2026 19:40:44 +0300 Subject: [PATCH 1/2] [mypyc] Fix memory leak when borrowing property getter return values `is_native_attr_ref()` uses `has_attr(name) and not get_method(name)` to decide if an attribute access can borrow. `has_attr()` is populated during preparation (always complete), but `get_method()` checks `ir.methods` which is populated during compilation. When cross-module classes haven't been compiled yet, `get_method()` returns None for property getters, incorrectly allowing borrowing. Property getters return new owned references, so skipping the DECREF leaks one reference per call. The fix checks `ir.attributes` directly (struct fields only, always populated during preparation). Properties are never in `ir.attributes`, so they always return False. This is the getter-side counterpart to #21095. --- mypyc/irbuild/builder.py | 3 +-- mypyc/test-data/run-classes.test | 44 ++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/mypyc/irbuild/builder.py b/mypyc/irbuild/builder.py index 5ef8d8594c150..c7f3748e8f225 100644 --- a/mypyc/irbuild/builder.py +++ b/mypyc/irbuild/builder.py @@ -1553,8 +1553,7 @@ def is_native_attr_ref(self, expr: MemberExpr) -> bool: return ( isinstance(obj_rtype, RInstance) and obj_rtype.class_ir.is_ext_class - and obj_rtype.class_ir.has_attr(expr.name) - and not obj_rtype.class_ir.get_method(expr.name) + and any(expr.name in ir.attributes for ir in obj_rtype.class_ir.mro) ) def mark_block_unreachable(self) -> None: diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index 877c40699cdae..b53dde531951b 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -5925,3 +5925,47 @@ assert NonExtDict.BASE == {"x": 1} assert NonExtDict.EXTENDED == {"x": 1, "y": 2} assert NonExtChained.Z == {10, 20, 30} + +[case testPropertyGetterLeak] +class Bar: + pass + +class Foo: + def __init__(self) -> None: + self.obj: object = Bar() + + @property + def val(self) -> object: + return self.obj + +[file other.py] +import gc +from native import Foo, Bar + +def check(foo: Foo) -> bool: + return isinstance(foo.val, Bar) + +def test_property_getter_no_leak() -> None: + foo = Foo() + gc.collect() + before = gc.get_objects() + for _ in range(100): + check(foo) + gc.collect() + after = gc.get_objects() + diff = len(after) - len(before) + assert diff <= 2, diff + +test_property_getter_no_leak() + +[file driver.py] +import sys +from other import check +from native import Foo + +foo = Foo() +init = sys.getrefcount(foo.obj) +for _ in range(100): + check(foo) +after = sys.getrefcount(foo.obj) +assert after - init == 0, f"Leaked {after - init} refs" From a15073a67bb849421f4fade632e3063e6b7456a2 Mon Sep 17 00:00:00 2001 From: vaggelisd Date: Thu, 16 Apr 2026 12:10:17 +0300 Subject: [PATCH 2/2] Move Foo inside of loop --- mypyc/test-data/run-classes.test | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mypyc/test-data/run-classes.test b/mypyc/test-data/run-classes.test index b53dde531951b..44f952916f52c 100644 --- a/mypyc/test-data/run-classes.test +++ b/mypyc/test-data/run-classes.test @@ -5946,10 +5946,10 @@ def check(foo: Foo) -> bool: return isinstance(foo.val, Bar) def test_property_getter_no_leak() -> None: - foo = Foo() gc.collect() before = gc.get_objects() for _ in range(100): + foo = Foo() check(foo) gc.collect() after = gc.get_objects()