Skip to content
8 changes: 8 additions & 0 deletions Lib/test/test_signal.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ def test_setting_signal_handler_to_none_raises_error(self):
self.assertRaises(TypeError, signal.signal,
signal.SIGUSR1, None)

@unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON; AssertionError: None is not an instance of <enum 'Handlers'>")
def test_getsignal(self):
hup = signal.signal(signal.SIGHUP, self.trivial_signal_handler)
self.assertIsInstance(hup, signal.Handlers)
Expand All @@ -101,6 +102,7 @@ def test_getsignal(self):
signal.signal(signal.SIGHUP, hup)
self.assertEqual(signal.getsignal(signal.SIGHUP), hup)

@unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON")
def test_no_repr_is_called_on_signal_handler(self):
# See https://github.com/python/cpython/issues/112559.

Expand Down Expand Up @@ -778,6 +780,7 @@ def test_siginterrupt_off(self):
self.assertFalse(interrupted)


@unittest.skipIf(sys.platform == "android", "TODO: RUSTPYTHON; Error during teardown")
@unittest.skipIf(sys.platform == "win32", "Not valid on Windows")
@unittest.skipUnless(hasattr(signal, 'getitimer') and hasattr(signal, 'setitimer'),
"needs signal.getitimer() and signal.setitimer()")
Expand Down Expand Up @@ -1264,6 +1267,7 @@ def decide_itimer_count(self):
"(> 10 ms.) on this platform (or system too busy)"
% (reso,))

@unittest.skipIf(sys.platform == "android", "TODO: RUSTPYTHON; TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object")
@unittest.skipUnless(hasattr(signal, "setitimer"),
"test needs setitimer()")
def test_stress_delivery_dependent(self):
Expand Down Expand Up @@ -1310,6 +1314,7 @@ def second_handler(signum=None, frame=None):
# Python handler
self.assertEqual(len(sigs), N, "Some signals were lost")

@unittest.skipIf(sys.platform == "android", "TODO: RUSTPYTHON; TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object")
@unittest.skipUnless(hasattr(signal, "setitimer"),
"test needs setitimer()")
def test_stress_delivery_simultaneous(self):
Expand Down Expand Up @@ -1409,6 +1414,7 @@ def cycle_handlers():

class RaiseSignalTest(unittest.TestCase):

@unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON; AssertionError: KeyboardInterrupt not raised")
def test_sigint(self):
with self.assertRaises(KeyboardInterrupt):
signal.raise_signal(signal.SIGINT)
Expand All @@ -1425,6 +1431,7 @@ def test_invalid_argument(self):
else:
raise

@unittest.skipIf(sys.platform == "android", "TODO: RUSTPYTHON; TypeError: signal handler must be signal.SIG_IGN, signal.SIG_DFL, or a callable object")
def test_handler(self):
is_ok = False
def handler(a, b):
Expand Down Expand Up @@ -1455,6 +1462,7 @@ def __del__(self):

class PidfdSignalTest(unittest.TestCase):

@unittest.expectedFailureIf(sys.platform == "android", "TODO: RUSTPYTHON; AssertionError: KeyboardInterrupt not raised")
@unittest.skipUnless(
hasattr(signal, "pidfd_send_signal"),
"pidfd support not built in",
Expand Down
11 changes: 3 additions & 8 deletions crates/host_env/src/signal.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,10 @@ mod ffi {
}
}

