Skip to content

Commit 76fdb6f

Browse files
ClaudeBrooooooklyn
andauthored
fix(cli): restore terminal state after Ctrl+C in interactive commands
Agent-Logs-Url: https://github.com/voidzero-dev/vite-plus/sessions/a9e99ef9-0713-4570-8c7a-d2cd4752e1c3 Co-authored-by: Brooooooklyn <3468483+Brooooooklyn@users.noreply.github.com>
1 parent ee4e19d commit 76fdb6f

3 files changed

Lines changed: 90 additions & 5 deletions

File tree

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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ use tokio_util::sync::CancellationToken;
1010
use vite_error::Error;
1111
use vite_path::{AbsolutePath, AbsolutePathBuf, RelativePathBuf};
1212

13+
#[cfg(unix)]
14+
use std::os::fd::{BorrowedFd, RawFd};
15+
1316
/// Result of running a command with fspy tracking.
1417
#[derive(Debug)]
1518
pub struct FspyCommandResult {
@@ -59,6 +62,32 @@ pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {
5962
cmd
6063
}
6164

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

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

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

Lines changed: 16 additions & 4 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 mut cmd =
60+
let is_interactive = matches!(
61+
subcommand,
62+
SynthesizableSubcommand::Dev { .. } | SynthesizableSubcommand::Preview { .. }
63+
);
64+
65+
let 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)