Skip to content

Counter App Error Handling

前のセクションでは、および矢印キーを押すために、カウンターの値を制御するarrowを押すユーザーに応答する basic counter app を作成しました。このチュートリアルは、そのコードから始まり、エラーとパニック処理を追加します。

基本的なアプリで中断した場所を簡単に思い出させる:

Cargo.toml (click to expand)
# -- snip --
[dependencies]
ratatui = "0.29.0"
crossterm = "0.28.1"
main.rs (click to expand)
use std::io;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::Stylize,
symbols::border,
text::{Line, Text},
widgets::{Block, Paragraph, Widget},
DefaultTerminal, Frame,
};
fn main() -> io::Result<()> {
let mut terminal = ratatui::init();
let app_result = App::default().run(&mut terminal);
ratatui::restore();
app_result
}
#[derive(Debug, Default)]
pub struct App {
counter: u8,
exit: bool,
}
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> io::Result<()> {
while !self.exit {
terminal.draw(|frame| self.draw(frame))?;
self.handle_events()?;
}
Ok(())
}
fn draw(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
/// updates the application's state based on user input
fn handle_events(&mut self) -> io::Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => {
self.handle_key_event(key_event)
}
_ => {}
};
Ok(())
}
fn handle_key_event(&mut self, key_event: KeyEvent) {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter(),
KeyCode::Right => self.increment_counter(),
_ => {}
}
}
fn exit(&mut self) {
self.exit = true;
}
fn increment_counter(&mut self) {
self.counter += 1;
}
fn decrement_counter(&mut self) {
self.counter -= 1;
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = Line::from(" Counter App Tutorial ".bold());
let instructions = Line::from(vec![
" Decrement ".into(),
"<Left>".blue().bold(),
" Increment ".into(),
"<Right>".blue().bold(),
" Quit ".into(),
"<Q> ".blue().bold(),
]);
let block = Block::bordered()
.title(title.centered())
.title_bottom(instructions.centered())
.border_set(border::THICK);
let counter_text = Text::from(vec![Line::from(vec![
"Value: ".into(),
self.counter.to_string().yellow(),
])]);
Paragraph::new(counter_text)
.centered()
.block(block)
.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Style;
#[test]
fn render() {
let app = App::default();
let mut buf = Buffer::empty(Rect::new(0, 0, 50, 4));
app.render(buf.area, &mut buf);
let mut expected = Buffer::with_lines(vec![
"┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓",
"┃ Value: 0 ┃",
"┃ ┃",
"┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛",
]);
let title_style = Style::new().bold();
let counter_style = Style::new().yellow();
let key_style = Style::new().blue().bold();
expected.set_style(Rect::new(14, 0, 22, 1), title_style);
expected.set_style(Rect::new(28, 1, 1, 1), counter_style);
expected.set_style(Rect::new(13, 3, 6, 1), key_style);
expected.set_style(Rect::new(30, 3, 7, 1), key_style);
expected.set_style(Rect::new(43, 3, 4, 1), key_style);
assert_eq!(buf, expected);
}
#[test]
fn handle_key_event() -> io::Result<()> {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into());
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into());
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into());
assert!(app.exit);
Ok(())
}
}

問題

前のセクションで作成したアプリには、カウンターがすでに 0 のときにユーザーが 矢印キーを押すとアプリがパニックになる意図的なエラーがあります。これが発生すると、メイン関数は終了する前にターミナル状態を復元する機会がありません。

src/main.rs (from basic app)
fn main() -> io::Result<()> {
let mut terminal = ratatui::init();
let app_result = App::default().run(&mut terminal);
ratatui::restore();
app_result
}

アプリケーションのデフォルトのパニック ハンドラが実行され、詳細が乱雑に表示されます。これは、raw モードではターミナルが通常の方法で改行を解釈できなくなるためです。シェル プロンプトも間違った場所に表示されます。

Basic App Error

これから回復するには、macosまたはLinuxコンソールで、 reset コマンドを実行します。Windowsコンソールでは、コンソールを再起動する必要がある場合があります。

セットアップフック

