fix(app): Survive panics in background work and log them (#122)

* fix(app): Survive panics in background work and log them

A panic in background work (a spell check, an LSP handler) aborted the whole
app and left nothing behind: the message went to stderr, which a launched app
doesn't keep, and the stripped binary gave crash reports no function names.

- Release builds unwind, so a panic fails one task or command instead of
  quitting the IDE. Commands already turn a panicked blocking task into an
  error, and the existing catch_unwind around JSON-RPC notification handlers
  now takes effect.
- A panic hook appends the thread, location, message and backtrace to the
  log file synchronously, before the non-blocking writer could lose them.
- Release builds keep the symbol table (strip = "debuginfo"), so crash
  reports and logged backtraces name functions.

* test(app): Match the panic location with file!() so the test passes on Windows
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-10-28 14:44:46 +08:00
1 джерело 8ff8f22940
коміт 1f383c4e0e
2 files changed
+58 -2

No files matched your search

+4 -2
Переглянути файл
@@ -108,5 +108,7 @@ opt-level = 2
codegen-units = 1
lto = true
opt-level = 3
panic = "abort"
strip = true
# Unwind, so a panic fails one task or command instead of quitting the IDE.
panic = "unwind"
# Keep the symbol table: crash reports and logged backtraces name functions.
strip = "debuginfo"
+54
Переглянути файл
@@ -2,9 +2,13 @@
//! (`~/Library/Logs/<identifier>` on macOS, `<local data>/<identifier>/logs`
//! elsewhere — the same place as Tauri's `app_log_dir`).
use std::fmt::Write as _;
use std::io::Write as _;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tracing_appender::non_blocking::WorkerGuard;
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::time::{FormatTime, SystemTime};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
use tracing_subscriber::{EnvFilter, Layer};
@@ -40,12 +44,41 @@ pub fn init(identifier: &str) {
.ok()?;
let (writer, guard) = tracing_appender::non_blocking(appender);
let _ = GUARD.set(guard);
log_panics(dir.clone());
let _ = LOG_DIR.set(dir);
Some(tracing_subscriber::fmt::layer().with_ansi(false).with_writer(writer).with_filter(filter()))
});
let _ = tracing_subscriber::registry().with(stdout).with(file).try_init();
}
/// Panics go to the log file synchronously: the non-blocking writer may not
/// get to them before a panic that aborts ends the process.
fn log_panics(dir: PathBuf) {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = append(&dir, &panic_record(info));
default(info);
}));
}
fn panic_record(info: &std::panic::PanicHookInfo) -> String {
let mut record = String::new();
let _ = SystemTime.format_time(&mut Writer::new(&mut record));
let thread = std::thread::current();
let _ = write!(
record,
" ERROR panic: thread '{}' {info}\n{}\n",
thread.name().unwrap_or("<unnamed>"),
std::backtrace::Backtrace::force_capture()
);
record
}
fn append(dir: &Path, record: &str) -> std::io::Result<()> {
let path = latest_log(dir).unwrap_or_else(|| dir.join(format!("{PREFIX}.panic.{SUFFIX}")));
std::fs::OpenOptions::new().create(true).append(true).open(path)?.write_all(record.as_bytes())
}
pub fn log_dir() -> Option<&'static Path> {
LOG_DIR.get().map(PathBuf::as_path)
}
@@ -76,6 +109,27 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn panic_record_names_thread_location_and_message() {
let dir = std::env::temp_dir().join(format!("openidea-panic-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("openidea.2026-01-01.log"), "earlier\n").unwrap();
let record = std::sync::Arc::new(std::sync::Mutex::new(String::new()));
let sink = record.clone();
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| *sink.lock().unwrap() = panic_record(info)));
let _ = std::thread::Builder::new().name("worker".into()).spawn(|| panic!("boom")).unwrap().join();
std::panic::set_hook(default);
let record = record.lock().unwrap().clone();
let location = format!("ERROR panic: thread 'worker' panicked at {}:", file!());
assert!(record.contains(&location), "{record}");
assert!(record.contains("boom"), "{record}");
append(&dir, &record).unwrap();
let log = std::fs::read_to_string(dir.join("openidea.2026-01-01.log")).unwrap();
assert!(log.starts_with("earlier\n") && log.ends_with(&record));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn log_dir_is_per_identifier() {
let d = log_dir_for("com.example.x").unwrap();