/
githubmirror
/
deno
Обзор
Документация
Войти
/
githubmirror
/
deno
Код
Запросы
0
Пакеты
0
Релизы
0
Аналитика
Безопасность
v2.8.2
cli/task_runner.rs
771 строка
22 KB
em
fix(task): restore terminal mode after task exits on Windows (#34685)
02 июн 2026, 14:48
Не верифицирован
02 июн 2026, 14:48
5857f18
Код
Авторство
О чём код?
// Copyright 2018-2026 the Deno authors. MIT license. use std::collections::HashMap; use std::ffi::OsStr; use std::ffi::OsString; use std::io::Write; use std::path::Path; use std::path::PathBuf; use std::rc::Rc; use deno_core::anyhow::Context; use deno_core::error::AnyError; use deno_core::futures::future::LocalBoxFuture; 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; use deno_task_shell::ShellPipeWriter; use tokio::task::JoinHandle; use tokio::task::LocalSet; use tokio_util::sync::CancellationToken; use crate::node::CliNodeResolver; use crate::npm::CliManagedNpmResolver; use crate::npm::CliNpmResolver; use crate::util::fs::canonicalize_path; pub fn get_script_with_args(script: &str, argv: &[String]) -> String { let additional_args = argv .iter() // Wrap each argument in single quotes so the shell preserves the // content literally (including backslashes, $, `, etc.). For an // argument containing a single quote, splice in a double-quoted `'` // using the POSIX idiom `'foo'"'"'bar'`. .map(|a| format!("'{}'", a.replace('\'', "'\"'\"'"))) .collect::<Vec<_>>() .join(" "); let script = format!("{script} {additional_args}"); script.trim().to_owned() } pub struct TaskStdio(Option<ShellPipeReader>, ShellPipeWriter); pub struct PrefixedWriter<W: std::io::Write> { prefix: Vec<u8>, inner: W, line_buf: Vec<u8>, at_line_start: bool, } impl<W: std::io::Write> PrefixedWriter<W> { pub fn new(prefix: String, inner: W) -> Self { Self { prefix: prefix.into_bytes(), inner, line_buf: Vec::new(), at_line_start: true, } } } impl<W: std::io::Write> std::io::Write for PrefixedWriter<W> { fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> { let mut rest = buf; while !rest.is_empty() { if self.at_line_start { self.line_buf.extend_from_slice(&self.prefix); self.at_line_start = false; } match rest.iter().position(|&b| b == b'\n') { Some(pos) => { self.line_buf.extend_from_slice(&rest[..pos + 1]); self.inner.write_all(&self.line_buf)?; self.line_buf.clear(); self.at_line_start = true; rest = &rest[pos + 1..]; } None => { self.line_buf.extend_from_slice(rest); break; } } } Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { if !self.line_buf.is_empty() { self.inner.write_all(&self.line_buf)?; self.line_buf.clear(); self.at_line_start = true; } self.inner.flush() } } pub fn make_prefixed_task_io(prefix: String) -> (TaskIo, Vec<JoinHandle<()>>) { let (out_r, out_w) = deno_task_shell::pipe(); let (err_r, err_w) = deno_task_shell::pipe(); let out_prefix = prefix.clone(); let out_handle = tokio::task::spawn_blocking(move || { let mut writer = PrefixedWriter::new(out_prefix, std::io::stdout()); let _ = out_r.pipe_to(&mut writer); let _ = writer.flush(); }); let err_handle = tokio::task::spawn_blocking(move || { let mut writer = PrefixedWriter::new(prefix, std::io::stderr()); let _ = err_r.pipe_to(&mut writer); let _ = writer.flush(); }); ( TaskIo { stdout: TaskStdio(None, out_w), stderr: TaskStdio(None, err_w), }, vec![out_handle, err_handle], ) } 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) } } pub struct TaskIo { pub stdout: TaskStdio, pub stderr: TaskStdio, } impl Default for TaskIo { fn default() -> Self { Self { stdout: TaskStdio::stdout(), stderr: TaskStdio::stderr(), } } } pub struct RunTaskOptions<'a> { pub task_name: &'a str, pub script: &'a str, pub cwd: PathBuf, pub init_cwd: &'a Path, pub env_vars: HashMap<OsString, OsString>, pub argv: &'a [String], pub custom_commands: HashMap<String, Rc<dyn ShellCommand>>, /// Directories to prepend to `PATH` for the duration of the task, /// ordered closest-first (closest entry ends up first in `PATH`). pub node_modules_bin_dirs: &'a [PathBuf], pub stdio: Option<TaskIo>, pub kill_signal: KillSignal, } pub type TaskCustomCommands = HashMap<String, Rc<dyn ShellCommand>>; pub struct TaskResult { pub exit_code: i32, pub stdout: Option<Vec<u8>>, pub stderr: Option<Vec<u8>>, } pub async fn run_task( mut opts: RunTaskOptions<'_>, ) -> Result<TaskResult, AnyError> { let script = get_script_with_args(opts.script, opts.argv); let seq_list = deno_task_shell::parser::parse(&script) .with_context(|| format!("Error parsing script '{}'.", opts.task_name))?; let env_vars = prepare_env_vars(opts.env_vars, opts.init_cwd, opts.node_modules_bin_dirs); if !opts.custom_commands.contains_key("deno") { opts .custom_commands .insert("deno".to_string(), Rc::new(DenoCommand::default())); } 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), TaskStdio(stderr_read, stderr_write), ) = (stdio.stdout, stdio.stderr); fn read(reader: ShellPipeReader) -> JoinHandle<Result<Vec<u8>, AnyError>> { tokio::task::spawn_blocking(move || { let mut buf = Vec::new(); reader.pipe_to(&mut buf)?; Ok(buf) }) } let stdout = stdout_read.map(read); let stderr = stderr_read.map(read); let local = LocalSet::new(); let future = async move { let exit_code = deno_task_shell::execute_with_pipes( seq_list, state, ShellPipeReader::stdin(), stdout_write, stderr_write, ) .await; Ok::<_, AnyError>(TaskResult { exit_code, stdout: if let Some(stdout) = stdout { Some(stdout.await??) } else { None }, stderr: if let Some(stderr) = stderr { Some(stderr.await??) } else { None }, }) }; local.run_until(future).await } fn prepare_env_vars( mut env_vars: HashMap<OsString, OsString>, initial_cwd: &Path, node_modules_bin_dirs: &[PathBuf], ) -> HashMap<OsString, OsString> { const INIT_CWD_NAME: &str = "INIT_CWD"; if !env_vars.contains_key(OsStr::new(INIT_CWD_NAME)) { // if not set, set an INIT_CWD env var that has the cwd env_vars.insert( INIT_CWD_NAME.into(), initial_cwd.to_path_buf().into_os_string(), ); } if !env_vars .contains_key(OsStr::new(crate::npm::NPM_CONFIG_USER_AGENT_ENV_VAR)) { env_vars.insert( crate::npm::NPM_CONFIG_USER_AGENT_ENV_VAR.into(), crate::npm::get_npm_config_user_agent().into(), ); } // Prepend in reverse so the closest dir ends up first in `PATH`. for bin_dir in node_modules_bin_dirs.iter().rev() { prepend_to_path(&mut env_vars, bin_dir.as_os_str().to_os_string()); } env_vars } fn prepend_to_path( env_vars: &mut HashMap<OsString, OsString>, value: OsString, ) { match env_vars.get_mut(OsStr::new("PATH")) { Some(path) => { if path.is_empty() { *path = value; } else { let mut new_path = value; new_path.push(if cfg!(windows) { ";" } else { ":" }); new_path.push(&path); *path = new_path; } } None => { env_vars.insert("PATH".into(), value); } } } pub fn real_env_vars() -> HashMap<OsString, OsString> { std::env::vars_os() .map(|(k, v)| { if cfg!(windows) { (k.to_ascii_uppercase(), v) } else { (k, v) } }) .collect() } // WARNING: Do not depend on this env var in user code. It's not stable API. pub(crate) static USE_PKG_JSON_HIDDEN_ENV_VAR_NAME: &str = "DENO_INTERNAL_TASK_USE_PKG_JSON"; pub struct NpmCommand; impl ShellCommand for NpmCommand { fn execute( &self, mut context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { if context.args.first().and_then(|s| s.to_str()) == Some("run") && context.args.len() >= 2 // for now, don't run any npm scripts that have a flag because // we don't handle stuff like `--workspaces` properly && !context.args.iter().any(|s| s.to_string_lossy().starts_with('-')) { // run with deno task instead let mut args: Vec<OsString> = Vec::with_capacity(context.args.len()); args.push("task".into()); args.extend(context.args.into_iter().skip(1)); let mut state = context.state; state.apply_env_var( OsStr::new(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME), OsStr::new("1"), ); return ExecutableCommand::new( "deno".to_string(), std::env::current_exe() .and_then(|p| canonicalize_path(&p)) .unwrap(), ) .execute(ShellCommandContext { args, state, ..context }); } // fallback to running the real npm command let npm_path = match context.state.resolve_command_path(OsStr::new("npm")) { Ok(path) => path, Err(err) => { let _ = context.stderr.write_line(&format!("{}", err)); return Box::pin(std::future::ready(ExecuteResult::from_exit_code( err.exit_code(), ))); } }; ExecutableCommand::new("npm".to_string(), npm_path).execute(context) } } pub struct DenoCommand(ExecutableCommand); impl Default for DenoCommand { fn default() -> Self { Self(ExecutableCommand::new( "deno".to_string(), std::env::current_exe() .and_then(|p| canonicalize_path(&p)) .unwrap(), )) } } impl ShellCommand for DenoCommand { fn execute( &self, context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { self.0.execute(context) } } pub struct NodeCommand; impl ShellCommand for NodeCommand { fn execute( &self, context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { // continue to use Node if the first argument is a flag // or there are no arguments provided for some reason if context.args.is_empty() || ({ let first_arg = context.args[0].to_string_lossy(); first_arg.starts_with('-') // has a flag }) { return ExecutableCommand::new("node".to_string(), PathBuf::from("node")) .execute(context); } let mut args: Vec<OsString> = Vec::with_capacity(7 + context.args.len()); args.extend([ "run".into(), "-A".into(), "--unstable-bare-node-builtins".into(), "--unstable-detect-cjs".into(), "--unstable-sloppy-imports".into(), "--unstable-unsafe-proto".into(), ]); args.extend(context.args); let mut state = context.state; state.apply_env_var( OsStr::new(USE_PKG_JSON_HIDDEN_ENV_VAR_NAME), OsStr::new("1"), ); ExecutableCommand::new( "deno".to_string(), std::env::current_exe() .and_then(|p| canonicalize_path(&p)) .unwrap(), ) .execute(ShellCommandContext { args, state, ..context }) } } pub struct NodeGypCommand; impl ShellCommand for NodeGypCommand { fn execute( &self, context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { // at the moment this shell command is just to give a warning if node-gyp is not found // in the future, we could try to run/install node-gyp for the user with deno if context .state .resolve_command_path(OsStr::new("node-gyp")) .is_err() { log::warn!( "{} node-gyp was used in a script, but was not listed as a dependency. Either add it as a dependency or install it globally (e.g. `npm install -g node-gyp`)", crate::colors::yellow("Warning") ); Box::pin(std::future::ready(ExecuteResult::from_exit_code(0))) } else { ExecutableCommand::new( "node-gyp".to_string(), "node-gyp".to_string().into(), ) .execute(context) } } } pub struct NpxCommand; impl ShellCommand for NpxCommand { fn execute( &self, mut context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { if let Some(first_arg) = context.args.first().cloned() { match context.state.resolve_custom_command(&first_arg) { Some(command) => { let context = ShellCommandContext { args: context.args.into_iter().skip(1).collect::<Vec<_>>(), ..context }; command.execute(context) } _ => { // can't find the command, so fallback to running the real npx command let npx_path = match context.state.resolve_command_path(OsStr::new("npx")) { Ok(npx) => npx, Err(err) => { let _ = context.stderr.write_line(&format!("{}", err)); return Box::pin(std::future::ready( ExecuteResult::from_exit_code(err.exit_code()), )); } }; ExecutableCommand::new("npx".to_string(), npx_path).execute(context) } } } else { let _ = context.stderr.write_line("npx: missing command"); Box::pin(std::future::ready(ExecuteResult::from_exit_code(1))) } } } /// Runs a module in the node_modules folder. #[derive(Clone)] pub struct NodeModulesFileRunCommand { pub command_name: String, pub path: PathBuf, } impl ShellCommand for NodeModulesFileRunCommand { fn execute( &self, mut context: ShellCommandContext, ) -> LocalBoxFuture<'static, ExecuteResult> { let mut args: Vec<OsString> = vec![ "run".into(), "--ext=js".into(), "-A".into(), self.path.clone().into_os_string(), ]; args.extend(context.args); let executable_command = deno_task_shell::ExecutableCommand::new( "deno".to_string(), std::env::current_exe() .and_then(|p| canonicalize_path(&p)) .unwrap(), ); // set this environment variable so that the launched process knows the npm command name context.state.apply_env_var( OsStr::new("DENO_INTERNAL_NPM_CMD_NAME"), OsStr::new(&self.command_name), ); executable_command.execute(ShellCommandContext { args, ..context }) } } pub fn resolve_custom_commands( node_resolver: &CliNodeResolver, npm_resolver: &CliNpmResolver, bin_dirs: &[PathBuf], ) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { let mut commands = match npm_resolver { CliNpmResolver::Byonm(_) => { // Walk the bin dirs in order (closest first) and merge; closest wins. let mut commands: HashMap<String, Rc<dyn ShellCommand>> = HashMap::new(); for bin_dir in bin_dirs { for (name, cmd) in resolve_npm_commands_from_bin_dir(bin_dir, node_resolver) { commands.entry(name).or_insert(cmd); } } commands } CliNpmResolver::Managed(npm_resolver) => { resolve_managed_npm_commands(node_resolver, npm_resolver)? } }; commands.insert("npm".to_string(), Rc::new(NpmCommand)); Ok(commands) } /// Builds the list of `node_modules/.bin` directories to consult for a task. /// /// For BYONM this walks up the filesystem from `cwd` collecting every /// `<ancestor>/node_modules/.bin` directory (matching how Node, npm, and pnpm /// resolve bin commands). For the managed npm resolver there is only ever a /// single `node_modules/.bin`. pub fn resolve_task_node_modules_bin_dirs( npm_resolver: &CliNpmResolver, cwd: &Path, ) -> Vec<PathBuf> { match npm_resolver { CliNpmResolver::Byonm(_) => cwd .ancestors() .map(|dir| dir.join("node_modules").join(".bin")) .collect(), CliNpmResolver::Managed(npm_resolver) => npm_resolver .root_node_modules_path() .map(|p| vec![p.join(".bin")]) .unwrap_or_default(), } } pub fn resolve_npm_commands_from_bin_dir( bin_dir: &Path, node_resolver: &CliNodeResolver, ) -> HashMap<String, Rc<dyn ShellCommand>> { let bin_commands = node_resolver.resolve_npm_commands_from_bin_dir(bin_dir); bin_commands .into_iter() .map(|(command_name, path)| { ( command_name.clone(), Rc::new(NodeModulesFileRunCommand { command_name, path: path.path().to_path_buf(), }) as Rc<dyn ShellCommand>, ) }) .collect() } fn resolve_managed_npm_commands( node_resolver: &CliNodeResolver, npm_resolver: &CliManagedNpmResolver, ) -> Result<HashMap<String, Rc<dyn ShellCommand>>, AnyError> { let mut result = HashMap::new(); for id in npm_resolver.resolution().top_level_packages() { let package_folder = npm_resolver.resolve_pkg_folder_from_pkg_id(&id)?; let bins = node_resolver.resolve_npm_binary_commands_for_package(&package_folder)?; result.extend(bins.into_iter().map(|(command_name, path)| { ( command_name.clone(), Rc::new(NodeModulesFileRunCommand { command_name, path: path.path().to_path_buf(), }) as Rc<dyn ShellCommand>, ) })); } if !result.contains_key("npx") { result.insert("npx".to_string(), Rc::new(NpxCommand)); } 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<TOutput>( kill_signal: KillSignal, future: impl std::future::Future<Output = TOutput>, ) -> TOutput { fn spawn_future_with_cancellation( future: impl std::future::Future<Output = ()> + 'static, token: CancellationToken, ) { deno_core::unsync::spawn(async move { tokio::select! { _ = future => {} _ = token.cancelled() => {} } }); } // Save the terminal mode up front so it can be restored once the task (and // all of its child processes) have finished. A child process such as a dev // server may switch the terminal into raw mode and, when terminated via // Ctrl+C, leave it in a broken state (no echo, no line editing). Declared // first so it is dropped last, i.e. after the children have been torn down by // the kill-signal drop guards below. let terminal_mode_guard = crate::util::console::TerminalModeGuard::acquire(); let saved_terminal_mode = terminal_mode_guard.saved(); 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(), saved_terminal_mode), 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, saved_terminal_mode: crate::util::console::SavedTerminalMode, ) { while let Ok(()) = deno_signals::ctrl_c().await { // On windows, ctrl+c is sent to the process group, so the signal would // have already been sent to the child process. We still want to listen // for ctrl+c here to keep the process alive when receiving it, but no // need to forward the signal because it's already been sent. if cfg!(windows) { // A child (e.g. a dev server) may have put the terminal into raw mode. // Restore it now so that input echoes again — for instance at cmd.exe's // "Terminate batch job (Y/N)?" prompt that appears for `.cmd` scripts. // The TerminalModeGuard still restores once more when the task ends, in // case the child re-enters raw mode before exiting. saved_terminal_mode.restore(); } else { 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_signals::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) = deno_signals::signal_stream(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(), ) } deno_core::futures::future::join_all(futures).await; } #[cfg(test)] mod test { use super::*; #[test] fn test_prepend_to_path() { let mut env_vars = HashMap::new(); prepend_to_path(&mut env_vars, "/example".into()); assert_eq!( env_vars, HashMap::from([("PATH".into(), "/example".into())]) ); prepend_to_path(&mut env_vars, "/example2".into()); let separator = if cfg!(windows) { ";" } else { ":" }; assert_eq!( env_vars, HashMap::from([( "PATH".into(), format!("/example2{}/example", separator).into() )]) ); env_vars.get_mut(OsStr::new("PATH")).unwrap().clear(); prepend_to_path(&mut env_vars, "/example".into()); assert_eq!( env_vars, HashMap::from([("PATH".into(), "/example".into())]) ); } #[test] fn test_get_script_with_args() { let cases: &[(&[&str], &str)] = &[ (&[], "echo"), (&["hello"], "echo 'hello'"), (&["hello", "world"], "echo 'hello' 'world'"), // Windows path with trailing backslash (issue #31453). (&[".\\dist\\"], "echo '.\\dist\\'"), // Dollar sign and backtick must not be expanded. (&["$HOME"], "echo '$HOME'"), (&["`cmd`"], "echo '`cmd`'"), // Double quotes pass through literally. (&["foo\"bar"], "echo 'foo\"bar'"), // Single quote uses the POSIX `'"'"'` idiom. (&["it's"], "echo 'it'\"'\"'s'"), ]; for (argv, expected) in cases { let argv: Vec<String> = argv.iter().map(|s| s.to_string()).collect(); assert_eq!(get_script_with_args("echo", &argv), *expected); } } }