Skip to content

Commit 9ae39c0

Browse files
branchseerclaude
andcommitted
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` (using argv spawn mode), synchronizes via milestone protocol, then sends ctrl-c and verifies both tasks handle it. Changes: - `vtt exit-on-ctrlc`: sets up ctrl-c handler, emits "ready" milestone, prints "ctrl-c received" and exits on SIGINT. On Windows, clears the inherited CONSOLE_IGNORE_CTRL_C flag (set by an ancestor process via CREATE_NEW_PROCESS_GROUP) before registering the handler. - `vt main.rs`: register no-op ctrlc handler before tokio runtime so vt survives Ctrl+C and reports actual task exit status. Use process::exit to avoid Windows runtime cleanup issues. - `ctrl-c` WriteKey variant for e2e test interactions (rename_all kebab-case) - Fix `expect_milestone` to preserve unmatched milestones in a local buffer instead of dropping them via `take_unhandled_osc_sequences` - Strip `^C` terminal echo and normalize `ctrl-c received` count in e2e redaction for cross-platform consistency - Fix incorrect comment in pty_terminal test about CTRL_C ignore flag source Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c4118ad commit 9ae39c0

File tree

16 files changed

+185
-22
lines changed

16 files changed

+185
-22
lines changed

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/pty_terminal/tests/terminal.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -305,8 +305,12 @@ fn send_ctrl_c_interrupts_process() {
305305
// On macOS/Windows, use ctrlc which works fine (no .init_array/musl issue).
306306
#[cfg(not(target_os = "linux"))]
307307
{
308-
// On Windows, clear the "ignore CTRL_C" flag set by Rust runtime
309-
// so that CTRL_C_EVENT reaches the ctrlc handler.
308+
// On Windows, an ancestor process may have been created with
309+
// CREATE_NEW_PROCESS_GROUP, which implicitly sets the per-process
310+
// CTRL_C ignore flag (CONSOLE_IGNORE_CTRL_C in PEB ConsoleFlags).
311+
// This flag is inherited by all descendants and silently drops
312+
// CTRL_C_EVENT before it reaches registered handlers. Clear it.
313+
// Ref: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
310314
#[cfg(windows)]
311315
{
312316
// SAFETY: Declaring correct signature for SetConsoleCtrlHandler from kernel32.

crates/pty_terminal_test/src/lib.rs

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
use std::io::{BufReader, Read};
1+
use std::{
2+
collections::VecDeque,
3+
io::{BufReader, Read},
4+
};
25

36
pub use portable_pty::CommandBuilder;
47
use pty_terminal::terminal::{PtyReader, Terminal};
@@ -25,6 +28,8 @@ pub struct TestTerminal {
2528
pub struct Reader {
2629
pty: BufReader<PtyReader>,
2730
child_handle: ChildHandle,
31+
/// OSC sequences taken from the PTY but not yet consumed by `expect_milestone`.
32+
pending_osc: VecDeque<Vec<Vec<u8>>>,
2833
}
2934

3035
impl TestTerminal {
@@ -37,7 +42,11 @@ impl TestTerminal {
3742
let Terminal { pty_reader, pty_writer, child_handle, .. } = Terminal::spawn(size, cmd)?;
3843
Ok(Self {
3944
writer: pty_writer,
40-
reader: Reader { pty: BufReader::new(pty_reader), child_handle: child_handle.clone() },
45+
reader: Reader {
46+
pty: BufReader::new(pty_reader),
47+
child_handle: child_handle.clone(),
48+
pending_osc: VecDeque::new(),
49+
},
4150
child_handle,
4251
})
4352
}
@@ -71,15 +80,24 @@ impl Reader {
7180
let mut buf = [0u8; 4096];
7281

7382
loop {
74-
let found = self
75-
.pty
76-
.get_ref()
77-
.take_unhandled_osc_sequences()
78-
.into_iter()
79-
.filter_map(|params| {
80-
pty_terminal_test_client::decode_milestone_from_osc8_params(&params)
81-
})
82-
.any(|decoded| decoded == name);
83+
// Drain new sequences from the PTY into our local buffer.
84+
self.pending_osc.append(&mut self.pty.get_ref().take_unhandled_osc_sequences());
85+
86+
// Scan for the first matching milestone, keeping the rest.
87+
let mut found = false;
88+
let mut remaining = VecDeque::with_capacity(self.pending_osc.len());
89+
for params in self.pending_osc.drain(..) {
90+
if !found
91+
&& pty_terminal_test_client::decode_milestone_from_osc8_params(&params)
92+
.is_some_and(|decoded| decoded == name)
93+
{
94+
found = true;
95+
continue;
96+
}
97+
remaining.push_back(params);
98+
}
99+
self.pending_osc = remaining;
100+
83101
if found {
84102
return self.screen_contents();
85103
}

crates/vite_task_bin/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ path = "src/vtt/main.rs"
1616

1717
[dependencies]
1818
anyhow = { workspace = true }
19+
ctrlc = { workspace = true }
1920
libc = { workspace = true }
2021
notify = { workspace = true }
22+
pty_terminal_test_client = { workspace = true, features = ["testing"] }
2123
async-trait = { workspace = true }
2224
clap = { workspace = true, features = ["derive"] }
2325
jsonc-parser = { workspace = true }

crates/vite_task_bin/src/main.rs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,27 @@
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+
#[expect(clippy::print_stderr, reason = "top-level error reporting")]
17+
Err(err) => {
18+
eprintln!("Error: {err:?}");
19+
1
20+
}
21+
}
22+
});
23+
24+
std::process::exit(exit_code);
1125
}
1226

1327
async fn run() -> anyhow::Result<ExitStatus> {
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/// exit-on-ctrlc
2+
///
3+
/// Sets up a Ctrl+C handler, emits a "ready" milestone, then waits.
4+
/// When Ctrl+C is received, prints "ctrl-c received" and exits.
5+
pub fn run() -> Result<(), Box<dyn std::error::Error>> {
6+
// On Windows, an ancestor process (e.g. cargo, the test harness) may have
7+
// been created with CREATE_NEW_PROCESS_GROUP, which implicitly calls
8+
// SetConsoleCtrlHandler(NULL, TRUE) and sets CONSOLE_IGNORE_CTRL_C in the
9+
// PEB's ConsoleFlags. This flag is inherited by all descendants and takes
10+
// precedence over registered handlers — CTRL_C_EVENT is silently dropped.
11+
// Clear it so our handler can fire.
12+
// Ref: https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags
13+
#[cfg(windows)]
14+
{
15+
// SAFETY: Passing (None, FALSE) clears the per-process CTRL_C ignore flag.
16+
unsafe extern "system" {
17+
fn SetConsoleCtrlHandler(
18+
handler: Option<unsafe extern "system" fn(u32) -> i32>,
19+
add: i32,
20+
) -> i32;
21+
}
22+
// SAFETY: Clearing the inherited ignore flag.
23+
unsafe {
24+
SetConsoleCtrlHandler(None, 0);
25+
}
26+
}
27+
28+
ctrlc::set_handler(move || {
29+
use std::io::Write;
30+
let _ = write!(std::io::stdout(), "ctrl-c received");
31+
let _ = std::io::stdout().flush();
32+
std::process::exit(0);
33+
})?;
34+
35+
pty_terminal_test_client::mark_milestone("ready");
36+
37+
loop {
38+
std::thread::park();
39+
}
40+
}

crates/vite_task_bin/src/vtt/main.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
mod barrier;
1010
mod check_tty;
1111
mod cp;
12+
mod exit_on_ctrlc;
1213
mod mkdir;
1314
mod pipe_stdin;
1415
mod print;
@@ -26,7 +27,7 @@ fn main() {
2627
if args.len() < 2 {
2728
eprintln!("Usage: vtt <subcommand> [args...]");
2829
eprintln!(
29-
"Subcommands: barrier, check-tty, cp, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file"
30+
"Subcommands: barrier, check-tty, cp, exit-on-ctrlc, mkdir, pipe-stdin, print, print-cwd, print-env, print-file, read-stdin, replace-file-content, rm, touch-file, write-file"
3031
);
3132
std::process::exit(1);
3233
}
@@ -38,7 +39,9 @@ fn main() {
3839
Ok(())
3940
}
4041
"cp" => cp::run(&args[2..]),
42+
"exit-on-ctrlc" => exit_on_ctrlc::run(),
4143
"mkdir" => mkdir::run(&args[2..]),
44+
"pipe-stdin" => pipe_stdin::run(&args[2..]),
4245
"print" => {
4346
print::run(&args[2..]);
4447
Ok(())
@@ -48,7 +51,6 @@ fn main() {
4851
"print-file" => print_file::run(&args[2..]),
4952
"read-stdin" => read_stdin::run(),
5053
"replace-file-content" => replace_file_content::run(&args[2..]),
51-
"pipe-stdin" => pipe_stdin::run(&args[2..]),
5254
"rm" => rm::run(&args[2..]),
5355
"touch-file" => touch_file::run(&args[2..]),
5456
"write-file" => write_file::run(&args[2..]),
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "ctrl-c-test",
3+
"private": true
4+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "@ctrl-c/a",
3+
"scripts": {
4+
"dev": "vtt exit-on-ctrlc"
5+
}
6+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
{
2+
"name": "@ctrl-c/b",
3+
"scripts": {
4+
"dev": "vtt exit-on-ctrlc"
5+
}
6+
}

0 commit comments

Comments
 (0)