Skip to content

Commit b726d6a

Browse files
arielb1Ariel Ben-Yehudadavidhewitt
authored
hang instead of pthread_exit during interpreter shutdown (#4874)
* hang instead of pthread_exit during interpreter shutdown see python/cpython#87135 and rust-lang/rust#135929 * relnotes * fix warnings * version using pthread_cleanup_push * add tests * new attempt * clippy * comment * msrv * address review comments * update comment * add comment * try to skip test on debug builds --------- Co-authored-by: Ariel Ben-Yehuda <[email protected]> Co-authored-by: David Hewitt <[email protected]>
1 parent 295e67a commit b726d6a

File tree

5 files changed

+127
-3
lines changed

5 files changed

+127
-3
lines changed

newsfragments/4874.changed.md

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* PyO3 threads now hang instead of `pthread_exit` trying to acquire the GIL when the interpreter is shutting down. This mimics the [Python 3.14](https://github.com/python/cpython/issues/87135) behavior and avoids undefined behavior and crashes.

pyo3-build-config/src/lib.rs

+7-1
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,10 @@ pub fn print_feature_cfgs() {
180180
println!("cargo:rustc-cfg=rustc_has_once_lock");
181181
}
182182

183+
if rustc_minor_version >= 71 {
184+
println!("cargo:rustc-cfg=rustc_has_extern_c_unwind");
185+
}
186+
183187
// invalid_from_utf8 lint was added in Rust 1.74
184188
if rustc_minor_version >= 74 {
185189
println!("cargo:rustc-cfg=invalid_from_utf8_lint");
@@ -226,12 +230,14 @@ pub fn print_expected_cfgs() {
226230
println!("cargo:rustc-check-cfg=cfg(diagnostic_namespace)");
227231
println!("cargo:rustc-check-cfg=cfg(c_str_lit)");
228232
println!("cargo:rustc-check-cfg=cfg(rustc_has_once_lock)");
233+
println!("cargo:rustc-check-cfg=cfg(rustc_has_extern_c_unwind)");
229234
println!("cargo:rustc-check-cfg=cfg(io_error_more)");
230235
println!("cargo:rustc-check-cfg=cfg(fn_ptr_eq)");
231236

232237
// allow `Py_3_*` cfgs from the minimum supported version up to the
233238
// maximum minor version (+1 for development for the next)
234-
for i in impl_::MINIMUM_SUPPORTED_VERSION.minor..=impl_::ABI3_MAX_MINOR + 1 {
239+
// FIXME: support cfg(Py_3_14) as well due to PyGILState_Ensure
240+
for i in impl_::MINIMUM_SUPPORTED_VERSION.minor..=std::cmp::max(14, impl_::ABI3_MAX_MINOR + 1) {
235241
println!("cargo:rustc-check-cfg=cfg(Py_3_{i})");
236242
}
237243
}

pyo3-ffi/src/pystate.rs

+66-2
Original file line numberDiff line numberDiff line change
@@ -80,9 +80,73 @@ pub enum PyGILState_STATE {
8080
PyGILState_UNLOCKED,
8181
}
8282

83+
struct HangThread;
84+
85+
impl Drop for HangThread {
86+
fn drop(&mut self) {
87+
loop {
88+
#[cfg(target_family = "unix")]
89+
unsafe {
90+
libc::pause();
91+
}
92+
#[cfg(not(target_family = "unix"))]
93+
std::thread::sleep(std::time::Duration::from_secs(9_999_999));
94+
}
95+
}
96+
}
97+
98+
// The PyGILState_Ensure function will call pthread_exit during interpreter shutdown,
99+
// which causes undefined behavior. Redirect to the "safe" version that hangs instead,
100+
// as Python 3.14 does.
101+
//
102+
// See https://github.com/rust-lang/rust/issues/135929
103+
104+
// C-unwind only supported (and necessary) since 1.71. Python 3.14+ does not do
105+
// pthread_exit from PyGILState_Ensure (https://github.com/python/cpython/issues/87135).
106+
mod raw {
107+
#[cfg(all(not(Py_3_14), rustc_has_extern_c_unwind))]
108+
extern "C-unwind" {
109+
#[cfg_attr(PyPy, link_name = "PyPyGILState_Ensure")]
110+
pub fn PyGILState_Ensure() -> super::PyGILState_STATE;
111+
}
112+
113+
#[cfg(not(all(not(Py_3_14), rustc_has_extern_c_unwind)))]
114+
extern "C" {
115+
#[cfg_attr(PyPy, link_name = "PyPyGILState_Ensure")]
116+
pub fn PyGILState_Ensure() -> super::PyGILState_STATE;
117+
}
118+
}
119+
120+
#[cfg(not(Py_3_14))]
121+
pub unsafe extern "C" fn PyGILState_Ensure() -> PyGILState_STATE {
122+
let guard = HangThread;
123+
// If `PyGILState_Ensure` calls `pthread_exit`, which it does on Python < 3.14
124+
// when the interpreter is shutting down, this will cause a forced unwind.
125+
// doing a forced unwind through a function with a Rust destructor is unspecified
126+
// behavior.
127+
//
128+
// However, currently it runs the destructor, which will cause the thread to
129+
// hang as it should.
130+
//
131+
// And if we don't catch the unwinding here, then one of our callers probably has a destructor,
132+
// so it's unspecified behavior anyway, and on many configurations causes the process to abort.
133+
//
134+
// The alternative is for pyo3 to contain custom C or C++ code that catches the `pthread_exit`,
135+
// but that's also annoying from a portability point of view.
136+
//
137+
// On Windows, `PyGILState_Ensure` calls `_endthreadex` instead, which AFAICT can't be caught
138+
// and therefore will cause unsafety if there are pinned objects on the stack. AFAICT there's
139+
// nothing we can do it other than waiting for Python 3.14 or not using Windows. At least,
140+
// if there is nothing pinned on the stack, it won't cause the process to crash.
141+
let ret: PyGILState_STATE = raw::PyGILState_Ensure();
142+
std::mem::forget(guard);
143+
ret
144+
}
145+
146+
#[cfg(Py_3_14)]
147+
pub use self::raw::PyGILState_Ensure;
148+
83149
extern "C" {
84-
#[cfg_attr(PyPy, link_name = "PyPyGILState_Ensure")]
85-
pub fn PyGILState_Ensure() -> PyGILState_STATE;
86150
#[cfg_attr(PyPy, link_name = "PyPyGILState_Release")]
87151
pub fn PyGILState_Release(arg1: PyGILState_STATE);
88152
#[cfg(not(PyPy))]

pytests/src/misc.rs

+25
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,30 @@ fn issue_219() {
99
Python::with_gil(|_| {});
1010
}
1111

12+
#[pyclass]
13+
struct LockHolder {
14+
#[allow(unused)]
15+
// Mutex needed for the MSRV
16+
sender: std::sync::Mutex<std::sync::mpsc::Sender<()>>,
17+
}
18+
19+
// This will hammer the GIL once the LockHolder is dropped.
20+
#[pyfunction]
21+
fn hammer_gil_in_thread() -> LockHolder {
22+
let (sender, receiver) = std::sync::mpsc::channel();
23+
std::thread::spawn(move || {
24+
receiver.recv().ok();
25+
// now the interpreter has shut down, so hammer the GIL. In buggy
26+
// versions of PyO3 this will cause a crash.
27+
loop {
28+
Python::with_gil(|_py| ());
29+
}
30+
});
31+
LockHolder {
32+
sender: std::sync::Mutex::new(sender),
33+
}
34+
}
35+
1236
#[pyfunction]
1337
fn get_type_fully_qualified_name<'py>(obj: &Bound<'py, PyAny>) -> PyResult<Bound<'py, PyString>> {
1438
obj.get_type().fully_qualified_name()
@@ -35,6 +59,7 @@ fn get_item_and_run_callback(dict: Bound<'_, PyDict>, callback: Bound<'_, PyAny>
3559
#[pymodule(gil_used = false)]
3660
pub fn misc(m: &Bound<'_, PyModule>) -> PyResult<()> {
3761
m.add_function(wrap_pyfunction!(issue_219, m)?)?;
62+
m.add_function(wrap_pyfunction!(hammer_gil_in_thread, m)?)?;
3863
m.add_function(wrap_pyfunction!(get_type_fully_qualified_name, m)?)?;
3964
m.add_function(wrap_pyfunction!(accepts_bool, m)?)?;
4065
m.add_function(wrap_pyfunction!(get_item_and_run_callback, m)?)?;
+28
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import sysconfig
2+
3+
import pytest
4+
5+
from pyo3_pytests import misc
6+
7+
8+
def make_loop():
9+
# create a reference loop that will only be destroyed when the GC is called at the end
10+
# of execution
11+
start = []
12+
cur = [start]
13+
for _ in range(1000 * 1000 * 10):
14+
cur = [cur]
15+
start.append(cur)
16+
return start
17+
18+
19+
# set a bomb that will explode when modules are cleaned up
20+
loopy = [make_loop()]
21+
22+
23+
@pytest.mark.skipif(
24+
sysconfig.get_config_var("Py_DEBUG"),
25+
reason="causes a crash on debug builds, see discussion in https://github.com/PyO3/pyo3/pull/4874",
26+
)
27+
def test_hammer_gil():
28+
loopy.append(misc.hammer_gil_in_thread())

0 commit comments

Comments
 (0)