Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions e2e/tasks/test_task_info
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ assert_json "mise tasks info lint --json" "$(
"dir": null,
"hide": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand Down
6 changes: 6 additions & 0 deletions e2e/tasks/test_task_ls
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand Down Expand Up @@ -98,6 +99,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand All @@ -123,6 +125,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand All @@ -148,6 +151,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand Down Expand Up @@ -175,6 +179,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand Down Expand Up @@ -205,6 +210,7 @@ assert_json "mise tasks ls --json" "$(
"hide": false,
"global": false,
"raw": false,
"interactive": false,
"sources": [],
"outputs": [],
"shell": null,
Expand Down
10 changes: 10 additions & 0 deletions schema/mise-task.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 15 additions & 0 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/cli/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
4 changes: 4 additions & 0 deletions src/cli/tasks/info.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(", "))?;
}
Expand Down Expand Up @@ -137,6 +140,7 @@ impl TasksInfo {
"dir": task.dir,
"hide": task.hide,
"raw": task.raw,
"interactive": task.interactive,
Comment thread
cursor[bot] marked this conversation as resolved.
"sources": task.sources,
"outputs": task.outputs,
"shell": task.shell,
Expand Down
1 change: 1 addition & 0 deletions src/cli/tasks/ls.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 6 additions & 0 deletions src/task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,8 @@ pub struct Task {
#[serde(default)]
pub raw: bool,
#[serde(default)]
pub interactive: bool,
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub outputs: TaskOutputs,
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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"
Expand All @@ -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);
Expand Down
80 changes: 73 additions & 7 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<RwLock<()>> = 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,
Expand Down Expand Up @@ -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?;

Expand All @@ -256,6 +283,7 @@ impl TaskExecutor {
&prefix,
rendered_run_scripts,
sched_tx,
confirm_guard,
)
.await?;
trace!(
Expand All @@ -281,6 +309,7 @@ impl TaskExecutor {
Ok(())
}

#[allow(clippy::too_many_arguments)]
async fn exec_task_run_entries(
&self,
config: &Arc<Config>,
Expand All @@ -289,19 +318,31 @@ impl TaskExecutor {
prefix: &str,
rendered_scripts: Vec<(String, Vec<String>)>,
sched_tx: Arc<mpsc::UnboundedSender<(Task, Arc<Mutex<Deps>>)>>,
existing_guard: Option<RuntimeLockGuard<'static>>,
) -> 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?;
}
Expand All @@ -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?;
}
Expand Down Expand Up @@ -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?;

Expand All @@ -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
}

Expand Down Expand Up @@ -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",
""
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
let output = self.output(Some(task));
cmd.with_pass_signals();
Expand Down Expand Up @@ -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(())
}
Expand Down
5 changes: 4 additions & 1 deletion src/task/task_output_handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

pub fn jobs(&self) -> usize {
Expand Down
Loading