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

- **Fixed** When the task runner itself is stopped with a termination signal (`kill`, a supervisor, or `docker stop`), it now forwards SIGINT, SIGTERM, and SIGHUP to the running tasks so they can shut down cleanly, and waits for them to exit instead of leaving them running behind it. Ctrl-C in the terminal already reached the tasks and keeps working as before ([vite-plus#3090](https://github.com/voidzero-dev/vite-plus/issues/3090), [#863](https://github.com/voidzero-dev/vite-task/pull/863)).
- **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)).
- **Fixed** In cached tasks on Linux and macOS, programs that pass `-1` as a directory descriptor to calls such as `openat` no longer abort. For example, `ps -p <pid>` from procps-ng 4.0.6 or later aborted when the process had already exited ([#862](https://github.com/voidzero-dev/vite-task/pull/862)).
- **Fixed** `vp run` no longer panics before running a script that contains non-ASCII characters, such as `echo こんにちは`, and no longer cuts off such commands where it displays them ([#861](https://github.com/voidzero-dev/vite-task/pull/861)).
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,7 @@ serde_json = "1.0.140"
serde_norway = "0.9.42"
sha2 = "0.11.0"
shell-escape = "0.1.5"
signal-hook = "0.3.18"
fspy_nostd = { path = "crates/fspy_nostd" }
fspy_nostd_alloc = { path = "crates/fspy_nostd_alloc" }
similar = "3.0.0"
Expand Down
4 changes: 4 additions & 0 deletions crates/fspy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ pub struct TrackedChild {
/// The future that resolves to exit status and path accesses when the process exits.
pub wait_handle: BoxFuture<'static, io::Result<ChildTermination>>,

/// The child's process id, so callers can forward termination signals to
/// it. `None` until the platform reports it.
pub pid: Option<u32>,

/// A duplicated process handle of the child, captured before the tokio `Child`
/// is moved into the background wait task. This is an independently owned handle
/// (via `DuplicateHandle`) so it remains valid even after tokio closes its copy.
Expand Down
1 change: 1 addition & 0 deletions crates/fspy/src/unix/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ impl SpyImpl {
.map_err(SpawnError::OsSpawn)?;

Ok(TrackedChild {
pid: child.id(),
stdin: child.stdin.take(),
stdout: child.stdout.take(),
stderr: child.stderr.take(),
Expand Down
1 change: 1 addition & 0 deletions crates/fspy/src/windows/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ impl SpyImpl {
})?;

Ok(TrackedChild {
pid: child.id(),
stdin: child.stdin.take(),
stdout: child.stdout.take(),
stderr: child.stderr.take(),
Expand Down
3 changes: 2 additions & 1 deletion crates/vt/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ tempfile = { workspace = true }
fspy = { workspace = true }

[target.'cfg(unix)'.dependencies]
nix = { workspace = true, features = ["dir", "poll"] }
nix = { workspace = true, features = ["dir", "poll", "signal"] }
signal-hook = { workspace = true }

[target.'cfg(windows)'.dependencies]
winapi = { workspace = true, features = ["handleapi", "jobapi2", "winnt"] }
Expand Down
16 changes: 16 additions & 0 deletions crates/vt/src/session/execute/spawn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ use tokio::process::{ChildStderr, ChildStdout};
use tokio_util::sync::CancellationToken;
use vt_plan::SpawnCommand;

use crate::session::signals::register_child;

/// How the child's stdin/stdout/stderr are configured.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SpawnStdio {
Expand Down Expand Up @@ -125,6 +127,10 @@ where
}

let mut tracked = fspy_cmd.spawn(cancellation_token).await?;
// Forward termination signals received by the runner to the task process
// (see `crate::session::signals`). The registration lives until the wait
// future completes.
let registration = register_child(tracked.pid);

// 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).
Expand All @@ -139,6 +145,9 @@ where
let wait_handle = tracked.wait_handle;

let wait = async move {
// Keep the task process registered for signal forwarding until it has
// exited, so a termination signal received meanwhile reaches it.
let _registration = registration;
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
Expand All @@ -160,6 +169,10 @@ fn spawn_tokio(
cancellation_token: CancellationToken,
) -> anyhow::Result<ChildHandle> {
let mut child = cmd.spawn()?;
// Forward termination signals received by the runner to the task process
// (see `crate::session::signals`). The registration lives until the wait
// future completes.
let registration = register_child(child.id());

#[cfg(windows)]
let job = {
Expand All @@ -175,6 +188,9 @@ fn spawn_tokio(
let stderr = child.stderr.take();

let wait = async move {
// Keep the task process registered for signal forwarding until it has
// exited, so a termination signal received meanwhile reaches it.
let _registration = registration;
let exit_status = tokio::select! {
status = child.wait() => status?,
() = cancellation_token.cancelled() => {
Expand Down
41 changes: 10 additions & 31 deletions crates/vt/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod cache;
mod event;
mod execute;
pub(crate) mod reporter;
mod signals;

// Re-export types that are part of the public API
use std::{ffi::OsStr, fmt::Debug, io::IsTerminal, sync::Arc};
Expand Down Expand Up @@ -379,40 +380,18 @@ impl<'a> Session<'a> {
self.program_name.clone(),
color_support,
));
// Don't let SIGINT/CTRL_C kill the runner. Child tasks receive
// the signal directly from the terminal driver and handle it
// themselves. Cancelling the cancel token prevents scheduling
// new tasks and caching results of in-flight tasks, and stops
// remote cache lookups. It's a child of the fast-fail token,
// so fast-fail cancels it too. Only Ctrl-C cancels the
// interrupt token, which cancels remote cache uploads.
//
// On Windows, an ancestor process (e.g. cargo) may have been
// created with CREATE_NEW_PROCESS_GROUP, which sets a per-process
// flag that silently drops CTRL_C_EVENT before it reaches
// registered handlers. Clear it so our handler fires.
//
// SAFETY: Passing (None, FALSE) clears the inherited
// CTRL_C ignore flag.
#[cfg(windows)]
unsafe {
unsafe extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
SetConsoleCtrlHandler(None, 0);
}
// Termination signals are forwarded to the running task
// processes, and cancel the cancel token: see `signals`. As
// before, an in-terminal Ctrl-C is delivered to the tasks by
// the terminal driver, and cancelling the cancel token
// prevents scheduling new tasks and caching results of
// in-flight tasks, and stops remote cache lookups. The
// interrupt token, which only this handler cancels, cancels
// remote cache uploads.
let fast_fail_token = tokio_util::sync::CancellationToken::new();
let cancel_token = fast_fail_token.child_token();
let interrupt_token = tokio_util::sync::CancellationToken::new();
let ct = cancel_token.clone();
let it = interrupt_token.clone();
ctrlc::set_handler(move || {
ct.cancel();
it.cancel();
})?;
signals::install(cancel_token.clone(), interrupt_token.clone())?;

self.execute_graph(graph, builder, fast_fail_token, cancel_token, interrupt_token)
.await
Expand Down
174 changes: 174 additions & 0 deletions crates/vt/src/session/signals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
//! Forwarding of termination signals to running task processes.
//!
//! A signal sent to the runner alone (`kill <pid>`, a supervisor, an editor's
//! stop button) does not reach the task processes that runner started.
//! Without forwarding, the runner exits and the tasks are orphaned, holding
//! ports and files behind it. The terminal is the only sender that reaches
//! tasks on its own, because Ctrl-C goes to the whole foreground process
//! group; this module makes directed signals behave the same way by
//! forwarding SIGINT, SIGTERM and SIGHUP to every registered task process.
//!
//! The handler also cancels the run's tokens, exactly like an in-terminal
//! Ctrl-C: no new tasks are scheduled and in-flight tasks aren't cached.
//! The run then ends through the regular wait paths, once the tasks exit.
//!
//! Forwarding happens on a dedicated thread fed by a self-pipe
//! ([`signal_hook`]), never inside the signal handler itself, which only
//! writes to the pipe (the one async-signal-safe operation available).
//!
//! Task processes stay in the runner's process group, so on a terminal they
//! receive Ctrl-C from the terminal driver *and* through this forwarding. The
//! duplicate is harmless because handling a termination signal is idempotent,
//! and keeping tasks in the foreground group is what lets interactive tasks
//! read from the terminal at all (a separate process group would stop them
//! with SIGTTIN).
//!
//! On Windows there are no Unix-style signals. Ctrl-C console events reach
//! every process attached to the console, and cancelling the run's tokens
//! terminates the task trees through their Job Objects, so the handler only
//! cancels the tokens there.

use std::io;

use tokio_util::sync::CancellationToken;

/// Registers a spawned task process so that termination signals received by
/// this process are forwarded to it.
///
/// Callers keep the registration for as long as the process runs and drop it
/// once the process has exited, so signals are never forwarded to a process
/// id that has been reaped and possibly reused.
pub struct ChildRegistration {
#[cfg(unix)]
pid: Option<u32>,
}

#[cfg(unix)]
impl Drop for ChildRegistration {
fn drop(&mut self) {
let Some(pid) = self.pid else {
return;
};
CHILDREN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).remove(&pid);
}
}

/// Registers `pid` for signal forwarding. `None` (the process id is unknown)
/// registers nothing.
pub fn register_child(pid: Option<u32>) -> ChildRegistration {
#[cfg(unix)]
if let Some(pid) = pid {
CHILDREN.lock().unwrap_or_else(std::sync::PoisonError::into_inner).insert(pid);
}
#[cfg(not(unix))]
let _ = pid;
ChildRegistration {
#[cfg(unix)]
pid,
}
}

/// Installs the signal handler and points it at the current run's tokens.
///
/// The handler thread is started once per process; later calls only update the
/// tokens. Every call registers the handler for its tokens before returning,
/// so the caller can rely on signals being handled from here on.
#[cfg(unix)]
pub fn install(cancel: CancellationToken, interrupt: CancellationToken) -> io::Result<()> {
use std::{
sync::{OnceLock, PoisonError},
thread,
};

use signal_hook::{
consts::{SIGHUP, SIGINT, SIGTERM},
iterator::Signals,
};

static STARTED: OnceLock<()> = OnceLock::new();

*TARGETS.lock().unwrap_or_else(PoisonError::into_inner) = Some(Targets { cancel, interrupt });

if STARTED.get().is_some() {
return Ok(());
}
let mut signals = Signals::new([SIGINT, SIGTERM, SIGHUP])?;
let _ = STARTED.set(());
thread::Builder::new().name("vt-signal-forward".to_string()).spawn(move || {
for signal in signals.forever() {
handle(signal);
}
})?;
Ok(())
}

/// Cancels the current run's tokens and forwards `signal` to every registered
/// task process.
#[cfg(unix)]
fn handle(signal: i32) {
use std::sync::PoisonError;

use nix::{
sys::signal::{Signal, kill},
unistd::Pid,
};

if let Some(targets) = TARGETS.lock().unwrap_or_else(PoisonError::into_inner).as_ref() {
targets.cancel.cancel();
targets.interrupt.cancel();
}

let Ok(signal) = Signal::try_from(signal) else {
return;
};
let children: Vec<u32> =
CHILDREN.lock().unwrap_or_else(PoisonError::into_inner).iter().copied().collect();
for pid in children {
let _ = kill(Pid::from_raw(pid.cast_signed()), signal);
}
}

/// On Windows, console Ctrl-C events already reach every process attached to
/// the console, and the Job Object assigned to each task kills its whole
/// process tree when the run's tokens are cancelled. The handler therefore
/// only cancels the tokens.
#[cfg(windows)]
pub(crate) fn install(cancel: CancellationToken, interrupt: CancellationToken) -> io::Result<()> {
// An ancestor process (e.g. cargo) may have been created with
// CREATE_NEW_PROCESS_GROUP, which sets a per-process flag that silently
// drops CTRL_C_EVENT before it reaches registered handlers. Clear it so
// our handler fires.
//
// SAFETY: Passing (None, FALSE) clears the inherited CTRL_C ignore flag.
unsafe {
unsafe extern "system" {
fn SetConsoleCtrlHandler(
handler: Option<unsafe extern "system" fn(u32) -> i32>,
add: i32,
) -> i32;
}
SetConsoleCtrlHandler(None, 0);
}
ctrlc::set_handler(move || {
cancel.cancel();
interrupt.cancel();
})
.map_err(io::Error::other)
}

/// Task processes to forward termination signals to.
#[cfg(unix)]
static CHILDREN: std::sync::Mutex<std::collections::BTreeSet<u32>> =
std::sync::Mutex::new(std::collections::BTreeSet::new());

/// The tokens of the run in progress.
#[cfg(unix)]
static TARGETS: std::sync::Mutex<Option<Targets>> = std::sync::Mutex::new(None);

/// The tokens a forwarded signal cancels, mirroring the in-terminal Ctrl-C
/// handler this module replaced.
#[cfg(unix)]
struct Targets {
cancel: CancellationToken,
interrupt: CancellationToken,
}
3 changes: 3 additions & 0 deletions crates/vt_bin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ vt_str = { workspace = true }
vt = { workspace = true }
which = { workspace = true }

[target.'cfg(unix)'.dependencies]
signal-hook = { workspace = true }

[target.'cfg(target_os = "linux")'.dependencies]
nix = { workspace = true, features = ["mount", "sched", "user"] }

Expand Down
25 changes: 25 additions & 0 deletions crates/vt_bin/src/vtt/exit_on_sigterm.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
/// exit-on-sigterm
///
/// Sets up a SIGTERM handler, emits a "ready" milestone, then waits.
/// When SIGTERM is received, prints "sigterm received" and exits.
#[cfg(unix)]
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
use signal_hook::{consts::SIGTERM, iterator::Signals};

let mut signals = Signals::new([SIGTERM])?;

vt_pty_terminal_test_client::mark_milestone("ready");

for signal in signals.forever() {
if signal == SIGTERM {
break;
}
}
println!("sigterm received");
Ok(())
}

#[cfg(not(unix))]
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
Err("vtt exit-on-sigterm is only supported on Unix".into())
}
Loading