Rust アプリケーションが失敗する方法は 2 つあります。Rust の書籍の エラー処理 の章で、これについてより詳しく説明されています。

Rust はエラーを recoverableunrecoverable の 2 つの主なカテゴリに分類します。file not found error などの回復可能なエラーの場合、おそらくユーザーに問題を報告して操作を再試行するだけです。回復不可能なエラーは常にバグの症状であり、配列の末尾を超える場所にアクセスしようとしている場合など、プログラムを直ちに停止する必要があります。— https://doc.rust-lang.org/book/ch09-00-error-handling.html

未処理のエラーを簡単に表示する方法の 1 つは、color-eyre クレートを使用してエラー報告フックを拡張することです。[raw モード] の alternate screen で実行されている ratatui アプリケーションでは、これらのエラーをユーザーに表示する前にターミナルを復元することが重要です。


color-eyre クレートを追加します

add color-eyre
cargo add color-eyre

main functionのreturn値をcolor_eyre::Result<()>に更新し、color_eyre::install関数を呼び出します。また、アプリユーザーがターミナルを復元した場合に何をすべきかを理解するのに役立つエラーメッセージを追加することもできます。

main.rs
use color_eyre::{
eyre::{bail, WrapErr},
Result,
};
fn main() -> Result<()> {
color_eyre::install()?;
let mut terminal = tui::init()?;
let app_result = App::default().run(&mut terminal);
if let Err(err) = tui::restore() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {}",
err
);
}
app_result
}

次に、tui::init() 関数を更新して、パニック フックを、パニック情報を出力する前にまずターミナルを復元するフックに置き換えます。これにより、アプリケーションが終了したときに、パニックと未処理のエラー (つまり、メイン関数の最上位レベルにバブルアップする Result::Err) の両方がターミナルに正しく表示されるようになります。

tui.rs
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
set_panic_hook();
Terminal::new(CrosstermBackend::new(stdout()))
}
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
hook(panic_info);
}));
}

Color_eyreを使用する

Color eyre は、結果に追加情報を追加することで機能します。wrap_err (color_eyre::eyre::WrapErr trait で定義) を呼び出すことで、エラーにコンテキストを追加できます。

App::run 関数を更新して、更新関数の失敗に関する情報を追加し、戻り値を変更します。

main.rs
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
while !self.exit {
terminal.draw(|frame| self.render_frame(frame))?;
self.handle_events().wrap_err("handle events failed")?;
}
Ok(())
}
}

回復可能なエラーを作成する

チュートリアルでは、回復可能なエラーの処理方法を示すために合成エラーが必要です。 handle_key_event を変更して color_eyre::Result を返すようにし、増分呼び出しと減分呼び出しに ? 演算子が含まれ、呼び出し元にエラーが伝わるようにしてください。

main.rs
impl App {
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter()?,
KeyCode::Right => self.increment_counter()?,
_ => {}
}
Ok(())
}
}

カウンターが2を超えているときに発生するエラーを追加しましょう。また、両方のメソッドの戻りタイプを変更します。 increment_counter メソッドに新しいエラーを追加します。これには bail! マクロを使用できます。

main.rs
impl App {
fn decrement_counter(&mut self) -> Result<()> {
self.counter -= 1;
Ok(())
}
fn increment_counter(&mut self) -> Result<()> {
self.counter += 1;
if self.counter > 2 {
bail!("counter overflow");
}
Ok(())
}
}

handle_events メソッドで、どのキーが障害を引き起こしたかについての追加情報を追加し、返品値を更新します。

main.rs
impl App {
/// updates the application's state based on user input
fn handle_events(&mut self) -> Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => self
.handle_key_event(key_event)
.wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}")),
_ => Ok(()),
}
}
}

このメソッドのテストを更新して、handle_key_eventsへの呼び出しを解除します。これにより、エラーが返されるとテストが失敗します。

main.rs
mod tests {
#[test]
fn handle_key_event() {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into()).unwrap();
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap();
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into()).unwrap();
assert!(app.exit);
}
}

パニックおよびオーバーフロー条件のテストを追加します

