Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/vite_command/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ vite_path = { workspace = true }
which = { workspace = true, features = ["tracing"] }

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

[dev-dependencies]
tempfile = { workspace = true }
Expand Down
72 changes: 72 additions & 0 deletions crates/vite_command/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
#[cfg(unix)]
use std::os::fd::{BorrowedFd, RawFd};
use std::{
collections::HashMap,
ffi::OsStr,
Expand Down Expand Up @@ -59,6 +61,32 @@ pub fn build_command(bin_path: &AbsolutePath, cwd: &AbsolutePath) -> Command {
cmd
}

/// Execute a command while preserving terminal state.
/// This prevents escape sequences from appearing in the prompt when the child process
/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state.
///
/// On Unix, saves the terminal state before spawning the child process and restores
/// it after the child exits. On Windows, this is a simple pass-through.
pub async fn execute_with_terminal_guard(mut cmd: Command) -> Result<ExitStatus, Error> {
#[cfg(unix)]
{
use nix::libc::STDIN_FILENO;

// Save terminal state before spawning child
let _guard = TerminalStateGuard::save(STDIN_FILENO);

// Spawn and wait for child - guard will restore terminal state on drop
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
}

#[cfg(not(unix))]
{
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
child.wait().await.map_err(|e| Error::Anyhow(e.into()))
}
}

/// Build a `tokio::process::Command` for shell execution.
/// Uses `/bin/sh -c` on Unix, `cmd.exe /C` on Windows.
pub fn build_shell_command(shell_cmd: &str, cwd: &AbsolutePath) -> Command {
Expand Down Expand Up @@ -230,6 +258,50 @@ pub fn fix_stdio_streams() {
clear_cloexec(unsafe { BorrowedFd::borrow_raw(STDERR_FILENO) });
}

/// Guard that saves terminal state and restores it on drop.
/// This prevents escape sequences from appearing in the prompt when a child process
/// is interrupted (e.g., via Ctrl+C) while the terminal is in a non-standard state.
#[cfg(unix)]
struct TerminalStateGuard {
fd: RawFd,
original: nix::sys::termios::Termios,
}

#[cfg(unix)]
impl TerminalStateGuard {
/// Save the current terminal state for the given file descriptor.
/// Returns None if the fd is not a terminal or if saving fails.
fn save(fd: RawFd) -> Option<Self> {
use nix::sys::termios::tcgetattr;

// SAFETY: fd comes from a valid stdin/stdout/stderr file descriptor
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(fd) };

// Only save state if this is actually a terminal
if !nix::unistd::isatty(borrowed_fd).unwrap_or(false) {
return None;
}

match tcgetattr(borrowed_fd) {
Ok(original) => Some(Self { fd, original }),
Err(_) => None,
}
}
}

#[cfg(unix)]
impl Drop for TerminalStateGuard {
fn drop(&mut self) {
use nix::sys::termios::{SetArg, tcsetattr};

// SAFETY: fd comes from stdin/stdout/stderr and the guard does not outlive the process
let borrowed_fd = unsafe { BorrowedFd::borrow_raw(self.fd) };

// Best effort: ignore errors during cleanup
let _ = tcsetattr(borrowed_fd, SetArg::TCSANOW, &self.original);
}
}

#[cfg(test)]
mod tests {
use tempfile::{TempDir, tempdir};
Expand Down
18 changes: 15 additions & 3 deletions packages/cli/binding/src/cli/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,24 @@ pub(super) async fn resolve_and_execute(
cwd: &AbsolutePathBuf,
cwd_arc: &Arc<AbsolutePath>,
) -> Result<ExitStatus, Error> {
let is_interactive = matches!(
subcommand,
SynthesizableSubcommand::Dev { .. } | SynthesizableSubcommand::Preview { .. }
);

let mut cmd =
resolve_and_build_command(resolver, subcommand, resolved_vite_config, envs, cwd, cwd_arc)
.await?;
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;
Ok(ExitStatus(status.code().unwrap_or(1) as u8))

// For interactive commands (dev, preview), use terminal guard to restore terminal state on exit
if is_interactive {
let status = vite_command::execute_with_terminal_guard(cmd).await?;
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
} else {
let mut child = cmd.spawn().map_err(|e| Error::Anyhow(e.into()))?;
let status = child.wait().await.map_err(|e| Error::Anyhow(e.into()))?;
Ok(ExitStatus(status.code().unwrap_or(1) as u8))
}
}

pub(super) enum FilterStream {
Expand Down
Loading