#[cfg(any(unix, windows))]
/// # Safety
///
/// The caller must ensure `signalnum` is a valid platform signal number.
#[cfg(any(unix, windows))]
pub unsafe fn probe_handler(signalnum: i32) -> Option<sighandler_t> {
let handler = unsafe { libc::signal(signalnum, libc::SIG_IGN) };
if handler == libc::SIG_ERR as sighandler_t {
Expand All @@ -69,11 +69,11 @@ pub unsafe fn probe_handler(signalnum: i32) -> Option<sighandler_t> {
}
}

#[cfg(any(unix, windows))]
/// # Safety
///
/// The caller must ensure `signalnum` is a valid platform signal number and
/// `handler` is accepted by the platform signal ABI.
#[cfg(any(unix, windows))]
pub unsafe fn install_handler(signalnum: i32, handler: sighandler_t) -> io::Result<sighandler_t> {
let old = unsafe { libc::signal(signalnum, handler) };
if old == libc::SIG_ERR as sighandler_t {
Expand Down Expand Up @@ -158,7 +158,7 @@ pub fn pthread_sigmask(how: i32, set: &libc::sigset_t) -> io::Result<libc::sigse
}
}

#[cfg(target_os = "linux")]
#[cfg(any(target_os = "android", target_os = "linux"))]
pub fn pidfd_send_signal(pidfd: i32, sig: i32, flags: u32) -> io::Result<()> {
let ret = unsafe {
libc::syscall(
Expand Down Expand Up @@ -199,11 +199,6 @@ pub const CTRL_BREAK_EVENT: u32 = 1;
#[cfg(windows)]
pub const INVALID_SOCKET: libc::SOCKET = windows_sys::Win32::Networking::WinSock::INVALID_SOCKET;

#[cfg(windows)]
pub fn is_valid_signal(signalnum: i32) -> bool {
VALID_SIGNALS.contains(&signalnum)
}

#[cfg(windows)]
fn init_winsock() {
static WSA_INIT: Once = Once::new();
Expand Down
137 changes: 118 additions & 19 deletions crates/vm/src/signal.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
use crate::{PyObjectRef, PyResult, VirtualMachine};
use core::{
cell::{Cell, RefCell},
fmt,
ops::{Deref, DerefMut},
ops::{Deref, DerefMut, Index, IndexMut, Range},
sync::atomic::{AtomicBool, Ordering},
};
use std::sync::mpsc;

#[cfg(windows)]
use core::sync::atomic::AtomicIsize;

static ANY_TRIGGERED: AtomicBool = AtomicBool::new(false);
use crate::{PyObjectRef, PyResult, TryFromBorrowedObject, TryFromObject, VirtualMachine};

pub(crate) const NSIG: usize = 64;

static ANY_TRIGGERED: AtomicBool = AtomicBool::new(false);

#[expect(
clippy::declare_interior_mutable_const,
reason = "workaround for const array repeat limitation (rust issue #79270)"
Expand Down Expand Up @@ -70,22 +71,32 @@ fn trigger_signals(vm: &VirtualMachine) -> PyResult<()> {
}
let _guard = SignalHandlerGuard;

// unwrap should never fail since we check above
let signal_handlers = vm.signal_handlers.get().unwrap().borrow();
let signal_handlers = vm
.signal_handlers
.get()
.expect("should never fail since we check above")
.borrow();

for (signum, trigger) in TRIGGERS.iter().enumerate().skip(1) {
let triggered = trigger.swap(false, Ordering::Relaxed);

// SAFETY: TRIGGERS has the same length as the signal_handlers
let signum = unsafe { SignalNum::new_unchecked(signum as i32) };

if triggered
&& let Some(handler) = &signal_handlers[signum]
&& let Some(callable) = handler.to_callable()
{
callable.invoke((signum, vm.ctx.none()), vm)?;
callable.invoke((signum.as_i32(), vm.ctx.none()), vm)?;
Comment on lines +74 to +90

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 | ⚡ Quick win

Drop the signal_handlers borrow before invoking Python.

signal_handlers.borrow() now lives across callable.invoke(...). If the Python signal handler calls back into _signal.signal() to replace a handler, crates/vm/src/stdlib/_signal.rs, Lines 222-255, will hit borrow_mut() on the same RefCell and panic on a valid delivery path. Copy out the PyObjectRef (or callable) inside a short borrow scope, then invoke it after the borrow is dropped.

🤖 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/signal.rs` around lines 74 - 90, The `signal_handlers` borrow
from `signal_handlers.borrow()` is held across the `callable.invoke()` call,
which causes a panic if the Python signal handler tries to call back into
`_signal.signal()` to modify signal handlers due to a conflict on the same
`RefCell`. Restructure the code to extract the handler and convert it to a
callable inside a short borrow scope, then explicitly drop the borrow before
invoking the callable. This can be achieved by moving the handler lookup, the
callable extraction, and the invoke call outside the scope of the borrow, or by
using a scoped block that ensures the borrow is released before the invoke call
happens.

}
}

if let Some(signal_rx) = &vm.signal_rx {
for f in signal_rx.rx.try_iter() {
f(vm)?;
}
}

Ok(())
}

Expand All @@ -109,28 +120,94 @@ pub(crate) fn clear_after_fork() {
}
}

pub fn assert_in_range(signum: i32, vm: &VirtualMachine) -> PyResult<()> {
if (1..NSIG as i32).contains(&signum) {
Ok(())
} else {
Err(vm.new_value_error("signal number out of range"))
/// A valid signal number.
#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
pub struct SignalNum(i32);

impl SignalNum {
pub(crate) const VALID_RANGE: Range<i32> = 1..NSIG as i32;

/// Alias for:
/// ```rust
/// # use rustpython_vm::signal::SignalNum;
///
/// unsafe { SignalNum::new_unchecked(libc::SIGINT) };
/// ```
#[cfg(any(unix, windows))]
#[allow(dead_code, reason = "Not used on all platforms")]
pub(crate) const SIGINT: Self = Self(libc::SIGINT);

/// Construct [`Self`] without any validation on the signalnum value.
///
/// # Safety
///
/// Caller's responsibility to ensure the signal num is valid.
#[must_use]
pub const unsafe fn new_unchecked(value: i32) -> Self {
Self(value)
}

/// Get the self as an [`i32`].
#[must_use]
pub const fn as_i32(&self) -> i32 {
self.0
}

/// Get the self as an [`usize`].
#[must_use]
pub const fn as_usize(&self) -> usize {
self.0 as usize
}
}

impl fmt::Display for SignalNum {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}

impl From<SignalNum> for i32 {
fn from(signalnum: SignalNum) -> Self {
signalnum.as_i32()
}
}

impl TryFrom<i32> for SignalNum {
type Error = String;

fn try_from(value: i32) -> Result<Self, Self::Error> {
let bounds = cfg_select! {
all(windows, feature = "host_env") => rustpython_host_env::signal::VALID_SIGNALS,
_ => Self::VALID_RANGE,
};

if bounds.contains(&value) {
Ok(Self(value))
} else {
Err("signal number out of range".into())
}
}
}

impl TryFromObject for SignalNum {
fn try_from_object(vm: &VirtualMachine, obj: PyObjectRef) -> PyResult<Self> {
Self::try_from(i32::try_from_borrowed_object(vm, &obj)?)
.map_err(|msg| vm.new_value_error(msg))
}
}

/// Similar to `PyErr_SetInterruptEx` in CPython
///
/// Missing signal handler for the given signal number is silently ignored.
#[allow(dead_code)]
#[cfg(all(not(target_arch = "wasm32"), feature = "host_env"))]
pub fn set_interrupt_ex(signum: i32, vm: &VirtualMachine) -> PyResult<()> {
pub fn set_interrupt_ex(signum: SignalNum) -> PyResult<()> {
use crate::stdlib::_signal::_signal::{SIG_DFL, SIG_IGN, run_signal};
assert_in_range(signum, vm)?;

match signum as usize {
match signum.as_usize() {
SIG_DFL | SIG_IGN => Ok(()),
_ => {
// interrupt the main thread with given signal number
run_signal(signum);
run_signal(signum.into());
Comment on lines +203 to +210

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 | 🟠 Major | ⚡ Quick win

Don't treat SIG_DFL/SIG_IGN as signal numbers here.

signum is already a validated SignalNum, but this branch still compares it to handler sentinel values. On Unix, SIG_IGN == 1, so _thread.interrupt_main(1) from crates/vm/src/stdlib/_thread.rs, Lines 616-621, becomes a silent no-op even though signal 1 is valid. set_interrupt_ex should forward every valid SignalNum to run_signal; default/ignore behavior is decided later from the installed handler object, not from the signal number itself.

Suggested fix
 pub fn set_interrupt_ex(signum: SignalNum) -> PyResult<()> {
-    use crate::stdlib::_signal::_signal::{SIG_DFL, SIG_IGN, run_signal};
-
-    match signum.as_usize() {
-        SIG_DFL | SIG_IGN => Ok(()),
-        _ => {
-            // interrupt the main thread with given signal number
-            run_signal(signum.into());
-            Ok(())
-        }
-    }
+    use crate::stdlib::_signal::_signal::run_signal;
+
+    // interrupt the main thread with given signal number
+    run_signal(signum.into());
+    Ok(())
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn set_interrupt_ex(signum: SignalNum) -> PyResult<()> {
use crate::stdlib::_signal::_signal::{SIG_DFL, SIG_IGN, run_signal};
assert_in_range(signum, vm)?;
match signum as usize {
match signum.as_usize() {
SIG_DFL | SIG_IGN => Ok(()),
_ => {
// interrupt the main thread with given signal number
run_signal(signum);
run_signal(signum.into());
pub fn set_interrupt_ex(signum: SignalNum) -> PyResult<()> {
use crate::stdlib::_signal::_signal::run_signal;
// interrupt the main thread with given signal number
run_signal(signum.into());
Ok(())
}
🤖 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/signal.rs` around lines 203 - 210, The set_interrupt_ex
function incorrectly compares a validated SignalNum against SIG_DFL and SIG_IGN
handler sentinel values, which are not signal numbers. Since SIG_IGN equals 1 on
Unix, this causes valid signal 1 to be silently ignored. Remove the match branch
that checks for SIG_DFL or SIG_IGN and instead always forward the validated
SignalNum directly to run_signal, letting the installed handler object determine
default/ignore behavior rather than the signal number itself.

Ok(())
}
}
Expand Down Expand Up @@ -190,16 +267,38 @@ pub fn get_sigint_event() -> Option<isize> {
if handle == 0 { None } else { Some(handle) }
}

pub struct SignalHandlers(Box<RefCell<[Option<PyObjectRef>; NSIG]>>);
pub struct SignalHandlersInner([Option<PyObjectRef>; NSIG]);

impl Default for SignalHandlersInner {
fn default() -> Self {
Self([const { None }; NSIG])
}
}

impl Index<SignalNum> for SignalHandlersInner {
type Output = Option<PyObjectRef>;

fn index(&self, index: SignalNum) -> &Self::Output {
&self.0[index.as_usize()]
}
}

impl IndexMut<SignalNum> for SignalHandlersInner {
fn index_mut(&mut self, index: SignalNum) -> &mut Self::Output {
&mut self.0[index.as_usize()]
}
}

pub struct SignalHandlers(Box<RefCell<SignalHandlersInner>>);

impl Default for SignalHandlers {
fn default() -> Self {
Self(Box::new(const { RefCell::new([const { None }; NSIG]) }))
Self(Box::new(RefCell::new(SignalHandlersInner::default())))
}
}

impl Deref for SignalHandlers {
type Target = Box<RefCell<[Option<PyObjectRef>; NSIG]>>;
type Target = Box<RefCell<SignalHandlersInner>>;

fn deref(&self) -> &Self::Target {
&self.0
Expand Down
Loading
Loading