Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
75 changes: 68 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,8 +264,17 @@ 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?;
drop(confirm_guard);

let exec_start = std::time::Instant::now();
self.exec_task_run_entries(
Expand Down Expand Up @@ -293,15 +321,23 @@ impl TaskExecutor {
let (env, task_env) = full_env;
use crate::task::RunEntry;
let mut script_iter = rendered_scripts.into_iter();
// Acquire the runtime lock once outside the loop so consecutive script entries
// maintain exclusivity for interactive tasks. The lock is temporarily dropped
// around inject_and_wait calls to avoid deadlocking with sub-tasks.
let mut guard = Some(acquire_runtime_lock(task.interactive).await);
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
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 +346,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 +606,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 +627,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 +697,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 {
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 +852,9 @@ impl TaskExecutor {
if let Some(timeout) = effective_timeout {
cmd = cmd.with_timeout(timeout);
}
cmd.execute()?;
Comment thread
cursor[bot] marked this conversation as resolved.
// 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