diff --git a/Cargo.lock b/Cargo.lock index 9e08fd4d..d9f4e2dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3980,12 +3980,14 @@ dependencies = [ "clap", "cow-utils", "cp_r", + "ctrlc", "insta", "jsonc-parser", "libc", "notify", "pty_terminal", "pty_terminal_test", + "pty_terminal_test_client", "regex", "serde", "serde_json", diff --git a/crates/pty_terminal/tests/terminal.rs b/crates/pty_terminal/tests/terminal.rs index aebbcb5d..ec6bcc49 100644 --- a/crates/pty_terminal/tests/terminal.rs +++ b/crates/pty_terminal/tests/terminal.rs @@ -305,8 +305,12 @@ fn send_ctrl_c_interrupts_process() { // On macOS/Windows, use ctrlc which works fine (no .init_array/musl issue). #[cfg(not(target_os = "linux"))] { - // On Windows, clear the "ignore CTRL_C" flag set by Rust runtime - // so that CTRL_C_EVENT reaches the ctrlc handler. + // On Windows, an ancestor process may have been created with + // CREATE_NEW_PROCESS_GROUP, which implicitly sets the per-process + // CTRL_C ignore flag (CONSOLE_IGNORE_CTRL_C in PEB ConsoleFlags). + // This flag is inherited by all descendants and silently drops + // CTRL_C_EVENT before it reaches registered handlers. Clear it. + // Ref: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags #[cfg(windows)] { // SAFETY: Declaring correct signature for SetConsoleCtrlHandler from kernel32. diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index 4fbc99e2..18e45ee5 100644 --- a/crates/vite_task_bin/Cargo.toml +++ b/crates/vite_task_bin/Cargo.toml @@ -16,8 +16,10 @@ path = "src/vtt/main.rs" [dependencies] anyhow = { workspace = true } +ctrlc = { workspace = true } libc = { workspace = true } notify = { workspace = true } +pty_terminal_test_client = { workspace = true, features = ["testing"] } async-trait = { workspace = true } clap = { workspace = true, features = ["derive"] } jsonc-parser = { workspace = true } diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index a0569897..2fe73c00 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -1,13 +1,27 @@ -use std::process::ExitCode; - use clap::Parser as _; use vite_task::{Command, ExitStatus, Session}; use vite_task_bin::OwnedSessionConfig; -#[tokio::main] -async fn main() -> anyhow::Result { - let exit_status = run().await?; - Ok(exit_status.0.into()) +fn main() -> ! { + // Ignore SIGINT/CTRL_C before the tokio runtime starts. Child tasks + // receive the signal directly from the terminal driver and handle it + // themselves. This lets the runner wait for tasks to exit and report + // their actual exit status rather than being killed mid-flight. + let _ = ctrlc::set_handler(|| {}); + + let exit_code: i32 = + tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async { + match run().await { + Ok(status) => i32::from(status.0), + #[expect(clippy::print_stderr, reason = "top-level error reporting")] + Err(err) => { + eprintln!("Error: {err:?}"); + 1 + } + } + }); + + std::process::exit(exit_code); } async fn run() -> anyhow::Result { diff --git a/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs new file mode 100644 index 00000000..3eed75e8 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs @@ -0,0 +1,40 @@ +/// exit-on-ctrlc +/// +/// Sets up a Ctrl+C handler, emits a "ready" milestone, then waits. +/// When Ctrl+C is received, prints "ctrl-c received" and exits. +pub fn run() -> Result<(), Box> { + // On Windows, an ancestor process (e.g. cargo, the test harness) may have + // been created with CREATE_NEW_PROCESS_GROUP, which implicitly calls + // SetConsoleCtrlHandler(NULL, TRUE) and sets CONSOLE_IGNORE_CTRL_C in the + // PEB's ConsoleFlags. This flag is inherited by all descendants and takes + // precedence over registered handlers — CTRL_C_EVENT is silently dropped. + // Clear it so our handler can fire. + // Ref: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags + #[cfg(windows)] + { + // SAFETY: Passing (None, FALSE) clears the per-process CTRL_C ignore flag. + unsafe extern "system" { + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + // SAFETY: Clearing the inherited ignore flag. + unsafe { + SetConsoleCtrlHandler(None, 0); + } + } + + ctrlc::set_handler(move || { + use std::io::Write; + let _ = write!(std::io::stdout(), "ctrl-c received"); + let _ = std::io::stdout().flush(); + std::process::exit(0); + })?; + + pty_terminal_test_client::mark_milestone("ready"); + + loop { + std::thread::park(); + } +} diff --git a/crates/vite_task_bin/src/vtt/main.rs b/crates/vite_task_bin/src/vtt/main.rs index a51014d3..527e423b 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -10,6 +10,7 @@ mod barrier; mod check_tty; mod cp; mod exit; +mod exit_on_ctrlc; mod mkdir; mod pipe_stdin; mod print; @@ -27,7 +28,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, cp, exit, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" + "Subcommands: barrier, check-tty, cp, exit, exit-on-ctrlc, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file" ); std::process::exit(1); } @@ -40,7 +41,9 @@ fn main() { } "cp" => cp::run(&args[2..]), "exit" => exit::run(&args[2..]), + "exit-on-ctrlc" => exit_on_ctrlc::run(), "mkdir" => mkdir::run(&args[2..]), + "pipe-stdin" => pipe_stdin::run(&args[2..]), "print" => { print::run(&args[2..]); Ok(()) @@ -50,7 +53,6 @@ fn main() { "print-file" => print_file::run(&args[2..]), "read-stdin" => read_stdin::run(), "replace-file-content" => replace_file_content::run(&args[2..]), - "pipe-stdin" => pipe_stdin::run(&args[2..]), "rm" => rm::run(&args[2..]), "touch-file" => touch_file::run(&args[2..]), "write-file" => write_file::run(&args[2..]), diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/package.json new file mode 100644 index 00000000..f444dee5 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/package.json @@ -0,0 +1,3 @@ +{ + "name": "ctrl-c-test" +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml new file mode 100644 index 00000000..db217d6d --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml @@ -0,0 +1,14 @@ +# Tests that Ctrl+C (SIGINT) propagates to and terminates a running task. + +[[e2e]] +name = "ctrl-c terminates running tasks" +steps = [ + { argv = [ + "vt", + "run", + "dev", + ], interactions = [ + { "expect-milestone" = "ready" }, + { "write-key" = "ctrl-c" }, + ] }, +] diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap new file mode 100644 index 00000000..b19a6308 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap @@ -0,0 +1,10 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run dev +@ expect-milestone: ready +$ vtt exit-on-ctrlc ⊘ cache disabled +@ write-key: ctrl-c +$ vtt exit-on-ctrlc ⊘ cache disabled +ctrl-c received diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/vite-task.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/vite-task.json new file mode 100644 index 00000000..f66089b1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/vite-task.json @@ -0,0 +1,8 @@ +{ + "cache": false, + "tasks": { + "dev": { + "command": "vtt exit-on-ctrlc" + } + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs index b2e85f22..4cc3fa64 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs @@ -101,6 +101,15 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String { let mise_warning_regex = regex::Regex::new(r"(?m)^mise WARN\s+.*\n?").unwrap(); output = mise_warning_regex.replace_all(&output, "").into_owned(); + // Remove ^C echo that Unix terminal drivers emit when ETX (0x03) is written + // to the PTY. Windows ConPTY does not echo it. + { + use cow_utils::CowUtils as _; + if let Cow::Owned(replaced) = output.as_str().cow_replace("^C", "") { + output = replaced; + } + } + // Sort consecutive diagnostic blocks to handle non-deterministic tool output // (e.g., oxlint reports warnings in arbitrary order due to multi-threading). // Each block starts with " ! " and ends at the next empty line.