diff --git a/crates/perry-runtime/Cargo.toml b/crates/perry-runtime/Cargo.toml index 9d2919f67..3aa45758b 100644 --- a/crates/perry-runtime/Cargo.toml +++ b/crates/perry-runtime/Cargo.toml @@ -246,6 +246,9 @@ windows-sys = { version = "0.61", features = [ "Win32_System_Console", "Win32_Foundation", "Win32_System_LibraryLoader", + # `Win32_System_Threading` powers the Windows arm of `process.kill` + # (`OpenProcess` / `TerminateProcess` / `GetExitCodeProcess`, mirroring + # libuv's `uv_kill`) in `src/os/signal.rs`. "Win32_System_Threading", ] } diff --git a/crates/perry-runtime/src/os/signal.rs b/crates/perry-runtime/src/os/signal.rs index 80b840404..d567c5f65 100644 --- a/crates/perry-runtime/src/os/signal.rs +++ b/crates/perry-runtime/src/os/signal.rs @@ -2,7 +2,9 @@ use crate::fs::validate::{describe_received, is_numeric, throw_type_error_with_c use crate::string::{js_string_from_bytes, StringHeader}; use crate::value::{JSValue, TAG_TRUE}; #[cfg(unix)] -use std::sync::atomic::{AtomicBool, AtomicI32, AtomicUsize, Ordering}; +use std::sync::atomic::AtomicI32; +#[cfg(any(unix, windows))] +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; fn signal_number_by_name(name: &str) -> Option { #[cfg(unix)] @@ -304,12 +306,157 @@ fn uninstall_process_signal_handler(slot: &'static ProcessSignalSlot) { } } +// ============================================================================ +// Windows: console control events → process signal events (#6609 audit). +// +// Mirrors the Unix structure above, with `SetConsoleCtrlHandler` standing in +// for `sigaction` and the mapping Node/libuv use on Windows: +// +// CTRL_C_EVENT → 'SIGINT' +// CTRL_BREAK_EVENT → 'SIGBREAK' +// CTRL_CLOSE_EVENT → 'SIGHUP' (console closed; ~5s grace before force-kill) +// 'SIGTERM' → registerable but source-less (no console event raises +// it — Node accepts the listener and it simply never +// fires from the console). +// +// The control handler runs on a Windows-spawned thread, never the JS main +// thread, so it must not touch the JS heap: it only bumps the slot's +// `pending` atomic and calls `js_notify_main_thread()` (atomics + condvar, +// documented safe from any thread). The main thread drains via the same +// platform-neutral path Unix uses: `take_pending_process_signals()` from +// `js_process_signal_drain` on the next event-loop tick. +#[cfg(windows)] +static WIN_SIGINT_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGINT_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGBREAK_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGBREAK_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGHUP_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGHUP_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGTERM_PENDING: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_SIGTERM_LISTENERS: AtomicUsize = AtomicUsize::new(0); +#[cfg(windows)] +static WIN_CTRL_HANDLER_INSTALLED: AtomicBool = AtomicBool::new(false); + +#[cfg(windows)] +struct WinSignalSlot { + name: &'static str, + /// Console control event that maps to this signal, or `None` when the + /// signal has no console source on Windows (SIGTERM). + ctrl_event: Option, + pending: &'static AtomicUsize, + listeners: &'static AtomicUsize, +} + +#[cfg(windows)] +static WIN_SIGNAL_SLOTS: &[WinSignalSlot] = &[ + WinSignalSlot { + name: "SIGINT", + ctrl_event: Some(windows_sys::Win32::System::Console::CTRL_C_EVENT), + pending: &WIN_SIGINT_PENDING, + listeners: &WIN_SIGINT_LISTENERS, + }, + WinSignalSlot { + name: "SIGBREAK", + ctrl_event: Some(windows_sys::Win32::System::Console::CTRL_BREAK_EVENT), + pending: &WIN_SIGBREAK_PENDING, + listeners: &WIN_SIGBREAK_LISTENERS, + }, + WinSignalSlot { + name: "SIGHUP", + ctrl_event: Some(windows_sys::Win32::System::Console::CTRL_CLOSE_EVENT), + pending: &WIN_SIGHUP_PENDING, + listeners: &WIN_SIGHUP_LISTENERS, + }, + WinSignalSlot { + name: "SIGTERM", + ctrl_event: None, + pending: &WIN_SIGTERM_PENDING, + listeners: &WIN_SIGTERM_LISTENERS, + }, +]; + +#[cfg(windows)] +fn win_slot_by_name(name: &str) -> Option<&'static WinSignalSlot> { + WIN_SIGNAL_SLOTS.iter().find(|slot| slot.name == name) +} + +/// Console control handler — runs on a thread Windows injects, NOT the JS +/// main thread. Async-context rules mirror the Unix `process_signal_handler`: +/// only atomics + the any-thread-safe event-pump notify; no JS heap access, +/// no JS calls. +/// +/// Returns TRUE (1, "handled") iff at least one JS listener is registered +/// for the mapped signal at this moment; otherwise FALSE (0) so the default +/// behavior is preserved — plain Ctrl+C on a program with no `SIGINT` +/// listener still terminates it, and removing the last listener restores +/// default behavior without needing to unregister the handler. +#[cfg(windows)] +unsafe extern "system" fn win_console_ctrl_handler(ctrl_type: u32) -> windows_sys::core::BOOL { + let Some(slot) = WIN_SIGNAL_SLOTS + .iter() + .find(|slot| slot.ctrl_event == Some(ctrl_type)) + else { + // Not a signal we map (logoff/shutdown/…): defer to the default. + return 0; + }; + if slot.listeners.load(Ordering::Acquire) == 0 { + return 0; + } + slot.pending.fetch_add(1, Ordering::Release); + crate::event_pump::js_notify_main_thread(); + if ctrl_type == windows_sys::Win32::System::Console::CTRL_CLOSE_EVENT { + // Mirror libuv: for CTRL_CLOSE_EVENT Windows terminates the process + // as soon as this handler *returns*, but grants ~5s while it runs. + // Park this handler thread so the main thread gets the full grace + // window to run the JS 'SIGHUP' listeners; the OS force-kills the + // process afterwards (or earlier, when the main thread exits). + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + 1 +} + +/// Install the console control handler once, on first listener registration. +/// It stays installed for the process lifetime; the per-slot listener-count +/// check inside the handler restores default behavior when the last listener +/// is removed (no unregister needed, so there is no install/remove race). +#[cfg(windows)] +fn ensure_win_console_ctrl_handler() { + if WIN_CTRL_HANDLER_INSTALLED + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return; + } + let ok = unsafe { + windows_sys::Win32::System::Console::SetConsoleCtrlHandler( + Some(win_console_ctrl_handler), + 1, + ) + }; + if ok == 0 { + WIN_CTRL_HANDLER_INSTALLED.store(false, Ordering::Release); + } +} + pub(crate) fn is_process_signal_name(name: &str) -> bool { #[cfg(unix)] { slot_by_name(name).is_some() } - #[cfg(not(unix))] + #[cfg(windows)] + { + win_slot_by_name(name).is_some() + } + #[cfg(not(any(unix, windows)))] { matches!(name, "SIGINT" | "SIGTERM") } @@ -328,7 +475,22 @@ pub(crate) fn set_process_signal_listener_count(name: &str, count: usize) { uninstall_process_signal_handler(slot); } } - #[cfg(not(unix))] + #[cfg(windows)] + { + let Some(slot) = win_slot_by_name(name) else { + return; + }; + slot.listeners.store(count, Ordering::Release); + if count == 0 { + // Mirror the Unix uninstall path: drop undelivered signals so a + // stale pending count can't keep the event loop alive or fire + // into a re-registered listener later. + slot.pending.store(0, Ordering::Release); + } else if slot.ctrl_event.is_some() { + ensure_win_console_ctrl_handler(); + } + } + #[cfg(not(any(unix, windows)))] { let _ = (name, count); } @@ -359,7 +521,13 @@ pub(crate) fn has_active_process_signal_listeners() -> bool { slot.pending.load(Ordering::Acquire) > 0 && slot.listeners.load(Ordering::Acquire) > 0 }) } - #[cfg(not(unix))] + #[cfg(windows)] + { + WIN_SIGNAL_SLOTS.iter().any(|slot| { + slot.pending.load(Ordering::Acquire) > 0 && slot.listeners.load(Ordering::Acquire) > 0 + }) + } + #[cfg(not(any(unix, windows)))] { false } @@ -378,7 +546,19 @@ pub(crate) fn take_pending_process_signals() -> Vec<&'static str> { } signals } - #[cfg(not(unix))] + #[cfg(windows)] + { + let mut signals = Vec::new(); + for slot in WIN_SIGNAL_SLOTS { + let count = slot.pending.swap(0, Ordering::AcqRel); + if count == 0 || slot.listeners.load(Ordering::Acquire) == 0 { + continue; + } + signals.extend(std::iter::repeat_n(slot.name, count)); + } + signals + } + #[cfg(not(any(unix, windows)))] { Vec::new() } @@ -547,9 +727,12 @@ fn kill_errno_code(errno: i32) -> &'static str { } } -#[cfg(unix)] -fn throw_kill_error(errno: i32) -> ! { - let code = kill_errno_code(errno); +/// Throw the Node-shaped `process.kill` failure: an `Error` whose message is +/// `kill ` with `.code = ` and `.syscall = "kill"`. Shared by the +/// Unix (errno-derived) and Windows (Win32-error-derived) arms so both report +/// failures identically. +#[cfg(any(unix, windows))] +fn throw_kill_error_code(code: &'static str) -> ! { let message = format!("kill {code}"); let msg = js_string_from_bytes(message.as_ptr(), message.len() as u32); crate::node_submodules::register_error_code_pub(msg, code); @@ -558,6 +741,122 @@ fn throw_kill_error(errno: i32) -> ! { crate::exception::js_throw(crate::value::js_nanbox_pointer(err as i64)) } +#[cfg(unix)] +fn throw_kill_error(errno: i32) -> ! { + throw_kill_error_code(kill_errno_code(errno)) +} + +/// Map a Win32 error from the `process.kill` syscalls to the Node errno code +/// the Unix arm would surface for the analogous failure (libuv's +/// `uv_translate_sys_error` conventions: invalid pid → ESRCH, access denied → +/// EPERM, anything else falls back to the file's existing EIO convention). +#[cfg(windows)] +fn win_error_to_kill_code(err: u32) -> &'static str { + use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, ERROR_INVALID_PARAMETER}; + match err { + ERROR_INVALID_PARAMETER => "ESRCH", + ERROR_ACCESS_DENIED => "EPERM", + _ => "EIO", + } +} + +/// Win32 half of `process.kill(pid, sig)`, mirroring libuv's `uv_kill` +/// (`src/win/process.c`). Returns `Ok(())` on success or the Node errno code +/// for `throw_kill_error_code`. Kept JS-free so unit tests can exercise it +/// directly. +/// +/// * `sig == 0` — existence probe: `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` +/// then `GetExitCodeProcess`; only a `STILL_ACTIVE` process counts as alive +/// (an exited process whose pid is pinned by an open handle reports ESRCH, +/// like Unix `kill(pid, 0)` on a reaped pid). +/// * SIGHUP/SIGINT/SIGQUIT/SIGKILL/SIGTERM — `OpenProcess(PROCESS_TERMINATE)` +/// + `TerminateProcess(h, 1)`. There is no graceful termination on Windows; +/// this is exactly what libuv does for all of them. +/// * Other signals in `0..NSIG` — ENOSYS (libuv's "unsupported signal"); +/// outside that range — EINVAL. +#[cfg(windows)] +fn win_process_kill(pid: i32, sig: i32) -> Result<(), &'static str> { + use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, STILL_ACTIVE}; + use windows_sys::Win32::System::Threading::{ + GetCurrentProcess, GetExitCodeProcess, OpenProcess, TerminateProcess, + PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_TERMINATE, + }; + + // MSVC CRT signal numbers (crt/signal.h); NSIG bound mirrors libuv. + const SIGHUP_N: i32 = 1; + const SIGINT_N: i32 = 2; + const SIGQUIT_N: i32 = 3; + const SIGKILL_N: i32 = 9; + const SIGTERM_N: i32 = 15; + const NSIG: i32 = 23; + + if !(0..NSIG).contains(&sig) { + return Err("EINVAL"); + } + let desired_access = match sig { + 0 => PROCESS_QUERY_LIMITED_INFORMATION, + // QUERY_LIMITED alongside TERMINATE (libuv does the same): the + // already-exited fallback below needs GetExitCodeProcess, which + // fails with ERROR_ACCESS_DENIED on a TERMINATE-only handle. + SIGHUP_N | SIGINT_N | SIGQUIT_N | SIGKILL_N | SIGTERM_N => { + PROCESS_TERMINATE | PROCESS_QUERY_LIMITED_INFORMATION + } + _ => return Err("ENOSYS"), + }; + + unsafe { + // libuv: pid 0 targets the current process via the pseudo-handle + // (Windows has no Unix-style process groups for kill). + let is_self = pid == 0; + let handle = if is_self { + GetCurrentProcess() + } else { + OpenProcess(desired_access, 0, pid as u32) + }; + if handle.is_null() { + // ERROR_INVALID_PARAMETER == no such pid → ESRCH. + return Err(win_error_to_kill_code(GetLastError())); + } + // The pseudo-handle from GetCurrentProcess needs no CloseHandle. + let close = |h| { + if !is_self { + CloseHandle(h); + } + }; + + if sig == 0 { + let mut status: u32 = 0; + if GetExitCodeProcess(handle, &mut status) == 0 { + let err = GetLastError(); + close(handle); + return Err(win_error_to_kill_code(err)); + } + close(handle); + if status != STILL_ACTIVE as u32 { + return Err("ESRCH"); + } + return Ok(()); + } + + if TerminateProcess(handle, 1) == 0 { + let err = GetLastError(); + // libuv: TerminateProcess fails with ERROR_ACCESS_DENIED when the + // target already exited — report that as ESRCH, not EPERM. + if err == windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED { + let mut status: u32 = 0; + if GetExitCodeProcess(handle, &mut status) != 0 && status != STILL_ACTIVE as u32 { + close(handle); + return Err("ESRCH"); + } + } + close(handle); + return Err(win_error_to_kill_code(err)); + } + close(handle); + } + Ok(()) +} + /// process.kill(pid, signal?) — send signal to process. signal=0 means /// existence check, and omitted/undefined signal defaults to SIGTERM. #[no_mangle] @@ -586,7 +885,9 @@ pub extern "C" fn js_process_kill(pid: f64, signal: f64) -> f64 { } #[cfg(windows)] { - let _ = (pid_i, sig_i); + if let Err(code) = win_process_kill(pid_i, sig_i) { + throw_kill_error_code(code); + } } #[cfg(not(any(unix, windows)))] { @@ -617,4 +918,125 @@ mod tests { assert_eq!(signal_number_by_name("SIGKILL"), Some(9)); assert_eq!(signal_number_by_name("sigterm"), None); } + + /// Spawn a child that blocks until killed: `cmd /c pause` waits for a + /// byte on stdin, which we pipe and never write. + #[cfg(windows)] + fn spawn_blocked_child() -> std::process::Child { + std::process::Command::new("cmd") + .args(["/c", "pause"]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("spawn cmd /c pause") + } + + #[cfg(windows)] + #[test] + fn win_kill_terminates_child_and_sig0_tracks_liveness() { + let mut child = spawn_blocked_child(); + let pid = child.id() as i32; + + // sig 0 existence probe: alive → Ok. + assert_eq!(win_process_kill(pid, 0), Ok(())); + + // SIGTERM → TerminateProcess(h, 1): child exits with code 1. + assert_eq!(win_process_kill(pid, 15), Ok(())); + let status = child.wait().expect("wait on terminated child"); + assert_eq!(status.code(), Some(1)); + + // `child` (still in scope) holds the process handle, so the pid + // cannot be recycled: the probe must now see the exited process + // (GetExitCodeProcess != STILL_ACTIVE) and report ESRCH. + assert_eq!(win_process_kill(pid, 0), Err("ESRCH")); + // Terminating an already-exited process also reports ESRCH + // (libuv's ERROR_ACCESS_DENIED + !STILL_ACTIVE special case). + assert_eq!(win_process_kill(pid, 15), Err("ESRCH")); + } + + #[cfg(windows)] + #[test] + fn win_kill_reports_esrch_for_nonexistent_pid() { + // Negative pid → OpenProcess(ERROR_INVALID_PARAMETER) → ESRCH, + // for both the probe and the terminate path. + assert_eq!(win_process_kill(-1, 0), Err("ESRCH")); + assert_eq!(win_process_kill(-1, 15), Err("ESRCH")); + } + + #[cfg(windows)] + #[test] + fn win_kill_rejects_unsupported_signals() { + // SIGBREAK(21) is deliverable via the console but not via kill — + // libuv reports ENOSYS. Out-of-range numbers are EINVAL. Both are + // rejected before any handle is opened, so pid 0 (self) is safe. + assert_eq!(win_process_kill(0, 21), Err("ENOSYS")); + assert_eq!(win_process_kill(0, -3), Err("EINVAL")); + assert_eq!(win_process_kill(0, 99), Err("EINVAL")); + } + + /// Serializes the tests that mutate the process-wide signal slots + /// (listener counts, pending counts, the global drain) — cargo runs + /// tests on parallel threads and interleaved drains would be flaky. + #[cfg(windows)] + static SIGNAL_SLOT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Drive the console control handler directly (it is an ordinary + /// `extern "system"` fn) — deterministic coverage of the mapping, + /// TRUE/FALSE return semantics, and the pending→drain marshal without + /// touching the real console. A live `GenerateConsoleCtrlEvent` + /// round-trip is deliberately NOT tested here: group 0 would hit every + /// process sharing the test console (cargo, the shell), and a fresh + /// process group needs a perry-compiled child binary — verified + /// manually instead (see PR body). + #[cfg(windows)] + #[test] + fn win_console_ctrl_handler_semantics() { + use windows_sys::Win32::System::Console::{ + CTRL_BREAK_EVENT, CTRL_CLOSE_EVENT, CTRL_C_EVENT, + }; + let _guard = SIGNAL_SLOT_TEST_LOCK.lock().unwrap(); + + // No listeners registered → FALSE for every event: the default + // terminate behavior must be preserved. + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_C_EVENT) }, 0); + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_BREAK_EVENT) }, 0); + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_CLOSE_EVENT) }, 0); + // Unmapped control types (logoff/shutdown) → FALSE always. + assert_eq!(unsafe { win_console_ctrl_handler(5) }, 0); + assert_eq!(unsafe { win_console_ctrl_handler(6) }, 0); + + // With a SIGINT listener: CTRL_C → TRUE, records exactly one + // pending SIGINT, which keeps the loop alive until drained. + set_process_signal_listener_count("SIGINT", 1); + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_C_EVENT) }, 1); + // CTRL_BREAK maps to SIGBREAK, which has no listener → FALSE. + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_BREAK_EVENT) }, 0); + assert!(has_active_process_signal_listeners()); + assert_eq!(take_pending_process_signals(), vec!["SIGINT"]); + assert!(!has_active_process_signal_listeners()); + + // Removing the last listener restores default behavior and drops + // any undelivered pending count. + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_C_EVENT) }, 1); + set_process_signal_listener_count("SIGINT", 0); + assert_eq!(unsafe { win_console_ctrl_handler(CTRL_C_EVENT) }, 0); + assert!(take_pending_process_signals().is_empty()); + } + + #[cfg(windows)] + #[test] + fn win_sigterm_registration_is_accepted_and_inert() { + let _guard = SIGNAL_SLOT_TEST_LOCK.lock().unwrap(); + // Node allows process.on('SIGTERM') on Windows; it just never fires + // from the console. Registration must not error, must not pin the + // event loop, and must never produce a pending signal. + assert!(is_process_signal_name("SIGTERM")); + assert!(is_process_signal_name("SIGBREAK")); + assert!(is_process_signal_name("SIGHUP")); + set_process_signal_listener_count("SIGTERM", 1); + assert!(!has_active_process_signal_listeners()); + assert!(take_pending_process_signals().is_empty()); + set_process_signal_listener_count("SIGTERM", 0); + } }