Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Unify C API thread VM tracking
  • Loading branch information
youknowone committed May 5, 2026
commit 3e65cecea1c60fa2303454117bda99a67eadbe9f
7 changes: 0 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,6 @@ rustls-platform-verifier = "0.7"
rustyline = "18"
serde = { package = "serde_core", version = "1.0.225", default-features = false, features = ["alloc"] }
schannel = "0.1.29"
scoped-tls = "1"
scopeguard = "1"
sha-1 = "0.10.0"
sha2 = "0.10.2"
Expand Down
91 changes: 39 additions & 52 deletions crates/capi/src/pystate.rs
Original file line number Diff line number Diff line change
@@ -1,34 +1,14 @@
use crate::pylifecycle::request_vm_from_interpreter;
use core::cell::RefCell;
use core::ffi::c_int;
use core::ptr;
use rustpython_vm::VirtualMachine;
use rustpython_vm::vm::thread::{ThreadedVirtualMachine, VM_CURRENT, with_current_vm};

thread_local! {
static VM: RefCell<Option<ThreadedVirtualMachine>> = const { RefCell::new(None) };
}

#[allow(dead_code)]
pub(crate) fn with_vm<R>(f: impl FnOnce(&VirtualMachine) -> R) -> R {
if VM_CURRENT.is_set() {
// We have an active VM set, so use that.
with_current_vm(f)
} else {
// We do not have an active vm running in this thread. Let's use our own.
// This will panic if `PyGILState_Ensure` was not called beforehand.
VM.with(|vm_ref| {
let vm = vm_ref.borrow();
let vm = vm
.as_ref()
.expect("Thread was not attached to an interpreter");
vm.run(|vm| f(vm))
})
}
}
use rustpython_vm::vm::thread::{
CurrentVmAttachState, attach_current_thread, release_current_thread,
};

#[allow(non_camel_case_types)]
type PyGILState_STATE = c_int;
const PYGILSTATE_LOCKED: PyGILState_STATE = 0;
const PYGILSTATE_UNLOCKED: PyGILState_STATE = 1;

