Skip to main content

ftui_core/
terminal_session.rs

1#![forbid(unsafe_code)]
2
3//! Terminal session lifecycle guard.
4//!
5//! This module provides RAII-based terminal lifecycle management that ensures
6//! cleanup even on panic. It owns raw-mode entry/exit and tracks all terminal
7//! state changes.
8//!
9//! # Lifecycle Guarantees
10//!
11//! 1. **All terminal state changes are tracked** - Each mode (raw, alt-screen,
12//!    mouse, bracketed paste, focus events) has a corresponding flag.
13//!
14//! 2. **Drop restores previous state** - When the [`TerminalSession`] is
15//!    dropped, all enabled modes are disabled in reverse order.
16//!
17//! 3. **Panic safety** - Because cleanup is in [`Drop`], it runs during panic
18//!    unwinding (unless `panic = "abort"` is set).
19//!
20//! 4. **No leaked state on any exit path** - Whether by return, `?`, panic,
21//!    or `process::exit()` (excluding abort), terminal state is restored.
22//!
23//! # Backend Decision (ADR-003)
24//!
25//! This module uses Crossterm as the terminal backend. Key requirements:
26//! - Raw mode enter/exit must be reliable
27//! - Cleanup must happen on normal exit AND panic
28//! - Resize events must be delivered accurately
29//!
30//! See ADR-003 for the full backend decision rationale.
31//!
32//! # Escape Sequences Reference
33//!
34//! The following escape sequences are used (via Crossterm):
35//!
36//! | Feature | Enable | Disable |
37//! |---------|--------|---------|
38//! | Alternate screen | `CSI ? 1049 h` | `CSI ? 1049 l` |
39//! | Mouse (SGR) | `CSI ? 1000;1002;1006 h` | `CSI ? 1000;1002;1006 l` |
40//! | Bracketed paste | `CSI ? 2004 h` | `CSI ? 2004 l` |
41//! | Focus events | `CSI ? 1004 h` | `CSI ? 1004 l` |
42//! | Kitty keyboard | `CSI > 15 u` | `CSI < u` |
43//! | Show cursor | `CSI ? 25 h` | `CSI ? 25 l` |
44//! | Reset style | `CSI 0 m` | N/A |
45//!
46//! # Cleanup Order
47//!
48//! On drop, cleanup happens in reverse order of enabling:
49//! 1. Disable kitty keyboard (if enabled)
50//! 2. Disable focus events (if enabled)
51//! 3. Disable bracketed paste (if enabled)
52//! 4. Disable mouse capture (if enabled)
53//! 5. Show cursor (always)
54//! 6. Leave alternate screen (if enabled)
55//! 7. Exit raw mode (always)
56//! 8. Flush stdout
57//!
58//! # Usage
59//!
60//! ```no_run
61//! use ftui_core::terminal_session::{TerminalSession, SessionOptions};
62//!
63//! // Create a session with desired options
64//! let session = TerminalSession::new(SessionOptions {
65//!     alternate_screen: true,
66//!     mouse_capture: true,
67//!     ..Default::default()
68//! })?;
69//!
70//! // Terminal is now in raw mode with alt screen and mouse
71//! // ... do work ...
72//!
73//! // When `session` is dropped, terminal is restored
74//! # Ok::<(), std::io::Error>(())
75//! ```
76
77use std::env;
78use std::io::{self, Write};
79use std::sync::OnceLock;
80use std::time::Duration;
81
82use crate::event::Event;
83
84const KITTY_KEYBOARD_ENABLE: &[u8] = b"\x1b[>15u";
85const KITTY_KEYBOARD_DISABLE: &[u8] = b"\x1b[<u";
86const SYNC_END: &[u8] = b"\x1b[?2026l";
87
88#[cfg(unix)]
89use signal_hook::consts::signal::{SIGINT, SIGTERM, SIGWINCH};
90#[cfg(unix)]
91use signal_hook::iterator::Signals;
92
93/// Terminal session configuration options.
94///
95/// These options control which terminal modes are enabled when a session
96/// starts. All options default to `false` for maximum portability.
97///
98/// # Example
99///
100/// ```
101/// use ftui_core::terminal_session::SessionOptions;
102///
103/// // Full-featured TUI
104/// let opts = SessionOptions {
105///     alternate_screen: true,
106///     mouse_capture: true,
107///     bracketed_paste: true,
108///     focus_events: true,
109///     ..Default::default()
110/// };
111///
112/// // Minimal inline mode
113/// let inline_opts = SessionOptions::default();
114/// ```
115#[derive(Debug, Clone, Default)]
116pub struct SessionOptions {
117    /// Enable alternate screen buffer (`CSI ? 1049 h`).
118    ///
119    /// When enabled, the terminal switches to a separate screen buffer,
120    /// preserving the original scrollback. On exit, the original screen
121    /// is restored.
122    ///
123    /// Use this for full-screen applications. For inline mode (preserving
124    /// scrollback), leave this `false`.
125    pub alternate_screen: bool,
126
127    /// Enable mouse capture with SGR encoding (`CSI ? 1000;1002;1006 h`).
128    ///
129    /// Enables:
130    /// - Normal mouse tracking (1000)
131    /// - Button event tracking (1002)
132    /// - SGR extended coordinates (1006) - supports coordinates > 223
133    pub mouse_capture: bool,
134
135    /// Enable bracketed paste mode (`CSI ? 2004 h`).
136    ///
137    /// When enabled, pasted text is wrapped in escape sequences:
138    /// - Start: `ESC [ 200 ~`
139    /// - End: `ESC [ 201 ~`
140    ///
141    /// This allows distinguishing pasted text from typed text.
142    pub bracketed_paste: bool,
143
144    /// Enable focus change events (`CSI ? 1004 h`).
145    ///
146    /// When enabled, the terminal sends events when focus is gained or lost:
147    /// - Focus in: `ESC [ I`
148    /// - Focus out: `ESC [ O`
149    pub focus_events: bool,
150
151    /// Enable Kitty keyboard protocol (pushes flags with `CSI > 15 u`).
152    ///
153    /// Uses the kitty protocol to report repeat/release events and disambiguate
154    /// keys. This is optional and only supported by select terminals.
155    pub kitty_keyboard: bool,
156}
157
158/// A terminal session that manages raw mode and cleanup.
159///
160/// This struct owns the terminal configuration and ensures cleanup on drop.
161/// It tracks all enabled modes and disables them in reverse order when dropped.
162///
163/// # Contract
164///
165/// - **Exclusive ownership**: Only one `TerminalSession` should exist at a time.
166///   Creating multiple sessions will cause undefined terminal behavior.
167///
168/// - **Raw mode entry**: Creating a session automatically enters raw mode.
169///   This disables line buffering and echo.
170///
171/// - **Cleanup guarantee**: When dropped (normally or via panic), all enabled
172///   modes are disabled and the terminal is restored to its previous state.
173///
174/// # State Tracking
175///
176/// Each optional mode has a corresponding `_enabled` flag. These flags are
177/// set when a mode is successfully enabled and cleared during cleanup.
178/// This ensures we only disable modes that were actually enabled.
179///
180/// # Example
181///
182/// ```no_run
183/// use ftui_core::terminal_session::{TerminalSession, SessionOptions};
184///
185/// fn run_app() -> std::io::Result<()> {
186///     let session = TerminalSession::new(SessionOptions {
187///         alternate_screen: true,
188///         mouse_capture: true,
189///         ..Default::default()
190///     })?;
191///
192///     // Application loop
193///     loop {
194///         if session.poll_event(std::time::Duration::from_millis(100))? {
195///             if let Some(event) = session.read_event()? {
196///                 // Handle event...
197///             }
198///         }
199///     }
200///     // Session cleaned up when dropped
201/// }
202/// ```
203#[derive(Debug)]
204pub struct TerminalSession {
205    options: SessionOptions,
206    /// Track what was enabled so we can disable on drop.
207    alternate_screen_enabled: bool,
208    mouse_enabled: bool,
209    bracketed_paste_enabled: bool,
210    focus_events_enabled: bool,
211    kitty_keyboard_enabled: bool,
212    #[cfg(unix)]
213    signal_guard: Option<SignalGuard>,
214}
215
216impl TerminalSession {
217    /// Enter raw mode and optionally enable additional features.
218    ///
219    /// # Errors
220    ///
221    /// Returns an error if raw mode cannot be enabled.
222    pub fn new(options: SessionOptions) -> io::Result<Self> {
223        install_panic_hook();
224
225        // Create signal guard before raw mode so that a failure here
226        // does not leave the terminal in raw mode (the struct would never
227        // be fully constructed, so Drop would not run).
228        #[cfg(unix)]
229        let signal_guard = Some(SignalGuard::new()?);
230
231        // Enter raw mode
232        crossterm::terminal::enable_raw_mode()?;
233        #[cfg(feature = "tracing")]
234        tracing::info!("terminal raw mode enabled");
235
236        let mut session = Self {
237            options: options.clone(),
238            alternate_screen_enabled: false,
239            mouse_enabled: false,
240            bracketed_paste_enabled: false,
241            focus_events_enabled: false,
242            kitty_keyboard_enabled: false,
243            #[cfg(unix)]
244            signal_guard,
245        };
246
247        // Enable optional features
248        let mut stdout = io::stdout();
249
250        if options.alternate_screen {
251            // Enter alternate screen and explicitly clear it.
252            // Some terminals (including WezTerm) may show stale content in the
253            // alt-screen buffer without an explicit clear. We also position the
254            // cursor at the top-left to ensure a known initial state.
255            crossterm::execute!(
256                stdout,
257                crossterm::terminal::EnterAlternateScreen,
258                crossterm::terminal::Clear(crossterm::terminal::ClearType::All),
259                crossterm::cursor::MoveTo(0, 0)
260            )?;
261            session.alternate_screen_enabled = true;
262            #[cfg(feature = "tracing")]
263            tracing::info!("alternate screen enabled (with clear)");
264        }
265
266        if options.mouse_capture {
267            crossterm::execute!(stdout, crossterm::event::EnableMouseCapture)?;
268            session.mouse_enabled = true;
269            #[cfg(feature = "tracing")]
270            tracing::info!("mouse capture enabled");
271        }
272
273        if options.bracketed_paste {
274            crossterm::execute!(stdout, crossterm::event::EnableBracketedPaste)?;
275            session.bracketed_paste_enabled = true;
276            #[cfg(feature = "tracing")]
277            tracing::info!("bracketed paste enabled");
278        }
279
280        if options.focus_events {
281            crossterm::execute!(stdout, crossterm::event::EnableFocusChange)?;
282            session.focus_events_enabled = true;
283            #[cfg(feature = "tracing")]
284            tracing::info!("focus events enabled");
285        }
286
287        if options.kitty_keyboard {
288            Self::enable_kitty_keyboard(&mut stdout)?;
289            session.kitty_keyboard_enabled = true;
290            #[cfg(feature = "tracing")]
291            tracing::info!("kitty keyboard enabled");
292        }
293
294        Ok(session)
295    }
296
297    /// Create a session for tests without touching the real terminal.
298    ///
299    /// This skips raw mode and feature toggles, allowing headless tests
300    /// to construct `TerminalSession` safely.
301    #[cfg(feature = "test-helpers")]
302    pub fn new_for_tests(options: SessionOptions) -> io::Result<Self> {
303        install_panic_hook();
304        #[cfg(unix)]
305        let signal_guard = None;
306
307        Ok(Self {
308            options,
309            alternate_screen_enabled: false,
310            mouse_enabled: false,
311            bracketed_paste_enabled: false,
312            focus_events_enabled: false,
313            kitty_keyboard_enabled: false,
314            #[cfg(unix)]
315            signal_guard,
316        })
317    }
318
319    /// Create a minimal session (raw mode only).
320    pub fn minimal() -> io::Result<Self> {
321        Self::new(SessionOptions::default())
322    }
323
324    /// Get the current terminal size (columns, rows).
325    pub fn size(&self) -> io::Result<(u16, u16)> {
326        let (w, h) = crossterm::terminal::size()?;
327        if w > 1 && h > 1 {
328            return Ok((w, h));
329        }
330
331        // Some terminals briefly report 1x1 on startup; fall back to env vars when available.
332        if let Some((env_w, env_h)) = size_from_env() {
333            return Ok((env_w, env_h));
334        }
335
336        // Re-probe once after a short delay to catch terminals that report size late.
337        std::thread::sleep(Duration::from_millis(10));
338        let (w2, h2) = crossterm::terminal::size()?;
339        if w2 > 1 && h2 > 1 {
340            return Ok((w2, h2));
341        }
342
343        // Ensure minimum viable size to prevent downstream panics in buffer allocation
344        // and layout calculations. 2x2 is the absolute minimum for a functional TUI.
345        let final_w = w.max(2);
346        let final_h = h.max(2);
347        Ok((final_w, final_h))
348    }
349
350    /// Poll for an event with a timeout.
351    ///
352    /// Returns `Ok(true)` if an event is available, `Ok(false)` if timeout.
353    pub fn poll_event(&self, timeout: std::time::Duration) -> io::Result<bool> {
354        crossterm::event::poll(timeout)
355    }
356
357    /// Read the next event (blocking until available).
358    ///
359    /// Returns `Ok(None)` if the event cannot be represented by the
360    /// ftui canonical event types (e.g. unsupported key codes).
361    pub fn read_event(&self) -> io::Result<Option<Event>> {
362        let event = crossterm::event::read()?;
363        Ok(Event::from_crossterm(event))
364    }
365
366    /// Show the cursor.
367    pub fn show_cursor(&self) -> io::Result<()> {
368        crossterm::execute!(io::stdout(), crossterm::cursor::Show)
369    }
370
371    /// Hide the cursor.
372    pub fn hide_cursor(&self) -> io::Result<()> {
373        crossterm::execute!(io::stdout(), crossterm::cursor::Hide)
374    }
375
376    /// Get the session options.
377    pub fn options(&self) -> &SessionOptions {
378        &self.options
379    }
380
381    /// Cleanup helper (shared between drop and explicit cleanup).
382    fn cleanup(&mut self) {
383        #[cfg(unix)]
384        let _ = self.signal_guard.take();
385
386        let mut stdout = io::stdout();
387
388        // End synchronized output first to ensure terminal updates resume
389        let _ = stdout.write_all(SYNC_END);
390
391        // Disable features in reverse order of enabling
392        if self.kitty_keyboard_enabled {
393            let _ = Self::disable_kitty_keyboard(&mut stdout);
394            self.kitty_keyboard_enabled = false;
395            #[cfg(feature = "tracing")]
396            tracing::info!("kitty keyboard disabled");
397        }
398
399        if self.focus_events_enabled {
400            let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
401            self.focus_events_enabled = false;
402            #[cfg(feature = "tracing")]
403            tracing::info!("focus events disabled");
404        }
405
406        if self.bracketed_paste_enabled {
407            let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
408            self.bracketed_paste_enabled = false;
409            #[cfg(feature = "tracing")]
410            tracing::info!("bracketed paste disabled");
411        }
412
413        if self.mouse_enabled {
414            let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
415            self.mouse_enabled = false;
416            #[cfg(feature = "tracing")]
417            tracing::info!("mouse capture disabled");
418        }
419
420        // Always show cursor before leaving
421        let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
422
423        if self.alternate_screen_enabled {
424            let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
425            self.alternate_screen_enabled = false;
426            #[cfg(feature = "tracing")]
427            tracing::info!("alternate screen disabled");
428        }
429
430        // Exit raw mode last
431        let _ = crossterm::terminal::disable_raw_mode();
432        #[cfg(feature = "tracing")]
433        tracing::info!("terminal raw mode disabled");
434
435        // Flush to ensure cleanup bytes are sent
436        let _ = stdout.flush();
437    }
438
439    fn enable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
440        writer.write_all(KITTY_KEYBOARD_ENABLE)?;
441        writer.flush()
442    }
443
444    fn disable_kitty_keyboard(writer: &mut impl Write) -> io::Result<()> {
445        writer.write_all(KITTY_KEYBOARD_DISABLE)?;
446        writer.flush()
447    }
448}
449
450impl Drop for TerminalSession {
451    fn drop(&mut self) {
452        self.cleanup();
453    }
454}
455
456fn size_from_env() -> Option<(u16, u16)> {
457    let cols = env::var("COLUMNS").ok()?.parse::<u16>().ok()?;
458    let rows = env::var("LINES").ok()?.parse::<u16>().ok()?;
459    if cols > 1 && rows > 1 {
460        Some((cols, rows))
461    } else {
462        None
463    }
464}
465
466fn install_panic_hook() {
467    static HOOK: OnceLock<()> = OnceLock::new();
468    HOOK.get_or_init(|| {
469        let previous = std::panic::take_hook();
470        std::panic::set_hook(Box::new(move |info| {
471            best_effort_cleanup();
472            previous(info);
473        }));
474    });
475}
476
477/// Best-effort cleanup for termination paths that skip `Drop`.
478///
479/// Call this before `std::process::exit` to restore terminal state when
480/// unwinding won't run destructors.
481pub fn best_effort_cleanup_for_exit() {
482    best_effort_cleanup();
483}
484
485fn best_effort_cleanup() {
486    let mut stdout = io::stdout();
487
488    // End synchronized output first to ensure any buffered content (like panic messages)
489    // is flushed to the terminal.
490    let _ = stdout.write_all(SYNC_END);
491
492    let _ = TerminalSession::disable_kitty_keyboard(&mut stdout);
493    let _ = crossterm::execute!(stdout, crossterm::event::DisableFocusChange);
494    let _ = crossterm::execute!(stdout, crossterm::event::DisableBracketedPaste);
495    let _ = crossterm::execute!(stdout, crossterm::event::DisableMouseCapture);
496    let _ = crossterm::execute!(stdout, crossterm::cursor::Show);
497    let _ = crossterm::execute!(stdout, crossterm::terminal::LeaveAlternateScreen);
498    let _ = crossterm::terminal::disable_raw_mode();
499    let _ = stdout.flush();
500}
501
502#[cfg(unix)]
503#[derive(Debug)]
504struct SignalGuard {
505    handle: signal_hook::iterator::Handle,
506    thread: Option<std::thread::JoinHandle<()>>,
507}
508
509#[cfg(unix)]
510impl SignalGuard {
511    fn new() -> io::Result<Self> {
512        let mut signals = Signals::new([SIGINT, SIGTERM, SIGWINCH]).map_err(io::Error::other)?;
513        let handle = signals.handle();
514        let thread = std::thread::spawn(move || {
515            for signal in signals.forever() {
516                match signal {
517                    SIGWINCH => {
518                        #[cfg(feature = "tracing")]
519                        tracing::debug!("SIGWINCH received");
520                    }
521                    SIGINT | SIGTERM => {
522                        #[cfg(feature = "tracing")]
523                        tracing::warn!("termination signal received, cleaning up");
524                        best_effort_cleanup();
525                        std::process::exit(128 + signal);
526                    }
527                    _ => {}
528                }
529            }
530        });
531        Ok(Self {
532            handle,
533            thread: Some(thread),
534        })
535    }
536}
537
538#[cfg(unix)]
539impl Drop for SignalGuard {
540    fn drop(&mut self) {
541        self.handle.close();
542        if let Some(thread) = self.thread.take() {
543            let _ = thread.join();
544        }
545    }
546}
547
548/// Spike validation notes (for ADR-003).
549///
550/// ## Crossterm Evaluation Results
551///
552/// ### Functionality (all verified)
553/// - ✅ raw mode: `enable_raw_mode()` / `disable_raw_mode()`
554/// - ✅ alternate screen: `EnterAlternateScreen` / `LeaveAlternateScreen`
555/// - ✅ cursor show/hide: `Show` / `Hide`
556/// - ✅ mouse mode (SGR): `EnableMouseCapture` / `DisableMouseCapture`
557/// - ✅ bracketed paste: `EnableBracketedPaste` / `DisableBracketedPaste`
558/// - ✅ focus events: `EnableFocusChange` / `DisableFocusChange`
559/// - ✅ resize events: `Event::Resize(cols, rows)`
560///
561/// ### Robustness
562/// - ✅ bounded-time reads via `poll()` with timeout
563/// - ✅ handles partial sequences (internal buffer management)
564/// - ⚠️ adversarial input: not fuzz-tested in this spike
565///
566/// ### Cleanup Discipline
567/// - ✅ Drop impl guarantees cleanup on normal exit
568/// - ✅ Drop impl guarantees cleanup on panic (via unwinding)
569/// - ✅ cursor shown before exit
570/// - ✅ raw mode disabled last
571///
572/// ### Platform Coverage
573/// - ✅ Linux: fully supported
574/// - ✅ macOS: fully supported
575/// - ⚠️ Windows: supported with some feature limitations (see ADR-004)
576///
577/// ## Decision
578/// **Crossterm is approved as the v1 terminal backend.**
579///
580/// Rationale: It provides all required functionality, handles cleanup via
581/// standard Rust drop semantics, and has broad platform support.
582///
583/// Limitations documented in ADR-004 (Windows scope).
584#[doc(hidden)]
585pub const _SPIKE_NOTES: () = ();
586
587#[cfg(test)]
588mod tests {
589    use super::*;
590    #[cfg(unix)]
591    use portable_pty::{CommandBuilder, PtySize};
592    #[cfg(unix)]
593    use std::io::{self, Read, Write};
594    #[cfg(unix)]
595    use std::sync::mpsc;
596    #[cfg(unix)]
597    use std::thread;
598    #[cfg(unix)]
599    use std::time::{Duration, Instant};
600
601    #[test]
602    fn session_options_default_is_minimal() {
603        let opts = SessionOptions::default();
604        assert!(!opts.alternate_screen);
605        assert!(!opts.mouse_capture);
606        assert!(!opts.bracketed_paste);
607        assert!(!opts.focus_events);
608        assert!(!opts.kitty_keyboard);
609    }
610
611    #[test]
612    fn session_options_clone() {
613        let opts = SessionOptions {
614            alternate_screen: true,
615            mouse_capture: true,
616            bracketed_paste: false,
617            focus_events: true,
618            kitty_keyboard: false,
619        };
620        let cloned = opts.clone();
621        assert_eq!(cloned.alternate_screen, opts.alternate_screen);
622        assert_eq!(cloned.mouse_capture, opts.mouse_capture);
623        assert_eq!(cloned.bracketed_paste, opts.bracketed_paste);
624        assert_eq!(cloned.focus_events, opts.focus_events);
625        assert_eq!(cloned.kitty_keyboard, opts.kitty_keyboard);
626    }
627
628    #[test]
629    fn session_options_debug() {
630        let opts = SessionOptions::default();
631        let debug = format!("{:?}", opts);
632        assert!(debug.contains("SessionOptions"));
633        assert!(debug.contains("alternate_screen"));
634    }
635
636    #[test]
637    fn kitty_keyboard_escape_sequences() {
638        // Verify the escape sequences are correct
639        assert_eq!(KITTY_KEYBOARD_ENABLE, b"\x1b[>15u");
640        assert_eq!(KITTY_KEYBOARD_DISABLE, b"\x1b[<u");
641    }
642
643    #[test]
644    fn session_options_partial_config() {
645        let opts = SessionOptions {
646            alternate_screen: true,
647            mouse_capture: false,
648            bracketed_paste: true,
649            ..Default::default()
650        };
651        assert!(opts.alternate_screen);
652        assert!(!opts.mouse_capture);
653        assert!(opts.bracketed_paste);
654        assert!(!opts.focus_events);
655        assert!(!opts.kitty_keyboard);
656    }
657
658    #[cfg(unix)]
659    enum ReaderMsg {
660        Data(Vec<u8>),
661        Eof,
662        Err(std::io::Error),
663    }
664
665    #[cfg(unix)]
666    fn read_until_pattern(
667        rx: &mpsc::Receiver<ReaderMsg>,
668        captured: &mut Vec<u8>,
669        pattern: &[u8],
670        timeout: Duration,
671    ) -> std::io::Result<()> {
672        let deadline = Instant::now() + timeout;
673        while Instant::now() < deadline {
674            let remaining = deadline.saturating_duration_since(Instant::now());
675            let wait = remaining.min(Duration::from_millis(50));
676            match rx.recv_timeout(wait) {
677                Ok(ReaderMsg::Data(chunk)) => {
678                    captured.extend_from_slice(&chunk);
679                    if captured.windows(pattern.len()).any(|w| w == pattern) {
680                        return Ok(());
681                    }
682                }
683                Ok(ReaderMsg::Eof) => break,
684                Ok(ReaderMsg::Err(err)) => return Err(err),
685                Err(mpsc::RecvTimeoutError::Timeout) => continue,
686                Err(mpsc::RecvTimeoutError::Disconnected) => break,
687            }
688        }
689        Err(std::io::Error::other(
690            "timeout waiting for PTY output marker",
691        ))
692    }
693
694    #[cfg(unix)]
695    fn assert_contains_any(output: &[u8], options: &[&[u8]], label: &str) {
696        let found = options
697            .iter()
698            .any(|needle| output.windows(needle.len()).any(|w| w == *needle));
699        assert!(found, "expected cleanup sequence for {label}");
700    }
701
702    #[cfg(unix)]
703    #[test]
704    fn terminal_session_panic_cleanup_idempotent() {
705        const MARKER: &[u8] = b"PANIC_CAUGHT";
706        const TEST_NAME: &str =
707            "terminal_session::tests::terminal_session_panic_cleanup_idempotent";
708        const ALT_SCREEN_EXIT_SEQS: &[&[u8]] = &[b"\x1b[?1049l", b"\x1b[?1047l"];
709        const MOUSE_DISABLE_SEQS: &[&[u8]] = &[
710            b"\x1b[?1000;1002;1006l",
711            b"\x1b[?1000;1002l",
712            b"\x1b[?1000l",
713        ];
714        const BRACKETED_PASTE_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?2004l"];
715        const FOCUS_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[?1004l"];
716        const KITTY_DISABLE_SEQS: &[&[u8]] = &[b"\x1b[<u"];
717        const CURSOR_SHOW_SEQS: &[&[u8]] = &[b"\x1b[?25h"];
718
719        if std::env::var("FTUI_CORE_PANIC_CHILD").is_ok() {
720            let _ = std::panic::catch_unwind(|| {
721                let _session = TerminalSession::new(SessionOptions {
722                    alternate_screen: true,
723                    mouse_capture: true,
724                    bracketed_paste: true,
725                    focus_events: true,
726                    kitty_keyboard: true,
727                })
728                .expect("TerminalSession::new should succeed in PTY");
729                panic!("intentional panic to exercise cleanup");
730            });
731
732            // The panic hook + Drop will have already attempted cleanup; call again to
733            // verify idempotence when cleanup paths run multiple times.
734            best_effort_cleanup_for_exit();
735
736            let _ = io::stdout().write_all(MARKER);
737            let _ = io::stdout().flush();
738            return;
739        }
740
741        let exe = std::env::current_exe().expect("current_exe");
742        let mut cmd = CommandBuilder::new(exe);
743        cmd.args(["--exact", TEST_NAME, "--nocapture"]);
744        cmd.env("FTUI_CORE_PANIC_CHILD", "1");
745        cmd.env("RUST_BACKTRACE", "0");
746
747        let pty_system = portable_pty::native_pty_system();
748        let pair = pty_system
749            .openpty(PtySize {
750                rows: 24,
751                cols: 80,
752                pixel_width: 0,
753                pixel_height: 0,
754            })
755            .expect("openpty");
756
757        let mut child = pair.slave.spawn_command(cmd).expect("spawn PTY child");
758        drop(pair.slave);
759
760        let mut reader = pair.master.try_clone_reader().expect("clone PTY reader");
761        let _writer = pair.master.take_writer().expect("take PTY writer");
762
763        let (tx, rx) = mpsc::channel::<ReaderMsg>();
764        let reader_thread = thread::spawn(move || {
765            let mut buf = [0u8; 4096];
766            loop {
767                match reader.read(&mut buf) {
768                    Ok(0) => {
769                        let _ = tx.send(ReaderMsg::Eof);
770                        break;
771                    }
772                    Ok(n) => {
773                        let _ = tx.send(ReaderMsg::Data(buf[..n].to_vec()));
774                    }
775                    Err(err) => {
776                        let _ = tx.send(ReaderMsg::Err(err));
777                        break;
778                    }
779                }
780            }
781        });
782
783        let mut captured = Vec::new();
784        read_until_pattern(&rx, &mut captured, MARKER, Duration::from_secs(5))
785            .expect("expected marker from child");
786
787        let status = child.wait().expect("child wait");
788        let _ = reader_thread.join();
789
790        assert!(status.success(), "child should exit successfully");
791        assert!(
792            captured.windows(MARKER.len()).any(|w| w == MARKER),
793            "expected panic marker in PTY output"
794        );
795        assert_contains_any(&captured, ALT_SCREEN_EXIT_SEQS, "alt-screen exit");
796        assert_contains_any(&captured, MOUSE_DISABLE_SEQS, "mouse disable");
797        assert_contains_any(
798            &captured,
799            BRACKETED_PASTE_DISABLE_SEQS,
800            "bracketed paste disable",
801        );
802        assert_contains_any(&captured, FOCUS_DISABLE_SEQS, "focus disable");
803        assert_contains_any(&captured, KITTY_DISABLE_SEQS, "kitty disable");
804        assert_contains_any(&captured, CURSOR_SHOW_SEQS, "cursor show");
805    }
806}