Skip to content

Commit 2e2c4c6

Browse files
ClaudeBrooooooklyn
andauthored
fix(cli): restore terminal state after Ctrl+C in interactive commands (#1407)
When interrupting `vp dev` or `vp preview` with Ctrl+C on Unix terminals (particularly macOS/Ghostty), escape sequences appeared in the shell prompt. This occurred because Vite sets the terminal to raw mode for interactive features, and when SIGINT interrupted the child process, the terminal state wasn't restored before the parent exited. ## Changes - **`crates/vite_command/src/lib.rs`**: - Added `TerminalStateGuard` RAII guard that saves terminal attributes via `tcgetattr()` on creation and restores via `tcsetattr()` on drop - Added `execute_with_terminal_guard()` wrapper function that applies the guard during command execution - Guard only activates when stdin is a TTY, safely ignoring pipes/redirects - **`packages/cli/binding/src/cli/execution.rs`**: - Modified `resolve_and_execute()` to detect interactive commands (`Dev`, `Preview`) - Routes interactive commands through terminal guard wrapper - Non-interactive commands use standard execution path unchanged - **`crates/vite_command/Cargo.toml`**: - Added `"term"` feature to `nix` dependency for terminal state APIs ## Implementation ```rust // Terminal state saved before spawning child let _guard = TerminalStateGuard::save(STDIN_FILENO); // Child runs with inherited stdio let mut child = cmd.spawn()?; child.wait().await? // Guard's Drop impl automatically restores terminal on exit, // even if interrupted by signal ``` The fix follows the same RAII pattern used in `vite_shared/src/header.rs` for raw mode management and only impacts interactive dev servers where terminal corruption can occur. --------- Co-authored-by: anthropic-code-agent[bot] <242468646+Claude@users.noreply.github.com> Co-authored-by: Brooooooklyn <3468483+Brooooooklyn@users.noreply.github.com> Co-authored-by: LongYinan <lynweklm@gmail.com>
1 parent abc3b2a commit 2e2c4c6

File tree

3 files changed

+88
-4
lines changed

3 files changed

+88
-4
lines changed

crates/vite_command/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ vite_path = { workspace = true }
1616
which = { workspace = true, features = ["tracing"] }
1717

1818
[target.'cfg(not(target_os = "windows"))'.dependencies]
19-
nix = { workspace = true }
19+
nix = { workspace = true, features = ["term"] }
2020

2121
[dev-dependencies]
2222
tempfile = { workspace = true }

crates/vite_command/src/lib.rs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
#[cfg(unix)]
2+
use std::os::fd::{BorrowedFd, RawFd};
13
use std::{
24
collections::HashMap,
35
ffi::OsStr,
@@ -59,6 +61,32 @@ pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {
5961
cmd
6062
}
6163

64+
/// Execute a command while preserving terminal state.
65+
/// This prevents escape sequences from appearing in the prompt when the child process
66+
/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state.
67+
///
68+
/// On Unix, saves the terminal state before spawning the child process and restores
69+
/// it after the child exits. On Windows, this is a simple pass-through.
70+
pub async fn execute_with_terminal_guard(mut cmd: Command) -> Result<ExitStatus, Error> {
71+
#[cfg(unix)]
72+
{
73+
use nix::libc::STDIN_FILENO;
74+
75+
// Save terminal state before spawning child
76+
let _guard = TerminalStateGuard::save(STDIN_FILENO);
77+
78+
// Spawn and wait for child - guard will restore terminal state on drop
79+
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
80+
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
81+
}
82+
83+
#[cfg(not(unix))]
84+
{
85+
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
86+
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
87+
}
88+
}
89+
6290
/// Build a `tokio::process::Command` for shell execution.
6391
/// Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows.
6492
pub fn build_shell_command(shell_cmd: &str, cwd: &AbsolutePath) -> Command {
@@ -230,6 +258,50 @@ pub fn fix_stdio_streams() {
230258
clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) });
231259
}
232260

261+
/// Guard that saves terminal state and restores it on drop.
262+
/// This prevents escape sequences from appearing in the prompt when a child process
263+
/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state.
264+
#[cfg(unix)]
265+
struct TerminalStateGuard {
266+
fd: RawFd,
267+
original: nix::sys::termios::Termios,
268+
}
269+
270+
#[cfg(unix)]
271+
impl TerminalStateGuard {
272+
/// Save the current terminal state for the given file descriptor.
273+
/// Returns None if the fd is not a terminal or if saving fails.
274+
fn save(fd: RawFd) -> Option<Self> {
275+
use nix::sys::termios::tcgetattr;
276+
277+
// SAFETY: fd comes from a valid stdin/stdout/stderr file descriptor
278+
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };
279+
280+
// Only save state if this is actually a terminal
281+
if !nix::unistd::isatty(borrowed_fd).unwrap_or(false) {
282+
return None;
283+
}
284+
285+
match tcgetattr(borrowed_fd) {
286+
Ok(original) => Some(Self { fd, original }),
287+
Err(_) => None,
288+
}
289+
}
290+
}
291+
292+
#[cfg(unix)]
293+
impl Drop for TerminalStateGuard {
294+
fn drop(&mut self) {
295+
use nix::sys::termios::{SetArg, tcsetattr};
296+
297+
// SAFETY: fd comes from stdin/stdout/stderr and the guard does not outlive the process
298+
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(self.fd) };
299+
300+
// Best effort: ignore errors during cleanup
301+
let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &self.original);
302+
}
303+
}
304+
233305
#[cfg(test)]
234306
mod tests {
235307
use tempfile::{TempDir, tempdir};

packages/cli/binding/src/cli/execution.rs

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,24 @@ pub(super) async fn resolve_and_execute(
5757
cwd: &AbsolutePathBuf,
5858
cwd_arc: &Arc<AbsolutePath>,
5959
) -> Result<ExitStatus, Error> {
60+
let is_interactive = matches!(
61+
subcommand,
62+
SynthesizableSubcommand::Dev { .. } | SynthesizableSubcommand::Preview { .. }
63+
);
64+
6065
let mut cmd =
6166
resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)
6267
.await?;
63-
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
64-
let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;
65-
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
68+
69+
// For interactive commands (dev, preview), use terminal guard to restore terminal state on exit
70+
if is_interactive {
71+
let status = vite_command::execute_with_terminal_guard(cmd).await?;
72+
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
73+
} else {
74+
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
75+
let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;
76+
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
77+
}
6678
}
6779

6880
pub(super) enum FilterStream {

0 commit comments

Comments
 (0)