Fix memory leak: clear managed dict in pybind11_object_dealloc on Python 3.13+          (#5999)

* fix: clear managed dict in pybind11_object_dealloc on Python 3.13+

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.

* [tests]: mark test_dynamic_attr_dealloc_frees_dict_contents to be strict=False xfail on PYPY

* [docs]: clarify Python version comments in pybind11_object_dealloc

Distinguish between when the API is available (3.13+, where
PyObject_ClearManagedDict was introduced) and when the leak actually
manifests (3.14+, where tp_free stopped implicitly clearing the
managed dict).

---------

Co-authored-by: Yury Matveev <yury.matveev@desy.de>
diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h
index 480c369..4b7422e 100644
--- a/include/pybind11/detail/class.h
+++ b/include/pybind11/detail/class.h
@@ -503,6 +503,17 @@
         PyObject_GC_UnTrack(self);
     }
 
+#if PY_VERSION_HEX >= 0x030D0000
+    // PyObject_ClearManagedDict() is available from Python 3.13+. It must be
+    // called before tp_free() because on Python 3.14+ tp_free no longer
+    // implicitly clears the managed dict, which would abandon the refcounts of
+    // objects stored in __dict__ of py::dynamic_attr() types, 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 f5fb02d..a72d187 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 <typename... Args>
 using overload_cast_ = pybind11::detail::overload_cast_impl<Args...>;
@@ -388,6 +394,24 @@
 
     class CppDerivedDynamicClass : public DynamicClass {};
     py::class_<CppDerivedDynamicClass, DynamicClass>(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<int *>(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 553d5bf..ef0e7ac 100644
--- a/tests/test_methods_and_attributes.py
+++ b/tests/test_methods_and_attributes.py
@@ -383,6 +383,23 @@
     assert cstats.alive() == 0
 
 
+@pytest.mark.xfail("env.PYPY", strict=False)
+@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