From 35f9d971c01b88df86d7377a3ef3a911b8c3e796 Mon Sep 17 00:00:00 2001 From: Max Bachmann Date: Wed, 8 Apr 2026 23:55:43 +0200 Subject: [PATCH 1/5] Handle result from PyObject_VisitManagedDict --- include/pybind11/detail/class.h | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 4b7422eee2..6a49dc09de 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -578,7 +578,10 @@ inline PyObject *make_object_base_type(PyTypeObject *metaclass) { /// 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 vret = PyObject_VisitManagedDict(self, visit, arg); + if (vret) { + return vret; + } #else PyObject *&dict = *_PyObject_GetDictPtr(self); Py_VISIT(dict); From 74e6f44d21f50ba80b156b0cf801bde94cde4806 Mon Sep 17 00:00:00 2001 From: Max Bachmann Date: Thu, 9 Apr 2026 01:34:10 +0200 Subject: [PATCH 2/5] add unit test --- tests/test_class.cpp | 7 +++++++ tests/test_class.py | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/tests/test_class.cpp b/tests/test_class.cpp index 2030cd6715..72d73ca93c 100644 --- a/tests/test_class.cpp +++ b/tests/test_class.cpp @@ -104,6 +104,10 @@ TEST_SUBMODULE(class_, m) { ~NoConstructorNew() { print_destroyed(this); } }; + struct DynamicAttr { + DynamicAttr() = default; + }; + py::class_(m, "NoConstructor") .def_static("new_instance", &NoConstructor::new_instance, "Return an instance"); @@ -112,6 +116,9 @@ TEST_SUBMODULE(class_, m) { .def_static("__new__", [](const py::object &) { return NoConstructorNew::new_instance(); }); + py::class_(m, "DynamicAttr", py::dynamic_attr()) + .def(py::init<>()); + // test_pass_unique_ptr struct ToBeHeldByUniquePtr {}; py::class_>(m, "ToBeHeldByUniquePtr") diff --git a/tests/test_class.py b/tests/test_class.py index fae6a31899..ab7876e4c5 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 @@ -45,6 +46,12 @@ def test_instance(msg): assert cstats.alive() == 0 +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__) From de00721ecbd3d6a31bf992b7aacb86a649ed15b7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:21:14 +0000 Subject: [PATCH 3/5] style: pre-commit fixes --- tests/test_class.cpp | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/test_class.cpp b/tests/test_class.cpp index 72d73ca93c..84efb800db 100644 --- a/tests/test_class.cpp +++ b/tests/test_class.cpp @@ -116,8 +116,7 @@ TEST_SUBMODULE(class_, m) { .def_static("__new__", [](const py::object &) { return NoConstructorNew::new_instance(); }); - py::class_(m, "DynamicAttr", py::dynamic_attr()) - .def(py::init<>()); + py::class_(m, "DynamicAttr", py::dynamic_attr()).def(py::init<>()); // test_pass_unique_ptr struct ToBeHeldByUniquePtr {}; From 5a48696f8c4f26c832db86d64954040a60ae272d Mon Sep 17 00:00:00 2001 From: Max Bachmann Date: Sat, 11 Apr 2026 09:11:49 +0200 Subject: [PATCH 4/5] use different variable name This avoids a warning on msvc about Py_Visit shadowing the vret variable. --- include/pybind11/detail/class.h | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/include/pybind11/detail/class.h b/include/pybind11/detail/class.h index 6a49dc09de..8b9d0b8e9d 100644 --- a/include/pybind11/detail/class.h +++ b/include/pybind11/detail/class.h @@ -578,9 +578,9 @@ inline PyObject *make_object_base_type(PyTypeObject *metaclass) { /// 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 - int vret = PyObject_VisitManagedDict(self, visit, arg); - if (vret) { - return vret; + int ret = PyObject_VisitManagedDict(self, visit, arg); + if (ret) { + return ret; } #else PyObject *&dict = *_PyObject_GetDictPtr(self); From e9f51a14afb6542092df40931c61b03fc246dd32 Mon Sep 17 00:00:00 2001 From: "Ralf W. Grosse-Kunstleve" Date: Sat, 11 Apr 2026 13:37:17 -0700 Subject: [PATCH 5/5] 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 --- tests/test_class.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_class.py b/tests/test_class.py index ab7876e4c5..201c7e339e 100644 --- a/tests/test_class.py +++ b/tests/test_class.py @@ -19,6 +19,13 @@ def refcount_immortal(ob: object) -> int: 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 @@ -46,6 +53,10 @@ def test_instance(msg): 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"