From f6248601f48fa0f4d2d64247f8100fd1d1a02d5a Mon Sep 17 00:00:00 2001 From: David Sherret Date: Fri, 29 Nov 2024 17:36:43 -0500 Subject: [PATCH] fix(task): forward signals to spawned sub-processes on unix (#27141) Closes https://github.com/denoland/deno/issues/18445 --- Cargo.lock | 6 +- cli/Cargo.toml | 2 +- cli/lsp/tsc.rs | 14 +- .../resolvers/common/lifecycle_scripts.rs | 25 ++ cli/task_runner.rs | 89 +++++- cli/tools/task.rs | 73 +++-- runtime/lib.rs | 1 + runtime/ops/process.rs | 14 +- runtime/ops/signal.rs | 248 +---------------- runtime/signal.rs | 257 ++++++++++++++++++ tests/specs/task/signals/__test__.jsonc | 8 + tests/specs/task/signals/deno.jsonc | 5 + tests/specs/task/signals/listener.ts | 16 ++ tests/specs/task/signals/sender.ts | 55 ++++ tests/specs/task/signals/signals.ts | 65 +++++ 15 files changed, 590 insertions(+), 288 deletions(-) create mode 100644 runtime/signal.rs create mode 100644 tests/specs/task/signals/__test__.jsonc create mode 100644 tests/specs/task/signals/deno.jsonc create mode 100644 tests/specs/task/signals/listener.ts create mode 100644 tests/specs/task/signals/sender.ts create mode 100644 tests/specs/task/signals/signals.ts diff --git a/Cargo.lock b/Cargo.lock index 5e27d1a801..9e72dac0df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2136,19 +2136,19 @@ dependencies = [ [[package]] name = "deno_task_shell" -version = "0.18.1" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f444918f7102c1a5a143e9d57809e499fb4d365070519bf2e8bdb16d586af2a" +checksum = "01e09966ce29f8d26b652a43355397e1df43b85759e7824196bf0ceaeaa9a2f4" dependencies = [ "anyhow", "futures", "glob", "monch", + "nix", "os_pipe", "path-dedot", "thiserror", "tokio", - "tokio-util", ] [[package]] diff --git a/cli/Cargo.toml b/cli/Cargo.toml index dd75f903e4..a21e5d5c68 100644 --- a/cli/Cargo.toml +++ b/cli/Cargo.toml @@ -82,7 +82,7 @@ deno_path_util.workspace = true deno_resolver.workspace = true deno_runtime = { workspace = true, features = ["include_js_files_for_snapshotting"] } deno_semver.workspace = true -deno_task_shell = "=0.18.1" +deno_task_shell = "=0.20.1" deno_telemetry.workspace = true deno_terminal.workspace = true libsui = "0.5.0" diff --git a/cli/lsp/tsc.rs b/cli/lsp/tsc.rs index a8e2b91e77..d3d821ebb3 100644 --- a/cli/lsp/tsc.rs +++ b/cli/lsp/tsc.rs @@ -1297,16 +1297,10 @@ impl TsServer { { // When an LSP request is cancelled by the client, the future this is being // executed under and any local variables here will be dropped at the next - // await point. To pass on that cancellation to the TS thread, we make this - // wrapper which cancels the request's token on drop. - struct DroppableToken(CancellationToken); - impl Drop for DroppableToken { - fn drop(&mut self) { - self.0.cancel(); - } - } + // await point. To pass on that cancellation to the TS thread, we use drop_guard + // which cancels the request's token on drop. let token = token.child_token(); - let droppable_token = DroppableToken(token.clone()); + let droppable_token = token.clone().drop_guard(); let (tx, mut rx) = oneshot::channel::>(); let change = self.pending_change.lock().take(); @@ -1320,7 +1314,7 @@ impl TsServer { tokio::select! { value = &mut rx => { let value = value??; - drop(droppable_token); + droppable_token.disarm(); Ok(serde_json::from_str(&value)?) } _ = token.cancelled() => { diff --git a/cli/npm/managed/resolvers/common/lifecycle_scripts.rs b/cli/npm/managed/resolvers/common/lifecycle_scripts.rs index f8b9e8a7e8..958c4bcd19 100644 --- a/cli/npm/managed/resolvers/common/lifecycle_scripts.rs +++ b/cli/npm/managed/resolvers/common/lifecycle_scripts.rs @@ -9,6 +9,7 @@ use deno_npm::resolution::NpmResolutionSnapshot; use deno_runtime::deno_io::FromRawIoHandle; use deno_semver::package::PackageNv; use deno_semver::Version; +use deno_task_shell::KillSignal; use std::borrow::Cow; use std::collections::HashSet; use std::rc::Rc; @@ -155,6 +156,29 @@ impl<'a> LifecycleScripts<'a> { packages: &[NpmResolutionPackage], root_node_modules_dir_path: &Path, progress_bar: &ProgressBar, + ) -> Result<(), AnyError> { + let kill_signal = KillSignal::default(); + let _drop_signal = kill_signal.clone().drop_guard(); + // we don't run with signals forwarded because once signals + // are setup then they're process wide. + self + .finish_with_cancellation( + snapshot, + packages, + root_node_modules_dir_path, + progress_bar, + kill_signal, + ) + .await + } + + async fn finish_with_cancellation( + self, + snapshot: &NpmResolutionSnapshot, + packages: &[NpmResolutionPackage], + root_node_modules_dir_path: &Path, + progress_bar: &ProgressBar, + kill_signal: KillSignal, ) -> Result<(), AnyError> { self.warn_not_run_scripts()?; let get_package_path = @@ -246,6 +270,7 @@ impl<'a> LifecycleScripts<'a> { stderr: TaskStdio::piped(), stdout: TaskStdio::piped(), }), + kill_signal: kill_signal.clone(), }, ) .await?; diff --git a/cli/task_runner.rs b/cli/task_runner.rs index ec043f280e..aabdaf5777 100644 --- a/cli/task_runner.rs +++ b/cli/task_runner.rs @@ -14,6 +14,7 @@ use deno_runtime::deno_node::NodeResolver; use deno_semver::package::PackageNv; use deno_task_shell::ExecutableCommand; use deno_task_shell::ExecuteResult; +use deno_task_shell::KillSignal; use deno_task_shell::ShellCommand; use deno_task_shell::ShellCommandContext; use deno_task_shell::ShellPipeReader; @@ -22,6 +23,7 @@ use lazy_regex::Lazy; use regex::Regex; use tokio::task::JoinHandle; use tokio::task::LocalSet; +use tokio_util::sync::CancellationToken; use crate::npm::CliNpmResolver; use crate::npm::InnerCliNpmResolverRef; @@ -45,9 +47,11 @@ impl TaskStdio { pub fn stdout() -> Self { Self(None, ShellPipeWriter::stdout()) } + pub fn stderr() -> Self { Self(None, ShellPipeWriter::stderr()) } + pub fn piped() -> Self { let (r, w) = deno_task_shell::pipe(); Self(Some(r), w) @@ -62,8 +66,8 @@ pub struct TaskIo { impl Default for TaskIo { fn default() -> Self { Self { - stderr: TaskStdio::stderr(), stdout: TaskStdio::stdout(), + stderr: TaskStdio::stderr(), } } } @@ -78,6 +82,7 @@ pub struct RunTaskOptions<'a> { pub custom_commands: HashMap>, pub root_node_modules_dir: Option<&'a Path>, pub stdio: Option, + pub kill_signal: KillSignal, } pub type TaskCustomCommands = HashMap>; @@ -96,8 +101,12 @@ pub async fn run_task( .with_context(|| format!("Error parsing script '{}'.", opts.task_name))?; let env_vars = prepare_env_vars(opts.env_vars, opts.init_cwd, opts.root_node_modules_dir); - let state = - deno_task_shell::ShellState::new(env_vars, opts.cwd, opts.custom_commands); + let state = deno_task_shell::ShellState::new( + env_vars, + opts.cwd, + opts.custom_commands, + opts.kill_signal, + ); let stdio = opts.stdio.unwrap_or_default(); let ( TaskStdio(stdout_read, stdout_write), @@ -537,6 +546,80 @@ fn resolve_managed_npm_commands( Ok(result) } +/// Runs a deno task future forwarding any signals received +/// to the process. +/// +/// Signal listeners and ctrl+c listening will be setup. +pub async fn run_future_forwarding_signals( + kill_signal: KillSignal, + future: impl std::future::Future, +) -> TOutput { + fn spawn_future_with_cancellation( + future: impl std::future::Future + 'static, + token: CancellationToken, + ) { + deno_core::unsync::spawn(async move { + tokio::select! { + _ = future => {} + _ = token.cancelled() => {} + } + }); + } + + let token = CancellationToken::new(); + let _token_drop_guard = token.clone().drop_guard(); + let _drop_guard = kill_signal.clone().drop_guard(); + + spawn_future_with_cancellation( + listen_ctrl_c(kill_signal.clone()), + token.clone(), + ); + #[cfg(unix)] + spawn_future_with_cancellation( + listen_and_forward_all_signals(kill_signal), + token, + ); + + future.await +} + +async fn listen_ctrl_c(kill_signal: KillSignal) { + while let Ok(()) = tokio::signal::ctrl_c().await { + kill_signal.send(deno_task_shell::SignalKind::SIGINT) + } +} + +#[cfg(unix)] +async fn listen_and_forward_all_signals(kill_signal: KillSignal) { + use deno_core::futures::FutureExt; + use deno_runtime::signal::SIGNAL_NUMS; + + // listen and forward every signal we support + let mut futures = Vec::with_capacity(SIGNAL_NUMS.len()); + for signo in SIGNAL_NUMS.iter().copied() { + if signo == libc::SIGKILL || signo == libc::SIGSTOP { + continue; // skip, can't listen to these + } + + let kill_signal = kill_signal.clone(); + futures.push( + async move { + let Ok(mut stream) = tokio::signal::unix::signal( + tokio::signal::unix::SignalKind::from_raw(signo), + ) else { + return; + }; + let signal_kind: deno_task_shell::SignalKind = signo.into(); + while let Some(()) = stream.recv().await { + kill_signal.send(signal_kind); + } + } + .boxed_local(), + ) + } + futures::future::join_all(futures).await; +} + #[cfg(test)] mod test { diff --git a/cli/tools/task.rs b/cli/tools/task.rs index 4752738c52..a2f76aaf1f 100644 --- a/cli/tools/task.rs +++ b/cli/tools/task.rs @@ -26,6 +26,7 @@ use deno_core::futures::StreamExt; use deno_core::url::Url; use deno_path_util::normalize_path; use deno_runtime::deno_node::NodeResolver; +use deno_task_shell::KillSignal; use deno_task_shell::ShellCommand; use indexmap::IndexMap; use regex::Regex; @@ -37,6 +38,7 @@ use crate::colors; use crate::factory::CliFactory; use crate::npm::CliNpmResolver; use crate::task_runner; +use crate::task_runner::run_future_forwarding_signals; use crate::util::fs::canonicalize_path; #[derive(Debug)] @@ -226,28 +228,33 @@ pub async fn execute_script( concurrency: no_of_concurrent_tasks.into(), }; - if task_flags.eval { - return task_runner - .run_deno_task( - &Url::from_directory_path(cli_options.initial_cwd()).unwrap(), - "", - &TaskDefinition { - command: task_flags.task.as_ref().unwrap().to_string(), - dependencies: vec![], - description: None, - }, - ) - .await; - } - - for task_config in &packages_task_configs { - let exit_code = task_runner.run_tasks(task_config).await?; - if exit_code > 0 { - return Ok(exit_code); + let kill_signal = KillSignal::default(); + run_future_forwarding_signals(kill_signal.clone(), async { + if task_flags.eval { + return task_runner + .run_deno_task( + &Url::from_directory_path(cli_options.initial_cwd()).unwrap(), + "", + &TaskDefinition { + command: task_flags.task.as_ref().unwrap().to_string(), + dependencies: vec![], + description: None, + }, + kill_signal, + ) + .await; } - } - Ok(0) + for task_config in &packages_task_configs { + let exit_code = task_runner.run_tasks(task_config, &kill_signal).await?; + if exit_code > 0 { + return Ok(exit_code); + } + } + + Ok(0) + }) + .await } struct RunSingleOptions<'a> { @@ -255,6 +262,7 @@ struct RunSingleOptions<'a> { script: &'a str, cwd: &'a Path, custom_commands: HashMap>, + kill_signal: KillSignal, } struct TaskRunner<'a> { @@ -270,9 +278,10 @@ impl<'a> TaskRunner<'a> { pub async fn run_tasks( &self, pkg_tasks_config: &PackageTaskInfo, + kill_signal: &KillSignal, ) -> Result { match sort_tasks_topo(pkg_tasks_config) { - Ok(sorted) => self.run_tasks_in_parallel(sorted).await, + Ok(sorted) => self.run_tasks_in_parallel(sorted, kill_signal).await, Err(err) => match err { TaskError::NotFound(name) => { if self.task_flags.is_run { @@ -307,6 +316,7 @@ impl<'a> TaskRunner<'a> { async fn run_tasks_in_parallel( &self, tasks: Vec>, + kill_signal: &KillSignal, ) -> Result { struct PendingTasksContext<'a> { completed: HashSet, @@ -327,6 +337,7 @@ impl<'a> TaskRunner<'a> { fn get_next_task<'b>( &mut self, runner: &'b TaskRunner<'b>, + kill_signal: &KillSignal, ) -> Option< LocalBoxFuture<'b, Result<(i32, &'a ResolvedTask<'a>), AnyError>>, > @@ -349,15 +360,23 @@ impl<'a> TaskRunner<'a> { } self.running.insert(task.id); + let kill_signal = kill_signal.clone(); return Some( async move { match task.task_or_script { TaskOrScript::Task(_, def) => { - runner.run_deno_task(task.folder_url, task.name, def).await + runner + .run_deno_task(task.folder_url, task.name, def, kill_signal) + .await } TaskOrScript::Script(scripts, _) => { runner - .run_npm_script(task.folder_url, task.name, scripts) + .run_npm_script( + task.folder_url, + task.name, + scripts, + kill_signal, + ) .await } } @@ -380,7 +399,7 @@ impl<'a> TaskRunner<'a> { while context.has_remaining_tasks() { while queue.len() < self.concurrency { - if let Some(task) = context.get_next_task(self) { + if let Some(task) = context.get_next_task(self, kill_signal) { queue.push(task); } else { break; @@ -409,6 +428,7 @@ impl<'a> TaskRunner<'a> { dir_url: &Url, task_name: &str, definition: &TaskDefinition, + kill_signal: KillSignal, ) -> Result { let cwd = match &self.task_flags.cwd { Some(path) => canonicalize_path(&PathBuf::from(path)) @@ -426,6 +446,7 @@ impl<'a> TaskRunner<'a> { script: &definition.command, cwd: &cwd, custom_commands, + kill_signal, }) .await } @@ -435,6 +456,7 @@ impl<'a> TaskRunner<'a> { dir_url: &Url, task_name: &str, scripts: &IndexMap, + kill_signal: KillSignal, ) -> Result { // ensure the npm packages are installed if using a managed resolver if let Some(npm_resolver) = self.npm_resolver.as_managed() { @@ -466,6 +488,7 @@ impl<'a> TaskRunner<'a> { script, cwd: &cwd, custom_commands: custom_commands.clone(), + kill_signal: kill_signal.clone(), }) .await?; if exit_code > 0 { @@ -486,6 +509,7 @@ impl<'a> TaskRunner<'a> { script, cwd, custom_commands, + kill_signal, } = opts; output_task( @@ -504,6 +528,7 @@ impl<'a> TaskRunner<'a> { argv: self.cli_options.argv(), root_node_modules_dir: self.npm_resolver.root_node_modules_path(), stdio: None, + kill_signal, }) .await? .exit_code, diff --git a/runtime/lib.rs b/runtime/lib.rs index 53d4f265e0..1ce325964f 100644 --- a/runtime/lib.rs +++ b/runtime/lib.rs @@ -34,6 +34,7 @@ pub mod inspector_server; pub mod js; pub mod ops; pub mod permissions; +pub mod signal; pub mod snapshot; pub mod sys_info; pub mod tokio_util; diff --git a/runtime/ops/process.rs b/runtime/ops/process.rs index 83d9317d32..422f229632 100644 --- a/runtime/ops/process.rs +++ b/runtime/ops/process.rs @@ -256,9 +256,7 @@ impl TryFrom for ChildStatus { success: false, code: 128 + signal, #[cfg(unix)] - signal: Some( - crate::ops::signal::signal_int_to_str(signal)?.to_string(), - ), + signal: Some(crate::signal::signal_int_to_str(signal)?.to_string()), #[cfg(not(unix))] signal: None, } @@ -1076,7 +1074,8 @@ mod deprecated { #[cfg(unix)] pub fn kill(pid: i32, signal: &str) -> Result<(), ProcessError> { - let signo = super::super::signal::signal_str_to_int(signal)?; + let signo = crate::signal::signal_str_to_int(signal) + .map_err(SignalError::InvalidSignalStr)?; use nix::sys::signal::kill as unix_kill; use nix::sys::signal::Signal; use nix::unistd::Pid; @@ -1099,7 +1098,12 @@ mod deprecated { use winapi::um::winnt::PROCESS_TERMINATE; if !matches!(signal, "SIGKILL" | "SIGTERM") { - Err(SignalError::InvalidSignalStr(signal.to_string()).into()) + Err( + SignalError::InvalidSignalStr(crate::signal::InvalidSignalStrError( + signal.to_string(), + )) + .into(), + ) } else if pid <= 0 { Err(ProcessError::InvalidPid) } else { diff --git a/runtime/ops/signal.rs b/runtime/ops/signal.rs index e1e4ab68bc..ef87c37297 100644 --- a/runtime/ops/signal.rs +++ b/runtime/ops/signal.rs @@ -46,34 +46,10 @@ deno_core::extension!( #[derive(Debug, thiserror::Error)] pub enum SignalError { - #[cfg(any( - target_os = "android", - target_os = "linux", - target_os = "openbsd", - target_os = "openbsd", - target_os = "macos", - target_os = "solaris", - target_os = "illumos" - ))] - #[error("Invalid signal: {0}")] - InvalidSignalStr(String), - #[cfg(any( - target_os = "android", - target_os = "linux", - target_os = "openbsd", - target_os = "openbsd", - target_os = "macos", - target_os = "solaris", - target_os = "illumos" - ))] - #[error("Invalid signal: {0}")] - InvalidSignalInt(libc::c_int), - #[cfg(target_os = "windows")] - #[error("Windows only supports ctrl-c (SIGINT) and ctrl-break (SIGBREAK), but got {0}")] - InvalidSignalStr(String), - #[cfg(target_os = "windows")] - #[error("Windows only supports ctrl-c (SIGINT) and ctrl-break (SIGBREAK), but got {0}")] - InvalidSignalInt(libc::c_int), + #[error(transparent)] + InvalidSignalStr(#[from] crate::signal::InvalidSignalStrError), + #[error(transparent)] + InvalidSignalInt(#[from] crate::signal::InvalidSignalIntError), #[error("Binding to signal '{0}' is not allowed")] SignalNotAllowed(String), #[error("{0}")] @@ -181,218 +157,6 @@ impl Resource for SignalStreamResource { } } -macro_rules! first_literal { - ($head:literal $(, $tail:literal)*) => { - $head - }; -} -macro_rules! signal_dict { - ($(($number:literal, $($name:literal)|+)),*) => { - pub fn signal_str_to_int(s: &str) -> Result { - match s { - $($($name)|* => Ok($number),)* - _ => Err(SignalError::InvalidSignalStr(s.to_string())), - } - } - - pub fn signal_int_to_str(s: libc::c_int) -> Result<&'static str, SignalError> { - match s { - $($number => Ok(first_literal!($($name),+)),)* - _ => Err(SignalError::InvalidSignalInt(s)), - } - } - } -} - -#[cfg(target_os = "freebsd")] -signal_dict!( - (1, "SIGHUP"), - (2, "SIGINT"), - (3, "SIGQUIT"), - (4, "SIGILL"), - (5, "SIGTRAP"), - (6, "SIGABRT" | "SIGIOT"), - (7, "SIGEMT"), - (8, "SIGFPE"), - (9, "SIGKILL"), - (10, "SIGBUS"), - (11, "SIGSEGV"), - (12, "SIGSYS"), - (13, "SIGPIPE"), - (14, "SIGALRM"), - (15, "SIGTERM"), - (16, "SIGURG"), - (17, "SIGSTOP"), - (18, "SIGTSTP"), - (19, "SIGCONT"), - (20, "SIGCHLD"), - (21, "SIGTTIN"), - (22, "SIGTTOU"), - (23, "SIGIO"), - (24, "SIGXCPU"), - (25, "SIGXFSZ"), - (26, "SIGVTALRM"), - (27, "SIGPROF"), - (28, "SIGWINCH"), - (29, "SIGINFO"), - (30, "SIGUSR1"), - (31, "SIGUSR2"), - (32, "SIGTHR"), - (33, "SIGLIBRT") -); - -#[cfg(target_os = "openbsd")] -signal_dict!( - (1, "SIGHUP"), - (2, "SIGINT"), - (3, "SIGQUIT"), - (4, "SIGILL"), - (5, "SIGTRAP"), - (6, "SIGABRT" | "SIGIOT"), - (7, "SIGEMT"), - (8, "SIGKILL"), - (10, "SIGBUS"), - (11, "SIGSEGV"), - (12, "SIGSYS"), - (13, "SIGPIPE"), - (14, "SIGALRM"), - (15, "SIGTERM"), - (16, "SIGURG"), - (17, "SIGSTOP"), - (18, "SIGTSTP"), - (19, "SIGCONT"), - (20, "SIGCHLD"), - (21, "SIGTTIN"), - (22, "SIGTTOU"), - (23, "SIGIO"), - (24, "SIGXCPU"), - (25, "SIGXFSZ"), - (26, "SIGVTALRM"), - (27, "SIGPROF"), - (28, "SIGWINCH"), - (29, "SIGINFO"), - (30, "SIGUSR1"), - (31, "SIGUSR2"), - (32, "SIGTHR") -); - -#[cfg(any(target_os = "android", target_os = "linux"))] -signal_dict!( - (1, "SIGHUP"), - (2, "SIGINT"), - (3, "SIGQUIT"), - (4, "SIGILL"), - (5, "SIGTRAP"), - (6, "SIGABRT" | "SIGIOT"), - (7, "SIGBUS"), - (8, "SIGFPE"), - (9, "SIGKILL"), - (10, "SIGUSR1"), - (11, "SIGSEGV"), - (12, "SIGUSR2"), - (13, "SIGPIPE"), - (14, "SIGALRM"), - (15, "SIGTERM"), - (16, "SIGSTKFLT"), - (17, "SIGCHLD"), - (18, "SIGCONT"), - (19, "SIGSTOP"), - (20, "SIGTSTP"), - (21, "SIGTTIN"), - (22, "SIGTTOU"), - (23, "SIGURG"), - (24, "SIGXCPU"), - (25, "SIGXFSZ"), - (26, "SIGVTALRM"), - (27, "SIGPROF"), - (28, "SIGWINCH"), - (29, "SIGIO" | "SIGPOLL"), - (30, "SIGPWR"), - (31, "SIGSYS" | "SIGUNUSED") -); - -#[cfg(target_os = "macos")] -signal_dict!( - (1, "SIGHUP"), - (2, "SIGINT"), - (3, "SIGQUIT"), - (4, "SIGILL"), - (5, "SIGTRAP"), - (6, "SIGABRT" | "SIGIOT"), - (7, "SIGEMT"), - (8, "SIGFPE"), - (9, "SIGKILL"), - (10, "SIGBUS"), - (11, "SIGSEGV"), - (12, "SIGSYS"), - (13, "SIGPIPE"), - (14, "SIGALRM"), - (15, "SIGTERM"), - (16, "SIGURG"), - (17, "SIGSTOP"), - (18, "SIGTSTP"), - (19, "SIGCONT"), - (20, "SIGCHLD"), - (21, "SIGTTIN"), - (22, "SIGTTOU"), - (23, "SIGIO"), - (24, "SIGXCPU"), - (25, "SIGXFSZ"), - (26, "SIGVTALRM"), - (27, "SIGPROF"), - (28, "SIGWINCH"), - (29, "SIGINFO"), - (30, "SIGUSR1"), - (31, "SIGUSR2") -); - -#[cfg(any(target_os = "solaris", target_os = "illumos"))] -signal_dict!( - (1, "SIGHUP"), - (2, "SIGINT"), - (3, "SIGQUIT"), - (4, "SIGILL"), - (5, "SIGTRAP"), - (6, "SIGABRT" | "SIGIOT"), - (7, "SIGEMT"), - (8, "SIGFPE"), - (9, "SIGKILL"), - (10, "SIGBUS"), - (11, "SIGSEGV"), - (12, "SIGSYS"), - (13, "SIGPIPE"), - (14, "SIGALRM"), - (15, "SIGTERM"), - (16, "SIGUSR1"), - (17, "SIGUSR2"), - (18, "SIGCHLD"), - (19, "SIGPWR"), - (20, "SIGWINCH"), - (21, "SIGURG"), - (22, "SIGPOLL"), - (23, "SIGSTOP"), - (24, "SIGTSTP"), - (25, "SIGCONT"), - (26, "SIGTTIN"), - (27, "SIGTTOU"), - (28, "SIGVTALRM"), - (29, "SIGPROF"), - (30, "SIGXCPU"), - (31, "SIGXFSZ"), - (32, "SIGWAITING"), - (33, "SIGLWP"), - (34, "SIGFREEZE"), - (35, "SIGTHAW"), - (36, "SIGCANCEL"), - (37, "SIGLOST"), - (38, "SIGXRES"), - (39, "SIGJVM1"), - (40, "SIGJVM2") -); - -#[cfg(target_os = "windows")] -signal_dict!((2, "SIGINT"), (21, "SIGBREAK")); - #[cfg(unix)] #[op2(fast)] #[smi] @@ -400,7 +164,7 @@ fn op_signal_bind( state: &mut OpState, #[string] sig: &str, ) -> Result { - let signo = signal_str_to_int(sig)?; + let signo = crate::signal::signal_str_to_int(sig)?; if signal_hook_registry::FORBIDDEN.contains(&signo) { return Err(SignalError::SignalNotAllowed(sig.to_string())); } @@ -437,7 +201,7 @@ fn op_signal_bind( state: &mut OpState, #[string] sig: &str, ) -> Result { - let signo = signal_str_to_int(sig)?; + let signo = crate::signal::signal_str_to_int(sig)?; let resource = SignalStreamResource { signal: AsyncRefCell::new(match signo { // SIGINT diff --git a/runtime/signal.rs b/runtime/signal.rs new file mode 100644 index 0000000000..0ef83d7de8 --- /dev/null +++ b/runtime/signal.rs @@ -0,0 +1,257 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +#[cfg(target_os = "windows")] +#[derive(Debug, thiserror::Error)] +#[error("Windows only supports ctrl-c (SIGINT) and ctrl-break (SIGBREAK), but got {0}")] +pub struct InvalidSignalStrError(pub String); + +#[cfg(any( + target_os = "android", + target_os = "linux", + target_os = "openbsd", + target_os = "openbsd", + target_os = "macos", + target_os = "solaris", + target_os = "illumos" +))] +#[derive(Debug, thiserror::Error)] +#[error("Invalid signal: {0}")] +pub struct InvalidSignalStrError(pub String); + +#[cfg(target_os = "windows")] +#[derive(Debug, thiserror::Error)] +#[error("Windows only supports ctrl-c (SIGINT) and ctrl-break (SIGBREAK), but got {0}")] +pub struct InvalidSignalIntError(pub libc::c_int); + +#[cfg(any( + target_os = "android", + target_os = "linux", + target_os = "openbsd", + target_os = "openbsd", + target_os = "macos", + target_os = "solaris", + target_os = "illumos" +))] +#[derive(Debug, thiserror::Error)] +#[error("Invalid signal: {0}")] +pub struct InvalidSignalIntError(pub libc::c_int); + +macro_rules! first_literal { + ($head:literal $(, $tail:literal)*) => { + $head + }; +} + +macro_rules! signal_dict { + ($(($number:literal, $($name:literal)|+)),*) => { + + pub const SIGNAL_NUMS: &'static [libc::c_int] = &[ + $( + $number + ),* + ]; + + pub fn signal_str_to_int(s: &str) -> Result { + match s { + $($($name)|* => Ok($number),)* + _ => Err(InvalidSignalStrError(s.to_string())), + } + } + + pub fn signal_int_to_str(s: libc::c_int) -> Result<&'static str, InvalidSignalIntError> { + match s { + $($number => Ok(first_literal!($($name),+)),)* + _ => Err(InvalidSignalIntError(s)), + } + } + } +} + +#[cfg(target_os = "freebsd")] +signal_dict!( + (1, "SIGHUP"), + (2, "SIGINT"), + (3, "SIGQUIT"), + (4, "SIGILL"), + (5, "SIGTRAP"), + (6, "SIGABRT" | "SIGIOT"), + (7, "SIGEMT"), + (8, "SIGFPE"), + (9, "SIGKILL"), + (10, "SIGBUS"), + (11, "SIGSEGV"), + (12, "SIGSYS"), + (13, "SIGPIPE"), + (14, "SIGALRM"), + (15, "SIGTERM"), + (16, "SIGURG"), + (17, "SIGSTOP"), + (18, "SIGTSTP"), + (19, "SIGCONT"), + (20, "SIGCHLD"), + (21, "SIGTTIN"), + (22, "SIGTTOU"), + (23, "SIGIO"), + (24, "SIGXCPU"), + (25, "SIGXFSZ"), + (26, "SIGVTALRM"), + (27, "SIGPROF"), + (28, "SIGWINCH"), + (29, "SIGINFO"), + (30, "SIGUSR1"), + (31, "SIGUSR2"), + (32, "SIGTHR"), + (33, "SIGLIBRT") +); + +#[cfg(target_os = "openbsd")] +signal_dict!( + (1, "SIGHUP"), + (2, "SIGINT"), + (3, "SIGQUIT"), + (4, "SIGILL"), + (5, "SIGTRAP"), + (6, "SIGABRT" | "SIGIOT"), + (7, "SIGEMT"), + (8, "SIGKILL"), + (10, "SIGBUS"), + (11, "SIGSEGV"), + (12, "SIGSYS"), + (13, "SIGPIPE"), + (14, "SIGALRM"), + (15, "SIGTERM"), + (16, "SIGURG"), + (17, "SIGSTOP"), + (18, "SIGTSTP"), + (19, "SIGCONT"), + (20, "SIGCHLD"), + (21, "SIGTTIN"), + (22, "SIGTTOU"), + (23, "SIGIO"), + (24, "SIGXCPU"), + (25, "SIGXFSZ"), + (26, "SIGVTALRM"), + (27, "SIGPROF"), + (28, "SIGWINCH"), + (29, "SIGINFO"), + (30, "SIGUSR1"), + (31, "SIGUSR2"), + (32, "SIGTHR") +); + +#[cfg(any(target_os = "android", target_os = "linux"))] +signal_dict!( + (1, "SIGHUP"), + (2, "SIGINT"), + (3, "SIGQUIT"), + (4, "SIGILL"), + (5, "SIGTRAP"), + (6, "SIGABRT" | "SIGIOT"), + (7, "SIGBUS"), + (8, "SIGFPE"), + (9, "SIGKILL"), + (10, "SIGUSR1"), + (11, "SIGSEGV"), + (12, "SIGUSR2"), + (13, "SIGPIPE"), + (14, "SIGALRM"), + (15, "SIGTERM"), + (16, "SIGSTKFLT"), + (17, "SIGCHLD"), + (18, "SIGCONT"), + (19, "SIGSTOP"), + (20, "SIGTSTP"), + (21, "SIGTTIN"), + (22, "SIGTTOU"), + (23, "SIGURG"), + (24, "SIGXCPU"), + (25, "SIGXFSZ"), + (26, "SIGVTALRM"), + (27, "SIGPROF"), + (28, "SIGWINCH"), + (29, "SIGIO" | "SIGPOLL"), + (30, "SIGPWR"), + (31, "SIGSYS" | "SIGUNUSED") +); + +#[cfg(target_os = "macos")] +signal_dict!( + (1, "SIGHUP"), + (2, "SIGINT"), + (3, "SIGQUIT"), + (4, "SIGILL"), + (5, "SIGTRAP"), + (6, "SIGABRT" | "SIGIOT"), + (7, "SIGEMT"), + (8, "SIGFPE"), + (9, "SIGKILL"), + (10, "SIGBUS"), + (11, "SIGSEGV"), + (12, "SIGSYS"), + (13, "SIGPIPE"), + (14, "SIGALRM"), + (15, "SIGTERM"), + (16, "SIGURG"), + (17, "SIGSTOP"), + (18, "SIGTSTP"), + (19, "SIGCONT"), + (20, "SIGCHLD"), + (21, "SIGTTIN"), + (22, "SIGTTOU"), + (23, "SIGIO"), + (24, "SIGXCPU"), + (25, "SIGXFSZ"), + (26, "SIGVTALRM"), + (27, "SIGPROF"), + (28, "SIGWINCH"), + (29, "SIGINFO"), + (30, "SIGUSR1"), + (31, "SIGUSR2") +); + +#[cfg(any(target_os = "solaris", target_os = "illumos"))] +signal_dict!( + (1, "SIGHUP"), + (2, "SIGINT"), + (3, "SIGQUIT"), + (4, "SIGILL"), + (5, "SIGTRAP"), + (6, "SIGABRT" | "SIGIOT"), + (7, "SIGEMT"), + (8, "SIGFPE"), + (9, "SIGKILL"), + (10, "SIGBUS"), + (11, "SIGSEGV"), + (12, "SIGSYS"), + (13, "SIGPIPE"), + (14, "SIGALRM"), + (15, "SIGTERM"), + (16, "SIGUSR1"), + (17, "SIGUSR2"), + (18, "SIGCHLD"), + (19, "SIGPWR"), + (20, "SIGWINCH"), + (21, "SIGURG"), + (22, "SIGPOLL"), + (23, "SIGSTOP"), + (24, "SIGTSTP"), + (25, "SIGCONT"), + (26, "SIGTTIN"), + (27, "SIGTTOU"), + (28, "SIGVTALRM"), + (29, "SIGPROF"), + (30, "SIGXCPU"), + (31, "SIGXFSZ"), + (32, "SIGWAITING"), + (33, "SIGLWP"), + (34, "SIGFREEZE"), + (35, "SIGTHAW"), + (36, "SIGCANCEL"), + (37, "SIGLOST"), + (38, "SIGXRES"), + (39, "SIGJVM1"), + (40, "SIGJVM2") +); + +#[cfg(target_os = "windows")] +signal_dict!((2, "SIGINT"), (21, "SIGBREAK")); diff --git a/tests/specs/task/signals/__test__.jsonc b/tests/specs/task/signals/__test__.jsonc new file mode 100644 index 0000000000..69801c46bf --- /dev/null +++ b/tests/specs/task/signals/__test__.jsonc @@ -0,0 +1,8 @@ +{ + // signals don't really exist on windows + "if": "unix", + // this runs a deno task + "args": "run -A --check sender.ts", + // just ensure this doesn't hang and completes successfully + "output": "[WILDCARD]" +} diff --git a/tests/specs/task/signals/deno.jsonc b/tests/specs/task/signals/deno.jsonc new file mode 100644 index 0000000000..18057558ee --- /dev/null +++ b/tests/specs/task/signals/deno.jsonc @@ -0,0 +1,5 @@ +{ + "tasks": { + "listener": "deno run listener.ts" + } +} diff --git a/tests/specs/task/signals/listener.ts b/tests/specs/task/signals/listener.ts new file mode 100644 index 0000000000..e4f54c2117 --- /dev/null +++ b/tests/specs/task/signals/listener.ts @@ -0,0 +1,16 @@ +import { signals } from "./signals.ts"; + +for (const signal of signals) { + Deno.addSignalListener(signal, () => { + console.log("Received", signal); + if (signal === "SIGTERM") { + Deno.exit(0); + } + }); +} + +setInterval(() => { + // keep alive +}, 1000); + +console.log("Ready"); diff --git a/tests/specs/task/signals/sender.ts b/tests/specs/task/signals/sender.ts new file mode 100644 index 0000000000..70f4dd788d --- /dev/null +++ b/tests/specs/task/signals/sender.ts @@ -0,0 +1,55 @@ +import { signals } from "./signals.ts"; + +class StdoutReader { + readonly #reader: ReadableStreamDefaultReader; + #text = ""; + + constructor(stream: ReadableStream) { + const textStream = stream.pipeThrough(new TextDecoderStream()); + this.#reader = textStream.getReader(); + } + + [Symbol.dispose]() { + this.#reader.releaseLock(); + } + + async waitForText(waitingText: string) { + if (this.#text.includes(waitingText)) { + return; + } + + while (true) { + const { value, done } = await this.#reader.read(); + if (value) { + this.#text += value; + if (this.#text.includes(waitingText)) { + break; + } + } + if (done) { + throw new Error("Did not find text: " + waitingText); + } + } + } +} + +const command = new Deno.Command(Deno.execPath(), { + args: ["task", "listener"], + stdout: "piped", +}); + +const child = command.spawn(); +const reader = new StdoutReader(child.stdout!); +await reader.waitForText("Ready"); + +for (const signal of signals) { + if (signal === "SIGTERM") { + continue; + } + console.error("Sending", signal); + child.kill(signal); + await reader.waitForText("Received " + signal); +} + +console.error("Sending SIGTERM"); +child.kill("SIGTERM"); diff --git a/tests/specs/task/signals/signals.ts b/tests/specs/task/signals/signals.ts new file mode 100644 index 0000000000..dd05ee1d17 --- /dev/null +++ b/tests/specs/task/signals/signals.ts @@ -0,0 +1,65 @@ +const signals = [ + "SIGABRT", + "SIGALRM", + "SIGBUS", + "SIGCHLD", + "SIGCONT", + "SIGEMT", + "SIGFPE", + "SIGHUP", + "SIGILL", + "SIGINFO", + "SIGINT", + "SIGIO", + "SIGPOLL", + "SIGPIPE", + "SIGPROF", + "SIGPWR", + "SIGQUIT", + "SIGSEGV", + "SIGSTKFLT", + "SIGSYS", + "SIGTERM", + "SIGTRAP", + "SIGTSTP", + "SIGTTIN", + "SIGTTOU", + "SIGURG", + "SIGUSR1", + "SIGUSR2", + "SIGVTALRM", + "SIGWINCH", + "SIGXCPU", + "SIGXFSZ", +] as const; + +// SIGKILL and SIGSTOP are not stoppable, SIGBREAK is for windows, and SIGUNUSED is not defined +type SignalsToTest = Exclude< + Deno.Signal, + "SIGKILL" | "SIGSTOP" | "SIGBREAK" | "SIGUNUSED" +>; +type EnsureAllSignalsIncluded = SignalsToTest extends typeof signals[number] + ? typeof signals[number] extends SignalsToTest ? true + : never + : never; +const _checkSignals: EnsureAllSignalsIncluded = true; + +const osSpecificSignals = signals.filter((s) => { + switch (s) { + case "SIGEMT": + return Deno.build.os === "darwin"; + case "SIGINFO": + case "SIGFPE": + case "SIGILL": + case "SIGSEGV": + return Deno.build.os === "freebsd"; + case "SIGPOLL": + case "SIGPWR": + case "SIGSTKFLT": + return Deno.build.os === "linux"; + default: + return true; + } +}); + +export { osSpecificSignals as signals };