From 366886b6bd8d15cfd1f824b33c11b0ce48e6b698 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:13:23 +0000 Subject: [PATCH 1/4] Run a .NET constructor once when it is instantiated from Python Constructing a reflected class from Python ran a constructor twice on the same object whenever the type had a constructor callable with no arguments: tp_new invoked the zero-argument one, then __init__ bound and invoked the matching constructor again. Field initializers allocated twice and constructor side effects (event subscriptions, registrations, counters) happened twice. - tp_new no longer runs a constructor when the type being constructed is the reflected class itself: MetaType.tp_call always follows with __init__, which runs the matching one. - A Python subclass still gets its base constructor in tp_new, before its own __init__ runs, so an __init__ that never calls the base __init__ keeps working. When that __init__ then calls the base __init__() with no arguments, the binder resolves the constructor tp_new already ran, so it is not run a second time. The mark tp_new leaves on the object only lasts for that construction. Co-Authored-By: Claude Opus 5.5 --- src/embed_tests/QCTest.cs | 102 ++++++++++++++++++++++++++++++- src/runtime/MethodBinder.cs | 13 ++++ src/runtime/Types/ClassBase.cs | 8 ++- src/runtime/Types/ClassObject.cs | 14 +++++ src/runtime/Types/ClrObject.cs | 2 + 5 files changed, 135 insertions(+), 4 deletions(-) diff --git a/src/embed_tests/QCTest.cs b/src/embed_tests/QCTest.cs index ea90f96ab..1749cba51 100644 --- a/src/embed_tests/QCTest.cs +++ b/src/embed_tests/QCTest.cs @@ -17,6 +17,11 @@ class QCTests private static dynamic withArgs_PythonSuperInitInt; private static dynamic pureCSharpConstruction; + private static dynamic pureCSharpDefaultConstruction; + private static dynamic pureCSharpOptionalArgsConstruction; + private static dynamic pureCSharpOptionalArgsDefaultConstruction; + private static dynamic pythonSuperInitDefaultAfterOtherConstruction; + private static dynamic reinitAfterConstruction; private static dynamic containsTest; private static dynamic module; @@ -68,6 +73,25 @@ def jose(self): def PureCSharpConstruction(): return SuperInit(1) + +def PureCSharpDefaultConstruction(): + return SuperInit() + +def PureCSharpOptionalArgsConstruction(): + return OptionalArgsInit(5) + +def PureCSharpOptionalArgsDefaultConstruction(): + return OptionalArgsInit() + +class PythonSuperInitDefaultAfterOtherConstruction(SuperInit): + def __init__(self): + self.other = SuperInit() + super().__init__() + +def ReinitAfterConstruction(): + instance = PythonSuperInitNone() + instance.__init__() + return instance "; [OneTimeSetUp] @@ -88,6 +112,11 @@ public void Setup() withArgs_PythonSuperInitInt = pyModule.GetAttr("WithArgs_PythonSuperInitInt"); pureCSharpConstruction = pyModule.GetAttr("PureCSharpConstruction"); + pureCSharpDefaultConstruction = pyModule.GetAttr("PureCSharpDefaultConstruction"); + pureCSharpOptionalArgsConstruction = pyModule.GetAttr("PureCSharpOptionalArgsConstruction"); + pureCSharpOptionalArgsDefaultConstruction = pyModule.GetAttr("PureCSharpOptionalArgsDefaultConstruction"); + pythonSuperInitDefaultAfterOtherConstruction = pyModule.GetAttr("PythonSuperInitDefaultAfterOtherConstruction"); + reinitAfterConstruction = pyModule.GetAttr("ReinitAfterConstruction"); } [OneTimeTearDown] @@ -127,7 +156,63 @@ public void PureCSharpConstruction() { var instance = pureCSharpConstruction(); Assert.AreEqual(1, (int)instance.CalledInt); + Assert.AreEqual(0, (int)instance.CalledDefault); + } + } + + [Test] + public void PureCSharpDefaultConstruction() + { + using (Py.GIL()) + { + var instance = pureCSharpDefaultConstruction(); + Assert.AreEqual(0, (int)instance.CalledInt); + Assert.AreEqual(1, (int)instance.CalledDefault); + } + } + + [Test] + public void PureCSharpOptionalArgsConstruction() + { + using (Py.GIL()) + { + var instance = pureCSharpOptionalArgsConstruction(); + Assert.AreEqual(1, (int)instance.Calls); + Assert.AreEqual(5, (int)instance.Value); + } + } + + [Test] + public void PureCSharpOptionalArgsDefaultConstruction() + { + using (Py.GIL()) + { + var instance = pureCSharpOptionalArgsDefaultConstruction(); + Assert.AreEqual(1, (int)instance.Calls); + Assert.AreEqual(0, (int)instance.Value); + } + } + + [Test] + public void NoArgs_DefaultConstructorAfterOtherConstruction() + { + using (Py.GIL()) + { + var instance = pythonSuperInitDefaultAfterOtherConstruction(); + Assert.AreEqual(0, (int)instance.CalledInt); Assert.AreEqual(1, (int)instance.CalledDefault); + Assert.AreEqual(1, (int)instance.other.CalledDefault); + } + } + + [Test] + public void ReinitAfterConstructionRunsConstructorAgain() + { + using (Py.GIL()) + { + var instance = reinitAfterConstruction(); + Assert.AreEqual(0, (int)instance.CalledInt); + Assert.AreEqual(2, (int)instance.CalledDefault); } } @@ -161,7 +246,7 @@ public void WithArgs_DefaultConstructor() { var instance = withArgs_PythonSuperInitDefault(1); Assert.AreEqual(0, (int)instance.CalledInt); - Assert.AreEqual(2, (int)instance.CalledDefault); + Assert.AreEqual(1, (int)instance.CalledDefault); } } @@ -196,7 +281,7 @@ public void NoArgs_DefaultConstructor() { var instance = pythonSuperInitNone(); Assert.AreEqual(0, (int)instance.CalledInt); - Assert.AreEqual(2, (int)instance.CalledDefault); + Assert.AreEqual(1, (int)instance.CalledDefault); } } @@ -208,7 +293,7 @@ public void NoArgs_NoConstructor() var instance = pythonSuperInitDefault.Invoke(); Assert.AreEqual(0, (int)instance.CalledInt); - Assert.AreEqual(2, (int)instance.CalledDefault); + Assert.AreEqual(1, (int)instance.CalledDefault); } } } @@ -246,6 +331,17 @@ public SuperInit() } } + public class OptionalArgsInit + { + public int Calls { get; private set; } + public int Value { get; private set; } + public OptionalArgsInit(int value = 0) + { + Calls++; + Value = value; + } + } + public class Insight { public string info; diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index 5ec3edc66..732ef492f 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1107,6 +1107,19 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a return default; } + if (binding.info is ConstructorInfo && ManagedType.GetManagedObject(inst) is CLRObject { ranZeroArgCtorInNew: true } co) + { + co.ranZeroArgCtorInNew = false; + // A zero-argument base __init__() call binds the constructor tp_new already ran on this + // Python subclass instance (same type's constructors, same zero arguments): don't run it twice. + if (binding.info.DeclaringType == co.inst.GetType() + && Runtime.PyTuple_Size(args) == 0 + && (kw == null || Runtime.PyDict_Size(kw) == 0)) + { + return new NewReference(Runtime.PyNone); + } + } + if (allow_threads) { ts = PythonEngine.BeginAllowThreads(); diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 3d08f8ca6..5eb5756f7 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -711,7 +711,13 @@ public override bool Init(BorrowedReference obj, BorrowedReference args, Borrowe // initialization must be done in tp_new return true; - return base.Init(obj, args, kw); + var initialized = base.Init(obj, args, kw); + // tp_new's mark only stands in for a base __init__() call made by this construction + if (GetManagedObject(obj) is CLRObject co) + { + co.ranZeroArgCtorInNew = false; + } + return initialized; } protected virtual void OnDeserialization(object sender) diff --git a/src/runtime/Types/ClassObject.cs b/src/runtime/Types/ClassObject.cs index b57378a32..ec5c1a6d9 100644 --- a/src/runtime/Types/ClassObject.cs +++ b/src/runtime/Types/ClassObject.cs @@ -113,6 +113,16 @@ static NewReference tp_new_impl(BorrowedReference tp, BorrowedReference args, Bo object obj = FormatterServices.GetUninitializedObject(type); var pythonObj = self.NewObjectToPython(obj, tp); + // Constructing the reflected type itself always continues with __init__ (MetaType.tp_call), + // which binds and runs the matching constructor: running one here too would run a constructor + // twice on the same object. A Python subclass's __init__ may never call the base __init__, so + // it still gets the base constructor here, before its own __init__ runs; that includes a subclass + // declaring __namespace__, whose generated .NET type is registered as its own reflected type. + if (tp == ClassManager.GetClass(type).Reference && self is not ClassDerivedObject) + { + return pythonObj; + } + try { var binder = new MethodBinder(); @@ -126,6 +136,10 @@ static NewReference tp_new_impl(BorrowedReference tp, BorrowedReference args, Bo if (binding != null) { binding.info.Invoke(obj, BindingFlags.Default, null, binding.args, null); + if (GetManagedObject(pythonObj.Borrow()) is CLRObject co) + { + co.ranZeroArgCtorInNew = true; + } } } catch (Exception) diff --git a/src/runtime/Types/ClrObject.cs b/src/runtime/Types/ClrObject.cs index a45080bf7..160adb85a 100644 --- a/src/runtime/Types/ClrObject.cs +++ b/src/runtime/Types/ClrObject.cs @@ -10,6 +10,8 @@ namespace Python.Runtime internal sealed class CLRObject : ManagedType { internal readonly object inst; + // Set when tp_new ran the zero-argument constructor on this object, before __init__. + internal bool ranZeroArgCtorInNew; static NewReference Create(object ob, BorrowedReference tp) { From 5757cec3e8321376610307848e7e998fac07a0d6 Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 19:44:53 +0000 Subject: [PATCH 2/4] Skip the repeated base constructor for __namespace__ subclasses too A __namespace__ subclass's generated .NET type mirrors each base constructor and chains to it, so the parameterless constructor tp_new runs on it has already run the base type's parameterless one. A zero-argument super().__init__() binds that base constructor, which was then run a second time. Also pin the all-optional-parameter constructor cases for Python subclasses in QCTest. Co-Authored-By: Claude Opus 5.5 --- src/embed_tests/QCTest.cs | 69 +++++++++++++++++++++++++++++++++++++ src/runtime/MethodBinder.cs | 6 +++- 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/src/embed_tests/QCTest.cs b/src/embed_tests/QCTest.cs index 1749cba51..73cd7e046 100644 --- a/src/embed_tests/QCTest.cs +++ b/src/embed_tests/QCTest.cs @@ -22,6 +22,10 @@ class QCTests private static dynamic pureCSharpOptionalArgsDefaultConstruction; private static dynamic pythonSuperInitDefaultAfterOtherConstruction; private static dynamic reinitAfterConstruction; + private static dynamic pythonOptionalArgsSuperInitDefault; + private static dynamic pythonOptionalArgsSuperInitValue; + private static dynamic namespacedSuperInitNone; + private static dynamic namespacedSuperInitDefault; private static dynamic containsTest; private static dynamic module; @@ -88,6 +92,22 @@ def __init__(self): self.other = SuperInit() super().__init__() +class PythonOptionalArgsSuperInitDefault(OptionalArgsInit): + def __init__(self): + super().__init__() + +class PythonOptionalArgsSuperInitValue(OptionalArgsInit): + def __init__(self): + super().__init__(5) + +class NamespacedSuperInitNone(SuperInit): + __namespace__ = 'Python.EmbeddingTest.QCTests' + +class NamespacedSuperInitDefault(SuperInit): + __namespace__ = 'Python.EmbeddingTest.QCTests' + def __init__(self): + super().__init__() + def ReinitAfterConstruction(): instance = PythonSuperInitNone() instance.__init__() @@ -117,6 +137,10 @@ public void Setup() pureCSharpOptionalArgsDefaultConstruction = pyModule.GetAttr("PureCSharpOptionalArgsDefaultConstruction"); pythonSuperInitDefaultAfterOtherConstruction = pyModule.GetAttr("PythonSuperInitDefaultAfterOtherConstruction"); reinitAfterConstruction = pyModule.GetAttr("ReinitAfterConstruction"); + pythonOptionalArgsSuperInitDefault = pyModule.GetAttr("PythonOptionalArgsSuperInitDefault"); + pythonOptionalArgsSuperInitValue = pyModule.GetAttr("PythonOptionalArgsSuperInitValue"); + namespacedSuperInitNone = pyModule.GetAttr("NamespacedSuperInitNone"); + namespacedSuperInitDefault = pyModule.GetAttr("NamespacedSuperInitDefault"); } [OneTimeTearDown] @@ -205,6 +229,51 @@ public void NoArgs_DefaultConstructorAfterOtherConstruction() } } + [Test] + public void OptionalArgs_DefaultConstructor() + { + using (Py.GIL()) + { + var instance = pythonOptionalArgsSuperInitDefault(); + Assert.AreEqual(1, (int)instance.Calls); + Assert.AreEqual(0, (int)instance.Value); + } + } + + [Test] + public void OptionalArgs_ValueConstructor() + { + using (Py.GIL()) + { + var instance = pythonOptionalArgsSuperInitValue(); + // the base constructor runs in tp_new before __init__, then again with the given value + Assert.AreEqual(2, (int)instance.Calls); + Assert.AreEqual(5, (int)instance.Value); + } + } + + [Test] + public void Namespaced_NoInit() + { + using (Py.GIL()) + { + var instance = namespacedSuperInitNone(); + Assert.AreEqual(0, (int)instance.CalledInt); + Assert.AreEqual(1, (int)instance.CalledDefault); + } + } + + [Test] + public void Namespaced_DefaultConstructor() + { + using (Py.GIL()) + { + var instance = namespacedSuperInitDefault(); + Assert.AreEqual(0, (int)instance.CalledInt); + Assert.AreEqual(1, (int)instance.CalledDefault); + } + } + [Test] public void ReinitAfterConstructionRunsConstructorAgain() { diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index 732ef492f..bd37662e6 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1112,7 +1112,11 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a co.ranZeroArgCtorInNew = false; // A zero-argument base __init__() call binds the constructor tp_new already ran on this // Python subclass instance (same type's constructors, same zero arguments): don't run it twice. - if (binding.info.DeclaringType == co.inst.GetType() + // A __namespace__ subclass's generated type mirrors each base constructor and chains to it, so + // its parameterless constructor has already run the base type's parameterless one. + var instType = co.inst.GetType(); + if ((binding.info.DeclaringType == instType + || co.inst is IPythonDerivedType && binding.info.DeclaringType == instType.BaseType && binding.args.Length == 0) && Runtime.PyTuple_Size(args) == 0 && (kw == null || Runtime.PyDict_Size(kw) == 0)) { From 6089cc09f0c8e85ff2f848d37e08d2220a74722c Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:24:08 +0000 Subject: [PATCH 3/4] Keep failed constructions out of the finalizer and match the constructor tp_new ran - tp_new hands out the reflected type's object without running a constructor, so a failed __init__ (no matching overload) left an object with no field initializers run to its finalizer, which could throw and terminate the process. tp_new now suppresses finalization of that object, and the binder registers it again right before a constructor runs on it, as for a .NET 'new'. - The skip of a repeated base constructor compared only the declaring type, so an explicitly selected overload such as super().__init__.__overloads__[System.String]() was skipped too. tp_new now records the constructor it ran, and only that constructor (or, for a __namespace__ subclass, the base parameterless constructor its generated one chains to) is skipped. Co-Authored-By: Claude Opus 5.5 --- src/embed_tests/QCTest.cs | 31 ++++++++++++++++++++++++++ src/runtime/MethodBinder.cs | 38 ++++++++++++++++++++++---------- src/runtime/Types/ClassBase.cs | 5 +++-- src/runtime/Types/ClassObject.cs | 7 +++++- src/runtime/Types/ClrObject.cs | 8 +++++-- src/testing/constructortests.cs | 16 ++++++++++++++ tests/test_constructors.py | 38 ++++++++++++++++++++++++++++++++ 7 files changed, 126 insertions(+), 17 deletions(-) diff --git a/src/embed_tests/QCTest.cs b/src/embed_tests/QCTest.cs index 73cd7e046..b2d22b8dc 100644 --- a/src/embed_tests/QCTest.cs +++ b/src/embed_tests/QCTest.cs @@ -26,6 +26,7 @@ class QCTests private static dynamic pythonOptionalArgsSuperInitValue; private static dynamic namespacedSuperInitNone; private static dynamic namespacedSuperInitDefault; + private static dynamic pythonSelectedOverloadInit; private static dynamic containsTest; private static dynamic module; @@ -34,6 +35,7 @@ from clr import AddReference AddReference(""System"") AddReference(""Python.EmbeddingTest"") from Python.EmbeddingTest import * +import System class PythonModule(Algo): def TestA(self): try: @@ -100,6 +102,10 @@ class PythonOptionalArgsSuperInitValue(OptionalArgsInit): def __init__(self): super().__init__(5) +class PythonSelectedOverloadInit(SelectedOverloadInit): + def __init__(self): + super().__init__.__overloads__[System.String]() + class NamespacedSuperInitNone(SuperInit): __namespace__ = 'Python.EmbeddingTest.QCTests' @@ -141,6 +147,7 @@ public void Setup() pythonOptionalArgsSuperInitValue = pyModule.GetAttr("PythonOptionalArgsSuperInitValue"); namespacedSuperInitNone = pyModule.GetAttr("NamespacedSuperInitNone"); namespacedSuperInitDefault = pyModule.GetAttr("NamespacedSuperInitDefault"); + pythonSelectedOverloadInit = pyModule.GetAttr("PythonSelectedOverloadInit"); } [OneTimeTearDown] @@ -252,6 +259,17 @@ public void OptionalArgs_ValueConstructor() } } + [Test] + public void SelectedOverload_OptionalArgsConstructor() + { + using (Py.GIL()) + { + var instance = pythonSelectedOverloadInit(); + // tp_new ran the parameterless constructor; the explicitly selected one must still run + Assert.AreEqual("optional", (string)instance.Selected); + } + } + [Test] public void Namespaced_NoInit() { @@ -411,6 +429,19 @@ public OptionalArgsInit(int value = 0) } } + public class SelectedOverloadInit + { + public string Selected { get; private set; } + public SelectedOverloadInit() + { + Selected = "default"; + } + public SelectedOverloadInit(string value = "optional") + { + Selected = value; + } + } + public class Insight { public string info; diff --git a/src/runtime/MethodBinder.cs b/src/runtime/MethodBinder.cs index bd37662e6..dc6a366d4 100644 --- a/src/runtime/MethodBinder.cs +++ b/src/runtime/MethodBinder.cs @@ -1107,20 +1107,34 @@ internal virtual NewReference Invoke(BorrowedReference inst, BorrowedReference a return default; } - if (binding.info is ConstructorInfo && ManagedType.GetManagedObject(inst) is CLRObject { ranZeroArgCtorInNew: true } co) + if (binding.info is ConstructorInfo && ManagedType.GetManagedObject(inst) is CLRObject co) { - co.ranZeroArgCtorInNew = false; - // A zero-argument base __init__() call binds the constructor tp_new already ran on this - // Python subclass instance (same type's constructors, same zero arguments): don't run it twice. - // A __namespace__ subclass's generated type mirrors each base constructor and chains to it, so - // its parameterless constructor has already run the base type's parameterless one. - var instType = co.inst.GetType(); - if ((binding.info.DeclaringType == instType - || co.inst is IPythonDerivedType && binding.info.DeclaringType == instType.BaseType && binding.args.Length == 0) - && Runtime.PyTuple_Size(args) == 0 - && (kw == null || Runtime.PyDict_Size(kw) == 0)) + if (co.awaitingCtor) { - return new NewReference(Runtime.PyNone); + // tp_new suppressed finalization until a constructor runs; register it before the + // constructor body, as for a .NET 'new', so the object is finalized even if it throws. + co.awaitingCtor = false; + GC.ReRegisterForFinalize(co.inst); + } + else if (co.ctorRanInNew != null) + { + var ranInNew = co.ctorRanInNew; + co.ctorRanInNew = null; + // A zero-argument base __init__() call that binds the constructor tp_new already ran on this + // Python subclass instance must not run it twice. A __namespace__ subclass's generated type + // mirrors each base constructor and chains to it, so its parameterless constructor has + // already run the base type's parameterless one. + var ranInNewAlready = binding.info == ranInNew + || co.inst is IPythonDerivedType + && binding.info.DeclaringType == ranInNew.DeclaringType.BaseType + && binding.args.Length == 0 + && ranInNew.GetParameters().Length == 0; + if (ranInNewAlready + && Runtime.PyTuple_Size(args) == 0 + && (kw == null || Runtime.PyDict_Size(kw) == 0)) + { + return new NewReference(Runtime.PyNone); + } } } diff --git a/src/runtime/Types/ClassBase.cs b/src/runtime/Types/ClassBase.cs index 5eb5756f7..19d8abf32 100644 --- a/src/runtime/Types/ClassBase.cs +++ b/src/runtime/Types/ClassBase.cs @@ -712,10 +712,11 @@ public override bool Init(BorrowedReference obj, BorrowedReference args, Borrowe return true; var initialized = base.Init(obj, args, kw); - // tp_new's mark only stands in for a base __init__() call made by this construction + // tp_new's marks only apply to this construction's __init__ if (GetManagedObject(obj) is CLRObject co) { - co.ranZeroArgCtorInNew = false; + co.ctorRanInNew = null; + co.awaitingCtor = false; } return initialized; } diff --git a/src/runtime/Types/ClassObject.cs b/src/runtime/Types/ClassObject.cs index ec5c1a6d9..2aa8ad7aa 100644 --- a/src/runtime/Types/ClassObject.cs +++ b/src/runtime/Types/ClassObject.cs @@ -120,6 +120,11 @@ static NewReference tp_new_impl(BorrowedReference tp, BorrowedReference args, Bo // declaring __namespace__, whose generated .NET type is registered as its own reflected type. if (tp == ClassManager.GetClass(type).Reference && self is not ClassDerivedObject) { + GC.SuppressFinalize(obj); + if (GetManagedObject(pythonObj.Borrow()) is CLRObject newObject) + { + newObject.awaitingCtor = true; + } return pythonObj; } @@ -138,7 +143,7 @@ static NewReference tp_new_impl(BorrowedReference tp, BorrowedReference args, Bo binding.info.Invoke(obj, BindingFlags.Default, null, binding.args, null); if (GetManagedObject(pythonObj.Borrow()) is CLRObject co) { - co.ranZeroArgCtorInNew = true; + co.ctorRanInNew = binding.info; } } } diff --git a/src/runtime/Types/ClrObject.cs b/src/runtime/Types/ClrObject.cs index 160adb85a..98ceba8f4 100644 --- a/src/runtime/Types/ClrObject.cs +++ b/src/runtime/Types/ClrObject.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Reflection; using System.Runtime.InteropServices; namespace Python.Runtime @@ -10,8 +11,11 @@ namespace Python.Runtime internal sealed class CLRObject : ManagedType { internal readonly object inst; - // Set when tp_new ran the zero-argument constructor on this object, before __init__. - internal bool ranZeroArgCtorInNew; + // The zero-argument constructor tp_new ran on this object before __init__, if any. + internal MethodBase ctorRanInNew; + // Set when tp_new returned this object without running a constructor: its finalization stays + // suppressed until one runs, so a failed __init__ doesn't leave it to its finalizer uninitialized. + internal bool awaitingCtor; static NewReference Create(object ob, BorrowedReference tp) { diff --git a/src/testing/constructortests.cs b/src/testing/constructortests.cs index 732692b3a..f49d05b6a 100644 --- a/src/testing/constructortests.cs +++ b/src/testing/constructortests.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using System.IO; namespace Python.Test @@ -83,4 +84,19 @@ public class DefaultConstructorMatching public DefaultConstructorMatching() { a = 1; } public DefaultConstructorMatching(double a) { this.a = a; } } + + public class FinalizedConstructorTest + { + private readonly List items = new List(); + + public FinalizedConstructorTest() + { + } + + ~FinalizedConstructorTest() + { + // throws, terminating the process, if no constructor ever ran on this instance + items.Clear(); + } + } } diff --git a/tests/test_constructors.py b/tests/test_constructors.py index 51822d36a..1fea40e47 100644 --- a/tests/test_constructors.py +++ b/tests/test_constructors.py @@ -2,6 +2,8 @@ """Test CLR class constructor support.""" +import os +import subprocess import pytest import sys @@ -104,3 +106,39 @@ def test_string_constructor(): with pytest.raises(TypeError): String(arr, 5, 4) + + +_FAILED_CONSTRUCTION_SCRIPT = """ +import os, sys +bin_path = sys.argv[1] +sys.path.append(bin_path) +from clr_loader import get_coreclr +from pythonnet import set_runtime +set_runtime(get_coreclr(runtime_config=os.path.join(bin_path, "Python.Test.runtimeconfig.json"))) +import clr +clr.AddReference("Python.Test") +from Python.Test import FinalizedConstructorTest +from System import GC +try: + FinalizedConstructorTest("unsupported") +except TypeError: + print("caught") +GC.Collect() +GC.WaitForPendingFinalizers() +GC.Collect() +print("alive") +""" + + +@pytest.mark.skipif(os.environ.get("PYTHONNET_RUNTIME") != "netcore", reason="loads the netcore runtime") +def test_failed_construction_does_not_finalize_uninitialized_object(): + """A failed T(...) must not leave an object whose constructor never ran + to its finalizer, which would terminate the process""" + from Python.Test import FinalizedConstructorTest + + bin_path = os.path.dirname(FinalizedConstructorTest().GetType().Assembly.Location) + result = subprocess.run([sys.executable, "-c", _FAILED_CONSTRUCTION_SCRIPT, bin_path], + capture_output=True, text=True, timeout=300) + + assert result.returncode == 0, result.stderr + assert result.stdout.splitlines()[-2:] == ["caught", "alive"] From 2692e636eec5325e7692076474bd3e79059e2d5f Mon Sep 17 00:00:00 2001 From: "quantconnect-kronos[bot]" <346399058+quantconnect-kronos[bot]@users.noreply.github.com> Date: Thu, 12 Nov 2026 21:53:59 +0000 Subject: [PATCH 4/4] Test that constructed objects are still finalized tp_new suppresses finalization of the reflected type's object until a constructor runs, and the binder re-registers it, so the subprocess test now also asserts that a successfully constructed instance, and one whose constructor throws, are each finalized once. Co-Authored-By: Claude Opus 5.5 --- src/testing/constructortests.cs | 9 +++++++++ tests/test_constructors.py | 36 ++++++++++++++++++++++----------- 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/src/testing/constructortests.cs b/src/testing/constructortests.cs index f49d05b6a..5912853b0 100644 --- a/src/testing/constructortests.cs +++ b/src/testing/constructortests.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.IO; +using System.Threading; namespace Python.Test { @@ -87,16 +88,24 @@ public class DefaultConstructorMatching public class FinalizedConstructorTest { + public static int Finalized; + private readonly List items = new List(); public FinalizedConstructorTest() { } + public FinalizedConstructorTest(Exception toThrow) + { + throw toThrow; + } + ~FinalizedConstructorTest() { // throws, terminating the process, if no constructor ever ran on this instance items.Clear(); + Interlocked.Increment(ref Finalized); } } } diff --git a/tests/test_constructors.py b/tests/test_constructors.py index 1fea40e47..ba9e7cc0d 100644 --- a/tests/test_constructors.py +++ b/tests/test_constructors.py @@ -117,23 +117,35 @@ def test_string_constructor(): set_runtime(get_coreclr(runtime_config=os.path.join(bin_path, "Python.Test.runtimeconfig.json"))) import clr clr.AddReference("Python.Test") +import gc from Python.Test import FinalizedConstructorTest -from System import GC -try: - FinalizedConstructorTest("unsupported") -except TypeError: - print("caught") -GC.Collect() -GC.WaitForPendingFinalizers() -GC.Collect() -print("alive") +from System import GC, InvalidOperationException + +def construct(): + try: + FinalizedConstructorTest("unsupported") + except TypeError: + print("caught") + FinalizedConstructorTest() + try: + FinalizedConstructorTest(InvalidOperationException("thrown")) + except InvalidOperationException: + print("thrown") + +construct() +gc.collect() +for _ in range(2): + GC.Collect() + GC.WaitForPendingFinalizers() +print(FinalizedConstructorTest.Finalized) """ @pytest.mark.skipif(os.environ.get("PYTHONNET_RUNTIME") != "netcore", reason="loads the netcore runtime") -def test_failed_construction_does_not_finalize_uninitialized_object(): +def test_construction_finalization(): """A failed T(...) must not leave an object whose constructor never ran - to its finalizer, which would terminate the process""" + to its finalizer, which would terminate the process, while a constructed + object, or one whose constructor threw, is finalized once, as with new""" from Python.Test import FinalizedConstructorTest bin_path = os.path.dirname(FinalizedConstructorTest().GetType().Assembly.Location) @@ -141,4 +153,4 @@ def test_failed_construction_does_not_finalize_uninitialized_object(): capture_output=True, text=True, timeout=300) assert result.returncode == 0, result.stderr - assert result.stdout.splitlines()[-2:] == ["caught", "alive"] + assert result.stdout.splitlines()[-3:] == ["caught", "thrown", "2"]