#[repr(C)]
pub struct PyThreadState {
Expand All @@ -37,24 +17,24 @@ pub struct PyThreadState {

/// Make sure this thread has a running vm attached. This only creates a new vm if we don't already
/// have one. So this will only create a new vm when we are in a new thread created outside RustPython.
pub(crate) fn ensure_thread_has_vm_attached() {
if !VM_CURRENT.is_set() {
VM.with(|vm| {
vm.borrow_mut()
.get_or_insert_with(request_vm_from_interpreter);
});
}
pub(crate) fn ensure_thread_has_vm_attached() -> CurrentVmAttachState {
attach_current_thread(request_vm_from_interpreter)
}

#[unsafe(no_mangle)]
pub extern "C" fn PyGILState_Ensure() -> PyGILState_STATE {
ensure_thread_has_vm_attached();

0
match ensure_thread_has_vm_attached() {
CurrentVmAttachState::AlreadyAttached => PYGILSTATE_LOCKED,
CurrentVmAttachState::Attached => PYGILSTATE_UNLOCKED,
}
}

#[unsafe(no_mangle)]
pub extern "C" fn PyGILState_Release(_state: PyGILState_STATE) {}
pub extern "C" fn PyGILState_Release(state: PyGILState_STATE) {
if state == PYGILSTATE_UNLOCKED {
release_current_thread(CurrentVmAttachState::Attached);
}
}

#[unsafe(no_mangle)]
pub extern "C" fn PyEval_SaveThread() -> *mut PyThreadState {
Expand All @@ -64,25 +44,25 @@ pub extern "C" fn PyEval_SaveThread() -> *mut PyThreadState {
#[cfg(test)]
mod tests {
use crate::get_main_interpreter;
use crate::pystate::{VM, with_vm};
use crate::pystate::{PyGILState_Ensure, PyGILState_Release};
use pyo3::prelude::*;
use rustpython_vm::vm::thread::VM_CURRENT;
use rustpython_vm::vm::thread::{current_vm_is_set, with_current_vm};

#[test]
fn test_new_thread() {
Python::attach(|_py| {
with_vm(|_vm| {
with_current_vm(|_vm| {
assert!(
VM_CURRENT.is_set(),
current_vm_is_set(),
"This thread did not have a vm attached"
)
});

std::thread::spawn(move || {
Python::attach(|_py| {
with_vm(|_vm| {
with_current_vm(|_vm| {
assert!(
VM_CURRENT.is_set(),
current_vm_is_set(),
"This thread did not have a vm attached"
)
});
Expand All @@ -97,9 +77,6 @@ mod tests {
fn test_current_vm_main_thread() {
Python::initialize();

// Detach vm from thread, because initialize attaches one automatically.
VM.with(|vm| vm.borrow_mut().take());

// let RustPython create a vm for this thread.
let vm = get_main_interpreter()
.as_ref()
Expand All @@ -108,17 +85,27 @@ mod tests {

// Attach the vm using RustPython
vm.run(|_vm| {
assert!(VM_CURRENT.is_set(), "This thread should have a vm attached");
assert!(current_vm_is_set(), "This thread should have a vm attached");

Python::attach(|_py| {
with_vm(|_vm| {
assert!(VM_CURRENT.is_set());
assert!(
VM.with(|vm| vm.borrow().is_none()),
"We should not create a new vm when there is already a vm active"
)
with_current_vm(|_vm| {
assert!(current_vm_is_set());
})
})
});
}

#[test]
fn test_gilstate_release_detaches_external_thread() {
Python::initialize();

std::thread::spawn(|| {
let state = PyGILState_Ensure();
assert!(current_vm_is_set());
PyGILState_Release(state);
assert!(!current_vm_is_set());
})
.join()
.unwrap();
}
}
1 change: 0 additions & 1 deletion crates/vm/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ num-traits = { workspace = true }
num_enum = { workspace = true }
parking_lot = { workspace = true }
paste = { workspace = true }
scoped-tls = { workspace = true }
scopeguard = { workspace = true }
serde = { workspace = true, optional = true }
static_assertions = { workspace = true }
Expand Down
149 changes: 119 additions & 30 deletions crates/vm/src/vm/thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,20 @@ pub type CurrentFrameSlot = Arc<ThreadSlot>;
thread_local! {
pub(super) static VM_STACK: RefCell<Vec<NonNull<VirtualMachine>>> = Vec::with_capacity(1).into();

/// Thread state created through the GILState-style C API.
///
/// This is separate from the current VM stack: it only means "attached now",
/// while this owns the per-thread VM that may be detached and re-attached.
/// Despite the historical CPython "GILState" name, this does not model a
/// GIL; it stores the VM used by that compatibility API.
///
/// The Box keeps the VM address stable while VM_STACK holds a raw pointer to it.
/// This matters when release_current_thread() moves the owner out of TLS and
/// drops it while the VM is still current, so object destructors can still find
/// their VM.
#[cfg(feature = "threading")]
static GILSTATE_VM: RefCell<Option<Box<ThreadedVirtualMachine>>> = const { RefCell::new(None) };

pub(crate) static COROUTINE_ORIGIN_TRACKING_DEPTH: Cell<u32> = const { Cell::new(0) };

/// Current thread's slot for sys._current_frames() and sys._current_exceptions()
Expand All @@ -66,42 +80,114 @@ thread_local! {

}

scoped_tls::scoped_thread_local!(pub static VM_CURRENT: VirtualMachine);
#[must_use]
pub fn current_vm_is_set() -> bool {
VM_STACK.with(|vms| !vms.borrow().is_empty())
}

pub fn with_current_vm<R>(f: impl FnOnce(&VirtualMachine) -> R) -> R {
if !VM_CURRENT.is_set() {
panic!("call with_current_vm() but VM_CURRENT is null");
}
VM_CURRENT.with(f)
VM_STACK.with(|vms| {
let vm = vms
.borrow()
.last()
.copied()
.expect("call with_current_vm() but no current VM is attached");
// SAFETY: entries in VM_STACK either borrow a VM for the dynamic
// scope of a set_current_vm()/enter_vm() call or point at GILSTATE_VM.
f(unsafe { vm.as_ref() })
})
}

pub fn enter_vm<R>(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R {
fn set_current_vm<R>(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R {
VM_STACK.with(|vms| {
// Outermost enter_vm: transition DETACHED → ATTACHED
#[cfg(all(unix, feature = "threading"))]
let was_outermost = vms.borrow().is_empty();

vms.borrow_mut().push(vm.into());
scopeguard::defer! {
vms.borrow_mut().pop();
}
f()
})
}

// Initialize thread slot for this thread if not already done
#[cfg(feature = "threading")]
init_thread_slot_if_needed(vm);
pub fn enter_vm<R>(vm: &VirtualMachine, f: impl FnOnce() -> R) -> R {
// Outermost enter_vm: transition DETACHED → ATTACHED
#[cfg(all(unix, feature = "threading"))]
let was_outermost = !current_vm_is_set();

// Initialize thread slot for this thread if not already done
#[cfg(feature = "threading")]
init_thread_slot_if_needed(vm);

#[cfg(all(unix, feature = "threading"))]
if was_outermost {
attach_thread(vm);
}

scopeguard::defer! {
// Outermost exit: transition ATTACHED → DETACHED
#[cfg(all(unix, feature = "threading"))]
if was_outermost {
attach_thread(vm);
detach_thread();
}
}
set_current_vm(vm, f)
}

scopeguard::defer! {
// Outermost exit: transition ATTACHED → DETACHED
#[cfg(all(unix, feature = "threading"))]
if vms.borrow().len() == 1 {
detach_thread();
}
vms.borrow_mut().pop();
}
VM_CURRENT.set(vm, f)
})
#[cfg(feature = "threading")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum CurrentVmAttachState {
AlreadyAttached,
Attached,
}

/// Attach the current native thread to a RustPython VM until
/// `release_current_thread()` is called.
#[cfg(feature = "threading")]
pub fn attach_current_thread(
make_vm: impl FnOnce() -> ThreadedVirtualMachine,
) -> CurrentVmAttachState {
if current_vm_is_set() {
return CurrentVmAttachState::AlreadyAttached;
}

GILSTATE_VM.with(|gilstate_vm| {
let mut gilstate_vm = gilstate_vm.borrow_mut();
let threaded_vm = gilstate_vm.get_or_insert_with(|| Box::new(make_vm()));
let vm = &threaded_vm.vm;

vm.c_stack_soft_limit
.set(VirtualMachine::calculate_c_stack_soft_limit());

init_thread_slot_if_needed(vm);

#[cfg(unix)]
attach_thread(vm);

VM_STACK.with(|vms| {
debug_assert!(vms.borrow().is_empty());
vms.borrow_mut().push(vm.into());
});
});

CurrentVmAttachState::Attached
}

#[cfg(feature = "threading")]
pub fn release_current_thread(state: CurrentVmAttachState) {
if state == CurrentVmAttachState::AlreadyAttached {
return;
}

let gilstate_vm = GILSTATE_VM.with(|gilstate_vm| gilstate_vm.borrow_mut().take());
drop(gilstate_vm);

VM_STACK.with(|vms| {
vms.borrow_mut()
.pop()
.expect("release_current_thread() called without an attached VM");
});

#[cfg(unix)]
detach_thread();
Comment on lines +175 to +190

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In Rust, if a raw pointer is copied from a Boxand theBox is dropped while that raw pointer is still stored elsewhere, is it valid to dereference that pointer during or after the drop of the boxed value?

💡 Result:

No. If you copy a raw pointer derived from a Box and then drop the Box while that pointer is still stored elsewhere, dereferencing the pointer during or after the drop is invalid (it triggers undefined behavior). [1][2] Reasoning (grounded in Rust’s rules for pointers and “dangling”): 1) Raw pointers have no automatic lifetime/liveness guarantees; copying/dropping the pointer itself doesn’t keep the referent alive. [3] 2) A pointer is “dangling” if it is null or if not all bytes it points to are part of the same live allocation; dereferencing a dangling pointer is one of the ways Rust characterizes incorrect behavior. [1][4] 3) After Box is dropped, the heap allocation backing it is deallocated; the raw pointer no longer has provenance/dereferenceability for non-zero-sized accesses, so reads through it require that the pointer be valid for reads to the relevant memory range, which it won’t be after free. [1][2][5] Practical takeaway: if you need a pointer that remains dereferenceable beyond the Box’s drop, you must ensure the allocation stays alive (e.g., by transferring ownership via Box::into_raw and later reconstructing via Box::from_raw), rather than dropping the Box while retaining a raw pointer to its contents. [6]

Citations:


🏁 Script executed:

cat -n crates/vm/src/vm/thread.rs | sed -n '175,190p'

Repository: RustPython/RustPython

Length of output: 624


🏁 Script executed:

# Search for destructor implementations (Drop trait) that might call with_current_vm
rg -A 10 'impl.*Drop.*for.*ThreadedVirtualMachine|impl.*Drop.*for.*VirtualMachine' crates/vm/src/

Repository: RustPython/RustPython

Length of output: 47


🏁 Script executed:

# Search for any calls to with_current_vm() to understand what it does
rg -B 5 -A 10 'fn with_current_vm' crates/vm/src/vm/thread.rs

Repository: RustPython/RustPython

Length of output: 621


release_current_thread() drops the VM before removing its raw pointer from VM_STACK, violating Rust's memory safety.

The function drops GILSTATE_VM at line 181 while the corresponding raw pointer remains in VM_STACK (popped at line 185). If any destructor in the Box runs during this drop and calls with_current_vm(), it dereferences a pointer into freed memory—undefined behavior in Rust.

Swap the order: pop from VM_STACK first, then drop the boxed VM.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/vm/src/vm/thread.rs` around lines 175 - 190, The
release_current_thread function currently drops GILSTATE_VM before popping the
raw pointer from VM_STACK, which can cause destructors to call with_current_vm
and dereference freed memory; fix by reversing the order in
release_current_thread: first remove/pop the VM pointer from VM_STACK (the
vms.borrow_mut().pop().expect(...) call) and only after that take/drop the
GILSTATE_VM (GILSTATE_VM.with(|gilstate_vm| gilstate_vm.borrow_mut().take()));
keep the existing detach_thread call and other logic unchanged.

}

/// Initialize thread slot for current thread if not already initialized.
Expand Down Expand Up @@ -509,17 +595,20 @@ where
obj.fast_isinstance(vm.ctx.types.object_type)
};
VM_STACK.with(|vms| {
let interp = match vms.borrow().iter().copied().exactly_one() {
Ok(x) => {
debug_assert!(vm_owns_obj(x));
x
let interp = {
let vms = vms.borrow();
match vms.iter().copied().exactly_one() {
Ok(x) => {
debug_assert!(vm_owns_obj(x));
x
}
Err(mut others) => others.find(|x| vm_owns_obj(*x))?,
}
Err(mut others) => others.find(|x| vm_owns_obj(*x))?,
};
// SAFETY: all references in VM_STACK should be valid, and should not be changed or moved
// at least until this function returns and the stack unwinds to an enter_vm() call
let vm = unsafe { interp.as_ref() };
Some(VM_CURRENT.set(vm, || f(vm)))
Some(set_current_vm(vm, || f(vm)))
})
}

Expand Down
Loading