From 0f50e4df823f1530e8587703a642a3599b0e533e Mon Sep 17 00:00:00 2001 From: Yury Matveev Date: Fri, 27 Feb 2026 13:01:21 +0100 Subject: [PATCH] fix: clear managed dict in pybind11_object_dealloc on Python 3.13+ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Python 3.14, PyObject_GC_Del (tp_free) no longer implicitly clears the managed dict of objects with Py_TPFLAGS_MANAGED_DICT. Without an explicit PyObject_ClearManagedDict() call before tp_free(), objects stored in the __dict__ of py::dynamic_attr() instances have their refcounts permanently abandoned, causing memory leaks — capsule destructors for numpy arrays (and other objects) never run. Adds a regression test: stores a py::capsule in the __dict__ of a DynamicClass instance and asserts the capsule destructor is called when the instance is deleted. --- include/pybind11/detail/class.h | 9 +++++++++ tests/test_methods_and_attributes.cpp | 24 ++++++++++++++++++++++++ tests/test_methods_and_attributes.py | 17 +++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 480c369aa6..9f24f45326 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -503,6 +503,15 @@ extern "C" inline void pybind11_object_dealloc(PyObject *self) { PyObject_GC_UnTrack(self); } +#if PY_VERSION_HEX >= 0x030D0000 + // On Python 3.13+, PyObject_GC_Del no longer implicitly clears the managed + // dict. Without this call, objects stored in __dict__ of py::dynamic_attr() + // types have their refcounts abandoned, causing permanent memory leaks. + if (PyType_HasFeature(type, Py_TPFLAGS_MANAGED_DICT)) { + PyObject_ClearManagedDict(self); + } +#endif + clear_instance(self); type->tp_free(self); diff --git a/tests/test_methods_and_attributes.cpp b/tests/test_methods_and_attributes.cpp index f5fb02d121..a72d18792d 100644 --- a/tests/test_methods_and_attributes.cpp +++ b/tests/test_methods_and_attributes.cpp @@ -11,6 +11,12 @@ #include "constructor_stats.h" #include "pybind11_tests.h" +#if !defined(PYPY_VERSION) +// Flag set by the capsule destructor in test_dynamic_attr_dealloc_frees_dict_contents. +// File scope so the captureless capsule destructor (void(*)(void*)) can access it. +static bool s_dynamic_attr_capsule_freed = false; +#endif + #if !defined(PYBIND11_OVERLOAD_CAST) template using overload_cast_ = pybind11::detail::overload_cast_impl; @@ -388,6 +394,24 @@ TEST_SUBMODULE(methods_and_attributes, m) { class CppDerivedDynamicClass : public DynamicClass {}; py::class_(m, "CppDerivedDynamicClass").def(py::init()); + + // test_dynamic_attr_dealloc_frees_dict_contents + // Regression test: pybind11_object_dealloc() must call PyObject_ClearManagedDict() + // before tp_free() so that objects stored in a py::dynamic_attr() instance __dict__ + // have their refcounts decremented when the pybind11 object is freed. On Python 3.14+ + // tp_free no longer implicitly clears the managed dict, causing permanent leaks. + m.def("make_dynamic_attr_with_capsule", []() -> py::object { + s_dynamic_attr_capsule_freed = false; + auto *dummy = new int(0); + py::capsule cap(dummy, [](void *ptr) { + delete static_cast(ptr); + s_dynamic_attr_capsule_freed = true; + }); + py::object obj = py::cast(new DynamicClass(), py::return_value_policy::take_ownership); + obj.attr("data") = cap; + return obj; + }); + m.def("is_dynamic_attr_capsule_freed", []() { return s_dynamic_attr_capsule_freed; }); #endif // test_bad_arg_default diff --git a/tests/test_methods_and_attributes.py b/tests/test_methods_and_attributes.py index 553d5bfc1b..61a72014bf 100644 --- a/tests/test_methods_and_attributes.py +++ b/tests/test_methods_and_attributes.py @@ -383,6 +383,23 @@ def test_cyclic_gc(): assert cstats.alive() == 0 +@pytest.mark.xfail("env.PYPY") +@pytest.mark.skipif("env.GRAALPY", reason="Cannot reliably trigger GC") +def test_dynamic_attr_dealloc_frees_dict_contents(): + """Regression: py::dynamic_attr() objects must free __dict__ contents on dealloc. + + pybind11_object_dealloc() did not call PyObject_ClearManagedDict() before tp_free(), + causing objects stored in __dict__ to have their refcounts permanently abandoned on + Python 3.14+ (where tp_free no longer implicitly clears the managed dict). + This caused capsule destructors to never run, leaking the underlying C++ data. + """ + instance = m.make_dynamic_attr_with_capsule() + assert not m.is_dynamic_attr_capsule_freed() + del instance + pytest.gc_collect() + assert m.is_dynamic_attr_capsule_freed() + + def test_bad_arg_default(msg): from pybind11_tests import detailed_error_messages_enabled