Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions include/pybind11/detail/class.h
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
24 changes: 24 additions & 0 deletions tests/test_methods_and_attributes.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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...>;
Expand Down Expand Up @@ -388,6 +394,24 @@ TEST_SUBMODULE(methods_and_attributes, m) {

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
Expand Down
17 changes: 17 additions & 0 deletions tests/test_methods_and_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this be xfail strict=False for flakey tests?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Skipping it is fine if passing vs. failing doesn't mean anything. If you hope it always passes some day, then yes.

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

Expand Down