diff --git a/include/kpn/python/auto_bind.hpp b/include/kpn/python/auto_bind.hpp index 12a8bd1..79f9d71 100644 --- a/include/kpn/python/auto_bind.hpp +++ b/include/kpn/python/auto_bind.hpp @@ -247,7 +247,7 @@ void bind_network(nb::module_& m) { nb::class_>(m, "INode"); - nb::class_(m, "Network") + nb::class_(m, "Network", nb::type_slots(network_type_slots())) .def("__init__", [](Net* self) { new (self) Net(); register_all_converters(*self); diff --git a/include/kpn/python/bindings.hpp b/include/kpn/python/bindings.hpp index 57554e9..0a31bfe 100644 --- a/include/kpn/python/bindings.hpp +++ b/include/kpn/python/bindings.hpp @@ -35,6 +35,16 @@ public: using VNode = IVariantNode; using VChannel = IVariantChannel; + // ── 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 + 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 ─────────────────────────────────────────────────────────── void add(std::string name, std::shared_ptr node) { @@ -367,6 +377,14 @@ public: 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: void run_loop() { while (!stop_flag_.load(std::memory_order_relaxed)) { @@ -439,6 +457,60 @@ private: NodeStats stats_; }; +// ── PyNetwork GC helpers (defined here: PyNode is now complete) ──────────────── + +template +template +void PyNetwork::visit_python_objects(Fn&& visit) const { + for (const auto& [name, node] : nodes_) + if (auto* py = dynamic_cast*>(node.get())) + visit(py->python_callable()); +} + +template +void PyNetwork::clear_python_objects() { + for (auto& [name, node] : nodes_) + if (auto* py = dynamic_cast*>(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 +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>(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 +int network_tp_clear(PyObject* self) { + auto* net = nb::inst_ptr>(self); + net->clear_python_objects(); + return 0; +} + +template +PyType_Slot* network_type_slots() { + static PyType_Slot slots[] = { + { Py_tp_traverse, reinterpret_cast(&network_tp_traverse) }, + { Py_tp_clear, reinterpret_cast(&network_tp_clear) }, + { 0, nullptr } + }; + return slots; +} + // ── register_py_network (legacy helper) ─────────────────────────────────────── // Registers PyNetwork with the given nanobind module. // Prefer bind_network from auto_bind.hpp for new code. @@ -447,7 +519,7 @@ template void register_py_network(nb::module_& m, const char* class_name = "Network") { using Net = PyNetwork; - nb::class_(m, class_name) + nb::class_(m, class_name, nb::type_slots(network_type_slots())) .def(nb::init<>()) .def("connect", &Net::connect, nb::arg("src"), nb::arg("out_idx"),