パニック(クラッシュ)時の処理を書く

std::panic::set_hook で panic をログファイルと印のファイルに残し、次回起動時に知らせる。コマンド内の panic で JS の invoke がどうなるかと、panic = abort の影響も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約9分 rust-020
目次
  1. 前提条件
  2. 1. panic フックでログと印を残す (Rust)
  3. 2. コマンドの中の panic と JS 側の見え方 (Rust)
  4. 3. 次回起動時に知らせる (TypeScript)
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

unwrap() の失敗や配列の範囲外アクセスなど、Rust のコードが想定外の状態になると「パニック(panic)」が起きます。panic は Result の Err とは別物で、JS の catch では受け取れず、起きた場所によってはアプリがそのまま終了します。配布したアプリで「突然消えた」と報告されても手がかりが残るよう、std::panic::set_hook で panic をログファイルに記録し、次回の起動時に利用者へ知らせる仕組みを作ります。想定内の失敗を Err で返す方法は Result 型を使ってエラーハンドリングする を参照してください。

前提条件

ログファイルへの書き込みに Log プラグインを使います。ここでは Rust からだけ記録するので、src-tauri でクレートを追加するだけで足ります。log::error! などのマクロはプラグインが再公開している tauri_plugin_log::log から使えます。JS からもログを書くなら、Log プラグインでログファイルを出力する の手順で npm パッケージと権限 log:default も入れます。

cd src-tauri
cargo add tauri-plugin-log

1. panic フックでログと印を残す (Rust)

set_hook に渡した関数は、panic が起きた瞬間に、起きたスレッドの上で呼ばれます。ここでは文言・発生場所・スレッド名・バックトレースをログに書き、次回の起動時に知らせるための「印」のファイルも書きます。フックの中でダイアログを出すような重い処理はしません。メインスレッドで起きた panic ではウィンドウの処理自体が止まっているためです。

// src-tauri/src/lib.rs
use std::path::PathBuf;
use std::sync::OnceLock;
use tauri::Manager;
use tauri_plugin_log::log;

/// 前回の panic の内容を書いておくファイル(setup で場所を決める)
static CRASH_MARKER: OnceLock<PathBuf> = OnceLock::new();

fn install_panic_hook() {
    let default_hook = std::panic::take_hook(); // 標準の表示も残すために取っておく
    std::panic::set_hook(Box::new(move |info| {
        // panic の文言は &str か String で入っている
        let message = info
            .payload()
            .downcast_ref::<&str>()
            .map(|s| s.to_string())
            .or_else(|| info.payload().downcast_ref::<String>().cloned())
            .unwrap_or_else(|| "(文言なし)".to_string());
        let location = info
            .location()
            .map(|l| format!("{}:{}", l.file(), l.line()))
            .unwrap_or_default();
        let thread = std::thread::current().name().unwrap_or("名前なし").to_string();
        let backtrace = std::backtrace::Backtrace::force_capture();

        // 1. ログファイルに書く(ロガーの準備前なら何も起きない)
        log::error!("panic in thread '{thread}' at {location}: {message}\n{backtrace}");
        // 2. 次回起動時に知らせるための印を書く
        if let Some(path) = CRASH_MARKER.get() {
            let _ = std::fs::write(path, format!("{message}\n({location})"));
        }
        // 3. ターミナルへの標準の表示
        default_hook(info);
    }));
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    install_panic_hook(); // Builder より前に、できるだけ早く設定する
    tauri::Builder::default()
        .plugin(
            tauri_plugin_log::Builder::new()
                .max_file_size(1_000_000) // 既定は 40KB。バックトレースで溢れないよう広げる
                .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(3))
                .build(),
        )
        .setup(|app| {
            let dir = app.path().app_log_dir()?;
            std::fs::create_dir_all(&dir)?;
            let _ = CRASH_MARKER.set(dir.join("last-crash.txt"));
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            take_last_crash,
            sum_lines,
            #[cfg(debug_assertions)]
            crash_sync, // 試験用のコマンドは開発ビルドだけで登録する
            #[cfg(debug_assertions)]
            crash_async,
        ])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
  • ログの書き込みは、プラグインの初期化が済んでからです。それより前の panic は標準の表示(ターミナル)にしか出ません。
  • Log プラグインの既定では、ファイルが 40KB を超えると古い内容が消えます。バックトレースは数 KB になるので、上のように上限と残す数を広げておきます。ログの時刻は既定で UTC です。
  • set_hook を後から別の場所で呼ぶと、前のフックは置き換わります。上の例は take_hook で標準のフックを取り出し、最後に呼んでいます。

2. コマンドの中の panic と JS 側の見え方 (Rust)

panic がどこで起きたかで、アプリと invoke の結果が変わります。

起きた場所アプリJS の invoke
同期コマンド(fn)や setup などメインスレッドの処理多くの場合、アプリごと終了する結果は返らない
async コマンド(async fn)そのコマンドだけが止まり、アプリは動き続けるresolve も reject もされないまま残ることがある
spawn_blocking や async_runtime::spawn の中その処理だけが止まる待つ側が Err を受け取れるので、Result にして返せる

async コマンドの panic はアプリが動き続けるぶん気付きにくく、画面が「読み込み中」のまま止まって見えることがあります。外部のライブラリなど panic しうる処理は、spawn_blocking で切り離して Err に変えると、JS の catch で扱えるようになります。

