Skip to content

Commit cb0a209

Browse files
authored
fix crash when dropping PyBuffer after interpreter finalized (#5242)
* fix crash when dropping `PyBuffer` after interpreter finalized * newsfragment * fixup CI * fixup test cfgs * add more test coverage * fix cfg combinations
1 parent bfbcb6e commit cb0a209

File tree

6 files changed

+174
-2
lines changed

6 files changed

+174
-2
lines changed

newsfragments/5242.fixed.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Fix segfault when dropping `PyBuffer<T>` after the Python interpreter has been finalized.

src/buffer.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -622,7 +622,19 @@ impl<T: Element> PyBuffer<T> {
622622

623623
impl<T> Drop for PyBuffer<T> {
624624
fn drop(&mut self) {
625-
Python::attach(|_| unsafe { ffi::PyBuffer_Release(&mut *self.0) });
625+
fn inner(buf: &mut Pin<Box<ffi::Py_buffer>>) {
626+
if Python::try_attach(|_| unsafe { ffi::PyBuffer_Release(&mut **buf) }).is_none()
627+
&& crate::internal::state::is_in_gc_traversal()
628+
{
629+
eprintln!("Warning: PyBuffer dropped while in GC traversal, this is a bug and will leak memory.");
630+
}
631+
// If `try_attach` failed and `is_in_gc_traversal()` is false, then probably the interpreter has
632+
// already finalized and we can just assume that the underlying memory has already been freed.
633+
//
634+
// So we don't handle that case here.
635+
}
636+
637+
inner(&mut self.0);
626638
}
627639
}
628640

src/internal/state.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,30 @@ impl AttachGuard {
6161
unsafe { Self::acquire_unchecked() }
6262
}
6363

64+
/// Variant of the above which will will return `None` if the interpreter cannot be attached to.
65+
#[cfg(any(not(Py_LIMITED_API), Py_3_11, test))] // see Python::try_attach
66+
pub(crate) fn try_acquire() -> Option<Self> {
67+
match ATTACH_COUNT.try_with(|c| c.get()) {
68+
Ok(i) if i > 0 => {
69+
// SAFETY: We just checked that the thread is already attached.
70+
return Some(unsafe { Self::assume() });
71+
}
72+
// Cannot attach during GC traversal.
73+
Ok(ATTACH_FORBIDDEN_DURING_TRAVERSE) => return None,
74+
// other cases handled below
75+
_ => {}
76+
}
77+
78+
// SAFETY: This API is always sound to call
79+
if unsafe { ffi::Py_IsInitialized() } == 0 {
80+
// If the interpreter is not initialized, we cannot attach.
81+
return None;
82+
}
83+
84+
// SAFETY: We have ensured the Python interpreter is initialized.
85+
Some(unsafe { Self::acquire_unchecked() })
86+
}
87+
6488
/// Acquires the `AttachGuard` without performing any state checking.
6589
///
6690
/// This can be called in "unsafe" contexts where the normal interpreter state
@@ -275,6 +299,14 @@ pub unsafe fn register_decref(obj: NonNull<ffi::PyObject>) {
275299
}
276300
}
277301

302+
/// Private helper function to check if we are currently in a GC traversal (as detected by PyO3).
303+
#[cfg(any(not(Py_LIMITED_API), Py_3_11))]
304+
pub(crate) fn is_in_gc_traversal() -> bool {
305+
ATTACH_COUNT
306+
.try_with(|c| c.get() == ATTACH_FORBIDDEN_DURING_TRAVERSE)
307+
.unwrap_or(false)
308+
}
309+
278310
/// Increments pyo3's internal attach count - to be called whenever an AttachGuard is created.
279311
#[inline(always)]
280312
fn increment_attach_count() {

src/marker.rs

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,22 @@ impl Python<'_> {
419419
f(guard.python())
420420
}
421421

422+
/// Variant of [`Python::attach`] which will do no work if the interpreter is in a
423+
/// state where it cannot be attached to:
424+
/// - in the middle of GC traversal
425+
/// - not initialized
426+
#[inline]
427+
#[track_caller]
428+
#[cfg(any(not(Py_LIMITED_API), Py_3_11, test))] // only used in buffer.rs for now, allow in test cfg for simplicity
429+
// TODO: make this API public?
430+
pub(crate) fn try_attach<F, R>(f: F) -> Option<R>
431+
where
432+
F: for<'py> FnOnce(Python<'py>) -> R,
433+
{
434+
let guard = AttachGuard::try_acquire()?;
435+
Some(f(guard.python()))
436+
}
437+
422438
/// Prepares the use of Python.
423439
///
424440
/// If the Python interpreter is not already initialized, this function will initialize it with
@@ -805,7 +821,10 @@ impl<'unbound> Python<'unbound> {
805821
#[cfg(test)]
806822
mod tests {
807823
use super::*;
808-
use crate::types::{IntoPyDict, PyList};
824+
use crate::{
825+
internal::state::ForbidAttaching,
826+
types::{IntoPyDict, PyList},
827+
};
809828

810829
#[test]
811830
fn test_eval() {
@@ -1012,4 +1031,26 @@ cls.func()
10121031
fn python_is_zst() {
10131032
assert_eq!(std::mem::size_of::<Python<'_>>(), 0);
10141033
}
1034+
1035+
#[test]
1036+
fn test_try_attach_fail_during_gc() {
1037+
Python::attach(|_| {
1038+
assert!(Python::try_attach(|_| {}).is_some());
1039+
1040+
let guard = ForbidAttaching::during_traverse();
1041+
assert!(Python::try_attach(|_| {}).is_none());
1042+
drop(guard);
1043+
1044+
assert!(Python::try_attach(|_| {}).is_some());
1045+
})
1046+
}
1047+
1048+
#[test]
1049+
fn test_try_attach_ok_when_detached() {
1050+
Python::attach(|py| {
1051+
py.detach(|| {
1052+
assert!(Python::try_attach(|_| {}).is_some());
1053+
});
1054+
});
1055+
}
10151056
}

tests/test_gc.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -739,3 +739,55 @@ extern "C" fn visit_error(
739739
) -> std::os::raw::c_int {
740740
-1
741741
}
742+
743+
#[test]
744+
#[cfg(any(not(Py_LIMITED_API), Py_3_11))] // buffer availability
745+
fn test_drop_buffer_during_traversal_without_gil() {
746+
use pyo3::buffer::PyBuffer;
747+
use pyo3::types::PyBytes;
748+
749+
// `PyBuffer` has a drop method which attempts to attach to the Python interpreter,
750+
// if the thread is during traverse we leak it for safety. This should _never_ be happening
751+
// so it's purely a user bug, but we leak to be safe.
752+
753+
#[pyclass]
754+
struct BufferDropDuringTraversal {
755+
inner: Mutex<Option<(DropGuard, PyBuffer<u8>)>>,
756+
cycle: Option<PyObject>,
757+
}
758+
759+
#[pymethods]
760+
impl BufferDropDuringTraversal {
761+
#[allow(clippy::unnecessary_wraps)]
762+
fn __traverse__(&self, _visit: PyVisit<'_>) -> Result<(), PyTraverseError> {
763+
self.inner.lock().unwrap().take();
764+
Ok(())
765+
}
766+
767+
fn __clear__(&mut self) {
768+
self.cycle = None;
769+
}
770+
}
771+
772+
let (guard, check) = drop_check();
773+
Python::attach(|py| {
774+
let obj = Py::new(
775+
py,
776+
BufferDropDuringTraversal {
777+
inner: Mutex::new(Some((
778+
guard,
779+
PyBuffer::get(&PyBytes::new(py, b"test")).unwrap(),
780+
))),
781+
cycle: None,
782+
},
783+
)
784+
.unwrap();
785+
786+
obj.borrow_mut(py).cycle = Some(obj.clone_ref(py).into_any());
787+
788+
let ptr = obj.as_ptr();
789+
drop(obj);
790+
791+
check.assert_drops_with_gc(ptr);
792+
});
793+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
#![cfg(any(not(Py_LIMITED_API), Py_3_11))] // buffer availability
2+
#![cfg(not(any(PyPy, GraalPy)))] // cannot control interpreter lifecycle in PyPy or GraalPy
3+
4+
//! Dropping `Py<T>` after the interpreter has been finalized should be sound.
5+
//!
6+
//! See e.g. https://github.com/PyO3/pyo3/issues/4632 for an extension of this problem
7+
//! where the interpreter was finalized before `PyBuffer<T>` was dropped.
8+
//!
9+
//! This test runs in its own process to control the interpreter lifecycle.
10+
11+
use pyo3::{buffer::PyBuffer, types::PyBytes};
12+
13+
#[test]
14+
fn test_pybuffer_drop_without_interpreter() {
15+
// SAFETY: this is knowingly unsafe as we're preserving the `Py<T>` object
16+
// after the Python interpreter has been finalized.
17+
//
18+
// However we should still be able to drop it without causing undefined behavior,
19+
// so that process shutdown is sound.
20+
let obj: PyBuffer<u8> = unsafe {
21+
pyo3::with_embedded_python_interpreter(|py| {
22+
PyBuffer::get(&PyBytes::new(py, b"abcdef")).unwrap()
23+
})
24+
};
25+
26+
// there should be no interpreter outside of the `with_embedded_python_interpreter` block
27+
assert_eq!(unsafe { pyo3_ffi::Py_IsInitialized() }, 0);
28+
29+
// dropping object should be sound
30+
drop(obj);
31+
32+
// dropping object should not re-initialize the interpreter
33+
assert_eq!(unsafe { pyo3_ffi::Py_IsInitialized() }, 0);
34+
}

0 commit comments

Comments
 (0)