diff --git a/Cargo.lock b/Cargo.lock index d9b24f76..ec0aea95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3979,12 +3979,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/pty_terminal_test/src/lib.rs b/crates/pty_terminal_test/src/lib.rs index b0cbe0d1..04adee8c 100644 --- a/crates/pty_terminal_test/src/lib.rs +++ b/crates/pty_terminal_test/src/lib.rs @@ -1,4 +1,7 @@ -use std::io::{BufReader, Read}; +use std::{ + collections::VecDeque, + io::{BufReader, Read}, +}; pub use portable_pty::CommandBuilder; use pty_terminal::terminal::{PtyReader, Terminal}; @@ -25,6 +28,8 @@ pub struct TestTerminal { pub struct Reader { pty: BufReader, child_handle: ChildHandle, + /// OSC sequences taken from the PTY but not yet consumed by `expect_milestone`. + pending_osc: VecDeque>>, } impl TestTerminal { @@ -37,7 +42,11 @@ impl TestTerminal { let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?; Ok(Self { writer: pty_writer, - reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() }, + reader: Reader { + pty: BufReader::new(pty_reader), + child_handle: child_handle.clone(), + pending_osc: VecDeque::new(), + }, child_handle, }) } @@ -71,15 +80,24 @@ impl Reader { let mut buf = [0u8; 4096]; loop { - let found = self - .pty - .get_ref() - .take_unhandled_osc_sequences() - .into_iter() - .filter_map(|params| { - pty_terminal_test_client::decode_milestone_from_osc8_params(¶ms) - }) - .any(|decoded| decoded == name); + // Drain new sequences from the PTY into our local buffer. + self.pending_osc.append(&mut self.pty.get_ref().take_unhandled_osc_sequences()); + + // Scan for the first matching milestone, keeping the rest. + let mut found = false; + let mut remaining = VecDeque::with_capacity(self.pending_osc.len()); + for params in self.pending_osc.drain(..) { + if !found + && pty_terminal_test_client::decode_milestone_from_osc8_params(¶ms) + .is_some_and(|decoded| decoded == name) + { + found = true; + continue; + } + remaining.push_back(params); + } + self.pending_osc = remaining; + if found { return self.screen_contents(); } diff --git a/crates/vite_task/src/session/execute/mod.rs b/crates/vite_task/src/session/execute/mod.rs index 9fec69b1..1627f691 100644 --- a/crates/vite_task/src/session/execute/mod.rs +++ b/crates/vite_task/src/session/execute/mod.rs @@ -560,6 +560,7 @@ async fn spawn_inherited( } } } + Ok(()) }); } diff --git a/crates/vite_task_bin/Cargo.toml b/crates/vite_task_bin/Cargo.toml index d80200eb..3fc4033f 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 bddaf857..144defb0 100644 --- a/crates/vite_task_bin/src/vtt/main.rs +++ b/crates/vite_task_bin/src/vtt/main.rs @@ -8,6 +8,7 @@ mod barrier; mod check_tty; +mod exit_on_ctrlc; mod print; mod print_cwd; mod print_env; @@ -21,7 +22,7 @@ fn main() { if args.len() < 2 { eprintln!("Usage: vtt [args...]"); eprintln!( - "Subcommands: barrier, check-tty, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, touch-file" + "Subcommands: barrier, check-tty, exit-on-ctrlc, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, touch-file" ); std::process::exit(1); } @@ -32,6 +33,7 @@ fn main() { check_tty::run(); Ok(()) } + "exit-on-ctrlc" => exit_on_ctrlc::run(), "print" => { print::run(&args[2..]); Ok(()) 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..a9a08d6f --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/package.json @@ -0,0 +1,4 @@ +{ + "name": "ctrl-c-test", + "private": true +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/a/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/a/package.json new file mode 100644 index 00000000..afec67f2 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/a/package.json @@ -0,0 +1,6 @@ +{ + "name": "@ctrl-c/a", + "scripts": { + "dev": "vtt exit-on-ctrlc" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/b/package.json b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/b/package.json new file mode 100644 index 00000000..74d067b4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/b/package.json @@ -0,0 +1,6 @@ +{ + "name": "@ctrl-c/b", + "scripts": { + "dev": "vtt exit-on-ctrlc" + } +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/pnpm-workspace.yaml b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/pnpm-workspace.yaml new file mode 100644 index 00000000..924b55f4 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - packages/* 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..60a27c9b --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml @@ -0,0 +1,18 @@ +# Tests that Ctrl+C (SIGINT) propagates to and terminates running tasks. +# Two packages run concurrently; both set up ctrl-c handlers and emit +# a "ready" milestone. After both are ready, ctrl-c is sent. + +[[e2e]] +name = "ctrl-c terminates running tasks" +steps = [ + { argv = [ + "vt", + "run", + "-r", + "dev", + ], interactions = [ + { "expect-milestone" = "ready" }, + { "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..15c6c702 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap @@ -0,0 +1,18 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +> vt run -r dev +@ expect-milestone: ready +~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled +~/packages/b$ vtt exit-on-ctrlc ⊘ cache disabled +@ expect-milestone: ready +~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled +~/packages/b$ vtt exit-on-ctrlc ⊘ cache disabled +@ write-key: ctrl-c +~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled +~/packages/b$ vtt exit-on-ctrlc ⊘ cache disabled +ctrl-c received + +--- +vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details) 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..b39113d0 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/vite-task.json @@ -0,0 +1,3 @@ +{ + "cache": false +} diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 36d4d168..a2cfb9ca 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -64,16 +64,48 @@ enum Step { #[derive(serde::Deserialize, Debug)] #[serde(deny_unknown_fields)] struct StepConfig { - command: Str, + /// Shell command string (run via `sh -c`). + #[serde(default)] + command: Option, + /// Argument vector — spawned directly without a shell wrapper. + #[serde(default)] + argv: Option>, #[serde(default)] interactions: Vec, } +/// How to spawn a step: either via shell or directly. +enum StepSpawn<'a> { + /// Run through `sh -c ""`. + Shell(&'a str), + /// Spawn directly with the given argv (first element is the program). + Direct(&'a [Str]), +} + impl Step { - fn command(&self) -> &str { + fn spawn_mode(&self) -> StepSpawn<'_> { match self { - Self::Command(command) => command.as_str(), - Self::Detailed(config) => config.command.as_str(), + Self::Command(command) => StepSpawn::Shell(command.as_str()), + Self::Detailed(config) => config.argv.as_deref().map_or_else( + || { + StepSpawn::Shell( + config + .command + .as_ref() + .expect("step must have either 'command' or 'argv'") + .as_str(), + ) + }, + StepSpawn::Direct, + ), + } + } + + #[expect(clippy::disallowed_types, reason = "String required by join")] + fn display_command(&self) -> String { + match self.spawn_mode() { + StepSpawn::Shell(cmd) => cmd.to_string(), + StepSpawn::Direct(argv) => argv.iter().map(Str::as_str).collect::>().join(" "), } } @@ -122,13 +154,14 @@ struct WriteKeyInteraction { } #[derive(serde::Deserialize, Debug, Clone, Copy)] -#[serde(rename_all = "lowercase")] +#[serde(rename_all = "kebab-case")] enum WriteKey { Up, Down, Enter, Escape, Backspace, + CtrlC, } impl WriteKey { @@ -139,6 +172,7 @@ impl WriteKey { Self::Enter => "enter", Self::Escape => "escape", Self::Backspace => "backspace", + Self::CtrlC => "ctrl-c", } } @@ -149,6 +183,7 @@ impl WriteKey { Self::Enter => b"\r", Self::Escape => b"\x1b", Self::Backspace => b"\x7f", + Self::CtrlC => b"\x03", } } } @@ -187,9 +222,7 @@ fn run_case(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, filter: Optio { println!("{fixture_name}"); } - // Configure insta to write snapshots to fixture directory let mut settings = insta::Settings::clone_current(); - settings.set_snapshot_path(fixture_path.join("snapshots")); settings.set_prepend_module_to_snapshot(false); settings.remove_snapshot_suffix(); @@ -214,6 +247,13 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let stage_path = tmpdir.join(fixture_name); CopyOptions::new().copy_tree(fixture_path, stage_path.as_path()).unwrap(); + // Point insta at the staged copy's snapshot dir (which is writable). The + // original fixture_path may be on a read-only filesystem (e.g. WebDAV mount + // during cross-platform testing via cargo-xtest). + let mut settings = insta::Settings::clone_current(); + settings.set_snapshot_path(stage_path.as_path().join("snapshots")); + let _guard = settings.bind_to_scope(); + let (workspace_root, _cwd) = find_workspace_root(&stage_path).unwrap(); assert_eq!( @@ -276,10 +316,27 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture let mut e2e_outputs = String::new(); for step in &e2e.steps { - let step_command = step.command(); - let mut cmd = CommandBuilder::new(&shell_exe); - cmd.arg("-c"); - cmd.arg(step_command); + let step_display = step.display_command(); + let mut cmd = match step.spawn_mode() { + StepSpawn::Shell(command) => { + let mut cmd = CommandBuilder::new(&shell_exe); + cmd.arg("-c"); + cmd.arg(command); + cmd + } + StepSpawn::Direct(argv) => { + // Resolve the program from CARGO_BIN_EXE_ if available, + // since CommandBuilder doesn't do PATH lookup on all platforms. + let program = argv[0].as_str(); + let exe_env = vite_str::format!("CARGO_BIN_EXE_{program}"); + let resolved = env::var_os(exe_env.as_str()).unwrap_or_else(|| program.into()); + let mut cmd = CommandBuilder::new(resolved); + for arg in &argv[1..] { + cmd.arg(arg.as_str()); + } + cmd + } + }; cmd.env_clear(); cmd.env("PATH", &e2e_env_path); cmd.env("NO_COLOR", "1"); @@ -390,7 +447,7 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture } e2e_outputs.push_str("> "); - e2e_outputs.push_str(step_command); + e2e_outputs.push_str(&step_display); e2e_outputs.push('\n'); e2e_outputs.push_str(&redact_e2e_output(output, e2e_stage_path_str)); @@ -412,8 +469,14 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture } fn main() { - // SAFETY: Called before any threads are spawned; insta reads this lazily on first assertion. - unsafe { std::env::set_var("INSTA_REQUIRE_FULL_MATCH", "1") }; + // Skip INSTA_REQUIRE_FULL_MATCH when running cross-platform (via cargo-xtest): the snapshot + // source metadata changes when the test binary runs on a remote machine, causing insta to + // write .snap.new even when content matches. INSTA_UPDATE=always in this mode accepts + // metadata-only changes. + if std::env::var_os("INSTA_UPDATE").is_none() { + // SAFETY: Called before any threads are spawned; insta reads this lazily on first assertion. + unsafe { std::env::set_var("INSTA_REQUIRE_FULL_MATCH", "1") }; + } let filter = std::env::args().nth(1); @@ -430,7 +493,7 @@ fn main() { // Copy .node-version to the tmp dir so version manager shims can resolve the correct // Node.js binary when running task commands. - let repo_root = manifest_dir.join("../..").canonicalize().unwrap(); + let repo_root = manifest_dir.parent().unwrap().parent().unwrap(); std::fs::copy(repo_root.join(".node-version"), tmp_dir.path().join(".node-version")) .unwrap(); diff --git a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs index b2e85f22..1e826bb1 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/redact.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/redact.rs @@ -101,6 +101,27 @@ 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; + } + } + + // Normalize "ctrl-c received" output: when vt and tasks all receive SIGINT, + // it's a race whether one or both tasks print before the process exits. + // Normalize to a single occurrence for stable snapshots. + { + use cow_utils::CowUtils as _; + if let Cow::Owned(replaced) = + output.as_str().cow_replace("ctrl-c receivedctrl-c received", "ctrl-c received") + { + 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.