/// 前回 panic していれば内容を返し、印を消す
#[tauri::command]
fn take_last_crash() -> Option<String> {
    let path = CRASH_MARKER.get()?;
    let text = std::fs::read_to_string(path).ok()?;
    let _ = std::fs::remove_file(path);
    Some(text)
}

/// panic しうる処理を切り離し、失敗を Err として JS に返す
#[tauri::command]
async fn sum_lines(text: String) -> Result<u64, String> {
    tauri::async_runtime::spawn_blocking(move || {
        // 数字でない行があると unwrap で panic する
        text.lines().map(|l| l.trim().parse::<u64>().unwrap()).sum::<u64>()
    })
    .await
    .map_err(|e| {
        log::error!("sum_lines: {e}");
        "入力を処理できませんでした".to_string()
    })
}

/// 試験用: 同期コマンドの panic(メインスレッドで起きる)
#[tauri::command]
fn crash_sync() {
    let items: Vec<u32> = Vec::new();
    println!("{}", items[0]); // 範囲外アクセスで panic
}

/// 試験用: async コマンドの panic
#[tauri::command]
async fn crash_async() -> Result<u16, String> {
    let port: u16 = "abc".parse().unwrap(); // Err なので panic
    Ok(port)
}

3. 次回起動時に知らせる (TypeScript)

起動時に take_last_crash を呼び、印があれば画面に知らせます。応答が返らない invoke に備えて、待ち時間の上限を付ける関数も用意しておきます(打ち切っても Rust 側の処理は止まりません)。

import { invoke, type InvokeArgs } from '@tauri-apps/api/core';

const lastCrash = await invoke<string | null>('take_last_crash');
if (lastCrash) {
  const notice = document.createElement('p');
  notice.textContent = `前回の起動中に内部エラーが起きました(${lastCrash.split('\n')[0]})。ログを添えてお知らせください。`;
  document.body.prepend(notice);
}

export function invokeWithTimeout<T>(cmd: string, ms: number, args?: InvokeArgs): Promise<T> {
  return Promise.race([
    invoke<T>(cmd, args),
    new Promise<T>((_, reject) => {
      setTimeout(() => reject(new Error(`${cmd}: ${ms}ms 以内に応答がありません`)), ms);
    }),
  ]);
}

動作確認

npm run tauri dev で起動し、ボタンなどから次の呼び出しを試します。

  1. invokeWithTimeout('crash_async', 5000) は 5 秒後に「応答がありません」で reject されますが、アプリは動き続けます。
  2. invokeWithTimeout('sum_lines', 5000, { text: '1\n2\nx' }) は「入力を処理できませんでした」で reject されます。
  3. invoke('crash_sync') を呼ぶと、多くの場合アプリが終了します。もう一度起動すると、画面の上に「index out of bounds: the len is 0 but the index is 0」を含む知らせが出ます。

1 のあと、ログファイルには次のような行が増えます(続けてバックトレース)。

[2026-09-12][03:15:42][my_app_lib][ERROR] panic in thread 'tokio-rt-worker' at src\lib.rs:98: called `Result::unwrap()` on an `Err` value: ParseIntError { kind: InvalidDigit }

よくあるエラーと対処法

  • ログファイルに panic が残っていない: ロガーの準備前の panic か、ファイルの上限を超えて消えたかのどちらかです。前者はターミナルの表示で確かめ、後者は 1 節のように上限を広げます。
  • 「thread panicked while processing panic. aborting.」: フックの中でさらに panic しています。フックの中では unwrap() を使わず、失敗しうる処理は let _ = や if let で受けます。
  • invoke がいつまでも終わらない: async コマンドの中で panic しています。ターミナルかログで場所を確かめ、Result を返す形に直すか、spawn_blocking で切り離します。
  • ほかのコマンドが「poisoned lock: another task failed inside」を含むエラーで失敗し始める: std::sync::Mutex のロック中に panic すると、その Mutex は以後のロックがエラーになります。ロック中に panic しうる処理を書かないようにし、中身を使い続けてよいなら lock().unwrap_or_else(|e| e.into_inner()) で取り出します(State と Mutex でアプリの状態を管理する)。

OS ごとの違いと注意点

  • Windows: リリースビルドはテンプレートの main.rs の設定でコンソールを持たないため、標準の panic の表示は見えません。ログファイル(%LOCALAPPDATA%\<識別子>\logs\アプリ名.log)が唯一の手がかりです。
  • macOS / Linux: ログは macOS では ~/Library/Logs/<識別子>/、Linux では ~/.local/share/<識別子>/logs/ に書かれます。アプリをターミナル以外から起動すると、標準の表示は目に触れません。
  • panic = "abort": サイズを減らすために Cargo.toml の [profile.release] に書いていると、フックは実行されますが、その直後にどこで起きた panic でもアプリが終了します。spawn_blocking での切り離しも効かなくなります(Rust バイナリを最適化して小さくする)。
  • リリースビルドのバックトレース: 特に strip = true でシンボルを削ると関数名がほとんど出なくなることがありますが、発生場所(ファイル名と行)は panic の情報に含まれるので残ります。
  • 失敗が JS にどう届くかの全体像は Rust 側で起きたエラーを JS でキャッチする を参照してください。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する