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 で起動し、ボタンなどから次の呼び出しを試します。
invokeWithTimeout('crash_async', 5000)は 5 秒後に「応答がありません」で reject されますが、アプリは動き続けます。invokeWithTimeout('sum_lines', 5000, { text: '1\n2\nx' })は「入力を処理できませんでした」で reject されます。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 でキャッチする を参照してください。
