hojicha_runtime/
panic_handler.rs

1//! Global panic handler for graceful TUI recovery
2//!
3//! This module provides a panic handler that ensures the terminal is restored
4//! to a usable state when a panic occurs, and optionally logs panic information.
5
6use log::error;
7use std::io::Write;
8use std::panic::{self, PanicHookInfo};
9use std::sync::atomic::{AtomicBool, Ordering};
10#[cfg(test)]
11use std::sync::Arc;
12
13/// Global flag to track if we're in a TUI context
14static TUI_ACTIVE: AtomicBool = AtomicBool::new(false);
15
16/// Cleanup function to be called on panic
17static mut CLEANUP_FN: Option<Box<dyn Fn() + Send + Sync>> = None;
18
19/// Install a panic handler that will restore the terminal on panic
20///
21/// This should be called at the start of your program, before entering the TUI.
22///
23/// # Example
24/// ```no_run
25/// use hojicha_runtime::panic_handler;
26///
27/// panic_handler::install();
28/// // ... run your TUI application
29/// ```
30pub fn install() {
31    panic::set_hook(Box::new(|panic_info| {
32        handle_panic(panic_info);
33    }));
34}
35
36/// Install a panic handler with a custom cleanup function
37///
38/// The cleanup function will be called before the terminal is restored.
39/// This is useful for saving application state or performing other cleanup.
40///
41/// # Safety
42/// The cleanup function must be thread-safe as it may be called from any thread.
43///
44/// # Example
45/// ```no_run
46/// use hojicha_runtime::panic_handler;
47///
48/// panic_handler::install_with_cleanup(|| {
49///     // Save application state, close files, etc.
50///     eprintln!("Saving application state before exit...");
51/// });
52/// // ... run your TUI application
53/// ```
54pub fn install_with_cleanup<F>(cleanup: F)
55where
56    F: Fn() + Send + Sync + 'static,
57{
58    unsafe {
59        CLEANUP_FN = Some(Box::new(cleanup));
60    }
61    install();
62}
63
64/// Mark that the TUI is active
65///
66/// This should be called when entering TUI mode and ensures that
67/// the panic handler knows to restore the terminal.
68pub fn set_tui_active(active: bool) {
69    TUI_ACTIVE.store(active, Ordering::SeqCst);
70}
71
72/// Create a guard that automatically sets TUI active/inactive
73pub struct TuiGuard;
74
75impl Default for TuiGuard {
76    fn default() -> Self {
77        Self::new()
78    }
79}
80
81impl TuiGuard {
82    /// Create a new TUI guard
83    pub fn new() -> Self {
84        set_tui_active(true);
85        TuiGuard
86    }
87}
88
89impl Drop for TuiGuard {
90    fn drop(&mut self) {
91        set_tui_active(false);
92    }
93}
94
95/// The actual panic handler
96fn handle_panic(panic_info: &PanicHookInfo) {
97    // First, log the panic if logging is available
98    error!("PANIC: {}", panic_info);
99
100    // Run custom cleanup if provided
101    unsafe {
102        if let Some(ref cleanup) = CLEANUP_FN {
103            cleanup();
104        }
105    }
106
107    // If we're in TUI mode, restore the terminal
108    if TUI_ACTIVE.load(Ordering::SeqCst) {
109        restore_terminal();
110    }
111
112    // Print panic information to stderr
113    eprintln!("\n\n==================== PANIC ====================");
114    eprintln!("{}", panic_info);
115
116    // Print location if available
117    if let Some(location) = panic_info.location() {
118        eprintln!(
119            "\nLocation: {}:{}:{}",
120            location.file(),
121            location.line(),
122            location.column()
123        );
124    }
125
126    // Print backtrace if available
127    if let Ok(var) = std::env::var("RUST_BACKTRACE") {
128        if var == "1" || var == "full" {
129            eprintln!("\nBacktrace:");
130            eprintln!("{:?}", std::backtrace::Backtrace::capture());
131        }
132    } else {
133        eprintln!("\nNote: Set RUST_BACKTRACE=1 to see a backtrace");
134    }
135
136    eprintln!("================================================\n");
137}
138
139/// Attempt to restore the terminal to a usable state
140fn restore_terminal() {
141    use crossterm::{
142        cursor,
143        event::{DisableBracketedPaste, DisableFocusChange, DisableMouseCapture},
144        execute,
145        terminal::{self, LeaveAlternateScreen},
146    };
147
148    // Try to restore terminal state
149    let _ = execute!(
150        std::io::stderr(),
151        LeaveAlternateScreen,
152        DisableMouseCapture,
153        DisableBracketedPaste,
154        DisableFocusChange,
155        cursor::Show,
156    );
157
158    // Disable raw mode
159    let _ = terminal::disable_raw_mode();
160
161    // Flush stderr to ensure all output is visible
162    let _ = std::io::stderr().flush();
163}
164
165/// A panic hook that can be used in tests to verify panic behavior
166#[cfg(test)]
167pub struct TestPanicHook {
168    /// Flag indicating whether a panic occurred
169    pub panicked: Arc<AtomicBool>,
170    /// The panic message, if captured
171    pub panic_message: Arc<std::sync::Mutex<Option<String>>>,
172}
173
174#[cfg(test)]
175impl Default for TestPanicHook {
176    fn default() -> Self {
177        Self::new()
178    }
179}
180
181#[cfg(test)]
182impl TestPanicHook {
183    /// Create a new test panic hook
184    pub fn new() -> Self {
185        Self {
186            panicked: Arc::new(AtomicBool::new(false)),
187            panic_message: Arc::new(std::sync::Mutex::new(None)),
188        }
189    }
190
191    /// Install this hook as the panic handler
192    pub fn install(&self) {
193        let panicked = Arc::clone(&self.panicked);
194        let panic_message = Arc::clone(&self.panic_message);
195
196        panic::set_hook(Box::new(move |panic_info| {
197            let msg = if let Some(s) = panic_info.payload().downcast_ref::<&str>() {
198                s.to_string()
199            } else if let Some(s) = panic_info.payload().downcast_ref::<String>() {
200                s.clone()
201            } else {
202                "Unknown panic".to_string()
203            };
204
205            // Only record panics that match our test pattern
206            if msg.starts_with("Test panic message for thread") {
207                panicked.store(true, Ordering::SeqCst);
208                *panic_message.lock().unwrap() = Some(msg);
209            }
210        }));
211    }
212
213    /// Check if a panic occurred
214    pub fn did_panic(&self) -> bool {
215        self.panicked.load(Ordering::SeqCst)
216    }
217
218    /// Get the panic message if one occurred
219    pub fn get_panic_message(&self) -> Option<String> {
220        self.panic_message.lock().unwrap().clone()
221    }
222
223    /// Reset the panic state
224    pub fn reset(&self) {
225        self.panicked.store(false, Ordering::SeqCst);
226        *self.panic_message.lock().unwrap() = None;
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233    use proptest::prelude::*;
234    use std::panic;
235    use std::sync::Mutex;
236
237    // Global mutex to ensure panic hook tests don't interfere with each other
238    static PANIC_TEST_MUTEX: Mutex<()> = Mutex::new(());
239
240    #[test]
241    fn test_panic_hook_captures_panic() {
242        // Use a static mutex to ensure only one panic hook test runs at a time
243        let _guard = PANIC_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
244
245        // Save the current panic hook to restore it later
246        let original_hook = panic::take_hook();
247
248        // Create our test hook
249        let hook = TestPanicHook::new();
250        hook.install();
251
252        // Use a unique message to avoid confusion with other tests
253        let test_id = std::thread::current().id();
254        let panic_msg = format!("Test panic message for thread {:?}", test_id);
255        let expected_msg = panic_msg.clone();
256
257        // Trigger a panic and catch it
258        let panic_result = panic::catch_unwind(move || {
259            panic!("{}", panic_msg);
260        });
261
262        // Verify the panic was caught
263        assert!(panic_result.is_err(), "Expected panic to occur");
264
265        // Check if our hook captured it
266        let captured = hook.get_panic_message();
267
268        // Restore the original panic hook BEFORE any assertions that might fail
269        panic::set_hook(original_hook);
270
271        // Now check our assertions
272        assert!(hook.did_panic(), "Hook should have detected panic");
273        assert_eq!(
274            captured,
275            Some(expected_msg),
276            "Hook should have captured our specific message"
277        );
278    }
279
280    /// Behavioral test: TUI state can be set manually
281    #[test]
282    fn test_manual_tui_state_behavior() {
283        // Save initial state
284        let initial_state = TUI_ACTIVE.load(Ordering::SeqCst);
285
286        set_tui_active(true);
287        assert!(TUI_ACTIVE.load(Ordering::SeqCst));
288
289        set_tui_active(false);
290        assert!(!TUI_ACTIVE.load(Ordering::SeqCst));
291
292        // Restore initial state
293        set_tui_active(initial_state);
294    }
295
296    /// Behavioral test: Panic hook installation doesn't interfere with normal operation
297    #[test]
298    fn test_panic_hook_installation_behavior() {
299        let _guard = PANIC_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
300
301        // Save original hook
302        let original_hook = panic::take_hook();
303
304        // Install our handler
305        install();
306
307        // Normal operations should work fine
308        let result = std::panic::catch_unwind(|| {
309            // This should NOT panic
310            let x = 1 + 1;
311            assert_eq!(x, 2);
312        });
313
314        assert!(result.is_ok());
315
316        // Restore original hook
317        panic::set_hook(original_hook);
318    }
319
320    /// Behavioral test: Custom cleanup function behavior
321    #[test]
322    fn test_custom_cleanup_behavior() {
323        let _guard = PANIC_TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
324        let original_hook = panic::take_hook();
325
326        let cleanup_called = Arc::new(AtomicBool::new(false));
327        let cleanup_called_clone = Arc::clone(&cleanup_called);
328
329        // Install with custom cleanup
330        install_with_cleanup(move || {
331            cleanup_called_clone.store(true, Ordering::SeqCst);
332        });
333
334        let test_id = std::thread::current().id();
335        let panic_msg = format!("Test cleanup panic for thread {:?}", test_id);
336
337        // Trigger panic to test cleanup
338        let _ = panic::catch_unwind(move || {
339            panic!("{}", panic_msg);
340        });
341
342        // Note: The cleanup might not be called in catch_unwind context
343        // This test verifies the installation doesn't break normal operation
344
345        // Restore original hook
346        panic::set_hook(original_hook);
347
348        // Test passes if we get here without hanging
349    }
350
351    proptest! {
352        #[test]
353        fn prop_tui_state_atomic(states in prop::collection::vec(prop::bool::ANY, 1..10)) {
354            for &state in &states {
355                set_tui_active(state);
356                prop_assert_eq!(TUI_ACTIVE.load(Ordering::SeqCst), state);
357            }
358        }
359    }
360}