fix(tui): RAII TerminalGuard prevents terminal corruption on error exit (closes #8)
The previous code called std::process::exit(1) on file load failure, bypassing all terminal restoration (disable_raw_mode, LeaveAlternateScreen, show_cursor). This left the user's shell in a broken state with no echo and no visible cursor. Introduce TerminalGuard with Drop-based cleanup that fires on every exit path: normal return, ? error propagation, and panic unwind. The guard also handles partial initialization rollback (e.g. raw mode enabled but alternate screen fails). Remove all process::exit calls from the guarded scope.
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use clap::Parser;
|
||||
|
||||
mod app;
|
||||
@@ -11,44 +14,93 @@ struct Cli {
|
||||
files: Vec<String>,
|
||||
}
|
||||
|
||||
// ── RAII terminal guard ────────────────────────────────────────────
|
||||
//
|
||||
// Holds the ratatui Terminal and guarantees terminal state restoration
|
||||
// (disable raw mode, leave alternate screen, show cursor) on **every**
|
||||
// exit path: normal return, `?` error propagation, and panic unwind.
|
||||
//
|
||||
// IMPORTANT: `Drop` does **not** run on `std::process::exit` or
|
||||
// `panic = "abort"`. Therefore we must never call `process::exit`
|
||||
// inside the guarded scope — use `?` to return `Err` instead.
|
||||
|
||||
type Backend = ratatui::backend::CrosstermBackend<std::io::Stdout>;
|
||||
|
||||
struct TerminalGuard {
|
||||
terminal: ratatui::Terminal<Backend>,
|
||||
}
|
||||
|
||||
impl TerminalGuard {
|
||||
/// Enable raw mode, enter alternate screen, and create the terminal.
|
||||
/// On partial failure, rolls back any steps that already succeeded.
|
||||
fn enter() -> anyhow::Result<Self> {
|
||||
crossterm::terminal::enable_raw_mode()
|
||||
.map_err(|e| anyhow::anyhow!("enable_raw_mode: {e}"))?;
|
||||
|
||||
let mut stdout = std::io::stdout();
|
||||
if let Err(e) = crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen) {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
return Err(anyhow::anyhow!("EnterAlternateScreen: {e}"));
|
||||
}
|
||||
|
||||
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||
match ratatui::Terminal::new(backend) {
|
||||
Ok(terminal) => Ok(Self { terminal }),
|
||||
Err(e) => {
|
||||
// Roll back: leave alternate screen + disable raw mode
|
||||
let _ = crossterm::execute!(
|
||||
std::io::stdout(),
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
);
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
Err(anyhow::anyhow!("Terminal::new: {e}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn terminal(&mut self) -> &mut ratatui::Terminal<Backend> {
|
||||
&mut self.terminal
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TerminalGuard {
|
||||
fn drop(&mut self) {
|
||||
// Best-effort cleanup; suppress errors (Drop must not panic).
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
let _ = crossterm::execute!(
|
||||
self.terminal.backend_mut(),
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
);
|
||||
let _ = self.terminal.show_cursor();
|
||||
}
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
let mut stdout = std::io::stdout();
|
||||
crossterm::execute!(stdout, crossterm::terminal::EnterAlternateScreen)?;
|
||||
let backend = ratatui::backend::CrosstermBackend::new(stdout);
|
||||
let mut terminal = ratatui::Terminal::new(backend)?;
|
||||
|
||||
let mut guard = TerminalGuard::enter()?;
|
||||
let mut app = app::App::new();
|
||||
app.color_config = log_viewer_core::config::ColorConfig::load();
|
||||
|
||||
if let Some(file) = cli.files.first()
|
||||
&& let Err(e) = app.load_file(file)
|
||||
{
|
||||
eprintln!("Error loading file: {e}");
|
||||
std::process::exit(1);
|
||||
if let Some(file) = cli.files.first() {
|
||||
app.load_file(file)
|
||||
.with_context(|| format!("loading file {file}"))?;
|
||||
}
|
||||
|
||||
while !app.should_quit {
|
||||
app.poll_background_indexer();
|
||||
app.poll_file_watcher();
|
||||
terminal.draw(|frame| ui::render(frame, &mut app))?;
|
||||
if crossterm::event::poll(std::time::Duration::from_millis(100))? {
|
||||
guard.terminal().draw(|frame| ui::render(frame, &mut app))?;
|
||||
if crossterm::event::poll(Duration::from_millis(100))? {
|
||||
match crossterm::event::read()? {
|
||||
crossterm::event::Event::Key(key) => app.handle_key(key),
|
||||
crossterm::event::Event::Resize(_w, _h) => {}
|
||||
crossterm::event::Event::Resize(_, _) => {}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
crossterm::terminal::disable_raw_mode()?;
|
||||
crossterm::execute!(
|
||||
terminal.backend_mut(),
|
||||
crossterm::terminal::LeaveAlternateScreen
|
||||
)?;
|
||||
terminal.show_cursor()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user