Handle result from PyObject_VisitManagedDict (#6032)

* Handle result from PyObject_VisitManagedDict

* add unit test

* style: pre-commit fixes

* use different variable name

This avoids a warning on msvc about Py_Visit shadowing the vret variable.

* skip test_get_referrers on unsupported runtimes

The managed-dict referrer check is only known to work on CPython 3.13.13+ and 3.14.4+, while earlier releases and non-CPython interpreters can report different traversal behavior.

Made-with: Cursor

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Ralf W. Grosse-Kunstleve <rgrossekunst@nvidia.com>
diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h
index 4b7422e..8b9d0b8 100644
--- a/include/pybind11/detail/class.h
+++ b/include/pybind11/detail/class.h
@@ -578,7 +578,10 @@
 /// dynamic_attr: Allow the garbage collector to traverse the internal instance `__dict__`.
 extern "C" inline int pybind11_traverse(PyObject *self, visitproc visit, void *arg) {
 #if PY_VERSION_HEX >= 0x030D0000
-    PyObject_VisitManagedDict(self, visit, arg);
+    int ret = PyObject_VisitManagedDict(self, visit, arg);
+    if (ret) {
+        return ret;
+    }
 #else
     PyObject *&dict = *_PyObject_GetDictPtr(self);
     Py_VISIT(dict);
diff --git a/tests/test_class.cpp b/tests/test_class.cpp
index 2030cd6..84efb80 100644
--- a/tests/test_class.cpp
+++ b/tests/test_class.cpp
@@ -104,6 +104,10 @@
         ~NoConstructorNew() { print_destroyed(this); }
     };
 
+    struct DynamicAttr {
+        DynamicAttr() = default;
+    };
+
     py::class_<NoConstructor>(m, "NoConstructor")
         .def_static("new_instance", &NoConstructor::new_instance, "Return an instance");
 
@@ -112,6 +116,8 @@
         .def_static("__new__",
                     [](const py::object &) { return NoConstructorNew::new_instance(); });
 
+    py::class_<DynamicAttr>(m, "DynamicAttr", py::dynamic_attr()).def(py::init<>());
+
     // test_pass_unique_ptr
     struct ToBeHeldByUniquePtr {};
     py::class_<ToBeHeldByUniquePtr, std::unique_ptr<ToBeHeldByUniquePtr>>(m, "ToBeHeldByUniquePtr")
diff --git a/tests/test_class.py b/tests/test_class.py
index fae6a31..201c7e3 100644
--- a/tests/test_class.py
+++ b/tests/test_class.py
@@ -1,5 +1,6 @@
 from __future__ import annotations
 
+import gc
 import sys
 from unittest import mock
 
@@ -18,6 +19,13 @@
     return sys.getrefcount(ob)
 
 
+MANAGED_DICT_GET_REFERRERS_SUPPORTED = (
+    env.CPYTHON
+    and sys.version_info >= (3, 13, 13)
+    and (sys.version_info < (3, 14) or sys.version_info >= (3, 14, 4))
+)
+
+
 def test_obj_class_name():
     expected_name = "UserType" if env.PYPY else "pybind11_tests.UserType"
     assert m.obj_class_name(UserType(1)) == expected_name
@@ -45,6 +53,16 @@
     assert cstats.alive() == 0
 
 
+@pytest.mark.skipif(
+    not MANAGED_DICT_GET_REFERRERS_SUPPORTED,
+    reason="Requires CPython 3.13.13+ or 3.14.4+ managed dict traversal support",
+)
+def test_get_referrers():
+    instance = m.DynamicAttr()
+    instance.a = "test"
+    assert instance in gc.get_referrers(instance.__dict__)
+
+
 def test_instance_new():
     instance = m.NoConstructorNew()  # .__new__(m.NoConstructor.__class__)