Skip to content

Commit e2fcef4

Browse files
branchseerclaude
andcommitted
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) <[email protected]>
1 parent f9d1ab5 commit e2fcef4

5 files changed

Lines changed: 104 additions & 20 deletions

File tree

crates/vite_task_bin/src/main.rs

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,26 @@
1-
use std::process::ExitCode;
2-
31
use clap::Parser as _;
42
use vite_task::{Command, ExitStatus, Session};
53
use vite_task_bin::OwnedSessionConfig;
64

7-
#[tokio::main]
8-
async fn main() -> anyhow::Result<ExitCode> {
9-
let exit_status = run().await?;
10-
Ok(exit_status.0.into())
5+
fn main() -> ! {
6+
// Ignore SIGINT/CTRL_C before the tokio runtime starts. Child tasks
7+
// receive the signal directly from the terminal driver and handle it
8+
// themselves. This lets the runner wait for tasks to exit and report
9+
// their actual exit status rather than being killed mid-flight.
10+
let _ = ctrlc::set_handler(|| {});
11+
12+
let exit_code: i32 =
13+
tokio::runtime::Builder::new_multi_thread().enable_all().build().unwrap().block_on(async {
14+
match run().await {
15+
Ok(status) => i32::from(status.0),
16+
Err(err) => {
17+
eprintln!("Error: {err:?}");
18+
1
19+
}
20+
}
21+
});
22+
23+
std::process::exit(exit_code);
1124
}
1225

1326
async fn run() -> anyhow::Result<ExitStatus> {

crates/vite_task_bin/src/vtt/exit_on_ctrlc.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,24 @@
33
/// Sets up a Ctrl+C handler, emits a "ready" milestone, then waits.
44
/// When Ctrl+C is received, prints "ctrl-c received" and exits.
55
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
6+
// On Windows, Rust's runtime sets `SetConsoleCtrlHandler(NULL, TRUE)` which
7+
// ignores CTRL_C_EVENT. This flag is inherited by child processes and takes
8+
// precedence over registered handlers. Clear it before registering ours.
9+
#[cfg(windows)]
10+
{
11+
// SAFETY: Passing (None, FALSE) removes the "ignore CTRL_C" flag.
12+
unsafe extern "system" {
13+
fn SetConsoleCtrlHandler(
14+
handler: Option<unsafe extern "system" fn(u32) -> i32>,
15+
add: i32,
16+
) -> i32;
17+
}
18+
// SAFETY: Clearing the Rust runtime's ignore flag.
19+
unsafe {
20+
SetConsoleCtrlHandler(None, 0);
21+
}
22+
}
23+
624
ctrlc::set_handler(move || {
725
use std::io::Write;
826
let _ = write!(std::io::stdout(), "ctrl-c received");

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots.toml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@
55
[[e2e]]
66
name = "ctrl-c terminates running tasks"
77
steps = [
8-
{ command = "vt run -r dev", interactions = [
8+
{ argv = [
9+
"vt",
10+
"run",
11+
"-r",
12+
"dev",
13+
], interactions = [
914
{ "expect-milestone" = "ready" },
1015
{ "expect-milestone" = "ready" },
1116
{ "write-key" = "ctrl-c" },

crates/vite_task_bin/tests/e2e_snapshots/fixtures/ctrl-c/snapshots/ctrl-c terminates running tasks.snap

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
source: crates/vite_task_bin/tests/e2e_snapshots/main.rs
33
expression: e2e_outputs
44
---
5-
[1]> vt run -r dev
5+
> vt run -r dev
66
@ expect-milestone: ready
77
~/packages/a$ vtt exit-on-ctrlccache disabled
88
~/packages/b$ vtt exit-on-ctrlccache disabled
@@ -13,3 +13,6 @@ expression: e2e_outputs
1313
~/packages/a$ vtt exit-on-ctrlccache disabled
1414
~/packages/b$ vtt exit-on-ctrlccache disabled
1515
ctrl-c received
16+
17+
---
18+
vt run: 0/2 cache hit (0%). (Run `vt run --last-details` for full details)

crates/vite_task_bin/tests/e2e_snapshots/main.rs

Lines changed: 57 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -64,16 +64,46 @@ enum Step {
6464
#[derive(serde::Deserialize, Debug)]
6565
#[serde(deny_unknown_fields)]
6666
struct StepConfig {
67-
command: Str,
67+
/// Shell command string (run via `sh -c`).
68+
#[serde(default)]
69+
command: Option<Str>,
70+
/// Argument vector — spawned directly without a shell wrapper.
71+
#[serde(default)]
72+
argv: Option<Vec<Str>>,
6873
#[serde(default)]
6974
interactions: Vec<Interaction>,
7075
}
7176

77+
/// How to spawn a step: either via shell or directly.
78+
enum StepSpawn<'a> {
79+
/// Run through `sh -c "<command>"`.
80+
Shell(&'a str),
81+
/// Spawn directly with the given argv (first element is the program).
82+
Direct(&'a [Str]),
83+
}
84+
7285
impl Step {
73-
fn command(&self) -> &str {
86+
fn spawn_mode(&self) -> StepSpawn<'_> {
7487
match self {
75-
Self::Command(command) => command.as_str(),
76-
Self::Detailed(config) => config.command.as_str(),
88+
Self::Command(command) => StepSpawn::Shell(command.as_str()),
89+
Self::Detailed(config) => {
90+
if let Some(argv) = &config.argv {
91+
StepSpawn::Direct(argv)
92+
} else if let Some(command) = &config.command {
93+
StepSpawn::Shell(command.as_str())
94+
} else {
95+
panic!("step must have either 'command' or 'argv'");
96+
}
97+
}
98+
}
99+
}
100+
101+
fn display_command(&self) -> String {
102+
match self.spawn_mode() {
103+
StepSpawn::Shell(cmd) => cmd.to_string(),
104+
StepSpawn::Direct(argv) => {
105+
argv.iter().map(|a| a.as_str()).collect::<Vec<_>>().join(" ")
106+
}
77107
}
78108
}
79109

@@ -284,10 +314,27 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture
284314

285315
let mut e2e_outputs = String::new();
286316
for step in &e2e.steps {
287-
let step_command = step.command();
288-
let mut cmd = CommandBuilder::new(&shell_exe);
289-
cmd.arg("-c");
290-
cmd.arg(step_command);
317+
let step_display = step.display_command();
318+
let mut cmd = match step.spawn_mode() {
319+
StepSpawn::Shell(command) => {
320+
let mut cmd = CommandBuilder::new(&shell_exe);
321+
cmd.arg("-c");
322+
cmd.arg(command);
323+
cmd
324+
}
325+
StepSpawn::Direct(argv) => {
326+
// Resolve the program from CARGO_BIN_EXE_<name> if available,
327+
// since CommandBuilder doesn't do PATH lookup on all platforms.
328+
let program = argv[0].as_str();
329+
let exe_env = vite_str::format!("CARGO_BIN_EXE_{program}");
330+
let resolved = env::var_os(exe_env.as_str()).unwrap_or_else(|| program.into());
331+
let mut cmd = CommandBuilder::new(resolved);
332+
for arg in &argv[1..] {
333+
cmd.arg(arg.as_str());
334+
}
335+
cmd
336+
}
337+
};
291338
cmd.env_clear();
292339
cmd.env("PATH", &e2e_env_path);
293340
cmd.env("NO_COLOR", "1");
@@ -391,16 +438,14 @@ fn run_case_inner(tmpdir: &AbsolutePath, fixture_path: &std::path::Path, fixture
391438
e2e_outputs.push_str("[timeout]");
392439
}
393440
TerminationState::Exited(exit_code) => {
394-
// Normalize Windows CTRL_C exit code (512) to match Unix (1).
395-
let exit_code = if cfg!(windows) && *exit_code == 512 { 1 } else { *exit_code };
396-
if exit_code != 0 {
441+
if *exit_code != 0 {
397442
e2e_outputs.push_str(vite_str::format!("[{exit_code}]").as_str());
398443
}
399444
}
400445
}
401446

402447
e2e_outputs.push_str("> ");
403-
e2e_outputs.push_str(step_command);
448+
e2e_outputs.push_str(&step_display);
404449
e2e_outputs.push('\n');
405450

406451
e2e_outputs.push_str(&redact_e2e_output(output, e2e_stage_path_str));

0 commit comments

Comments
 (0)