Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Added** `vp run --continue` (also accepted as `--no-bail`) keeps running tasks after a task fails, instead of killing the running tasks and starting no new ones. Tasks that succeed are cached. Tasks that depend on a failed task don't run, and the run still exits with a non-zero status once the other tasks finish. `--continue` also applies to `vp run` commands in the scripts of the tasks it runs, and a `vp run --continue` in a task's script applies to the tasks that it runs ([#898](https://github.com/voidzero-dev/vite-task/pull/898)).
- **Changed** Tasks that write a file they also read are now cached, instead of being reported as `not cached because it modified its inputs`. For example, `tsc --incremental` reads and rewrites `tsconfig.tsbuildinfo`: the next run is a cache hit if the file is as `tsc` left it, or if it is missing, as on a fresh clone, in which case the hit restores it ([#913](https://github.com/voidzero-dev/vite-task/pull/913)).
- **Fixed** A cache hit restores the symbolic links a task's `output` globs match as links, instead of dropping them, so a replayed output that links a directory, such as a bundler's `node_modules/<package>` link, is complete ([#885](https://github.com/voidzero-dev/vite-task/pull/885)).
- **Fixed** On Linux and macOS, programs using automatic file-access tracking no longer abort when they call `execve`, `execv`, `execl`, `execle`, or `posix_spawn` with an empty path; the call fails with `ENOENT`, as it does without tracking. On Linux, `execveat` with an empty path and without `AT_EMPTY_PATH` now also fails with `ENOENT`, instead of trying to run what its directory descriptor refers to ([#896](https://github.com/voidzero-dev/vite-task/pull/896)).
Expand Down
10 changes: 10 additions & 0 deletions crates/vt/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@ pub struct RunFlags {
#[clap(default_value = "false", long)]
pub ignore_depends_on: bool,

/// Keep running other tasks after a task fails.
///
/// Tasks that depend on a failed task don't run, and the run still exits
/// with a non-zero code.
// `--no-bail` is pnpm's name for the same behavior.
#[clap(default_value = "false", long = "continue", alias = "no-bail")]
pub continue_on_failure: bool,

/// Show full detailed summary after execution.
#[clap(default_value = "false", short = 'v', long)]
pub verbose: bool,
Expand Down Expand Up @@ -246,6 +254,7 @@ impl ResolvedRunCommand {
let include_explicit_deps = !self.flags.ignore_depends_on;
let concurrency_limit = self.flags.concurrency_limit.map(|n| n.max(1));
let parallel = self.flags.parallel;
let continue_on_failure = self.flags.continue_on_failure;
// Read before `into_package_query` consumes the args.
let fail_if_no_match = self.flags.package_query.fail_if_no_match;

Expand All @@ -265,6 +274,7 @@ impl ResolvedRunCommand {
remote_cache_mode: self.flags.remote_cache.map(Into::into),
concurrency_limit,
parallel,
continue_on_failure,
fail_if_no_match,
},
},
Expand Down
6 changes: 4 additions & 2 deletions crates/vt/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,8 +340,10 @@ impl Report {
/// via [`run_child`], then decide the cache update
/// ([`cache_update::update_cache`])
///
/// Cancelling `fast_fail_token` kills the process. `cancel_token` must be a
/// child of `fast_fail_token` that Ctrl-C also cancels: cancelling it stops
/// Cancelling `fast_fail_token` kills the process. This function cancels it
/// too when forwarding the process's output fails, so it must belong to this
/// spawn alone (a child of the run's fast-fail token). `cancel_token` must be
/// cancelled when the run fast-fails and on Ctrl-C: cancelling it stops
/// remote cache lookups and prevents caching. A cached run's upload to the
/// remote cache runs in the background, and the caller waits for it with
/// [`ExecutionCache::wait_for_uploads`].
Expand Down
114 changes: 78 additions & 36 deletions crates/vt/src/session/execute/scheduler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,14 @@ struct ExecutionContext<'a> {
/// Public-facing program name (e.g. `vp`), used in user-facing error
/// messages that suggest a CLI command (e.g. `cache clean`).
program_name: &'a str,
/// Token cancelled when a task fails. Kills in-flight child processes
/// (via `start_kill` in spawn.rs).
/// Token cancelled when a task fails in a graph without `--continue`.
/// Kills in-flight child processes (via `start_kill` in spawn.rs).
///
/// One token serves every nested graph, and graphs with `--continue`
/// obey it too. That matches cancelling each graph on its own because a
/// nested `--continue` can't be turned off: a graph without it has no
/// ancestor with it, so its failure would cancel every graph up to the
/// root anyway.
fast_fail_token: CancellationToken,
/// Token cancelled by Ctrl-C, and by fast-fail as a child of
/// `fast_fail_token`. Prevents scheduling new tasks and caching results,
Expand All @@ -79,14 +85,20 @@ impl ExecutionContext<'_> {
/// `concurrency_limit` permits. Each recursive `Expanded` graph creates its own
/// semaphore, so nested graphs have independent concurrency limits.
///
/// Fast-fail: if any task fails, `execute_leaf` cancels the `fast_fail_token`
/// (killing in-flight child processes). Ctrl-C cancels the `cancel_token`.
/// Fast-fail: if a node fails, this cancels the `fast_fail_token` (killing
/// in-flight child processes). Ctrl-C cancels the `cancel_token`.
/// Either cancellation causes this method to close the semaphore, drain
/// remaining futures, and return.
///
/// If the graph has `continue_on_failure` (`--continue`), a failure cancels
/// nothing. A failed node doesn't release its dependents, so they never
/// start, and every other node still runs.
///
/// Returns whether any node in the graph failed.
#[tracing::instrument(level = "debug", skip_all)]
async fn execute_expanded_graph(&self, graph: &ExecutionGraph) {
async fn execute_expanded_graph(&self, graph: &ExecutionGraph) -> bool {
if graph.graph.node_count() == 0 {
return;
return false;
}

let semaphore =
Expand All @@ -109,13 +121,28 @@ impl ExecutionContext<'_> {
}

// Process completions and schedule newly ready dependents.
// On failure, `execute_leaf` cancels the token — we detect it here, close
// the semaphore (so pending acquires fail immediately), and drain.
while let Some(completed_ix) = futures.next().await {
// On failure without `--continue`, cancel the token here rather than in
// `execute_leaf`, so a nested `--continue` graph that failed also stops
// this one. Then close the semaphore (so pending acquires fail
// immediately) and drain.
let mut any_failed = false;
while let Some((completed_ix, failed)) = futures.next().await {
any_failed |= failed;
if failed && !graph.continue_on_failure {
self.fast_fail_token.cancel();
}
if self.cancelled() {
semaphore.close();
while futures.next().await.is_some() {}
return;
while let Some((_, failed)) = futures.next().await {
any_failed |= failed;
}
return any_failed;
}

// A failed node doesn't release its dependents, so their counts
// never reach zero.
if failed {
continue;
}

// Find dependents of the completed node (nodes that depend on it).
Expand All @@ -129,67 +156,82 @@ impl ExecutionContext<'_> {
}
}
}
any_failed
}

/// Create a future that acquires a semaphore permit, then executes a graph node.
///
/// On failure, `execute_node` cancels the `fast_fail_token` — the caller
/// detects this after the future completes. On semaphore closure or prior
/// cancellation, the node is skipped.
/// The future resolves to the node and whether it failed; the caller then
/// decides whether the failure cancels the run. On semaphore closure or
/// prior cancellation, the node is skipped and doesn't count as failed.
fn spawn_node<'a>(
&'a self,
graph: &'a ExecutionGraph,
node_ix: ExecutionNodeIndex,
semaphore: &Arc<Semaphore>,
) -> LocalBoxFuture<'a, ExecutionNodeIndex> {
) -> LocalBoxFuture<'a, (ExecutionNodeIndex, bool)> {
let sem = semaphore.clone();
async move {
if let Ok(_permit) = sem.acquire_owned().await
let failed = if let Ok(_permit) = sem.acquire_owned().await
&& !self.cancelled()
{
self.execute_node(graph, node_ix).await;
}
node_ix
self.execute_node(graph, node_ix).await
} else {
false
};
(node_ix, failed)
}
.boxed_local()
}

/// Execute a single node's items sequentially.
/// Execute a single node's items sequentially, returning whether one failed.
///
/// A node may have multiple items (from `&&`-split commands). Items are executed
/// in order; if any item fails, `execute_leaf` cancels the `fast_fail_token`
/// and remaining items are skipped (preserving `&&` semantics).
async fn execute_node(&self, graph: &ExecutionGraph, node_ix: ExecutionNodeIndex) {
/// in order; if any item fails, remaining items are skipped (preserving `&&`
/// semantics). An `Expanded` item fails if any node in its nested graph fails.
async fn execute_node(&self, graph: &ExecutionGraph, node_ix: ExecutionNodeIndex) -> bool {
let task_execution = &graph.graph[node_ix];

for item in &task_execution.items {
if self.cancelled() {
return;
return false;
}
match &item.kind {
let failed = match &item.kind {
ExecutionItemKind::Leaf(leaf_kind) => {
self.execute_leaf(&item.execution_item_display, leaf_kind).boxed_local().await;
self.execute_leaf(&item.execution_item_display, leaf_kind).boxed_local().await
}
ExecutionItemKind::Expanded(nested_graph) => {
self.execute_expanded_graph(nested_graph).boxed_local().await;
self.execute_expanded_graph(nested_graph).boxed_local().await
}
};
if failed {
return true;
}
}
false
}

/// Execute a single leaf item (in-process command or spawned process).
///
/// Creates a [`LeafExecutionReporter`](crate::session::reporter::LeafExecutionReporter)
/// from the graph reporter and delegates to the appropriate execution
/// method. On failure (non-zero exit or infrastructure error), cancels the
/// `fast_fail_token`.
/// method. Returns whether the leaf failed (non-zero exit or infrastructure
/// error); the graph decides whether that cancels the run.
///
/// A spawn gets a child of the `fast_fail_token`: fast-fail still kills
/// it, and when the spawn cancels the token to kill its own process (after
/// failing to forward its output), other tasks keep running.
#[tracing::instrument(level = "debug", skip_all)]
async fn execute_leaf(&self, display: &ExecutionItemDisplay, leaf_kind: &LeafExecutionKind) {
async fn execute_leaf(
&self,
display: &ExecutionItemDisplay,
leaf_kind: &LeafExecutionKind,
) -> bool {
// Borrow the reporter briefly to create the leaf reporter, then drop
// the RefCell guard before any `.await` point.
let mut leaf_reporter = self.reporter.borrow_mut().new_leaf_execution(display, leaf_kind);

let failed = match leaf_kind {
match leaf_kind {
LeafExecutionKind::InProcess(in_process_execution) => {
// In-process (built-in) commands: caching is disabled, execute synchronously
let mut stdio_config = leaf_reporter
Expand All @@ -215,7 +257,7 @@ impl ExecutionContext<'_> {
self.workspace_root,
self.cache_dir,
self.program_name,
self.fast_fail_token.clone(),
self.fast_fail_token.child_token(),
self.cancel_token.clone(),
)
.await;
Expand All @@ -225,9 +267,6 @@ impl ExecutionContext<'_> {
SpawnOutcome::Failed => true,
}
}
};
if failed {
self.fast_fail_token.cancel();
}
}
}
Expand Down Expand Up @@ -283,7 +322,10 @@ impl Session<'_> {
};

// Execute the graph with fast-fail: if any task fails, remaining tasks
// are skipped. Leaf-level errors are reported through the reporter.
// are skipped. In graphs with `--continue`, only the failed tasks'
// dependents are. Leaf-level errors are reported through the reporter,
// which also decides the exit status, so whether a task failed isn't
// needed here.
execution_context.execute_expanded_graph(&execution_graph).await;

// Nested graphs share the cache, so this waits for their uploads too.
Expand Down
1 change: 1 addition & 0 deletions crates/vt/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ impl<'a> Session<'a> {
remote_cache_mode: run_command.flags.remote_cache.map(Into::into),
concurrency_limit: None,
parallel: false,
continue_on_failure: run_command.flags.continue_on_failure,
// The selector path runs whatever the user picked interactively;
// there is no `--filter` in play, so strict-mode does not apply.
fail_if_no_match: false,
Expand Down
18 changes: 18 additions & 0 deletions crates/vt_bin/src/vtt/broken_stdout.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// broken-stdout -- `<command>` \[`<args>`...\]
///
/// Spawns `<command>` with its stdout connected to a pipe whose read end is
/// already closed, so every write to it fails (`EPIPE` on Unix,
/// `ERROR_NO_DATA` on Windows), then exits with the child's exit code.
pub fn run(args: &[String]) -> Result<(), Box<dyn std::error::Error>> {
let Some(("--", cmd_args)) = args.split_first().map(|(sep, rest)| (sep.as_str(), rest)) else {
return Err("Usage: vtt broken-stdout -- <command> [args...]".into());
};
let Some((program, program_args)) = cmd_args.split_first() else {
return Err("Usage: vtt broken-stdout -- <command> [args...]".into());
};

let (reader, writer) = std::io::pipe()?;
drop(reader);
let status = std::process::Command::new(program).args(program_args).stdout(writer).status()?;
std::process::exit(status.code().unwrap_or(1));
}
4 changes: 3 additions & 1 deletion crates/vt_bin/src/vtt/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#![expect(clippy::print_stdout, reason = "CLI tool output")]

mod barrier;
mod broken_stdout;
mod check_tty;
mod cp;
mod exit;
Expand Down Expand Up @@ -40,13 +41,14 @@ fn main() {
if args.len() < 2 {
eprintln!("Usage: vtt <subcommand> [args...]");
eprintln!(
"Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, fs, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-link, read-stdin, replace-file-content, rm, small_dev_shm, stalled-remote-cache, stat-file, stat-many, symlink, touch-file, write-file"
"Subcommands: barrier, broken-stdout, check-tty, cp, exit, exit-on-ctrlc, fs, grep-file, list-dir, mkdir, pipe-stdin, print, print-color, print-cwd, print-env, print-file, read-link, read-stdin, replace-file-content, rm, small_dev_shm, stalled-remote-cache, stat-file, stat-many, symlink, touch-file, write-file"
);
std::process::exit(1);
}

let result: Result<(), Box<dyn std::error::Error>> = match args[1].as_str() {
"barrier" => barrier::run(&args[2..]),
"broken-stdout" => broken_stdout::run(&args[2..]),
"check-tty" => {
check_tty::run();
Ok(())
Expand Down
19 changes: 19 additions & 0 deletions crates/vt_bin/tests/e2e_snapshots/fixtures/ctrl_c/snapshots.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,25 @@ steps = [
] },
]

[[e2e]]
name = "ctrl_c_prevents_future_tasks_with___continue"
comment = """
`--continue` doesn't stop Ctrl+C from cancelling the run: a exits 0 after Ctrl+C, and with `--continue` no task's exit cancels anything, so only Ctrl+C keeps b, which depends on a, from starting.
"""
steps = [
{ argv = [
"vt",
"run",
"-r",
"--continue",
"--no-cache",
"dev",
], interactions = [
{ "expect-milestone" = "ready" },
{ "write-key" = "ctrl-c" },
] },
]

[[e2e]]
name = "ctrl_c_prevents_caching"
comment = """
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# ctrl_c_prevents_future_tasks_with___continue

`--continue` doesn't stop Ctrl+C from cancelling the run: a exits 0 after Ctrl+C, and with `--continue` no task's exit cancels anything, so only Ctrl+C keeps b, which depends on a, from starting.

## `vt run -r --continue --no-cache dev`

**→ expect-milestone:** `ready`

```
~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled
```

**← write-key:** `ctrl-c`

```
~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled
ctrl-c received
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"name": "pkg-c"
}
Loading
Loading