main.rs
mod tests {
#[test]
#[should_panic(expected = "attempt to subtract with overflow")]
fn handle_key_event_panic() {
let mut app = App::default();
let _ = app.handle_key_event(KeyCode::Left.into());
}
#[test]
fn handle_key_event_overflow() {
let mut app = App::default();
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert_eq!(
app.handle_key_event(KeyCode::Right.into())
.unwrap_err()
.to_string(),
"counter overflow"
);
}
}

テストを実行します:

run tests
cargo test
running 4 tests
thread 'tests::handle_key_event_panic' panicked at code/counter-app-error-handling/src/main.rs:94:9:
attempt to subtract with overflow
test tests::handle_key_event ... okstack backtrace:
test tests::handle_key_event_overflow ... ok
test tests::render ... ok
20 collapsed lines
0: rust_begin_unwind
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/std/src/panicking.rs:645:5
1: core::panicking::panic_fmt
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:72:14
2: core::panicking::panic
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/panicking.rs:144:5
3: counter_app_error_handling::App::decrement_counter
at ./src/main.rs:94:9
4: counter_app_error_handling::App::handle_key_event
at ./src/main.rs:79:30
5: counter_app_error_handling::tests::handle_key_event_panic
at ./src/main.rs:200:17
6: counter_app_error_handling::tests::handle_key_event_panic::{{closure}}
at ./src/main.rs:198:32
7: core::ops::function::FnOnce::call_once
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
8: core::ops::function::FnOnce::call_once
at /rustc/07dca489ac2d933c78d3c5158e3f43beefeb02ce/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with `RUST_BACKTRACE=full` for a verbose backtrace.
test tests::handle_key_event_panic - should panic ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s

完成したアプリ

これを完全に言えば、これで次のファイルが必要です。

