Skip to content

Commit 711e66e

Browse files
branchseerclaude
andcommitted
Handle SIGINT gracefully so vt exits with correct status
Register a ctrlc handler before task execution so SIGINT doesn't kill the process via the default handler. Child tasks receive SIGINT directly from the terminal driver (same process group) and handle it themselves. This lets vt wait for tasks to exit and report their actual exit status (0 when tasks handle ctrl-c). Also strip ^C terminal echo in e2e redaction for cross-platform consistency. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c100c3b commit 711e66e

File tree

5 files changed

+58
-5
lines changed

5 files changed

+58
-5
lines changed

Cargo.lock

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

crates/vite_task/Cargo.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,11 @@ wax = { workspace = true }
4646
tempfile = { workspace = true }
4747

4848
[target.'cfg(unix)'.dependencies]
49+
libc = { workspace = true }
4950
nix = { workspace = true }
5051

5152
[target.'cfg(windows)'.dependencies]
52-
winapi = { workspace = true, features = ["handleapi", "jobapi2", "winnt"] }
53+
winapi = { workspace = true, features = ["consoleapi", "handleapi", "jobapi2", "winnt"] }
5354

5455
[lib]
5556
doctest = false

crates/vite_task/src/session/execute/mod.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -538,8 +538,8 @@ async fn spawn_inherited(
538538
// the child process calls exec(). Without this fix, the child's fds 0-2 are
539539
// closed after exec and Node.js reopens them as /dev/null, losing all output.
540540
// See: https://github.com/libuv/libuv/issues/2062
541-
// SAFETY: The pre_exec closure only performs fcntl operations to clear
542-
// FD_CLOEXEC flags on stdio fds, which is safe in a post-fork context.
541+
// SAFETY: The pre_exec closure performs fcntl operations and signal reset,
542+
// both of which are async-signal-safe and valid in a post-fork context.
543543
#[cfg(unix)]
544544
unsafe {
545545
tokio_cmd.pre_exec(|| {
@@ -549,6 +549,13 @@ async fn spawn_inherited(
549549
fcntl::{FcntlArg, FdFlag, fcntl},
550550
libc::{STDERR_FILENO, STDIN_FILENO, STDOUT_FILENO},
551551
};
552+
553+
// Clear FD_CLOEXEC on stdio fds before exec. libuv (used by Node.js)
554+
// marks stdin/stdout/stderr as close-on-exec, which causes them to be
555+
// closed when the child process calls exec(). Without this fix, the
556+
// child's fds 0-2 are closed after exec and Node.js reopens them as
557+
// /dev/null, losing all output.
558+
// See: https://github.com/libuv/libuv/issues/2062
552559
for fd in [STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO] {
553560
// SAFETY: fds 0-2 are always valid in a post-fork context
554561
let borrowed = BorrowedFd::borrow_raw(fd);
@@ -560,6 +567,12 @@ async fn spawn_inherited(
560567
}
561568
}
562569
}
570+
571+
// Restore default SIGINT behavior for the child. The parent sets
572+
// SIG_IGN so it survives Ctrl+C, but children must be able to
573+
// install their own handlers (SIG_IGN persists across exec).
574+
libc::signal(libc::SIGINT, libc::SIG_DFL);
575+
563576
Ok(())
564577
});
565578
}
@@ -712,6 +725,32 @@ impl Session<'_> {
712725

713726
let reporter = RefCell::new(builder.build());
714727

728+
// Ignore SIGINT so that Ctrl+C does not kill this process. Child tasks
729+
// receive SIGINT directly from the terminal driver (same process group)
730+
// and handle it themselves. This lets the runner wait for tasks to exit
731+
// and report their actual status.
732+
#[cfg(unix)]
733+
// SAFETY: SIG_IGN is a valid signal disposition.
734+
// Child processes inherit SIG_IGN, but we reset to SIG_DFL in pre_exec
735+
// (see spawn_inherited) so children can set their own handlers.
736+
unsafe {
737+
libc::signal(libc::SIGINT, libc::SIG_IGN);
738+
}
739+
#[cfg(windows)]
740+
{
741+
// Register a handler that swallows the event. Unlike (None, TRUE)
742+
// which sets an inheritable "ignore" flag, a registered handler is
743+
// per-process and child processes keep the default behavior.
744+
// SAFETY: The handler is a valid extern "system" fn with the correct signature.
745+
const unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
746+
1 // TRUE = handled
747+
}
748+
// SAFETY: Registering a valid handler function pointer with add=TRUE.
749+
unsafe {
750+
winapi::um::consoleapi::SetConsoleCtrlHandler(Some(ctrl_handler), 1);
751+
}
752+
}
753+
715754
let execution_context = ExecutionContext {
716755
reporter: &reporter,
717756
cache,

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

Lines changed: 5 additions & 2 deletions
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
@@ -12,4 +12,7 @@ expression: e2e_outputs
1212
@ write-key: ctrl-c
1313
~/packages/a$ vtt exit-on-ctrlccache disabled
1414
~/packages/b$ vtt exit-on-ctrlccache disabled
15-
^Cctrl-c receivedctrl-c received
15+
ctrl-c receivedctrl-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/redact.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,15 @@ pub fn redact_e2e_output(mut output: String, workspace_root: &str) -> String {
101101
let mise_warning_regex = regex::Regex::new(r"(?m)^mise WARN\s+.*\n?").unwrap();
102102
output = mise_warning_regex.replace_all(&output, "").into_owned();
103103

104+
// Remove ^C echo that Unix terminal drivers emit when ETX (0x03) is written
105+
// to the PTY. Windows ConPTY does not echo it.
106+
{
107+
use cow_utils::CowUtils as _;
108+
if let Cow::Owned(replaced) = output.as_str().cow_replace("^C", "") {
109+
output = replaced;
110+
}
111+
}
112+
104113
// Sort consecutive diagnostic blocks to handle non-deterministic tool output
105114
// (e.g., oxlint reports warnings in arbitrary order due to multi-threading).
106115
// Each block starts with " ! " and ends at the next empty line.

0 commit comments

Comments
 (0)