Catching unwind with tui and crossterm – Where does the error message go?

While playing with tui, I tried to catch panics in order to restore terminal operation if a panic occurs in my problem. But I get strange behavior. This is what I made:

[dependencies]
tui = "0.18.0"
crossterm = "0.23.2"
use crossterm::execute;
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use tui::{backend::CrosstermBackend, Terminal};

use std::io;
use std::panic;
use std::thread::sleep;
use std::time::Duration;

fn run() -> Result<(), io::Error> {
    sleep(Duration::from_secs(1));
    panic!("PANIC!");
}

fn main() -> Result<(), io::Error> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let result = panic::catch_unwind(|| run().unwrap());
    disable_raw_mode()?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
    if let Err(err) = result {
        println!("This is executed.");
        sleep(Duration::from_secs(1));
        panic::resume_unwind(err);
        println!("This is not executed, but where went the panic output?");
    }
    Ok(())
}

When I execute cargo run, the screen gets empty for 1 second, and then all I get is the following output:

% cargo run
warning: unreachable statement
  --> src/main.rs:31:9
   |
30 |         panic::resume_unwind(err);
   |         ------------------------- any code following this expression is unreachable
31 |         println!("This is not executed, but where went the panic output?");
   |         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ unreachable statement
   |
   = note: `#[warn(unreachable_code)]` on by default
   = note: this warning originates in the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

warning: `mycrate` (bin "mycrate") generated 1 warning
    Finished dev [unoptimized + debuginfo] target(s) in 0.01s
     Running `target/debug/mycrate`
This is executed.

But where did my error end up?

If I instead move resume_unwind up, it kinda works (but gets messed up due to the bad terminal state):

fn main() -> Result<(), io::Error> {
    enable_raw_mode()?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen)?;
    let backend = CrosstermBackend::new(stdout);
    let mut terminal = Terminal::new(backend)?;
    let result = panic::catch_unwind(|| run().unwrap());
    disable_raw_mode()?;
+    if let Err(err) = result {
+        println!("This is executed.");
+        sleep(Duration::from_secs(1));
+        panic::resume_unwind(err);
+        println!("This is not executed, but where went the panic output?");
+    }
    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
    terminal.show_cursor()?;
-    if let Err(err) = result {
-        println!("This is executed.");
-        sleep(Duration::from_secs(1));
-        panic::resume_unwind(err);
-        println!("This is not executed, but where went the panic output?");
-    }
    Ok(())
}

Output:

thread 'main' panicked at 'PANIC!', src/main.rs:14:5
                                                    note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
                                                                                                                                 This is executed.

My questions:

  1. Why doesn't resume_unwind work as expected in the first case (i.e. when I call it after restoring the terminal) by showing the panic?
  2. Is it reasonable to use catch_unwind, or should I rather implement some sort of drop guard?

In to your alternate screen, which you then exited, is my guess.

You might [1] be able to test this theory by using a terminal that doesn't have an alternate screen capability. Compare the behavior of less here:

# Assuming bash here

less /etc/ssh/sshd_config
# Then quit out, probably the contents of the file disappear
# That's the alternative screen going away


ORIGTERM="$TERM"
export TERM=linux
less /etc/ssh/sshd_config
# Then quit out, probably the contents of the file remain on screen
# I.e. it reused your only "screen"

# Restoring the original is probably best at some point
export TERM="$ORIGTERM"

If linux doesn't work like that, you could try some others (though you might get some weird interactions with your actual terminal software) like vt102 or something (see /usr/lib/terminfo, probably).

Once you've found one without the capability, you could try running your program with the different terminal settings.

Or, you could just try not entering the alternative screen, I suppose.


  1. I'm not going to go check against the libraries myself right now, sorry ↩ī¸Ž

1 Like

Oh, that makes sense! I didn't understand that the error output happens at the time of the panic. I thought it would be deferred until I call resume_unwind (similar to how an exception can be caught and re-thrown). But apparently catch_unwind doesn't stop the panic message being emitted to stderr.

Is there any way to catch the output of panics gracefully? Such that I can print them with terminal settings restored? I guess this might work with redirecting stderr? Seems complicated though.


Maybe std::panic::set_hook can help me.

See here.

1 Like

The gag crate seems to require a file for the output (which I find a bit unnecessary), but using panic::set_hook, I came up with something similar to the second response there:

use std::sync::{Arc, Mutex};

fn main() {
    let panic_info: Arc<Mutex<Option<String>>> = Default::default();
    let panic_info_in_hook = Arc::clone(&panic_info);
    std::panic::set_hook(Box::new(move |info| {
        *panic_info_in_hook.lock().unwrap() = Some(info.to_string());
    }));
    println!("Execute some code.");
    let result = std::panic::catch_unwind(|| panic!("BOOM!"));
    println!("Done executing.");
    let _ = std::panic::take_hook();
    if let Err(err) = result {
        println!(
            "Panic happened: {}",
            panic_info.lock().unwrap().as_deref().unwrap()
        );
        std::panic::resume_unwind(err);
    }
    println!("This shouldn't be executed.");
}

(Playground)

Output:

Execute some code.
Done executing.
Panic happened: panicked at 'BOOM!', src/main.rs:10:46

So maybe that will do. It doesn't capture a Backtrace though. But feels better than messed up terminal output at panic.