python: fix nanobind Network reference leak via GC type slots

The Python Network holds each PyNode's callable, forming an
uncollectable instance -> callable -> globals() -> instance cycle that
tripped nanobind's leak check at interpreter shutdown.

Implement tp_traverse/tp_clear type slots on the Network binding so
Python's cyclic collector can see through the C++-held callables and
break the cycle. PyNode exposes its callable; PyNetwork visits and
clears them. Wired into both binding sites (auto_bind and the legacy
register_py_network).
This commit is contained in:
Duncan Tourolle 2026-07-04 14:43:47 +02:00
parent 949c8134ef
commit c4538f03ca
2 changed files with 74 additions and 2 deletions

View File

@ -247,7 +247,7 @@ void bind_network(nb::module_& m) {
nb::class_<IVariantNode<Variant>>(m, "INode"); nb::class_<IVariantNode<Variant>>(m, "INode");
nb::class_<Net>(m, "Network") nb::class_<Net>(m, "Network", nb::type_slots(network_type_slots<Variant>()))
.def("__init__", [](Net* self) { .def("__init__", [](Net* self) {
new (self) Net(); new (self) Net();
register_all_converters<Registry>(*self); register_all_converters<Registry>(*self);

View File

@ -35,6 +35,16 @@ public:
using VNode = IVariantNode<Variant>; using VNode = IVariantNode<Variant>;
using VChannel = IVariantChannel<Variant>; using VChannel = IVariantChannel<Variant>;
// ── GC support ────────────────────────────────────────────────────────────
// Visit every Python object this network transitively holds (currently the
// callable of each PyNode). Used by the Network type's tp_traverse slot so
// Python's cyclic GC can discover instance → callable → globals() cycles.
// Defined out-of-line below, once PyNode is a complete type.
template<typename Fn>
void visit_python_objects(Fn&& visit) const;
// Drop all Python references held by nodes, breaking any cycle (tp_clear).
void clear_python_objects();
// ── Builder API ─────────────────────────────────────────────────────────── // ── Builder API ───────────────────────────────────────────────────────────
void add(std::string name, std::shared_ptr<VNode> node) { void add(std::string name, std::shared_ptr<VNode> node) {
@ -367,6 +377,14 @@ public:
out_channels_[i] = std::move(ch); out_channels_[i] = std::move(ch);
} }
// ── GC support (tp_traverse / tp_clear on the owning Network) ──────────────
// The node holds a Python callable, which typically forms an
// instance → callable → globals() → instance cycle. Expose the callable so
// the Network's GC slots can traverse and clear it. See bindings.hpp's
// network_tp_traverse/network_tp_clear.
const nb::object& python_callable() const { return callable_; }
void clear_python_callable() { callable_ = nb::object(); }
private: private:
void run_loop() { void run_loop() {
while (!stop_flag_.load(std::memory_order_relaxed)) { while (!stop_flag_.load(std::memory_order_relaxed)) {
@ -439,6 +457,60 @@ private:
NodeStats stats_; NodeStats stats_;
}; };
// ── PyNetwork GC helpers (defined here: PyNode is now complete) ────────────────
template<typename Variant>
template<typename Fn>
void PyNetwork<Variant>::visit_python_objects(Fn&& visit) const {
for (const auto& [name, node] : nodes_)
if (auto* py = dynamic_cast<const PyNode<Variant>*>(node.get()))
visit(py->python_callable());
}
template<typename Variant>
void PyNetwork<Variant>::clear_python_objects() {
for (auto& [name, node] : nodes_)
if (auto* py = dynamic_cast<PyNode<Variant>*>(node.get()))
py->clear_python_callable();
}
// ── GC type slots for the Network binding ─────────────────────────────────────
// The Network holds Python callables (via PyNode), forming uncollectable
// instance → callable → globals() → instance cycles at interpreter shutdown.
// These slots let Python's cyclic collector traverse and break them, silencing
// nanobind's leak warnings. See the nanobind "Reference leaks" documentation.
template<typename Variant>
int network_tp_traverse(PyObject* self, visitproc visit, void* arg) {
Py_VISIT(Py_TYPE(self));
if (!nb::inst_ready(self))
return 0;
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
int rv = 0;
net->visit_python_objects([&](const nb::object& obj) {
if (rv == 0 && obj.is_valid())
rv = visit(obj.ptr(), arg);
});
return rv;
}
template<typename Variant>
int network_tp_clear(PyObject* self) {
auto* net = nb::inst_ptr<PyNetwork<Variant>>(self);
net->clear_python_objects();
return 0;
}
template<typename Variant>
PyType_Slot* network_type_slots() {
static PyType_Slot slots[] = {
{ Py_tp_traverse, reinterpret_cast<void*>(&network_tp_traverse<Variant>) },
{ Py_tp_clear, reinterpret_cast<void*>(&network_tp_clear<Variant>) },
{ 0, nullptr }
};
return slots;
}
// ── register_py_network (legacy helper) ─────────────────────────────────────── // ── register_py_network (legacy helper) ───────────────────────────────────────
// Registers PyNetwork<Variant> with the given nanobind module. // Registers PyNetwork<Variant> with the given nanobind module.
// Prefer bind_network<Registry> from auto_bind.hpp for new code. // Prefer bind_network<Registry> from auto_bind.hpp for new code.
@ -447,7 +519,7 @@ template<typename Variant>
void register_py_network(nb::module_& m, const char* class_name = "Network") { void register_py_network(nb::module_& m, const char* class_name = "Network") {
using Net = PyNetwork<Variant>; using Net = PyNetwork<Variant>;
nb::class_<Net>(m, class_name) nb::class_<Net>(m, class_name, nb::type_slots(network_type_slots<Variant>()))
.def(nb::init<>()) .def(nb::init<>())
.def("connect", &Net::connect, .def("connect", &Net::connect,
nb::arg("src"), nb::arg("out_idx"), nb::arg("src"), nb::arg("out_idx"),