From 4a617b1b2c4c10ecd0f1f3e0f008bc8779e652bb Mon Sep 17 00:00:00 2001 From: Vishnu <104626273+Vishnu1837@users.noreply.github.com> Date: Thu, 14 May 2026 17:33:54 +0530 Subject: [PATCH] fix(tui): restore terminal on SIGINT and SIGTERM Restore terminal modes on abnormal signal exit and share the emergency restore path with the panic hook. Fixes #1583. --- crates/tui/src/main.rs | 84 ++++++++++++++++++++++++++++++++-------- crates/tui/src/tui/ui.rs | 17 ++++++++ 2 files changed, 85 insertions(+), 16 deletions(-) diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 9a90bee9..e0212acc 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -310,6 +310,54 @@ enum ExecOutputFormat { StreamJson, } +/// Spawn a tokio task that listens for terminating signals (SIGINT +/// always; SIGTERM and SIGHUP on Unix) and, on receipt, restores the +/// terminal modes and exits with the conventional 128 + signal code. +/// Multiple deliveries are tolerated: once the cleanup runs, a second +/// signal short-circuits to plain exit so a stuck cleanup can never +/// trap a frustrated user pressing Ctrl+C repeatedly. +/// +/// See the call site in `main` for the rationale (#1583). +fn spawn_signal_cleanup_task() { + tokio::spawn(async { + let exit_code = wait_for_terminating_signal().await; + // If we get here a fatal signal arrived. Restore the terminal + // and exit. A second signal during cleanup re-enters this + // path and aborts via `std::process::exit` directly. + static CLEANED_UP: std::sync::atomic::AtomicBool = + std::sync::atomic::AtomicBool::new(false); + if !CLEANED_UP.swap(true, std::sync::atomic::Ordering::SeqCst) { + crate::tui::ui::emergency_restore_terminal(); + } + std::process::exit(exit_code); + }); +} + +#[cfg(unix)] +async fn wait_for_terminating_signal() -> i32 { + use tokio::signal::unix::{SignalKind, signal}; + // Failing to install any individual stream is non-fatal: we still + // want the others to work. The fallback never-resolving future + // keeps `select!` well-typed when a stream fails to register. + let mut sigint = signal(SignalKind::interrupt()).ok(); + let mut sigterm = signal(SignalKind::terminate()).ok(); + let mut sighup = signal(SignalKind::hangup()).ok(); + tokio::select! { + _ = async { match sigint.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 130, + _ = async { match sigterm.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 143, + _ = async { match sighup.as_mut() { Some(s) => { s.recv().await; }, None => std::future::pending::<()>().await, } } => 129, + } +} + +#[cfg(not(unix))] +async fn wait_for_terminating_signal() -> i32 { + // Windows: tokio::signal::ctrl_c covers both Ctrl+C and Ctrl+Break + // (CTRL_C_EVENT / CTRL_BREAK_EVENT). Console-close, logoff, and + // shutdown events are not currently routed through tokio. + let _ = tokio::signal::ctrl_c().await; + 130 +} + fn join_prompt_parts(parts: &[String]) -> String { parts.join(" ") } @@ -631,22 +679,10 @@ async fn main() -> Result<()> { std::panic::set_hook(Box::new(move |panic_info| { // Restore the terminal first so the panic message itself, plus the // user's shell after exit, are visible. Best-effort — we may not be - // in raw / alt-screen mode if the panic happens pre-TUI. - use crossterm::event::{DisableBracketedPaste, DisableMouseCapture}; - use crossterm::terminal::{LeaveAlternateScreen, disable_raw_mode}; - // Use the Windows-aware helper: crossterm's PopKeyboardEnhancementFlags - // is a no-op on Windows (is_ansi_code_supported() == false), so the - // plain execute!() form would leave the terminal in Kitty-enhanced mode - // after a panic. pop_keyboard_enhancement_flags writes the pop escape - // directly on Windows (#1359). - crate::tui::ui::pop_keyboard_enhancement_flags(&mut std::io::stdout()); - // Best-effort: turn off bracketed paste + mouse capture so the user's - // parent shell doesn't get stuck wrapping pastes in `\e[200~…\e[201~` - // or printing `\e[<…M` on every click after a TUI panic. - let _ = crossterm::execute!(std::io::stdout(), DisableBracketedPaste); - let _ = crossterm::execute!(std::io::stdout(), DisableMouseCapture); - let _ = disable_raw_mode(); - let _ = crossterm::execute!(std::io::stdout(), LeaveAlternateScreen); + // in raw / alt-screen mode if the panic happens pre-TUI. Shared + // with the signal handler installed below so both exit paths leave + // the terminal in the same well-defined state. + crate::tui::ui::emergency_restore_terminal(); let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() { s.to_string() @@ -675,6 +711,22 @@ async fn main() -> Result<()> { orig_hook(panic_info); })); + // Install signal handlers that restore the terminal before the + // process exits. Without this, Ctrl+C delivered while raw mode / + // kitty keyboard enhancement / alt-screen are active (or in the + // brief windows around startup and teardown where they're being + // toggled) leaves the user's shell receiving raw CSI sequences + // like `^[[>5u` until they run `reset` (#1583). + // + // Once the TUI's raw mode is engaged the terminal driver delivers + // Ctrl+C as the byte 0x03 rather than SIGINT, so the in-TUI key + // handler — not this handler — is what processes user interrupts + // during normal operation. This handler exists for the gaps: + // pre-TUI subcommands (--version, doctor, login, …), the moments + // around enable_raw_mode / disable_raw_mode, the external-editor + // suspend path, and SIGTERM / SIGHUP from the OS. + spawn_signal_cleanup_task(); + dotenv().ok(); let cli = Cli::parse(); logging::set_verbose(cli.verbose || logging::env_requests_verbose_logging()); diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index bac605a8..b60e1830 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -6375,6 +6375,23 @@ pub(crate) fn pop_keyboard_enhancement_flags(writer: &mut W) { let _ = execute!(writer, PopKeyboardEnhancementFlags); } +/// Best-effort terminal restoration for emergency exit paths +/// (panic hook, signal handlers). Mirrors the normal teardown in +/// `run_event_loop` but tolerates any subset of modes not actually being +/// active — every step is discarded on failure so a half-initialized TUI +/// (e.g. SIGINT during startup before `EnterAlternateScreen`) still gets +/// raw mode + kitty keyboard flags cleared, which is what causes the +/// `^[[>5u` shell pollution reported in #1583. +pub fn emergency_restore_terminal() { + let mut stdout = std::io::stdout(); + pop_keyboard_enhancement_flags(&mut stdout); + let _ = execute!(stdout, DisableFocusChange); + let _ = execute!(stdout, DisableBracketedPaste); + let _ = execute!(stdout, DisableMouseCapture); + let _ = disable_raw_mode(); + let _ = execute!(stdout, LeaveAlternateScreen); +} + /// Re-establish terminal mode flags. Idempotent and best-effort: each /// underlying flag is silently discarded by terminals that don't support /// it, and a single flag's failure doesn't prevent later flags from being