main.rs (click to expand)
use color_eyre::{
eyre::{bail, WrapErr},
Result,
};
use ratatui::{
buffer::Buffer,
crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind},
layout::Rect,
style::Stylize,
symbols::border,
text::{Line, Text},
widgets::{Block, Borders, Paragraph, Widget},
Frame,
};
mod tui;
fn main() -> Result<()> {
color_eyre::install()?;
let mut terminal = tui::init()?;
let app_result = App::default().run(&mut terminal);
if let Err(err) = tui::restore() {
eprintln!(
"failed to restore terminal. Run `reset` or restart your terminal to recover: {}",
err
);
}
app_result
}
#[derive(Debug, Default)]
pub struct App {
counter: u8,
exit: bool,
}
impl App {
/// runs the application's main loop until the user quits
pub fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> {
while !self.exit {
terminal.draw(|frame| self.render_frame(frame))?;
self.handle_events().wrap_err("handle events failed")?;
}
Ok(())
}
fn render_frame(&self, frame: &mut Frame) {
frame.render_widget(self, frame.area());
}
/// updates the application's state based on user input
fn handle_events(&mut self) -> Result<()> {
match event::read()? {
// it's important to check that the event is a key press event as
// crossterm also emits key release and repeat events on Windows.
Event::Key(key_event) if key_event.kind == KeyEventKind::Press => self
.handle_key_event(key_event)
.wrap_err_with(|| format!("handling key event failed:\n{key_event:#?}")),
_ => Ok(()),
}
}
fn handle_key_event(&mut self, key_event: KeyEvent) -> Result<()> {
match key_event.code {
KeyCode::Char('q') => self.exit(),
KeyCode::Left => self.decrement_counter()?,
KeyCode::Right => self.increment_counter()?,
_ => {}
}
Ok(())
}
fn exit(&mut self) {
self.exit = true;
}
fn decrement_counter(&mut self) -> Result<()> {
self.counter -= 1;
Ok(())
}
fn increment_counter(&mut self) -> Result<()> {
self.counter += 1;
if self.counter > 2 {
bail!("counter overflow");
}
Ok(())
}
}
impl Widget for &App {
fn render(self, area: Rect, buf: &mut Buffer) {
let title = Line::from(" Counter App Tutorial ".bold());
let instructions = Line::from(vec![
" Decrement ".into(),
"<Left>".blue().bold(),
" Increment ".into(),
"<Right>".blue().bold(),
" Quit ".into(),
"<Q> ".blue().bold(),
]);
let block = Block::default()
.title(title.centered())
.title_bottom(instructions.centered())
.borders(Borders::ALL)
.border_set(border::THICK);
let counter_text = Text::from(vec![Line::from(vec![
"Value: ".into(),
self.counter.to_string().yellow(),
])]);
Paragraph::new(counter_text)
.centered()
.block(block)
.render(area, buf);
}
}
#[cfg(test)]
mod tests {
use ratatui::style::Style;
use super::*;
#[test]
fn render() {
let app = App::default();
let mut buf = Buffer::empty(Rect::new(0, 0, 50, 4));
app.render(buf.area, &mut buf);
let mut expected = Buffer::with_lines(vec![
"┏━━━━━━━━━━━━━ Counter App Tutorial ━━━━━━━━━━━━━┓",
"┃ Value: 0 ┃",
"┃ ┃",
"┗━ Decrement <Left> Increment <Right> Quit <Q> ━━┛",
]);
let title_style = Style::new().bold();
let counter_style = Style::new().yellow();
let key_style = Style::new().blue().bold();
expected.set_style(Rect::new(14, 0, 22, 1), title_style);
expected.set_style(Rect::new(28, 1, 1, 1), counter_style);
expected.set_style(Rect::new(13, 3, 6, 1), key_style);
expected.set_style(Rect::new(30, 3, 7, 1), key_style);
expected.set_style(Rect::new(43, 3, 4, 1), key_style);
assert_eq!(buf, expected);
}
#[test]
fn handle_key_event() {
let mut app = App::default();
app.handle_key_event(KeyCode::Right.into()).unwrap();
assert_eq!(app.counter, 1);
app.handle_key_event(KeyCode::Left.into()).unwrap();
assert_eq!(app.counter, 0);
let mut app = App::default();
app.handle_key_event(KeyCode::Char('q').into()).unwrap();
assert!(app.exit);
}
#[test]
#[should_panic(expected = "attempt to subtract with overflow")]
fn handle_key_event_panic() {
let mut app = App::default();
let _ = app.handle_key_event(KeyCode::Left.into());
}
#[test]
fn handle_key_event_overflow() {
let mut app = App::default();
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert!(app.handle_key_event(KeyCode::Right.into()).is_ok());
assert_eq!(
app.handle_key_event(KeyCode::Right.into())
.unwrap_err()
.to_string(),
"counter overflow"
);
}
}
tui.rs (click to expand)
use std::io::{self, stdout, Stdout};
use ratatui::{
backend::CrosstermBackend,
crossterm::{
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
},
Terminal,
};
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
/// Initialize the terminal
pub fn init() -> io::Result<Tui> {
execute!(stdout(), EnterAlternateScreen)?;
enable_raw_mode()?;
set_panic_hook();
Terminal::new(CrosstermBackend::new(stdout()))
}
fn set_panic_hook() {
let hook = std::panic::take_hook();
std::panic::set_hook(Box::new(move |panic_info| {
let _ = restore(); // ignore any errors as we are already failing
hook(panic_info);
}));
}
/// Restore the terminal to its original state
pub fn restore() -> io::Result<()> {
execute!(stdout(), LeaveAlternateScreen)?;
disable_raw_mode()?;
Ok(())
}

パニックの処理

アプリケーションがパニックになったときに何が起こるかを確認するために実験します。このアプリケーションには、カウンターフィールドに u8 を使用する意図的なバグがありますが、これを下回ることを防ぐことはできません。アプリを実行して、矢印キーを押します。

panic demo

エラーが発生した場所に関する詳細情報を取得するには、コマンドの前に RUST_BACKTRACE=full を追加します。

panic-full demo

取り扱いエラー

結果として、アプリケーションが未処理のエラーを返したときに何が起こるかを確認します。このアプリは、カウンターが2を超えて増加したときにこれを発生させます。アプリを実行し、右矢印を3回押します。

error demo

エラーが発生した場所に関する詳細情報を取得するには、コマンドの前に RUST_BACKTRACE=full を追加します。

error-full demo