From ef5c651958b23ccb7f205fe52f5c28b80f1791e4 Mon Sep 17 00:00:00 2001 From: Jiwoo Ahn Date: Wed, 7 Oct 2026 13:57:13 +0900 Subject: [PATCH] fix: signal.SIGINT and signal() raise NotImplementedError on wasi (#8986) * fix: signal.SIGINT and signal() raise NotImplementedError on wasi - Problem: signal.SIGINT and friends don't exist on wasi, and signal.signal() is hard-stubbed to raise NotImplementedError there - Cause: wasi-libc emulates signal()/raise() in a separate opt-in library that Rust's own wasm32-wasip1 target distribution doesn't ship - Fix: vendor wasi-sdk's prebuilt libwasi-emulated-signal.a, link it for wasm32-wasip1, and wire SIGINT/SIGABRT/SIGFPE/SIGILL/SIGSEGV/SIGTERM, signal()/getsignal()/raise_signal() through it. CPython's own WASI build links the same library Assisted-by: Claude Code:claude-sonnet-5 Signed-off-by: Jiwoo Ahn * Group WASI signal definitions in mod wasm Move WASI-only types, constants, FFI, and handlers into `mod wasm` and re-export them with `#[cfg(target_os = "wasi")]`. Assisted-by: Grok 4.6 --------- Signed-off-by: Jiwoo Ahn Co-authored-by: Jeong YunWon --- .cargo/config.toml | 3 + crates/host_env/src/lib.rs | 2 +- crates/host_env/src/signal.rs | 79 +++++++++++++++++- .../host_env/vendor/wasm32-wasip1/README.md | 16 ++++ .../wasm32-wasip1/libwasi-emulated-signal.a | Bin 0 -> 12594 bytes crates/vm/src/signal.rs | 4 + crates/vm/src/stdlib/_signal.rs | 26 +++--- extra_tests/snippets/stdlib_signal.py | 4 +- 8 files changed, 114 insertions(+), 20 deletions(-) create mode 100644 crates/host_env/vendor/wasm32-wasip1/README.md create mode 100644 crates/host_env/vendor/wasm32-wasip1/libwasi-emulated-signal.a diff --git a/.cargo/config.toml b/.cargo/config.toml index 4e54d1d3b..79a36b717 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -10,9 +10,12 @@ rustflags = ["--cfg=getrandom_backend=\"wasm_js\""] # Enforce a 1 MB stack limit for WASI targets. # (Runaway heap growth is fixed by the smaller DataStack chunk # and the size‑optimized wasm‑release profile.) + [target.wasm32-wasip1] rustflags = [ "-C", "link-arg=-zstack-size=1048576", + "-L", "crates/host_env/vendor/wasm32-wasip1", + "-l", "wasi-emulated-signal", ] [target.wasm32-wasip2] diff --git a/crates/host_env/src/lib.rs b/crates/host_env/src/lib.rs index ee9c5238e..33b5ad60b 100644 --- a/crates/host_env/src/lib.rs +++ b/crates/host_env/src/lib.rs @@ -74,7 +74,7 @@ pub mod pwd; pub mod resource; #[cfg(all(unix, not(target_os = "redox"), not(target_os = "android")))] pub mod shm; -#[cfg(any(unix, windows))] +#[cfg(any(unix, windows, target_os = "wasi"))] pub mod signal; pub mod time; diff --git a/crates/host_env/src/signal.rs b/crates/host_env/src/signal.rs index 090269123..70d32a9fd 100644 --- a/crates/host_env/src/signal.rs +++ b/crates/host_env/src/signal.rs @@ -1,3 +1,4 @@ +#[cfg(any(unix, windows))] use std::io; #[cfg(windows)] use std::sync::Once; @@ -13,13 +14,83 @@ pub use libc::sighandler_t; #[cfg(unix)] pub use libc::{SIG_DFL, SIG_ERR, SIG_IGN}; -#[cfg(not(unix))] +#[cfg(not(any(unix, target_os = "wasi")))] pub const SIG_DFL: libc::sighandler_t = 0; -#[cfg(not(unix))] +#[cfg(not(any(unix, target_os = "wasi")))] pub const SIG_IGN: libc::sighandler_t = 1; -#[cfg(not(unix))] +#[cfg(not(any(unix, target_os = "wasi")))] pub const SIG_ERR: libc::sighandler_t = -1 as _; +/// wasi-libc's userspace emulation of `signal()`/`raise()`, linked from +/// `libwasi-emulated-signal` (see `crates/host_env/vendor/wasm32-wasip1`). +/// WebAssembly has no asynchronous signal delivery, so this is a +/// synchronous, in-process handler table: a handler only runs when this +/// process's own code calls `raise()`, never from an outside interrupt. +/// Numbering matches wasi-libc's `bits/signal.h`, the same values CPython's +/// WASI build gets by linking the same library. +#[cfg(target_os = "wasi")] +mod wasm { + use std::io; + + #[allow(non_camel_case_types)] + pub type sighandler_t = usize; + + pub const SIG_DFL: sighandler_t = 0; + pub const SIG_IGN: sighandler_t = 1; + pub const SIG_ERR: sighandler_t = -1isize as usize; + + pub const SIGINT: i32 = 2; + pub const SIGILL: i32 = 4; + pub const SIGABRT: i32 = 6; + pub const SIGFPE: i32 = 8; + pub const SIGSEGV: i32 = 11; + pub const SIGTERM: i32 = 15; + + unsafe extern "C" { + fn signal(signum: i32, handler: sighandler_t) -> sighandler_t; + fn raise(signum: i32) -> i32; + } + + /// # Safety + /// + /// The caller must ensure `signalnum` is a valid platform signal number. + pub unsafe fn probe_handler(signalnum: i32) -> Option { + let handler = unsafe { signal(signalnum, SIG_IGN) }; + if handler == SIG_ERR { + None + } else { + unsafe { signal(signalnum, handler) }; + Some(handler) + } + } + + /// # Safety + /// + /// The caller must ensure `signalnum` is a valid platform signal number and + /// `handler` is accepted by the platform signal ABI. + pub unsafe fn install_handler( + signalnum: i32, + handler: sighandler_t, + ) -> io::Result { + let old = unsafe { signal(signalnum, handler) }; + if old == SIG_ERR { + return Err(io::Error::last_os_error()); + } + Ok(old) + } + + pub fn raise_signal(signalnum: i32) -> io::Result<()> { + if unsafe { raise(signalnum) } == 0 { + Ok(()) + } else { + Err(io::Error::last_os_error()) + } + } +} + +#[cfg(target_os = "wasi")] +pub use wasm::*; + #[cfg(unix)] pub use libc::{SIG_BLOCK, SIG_SETMASK, SIG_UNBLOCK}; @@ -320,7 +391,7 @@ pub fn notify_signal( } } -#[cfg(unix)] +#[cfg(any(unix, target_os = "wasi"))] pub fn notify_signal(signum: i32, wakeup_fd: i32) { if wakeup_fd == -1 { return; diff --git a/crates/host_env/vendor/wasm32-wasip1/README.md b/crates/host_env/vendor/wasm32-wasip1/README.md new file mode 100644 index 000000000..b8d201c91 --- /dev/null +++ b/crates/host_env/vendor/wasm32-wasip1/README.md @@ -0,0 +1,16 @@ +# Vendored wasi-libc emulation library + +`libwasi-emulated-signal.a` is a prebuilt static library from +[wasi-sdk](https://github.com/WebAssembly/wasi-sdk), providing wasi-libc's +userspace emulation of `signal()`/`raise()` for `wasm32-wasip1` (WebAssembly +has no asynchronous signal delivery, so this is a synchronous, in-process +handler table, not a real OS signal mechanism). + +CPython's own WASI build links the same library +(`configure.ac`'s `_WASI_EMULATED_SIGNAL` case). + +- Source: https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-34/wasi-sysroot-34.0.tar.gz + (path: `lib/wasm32-wasip1/libwasi-emulated-signal.a`) +- License: Apache-2.0 WITH LLVM-exception (same as wasi-libc) +- sha256: see below +becc35b856608fb9282d94c70166796e4d7b21e4e1057e625725306e765eb035 crates/host_env/vendor/wasm32-wasip1/libwasi-emulated-signal.a diff --git a/crates/host_env/vendor/wasm32-wasip1/libwasi-emulated-signal.a b/crates/host_env/vendor/wasm32-wasip1/libwasi-emulated-signal.a new file mode 100644 index 0000000000000000000000000000000000000000..25ba3e0515c5322aaadfcbcab514d1e92b253643 GIT binary patch literal 12594 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 { + any(unix, windows, target_os = "wasi") => { use crate::convert::{IntoPyException, TryFromBorrowedObject}; use rustpython_host_env::signal as host_signal; } @@ -73,11 +73,11 @@ pub(crate) mod _signal { } } - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] #[allow(unused_imports)] pub use host_signal::SIG_ERR; - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] #[pyattr] pub use host_signal::{SIG_DFL, SIG_IGN}; @@ -86,22 +86,22 @@ pub(crate) mod _signal { #[pyattr] use host_signal::{SIG_BLOCK, SIG_SETMASK, SIG_UNBLOCK}; - #[cfg(not(any(unix, windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] #[pyattr] pub const SIG_DFL: sighandler_t = 0; - #[cfg(not(any(unix, windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] #[pyattr] pub const SIG_IGN: sighandler_t = 1; - #[cfg(not(any(unix, windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] #[allow(dead_code)] pub const SIG_ERR: sighandler_t = -1 as _; #[pyattr] use crate::signal::NSIG; - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] #[pyattr] pub use host_signal::{SIGABRT, SIGFPE, SIGILL, SIGINT, SIGSEGV, SIGTERM}; @@ -160,7 +160,7 @@ pub(crate) mod _signal { const _: () = assert!(SignalNum::VALID_RANGE.start.is_positive()); const _: () = assert!(SignalNum::VALID_RANGE.end.is_positive()); - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] pub(super) fn init_signal_handlers( module: &Py, vm: &VirtualMachine, @@ -199,7 +199,7 @@ pub(crate) mod _signal { } } - #[cfg(not(any(unix, windows)))] + #[cfg(not(any(unix, windows, target_os = "wasi")))] #[pyfunction] pub fn signal( _signalnum: i32, @@ -209,7 +209,7 @@ pub(crate) mod _signal { Err(vm.new_not_implemented_error("signal is not implemented on this platform")) } - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] #[pyfunction] pub fn signal( signalnum: SignalNum, @@ -401,7 +401,7 @@ pub(crate) mod _signal { host_signal::siginterrupt(signalnum.into(), flag).map_err(|_| vm.new_last_errno_error()) } - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] #[pyfunction] fn raise_signal(signalnum: i32, vm: &VirtualMachine) -> PyResult<()> { let signalnum = SignalNum::try_from(signalnum).map_err(cfg_select! { @@ -506,7 +506,7 @@ pub(crate) mod _signal { sigset_to_pyset(old_mask, vm) } - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] pub extern "C" fn run_signal(signum: i32) { signal::TRIGGERS[signum as usize].store(true, Ordering::Relaxed); signal::set_triggered(); @@ -535,7 +535,7 @@ pub(crate) mod _signal { ) -> PyResult<()> { __module_exec(vm, module); - #[cfg(any(unix, windows))] + #[cfg(any(unix, windows, target_os = "wasi"))] init_signal_handlers(module, vm); Ok(()) diff --git a/extra_tests/snippets/stdlib_signal.py b/extra_tests/snippets/stdlib_signal.py index 50a025a06..8e00b5d88 100644 --- a/extra_tests/snippets/stdlib_signal.py +++ b/extra_tests/snippets/stdlib_signal.py @@ -21,8 +21,8 @@ assert old_signal is signal.SIG_IGN assert signal.getsignal(signal.SIGILL) is signal.SIG_DFL -# unix -if "win" not in sys.platform: +# unix, and not WASI +if "win" not in sys.platform and sys.platform != "wasi": signal.signal(signal.SIGALRM, handler) assert signal.getsignal(signal.SIGALRM) is handler