diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 62f68cd4..ae7c3c88 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -6258,6 +6258,7 @@ dependencies = [ "pipewire", "png 0.17.16", "raw-window-handle", + "regex", "reqwest 0.12.28", "resvg", "rtrb", @@ -6282,6 +6283,7 @@ dependencies = [ "tokio", "tokio-tungstenite", "tracing", + "tracing-appender", "tracing-subscriber", "ts-rs", "url", @@ -6418,6 +6420,12 @@ dependencies = [ "serde_json", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "symphonia" version = "0.6.0" @@ -7617,6 +7625,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 2d1474c1..31591a0a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -97,6 +97,8 @@ bytes = "1" cuid2 = "0.1" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } +tracing-appender = "0.2" +regex = "1" ts-rs = { version = "12", features = ["serde-compat"] } ndarray = "0.15" # CLAP plugin hosting. clack is git-only and pre-1.0; pin an exact rev because diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 780660a9..23d70f27 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -192,6 +192,12 @@ pub fn clear_logs() { crate::logs::clear(); } +/// Webview errors, so the session log on disk shows both sides of a crash. +#[tauri::command] +pub fn log_frontend(message: String) { + error!(target: "webview", "{message}"); +} + /// Dev-only: panics on the main thread to exercise the crash-persistence path /// (a real panic, not a faked event). Crashes the app on purpose. #[tauri::command] @@ -220,6 +226,14 @@ pub fn debug_unexpected_exit() { std::process::exit(86); } +/// Dev-only: blocks the main thread to exercise the hang watchdog. Kill the app +/// while it is stuck to get the next-launch hang report. +#[tauri::command] +pub fn debug_hang() { + #[cfg(debug_assertions)] + std::thread::sleep(Duration::from_secs(20)); +} + #[tauri::command] pub async fn close_plugin_editor(node_id: String) -> AppResult<()> { let id = node_id.clone(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e5e5ae7..ab4d2796 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,9 +4,10 @@ mod error; mod logs; mod native_crash; mod state; +mod watchdog; use std::path::PathBuf; -use std::sync::OnceLock; +use std::sync::{Mutex, OnceLock}; use serde_json::json; use state::AppState; @@ -25,6 +26,8 @@ static APP_HANDLE: OnceLock = OnceLock::new(); // next launch because a dying process cannot reliably notify the webview. static CRASH_FILE: OnceLock = OnceLock::new(); static SESSION_FILE: OnceLock = OnceLock::new(); +/// Tail of the log of a session that ended uncleanly, attached to its reports. +static PREVIOUS_LOGS: Mutex> = Mutex::new(None); fn append_report(path: &std::path::Path, payload: &serde_json::Value) { if let Ok(mut file) = std::fs::OpenOptions::new() @@ -37,21 +40,25 @@ fn append_report(path: &std::path::Path, payload: &serde_json::Value) { } } -fn initialize_crash_reporting(dir: &std::path::Path) { +fn initialize_crash_reporting(dir: &std::path::Path, home: Option<&std::path::Path>) { let crash_file = dir.join("crashes.jsonl"); let session_file = dir.join("running-session"); let crash_len = std::fs::metadata(&crash_file).map_or(0, |meta| meta.len()); + let previous_hang = watchdog::take_previous_hang(dir); if let Ok(baseline) = std::fs::read_to_string(&session_file) { + if let Ok(mut logs) = PREVIOUS_LOGS.lock() { + *logs = logs::previous_session_tail(dir, home); + } let baseline = baseline.trim().parse::().unwrap_or(0); if crash_len <= baseline { let ts = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|duration| duration.as_millis() as u64) .unwrap_or(0); - append_report( - &crash_file, - &json!({ + let report = match previous_hang { + Some(hang) => hang_report(&hang, ts), + None => json!({ "kind": "unexpectedExit", "message": "Splitwave did not shut down cleanly", "backtrace": "No native stack was captured. The process may have been killed by the OS, an out-of-memory condition, or a native crash without a dump.", @@ -59,7 +66,8 @@ fn initialize_crash_reporting(dir: &std::path::Path) { "version": env!("CARGO_PKG_VERSION"), "ts": ts, }), - ); + }; + append_report(&crash_file, &report); } } @@ -72,10 +80,37 @@ fn initialize_crash_reporting(dir: &std::path::Path) { } } +fn hang_report(hang: &serde_json::Value, ts: u64) -> serde_json::Value { + let seconds = hang["stalledMs"].as_u64().unwrap_or(0) / 1000; + let place = match hang["command"].as_str() { + Some(command) => format!("in the `{command}` command"), + None => "outside any IPC command".to_string(), + }; + json!({ + "kind": "hang", + "message": "Splitwave stopped responding and was closed", + "backtrace": format!("The main thread was blocked for at least {seconds} s {place} before the app was closed. A hang captures no stack; the session log shows what led up to it."), + "thread": "main", + "version": env!("CARGO_PKG_VERSION"), + "ts": hang["ts"].as_u64().unwrap_or(ts), + }) +} + +fn with_watchdog( + handler: impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, +) -> impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static { + move |invoke| { + let _command = watchdog::enter_command(invoke.message.command()); + handler(invoke) + } +} + fn mark_clean_exit() { if let Some(path) = SESSION_FILE.get() { let _ = std::fs::remove_file(path); } + watchdog::clear_marker(); + logs::flush_file_sink(); } #[cfg(target_os = "windows")] @@ -102,10 +137,17 @@ pub fn take_crash_reports() -> Vec { if let Some(session) = SESSION_FILE.get() { let _ = std::fs::write(session, "0"); } + let logs = PREVIOUS_LOGS.lock().ok().and_then(|mut l| l.take()); contents .lines() .filter(|l| !l.trim().is_empty()) - .filter_map(|l| serde_json::from_str(l).ok()) + .filter_map(|l| serde_json::from_str::(l).ok()) + .map(|mut report| { + if let (Some(logs), Some(fields)) = (&logs, report.as_object_mut()) { + fields.insert("logs".into(), json!(logs)); + } + report + }) .collect() } @@ -253,7 +295,10 @@ pub fn run() { if let Ok(dir) = handle.path().app_log_dir() { let _ = std::fs::create_dir_all(&dir); - initialize_crash_reporting(&dir); + logs::start_file_sink(&dir); + let home = handle.path().home_dir().ok(); + initialize_crash_reporting(&dir, home.as_deref()); + watchdog::start(handle.clone(), &dir); if let Some(file) = CRASH_FILE.get() { info!(path = %file.display(), "crash log"); } @@ -301,7 +346,7 @@ pub fn run() { .plugin(tauri_plugin_os::init()) .plugin(tauri_plugin_global_shortcut::Builder::new().build()) .manage(AppState::spawn()) - .invoke_handler(tauri::generate_handler![ + .invoke_handler(with_watchdog(tauri::generate_handler![ commands::scan_plugins, commands::open_plugin_editor, commands::close_plugin_editor, @@ -311,9 +356,11 @@ pub fn run() { commands::take_crash_reports, commands::get_logs, commands::clear_logs, + commands::log_frontend, commands::debug_panic, commands::debug_native_crash, commands::debug_unexpected_exit, + commands::debug_hang, commands::list_input_devices, commands::list_output_devices, commands::play_cue, @@ -363,7 +410,7 @@ pub fn run() { commands::net_sender_stats, commands::webrtc_set_identity, commands::webrtc_session_state, - ]) + ])) .build(tauri::generate_context!()) .expect("error while building tauri application") .run(|_, event| { diff --git a/src-tauri/src/logs.rs b/src-tauri/src/logs.rs index 2b2f1607..9a5ef1f4 100644 --- a/src-tauri/src/logs.rs +++ b/src-tauri/src/logs.rs @@ -1,15 +1,33 @@ use std::collections::VecDeque; use std::fmt::Write as _; -use std::sync::Mutex; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::path::Path; +use std::sync::{LazyLock, Mutex, OnceLock}; use std::time::{SystemTime, UNIX_EPOCH}; +use regex::{Captures, Regex}; use serde::Serialize; use tracing::field::{Field, Visit}; use tracing::{Event, Subscriber}; +use tracing_appender::non_blocking::{NonBlocking, NonBlockingBuilder, WorkerGuard}; use tracing_subscriber::layer::{Context, Layer}; const CAPACITY: usize = 2000; +const SESSION_LOG: &str = "session.log"; +const PREVIOUS_SESSION_LOG: &str = "previous-session.log"; +/// Past this the file is cut down to its newest `FILE_KEEP` bytes. +const FILE_CAP: u64 = 4 * 1024 * 1024; +const FILE_KEEP: u64 = 1024 * 1024; +/// Enough to cover what led up to a crash without bloating the report. +const TAIL_BYTES: u64 = 64 * 1024; +const TAIL_LINES: usize = 300; + +static FILE_WRITER: OnceLock = OnceLock::new(); +static FILE_GUARD: Mutex> = Mutex::new(None); + #[derive(Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct LogLine { @@ -56,6 +74,9 @@ impl Layer for RingLayer { message: visitor.message, }; + if let Some(writer) = FILE_WRITER.get() { + let _ = writer.clone().write_all(format_line(&line).as_bytes()); + } if let Ok(mut buffer) = BUFFER.lock() { if buffer.len() == CAPACITY { buffer.pop_front(); @@ -65,6 +86,192 @@ impl Layer for RingLayer { } } +fn format_line(line: &LogLine) -> String { + let day_ms = line.at % 86_400_000; + format!( + "{:02}:{:02}:{:02}.{:03}Z {:>5} {}: {}\n", + day_ms / 3_600_000, + day_ms / 60_000 % 60, + day_ms / 1000 % 60, + day_ms % 1000, + line.level, + line.target, + line.message + ) +} + +/// Moves the last session's log aside and starts mirroring this one to disk, +/// so whatever led up to a crash or a kill is still there on the next launch. +pub fn start_file_sink(dir: &Path) { + let path = dir.join(SESSION_LOG); + let _ = std::fs::rename(&path, dir.join(PREVIOUS_SESSION_LOG)); + let Ok(file) = OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&path) + else { + return; + }; + let (mut writer, guard) = NonBlockingBuilder::default() + .lossy(false) + .thread_name("log-file") + .finish(SessionFile { file, path }); + // Lines logged before the log dir was known only made it to the ring. + for line in snapshot() { + let _ = writer.write_all(format_line(&line).as_bytes()); + } + if FILE_WRITER.set(writer).is_ok() { + if let Ok(mut slot) = FILE_GUARD.lock() { + *slot = Some(guard); + } + } +} + +/// Flushes what is still queued for the file; call on a clean exit. +pub fn flush_file_sink() { + if let Ok(mut slot) = FILE_GUARD.lock() { + slot.take(); + } +} + +struct SessionFile { + file: File, + path: std::path::PathBuf, +} + +impl Write for SessionFile { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.file.write(buf) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.file.flush()?; + if self.file.metadata().map_or(0, |m| m.len()) > FILE_CAP { + if let Some(trimmed) = keep_tail(&self.path) { + self.file = trimmed; + } + } + Ok(()) + } +} + +fn keep_tail(path: &Path) -> Option { + let tail = read_tail(path, FILE_KEEP)?; + let mut file = OpenOptions::new() + .write(true) + .truncate(true) + .open(path) + .ok()?; + file.write_all(tail.as_bytes()).ok()?; + Some(file) +} + +/// The newest lines of the previous session's log, if it left one. +/// Redacted: the report may be pasted into a public issue. +pub fn previous_session_tail(dir: &Path, home: Option<&Path>) -> Option { + let tail = read_tail(&dir.join(PREVIOUS_SESSION_LOG), TAIL_BYTES)?; + let lines: Vec<&str> = tail.lines().collect(); + let start = lines.len().saturating_sub(TAIL_LINES); + let text = lines[start..].join("\n"); + (!text.is_empty()).then(|| redact(&text, home)) +} + +/// Hides the home directory (it carries the user name) and IP addresses. +fn redact(text: &str, home: Option<&Path>) -> String { + let mut text = text.to_string(); + if let Some(home) = home.and_then(Path::to_str).filter(|h| h.len() > 1) { + // `?path` fields log the Debug form, with backslashes doubled on Windows. + let escaped = home.replace('\\', "\\\\"); + text = text.replace(&escaped, "~").replace(home, "~"); + } + redact_ips(&text) +} + +fn redact_ips(text: &str) -> String { + static V4: LazyLock = + LazyLock::new(|| Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").unwrap()); + // Candidates only; std's parser decides what is an address, so clock + // times and `crate::path` targets stay untouched. + static V6: LazyLock = LazyLock::new(|| Regex::new(r"[\w:.]+").unwrap()); + let text = V4.replace_all(text, |caps: &Captures| match caps[0].parse::() { + Ok(_) => "".to_string(), + Err(_) => caps[0].to_string(), + }); + V6.replace_all(&text, |caps: &Captures| { + let token = &caps[0]; + // `::1` opens with colons of its own; a stray `:` around an address is + // punctuation. + for addr in [token.trim_end_matches('.'), token.trim_matches([':', '.'])] { + if addr.parse::().is_ok() { + return token.replacen(addr, "", 1); + } + } + token.to_string() + }) + .into_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn redacts_home_and_addresses() { + let home = Path::new("/Users/jane"); + let log = "16:02:48.787Z INFO splitwave_lib: crash log path=/Users/jane/Library/Logs/x\n\ + peer 192.168.1.20:5004 joined, candidate fe80::1c2b:3aff:fe4d:2 and 2001:db8::7\n\ + WARN splitwave_lib::audio::engine: v1.4.1 at 12:34:56"; + let out = redact(log, Some(home)); + assert!(out.contains("path=~/Library/Logs/x")); + assert!(out.contains("peer :5004 joined")); + assert!(out.contains("candidate and ")); + assert!(out.contains("splitwave_lib::audio::engine: v1.4.1 at 12:34:56")); + assert!(out.starts_with("16:02:48.787Z")); + } + + #[test] + fn redacts_addresses_next_to_colons() { + assert_eq!(redact_ips("ip:1.2.3.4"), "ip:"); + assert_eq!(redact_ips("to 1.2.3.4: refused"), "to : refused"); + assert_eq!(redact_ips("host:[::1]:80"), "host:[]:80"); + assert_eq!(redact_ips("addr fe80::1:"), "addr :"); + } + + #[test] + fn redacts_debug_escaped_home() { + let home = Path::new(r"C:\Users\jane"); + let out = redact( + r#"path="C:\\Users\\jane\\AppData" dir=C:\Users\jane\x"#, + Some(home), + ); + assert_eq!(out, r#"path="~\\AppData" dir=~\x"#); + } + + #[test] + fn leaves_non_addresses_alone() { + let line = "blocked_ms=20081 device \"MacBook Pro Speakers\" 48000 Hz 10.0.26100"; + assert_eq!(redact_ips(line), line); + } +} + +/// Last `max` bytes, starting at a line boundary. +fn read_tail(path: &Path, max: u64) -> Option { + let mut file = File::open(path).ok()?; + let len = file.metadata().ok()?.len(); + let from = len.saturating_sub(max); + file.seek(SeekFrom::Start(from)).ok()?; + let mut bytes = Vec::new(); + file.read_to_end(&mut bytes).ok()?; + let text = String::from_utf8_lossy(&bytes); + let text = if from > 0 { + text.split_once('\n').map_or("", |(_, rest)| rest) + } else { + &text + }; + Some(text.to_string()) +} + struct MessageVisitor { message: String, } diff --git a/src-tauri/src/watchdog.rs b/src-tauri/src/watchdog.rs new file mode 100644 index 00000000..ff34d070 --- /dev/null +++ b/src-tauri/src/watchdog.rs @@ -0,0 +1,119 @@ +//! Detects a blocked main thread. A "Not responding" window that the user or +//! the OS then kills leaves no panic and no signal, only an unclean exit. The +//! marker written here while the thread is stuck lets the next launch report a +//! hang and name the command it was stuck in. + +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::thread; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use serde_json::json; +use tauri::AppHandle; +use tracing::warn; + +const PROBE_EVERY: Duration = Duration::from_secs(1); +const HANG_AFTER: Duration = Duration::from_secs(5); +/// A probe that wakes this late slept through a system suspend; the main +/// thread was not blocked meanwhile. +const SUSPEND_GAP: Duration = Duration::from_secs(3); +const MARKER: &str = "hang.json"; + +static MARKER_PATH: OnceLock = OnceLock::new(); +static MAIN_COMMAND: Mutex> = Mutex::new(None); + +pub struct CommandGuard; + +impl Drop for CommandGuard { + fn drop(&mut self) { + if let Ok(mut current) = MAIN_COMMAND.lock() { + *current = None; + } + } +} + +/// Wraps every IPC dispatch. Sync commands run inline on the main thread, so +/// while one is stuck its name is what this holds. +pub fn enter_command(name: &str) -> CommandGuard { + if let Ok(mut current) = MAIN_COMMAND.lock() { + *current = Some(name.to_string()); + } + CommandGuard +} + +/// The hang the previous session was stuck in when it died, if any. +pub fn take_previous_hang(dir: &Path) -> Option { + let path = dir.join(MARKER); + let contents = std::fs::read_to_string(&path).ok(); + let _ = std::fs::remove_file(&path); + serde_json::from_str(&contents?).ok() +} + +pub fn clear_marker() { + if let Some(path) = MARKER_PATH.get() { + let _ = std::fs::remove_file(path); + } +} + +pub fn start(app: AppHandle, dir: &Path) { + let marker = dir.join(MARKER); + let _ = MARKER_PATH.set(marker.clone()); + let beat = Arc::new(Mutex::new(Instant::now())); + // One probe in flight at a time, so a stuck thread doesn't pile them up. + let pending = Arc::new(AtomicBool::new(false)); + let _ = thread::Builder::new() + .name("main-watchdog".into()) + .spawn(move || { + let mut last_tick = Instant::now(); + let mut worst: Option = None; + loop { + thread::sleep(PROBE_EVERY); + let now = Instant::now(); + if now.duration_since(last_tick) > PROBE_EVERY + SUSPEND_GAP { + *beat.lock().unwrap() = now; + } + last_tick = now; + + if !pending.swap(true, Ordering::AcqRel) { + let beat = beat.clone(); + let done = pending.clone(); + let posted = app.run_on_main_thread(move || { + *beat.lock().unwrap() = Instant::now(); + done.store(false, Ordering::Release); + }); + if posted.is_err() { + pending.store(false, Ordering::Release); + } + } + + let stalled = now.saturating_duration_since(*beat.lock().unwrap()); + if stalled >= HANG_AFTER { + let command = MAIN_COMMAND.lock().ok().and_then(|c| c.clone()); + if worst.is_none() { + warn!(?command, "main thread unresponsive"); + } + worst = Some(stalled); + write_marker(&marker, stalled, command.as_deref()); + } else if let Some(blocked) = worst.take() { + let _ = std::fs::remove_file(&marker); + warn!( + blocked_ms = blocked.as_millis() as u64, + "main thread responsive again" + ); + } + } + }); +} + +fn write_marker(path: &Path, stalled: Duration, command: Option<&str>) { + let ts = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |d| d.as_millis() as u64); + let payload = json!({ + "stalledMs": stalled.as_millis() as u64, + "command": command, + "ts": ts, + }); + let _ = std::fs::write(path, payload.to_string()); +} diff --git a/src/lib/modules/debug/ui/debug_panel.svelte b/src/lib/modules/debug/ui/debug_panel.svelte index 912615db..322af6b6 100644 --- a/src/lib/modules/debug/ui/debug_panel.svelte +++ b/src/lib/modules/debug/ui/debug_panel.svelte @@ -35,6 +35,10 @@ invoke('debug_unexpected_exit').catch(() => {}); } + function mainThreadHang() { + invoke('debug_hang').catch(() => {}); + } + function fakeJsError() { errorStore.report({ source: 'jsError', @@ -244,6 +248,7 @@ + diff --git a/src/lib/modules/error/init.ts b/src/lib/modules/error/init.ts index d8c6eb48..6e1fa7e5 100644 --- a/src/lib/modules/error/init.ts +++ b/src/lib/modules/error/init.ts @@ -5,17 +5,22 @@ import { errorStore } from './stores.svelte'; const PANIC_EVENT = 'error://panic'; interface CrashPayload { - kind?: 'rustPanic' | 'nativeCrash' | 'unexpectedExit'; + kind?: 'rustPanic' | 'nativeCrash' | 'unexpectedExit' | 'hang'; message: string; backtrace: string; thread: string; version: string; ts?: number; + logs?: string; } let installed = false; let unlistenPanic: UnlistenFn | undefined; +function logToBackend(message: string, stack?: string): void { + invoke('log_frontend', { message: stack ? `${message}\n${stack}` : message }).catch(() => {}); +} + export async function installErrorHandlers(): Promise { if (installed) return; installed = true; @@ -41,6 +46,7 @@ export async function installErrorHandlers(): Promise { stack: r.backtrace, thread: r.thread, at: r.ts ?? Date.now(), + logs: r.logs, previousRun: true }); } @@ -48,23 +54,18 @@ export async function installErrorHandlers(): Promise { window.addEventListener('error', (e) => { if (e.message?.startsWith('ResizeObserver loop')) return; - errorStore.report({ - source: 'jsError', - message: e.message || String(e.error), - stack: e.error instanceof Error ? e.error.stack : undefined, - at: Date.now() - }); + const message = e.message || String(e.error); + const stack = e.error instanceof Error ? e.error.stack : undefined; + logToBackend(message, stack); + errorStore.report({ source: 'jsError', message, stack, at: Date.now() }); }); window.addEventListener('unhandledrejection', (e) => { const reason = e.reason; const message = reason instanceof Error ? reason.message : typeof reason === 'string' ? reason : JSON.stringify(reason); - errorStore.report({ - source: 'unhandledRejection', - message, - stack: reason instanceof Error ? reason.stack : undefined, - at: Date.now() - }); + const stack = reason instanceof Error ? reason.stack : undefined; + logToBackend(`unhandled rejection: ${message}`, stack); + errorStore.report({ source: 'unhandledRejection', message, stack, at: Date.now() }); }); } diff --git a/src/lib/modules/error/stores.svelte.ts b/src/lib/modules/error/stores.svelte.ts index 6c18ab66..0fbf7095 100644 --- a/src/lib/modules/error/stores.svelte.ts +++ b/src/lib/modules/error/stores.svelte.ts @@ -1,4 +1,4 @@ -export type ErrorSource = 'rustPanic' | 'nativeCrash' | 'unexpectedExit' | 'jsError' | 'unhandledRejection'; +export type ErrorSource = 'rustPanic' | 'nativeCrash' | 'unexpectedExit' | 'hang' | 'jsError' | 'unhandledRejection'; export interface ErrorEntry { source: ErrorSource; @@ -6,6 +6,8 @@ export interface ErrorEntry { stack?: string; thread?: string; at: number; + /** Tail of the session log of the run that crashed. */ + logs?: string; /** True when replayed from a crash that killed a previous run, not live. */ previousRun?: boolean; } diff --git a/src/lib/modules/error/ui/error_modal.svelte b/src/lib/modules/error/ui/error_modal.svelte index e9c00bf3..36bc6171 100644 --- a/src/lib/modules/error/ui/error_modal.svelte +++ b/src/lib/modules/error/ui/error_modal.svelte @@ -7,6 +7,8 @@ import CopyButton from '$lib/components/copy_button.svelte'; const REPO = 'Horuse/Splitwave'; + // The whole log does not fit in an issue URL; Copy details carries all of it. + const URL_LOG_LINES = 60; let current = $derived(errorStore.current); @@ -14,7 +16,7 @@ if (current) loadAppInfo().catch(() => {}); }); - function buildDetails(): string { + function buildDetails(maxLogLines = Infinity): string { if (!current) return ''; const info = getCachedAppInfo(); const lines = [ @@ -32,6 +34,10 @@ if (current.stack) { lines.push('', '**Stack:**', '```', current.stack, '```'); } + if (current.logs) { + const log = current.logs.split('\n').slice(-maxLogLines).join('\n'); + lines.push('', '
Session log', '', '```', log, '```', '', '
'); + } lines.push( '', '## Steps to reproduce', @@ -54,7 +60,7 @@ if (!current) return; await loadAppInfo().catch(() => {}); const title = `[crash] ${current.message.split('\n')[0].slice(0, 80)}`; - const details = buildDetails(); + const details = buildDetails(URL_LOG_LINES); const url = `https://github.com/${REPO}/issues/new?template=crash.yml&title=${encodeURIComponent(title)}&details=${encodeURIComponent(details)}`; try { await openUrl(url); @@ -73,6 +79,8 @@ return 'Native crash'; case 'unexpectedExit': return 'Unexpected exit'; + case 'hang': + return 'Not responding'; case 'jsError': return 'JS error'; case 'unhandledRejection': @@ -125,11 +133,18 @@ class="mt-2 max-h-60 overflow-auto rounded-lg border border-neutral-300 bg-neutral-200 p-3 font-mono text-[10px] leading-relaxed break-words whitespace-pre-wrap text-neutral-1000">{current.stack} {/if} + {#if current.logs} +
+ Session log +
{current.logs}
+
+ {/if} {#snippet footer()} - + buildDetails()} label="Copy details" class="button-main primary gap-3 rounded-lg" /> {/snippet}