// TODO https://github.com/PyO3/pyo3/issues/5487 #![allow(clippy::undocumented_unsafe_blocks)] #![cfg(feature = "macros")] use pyo3::class::PyTraverseError; use pyo3::class::PyVisit; use pyo3::ffi; use pyo3::prelude::*; #[cfg(not(Py_GIL_DISABLED))] use pyo3::py_run; #[cfg(not(target_arch = "wasm32"))] use std::cell::Cell; use std::sync::atomic::{AtomicBool, AtomicPtr, Ordering}; use std::sync::Once; use std::sync::{Arc, Mutex}; mod test_utils; #[pyclass(freelist = 2)] struct ClassWithFreelist {} #[test] fn class_with_freelist() { let ptr = Python::attach(|py| { let inst = Py::new(py, ClassWithFreelist {}).unwrap(); let _inst2 = Py::new(py, ClassWithFreelist {}).unwrap(); let ptr = inst.as_ptr(); drop(inst); ptr }); Python::attach(|py| { let inst3 = Py::new(py, ClassWithFreelist {}).unwrap(); assert_eq!(ptr, inst3.as_ptr()); let inst4 = Py::new(py, ClassWithFreelist {}).unwrap(); assert_ne!(ptr, inst4.as_ptr()) }); } #[pyclass(freelist = 2)] #[cfg(not(target_arch = "wasm32"))] struct ClassWithFreelistAndData { data: Option, } #[cfg(not(target_arch = "wasm32"))] fn spin_freelist(py: Python<'_>, data: usize) { for _ in 0..500 { let inst1 = Py::new(py, ClassWithFreelistAndData { data: Some(data) }).unwrap(); let inst2 = Py::new(py, ClassWithFreelistAndData { data: Some(data) }).unwrap(); assert_eq!(inst1.borrow(py).data, Some(data)); assert_eq!(inst2.borrow(py).data, Some(data)); } } #[test] #[cfg(not(target_arch = "wasm32"))] fn multithreaded_class_with_freelist() { std::thread::scope(|s| { s.spawn(|| { Python::attach(|py| spin_freelist(py, 12)); }); s.spawn(|| { Python::attach(|py| spin_freelist(py, 0x4d3d3d3)); }); }); } /// Helper function to create a pair of objects that can be used to test drops; /// the first object is a guard that records when it has been dropped, the second /// object is a check that can be used to assert that the guard has been dropped. fn drop_check() -> (DropGuard, DropCheck) { let flag = Arc::new(Once::new()); (DropGuard(flag.clone()), DropCheck(flag)) } /// Helper structure that records when it has been dropped in the cor struct DropGuard(Arc); impl Drop for DropGuard { fn drop(&mut self) { self.0.call_once(|| ()); } } struct DropCheck(Arc); impl DropCheck { #[track_caller] fn assert_not_dropped(&self) { assert!(!self.0.is_completed()); } #[track_caller] fn assert_dropped(&self) { assert!(self.0.is_completed()); } #[track_caller] fn assert_drops_with_gc(&self, object: *mut pyo3::ffi::PyObject) { // running the GC might take a few cycles to collect an object for _ in 0..100 { if self.0.is_completed() { return; } Python::attach(|py| { py.run(c"import gc; gc.collect()", None, None).unwrap(); }); #[cfg(Py_GIL_DISABLED)] { // on the free-threaded build, the GC might be running in a separate thread, allow // some time for this std::thread::sleep(std::time::Duration::from_millis(5)); } } panic!( "Object was not dropped after 100 GC cycles, refcount is {}", // this could be garbage, but it's in a test and we're just printing the value unsafe { ffi::Py_REFCNT(object) } ); } } #[test] fn data_is_dropped() { #[pyclass] struct DataIsDropped { _guard1: DropGuard, _guard2: DropGuard, } let (guard1, check1) = drop_check(); let (guard2, check2) = drop_check(); Python::attach(|py| { let data_is_dropped = DataIsDropped { _guard1: guard1, _guard2: guard2, }; let inst = Py::new(py, data_is_dropped).unwrap(); check1.assert_not_dropped(); check2.assert_not_dropped(); drop(inst); }); check1.assert_dropped(); check2.assert_dropped(); } #[pyclass(subclass)] struct CycleWithClear { cycle: Option>, _guard: DropGuard, } #[pymethods] impl CycleWithClear { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.cycle) } fn __clear__(slf: &Bound<'_, Self>) { slf.borrow_mut().cycle = None; } } #[test] fn test_cycle_clear() { let (guard, check) = drop_check(); let ptr = Python::attach(|py| { let inst = Bound::new( py, CycleWithClear { cycle: None, _guard: guard, }, ) .unwrap(); inst.borrow_mut().cycle = Some(inst.clone().into_any().unbind()); // gc.get_objects can create references to partially initialized objects, // leading to races on the free-threaded build. // see https://github.com/python/cpython/issues/130421#issuecomment-2682924142 #[cfg(not(Py_GIL_DISABLED))] py_run!(py, inst, "import gc; assert inst in gc.get_objects()"); check.assert_not_dropped(); inst.as_ptr() }); check.assert_drops_with_gc(ptr); } /// Test that traversing `None` of `Option>` does not cause a segfault #[test] fn gc_null_traversal() { #[pyclass] struct GcNullTraversal { cycle: Option>, null: Option>, } #[pymethods] impl GcNullTraversal { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.cycle)?; visit.call(&self.null)?; // Should not segfault Ok(()) } fn __clear__(&mut self) { self.cycle = None; self.null = None; } } Python::attach(|py| { let obj = Py::new( py, GcNullTraversal { cycle: None, null: None, }, ) .unwrap(); obj.borrow_mut(py).cycle = Some(obj.clone_ref(py)); // the object doesn't have to be cleaned up, it just needs to be traversed. py.run(c"import gc; gc.collect()", None, None).unwrap(); }); } #[test] fn inheritance_with_new_methods_with_drop() { #[pyclass(subclass)] struct BaseClassWithDrop { guard: Option, } #[pymethods] impl BaseClassWithDrop { #[new] fn new() -> BaseClassWithDrop { BaseClassWithDrop { guard: None } } } #[pyclass(extends = BaseClassWithDrop)] struct SubClassWithDrop { guard: Option, } #[pymethods] impl SubClassWithDrop { #[new] fn new() -> PyClassInitializer { PyClassInitializer::from(BaseClassWithDrop { guard: None }) .add_subclass(SubClassWithDrop { guard: None }) } } let (guard_base, check_base) = drop_check(); let (guard_sub, check_sub) = drop_check(); Python::attach(|py| { let typeobj = py.get_type::(); let inst = typeobj .call((), None) .unwrap() .cast_into::() .unwrap(); inst.as_super().borrow_mut().guard = Some(guard_base); inst.borrow_mut().guard = Some(guard_sub); check_base.assert_not_dropped(); check_sub.assert_not_dropped(); }); check_base.assert_dropped(); check_sub.assert_dropped(); } #[test] fn gc_during_borrow() { #[pyclass] struct TraversableClass { traversed: AtomicBool, } impl TraversableClass { fn new() -> Self { Self { traversed: AtomicBool::new(false), } } } #[pymethods] impl TraversableClass { fn __clear__(&mut self) {} #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.traversed.store(true, Ordering::Relaxed); Ok(()) } } Python::attach(|py| { // get the traverse function let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; // create an object and check that traversing it works normally // when it's not borrowed let cell = Bound::new(py, TraversableClass::new()).unwrap(); assert!(!cell.borrow().traversed.load(Ordering::Relaxed)); unsafe { traverse(cell.as_ptr(), novisit, std::ptr::null_mut()) }; assert!(cell.borrow().traversed.load(Ordering::Relaxed)); // create an object and check that it is not traversed if the GC // is invoked while it is already borrowed mutably let cell2 = Bound::new(py, TraversableClass::new()).unwrap(); let guard = cell2.borrow_mut(); assert!(!guard.traversed.load(Ordering::Relaxed)); unsafe { traverse(cell2.as_ptr(), novisit, std::ptr::null_mut()) }; assert!(!guard.traversed.load(Ordering::Relaxed)); drop(guard); }); } #[test] fn traverse_partial() { #[pyclass] struct PartialTraverse { member: Py, } impl PartialTraverse { fn new(py: Python<'_>) -> Self { Self { member: py.None() } } } #[pymethods] impl PartialTraverse { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.member)?; // In the test, we expect this to never be hit unreachable!() } } Python::attach(|py| { // get the traverse function let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; // confirm that traversing errors let obj = Py::new(py, PartialTraverse::new(py)).unwrap(); assert_eq!( unsafe { traverse(obj.as_ptr(), visit_error, std::ptr::null_mut()) }, -1 ); }) } #[test] #[cfg(panic = "unwind")] fn traverse_panic() { #[pyclass] struct PanickyTraverse { member: Py, } impl PanickyTraverse { fn new(py: Python<'_>) -> Self { Self { member: py.None() } } } #[pymethods] impl PanickyTraverse { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.member)?; panic!("at the disco"); } } Python::attach(|py| { // get the traverse function let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; // confirm that traversing errors let obj = Py::new(py, PanickyTraverse::new(py)).unwrap(); assert_eq!( unsafe { traverse(obj.as_ptr(), novisit, std::ptr::null_mut()) }, -1 ); }) } #[test] #[cfg(panic = "unwind")] fn tries_gil_in_traverse() { #[pyclass] struct TriesGILInTraverse {} #[pymethods] impl TriesGILInTraverse { fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { Python::attach(|_py| Ok(())) } } Python::attach(|py| { // get the traverse function let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; // confirm that traversing panicks let obj = Py::new(py, TriesGILInTraverse {}).unwrap(); assert_eq!( unsafe { traverse(obj.as_ptr(), novisit, std::ptr::null_mut()) }, -1 ); }) } #[test] fn traverse_cannot_be_hijacked() { #[pyclass] struct HijackedTraverse { traversed: AtomicBool, hijacked: AtomicBool, } impl HijackedTraverse { fn new() -> Self { Self { traversed: AtomicBool::new(false), hijacked: AtomicBool::new(false), } } fn traversed_and_hijacked(&self) -> (bool, bool) { ( self.traversed.load(Ordering::Acquire), self.hijacked.load(Ordering::Acquire), ) } } #[pymethods] impl HijackedTraverse { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.traversed.store(true, Ordering::Release); Ok(()) } } #[allow(dead_code, reason = "used to test the trait method is not used")] trait Traversable { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError>; } impl Traversable for PyRef<'_, HijackedTraverse> { fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.hijacked.store(true, Ordering::Release); Ok(()) } } Python::attach(|py| { // get the traverse function let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; let cell = Bound::new(py, HijackedTraverse::new()).unwrap(); assert_eq!(cell.borrow().traversed_and_hijacked(), (false, false)); unsafe { traverse(cell.as_ptr(), novisit, std::ptr::null_mut()) }; assert_eq!(cell.borrow().traversed_and_hijacked(), (true, false)); }) } #[pyclass] struct DropDuringTraversal { cycle: Mutex>>, _guard: DropGuard, } #[pymethods] impl DropDuringTraversal { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { let mut cycle_ref = self.cycle.lock().unwrap(); *cycle_ref = None; Ok(()) } } #[cfg(not(pyo3_disable_reference_pool))] #[test] fn drop_during_traversal_with_gil() { let (guard, check) = drop_check(); let ptr = Python::attach(|py| { let cycle = Mutex::new(None); let inst = Py::new( py, DropDuringTraversal { cycle, _guard: guard, }, ) .unwrap(); *inst.borrow_mut(py).cycle.lock().unwrap() = Some(inst.clone_ref(py)); check.assert_not_dropped(); let ptr = inst.as_ptr(); drop(inst); // drop the object while holding the GIL #[cfg(not(Py_GIL_DISABLED))] { // other thread might have caused GC on free-threaded build check.assert_not_dropped(); } ptr }); check.assert_drops_with_gc(ptr); } #[cfg(not(pyo3_disable_reference_pool))] #[test] fn drop_during_traversal_without_gil() { let (guard, check) = drop_check(); let inst = Python::attach(|py| { let cycle = Mutex::new(None); let inst = Py::new( py, DropDuringTraversal { cycle, _guard: guard, }, ) .unwrap(); *inst.borrow_mut(py).cycle.lock().unwrap() = Some(inst.clone_ref(py)); check.assert_not_dropped(); inst }); let ptr = inst.as_ptr(); drop(inst); // drop the object without holding the GIL check.assert_drops_with_gc(ptr); } #[test] #[cfg(not(target_arch = "wasm32"))] // We are building wasm Python with pthreads disabled fn unsendable_are_not_traversed_on_foreign_thread() { #[pyclass(unsendable)] struct UnsendableTraversal { traversed: Cell, } #[pymethods] impl UnsendableTraversal { fn __clear__(&mut self) {} #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.traversed.set(true); Ok(()) } } #[derive(Clone, Copy)] struct SendablePtr(*mut pyo3::ffi::PyObject); unsafe impl Send for SendablePtr {} Python::attach(|py| { let ty = py.get_type::(); let traverse = unsafe { get_type_traverse(ty.as_type_ptr()).unwrap() }; let obj = Bound::new( py, UnsendableTraversal { traversed: Cell::new(false), }, ) .unwrap(); let ptr = SendablePtr(obj.as_ptr()); std::thread::spawn(move || { // traversal on foreign thread is a no-op assert_eq!( unsafe { traverse({ ptr }.0, novisit, std::ptr::null_mut()) }, 0 ); }) .join() .unwrap(); assert!(!obj.borrow().traversed.get()); // traversal on home thread still works assert_eq!( unsafe { traverse({ ptr }.0, novisit, std::ptr::null_mut()) }, 0 ); assert!(obj.borrow().traversed.get()); }); } #[test] fn test_traverse_subclass() { #[pyclass(extends = CycleWithClear)] struct SubOverrideTraverse {} #[pymethods] impl SubOverrideTraverse { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { // subclass traverse overrides the base class traverse Ok(()) } } let (guard, check) = drop_check(); Python::attach(|py| { let base = CycleWithClear { cycle: None, _guard: guard, }; let obj = Bound::new( py, PyClassInitializer::from(base).add_subclass(SubOverrideTraverse {}), ) .unwrap(); obj.borrow_mut().as_super().cycle = Some(obj.clone().into_any().unbind()); check.assert_not_dropped(); let ptr = obj.as_ptr(); drop(obj); #[cfg(not(Py_GIL_DISABLED))] { // other thread might have caused GC on free-threaded build check.assert_not_dropped(); } check.assert_drops_with_gc(ptr); }); } #[test] fn test_traverse_subclass_override_clear() { #[pyclass(extends = CycleWithClear)] struct SubOverrideClear {} #[pymethods] impl SubOverrideClear { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { // subclass traverse overrides the base class traverse, necessary for // the sub clear to be called // FIXME: should this really need to be the case? Ok(()) } fn __clear__(&self) { // subclass clear overrides the base class clear } } let (guard, check) = drop_check(); Python::attach(|py| { let base = CycleWithClear { cycle: None, _guard: guard, }; let obj = Bound::new( py, PyClassInitializer::from(base).add_subclass(SubOverrideClear {}), ) .unwrap(); obj.borrow_mut().as_super().cycle = Some(obj.clone().into_any().unbind()); check.assert_not_dropped(); let ptr = obj.as_ptr(); drop(obj); #[cfg(not(Py_GIL_DISABLED))] { // other thread might have caused GC on free-threaded build check.assert_not_dropped(); } check.assert_drops_with_gc(ptr); }); } // Manual traversal utilities unsafe fn get_type_traverse(tp: *mut pyo3::ffi::PyTypeObject) -> Option { unsafe { std::mem::transmute(pyo3::ffi::PyType_GetSlot(tp, pyo3::ffi::Py_tp_traverse)) } } // a dummy visitor function extern "C" fn novisit( _object: *mut pyo3::ffi::PyObject, _arg: *mut core::ffi::c_void, ) -> std::ffi::c_int { 0 } // a visitor function which errors (returns nonzero code) extern "C" fn visit_error( _object: *mut pyo3::ffi::PyObject, _arg: *mut core::ffi::c_void, ) -> std::ffi::c_int { -1 } // the fields visited below, set before driving the traversal static BASE_FIELD: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); static CHILD_FIELD: AtomicPtr = AtomicPtr::new(std::ptr::null_mut()); static BASE_VISITED: AtomicBool = AtomicBool::new(false); static CHILD_VISITED: AtomicBool = AtomicBool::new(false); // a visitor function which errors on `BASE_FIELD` only extern "C" fn visit_error_on_base_field( object: *mut pyo3::ffi::PyObject, _arg: *mut core::ffi::c_void, ) -> std::ffi::c_int { if object == CHILD_FIELD.load(Ordering::SeqCst) { CHILD_VISITED.store(true, Ordering::SeqCst); } if object == BASE_FIELD.load(Ordering::SeqCst) { BASE_VISITED.store(true, Ordering::SeqCst); return -1; } 0 } #[test] #[cfg(any(not(Py_LIMITED_API), Py_3_11))] // buffer availability fn test_drop_buffer_during_traversal_without_gil() { use pyo3::buffer::PyBuffer; use pyo3::types::PyBytes; // `PyBuffer` has a drop method which attempts to attach to the Python interpreter, // if the thread is during traverse we leak it for safety. This should _never_ be happening // so it's purely a user bug, but we leak to be safe. #[pyclass] struct BufferDropDuringTraversal { inner: Mutex)>>, cycle: Option>, } #[pymethods] impl BufferDropDuringTraversal { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { self.inner.lock().unwrap().take(); Ok(()) } fn __clear__(&mut self) { self.cycle = None; } } let (guard, check) = drop_check(); Python::attach(|py| { let obj = Py::new( py, BufferDropDuringTraversal { inner: Mutex::new(Some(( guard, PyBuffer::get(&PyBytes::new(py, b"test")).unwrap(), ))), cycle: None, }, ) .unwrap(); obj.borrow_mut(py).cycle = Some(obj.clone_ref(py).into_any()); let ptr = obj.as_ptr(); drop(obj); check.assert_drops_with_gc(ptr); }); } #[test] fn test_super_traverse_early_return_does_not_abort() { #[pyclass(subclass)] struct TraverseBase { field: Py, } #[pymethods] impl TraverseBase { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.field) } } #[pyclass(extends=TraverseBase)] struct TraverseChild { field: Py, } #[pymethods] impl TraverseChild { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { visit.call(&self.field) } } Python::attach(|py| { let base_field = pyo3::types::PyList::empty(py).into_any().unbind(); let child_field = pyo3::types::PyList::empty(py).into_any().unbind(); let initializer = PyClassInitializer::from(TraverseBase { field: base_field.clone_ref(py), }) .add_subclass(TraverseChild { field: child_field.clone_ref(py), }); let child = Bound::new(py, initializer).unwrap(); BASE_FIELD.store(base_field.as_ptr(), Ordering::SeqCst); CHILD_FIELD.store(child_field.as_ptr(), Ordering::SeqCst); let traverse = unsafe { get_type_traverse(py.get_type::().as_type_ptr()).unwrap() }; let retval = unsafe { traverse( child.as_ptr(), visit_error_on_base_field, std::ptr::null_mut(), ) }; assert!( BASE_VISITED.load(Ordering::SeqCst), "super-type traverse never visited its field, so the early return was not exercised" ); assert_eq!( retval, -1, "traverse did not propagate the non-zero return of the super-type traverse" ); assert!( !CHILD_VISITED.load(Ordering::SeqCst), "child __traverse__ ran despite the super-type traverse returning non-zero" ); }); } // A `#[pyclass(dict)]` can form a reference cycle through its instance `__dict__` // (`obj.attr = obj`). The tests below cover each valid combination of user-defined // `__traverse__` / `__clear__`; a `__clear__` without a `__traverse__` is rejected at // type-creation time. #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[test] fn dict_class_is_a_gc_type() { Python::attach(|py| { let ty = py.get_type::(); let flags = unsafe { ffi::PyType_GetFlags(ty.as_type_ptr()) }; assert_ne!(flags & ffi::Py_TPFLAGS_HAVE_GC, 0); }); } /// `#[pyclass(dict)]` with neither `__traverse__` nor `__clear__`: both slots are synthesized. #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[pyclass(dict)] struct DictCycleNoTraverse { _guard: DropGuard, } #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[test] fn dict_cycle_collected_without_traverse() { let (guard, check) = drop_check(); let ptr = Python::attach(|py| { let inst = Bound::new(py, DictCycleNoTraverse { _guard: guard }).unwrap(); // Reference cycle through the instance `__dict__`: inst.__dict__["cycle"] -> inst. inst.setattr("cycle", &inst).unwrap(); check.assert_not_dropped(); inst.as_ptr() }); check.assert_drops_with_gc(ptr); } /// `#[pyclass(dict)]` with `__traverse__` but no `__clear__`: the `__dict__` is visited by /// `_call_traverse` and cleared by a synthesized `tp_clear`. #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[pyclass(dict)] struct DictCycleTraverseOnly { _guard: DropGuard, } #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[pymethods] impl DictCycleTraverseOnly { #[expect(clippy::unnecessary_wraps)] fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> { // No Rust references to visit; the `__dict__` is visited by `_call_traverse` itself. Ok(()) } } #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[test] fn dict_cycle_collected_with_traverse_only() { let (guard, check) = drop_check(); let ptr = Python::attach(|py| { let inst = Bound::new(py, DictCycleTraverseOnly { _guard: guard }).unwrap(); inst.setattr("cycle", &inst).unwrap(); check.assert_not_dropped(); inst.as_ptr() }); check.assert_drops_with_gc(ptr); } /// `#[pyclass(dict)]` with both `__traverse__` and `__clear__`: the `__dict__` is folded into /// the user-defined slots by `_call_traverse` / `_call_clear`. #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[pyclass(dict)] struct DictCycleTraverseAndClear { _guard: DropGuard, field: Option>, } #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[pymethods] impl DictCycleTraverseAndClear { fn __traverse__(&self, visit: PyVisit<'_>) -> Result<(), PyTraverseError> { if let Some(field) = &self.field { visit.call(field)?; } Ok(()) } fn __clear__(&mut self) { self.field = None; } } #[cfg(any(Py_3_9, not(Py_LIMITED_API)))] #[test] fn dict_cycle_collected_with_traverse_and_clear() { let (guard, check) = drop_check(); let ptr = Python::attach(|py| { let inst = Bound::new( py, DictCycleTraverseAndClear { _guard: guard, field: None, }, ) .unwrap(); inst.setattr("cycle", &inst).unwrap(); check.assert_not_dropped(); inst.as_ptr() }); check.assert_drops_with_gc(ptr); }