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
20 changes: 20 additions & 0 deletions e2e/tasks/test_task_keep_order
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,23 @@ tasks.b = "echo b ; exit 1"
tasks.all.depends = ['a', 'b']
EOF
assert_fail "mise run -o keep-order all" "[b] b"

# Verify definition order is preserved even when later tasks finish first
cat <<EOF >mise.toml
[tasks.slow]
run = "sleep 2 && echo slow"

[tasks.fast]
run = "echo fast"

[tasks.all]
depends = ['slow', 'fast']
EOF
output=$(MISE_JOBS=4 mise run -o keep-order all 2>&1)
slow_pos=$(echo "$output" | grep -n '^\[slow\] slow$' | cut -d: -f1)
fast_pos=$(echo "$output" | grep -n '^\[fast\] fast$' | cut -d: -f1)
if [ "$slow_pos" -ge "$fast_pos" ]; then
echo "keep-order did not preserve definition order: slow at $slow_pos, fast at $fast_pos"
echo "$output"
exit 1
fi
2 changes: 1 addition & 1 deletion settings.toml
Original file line number Diff line number Diff line change
Expand Up @@ -1703,7 +1703,7 @@ Change output style when executing tasks. This controls the output of `mise run`
enum = [
{ value = "prefix", description = "(default if jobs > 1) print by line with the prefix of the task name" },
{ value = "interleave", description = "(default if jobs == 1 or all tasks run sequentially) print output as it comes in" },
{ value = "keep-order", description = "print output from tasks in the order they are defined" },
{ value = "keep-order", description = "stream one task's output live while buffering others, printing in definition order as tasks complete" },
{ value = "replacing", description = "replace stdout each time a line is printed-this uses similar logic as `mise install`" },
{ value = "timed", description = "only show stdout lines that take longer than 1s to complete" },
{ value = "quiet", description = "print only stdout/stderr from tasks and nothing from mise" },
Expand Down
5 changes: 5 additions & 0 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -475,6 +475,11 @@ impl Run {
}
this.add_failed_task(task.clone(), status);
}
if let Some(oh) = &this.output_handler
&& oh.output(None) == TaskOutput::KeepOrder
{
oh.keep_order_state.lock().unwrap().on_task_finished(&task);
}
deps_for_remove.lock().await.remove(&task);
trace!("deps removed: {} {}", task.name, task.args.join(" "));
in_flight_c.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
Expand Down
32 changes: 16 additions & 16 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -669,27 +669,27 @@ impl TaskExecutor {
}
TaskOutput::KeepOrder => {
if !task.silent.suppresses_stdout() {
cmd = cmd.with_on_stdout(|line| {
let mut map = self.output_handler.keep_order_output.lock().unwrap();
if !map.contains_key(task) {
map.insert(task.clone(), Default::default());
}
if let Some(entry) = map.get_mut(task) {
entry.0.push((prefix.to_string(), line));
}
let state = self.output_handler.keep_order_state.clone();
let task_clone = task.clone();
let prefix_str = prefix.to_string();
cmd = cmd.with_on_stdout(move |line| {
state
.lock()
.unwrap()
.on_stdout(&task_clone, prefix_str.clone(), line);
});
Comment on lines +672 to 680

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

In the with_on_stdout closure, prefix_str.clone() is called for every line of output. For tasks that produce a lot of output, this can be inefficient. A similar issue exists for with_on_stderr.

You could optimize this by using an Arc<String> for the prefix. This would involve a few changes across files:

  1. In src/task/task_executor.rs, create an Arc<String> for the prefix outside the closure and clone the Arc inside:

    let prefix_arc = std::sync::Arc::new(prefix.to_string());
    cmd = cmd.with_on_stdout(move |line| {
        state
            .lock()
            .unwrap()
            .on_stdout(&task_clone, prefix_arc.clone(), line);
    });
  2. In src/task/task_output_handler.rs, update KeepOrderLine to store an Arc<String>:

    pub enum KeepOrderLine {
        Stdout(std::sync::Arc<String>, String), // (prefix, line)
        Stderr(std::sync::Arc<String>, String), // (prefix, line)
    }
  3. Update on_stdout and on_stderr in KeepOrderState to accept Arc<String>:

    pub fn on_stdout(&mut self, task: &Task, prefix: std::sync::Arc<String>, line: String) {
        // ...
        self.buffers
            .entry(task.clone())
            .or_default()
            .push(KeepOrderLine::Stdout(prefix, line));
    }

This would replace expensive string cloning with cheap Arc cloning.

} else {
cmd = cmd.stdout(Stdio::null());
}
if !task.silent.suppresses_stderr() {
cmd = cmd.with_on_stderr(|line| {
let mut map = self.output_handler.keep_order_output.lock().unwrap();
if !map.contains_key(task) {
map.insert(task.clone(), Default::default());
}
if let Some(entry) = map.get_mut(task) {
entry.1.push((prefix.to_string(), line));
}
let state = self.output_handler.keep_order_state.clone();
let task_clone = task.clone();
let prefix_str = prefix.to_string();
cmd = cmd.with_on_stderr(move |line| {
state
.lock()
.unwrap()
.on_stderr(&task_clone, prefix_str.clone(), line);
});
} else {
cmd = cmd.stderr(Stdio::null());
Expand Down
188 changes: 179 additions & 9 deletions src/task/task_output_handler.rs
Original file line number Diff line number Diff line change
@@ -1,13 +1,174 @@
use crate::config::Settings;
use crate::task::Task;
use crate::task::task_helpers::task_needs_permit;
use crate::task::task_output::TaskOutput;
use crate::ui::multi_progress_report::MultiProgressReport;
use crate::ui::progress_report::SingleReport;
use indexmap::IndexMap;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;

type KeepOrderOutputs = (Vec<(String, String)>, Vec<(String, String)>);
/// A single line of output, tagged by stream.
pub enum KeepOrderLine {
Stdout(String, String), // (prefix, line)
Stderr(String, String), // (prefix, line)
}

/// Streaming state for keep-order mode.
///
/// One task at a time is "active" and streams output in real-time.
/// Other tasks buffer their output. When the active task finishes,
/// any already-finished tasks' buffers are flushed, then the next
/// running task with buffered output is promoted to stream live.
pub struct KeepOrderState {
/// The task whose output is currently being streamed live
active: Option<Task>,
/// Buffered output for non-active tasks (insertion order preserved)
buffers: IndexMap<Task, Vec<KeepOrderLine>>,
/// Tasks that finished while not active (in order of completion)
finished: Vec<Task>,
/// Set after flush_all — further output prints directly
done: bool,
}

impl KeepOrderState {
pub fn new() -> Self {
Self {
active: None,
buffers: IndexMap::new(),
finished: Vec::new(),
done: false,
}
}

pub fn init_task(&mut self, task: &Task) {
self.buffers.entry(task.clone()).or_default();
}

/// Whether this task should stream live (is active, or is first in
/// definition order when no task is active yet).
fn is_active(&self, task: &Task) -> bool {
if let Some(active) = &self.active {
active == task
} else {
// No active task yet — only the first task in definition order may claim it
self.buffers.first().map(|(t, _)| t) == Some(task)
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// Called when a stdout line is produced by a task's process.
pub fn on_stdout(&mut self, task: &Task, prefix: String, line: String) {
if self.done || self.is_active(task) {
self.active = Some(task.clone());
print_stdout(&prefix, &line);
} else {
self.buffers
.entry(task.clone())
.or_default()
.push(KeepOrderLine::Stdout(prefix, line));
}
Comment on lines +60 to +69

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When active is None, the first task to emit output becomes active and prints immediately. This can allow a later-defined task to print before earlier tasks, violating the stated keep-order behavior. Consider selecting the initial active task based on the pre-initialized task order (from init_task) and buffering output until that task is active, or otherwise ensure output cannot appear before earlier tasks in the configured order.

Copilot uses AI. Check for mistakes.
}

/// Called when a stderr line is produced by a task's process,
/// or when metadata (command echo, timing) is emitted for a task.
pub fn on_stderr(&mut self, task: &Task, prefix: String, line: String) {
if self.done || self.is_active(task) {
self.active = Some(task.clone());
print_stderr(&prefix, &line);
} else {
self.buffers
.entry(task.clone())
.or_default()
.push(KeepOrderLine::Stderr(prefix, line));
}
}

/// Called when a task finishes execution.
pub fn on_task_finished(&mut self, task: &Task) {
if !self.buffers.contains_key(task) {
return; // Not a keep-order task
}
if self.is_active(task) {
// Active task finished — clear it, flush waiting tasks, promote next
self.active = None;
self.buffers.shift_remove(task);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Active task's buffered output discarded on finish

Low Severity

In on_task_finished, self.buffers.shift_remove(task) discards the removed buffer without printing it. This assumes the active task's buffer is empty, which is true for tasks initialized by init_task but not for tasks added to buffers dynamically via on_stdout/on_stderr's entry().or_default(). A task's first output line can get buffered (when is_active returns false because buffers.first() is None at that moment), yet a subsequent on_task_finished sees the task as active (since it's now first in buffers) and discards the buffer. Compare with promote_next, which correctly flushes via std::mem::take before streaming.

Additional Locations (1)

Fix in Cursor Fix in Web

self.flush_finished();
self.promote_next();
} else {
// Non-active task finished — remember it for later flushing
self.finished.push(task.clone());
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

/// Flush contiguous finished tasks from the front of the buffer.
/// Stops at the first non-finished task to preserve definition order.
fn flush_finished(&mut self) {
let mut finished: std::collections::HashSet<_> = self.finished.drain(..).collect();
loop {
let Some((task, _)) = self.buffers.first() else {
break;
};
if !finished.remove(task) {
break; // Hit a non-finished task, stop
}
let task = task.clone();
if let Some(lines) = self.buffers.shift_remove(&task) {
Self::print_lines(&lines);
}
}
Comment on lines +105 to +118

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

flush_finished removes buffers with swap_remove, which reorders the remaining IndexMap entries and can cause later promotion/printing to happen in the wrong task order. Prefer an order-preserving removal (shift_remove) or a different data structure that keeps the original task order intact.

Copilot uses AI. Check for mistakes.
// Re-add finished tasks we couldn't flush (behind a still-running task)
self.finished.extend(finished);
}
Comment thread
cursor[bot] marked this conversation as resolved.

/// Promote the next buffered (still-running) task to active and
/// flush its current buffer so it can stream live going forward.
fn promote_next(&mut self) {
if let Some((task, _)) = self.buffers.first() {
let task = task.clone();
self.active = Some(task.clone());
if let Some(lines) = self.buffers.get_mut(&task) {
let lines = std::mem::take(lines);
Self::print_lines(&lines);
}
Comment on lines +126 to +132

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

promote_next promotes buffers.first() unconditionally, even if that task has an empty buffer (never produced output) or may not be running yet. This contradicts the comment about promoting the next running task with buffered output and can result in all other tasks being forced to buffer until the promoted task eventually emits output. Consider selecting the first task with a non-empty buffer (or tracking started/running tasks) and leaving active as None otherwise.

Suggested change
if let Some((task, _)) = self.buffers.first() {
let task = task.clone();
self.active = Some(task.clone());
if let Some(lines) = self.buffers.get_mut(&task) {
let lines = std::mem::take(lines);
Self::print_lines(&lines);
}
// Find the first task that actually has buffered output.
if let Some((task, lines)) = self
.buffers
.iter_mut()
.find(|(_, lines)| !lines.is_empty())
{
let task = task.clone();
self.active = Some(task.clone());
let lines = std::mem::take(lines);
Self::print_lines(&lines);

Copilot uses AI. Check for mistakes.
}
}

fn print_lines(lines: &[KeepOrderLine]) {
for line in lines {
match line {
KeepOrderLine::Stdout(prefix, line) => print_stdout(prefix, line),
KeepOrderLine::Stderr(prefix, line) => print_stderr(prefix, line),
}
}
}

/// Safety-net: flush any remaining output (called at the very end).
/// After this, any further output prints directly.
pub fn flush_all(&mut self) {
self.active = None;
self.flush_finished();
for (_, lines) in self.buffers.drain(..) {
Self::print_lines(&lines);
}
self.done = true;
}
}

fn print_stdout(prefix: &str, line: &str) {
if console::colors_enabled() {
prefix_println!(prefix, "{line}\x1b[0m");
} else {
prefix_println!(prefix, "{line}");
}
}

fn print_stderr(prefix: &str, line: &str) {
if console::colors_enabled_stderr() {
prefix_eprintln!(prefix, "{line}\x1b[0m");
} else {
prefix_eprintln!(prefix, "{line}");
}
}

/// Configuration for OutputHandler
pub struct OutputHandlerConfig {
Expand All @@ -23,7 +184,7 @@ pub struct OutputHandlerConfig {

/// Handles task output routing, formatting, and display
pub struct OutputHandler {
pub keep_order_output: Arc<Mutex<IndexMap<Task, KeepOrderOutputs>>>,
pub keep_order_state: Arc<Mutex<KeepOrderState>>,
pub task_prs: IndexMap<Task, Arc<Box<dyn SingleReport>>>,
pub timed_outputs: Arc<Mutex<IndexMap<String, (SystemTime, String)>>>,

Expand All @@ -41,7 +202,7 @@ pub struct OutputHandler {
impl Clone for OutputHandler {
fn clone(&self) -> Self {
Self {
keep_order_output: self.keep_order_output.clone(),
keep_order_state: self.keep_order_state.clone(),
task_prs: self.task_prs.clone(),
timed_outputs: self.timed_outputs.clone(),
prefix: self.prefix,
Expand All @@ -59,7 +220,7 @@ impl Clone for OutputHandler {
impl OutputHandler {
pub fn new(config: OutputHandlerConfig) -> Self {
Self {
keep_order_output: Arc::new(Mutex::new(IndexMap::new())),
keep_order_state: Arc::new(Mutex::new(KeepOrderState::new())),
task_prs: IndexMap::new(),
timed_outputs: Arc::new(Mutex::new(IndexMap::new())),
prefix: config.prefix,
Expand All @@ -77,10 +238,10 @@ impl OutputHandler {
pub fn init_task(&mut self, task: &Task) {
match self.output(Some(task)) {
TaskOutput::KeepOrder => {
self.keep_order_output
.lock()
.unwrap()
.insert(task.clone(), Default::default());
// Only add tasks that produce output (not orchestrator-only tasks)
if task_needs_permit(task) {
self.keep_order_state.lock().unwrap().init_task(task);
}
}
TaskOutput::Replacing => {
let pr = MultiProgressReport::get().add(&task.estyled_prefix());
Expand Down Expand Up @@ -139,9 +300,18 @@ impl OutputHandler {
}
}

/// Print error message for a task
/// Print error/metadata message for a task.
/// For keep-order mode, routes through the streaming state so messages
/// stay ordered with the task's stdout/stderr.
pub fn eprint(&self, task: &Task, prefix: &str, line: &str) {
match self.output(Some(task)) {
TaskOutput::KeepOrder => {
self.keep_order_state.lock().unwrap().on_stderr(
task,
prefix.to_string(),
line.to_string(),
);
}
Comment thread
cursor[bot] marked this conversation as resolved.
TaskOutput::Replacing => {
let pr = self.task_prs.get(task).unwrap().clone();
pr.set_message(format!("{prefix} {line}"));
Expand Down
26 changes: 6 additions & 20 deletions src/task/task_results_display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,30 +35,16 @@ impl TaskResultsDisplay {
self.exit_if_failed();
}

/// Display keep-order output if using that mode
/// Flush any remaining keep-order output (safety net)
fn display_keep_order_output(&self) {
if self.output_handler.output(None) != TaskOutput::KeepOrder {
return;
}

let output = self.output_handler.keep_order_output.lock().unwrap();

for (out, err) in output.values() {
for (prefix, line) in out {
if console::colors_enabled() {
prefix_println!(prefix, "{line}\x1b[0m");
} else {
prefix_println!(prefix, "{line}");
}
}
for (prefix, line) in err {
if console::colors_enabled_stderr() {
prefix_eprintln!(prefix, "{line}\x1b[0m");
} else {
prefix_eprintln!(prefix, "{line}");
}
}
}
self.output_handler
.keep_order_state
.lock()
.unwrap()
.flush_all();
}

/// Display timing summary if enabled
Expand Down
Loading