From 5d64310756dfb7a508b21836bc676d8144357b2f Mon Sep 17 00:00:00 2001 From: greateggsgreg Date: Sat, 9 May 2026 16:17:39 -0400 Subject: [PATCH] Propagate exceptions from TryGetMember in tp_getattro_dlr_proxy The dynamic getter swallowed any exception from TryGetMember and returned default to Python with the prior AttributeError still set, so user code observed a misleading AttributeError instead of the real failure. Set a Python exception in the catch arm. We use RuntimeError with the message string rather than Converter.ToPython(e) because wrapping the CLR exception object can trigger type initialisation that re-enters this same slot on the live dynamic object, producing infinite recursion. Mirrors the symmetry already present in the setter (#2706 review, @lostmsu) and adds a regression test alongside the existing ThrowingSetDynamicObject coverage. --- src/runtime/TypeManager.cs | 7 ++++++- src/testing/dlrtest.cs | 6 ++++++ tests/test_dynamic.py | 9 +++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/runtime/TypeManager.cs b/src/runtime/TypeManager.cs index 30a99690a..3b3c6db1a 100644 --- a/src/runtime/TypeManager.cs +++ b/src/runtime/TypeManager.cs @@ -123,8 +123,13 @@ public static NewReference tp_getattro_dlr_proxy(BorrowedReference ob, BorrowedR { resolved = dynamicMemberAccessor.TryGetMember(dynamicObject, memberName, out value); } - catch + catch (Exception e) { + // Avoid wrapping the CLR exception via Converter.ToPython here: that would trigger + // CLR type initialisation which can re-enter this slot on the same live object, + // causing infinite recursion. A plain RuntimeError with the message is safe. + Runtime.PyErr_Clear(); + Exceptions.SetError(Exceptions.RuntimeError, e.Message); return default; } diff --git a/src/testing/dlrtest.cs b/src/testing/dlrtest.cs index b58f5fc82..783a3a133 100644 --- a/src/testing/dlrtest.cs +++ b/src/testing/dlrtest.cs @@ -62,6 +62,12 @@ public override bool TrySetMember(SetMemberBinder binder, object value) } } +public class ThrowingGetDynamicObject : DynamicStorageObject +{ + public override bool TryGetMember(GetMemberBinder binder, out object result) + => throw new InvalidOperationException($"TryGetMember failed for '{binder.Name}'"); +} + public class ThrowingSetDynamicObject : DynamicStorageObject { public override bool TrySetMember(SetMemberBinder binder, object value) diff --git a/tests/test_dynamic.py b/tests/test_dynamic.py index b8caa24b3..f093ee19a 100644 --- a/tests/test_dynamic.py +++ b/tests/test_dynamic.py @@ -8,6 +8,7 @@ from Python.Test import RejectingDeleteDynamicObject from Python.Test import RejectingSetDynamicObject from Python.Test import ThrowingDeleteDynamicObject +from Python.Test import ThrowingGetDynamicObject from Python.Test import ThrowingSetDynamicObject @@ -186,6 +187,14 @@ def test_trysetmember_false_raises_attributeerror_instead_of_silent_python_setat assert not hasattr(obj, "typoed_name") +def test_trygetmember_exception_is_raised_in_python(): + obj = ThrowingGetDynamicObject() + obj.AddDynamicMember("any_key", 1) + + with pytest.raises(Exception, match="TryGetMember failed for 'any_key'"): + _ = obj.any_key + + def test_trysetmember_exception_is_raised_in_python(): obj = ThrowingSetDynamicObject()