Skip to content

Commit 0f50e4d

Browse files
author
Yury Matveev
committed
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.
1 parent 4a77b97 commit 0f50e4d

3 files changed

Lines changed: 50 additions & 0 deletions

File tree

include/pybind11/detail/class.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,15 @@ extern "C" inline void pybind11_object_dealloc(PyObject *self) {
503503
PyObject_GC_UnTrack(self);
504504
}
505505

506+
#if PY_VERSION_HEX >= 0x030D0000
507+
// On Python 3.13+, PyObject_GC_Del no longer implicitly clears the managed
508+
// dict. Without this call, objects stored in __dict__ of py::dynamic_attr()
509+
// types have their refcounts abandoned, causing permanent memory leaks.
510+
if (PyType_HasFeature(type, Py_TPFLAGS_MANAGED_DICT)) {
511+
PyObject_ClearManagedDict(self);
512+
}
513+
#endif
514+
506515
clear_instance(self);
507516

508517
type->tp_free(self);

tests/test_methods_and_attributes.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
#include "constructor_stats.h"
1212
#include "pybind11_tests.h"
1313

14+
#if !defined(PYPY_VERSION)
15+
// Flag set by the capsule destructor in test_dynamic_attr_dealloc_frees_dict_contents.
16+
// File scope so the captureless capsule destructor (void(*)(void*)) can access it.
17+
static bool s_dynamic_attr_capsule_freed = false;
18+
#endif
19+
1420
#if !defined(PYBIND11_OVERLOAD_CAST)
1521
template <typename... Args>
1622
using overload_cast_ = pybind11::detail::overload_cast_impl<Args...>;
@@ -388,6 +394,24 @@ TEST_SUBMODULE(methods_and_attributes, m) {
388394

389395
class CppDerivedDynamicClass : public DynamicClass {};
390396
py::class_<CppDerivedDynamicClass, DynamicClass>(m, "CppDerivedDynamicClass").def(py::init());
397+
398+
// test_dynamic_attr_dealloc_frees_dict_contents
399+
// Regression test: pybind11_object_dealloc() must call PyObject_ClearManagedDict()
400+
// before tp_free() so that objects stored in a py::dynamic_attr() instance __dict__
401+
// have their refcounts decremented when the pybind11 object is freed. On Python 3.14+
402+
// tp_free no longer implicitly clears the managed dict, causing permanent leaks.
403+
m.def("make_dynamic_attr_with_capsule", []() -> py::object {
404+
s_dynamic_attr_capsule_freed = false;
405+
auto *dummy = new int(0);
406+
py::capsule cap(dummy, [](void *ptr) {
407+
delete static_cast<int *>(ptr);
408+
s_dynamic_attr_capsule_freed = true;
409+
});
410+
py::object obj = py::cast(new DynamicClass(), py::return_value_policy::take_ownership);
411+
obj.attr("data") = cap;
412+
return obj;
413+
});
414+
m.def("is_dynamic_attr_capsule_freed", []() { return s_dynamic_attr_capsule_freed; });
391415
#endif
392416

393417
// test_bad_arg_default

tests/test_methods_and_attributes.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,23 @@ def test_cyclic_gc():
383383
assert cstats.alive() == 0
384384

385385

386+
@pytest.mark.xfail("env.PYPY")
387+
@pytest.mark.skipif("env.GRAALPY", reason="Cannot reliably trigger GC")
388+
def test_dynamic_attr_dealloc_frees_dict_contents():
389+
"""Regression: py::dynamic_attr() objects must free __dict__ contents on dealloc.
390+
391+
pybind11_object_dealloc() did not call PyObject_ClearManagedDict() before tp_free(),
392+
causing objects stored in __dict__ to have their refcounts permanently abandoned on
393+
Python 3.14+ (where tp_free no longer implicitly clears the managed dict).
394+
This caused capsule destructors to never run, leaking the underlying C++ data.
395+
"""
396+
instance = m.make_dynamic_attr_with_capsule()
397+
assert not m.is_dynamic_attr_capsule_freed()
398+
del instance
399+
pytest.gc_collect()
400+
assert m.is_dynamic_attr_capsule_freed()
401+
402+
386403
def test_bad_arg_default(msg):
387404
from pybind11_tests import detailed_error_messages_enabled
388405

0 commit comments

Comments
 (0)