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.
This commit is contained in:
Vishnu
2026-05-14 17:33:54 +05:30
committed by GitHub
parent 7d3a36ddbc
commit 4a617b1b2c
2 changed files with 85 additions and 16 deletions
+68 -16
View File
@@ -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());
+17
View File
@@ -6375,6 +6375,23 @@ pub(crate) fn pop_keyboard_enhancement_flags<W: Write>(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