Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
17 changes: 17 additions & 0 deletions Lib/test/test_with.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import unittest
from collections import deque
from contextlib import _GeneratorContextManager, contextmanager, nullcontext
from _testinternalcapi import SelfInterruptingContextManager


def do_with(obj):
Expand Down Expand Up @@ -850,5 +851,21 @@ def exit_raises():
expected)


class InterruptDuringEnter(unittest.TestCase):

def test_exit_called_after_interrupt(self):
cm = SelfInterruptingContextManager()
self.assertFalse(cm.within())
Comment on lines +856 to +858

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Obtain a policy exception before restoring this test body

The root AGENTS.md states that “the only acceptable modifications to test files” are the specified expected-failure decorator and TODO-comment changes. Adding this test body and its import exceeds that restriction, and the guidance documents no exemption for restoring canonical CPython tests. Obtain an explicit upstream-restoration exemption or keep the test-file edits within the permitted forms.

AGENTS.md reference: AGENTS.md:L280-L282

Useful? React with 👍 / 👎.

try:
with cm:
self.assertTrue(cm.within())
except KeyboardInterrupt:
self.assertFalse(cm.within())
return
except:
self.fail("Wrong exception raised")
self.fail("No exception raised")


if __name__ == '__main__':
unittest.main()
2 changes: 1 addition & 1 deletion crates/vm/src/frame.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3931,7 +3931,7 @@ impl ExecutingFrame<'_> {
// the allocation that tripped the threshold.
#[cfg(feature = "threading")]
vm.run_scheduled_gc();
Ok(())
vm.raise_async_exception()
}
if vm.eval_breaker_tripped()
&& let Err(exception) = eval_breaker_work(vm)
Expand Down
43 changes: 43 additions & 0 deletions crates/vm/src/stdlib/_testinternalcapi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,49 @@ mod _testinternalcapi {
#[pyattr]
const SPECIALIZATION_THRESHOLD: usize = 2;

#[pyattr]
#[pyclass(name, module = "_testcapi")]
#[derive(Debug, PyPayload)]
struct SelfInterruptingContextManager {
within: core::sync::atomic::AtomicBool,
}

impl Constructor for SelfInterruptingContextManager {
type Args = FuncArgs;

fn py_new(_cls: &Py<PyType>, _args: FuncArgs, _vm: &VirtualMachine) -> PyResult<Self> {
Ok(Self {
within: core::sync::atomic::AtomicBool::new(false),
})
}
}

#[pyclass(with(Constructor), flags(IMMUTABLETYPE))]
impl SelfInterruptingContextManager {
#[pymethod]
fn __enter__(zelf: PyRef<Self>, vm: &VirtualMachine) -> PyResult<PyRef<Self>> {
zelf.within.store(true, Ordering::Relaxed);
let Some(old) = vm.replace_async_exception(Some(
vm.ctx.exceptions.keyboard_interrupt.to_owned().into(),
)) else {
return Err(vm.new_runtime_error("the current thread state is being cleared"));
};
vm.notify_async_exception();
drop(old);
Ok(zelf)
}

#[pymethod]
fn within(zelf: &Py<Self>) -> bool {
zelf.within.load(Ordering::Relaxed)
}

#[pymethod]
fn __exit__(zelf: &Py<Self>, _args: crate::function::PosArgs<PyObjectRef>) {
zelf.within.store(false, Ordering::Relaxed);
}
}

// ADAPTIVE_COOLDOWN_VALUE + 1
#[pyattr]
const SPECIALIZATION_COOLDOWN: usize = 53;
Expand Down
2 changes: 2 additions & 0 deletions crates/vm/src/stdlib/_thread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ pub(crate) mod _thread {
// Clean up thread-local storage while VM context is still active
// This ensures __del__ methods are called properly
cleanup_thread_local_data();
vm.clear_async_exception();
// Clean up frame tracking
crate::vm::thread::cleanup_current_thread_frames(vm);
vm.state.thread_count.fetch_sub(1);
Expand Down Expand Up @@ -2240,6 +2241,7 @@ pub(crate) mod _thread {

// Clean up thread-local data while VM context is still active
cleanup_thread_local_data();
vm.clear_async_exception();

// Clean up frame tracking
crate::vm::thread::cleanup_current_thread_frames(vm);
Expand Down
150 changes: 150 additions & 0 deletions crates/vm/src/vm/async_exception.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
use super::VirtualMachine;
use crate::{
AsObject, PyAtomicRef, PyObject, PyObjectRef, PyResult,
builtins::{PyBaseException, PyType},
common::wtf8::Wtf8Buf,
};
use core::cell::Cell;

/// Owned by one execution context, like `PyThreadState.async_exc`.
/// This is an external GC root, not an edge owned by a Python object.
/// The VM is not Sync: only its owning execution thread accesses these flags.
/// Cross-thread requests would require a separate synchronized API and wakeup.
pub(super) struct PendingAsyncException {
exception: PyAtomicRef<Option<PyObject>>,
pending: Cell<bool>,
closed: Cell<bool>,
}

impl Default for PendingAsyncException {
fn default() -> Self {
Self {
exception: PyAtomicRef::new_empty(),
pending: Cell::new(false),
closed: Cell::new(false),
}
}
}

impl PendingAsyncException {
pub(super) fn is_pending(&self) -> bool {
self.pending.get()
}

pub(super) fn has_reference(&self) -> bool {
!self.exception.load_ptr().is_null()
}

fn replace(&self, exception: Option<PyObjectRef>) -> Option<Option<PyObjectRef>> {
if self.closed.get() {
return None;
}
Some(self.exception.store(exception))
}

fn notify(&self) {
if !self.closed.get() {
self.pending.set(true);
}
}

fn take(&self) -> Option<PyObjectRef> {
if !self.pending.replace(false) {
return None;
}
self.exception.store(None)
}

fn close(&self) {
// A cleared thread state cannot accept another request. Rejecting
// reentrant requests makes this a single clear, not a finalizer drain.
self.closed.set(true);
self.pending.set(false);
drop(self.exception.store(None));
}
}

impl VirtualMachine {
/// Replace the currently attached thread state's asynchronous exception.
/// The outer `None` means that this VM is inactive or is being cleared.
/// The old owner is returned so callers can release it outside all locks,
/// either before or after notification as required by their API contract.
/// `None` as the input cancels; validation happens only during delivery.
pub(crate) fn replace_async_exception(
&self,
exception: Option<PyObjectRef>,
) -> Option<Option<PyObjectRef>> {
if !super::thread::try_with_current_vm(|vm| core::ptr::eq(vm, self)).unwrap_or(false) {
return None;
}
self.async_exception.replace(exception)
}

pub(crate) fn notify_async_exception(&self) {
self.async_exception.notify();
}

/// Called only at bytecode safepoints, after pending signals and GC.
pub(crate) fn raise_async_exception(&self) -> PyResult<()> {
let Some(exception) = self.async_exception.take() else {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Prevent profiling callbacks from consuming the pending interrupt

With a Python callback such as sys.setprofile(lambda *args: None) installed, the successful native __enter__ call invokes its c_return profiling callback before returning to the with frame (protocol/callable.rs:119–123). The callback's first bytecode safepoint consumes the pending exception here, but _trace_event_inner discards profiling callback errors (protocol/callable.rs:409–419). Consequently, the KeyboardInterrupt disappears, profiling is disabled, and the restored test reaches “No exception raised.” Defer delivery during instrumentation callbacks or propagate the callback error so profiling cannot silently cancel the interrupt.

Useful? React with 👍 / 👎.

return Ok(());
};
let class = match exception.downcast::<PyType>() {
Ok(class) if class.fast_issubclass(self.ctx.exceptions.base_exception_type) => class,
object => {
let object: PyObjectRef = match object {
Ok(class) => class.into(),
Err(object) => object,
};
let mut message = Wtf8Buf::from("_PyErr_SetObject: exception ");
message.push_wtf8(object.repr(self)?.as_wtf8());
message.push_str(" is not a BaseException subclass");
return Err(self.new_system_error(message));
}
};

// `_PyErr_SetNone` invokes the class through its metaclass, including
// user __call__/__new__/__init__, rather than allocating a bare payload.
let result = class.as_object().call((), self).and_then(|object| {
object.downcast::<PyBaseException>().map_err(|object| {
let mut message = Wtf8Buf::from("calling ");
match class.as_object().repr(self) {
Ok(repr) => message.push_wtf8(repr.as_wtf8()),
Err(error) => return error,
}
message.push_str(" should have returned an instance of BaseException, not ");
message.push_str(&object.class().slot_name());
self.new_type_error(message)
})
});
let exception = match result {
Ok(exception) => exception,
Err(error) => {
// CPython represents the NULL value passed by SetNone as
// '<NULL>'; failure in repr's signal check uses '<unknown>'.
#[cfg(not(target_arch = "wasm32"))]
let args = if crate::signal::check_signals(self).is_ok() {
"<NULL>"
} else {
"<unknown>"
};
#[cfg(target_arch = "wasm32")]
let args = "<NULL>";
let note = self.ctx.new_str(format!(
"Normalization failed: type={} args={args}",
class.slot_name()
));
// A failed attempt to append the note must not replace the
// exception raised by the class constructor.
let _ = error.add_note(note, self);
error
}
};
Err(exception)
}

/// Clear a thread-state root while its VM and frame slot are still usable.
pub(crate) fn clear_async_exception(&self) {
self.async_exception.close();
}
}
9 changes: 8 additions & 1 deletion crates/vm/src/vm/interpreter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -487,6 +487,12 @@ pub struct Interpreter {
vm: VirtualMachine,
}

impl Drop for Interpreter {
fn drop(&mut self) {
thread::clear_async_exception_on_owner_drop(&self.vm);
}
}

impl Interpreter {
/// Create a new interpreter configuration builder.
///
Expand Down Expand Up @@ -640,7 +646,7 @@ impl Interpreter {
#[cfg(feature = "threading")]
#[must_use]
pub fn take_main_thread(&mut self) -> Self {
let replacement = self.vm.new_thread().vm;
let replacement = self.vm.new_thread_vm();
let vm = core::mem::replace(&mut self.vm, replacement);
Self {
global_state: self.global_state.clone(),
Expand Down Expand Up @@ -845,6 +851,7 @@ impl VirtualMachine {
#[cfg(feature = "threading")]
crate::object::qsbr::QSBR.process();

vm.clear_async_exception();
flush_status
}
}
Expand Down
8 changes: 8 additions & 0 deletions crates/vm/src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! See also:
//! <https://github.com/ProgVal/pythonvm-rust/blob/master/src/processor/mod.rs>

mod async_exception;
#[cfg(feature = "rustpython-compiler")]
mod compile;
pub(crate) mod compile_mode;
Expand Down Expand Up @@ -158,6 +159,9 @@ pub struct VirtualMachine {
/// UnsafeCell because the VM is per-thread and no reference into the Vec
/// is held across anything that could push to it.
trampoline_stack: core::cell::UnsafeCell<Vec<SuspendedFrame>>,
// Keep the closed state alive through the release of every other VM root.
// A callback during field destruction must not reserve another exception.
async_exception: async_exception::PendingAsyncException,
}

/// Non-owning frame pointer for the non-unix threading frames stack.
Expand Down Expand Up @@ -1408,6 +1412,7 @@ impl VirtualMachine {
pending_tailcall_owner: core::cell::UnsafeCell::new(None),
pending_gen_resume: core::cell::UnsafeCell::new(None),
trampoline_stack: core::cell::UnsafeCell::new(Vec::new()),
async_exception: async_exception::PendingAsyncException::default(),
};

vm.builtins.init_dict(
Expand Down Expand Up @@ -3864,6 +3869,9 @@ impl VirtualMachine {
/// `check_signals` and never park, so `stop_the_world` waits forever.
#[inline]
pub(crate) fn eval_breaker_tripped(&self) -> bool {
if self.async_exception.is_pending() {
return true;
}
#[cfg(feature = "threading")]
if thread::stop_requested_for_current_thread() || self.state.gc.collection_ready() {
return true;
Expand Down
Loading
Loading