From 4d245e2fb4e7acb255c698ee7ccb358ad8b7554a Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Thu, 16 Oct 2025 14:53:33 +0100 Subject: [PATCH 1/4] ci: enable more tests on 3.14t --- tests/test_buffer_protocol.rs | 18 +++++------ tests/test_class_attributes.rs | 22 +++++++------ tests/test_class_basics.rs | 52 +++++++++++++++--------------- tests/test_exceptions.rs | 37 ++++++++++++---------- tests/test_utils/mod.rs | 58 ++++++++++++++++++++++++++++++---- 5 files changed, 120 insertions(+), 67 deletions(-) diff --git a/tests/test_buffer_protocol.rs b/tests/test_buffer_protocol.rs index 5a2baa33d05..918e100e40c 100644 --- a/tests/test_buffer_protocol.rs +++ b/tests/test_buffer_protocol.rs @@ -95,7 +95,7 @@ fn test_buffer_referenced() { } #[test] -#[cfg(all(Py_3_8, not(Py_GIL_DISABLED)))] // sys.unraisablehook not available until Python 3.8 +#[cfg(all(Py_3_8))] // sys.unraisablehook not available until Python 3.8 fn test_releasebuffer_unraisable_error() { use pyo3::exceptions::PyValueError; use test_utils::UnraisableCapture; @@ -120,20 +120,20 @@ fn test_releasebuffer_unraisable_error() { } Python::attach(|py| { - let capture = UnraisableCapture::install(py); - let instance = Py::new(py, ReleaseBufferError {}).unwrap(); - let env = [("ob", instance.clone_ref(py))].into_py_dict(py).unwrap(); - assert!(capture.borrow(py).capture.is_none()); + let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let env = [("ob", instance.clone_ref(py))].into_py_dict(py).unwrap(); - py_assert!(py, *env, "bytes(ob) == b'hello world'"); + py_assert!(py, *env, "bytes(ob) == b'hello world'"); + Ok(()) + }) + .unwrap() else { + panic!("no unraisable error captured"); + }; - let (err, object) = capture.borrow_mut(py).capture.take().unwrap(); assert_eq!(err.to_string(), "ValueError: oh dear"); assert!(object.is(&instance)); - - capture.borrow_mut(py).uninstall(py); }); } diff --git a/tests/test_class_attributes.rs b/tests/test_class_attributes.rs index e8d104d9d10..29227a0dd4f 100644 --- a/tests/test_class_attributes.rs +++ b/tests/test_class_attributes.rs @@ -151,7 +151,7 @@ fn recursive_class_attributes() { } #[test] -#[cfg(all(Py_3_8, not(Py_GIL_DISABLED)))] // sys.unraisablehook not available until Python 3.8 +#[cfg(all(Py_3_8))] // sys.unraisablehook not available until Python 3.8 fn test_fallible_class_attribute() { use pyo3::exceptions::PyValueError; use test_utils::UnraisableCapture; @@ -168,29 +168,33 @@ fn test_fallible_class_attribute() { } Python::attach(|py| { - let capture = UnraisableCapture::install(py); - assert!(std::panic::catch_unwind(|| py.get_type::()).is_err()); - - let (err, object) = capture.borrow_mut(py).capture.take().unwrap(); - assert!(object.is_none(py)); - + let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + // Accessing the type will attempt to initialize the class attributes + assert!(std::panic::catch_unwind(|| py.get_type::()).is_err()); + Ok(()) + }) + .unwrap() else { + panic!("no unraisable error captured"); + }; + + assert!(object.is_none()); assert_eq!( err.to_string(), "RuntimeError: An error occurred while initializing class BrokenClass" ); + let cause = err.cause(py).unwrap(); assert_eq!( cause.to_string(), "RuntimeError: An error occurred while initializing `BrokenClass.fails_to_init`" ); + let cause = cause.cause(py).unwrap(); assert_eq!( cause.to_string(), "ValueError: failed to create class attribute" ); assert!(cause.cause(py).is_none()); - - capture.borrow_mut(py).uninstall(py); }); } diff --git a/tests/test_class_basics.rs b/tests/test_class_basics.rs index 1dda580c2a4..1ddb1dbb4f9 100644 --- a/tests/test_class_basics.rs +++ b/tests/test_class_basics.rs @@ -615,7 +615,7 @@ fn access_frozen_class_without_gil() { } #[test] -#[cfg(all(Py_3_8, not(Py_GIL_DISABLED)))] // sys.unraisablehook not available until Python 3.8 +#[cfg(all(Py_3_8))] #[cfg_attr(target_arch = "wasm32", ignore)] fn drop_unsendable_elsewhere() { use std::sync::{ @@ -637,35 +637,37 @@ fn drop_unsendable_elsewhere() { } Python::attach(|py| { - let capture = UnraisableCapture::install(py); + let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let dropped = Arc::new(AtomicBool::new(false)); - let dropped = Arc::new(AtomicBool::new(false)); - - let unsendable = Py::new( - py, - Unsendable { - dropped: dropped.clone(), - }, - ) - .unwrap(); - - py.detach(|| { - spawn(move || { - Python::attach(move |_py| { - drop(unsendable); - }); - }) - .join() + let unsendable = Py::new( + py, + Unsendable { + dropped: dropped.clone(), + }, + ) .unwrap(); - }); - assert!(!dropped.load(Ordering::SeqCst)); + py.detach(|| { + spawn(move || { + Python::attach(move |_py| { + drop(unsendable); + }); + }) + .join() + .unwrap(); + }); + + assert!(!dropped.load(Ordering::SeqCst)); + + Ok(()) + }) + .unwrap() else { + panic!("no unraisable error captured"); + }; - let (err, object) = capture.borrow_mut(py).capture.take().unwrap(); assert_eq!(err.to_string(), "RuntimeError: test_class_basics::drop_unsendable_elsewhere::Unsendable is unsendable, but is being dropped on another thread"); - assert!(object.is_none(py)); - - capture.borrow_mut(py).uninstall(py); + assert!(object.is_none()); }); } diff --git a/tests/test_exceptions.rs b/tests/test_exceptions.rs index 97b5466d205..18342f856a7 100644 --- a/tests/test_exceptions.rs +++ b/tests/test_exceptions.rs @@ -98,31 +98,34 @@ fn test_exception_nosegfault() { } #[test] -#[cfg(all(Py_3_8, not(Py_GIL_DISABLED)))] +#[cfg(all(Py_3_8))] fn test_write_unraisable() { - use pyo3::{exceptions::PyRuntimeError, ffi, types::PyNotImplemented}; - use std::ptr; + use pyo3::{exceptions::PyRuntimeError, types::PyNotImplemented}; use test_utils::UnraisableCapture; Python::attach(|py| { - let capture = UnraisableCapture::install(py); + let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let err = PyRuntimeError::new_err("foo"); + err.write_unraisable(py, None); + Ok(()) + }) + .unwrap() else { + panic!("no unraisable error captured"); + }; - assert!(capture.borrow(py).capture.is_none()); - - let err = PyRuntimeError::new_err("foo"); - err.write_unraisable(py, None); - - let (err, object) = capture.borrow_mut(py).capture.take().unwrap(); assert_eq!(err.to_string(), "RuntimeError: foo"); - assert!(object.is_none(py)); + assert!(object.is_none()); - let err = PyRuntimeError::new_err("bar"); - err.write_unraisable(py, Some(&PyNotImplemented::get(py))); + let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let err = PyRuntimeError::new_err("bar"); + err.write_unraisable(py, Some(&PyNotImplemented::get(py))); + Ok(()) + }) + .unwrap() else { + panic!("no unraisable error captured"); + }; - let (err, object) = capture.borrow_mut(py).capture.take().unwrap(); assert_eq!(err.to_string(), "RuntimeError: bar"); - assert!(unsafe { ptr::eq(object.as_ptr(), ffi::Py_NotImplemented()) }); - - capture.borrow_mut(py).uninstall(py); + assert!(object.is(PyNotImplemented::get(py))); }); } diff --git a/tests/test_utils/mod.rs b/tests/test_utils/mod.rs index 235adc2f99d..1e8a20273d2 100644 --- a/tests/test_utils/mod.rs +++ b/tests/test_utils/mod.rs @@ -117,17 +117,59 @@ mod inner { }; } + /// unraisablehook is a global, so only one thread can be using this struct at a time. + static UNRAISABLE_HOOK_MUTEX: Mutex<()> = Mutex::new(()); + // sys.unraisablehook not available until Python 3.8 - #[cfg(all(feature = "macros", Py_3_8, not(Py_GIL_DISABLED)))] + #[cfg(all(feature = "macros", Py_3_8))] + pub struct UnraisableCapture<'py> { + hook: Bound<'py, UnraisableCaptureHook>, + } + + #[cfg(all(feature = "macros", Py_3_8))] + impl<'py> UnraisableCapture<'py> { + pub fn enter( + py: Python<'py>, + f: impl FnOnce() -> PyResult, + ) -> PyResult<(R, Option<(PyErr, Bound<'py, PyAny>)>)> { + // NB this is best-effort, other tests could always modify sys.unraisablehook directly. + let _mutex_guard = UNRAISABLE_HOOK_MUTEX + .lock_py_attached(py) + .unwrap_or_else(PoisonError::into_inner); + + let hook = UnraisableCaptureHook::install(py).into_bound(py); + let guard = Self { hook: hook.clone() }; + let result = f()?; + drop(guard); + + let capture = hook + .borrow_mut() + .capture + .take() + .map(|(e, o)| (e, o.into_bound(py))); + + Ok((result, capture)) + } + } + + #[cfg(all(feature = "macros", Py_3_8))] + impl Drop for UnraisableCapture<'_> { + fn drop(&mut self) { + let py = self.hook.py(); + self.hook.borrow_mut().uninstall(py); + } + } + + #[cfg(all(feature = "macros", Py_3_8))] #[pyclass(crate = "pyo3")] - pub struct UnraisableCapture { + struct UnraisableCaptureHook { pub capture: Option<(PyErr, Py)>, old_hook: Option>, } - #[cfg(all(feature = "macros", Py_3_8, not(Py_GIL_DISABLED)))] + #[cfg(all(feature = "macros", Py_3_8))] #[pymethods(crate = "pyo3")] - impl UnraisableCapture { + impl UnraisableCaptureHook { pub fn hook(&mut self, unraisable: Bound<'_, PyAny>) { let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); let instance = unraisable.getattr("object").unwrap(); @@ -135,15 +177,15 @@ mod inner { } } - #[cfg(all(feature = "macros", Py_3_8, not(Py_GIL_DISABLED)))] - impl UnraisableCapture { + #[cfg(all(feature = "macros", Py_3_8))] + impl UnraisableCaptureHook { pub fn install(py: Python<'_>) -> Py { let sys = py.import("sys").unwrap(); let old_hook = sys.getattr("unraisablehook").unwrap().into(); let capture = Py::new( py, - UnraisableCapture { + UnraisableCaptureHook { capture: None, old_hook: Some(old_hook), }, @@ -170,6 +212,7 @@ mod inner { /// catch_warnings is not thread-safe, so only one thread can be using this struct at /// a time. + #[cfg(not(all(Py_GIL_DISABLED, Py_3_14)))] // Python 3.14t has thread-safe catch_warnings static CATCH_WARNINGS_MUTEX: Mutex<()> = Mutex::new(()); impl<'py> CatchWarnings<'py> { @@ -178,6 +221,7 @@ mod inner { f: impl FnOnce(&Bound<'py, PyList>) -> PyResult, ) -> PyResult { // NB this is best-effort, other tests could always call the warnings API directly. + #[cfg(not(all(Py_GIL_DISABLED, Py_3_14)))] let _mutex_guard = CATCH_WARNINGS_MUTEX .lock_py_attached(py) .unwrap_or_else(PoisonError::into_inner); From 090091af6d995a1068f2d6f247528ae6f4b6412f Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Thu, 16 Oct 2025 20:12:35 +0100 Subject: [PATCH 2/4] simplify implementation of `UnraisableCapture` --- tests/test_buffer_protocol.rs | 14 ++++---- tests/test_class_attributes.rs | 12 +++---- tests/test_class_basics.rs | 11 +++--- tests/test_exceptions.rs | 30 +++++++--------- tests/test_utils/mod.rs | 65 ++++++++++++++++------------------ 5 files changed, 59 insertions(+), 73 deletions(-) diff --git a/tests/test_buffer_protocol.rs b/tests/test_buffer_protocol.rs index 918e100e40c..9af163f515b 100644 --- a/tests/test_buffer_protocol.rs +++ b/tests/test_buffer_protocol.rs @@ -95,7 +95,7 @@ fn test_buffer_referenced() { } #[test] -#[cfg(all(Py_3_8))] // sys.unraisablehook not available until Python 3.8 +#[cfg(Py_3_8)] // sys.unraisablehook not available until Python 3.8 fn test_releasebuffer_unraisable_error() { use pyo3::exceptions::PyValueError; use test_utils::UnraisableCapture; @@ -122,15 +122,15 @@ fn test_releasebuffer_unraisable_error() { Python::attach(|py| { let instance = Py::new(py, ReleaseBufferError {}).unwrap(); - let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let (err, object) = UnraisableCapture::enter(py, |capture| { let env = [("ob", instance.clone_ref(py))].into_py_dict(py).unwrap(); + assert!(capture.take_capture().is_none()); + py_assert!(py, *env, "bytes(ob) == b'hello world'"); - Ok(()) - }) - .unwrap() else { - panic!("no unraisable error captured"); - }; + + capture.take_capture().unwrap() + }); assert_eq!(err.to_string(), "ValueError: oh dear"); assert!(object.is(&instance)); diff --git a/tests/test_class_attributes.rs b/tests/test_class_attributes.rs index 29227a0dd4f..f706b414ff3 100644 --- a/tests/test_class_attributes.rs +++ b/tests/test_class_attributes.rs @@ -151,7 +151,7 @@ fn recursive_class_attributes() { } #[test] -#[cfg(all(Py_3_8))] // sys.unraisablehook not available until Python 3.8 +#[cfg(Py_3_8)] // sys.unraisablehook not available until Python 3.8 fn test_fallible_class_attribute() { use pyo3::exceptions::PyValueError; use test_utils::UnraisableCapture; @@ -168,14 +168,12 @@ fn test_fallible_class_attribute() { } Python::attach(|py| { - let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let (err, object) = UnraisableCapture::enter(py, |capture| { // Accessing the type will attempt to initialize the class attributes assert!(std::panic::catch_unwind(|| py.get_type::()).is_err()); - Ok(()) - }) - .unwrap() else { - panic!("no unraisable error captured"); - }; + + capture.take_capture().unwrap() + }); assert!(object.is_none()); assert_eq!( diff --git a/tests/test_class_basics.rs b/tests/test_class_basics.rs index 1ddb1dbb4f9..83f2d036705 100644 --- a/tests/test_class_basics.rs +++ b/tests/test_class_basics.rs @@ -615,7 +615,7 @@ fn access_frozen_class_without_gil() { } #[test] -#[cfg(all(Py_3_8))] +#[cfg(Py_3_8)] #[cfg_attr(target_arch = "wasm32", ignore)] fn drop_unsendable_elsewhere() { use std::sync::{ @@ -637,7 +637,7 @@ fn drop_unsendable_elsewhere() { } Python::attach(|py| { - let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + let (err, object) = UnraisableCapture::enter(py, |capture| { let dropped = Arc::new(AtomicBool::new(false)); let unsendable = Py::new( @@ -660,11 +660,8 @@ fn drop_unsendable_elsewhere() { assert!(!dropped.load(Ordering::SeqCst)); - Ok(()) - }) - .unwrap() else { - panic!("no unraisable error captured"); - }; + capture.take_capture().unwrap() + }); assert_eq!(err.to_string(), "RuntimeError: test_class_basics::drop_unsendable_elsewhere::Unsendable is unsendable, but is being dropped on another thread"); assert!(object.is_none()); diff --git a/tests/test_exceptions.rs b/tests/test_exceptions.rs index 18342f856a7..d09dd0a1dc2 100644 --- a/tests/test_exceptions.rs +++ b/tests/test_exceptions.rs @@ -98,34 +98,28 @@ fn test_exception_nosegfault() { } #[test] -#[cfg(all(Py_3_8))] +#[cfg(Py_3_8)] fn test_write_unraisable() { use pyo3::{exceptions::PyRuntimeError, types::PyNotImplemented}; use test_utils::UnraisableCapture; Python::attach(|py| { - let ((), Some((err, object))) = UnraisableCapture::enter(py, || { + UnraisableCapture::enter(py, |capture| { let err = PyRuntimeError::new_err("foo"); err.write_unraisable(py, None); - Ok(()) - }) - .unwrap() else { - panic!("no unraisable error captured"); - }; - assert_eq!(err.to_string(), "RuntimeError: foo"); - assert!(object.is_none()); + let (err, object) = capture.take_capture().unwrap(); + + assert_eq!(err.to_string(), "RuntimeError: foo"); + assert!(object.is_none()); - let ((), Some((err, object))) = UnraisableCapture::enter(py, || { let err = PyRuntimeError::new_err("bar"); err.write_unraisable(py, Some(&PyNotImplemented::get(py))); - Ok(()) - }) - .unwrap() else { - panic!("no unraisable error captured"); - }; - - assert_eq!(err.to_string(), "RuntimeError: bar"); - assert!(object.is(PyNotImplemented::get(py))); + + let (err, object) = capture.take_capture().unwrap(); + + assert_eq!(err.to_string(), "RuntimeError: bar"); + assert!(object.is(PyNotImplemented::get(py))); + }); }); } diff --git a/tests/test_utils/mod.rs b/tests/test_utils/mod.rs index 1e8a20273d2..88e8b195ea1 100644 --- a/tests/test_utils/mod.rs +++ b/tests/test_utils/mod.rs @@ -128,27 +128,31 @@ mod inner { #[cfg(all(feature = "macros", Py_3_8))] impl<'py> UnraisableCapture<'py> { - pub fn enter( - py: Python<'py>, - f: impl FnOnce() -> PyResult, - ) -> PyResult<(R, Option<(PyErr, Bound<'py, PyAny>)>)> { + /// Runs the closure `f` with a custom sys.unraisablehook installed. + /// + /// `f` + pub fn enter(py: Python<'py>, f: impl FnOnce(&Self) -> R) -> R { // NB this is best-effort, other tests could always modify sys.unraisablehook directly. - let _mutex_guard = UNRAISABLE_HOOK_MUTEX + let mutex_guard = UNRAISABLE_HOOK_MUTEX .lock_py_attached(py) .unwrap_or_else(PoisonError::into_inner); - let hook = UnraisableCaptureHook::install(py).into_bound(py); - let guard = Self { hook: hook.clone() }; - let result = f()?; + let guard = Self { + hook: UnraisableCaptureHook::install(py), + }; + + let result = f(&guard); + drop(guard); + drop(mutex_guard); - let capture = hook - .borrow_mut() - .capture - .take() - .map(|(e, o)| (e, o.into_bound(py))); + result + } - Ok((result, capture)) + /// Takes the captured unraisable error, if any. + pub fn take_capture(&self) -> Option<(PyErr, Bound<'py, PyAny>)> { + let mut guard = self.hook.get().capture.lock().unwrap(); + guard.take().map(|(e, o)| (e, o.into_bound(self.hook.py()))) } } @@ -156,53 +160,46 @@ mod inner { impl Drop for UnraisableCapture<'_> { fn drop(&mut self) { let py = self.hook.py(); - self.hook.borrow_mut().uninstall(py); + self.hook.get().uninstall(py); } } #[cfg(all(feature = "macros", Py_3_8))] - #[pyclass(crate = "pyo3")] + #[pyclass(crate = "pyo3", frozen)] struct UnraisableCaptureHook { - pub capture: Option<(PyErr, Py)>, - old_hook: Option>, + pub capture: Mutex)>>, + old_hook: Py, } #[cfg(all(feature = "macros", Py_3_8))] #[pymethods(crate = "pyo3")] impl UnraisableCaptureHook { - pub fn hook(&mut self, unraisable: Bound<'_, PyAny>) { + pub fn hook(&self, unraisable: Bound<'_, PyAny>) { let err = PyErr::from_value(unraisable.getattr("exc_value").unwrap()); let instance = unraisable.getattr("object").unwrap(); - self.capture = Some((err, instance.into())); + self.capture.lock().unwrap().replace((err, instance.into())); } } #[cfg(all(feature = "macros", Py_3_8))] impl UnraisableCaptureHook { - pub fn install(py: Python<'_>) -> Py { + fn install(py: Python<'_>) -> Bound<'_, Self> { let sys = py.import("sys").unwrap(); + let old_hook = sys.getattr("unraisablehook").unwrap().into(); + let capture = Mutex::new(None); - let capture = Py::new( - py, - UnraisableCaptureHook { - capture: None, - old_hook: Some(old_hook), - }, - ) - .unwrap(); + let capture = Bound::new(py, UnraisableCaptureHook { capture, old_hook }).unwrap(); - sys.setattr("unraisablehook", capture.getattr(py, "hook").unwrap()) + sys.setattr("unraisablehook", capture.getattr("hook").unwrap()) .unwrap(); capture } - pub fn uninstall(&mut self, py: Python<'_>) { - let old_hook = self.old_hook.take().unwrap(); - + fn uninstall(&self, py: Python<'_>) { let sys = py.import("sys").unwrap(); - sys.setattr("unraisablehook", old_hook).unwrap(); + sys.setattr("unraisablehook", &self.old_hook).unwrap(); } } From 31c17e40dcbdc02711304561b7ff4b6823fd4f16 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 17 Oct 2025 09:41:00 +0100 Subject: [PATCH 3/4] fix imports on 3.14t --- tests/test_utils/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/test_utils/mod.rs b/tests/test_utils/mod.rs index 88e8b195ea1..e154c16372d 100644 --- a/tests/test_utils/mod.rs +++ b/tests/test_utils/mod.rs @@ -18,9 +18,11 @@ mod inner { use pyo3::prelude::*; + #[cfg(any(not(all(Py_GIL_DISABLED, Py_3_14)), all(feature = "macros", Py_3_8)))] use pyo3::sync::MutexExt; use pyo3::types::{IntoPyDict, PyList}; + #[cfg(any(not(all(Py_GIL_DISABLED, Py_3_14)), all(feature = "macros", Py_3_8)))] use std::sync::{Mutex, PoisonError}; use uuid::Uuid; @@ -117,9 +119,6 @@ mod inner { }; } - /// unraisablehook is a global, so only one thread can be using this struct at a time. - static UNRAISABLE_HOOK_MUTEX: Mutex<()> = Mutex::new(()); - // sys.unraisablehook not available until Python 3.8 #[cfg(all(feature = "macros", Py_3_8))] pub struct UnraisableCapture<'py> { @@ -132,6 +131,9 @@ mod inner { /// /// `f` pub fn enter(py: Python<'py>, f: impl FnOnce(&Self) -> R) -> R { + // unraisablehook is a global, so only one thread can be using this struct at a time. + static UNRAISABLE_HOOK_MUTEX: Mutex<()> = Mutex::new(()); + // NB this is best-effort, other tests could always modify sys.unraisablehook directly. let mutex_guard = UNRAISABLE_HOOK_MUTEX .lock_py_attached(py) From e18f1c15bf5e78d8dc08227a1a1b17f7710e14a9 Mon Sep 17 00:00:00 2001 From: David Hewitt Date: Fri, 17 Oct 2025 10:58:29 +0100 Subject: [PATCH 4/4] force object cleanup on 3.14t --- tests/test_class_basics.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/test_class_basics.rs b/tests/test_class_basics.rs index 83f2d036705..dfe7f1ef0a5 100644 --- a/tests/test_class_basics.rs +++ b/tests/test_class_basics.rs @@ -1,5 +1,7 @@ #![cfg(feature = "macros")] +#[cfg(Py_3_8)] +use pyo3::ffi::c_str; use pyo3::prelude::*; use pyo3::types::PyType; use pyo3::{py_run, PyClass}; @@ -650,8 +652,14 @@ fn drop_unsendable_elsewhere() { py.detach(|| { spawn(move || { - Python::attach(move |_py| { + Python::attach(move |py| { drop(unsendable); + // On the free-threaded build, dropping an object on its non-origin thread + // will not immediately drop it because the refcounts need to be merged. + // + // Force GC to ensure the drop happens now on the wrong thread. + py.run(c_str!("import gc; gc.collect()"), None, None) + .unwrap(); }); }) .join()