From 90c344d7a1b621b5f0de9fc23442a37aaa0482f0 Mon Sep 17 00:00:00 2001 From: Dan van der Merwe Date: Mon, 14 Sep 2026 12:04:39 -0700 Subject: [PATCH 1/3] fix(run): terminate descendants when cancelling tasks Co-authored-by: GPT-6 Codex --- CHANGELOG.md | 1 + Cargo.lock | 2 + crates/fspy/src/command.rs | 16 ++ crates/fspy/src/lib.rs | 3 + crates/fspy/src/unix/mod.rs | 1 + crates/fspy/src/windows/mod.rs | 1 + crates/vt/Cargo.toml | 9 +- crates/vt/src/session/execute/mod.rs | 45 ++-- crates/vt/src/session/execute/scheduler.rs | 3 +- crates/vt/src/session/execute/spawn.rs | 286 ++++++++++++++++++--- crates/vt/src/session/execute/win_job.rs | 7 +- 11 files changed, 314 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a0d93dcf2..82ff0205a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Fixed** Cancelling a task after a sibling failure now terminates its descendant processes as well, so worker runtimes and background helpers do not survive the cancelled run ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). - **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)). diff --git a/Cargo.lock b/Cargo.lock index 14cfa6dfe..695fb7045 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4243,6 +4243,7 @@ dependencies = [ "anyhow", "async-trait", "clap", + "ctor", "ctrlc", "derive_more", "fspy", @@ -4258,6 +4259,7 @@ dependencies = [ "rustc-hash", "serde", "serde_json", + "subprocess_test", "supports-color 3.0.2", "tar", "tempfile", diff --git a/crates/fspy/src/command.rs b/crates/fspy/src/command.rs index fb150b26c..164e63e67 100644 --- a/crates/fspy/src/command.rs +++ b/crates/fspy/src/command.rs @@ -20,6 +20,8 @@ pub struct Command { cwd: Option, #[cfg(unix)] arg0: Option, + #[cfg(unix)] + process_group: Option, stderr: Option, stdout: Option, @@ -42,6 +44,8 @@ impl Command { cwd: None, #[cfg(unix)] arg0: None, + #[cfg(unix)] + process_group: None, stderr: None, stdout: None, stdin: None, @@ -50,6 +54,14 @@ impl Command { } } + /// Set the child process group, matching `std::process::Command`. + /// A value of zero creates a group whose ID is the child's process ID. + #[cfg(unix)] + pub const fn process_group(&mut self, process_group: i32) -> &mut Self { + self.process_group = Some(process_group); + self + } + #[cfg(unix)] #[must_use] pub(crate) fn get_exec(&self) -> Exec { @@ -238,6 +250,10 @@ impl Command { if let Some(arg0) = self.arg0 { tokio_cmd.arg0(arg0); } + #[cfg(unix)] + if let Some(process_group) = self.process_group { + tokio_cmd.process_group(process_group); + } tokio_cmd.args(self.args); tokio_cmd.env_clear(); tokio_cmd.envs(self.envs); diff --git a/crates/fspy/src/lib.rs b/crates/fspy/src/lib.rs index 5621547f5..970627597 100644 --- a/crates/fspy/src/lib.rs +++ b/crates/fspy/src/lib.rs @@ -37,6 +37,9 @@ pub struct ChildTermination { } pub struct TrackedChild { + /// The process ID captured at spawn, before the child can exit. + pub id: u32, + /// The handle for writing to the child's standard input (stdin), if it has /// been captured. pub stdin: Option, diff --git a/crates/fspy/src/unix/mod.rs b/crates/fspy/src/unix/mod.rs index 194cdd28e..ce8db03d8 100644 --- a/crates/fspy/src/unix/mod.rs +++ b/crates/fspy/src/unix/mod.rs @@ -149,6 +149,7 @@ impl SpyImpl { .map_err(SpawnError::OsSpawn)?; Ok(TrackedChild { + id: child.id().expect("newly spawned child has a process ID"), stdin: child.stdin.take(), stdout: child.stdout.take(), stderr: child.stderr.take(), diff --git a/crates/fspy/src/windows/mod.rs b/crates/fspy/src/windows/mod.rs index 096335078..12e31a910 100644 --- a/crates/fspy/src/windows/mod.rs +++ b/crates/fspy/src/windows/mod.rs @@ -150,6 +150,7 @@ impl SpyImpl { }; Ok(TrackedChild { + id: child.id().expect("newly spawned child has a process ID"), stdin: child.stdin.take(), stdout: child.stdout.take(), stderr: child.stderr.take(), diff --git a/crates/vt/Cargo.toml b/crates/vt/Cargo.toml index 99e3f22c9..0f0a06d8f 100644 --- a/crates/vt/Cargo.toml +++ b/crates/vt/Cargo.toml @@ -64,16 +64,23 @@ wax = { workspace = true } zstd = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["net", "time"] } +ctor = { workspace = true } +subprocess_test = { workspace = true } tempfile = { workspace = true } [target.'cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))'.dependencies] fspy = { workspace = true } [target.'cfg(unix)'.dependencies] -nix = { workspace = true, features = ["dir"] } +nix = { workspace = true, features = ["dir", "signal"] } [target.'cfg(windows)'.dependencies] winapi = { workspace = true, features = ["handleapi", "jobapi2", "winnt"] } [lib] doctest = false + +[package.metadata.cargo-shear] +# Expanded by subprocess_test::command_for_fn! in the cancellation regression. +ignored = ["ctor"] diff --git a/crates/vt/src/session/execute/mod.rs b/crates/vt/src/session/execute/mod.rs index ae31c485e..ee2534dbf 100644 --- a/crates/vt/src/session/execute/mod.rs +++ b/crates/vt/src/session/execute/mod.rs @@ -419,6 +419,7 @@ async fn run( fspy_enabled, spawn_stdio, fast_fail_token.clone(), + interrupt_token.clone(), mode.injected_envs(), ) .await @@ -590,31 +591,31 @@ async fn run_child( stop_accepting: Option<&StopAccepting>, fast_fail_token: CancellationToken, ) -> Result { - let pipe_result: Result<(), ExecutionError> = if let Some(sinks) = sinks { - let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); - let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); - #[expect( - clippy::large_futures, - reason = "pipe_stdio streams child I/O and creates a large future" - )] - let r = pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()).await; - r.map_err(|err| ExecutionError::ForwardTaskProcessOutput(err.into())) - } else { - Ok(()) - }; - - let wait_result = match pipe_result { - Ok(()) => { - child.wait.await.map_err(|err| ExecutionError::WaitForTaskProcessExit(err.into())) - } - Err(err) => { - // Pipe failed — cancel so `child.wait` kills the child instead of - // orphaning it. Still signal the server below so it can drain. + let wait = child.wait; + let pipe = async { + let result = if let Some(sinks) = sinks { + let stdout = child.stdout.take().expect("SpawnStdio::Piped yields a stdout pipe"); + let stderr = child.stderr.take().expect("SpawnStdio::Piped yields a stderr pipe"); + #[expect( + clippy::large_futures, + reason = "pipe_stdio streams child I/O and creates a large future" + )] + let result = pipe_stdio(stdout, stderr, sinks, fast_fail_token.clone()).await; + result.map_err(|err| ExecutionError::ForwardTaskProcessOutput(err.into())) + } else { + Ok(()) + }; + if result.is_err() { fast_fail_token.cancel(); - let _ = child.wait.await; - Err(err) } + result }; + // The lifetime future forwards cancellation and terminal interruption to + // owned task groups while output is still being drained. + let (pipe_result, wait_result) = tokio::join!(pipe, wait); + let wait_result = pipe_result.and_then(|()| { + wait_result.map_err(|err| ExecutionError::WaitForTaskProcessExit(err.into())) + }); if let Some(stop_accepting) = stop_accepting { stop_accepting.signal(); diff --git a/crates/vt/src/session/execute/scheduler.rs b/crates/vt/src/session/execute/scheduler.rs index 44a3c6e37..3e957db0b 100644 --- a/crates/vt/src/session/execute/scheduler.rs +++ b/crates/vt/src/session/execute/scheduler.rs @@ -53,7 +53,8 @@ struct ExecutionContext<'a> { fast_fail_token: CancellationToken, /// Token cancelled by Ctrl-C. Unlike `fast_fail_token` (which kills /// children), this only prevents scheduling new tasks and caching - /// results — running processes are left to handle SIGINT naturally. + /// results. Foreground processes receive SIGINT from the terminal; isolated + /// piped task groups receive the forwarded signal. interrupt_token: CancellationToken, } diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index 7e2abb59e..7659cbfbf 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -30,8 +30,9 @@ pub enum SpawnStdio { /// /// `stdout` and `stderr` are `Some` iff [`SpawnStdio::Piped`] was requested. /// `wait` resolves when the child exits and handles cancellation internally: -/// when the token fires, the child (and on Windows its descendants via the Job -/// Object) is killed before the future resolves. +/// when the token fires, noninteractive tasks terminate their owned process group +/// (or Windows Job Object). Interactive Unix tasks keep the terminal foreground +/// group and its existing signal delivery. pub struct ChildHandle { pub stdout: Option, pub stderr: Option, @@ -64,6 +65,7 @@ pub async fn spawn( fspy: bool, stdio: SpawnStdio, cancellation_token: CancellationToken, + interrupt_token: CancellationToken, extra_envs: E, ) -> anyhow::Result where @@ -73,7 +75,7 @@ where { #[cfg(fspy)] if fspy { - return spawn_fspy(cmd, stdio, cancellation_token, extra_envs).await; + return spawn_fspy(cmd, stdio, cancellation_token, interrupt_token, extra_envs).await; } #[cfg(not(fspy))] let _ = fspy; @@ -85,7 +87,7 @@ where tokio_cmd.envs(extra_envs); tokio_cmd.current_dir(&*cmd.cwd); apply_stdio(&mut tokio_cmd, stdio); - spawn_tokio(tokio_cmd, cancellation_token) + spawn_tokio(tokio_cmd, stdio, cancellation_token, interrupt_token) } #[cfg(fspy)] @@ -93,6 +95,7 @@ async fn spawn_fspy( cmd: &SpawnCommand, stdio: SpawnStdio, cancellation_token: CancellationToken, + interrupt_token: CancellationToken, extra_envs: E, ) -> anyhow::Result where @@ -124,14 +127,29 @@ where } } - let mut tracked = fspy_cmd.spawn(cancellation_token).await?; + #[cfg(unix)] + let group = isolate_process_group(stdio); + #[cfg(unix)] + if group { + fspy_cmd.process_group(0); + } + // Task ownership includes descendants. Keep cancellation here so the + // whole task scope terminates before the trace is collected. + let mut tracked = fspy_cmd.spawn(CancellationToken::new()).await?; + #[cfg(unix)] + let process_scope = TaskProcess { + id: nix::unistd::Pid::from_raw(tracked.id.try_into().expect("process ID fits pid_t")), + group, + }; // On Windows, assign the child to a Job Object so that killing the child // also kills all descendant processes (e.g., node.exe via a .cmd shim). #[cfg(windows)] - let job = { - use std::os::windows::io::AsRawHandle; - super::win_job::assign_to_kill_on_close_job(tracked.process_handle.as_raw_handle())? + let process_scope = TaskProcess { + job: { + use std::os::windows::io::AsRawHandle; + super::win_job::assign_to_kill_on_close_job(tracked.process_handle.as_raw_handle())? + }, }; let stdout = tracked.stdout.take(); @@ -139,12 +157,9 @@ where let wait_handle = tracked.wait_handle; let wait = async move { - let termination = wait_handle.await?; - // Drop order: `job` drops here, KILL_ON_JOB_CLOSE kills any descendants - // still alive. fspy's wait handle already watched the cancellation - // token and killed the direct child. - #[cfg(windows)] - drop(job); + let termination = + wait_for_termination(wait_handle, process_scope, cancellation_token, interrupt_token) + .await?; Ok(ChildOutcome { exit_status: termination.status, path_accesses: Some(termination.path_accesses), @@ -157,37 +172,54 @@ where fn spawn_tokio( mut cmd: tokio::process::Command, + stdio: SpawnStdio, cancellation_token: CancellationToken, + interrupt_token: CancellationToken, ) -> anyhow::Result { + #[cfg(unix)] + let group = isolate_process_group(stdio); + #[cfg(unix)] + if group { + cmd.process_group(0); + } + #[cfg(windows)] + let _ = stdio; let mut child = cmd.spawn()?; + #[cfg(unix)] + let process_scope = TaskProcess { + id: nix::unistd::Pid::from_raw( + child + .id() + .expect("new child has a process ID") + .try_into() + .expect("process ID fits pid_t"), + ), + group, + }; #[cfg(windows)] - let job = { - use std::os::windows::io::{AsRawHandle, BorrowedHandle}; - // Duplicate the process handle so the job outlives tokio's handle. - // SAFETY: The child was just spawned, so its raw handle is valid. - let borrowed = unsafe { BorrowedHandle::borrow_raw(child.raw_handle().unwrap()) }; - let owned = borrowed.try_clone_to_owned()?; - super::win_job::assign_to_kill_on_close_job(owned.as_raw_handle())? + let process_scope = TaskProcess { + job: { + use std::os::windows::io::{AsRawHandle, BorrowedHandle}; + // Duplicate the process handle so the job outlives tokio's handle. + // SAFETY: The child was just spawned, so its raw handle is valid. + let borrowed = unsafe { BorrowedHandle::borrow_raw(child.raw_handle().unwrap()) }; + let owned = borrowed.try_clone_to_owned()?; + super::win_job::assign_to_kill_on_close_job(owned.as_raw_handle())? + }, }; let stdout = child.stdout.take(); let stderr = child.stderr.take(); let wait = async move { - let exit_status = tokio::select! { - status = child.wait() => status?, - () = cancellation_token.cancelled() => { - child.start_kill()?; - // Eagerly kill descendants; KILL_ON_JOB_CLOSE on drop is a backstop. - #[cfg(windows)] - job.terminate(); - child.wait().await? - } - }; - // `job` drops here on Windows, terminating any stragglers. - #[cfg(windows)] - drop(job); + let exit_status = wait_for_termination( + async move { child.wait().await }, + process_scope, + cancellation_token, + interrupt_token, + ) + .await?; Ok(ChildOutcome { exit_status, #[cfg(fspy)] @@ -199,6 +231,89 @@ fn spawn_tokio( Ok(ChildHandle { stdout, stderr, wait }) } +#[cfg(unix)] +fn isolate_process_group(stdio: SpawnStdio) -> bool { + use std::io::IsTerminal; + stdio == SpawnStdio::Piped || !std::io::stdin().is_terminal() +} + +struct TaskProcess { + #[cfg(unix)] + id: nix::unistd::Pid, + #[cfg(unix)] + group: bool, + #[cfg(windows)] + job: super::win_job::OwnedJobHandle, +} + +impl TaskProcess { + const fn forwards_interrupt(&self) -> bool { + #[cfg(unix)] + { + self.group + } + #[cfg(windows)] + { + false + } + } + + fn terminate(&self) -> io::Result<()> { + #[cfg(unix)] + { + self.signal(nix::sys::signal::Signal::SIGKILL) + } + #[cfg(windows)] + { + self.job.terminate() + } + } + + #[cfg(unix)] + fn interrupt(&self) -> io::Result<()> { + self.signal(nix::sys::signal::Signal::SIGINT) + } + + #[cfg(unix)] + fn signal(&self, signal: nix::sys::signal::Signal) -> io::Result<()> { + use nix::{ + errno::Errno, + sys::signal::{kill, killpg}, + }; + // Only piped or noninteractive tasks enter a fresh group. Never signal the + // runner's own foreground group or a group discovered by enumeration. + let result = if self.group { killpg(self.id, signal) } else { kill(self.id, signal) }; + match result { + Ok(()) | Err(Errno::ESRCH) => Ok(()), + Err(error) => Err(error.into()), + } + } +} + +async fn wait_for_termination( + termination: impl std::future::Future>, + process_scope: TaskProcess, + cancellation_token: CancellationToken, + interrupt_token: CancellationToken, +) -> io::Result { + tokio::pin!(termination); + let mut interrupted = false; + loop { + tokio::select! { + result = &mut termination => return result, + () = cancellation_token.cancelled() => { + process_scope.terminate()?; + return termination.await; + } + () = interrupt_token.cancelled(), if process_scope.forwards_interrupt() && !interrupted => { + #[cfg(unix)] + process_scope.interrupt()?; + interrupted = true; + } + } + } +} + fn apply_stdio(cmd: &mut tokio::process::Command, stdio: SpawnStdio) { match stdio { SpawnStdio::Inherited => { @@ -244,3 +359,106 @@ fn clear_stdio_cloexec() -> io::Result<()> { } Ok(()) } + +#[cfg(test)] +mod tests { + use std::{io, sync::Arc, time::Duration}; + + use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + net::TcpListener, + }; + use tokio_util::sync::CancellationToken; + use vt_path::AbsolutePath; + use vt_plan::SpawnCommand; + + use super::{SpawnStdio, spawn}; + + // https://github.com/voidzero-dev/vite-task/pull/675 + // Nonblocking trace collection must not leave cancelled task descendants alive. + #[tokio::test] + async fn cancelled_task_terminates_descendants() -> anyhow::Result<()> { + let mut failures = Vec::new(); + for tracked in [false, true] { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let address = vt_str::Str::from(listener.local_addr()?.to_string()); + let command = subprocess_test::command_for_fn!(address, |address: vt_str::Str| { + use std::io::Read; + // Do not spawn descendants until the runner has finished + // attaching the task's scope (including Windows Job Objects). + let mut started = std::net::TcpStream::connect(address.as_str()).unwrap(); + started.read_exact(&mut [0u8]).unwrap(); + drop(started); + let descendant = + subprocess_test::command_for_fn!(address, |address: vt_str::Str| { + use std::io::{Read, Write}; + let mut stream = std::net::TcpStream::connect(address.as_str()).unwrap(); + stream.write_all(&std::process::id().to_ne_bytes()).unwrap(); + // Only the owning test can release this barrier. Cancellation + // must terminate the descendant without releasing it. + let _ = stream.read_exact(&mut [0u8]); + }); + let mut command = std::process::Command::from(descendant); + command.stdin(std::process::Stdio::null()); + command.stdout(std::process::Stdio::null()); + command.stderr(std::process::Stdio::null()); + command.spawn().unwrap().wait().unwrap(); + }); + let command = SpawnCommand { + program_path: Arc::from(AbsolutePath::new(&command.program).unwrap()), + args: command.args.iter().map(|arg| arg.to_str().unwrap().into()).collect(), + spawn_envs: Arc::new( + command.envs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(), + ), + cwd: Arc::from(AbsolutePath::new(&command.cwd).unwrap()), + }; + let cancelled = CancellationToken::new(); + let mut child = spawn( + &command, + tracked, + SpawnStdio::Piped, + cancelled.clone(), + CancellationToken::new(), + std::iter::empty::<(&str, &str)>(), + ) + .await?; + let (mut started, _) = listener.accept().await?; + started.write_all(b"x").await?; + drop(started); + let (mut stream, _) = listener.accept().await?; + let mut pid = [0u8; 4]; + stream.read_exact(&mut pid).await?; + let descendant_pid = u32::from_ne_bytes(pid); + cancelled.cancel(); + + let mut outcome = None; + let mut byte = [0u8]; + let settled = tokio::time::timeout(Duration::from_secs(2), async { + let (eof, status) = tokio::join!(stream.read(&mut byte), async { + outcome = Some(child.wait.as_mut().await?); + io::Result::Ok(()) + }); + status?; + assert_eq!(eof?, 0, "descendant barrier was unexpectedly released"); + io::Result::Ok(()) + }) + .await; + if let Ok(result) = settled { + result?; + } else { + // Clean up the known descendant on the red implementation. + // This release is never used to satisfy the assertion. + stream.write_all(b"x").await?; + if outcome.is_none() { + outcome = Some(child.wait.await?); + } + failures.push(vt_str::format!( + "tracking={tracked}: cancellation left descendant {descendant_pid} alive" + )); + } + assert!(!outcome.unwrap().exit_status.success()); + } + assert!(failures.is_empty(), "{failures:?}"); + Ok(()) + } +} diff --git a/crates/vt/src/session/execute/win_job.rs b/crates/vt/src/session/execute/win_job.rs index 659203b1f..7614fc545 100644 --- a/crates/vt/src/session/execute/win_job.rs +++ b/crates/vt/src/session/execute/win_job.rs @@ -25,9 +25,12 @@ impl OwnedJobHandle { /// /// This is needed when pipes to a grandchild process must be closed before /// the job handle is dropped (e.g., to unblock pipe reads in `spawn`). - pub(super) fn terminate(&self) { + pub(super) fn terminate(&self) -> io::Result<()> { // SAFETY: self.0 is a valid job handle from CreateJobObjectW. - unsafe { TerminateJobObject(self.0, 1) }; + if unsafe { TerminateJobObject(self.0, 1) } == FALSE { + return Err(io::Error::last_os_error()); + } + Ok(()) } } From 036ab7f3155e372c220905a570124b70ac5cc394 Mon Sep 17 00:00:00 2001 From: Dan van der Merwe Date: Mon, 14 Sep 2026 12:19:04 -0700 Subject: [PATCH 2/3] chore: finish cancellation review metadata Co-authored-by: GPT-6 Codex --- CHANGELOG.md | 2 +- crates/vt/src/session/execute/spawn.rs | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82ff0205a..39598216f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -- **Fixed** Cancelling a task after a sibling failure now terminates its descendant processes as well, so worker runtimes and background helpers do not survive the cancelled run ([#???](https://github.com/voidzero-dev/vite-task/pull/???)). +- **Fixed** Cancelling a noninteractive task after a sibling failure now terminates its descendant processes as well, so worker runtimes and background helpers do not survive the cancelled run ([#724](https://github.com/voidzero-dev/vite-task/pull/724)). - **Fixed** `vp run` no longer hangs or fails when a task leaves a process running behind it, such as a dev server or a background helper, or when one of a task's processes is killed. The run finishes as soon as the task itself does, and the files the task used are still recorded ([#544](https://github.com/voidzero-dev/vite-task/issues/544), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** A task that reads or writes an unusually large number of files now runs to the end instead of being killed partway through. Vite+ reports the run as not cached, because it could not record every file the task used ([#533](https://github.com/voidzero-dev/vite-task/issues/533), [#675](https://github.com/voidzero-dev/vite-task/pull/675)). - **Fixed** Vite+ diagnostics now display individual paths and working directories without Rust debug formatting such as quoted paths or escaped Windows backslashes ([#534](https://github.com/voidzero-dev/vite-task/pull/534)). diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index 7659cbfbf..aa00a3c00 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -247,6 +247,13 @@ struct TaskProcess { } impl TaskProcess { + #[cfg_attr( + windows, + expect( + clippy::unused_self, + reason = "Windows console events are delivered without per-task forwarding" + ) + )] const fn forwards_interrupt(&self) -> bool { #[cfg(unix)] { From 83fd4ee084f6143dd0379001a541fba97a72385e Mon Sep 17 00:00:00 2001 From: Dan van der Merwe Date: Mon, 14 Sep 2026 12:21:33 -0700 Subject: [PATCH 3/3] test(run): cite introducing cancellation commit Co-authored-by: GPT-6 Codex --- crates/vt/src/session/execute/spawn.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/vt/src/session/execute/spawn.rs b/crates/vt/src/session/execute/spawn.rs index aa00a3c00..01096a1ed 100644 --- a/crates/vt/src/session/execute/spawn.rs +++ b/crates/vt/src/session/execute/spawn.rs @@ -381,8 +381,8 @@ mod tests { use super::{SpawnStdio, spawn}; - // https://github.com/voidzero-dev/vite-task/pull/675 - // Nonblocking trace collection must not leave cancelled task descendants alive. + // https://github.com/voidzero-dev/vite-task/commit/88e796f4b49e2bcdf4bf30a781250594fc62a030 + // Fast-fail cancellation must terminate the task's descendants. #[tokio::test] async fn cancelled_task_terminates_descendants() -> anyhow::Result<()> { let mut failures = Vec::new();