From c100c3bcf2b8348e4078063a3ba031883c3860a0 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 17:20:22 +0800 Subject: [PATCH 1/7] Add e2e snapshot test for ctrl-c propagation to running tasks Add a `vtt exit-on-ctrlc` subcommand and e2e fixture that verifies SIGINT propagates to concurrent tasks when the user presses Ctrl+C. The test runs two packages with `vt run -r dev`, synchronizes them via a filesystem barrier, then sends ctrl-c and verifies both tasks receive and handle it. Also adds `ctrl-c` as a new `write-key` interaction type for e2e snapshot tests. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.lock | 2 + crates/pty_terminal_test/src/lib.rs | 40 ++++++++++++++----- crates/vite_task_bin/Cargo.toml | 2 + crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs | 18 +++++++++ crates/vite_task_bin/src/vtt/main.rs | 4 +- .../fixtures/ctrl-c/package.json | 4 ++ .../fixtures/ctrl-c/packages/a/package.json | 6 +++ .../fixtures/ctrl-c/packages/b/package.json | 6 +++ .../fixtures/ctrl-c/pnpm-workspace.yaml | 2 + .../fixtures/ctrl-c/snapshots.toml | 13 ++++++ .../ctrl-c terminates running tasks.snap | 15 +++++++ .../fixtures/ctrl-c/vite-task.json | 3 ++ .../vite_task_bin/tests/e2e_snapshots/main.rs | 5 ++- 13 files changed, 107 insertions(+), 13 deletions(-) create mode 100644 crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/a/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/packages/b/package.json create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/pnpm-workspace.yaml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap create mode 100644 crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/vite-task.json 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_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_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/vtt/exit_on_ctrlc.rs b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs new file mode 100644 index 00000000..fe507919 --- /dev/null +++ b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs @@ -0,0 +1,18 @@ +/// 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> { + 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..d7c21ab1 --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml @@ -0,0 +1,13 @@ +# 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 = [ + { command = "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..4fb1bf4c --- /dev/null +++ b/crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap @@ -0,0 +1,15 @@ +--- +source: crates/vite_task_bin/tests/e2e_snapshots/main.rs +expression: e2e_outputs +--- +[1]> 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 +^Cctrl-c receivedctrl-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..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..b1d800da 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -122,13 +122,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 +140,7 @@ impl WriteKey { Self::Enter => "enter", Self::Escape => "escape", Self::Backspace => "backspace", + Self::CtrlC => "ctrl-c", } } @@ -149,6 +151,7 @@ impl WriteKey { Self::Enter => b"\r", Self::Escape => b"\x1b", Self::Backspace => b"\x7f", + Self::CtrlC => b"\x03", } } } From 7646aec11b74655a8283711db874957ec091c281 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 17:57:37 +0800 Subject: [PATCH 2/7] Add ctrl-c e2e test for SIGINT propagation to running tasks Add a `vtt exit-on-ctrlc` subcommand and e2e fixture that verifies SIGINT propagates to concurrent tasks when the user presses Ctrl+C. The test runs two packages with `vt run -r dev`, synchronizes via milestone protocol, then sends ctrl-c and verifies both tasks receive and handle it. Also adds `ctrl-c` as a new `write-key` interaction type, fixes `expect_milestone` to preserve unmatched milestones, strips `^C` terminal echo in redaction, and normalizes Windows CTRL_C exit code (512) to match Unix (1) for cross-platform snapshot consistency. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/vite_task/src/session/execute/mod.rs | 1 + .../ctrl-c terminates running tasks.snap | 2 +- .../vite_task_bin/tests/e2e_snapshots/main.rs | 4 +++- .../tests/e2e_snapshots/redact.rs | 21 +++++++++++++++++++ 4 files changed, 26 insertions(+), 2 deletions(-) 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/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 index 4fb1bf4c..e18862ef 100644 --- 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 @@ -12,4 +12,4 @@ expression: e2e_outputs @ write-key: ctrl-c ~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled ~/packages/b$ vtt exit-on-ctrlc ⊘ cache disabled -^Cctrl-c receivedctrl-c received +ctrl-c received diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index b1d800da..a4887ea6 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -386,7 +386,9 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture e2e_outputs.push_str("[timeout]"); } TerminationState::Exited(exit_code) => { - if *exit_code != 0 { + // Normalize Windows CTRL_C exit code (512) to match Unix (1). + let exit_code = if cfg!(windows) && *exit_code == 512 { 1 } else { *exit_code }; + if exit_code != 0 { e2e_outputs.push_str(vite_str::format!("[{exit_code}]").as_str()); } } 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. From 8d3e9df02f081f9c7703a098f3a325fdc14cdfa1 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 21:24:03 +0800 Subject: [PATCH 3/7] Avoid canonicalize when resolving repo root in e2e test runner Use parent().parent() instead of canonicalize() to find the repo root from CARGO_MANIFEST_DIR. Avoids resolving symlinks which can cause issues on some CI environments. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/vite_task_bin/tests/e2e_snapshots/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index a4887ea6..d77849dc 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -435,7 +435,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(); From f9d1ab502dac0dc29ff86322e8088d0b38fef108 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 21:42:12 +0800 Subject: [PATCH 4/7] Support cross-platform e2e snapshot testing via cargo-xtest Use staged temp dir for insta snapshot paths so writes work on read-only filesystems (e.g. WebDAV mount during cargo-xtest). Skip INSTA_REQUIRE_FULL_MATCH when INSTA_UPDATE is set, allowing cross-platform runs to accept metadata-only snapshot changes. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../vite_task_bin/tests/e2e_snapshots/main.rs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index d77849dc..4ecca13c 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -190,9 +190,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(); @@ -217,6 +215,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!( @@ -418,7 +423,13 @@ 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() { + unsafe { std::env::set_var("INSTA_REQUIRE_FULL_MATCH", "1") }; + } let filter = std::env::args().nth(1); From e2fcef434309eee5050d20dd3dfa4ee9c471d268 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 22:10:39 +0800 Subject: [PATCH 5/7] Handle SIGINT gracefully and add argv spawn mode for e2e tests - Register a ctrlc no-op handler in vt before the tokio runtime starts, so Ctrl+C doesn't kill the runner via the default signal handler. Child tasks receive SIGINT directly from the terminal driver. - Use std::process::exit to avoid non-zero exit codes from Rust runtime cleanup on Windows when background ctrlc threads are active. - Add argv spawn mode for e2e steps: `{ argv = ["vt", "run", ...] }` spawns directly without a shell wrapper, avoiding bash's CTRL_C exit code interference on Windows. - Clear Windows CTRL_C ignore flag in vtt exit-on-ctrlc before registering the handler (Rust runtime sets this flag and it persists to children). Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/vite_task_bin/src/main.rs | 25 +++++-- crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs | 18 +++++ .../fixtures/ctrl-c/snapshots.toml | 7 +- .../ctrl-c terminates running tasks.snap | 5 +- .../vite_task_bin/tests/e2e_snapshots/main.rs | 69 +++++++++++++++---- 5 files changed, 104 insertions(+), 20 deletions(-) diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index a0569897..852e8aec 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -1,13 +1,26 @@ -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), + 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 index fe507919..6c225894 100644 --- a/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs +++ b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs @@ -3,6 +3,24 @@ /// 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, Rust's runtime sets `SetConsoleCtrlHandler(NULL, TRUE)` which + // ignores CTRL_C_EVENT. This flag is inherited by child processes and takes + // precedence over registered handlers. Clear it before registering ours. + #[cfg(windows)] + { + // SAFETY: Passing (None, FALSE) removes the "ignore CTRL_C" flag. + unsafe extern "system" { + fn SetConsoleCtrlHandler( + handler: Option i32>, + add: i32, + ) -> i32; + } + // SAFETY: Clearing the Rust runtime's ignore flag. + unsafe { + SetConsoleCtrlHandler(None, 0); + } + } + ctrlc::set_handler(move || { use std::io::Write; let _ = write!(std::io::stdout(), "ctrl-c received"); 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 index d7c21ab1..60a27c9b 100644 --- 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 @@ -5,7 +5,12 @@ [[e2e]] name = "ctrl-c terminates running tasks" steps = [ - { command = "vt run -r dev", interactions = [ + { 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 index e18862ef..15c6c702 100644 --- 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 @@ -2,7 +2,7 @@ source: crates/vite_task_bin/tests/e2e_snapshots/main.rs expression: e2e_outputs --- -[1]> vt run -r dev +> vt run -r dev @ expect-milestone: ready ~/packages/a$ vtt exit-on-ctrlc ⊘ cache disabled ~/packages/b$ vtt exit-on-ctrlc ⊘ cache disabled @@ -13,3 +13,6 @@ expression: e2e_outputs ~/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/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index 4ecca13c..fc92dbb8 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -64,16 +64,46 @@ 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) => { + if let Some(argv) = &config.argv { + StepSpawn::Direct(argv) + } else if let Some(command) = &config.command { + StepSpawn::Shell(command.as_str()) + } else { + panic!("step must have either 'command' or 'argv'"); + } + } + } + } + + fn display_command(&self) -> String { + match self.spawn_mode() { + StepSpawn::Shell(cmd) => cmd.to_string(), + StepSpawn::Direct(argv) => { + argv.iter().map(|a| a.as_str()).collect::>().join(" ") + } } } @@ -284,10 +314,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"); @@ -391,16 +438,14 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture e2e_outputs.push_str("[timeout]"); } TerminationState::Exited(exit_code) => { - // Normalize Windows CTRL_C exit code (512) to match Unix (1). - let exit_code = if cfg!(windows) && *exit_code == 512 { 1 } else { *exit_code }; - if exit_code != 0 { + if *exit_code != 0 { e2e_outputs.push_str(vite_str::format!("[{exit_code}]").as_str()); } } } 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)); From 4e48134982c393226f72c39591150c45f3b32d22 Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 22:19:37 +0800 Subject: [PATCH 6/7] Fix clippy: expect attributes, map_or_else, safety comment Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/vite_task_bin/src/main.rs | 1 + .../vite_task_bin/tests/e2e_snapshots/main.rs | 28 ++++++++++--------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/vite_task_bin/src/main.rs b/crates/vite_task_bin/src/main.rs index 852e8aec..2fe73c00 100644 --- a/crates/vite_task_bin/src/main.rs +++ b/crates/vite_task_bin/src/main.rs @@ -13,6 +13,7 @@ fn main() -> ! { 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 diff --git a/crates/vite_task_bin/tests/e2e_snapshots/main.rs b/crates/vite_task_bin/tests/e2e_snapshots/main.rs index fc92dbb8..a2cfb9ca 100644 --- a/crates/vite_task_bin/tests/e2e_snapshots/main.rs +++ b/crates/vite_task_bin/tests/e2e_snapshots/main.rs @@ -86,24 +86,26 @@ impl Step { fn spawn_mode(&self) -> StepSpawn<'_> { match self { Self::Command(command) => StepSpawn::Shell(command.as_str()), - Self::Detailed(config) => { - if let Some(argv) = &config.argv { - StepSpawn::Direct(argv) - } else if let Some(command) = &config.command { - StepSpawn::Shell(command.as_str()) - } else { - panic!("step must have either 'command' or 'argv'"); - } - } + 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(|a| a.as_str()).collect::>().join(" ") - } + StepSpawn::Direct(argv) => argv.iter().map(Str::as_str).collect::>().join(" "), } } @@ -467,12 +469,12 @@ 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. // 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") }; } From 3a54fbc7568d0d249ccdf876e4d12e27f485dd2d Mon Sep 17 00:00:00 2001 From: branchseer Date: Fri, 27 Mar 2026 22:59:31 +0800 Subject: [PATCH 7/7] Fix incorrect comment: ConPTY sets CTRL_C ignore flag, not Rust runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Investigation confirmed Rust std, ctrlc crate, and portable-pty have zero calls to SetConsoleCtrlHandler(NULL, TRUE). The inheritable ignore flag is set by the Windows ConPTY subsystem for spawned processes. The SetConsoleCtrlHandler(None, 0) workaround is still needed — without it the test times out on Windows — but the comment was wrong about the source. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/pty_terminal/tests/terminal.rs | 8 ++++++-- crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs | 14 +++++++++----- 2 files changed, 15 insertions(+), 7 deletions(-) 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/src/vtt/exit_on_ctrlc.rs b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs index 6c225894..3eed75e8 100644 --- a/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs +++ b/crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs @@ -3,19 +3,23 @@ /// 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, Rust's runtime sets `SetConsoleCtrlHandler(NULL, TRUE)` which - // ignores CTRL_C_EVENT. This flag is inherited by child processes and takes - // precedence over registered handlers. Clear it before registering ours. + // 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) removes the "ignore CTRL_C" flag. + // 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 Rust runtime's ignore flag. + // SAFETY: Clearing the inherited ignore flag. unsafe { SetConsoleCtrlHandler(None, 0); }