Skip to content

Commit e19f605

Browse files
branchseerclaude
andcommitted
feat: fast-fail execution when a task fails
Stop executing subsequent tasks and &&-chained commands when any task exits with non-zero status. Previously, all tasks would run regardless of failures. Now the runner stops immediately, matching shell && semantics for chained commands and providing faster feedback on errors. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 47cee2c commit e19f605

File tree

8 files changed

+75
-30
lines changed

8 files changed

+75
-30
lines changed

crates/vite_task/src/session/execute/mod.rs

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -72,62 +72,73 @@ impl ExecutionContext<'_> {
7272
/// passes `graph.node_count() == 1`; recursive calls AND with the nested graph's
7373
/// node count.
7474
///
75-
/// Leaf-level errors are reported through the reporter and do not abort the graph.
76-
/// Cycle detection is handled at plan time, so this function cannot encounter cycles.
75+
/// Fast-fail: if any task fails (non-zero exit or infrastructure error), remaining
76+
/// tasks and `&&`-chained items are skipped. Leaf-level errors are reported through
77+
/// the reporter. Cycle detection is handled at plan time.
78+
///
79+
/// Returns `true` if all tasks succeeded, `false` if any task failed.
7780
#[tracing::instrument(level = "debug", skip_all)]
7881
#[expect(clippy::future_not_send, reason = "uses !Send types internally")]
7982
async fn execute_expanded_graph(
8083
&mut self,
8184
graph: &ExecutionGraph,
8285
all_ancestors_single_node: bool,
83-
) {
86+
) -> bool {
8487
// `compute_topological_order()` returns nodes in topological order: for every
8588
// edge A→B, A appears before B. Since our edges mean "A depends on B",
8689
// dependencies (B) appear after their dependents (A). We iterate in reverse
8790
// to get execution order where dependencies run first.
8891

8992
// Execute tasks in dependency-first order. Each task may have multiple items
9093
// (from `&&`-split commands), which are executed sequentially.
94+
// If any task fails, subsequent tasks and items are skipped (fast-fail).
9195
let topo_order = graph.compute_topological_order();
9296
for &node_ix in topo_order.iter().rev() {
9397
let task_execution = &graph[node_ix];
9498

9599
for item in &task_execution.items {
96-
match &item.kind {
100+
let failed = match &item.kind {
97101
ExecutionItemKind::Leaf(leaf_kind) => {
98102
self.execute_leaf(
99103
&item.execution_item_display,
100104
leaf_kind,
101105
all_ancestors_single_node,
102106
)
103107
.boxed_local()
104-
.await;
108+
.await
105109
}
106110
ExecutionItemKind::Expanded(nested_graph) => {
107-
self.execute_expanded_graph(
108-
nested_graph,
109-
all_ancestors_single_node && nested_graph.node_count() == 1,
110-
)
111-
.boxed_local()
112-
.await;
111+
!self
112+
.execute_expanded_graph(
113+
nested_graph,
114+
all_ancestors_single_node && nested_graph.node_count() == 1,
115+
)
116+
.boxed_local()
117+
.await
113118
}
119+
};
120+
if failed {
121+
return false;
114122
}
115123
}
116124
}
125+
true
117126
}
118127

119128
/// Execute a single leaf item (in-process command or spawned process).
120129
///
121130
/// Creates a [`LeafExecutionReporter`] from the graph reporter and delegates
122131
/// to the appropriate execution method.
132+
///
133+
/// Returns `true` if the execution failed (non-zero exit or infrastructure error).
123134
#[tracing::instrument(level = "debug", skip_all)]
124135
#[expect(clippy::future_not_send, reason = "uses !Send types internally")]
125136
async fn execute_leaf(
126137
&mut self,
127138
display: &ExecutionItemDisplay,
128139
leaf_kind: &LeafExecutionKind,
129140
all_ancestors_single_node: bool,
130-
) {
141+
) -> bool {
131142
let mut leaf_reporter =
132143
self.reporter.new_leaf_execution(display, leaf_kind, all_ancestors_single_node);
133144

@@ -150,15 +161,21 @@ impl ExecutionContext<'_> {
150161
None,
151162
)
152163
.await;
164+
false
153165
}
154166
LeafExecutionKind::Spawn(spawn_execution) => {
155167
#[expect(
156168
clippy::large_futures,
157169
reason = "spawn execution with cache management creates large futures"
158170
)]
159-
let _ =
171+
let outcome =
160172
execute_spawn(leaf_reporter, spawn_execution, self.cache, self.cache_base_path)
161173
.await;
174+
match outcome {
175+
SpawnOutcome::CacheHit => false,
176+
SpawnOutcome::Spawned(status) => !status.success(),
177+
SpawnOutcome::Failed => true,
178+
}
162179
}
163180
}
164181
}
@@ -519,8 +536,8 @@ impl Session<'_> {
519536
cache_base_path: &self.workspace_path,
520537
};
521538

522-
// Execute the graph. Leaf-level errors are reported through the reporter
523-
// and do not abort the graph. Cycle detection is handled at plan time.
539+
// Execute the graph with fast-fail: if any task fails, remaining tasks
540+
// are skipped. Leaf-level errors are reported through the reporter.
524541
let all_single_node = execution_graph.node_count() == 1;
525542
execution_context.execute_expanded_graph(&execution_graph, all_single_node).await;
526543

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
{
22
"name": "pkg-a",
33
"scripts": {
4-
"fail": "node -e \"process.exit(42)\""
4+
"fail": "node -e \"process.exit(42)\"",
5+
"check": "node -e \"process.exit(1)\"",
6+
"chained": "node -e \"process.exit(3)\" && echo 'second'"
57
}
68
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
{
22
"name": "pkg-b",
3+
"dependencies": {
4+
"pkg-a": "workspace:*"
5+
},
36
"scripts": {
4-
"fail": "node -e \"process.exit(7)\""
7+
"fail": "node -e \"process.exit(7)\"",
8+
"check": "echo 'pkg-b check passed'"
59
}
610
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots.toml

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,20 @@ steps = [
77
]
88

99
[[e2e]]
10-
name = "multiple task failures returns exit code 1"
10+
name = "task failure fast-fails remaining tasks"
1111
steps = [
12-
"vt run -r fail # multiple failures -> exit code 1",
12+
"vt run -r fail # pkg-a fails, pkg-b is skipped",
13+
]
14+
15+
[[e2e]]
16+
name = "dependency failure fast-fails dependents"
17+
cwd = "packages/pkg-b"
18+
steps = [
19+
"vt run -t check # pkg-a fails, pkg-b is skipped",
20+
]
21+
22+
[[e2e]]
23+
name = "chained command with && stops at first failure"
24+
steps = [
25+
"vt run pkg-a#chained # first fails with exit code 3, second should not run",
1326
]
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
[3]> vt run pkg-a#chained # first fails with exit code 3, second should not run
6+
~/packages/pkg-a$ node -e "process.exit(3)"
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
info:
5+
cwd: packages/pkg-b
6+
---
7+
[1]> vt run -t check # pkg-a fails, pkg-b is skipped
8+
~/packages/pkg-a$ node -e "process.exit(1)"

crates/vite_task_bin/tests/e2e_snapshots/fixtures/exit-codes/snapshots/multiple task failures returns exit code 1.snap

Lines changed: 0 additions & 11 deletions
This file was deleted.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
3+
expression: e2e_outputs
4+
---
5+
[42]> vt run -r fail # pkg-a fails, pkg-b is skipped
6+
~/packages/pkg-a$ node -e "process.exit(42)"

0 commit comments

Comments
 (0)