//! TUI-side signal handlers that restore the terminal before exiting. //! //! Without these, signal-triggered termination (SIGINT, SIGTERM, SIGHUP from //! WSL/SSH disconnect) leaves the user's terminal in raw mode with the //! alternate screen still active, mouse capture still on, and Kitty keyboard //! protocol flags still pushed. The next time the terminal becomes visible the //! user sees a stale snapshot of the TUI that looks alive but is backed by a //! dead process. //! //! SIGINT / SIGTERM / SIGHUP are handled in a tokio task. The handler runs //! in normal Rust context (not actual signal-handler context), so it can use //! the full [`super::emit_terminal_teardown_sequences`] path //! (`with_locked_stderr`, conditional cursor-style reset, multiplexer flush) plus //! `disable_raw_mode`, then flush Sentry/OpenTelemetry, then exit. //! //! SIGPIPE is intentionally left alone. The current disposition is `SIG_IGN` //! (Rust's stdlib default), which means writes to a closed pipe return //! `Err(BrokenPipe)` instead of terminating the process. The writer thread //! already swallows those errors. Installing a custom handler would change //! the disposition inherited by fork+exec children (MCP servers, hooks) from //! `SIG_IGN` to `SIG_DFL`, re-introducing a prior SIGPIPE regression. use std::sync::atomic::{AtomicBool, Ordering}; use agent_client_protocol as acp; use super::ScreenMode; /// Whether the active screen mode is fullscreen. Set by [`install`]. static SCREEN_MODE_FULLSCREEN: AtomicBool = AtomicBool::new(false); /// Most recently active session ID for signal-handler cleanup. /// `parking_lot::Mutex` avoids poisoning in the `-> !` exit path. static CURRENT_SESSION_ID: parking_lot::Mutex> = parking_lot::Mutex::new(None); pub(crate) fn set_current_session_id(id: Option) { *CURRENT_SESSION_ID.lock() = id; } /// Lets the signal handler route SIGINT/SIGTERM/SIGHUP into the same graceful /// quit as `/exit` (running teardown + history/telemetry flushes) instead of a /// hard exit. Registered by the event loop before it starts. static QUIT_NOTIFY: parking_lot::Mutex>> = parking_lot::Mutex::new(None); pub(crate) fn set_quit_notify(notify: std::sync::Arc) { *QUIT_NOTIFY.lock() = Some(notify); } /// Whether the TUI currently owns the terminal. Set by [`install`], cleared /// by [`mark_restored`] or by [`shutdown_with_terminal_restore`] after /// teardown completes. The SIGPIPE path (SIG_IGN, no handler) does not /// interact with this flag. static TERMINAL_OWNED: AtomicBool = AtomicBool::new(false); /// Install signal handlers for the TUI lifecycle. Call after `init_terminal`. pub(crate) fn install(mode: ScreenMode) { SCREEN_MODE_FULLSCREEN.store(mode.is_fullscreen(), Ordering::Release); TERMINAL_OWNED.store(true, Ordering::Release); // Ignore SIGTTIN/SIGTTOU so the pager can't be suspended if a // child process (or its grandchild) briefly steals the terminal's // foreground process group. Every TUI that reads from stdin should // do this — without it, a single tcsetpgrp() from a rogue child // stops the entire pager, leaving the terminal in raw mode. #[cfg(unix)] unsafe { libc::signal(libc::SIGTTIN, libc::SIG_IGN); libc::signal(libc::SIGTTOU, libc::SIG_IGN); } spawn_async_signal_task(); } /// Mark the terminal as no longer owned by the TUI. Called by the clean /// shutdown path and the panic hook so a signal arriving after the shell /// has reclaimed the terminal does not re-run teardown. pub(crate) fn mark_restored() { TERMINAL_OWNED.store(false, Ordering::Release); } fn spawn_async_signal_task() { tokio::spawn(async { #[cfg(unix)] { use tokio::signal::unix::{SignalKind, signal}; let mut sigterm = signal(SignalKind::terminate()).ok(); let mut sighup = signal(SignalKind::hangup()).ok(); // First signal requests the graceful quit; a second forces exit. let code = next_signal_code(&mut sigterm, &mut sighup).await; request_graceful_or_exit(code); let code2 = next_signal_code(&mut sigterm, &mut sighup).await; shutdown_with_terminal_restore(code2); } #[cfg(windows)] { // ctrl_c gets the first-graceful / second-force treatment. Console // close, logoff, and shutdown stay immediate: the OS grants only a // short window, so graceful teardown may not finish. use tokio::signal::windows; let mut ctrl_close = windows::ctrl_close().ok(); let mut ctrl_logoff = windows::ctrl_logoff().ok(); let mut ctrl_shutdown = windows::ctrl_shutdown().ok(); tokio::select! { _ = tokio::signal::ctrl_c() => { request_graceful_or_exit(130); let _ = tokio::signal::ctrl_c().await; shutdown_with_terminal_restore(130); } _ = async { if let Some(s) = ctrl_close.as_mut() { let _ = s.recv().await; } else { std::future::pending::<()>().await; } } => shutdown_with_terminal_restore(1), _ = async { if let Some(s) = ctrl_logoff.as_mut() { let _ = s.recv().await; } else { std::future::pending::<()>().await; } } => shutdown_with_terminal_restore(0), _ = async { if let Some(s) = ctrl_shutdown.as_mut() { let _ = s.recv().await; } else { std::future::pending::<()>().await; } } => shutdown_with_terminal_restore(0), } } #[cfg(not(any(unix, windows)))] { if tokio::signal::ctrl_c().await.is_ok() { request_graceful_or_exit(130); let _ = tokio::signal::ctrl_c().await; shutdown_with_terminal_restore(130); } } }); } /// Wait for the next SIGINT/SIGTERM/SIGHUP and map it to its exit code. #[cfg(unix)] async fn next_signal_code( sigterm: &mut Option, sighup: &mut Option, ) -> i32 { tokio::select! { _ = tokio::signal::ctrl_c() => 130, _ = async { if let Some(s) = sigterm.as_mut() { let _ = s.recv().await; } else { std::future::pending::<()>().await; } } => 143, _ = async { if let Some(s) = sighup.as_mut() { let _ = s.recv().await; } else { std::future::pending::<()>().await; } } => 129, } } /// Request the event loop's graceful quit when it is registered and the TUI /// still owns the terminal; otherwise hard-exit (agent mode, or a signal after /// teardown already started). fn request_graceful_or_exit(code: i32) { let notify = QUIT_NOTIFY.lock().clone(); if TERMINAL_OWNED.load(Ordering::Acquire) && let Some(n) = notify { n.notify_one(); } else { shutdown_with_terminal_restore(code); } } /// Restore the terminal first, then flush observability, then exit. /// /// Restore must precede the (up to 2-second) Sentry flush; otherwise the /// user stares at a raw-mode + alt-screen + mouse-SGR terminal for that /// whole window. Best-effort: a frame queued on the writer thread microseconds /// before the signal can still land after our teardown writes -- the writer /// thread is not reachable from here without a deadlock risk. fn shutdown_with_terminal_restore(exit_code: i32) -> ! { // The graceful quit (or a prior teardown) already restored the terminal; // skip teardown and just flush telemetry before exiting. if !TERMINAL_OWNED.load(Ordering::Acquire) { flush_telemetry_and_exit(exit_code); } let mode = if SCREEN_MODE_FULLSCREEN.load(Ordering::Acquire) { ScreenMode::Fullscreen } else { ScreenMode::Inline }; // Signal-path shutdown has no terminal handle, so fall back to the screen // bottom for the final cursor position. super::emit_terminal_teardown_sequences(mode, None); let _ = crossterm::terminal::disable_raw_mode(); // Mark after teardown so concurrent paths see TERMINAL_OWNED == true // until all escape sequences and tcsetattr have been written. TERMINAL_OWNED.store(false, Ordering::Release); // Best-effort unregister (non-blocking flock to avoid hanging). if let Some(ref sid) = *CURRENT_SESSION_ID.lock() { let _ = kigi_shell::active_sessions::try_unregister(sid); } flush_telemetry_and_exit(exit_code); } /// Shared `-> !` exit tail of `shutdown_with_terminal_restore`'s early-return /// and full-teardown paths. fn flush_telemetry_and_exit(exit_code: i32) -> ! { // Reap detached (setsid) background children before the hard exit. This tail // runs on the force/second-signal and agent-mode paths that skip the // graceful quit; the graceful path reaps them in `app::run`'s teardown. kigi_tty_utils::global_process_scope().kill_all(); // Restore fd 2 so Sentry/OTEL flushes reach the terminal. kigi_tty_utils::restore_native_stderr(); // Flush the --debug firehose on TUI signal exit (this path bypasses main's flush). kigi_log::debug_log::flush(); std::process::exit(exit_code); } #[cfg(test)] mod tests { use super::*; /// Restores `TERMINAL_OWNED` to its prior value on drop, including on panic. struct TerminalOwnedGuard(bool); impl TerminalOwnedGuard { fn set(value: bool) -> Self { Self(TERMINAL_OWNED.swap(value, Ordering::AcqRel)) } } impl Drop for TerminalOwnedGuard { fn drop(&mut self) { TERMINAL_OWNED.store(self.0, Ordering::Release); } } #[tokio::test] async fn request_graceful_or_exit_notifies_registered_quit() { let notify = std::sync::Arc::new(tokio::sync::Notify::new()); set_quit_notify(notify.clone()); let stored = QUIT_NOTIFY.lock().clone().expect("quit notify registered"); assert!(std::sync::Arc::ptr_eq(&stored, ¬ify)); // request_graceful_or_exit only notifies (vs process::exit) while the // terminal reads as owned; the guard restores the flag even on panic. let _owned_guard = TerminalOwnedGuard::set(true); request_graceful_or_exit(130); tokio::time::timeout(std::time::Duration::from_secs(1), notify.notified()) .await .expect("graceful branch notified the registered quit handle"); } }