diff --git a/e2e/tasks/test_task_info b/e2e/tasks/test_task_info index 1da1cb5e06..bf6d2dfd60 100644 --- a/e2e/tasks/test_task_info +++ b/e2e/tasks/test_task_info @@ -38,6 +38,7 @@ assert_json "mise tasks info lint --json" "$( "dir": null, "hide": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, diff --git a/e2e/tasks/test_task_ls b/e2e/tasks/test_task_ls index 3aa45e518c..c7571a20f9 100644 --- a/e2e/tasks/test_task_ls +++ b/e2e/tasks/test_task_ls @@ -71,6 +71,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, @@ -98,6 +99,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, @@ -123,6 +125,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, @@ -148,6 +151,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, @@ -175,6 +179,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, @@ -205,6 +210,7 @@ assert_json "mise tasks ls --json" "$( "hide": false, "global": false, "raw": false, + "interactive": false, "sources": [], "outputs": [], "shell": null, diff --git a/schema/mise-task.json b/schema/mise-task.json index 3400b79654..6c63f2f6e9 100644 --- a/schema/mise-task.json +++ b/schema/mise-task.json @@ -232,6 +232,11 @@ } ] }, + "interactive": { + "default": false, + "description": "mark task as interactive, acquiring exclusive terminal access while allowing other non-interactive tasks to run in parallel", + "type": "boolean" + }, "raw": { "default": false, "description": "directly connect task to stdin/stdout/stderr", @@ -885,6 +890,11 @@ } ] }, + "interactive": { + "default": false, + "description": "mark task as interactive, acquiring exclusive terminal access while allowing other non-interactive tasks to run in parallel", + "type": "boolean" + }, "raw": { "default": false, "description": "directly connect task to stdin/stdout/stderr", diff --git a/schema/mise.json b/schema/mise.json index dffd1664a4..6e1afb06b7 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -1841,6 +1841,11 @@ } ] }, + "interactive": { + "default": false, + "description": "mark task as interactive, acquiring exclusive terminal access while allowing other non-interactive tasks to run in parallel", + "type": "boolean" + }, "raw": { "default": false, "description": "directly connect task to stdin/stdout/stderr", @@ -2434,6 +2439,11 @@ } ] }, + "interactive": { + "default": false, + "description": "mark task as interactive, acquiring exclusive terminal access while allowing other non-interactive tasks to run in parallel", + "type": "boolean" + }, "raw": { "default": false, "description": "directly connect task to stdin/stdout/stderr", @@ -2679,6 +2689,11 @@ } ] }, + "interactive": { + "default": false, + "description": "mark task as interactive, acquiring exclusive terminal access while allowing other non-interactive tasks to run in parallel", + "type": "boolean" + }, "raw": { "default": false, "description": "directly connect task to stdin/stdout/stderr", diff --git a/src/cli/mcp.rs b/src/cli/mcp.rs index 868093303f..8d83c71ce1 100644 --- a/src/cli/mcp.rs +++ b/src/cli/mcp.rs @@ -326,6 +326,7 @@ impl ServerHandler for MiseServer { "dir": task.dir.clone(), "hide": task.hide, "raw": task.raw, + "interactive": task.interactive, "sources": task.sources.clone(), "outputs": task.outputs.clone(), "shell": task.shell.clone(), diff --git a/src/cli/tasks/info.rs b/src/cli/tasks/info.rs index 1dd3b302ed..e6a36c49f4 100644 --- a/src/cli/tasks/info.rs +++ b/src/cli/tasks/info.rs @@ -79,6 +79,9 @@ impl TasksInfo { if task.raw { properties.push("raw"); } + if task.interactive { + properties.push("interactive"); + } if !properties.is_empty() { info::inline_section("Properties", properties.join(", "))?; } @@ -137,6 +140,7 @@ impl TasksInfo { "dir": task.dir, "hide": task.hide, "raw": task.raw, + "interactive": task.interactive, "sources": task.sources, "outputs": task.outputs, "shell": task.shell, diff --git a/src/cli/tasks/ls.rs b/src/cli/tasks/ls.rs index 443bf106f4..17dec8106c 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -191,6 +191,7 @@ impl TasksLs { "hide": task.hide, "global": task.global, "raw": task.raw, + "interactive": task.interactive, "sources": task.sources, "outputs": task.outputs, "shell": task.shell, diff --git a/src/task/mod.rs b/src/task/mod.rs index 63b595391e..a3f3f7d518 100644 --- a/src/task/mod.rs +++ b/src/task/mod.rs @@ -246,6 +246,8 @@ pub struct Task { #[serde(default)] pub raw: bool, #[serde(default)] + pub interactive: bool, + #[serde(default)] pub sources: Vec, #[serde(default)] pub outputs: TaskOutputs, @@ -380,6 +382,7 @@ impl Task { task.dir = p.parse_str("dir"); task.hide = !file::is_executable(path) || p.parse_bool("hide").unwrap_or_default(); task.raw = p.parse_bool("raw").unwrap_or_default(); + task.interactive = p.parse_bool("interactive").unwrap_or_default(); task.sources = p.parse_array("sources").unwrap_or_default(); task.outputs = p.get_raw("outputs").map(|to| to.into()).unwrap_or_default(); task.file = Some(path.to_path_buf()); @@ -1245,6 +1248,7 @@ impl Default for Task { hide: false, global: false, raw: false, + interactive: false, sources: vec![], outputs: Default::default(), raw_outputs: Default::default(), @@ -2269,6 +2273,7 @@ echo "hello world" #MISE dir="/some/dir" #MISE hide=true #MISE raw=true +#MISE interactive=true #MISE sources=["src1.txt", "src2.txt"] #MISE outputs=["out1.txt"] #MISE shell="bash -c" @@ -2294,6 +2299,7 @@ echo "test" assert_eq!(task.dir, Some("/some/dir".to_string())); assert_eq!(task.hide, true); assert_eq!(task.raw, true); + assert_eq!(task.interactive, true); assert_eq!(task.sources, vec!["src1.txt", "src2.txt"]); assert_eq!(task.shell, Some("bash -c".to_string())); assert_eq!(task.quiet, true); diff --git a/src/task/task_executor.rs b/src/task/task_executor.rs index 928e8b17c1..fb19aeb9ab 100644 --- a/src/task/task_executor.rs +++ b/src/task/task_executor.rs @@ -22,12 +22,31 @@ use std::iter::once; use std::ops::Deref; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::{Arc, Mutex as StdMutex}; +use std::sync::{Arc, LazyLock, Mutex as StdMutex}; use std::time::{Duration, SystemTime}; use tokio::sync::Mutex; +use tokio::sync::RwLock; use tokio::sync::{mpsc, oneshot}; use xx::file; +/// Global lock for interactive task exclusivity. +/// Interactive tasks acquire a write lock (exclusive), non-interactive tasks acquire a read lock (shared). +static TASK_RUNTIME_LOCK: LazyLock> = LazyLock::new(|| RwLock::new(())); + +#[allow(dead_code)] // Guards are held for their Drop impl, not read +enum RuntimeLockGuard<'a> { + Read(tokio::sync::RwLockReadGuard<'a, ()>), + Write(tokio::sync::RwLockWriteGuard<'a, ()>), +} + +async fn acquire_runtime_lock(interactive: bool) -> RuntimeLockGuard<'static> { + if interactive { + RuntimeLockGuard::Write(TASK_RUNTIME_LOCK.write().await) + } else { + RuntimeLockGuard::Read(TASK_RUNTIME_LOCK.read().await) + } +} + /// Configuration for TaskExecutor pub struct TaskExecutorConfig { pub force: bool, @@ -245,6 +264,14 @@ impl TaskExecutor { self.parse_usage_spec_and_init_env(config, task, &mut env, get_args, extra_vars) .await?; + // For interactive tasks, acquire the lock before confirmation so the + // prompt gets exclusive terminal access (consistent with exec_file path). + let confirm_guard = if task.interactive { + Some(acquire_runtime_lock(task.interactive).await) + } else { + None + }; + // Check confirmation after usage args are parsed self.check_confirmation(config, task, &env).await?; @@ -256,6 +283,7 @@ impl TaskExecutor { &prefix, rendered_run_scripts, sched_tx, + confirm_guard, ) .await?; trace!( @@ -281,6 +309,7 @@ impl TaskExecutor { Ok(()) } + #[allow(clippy::too_many_arguments)] async fn exec_task_run_entries( &self, config: &Arc, @@ -289,19 +318,31 @@ impl TaskExecutor { prefix: &str, rendered_scripts: Vec<(String, Vec)>, sched_tx: Arc>)>>, + existing_guard: Option>, ) -> Result<()> { let (env, task_env) = full_env; use crate::task::RunEntry; let mut script_iter = rendered_scripts.into_iter(); + // Use an existing guard (e.g. from confirmation) or acquire a new one. + // The lock is held across consecutive script entries for exclusivity + // and temporarily dropped around inject_and_wait to avoid deadlocking. + let mut guard = match existing_guard { + Some(g) => Some(g), + None => Some(acquire_runtime_lock(task.interactive).await), + }; for entry in task.run() { match entry { RunEntry::Script(_) => { if let Some((script, args)) = script_iter.next() { + if guard.is_none() { + guard = Some(acquire_runtime_lock(task.interactive).await); + } self.exec_script(&script, &args, task, env, prefix).await?; } } RunEntry::SingleTask { task: spec } => { let resolved_spec = crate::task::resolve_task_pattern(spec, Some(task)); + guard = None; // drop lock before waiting on sub-tasks self.inject_and_wait(config, &[resolved_spec], task_env, sched_tx.clone()) .await?; } @@ -310,6 +351,7 @@ impl TaskExecutor { .iter() .map(|t| crate::task::resolve_task_pattern(t, Some(task))) .collect(); + guard = None; // drop lock before waiting on sub-tasks self.inject_and_wait(config, &resolved_tasks, task_env, sched_tx.clone()) .await?; } @@ -569,6 +611,15 @@ impl TaskExecutor { self.parse_usage_spec_and_init_env(config, task, &mut env, get_args, extra_vars) .await?; + // For interactive tasks, acquire the lock before confirmation so the + // prompt gets exclusive terminal access. For non-interactive tasks, + // acquire after confirmation to avoid blocking the task graph. + let guard = if task.interactive { + Some(acquire_runtime_lock(task.interactive).await) + } else { + None + }; + // Check confirmation after usage args are parsed self.check_confirmation(config, task, &env).await?; @@ -581,6 +632,11 @@ impl TaskExecutor { self.eprint(task, prefix, &cmd); } + let _guard = if guard.is_some() { + guard + } else { + Some(acquire_runtime_lock(task.interactive).await) + }; self.exec(file, &args, task, &env, prefix).await } @@ -646,11 +702,19 @@ impl TaskExecutor { .redact(redactions.deref().clone()) .raw(raw); if raw && !redactions.is_empty() { - hint!( - "raw_redactions", - "--raw will prevent mise from being able to use redactions", - "" - ); + if task.interactive && !task.raw && !Settings::get().raw { + hint!( + "interactive_redactions", + "interactive tasks bypass redactions—secrets may appear in terminal output", + "" + ); + } else { + hint!( + "raw_redactions", + "--raw will prevent mise from being able to use redactions", + "" + ); + } } let output = self.output(Some(task)); cmd.with_pass_signals(); @@ -793,7 +857,9 @@ impl TaskExecutor { if let Some(timeout) = effective_timeout { cmd = cmd.with_timeout(timeout); } - cmd.execute()?; + // cmd.execute() is blocking (calls cp.wait()), so use block_in_place + // to avoid starving the tokio runtime while holding the TASK_RUNTIME_LOCK guard. + tokio::task::block_in_place(|| cmd.execute())?; trace!("{prefix} exited successfully"); Ok(()) } diff --git a/src/task/task_output_handler.rs b/src/task/task_output_handler.rs index f3f5697cdb..bc0d8db521 100644 --- a/src/task/task_output_handler.rs +++ b/src/task/task_output_handler.rs @@ -343,7 +343,10 @@ impl OutputHandler { } pub fn raw(&self, task: Option<&Task>) -> bool { - self.raw || Settings::get().raw || task.is_some_and(|t| t.raw) + // Interactive tasks are treated as raw for I/O (stdin/stdout/stderr inherit). + // This means CmdLineRunner will also acquire its internal RAW_LOCK — that's + // intentional and harmless since TASK_RUNTIME_LOCK already provides exclusivity. + self.raw || Settings::get().raw || task.is_some_and(|t| t.raw || t.interactive) } pub fn jobs(&self) -> usize {