diff --git a/Cargo.lock b/Cargo.lock index c0fe995..9f7f7c2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4046,6 +4046,7 @@ dependencies = [ "egui-phosphor", "egui_extras", "image", + "log", "muda", "num-complex", "plotx-analysis", diff --git a/Cargo.toml b/Cargo.toml index bd61e39..5e477d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,7 @@ unicode-width = "0.2" statrs = { version = "0.18", default-features = false } tukey_test = "0.2" directories = "6" +log = "0.4" uuid = { version = "1", features = ["serde", "v4"] } arrow = { version = "58.3", default-features = false, features = ["ipc"] } datafusion = { version = "54.0", default-features = false } @@ -122,4 +123,6 @@ opt-level = 0 [profile.release] lto = "thin" codegen-units = 1 +debug = "line-tables-only" +split-debuginfo = "packed" strip = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a7b288d..7fc6aba 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -28,6 +28,7 @@ eframe.workspace = true egui_extras.workspace = true egui-phosphor.workspace = true image.workspace = true +log.workspace = true num-complex.workspace = true rfd.workspace = true serde_json.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index e33832d..a4c9a0b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -3,6 +3,7 @@ // Release Windows builds are GUI apps: suppress the console window. #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod observability; mod scale; mod shot; mod ui; @@ -45,6 +46,7 @@ struct Shell { app: PlotxApp, recovery: Option, pending_recovery: Option, + pending_crash_report: Option, recovery_job: Option>>, recovery_written: bool, next_recovery_at: Instant, @@ -59,6 +61,7 @@ struct Shell { impl eframe::App for Shell { fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { let ctx = ui.ctx().clone(); + observability::show_pending_crash_dialog(); self.scale.drive(&mut self.app, &ctx, frame); let recovery_blocked = self.pending_recovery.is_some(); #[cfg(target_os = "macos")] @@ -109,15 +112,16 @@ impl eframe::App for Shell { if let Some(job) = self.recovery_job.take() { match job.join() { Ok(Ok(())) => {} - Ok(Err(error)) => eprintln!("automatic recovery save failed on exit: {error}"), - Err(_) => eprintln!("automatic recovery worker panicked on exit"), + Ok(Err(error)) => log::error!("automatic recovery save failed on exit: {error}"), + Err(_) => log::error!("automatic recovery worker panicked on exit"), } } if let Some(recovery) = self.recovery.take() && let Err(error) = recovery.shutdown() { - eprintln!("failed to clear crash-recovery snapshot on clean exit: {error}"); + log::error!("failed to clear crash-recovery snapshot on clean exit: {error}"); } + log::logger().flush(); } } @@ -221,6 +225,9 @@ impl Shell { } else { ui.small("The recovered document had not been saved yet."); } + if let Some(path) = &self.pending_crash_report { + ui.small(format!("Crash report: {}", path.display())); + } ui.add_space(8.0); ui.horizontal(|ui| { if ui.button("Recover").clicked() { @@ -231,6 +238,9 @@ impl Shell { } }); }); + if (recover || discard) && self.pending_crash_report.take().is_some() { + observability::acknowledge_crash_report(); + } if recover { match plotx_core::project::restore_recovery(&snapshot) { @@ -333,6 +343,7 @@ fn main() -> eframe::Result<()> { if let Some(code) = plotx_core::update::run_helper_from_args() { std::process::exit(code); } + observability::initialize(); plotx_core::update::cleanup_after_restart(); let shot_active = std::env::var_os("PLOTX_SHOT").is_some(); let settings = plotx_core::settings::load(); @@ -412,11 +423,31 @@ fn main() -> eframe::Result<()> { }, None => None, }; + let mut pending_crash_report = observability::pending_crash_report(); + let crash_notice = if pending_recovery.is_none() { + pending_crash_report.take().map(|path| { + observability::acknowledge_crash_report(); + format!( + "PlotX did not shut down cleanly last time. A crash report was saved to {}.", + path.display() + ) + }) + } else { + None + }; if updated { app.session.status = format!("Updated to PlotX {}.", env!("CARGO_PKG_VERSION")); // Stamp the new version so the notice shows only once. plotx_core::settings::update(|_| {}); } + if let Some(notice) = crash_notice { + if updated { + app.session.status.push(' '); + app.session.status.push_str(¬ice); + } else { + app.session.status = notice; + } + } #[cfg(target_os = "macos")] let native_menu = ui::native_menu::NativeMenu::new(&app, &cc.egui_ctx).map_err(|error| { @@ -426,6 +457,7 @@ fn main() -> eframe::Result<()> { app, recovery, pending_recovery, + pending_crash_report, recovery_job: None, recovery_written: false, next_recovery_at: Instant::now() + RECOVERY_INTERVAL, @@ -441,14 +473,16 @@ fn main() -> eframe::Result<()> { }), )?; if let Some(error) = SHOT_FAILURE.lock().unwrap().take() { - eprintln!("screenshot harness failed: {error}"); + log::error!("screenshot harness failed: {error}"); + log::logger().flush(); std::process::exit(1); } if let Some(plan) = PENDING_INSTALL.lock().unwrap().take() && let Err(error) = plan.launch(RELAUNCH_REQUESTED.load(Ordering::Relaxed)) { - eprintln!("failed to launch update helper: {error}"); + log::error!("failed to launch update helper: {error}"); } + log::logger().flush(); Ok(()) } diff --git a/crates/app/src/observability.rs b/crates/app/src/observability.rs new file mode 100644 index 0000000..f584f61 --- /dev/null +++ b/crates/app/src/observability.rs @@ -0,0 +1,346 @@ +use log::{LevelFilter, Log, Metadata, Record}; +use std::backtrace::Backtrace; +use std::fs::{File, OpenOptions}; +use std::io::{self, BufWriter, Read, Write}; +use std::panic::PanicHookInfo; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; + +const LOG_KEEP_COUNT: usize = 5; +const CRASH_KEEP_COUNT: usize = 10; +const LOG_TAIL_LINES: usize = 200; +const CRASH_MARKER: &str = "latest"; +static LOGGER: SessionLogger = SessionLogger { + state: Mutex::new(None), +}; +static HANDLING_PANIC: AtomicBool = AtomicBool::new(false); +static MAIN_THREAD: OnceLock = OnceLock::new(); +static PENDING_DIALOG: Mutex> = Mutex::new(None); + +struct LoggerState { + file: BufWriter, + path: PathBuf, +} + +struct SessionLogger { + state: Mutex>, +} + +impl Log for SessionLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + metadata.level() <= log::Level::Info && metadata.target().starts_with("plotx") + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let line = format!( + "{} {:<5} [{}] {}\n", + utc_timestamp(SystemTime::now()), + record.level(), + record.target(), + record.args() + ); + let mut wrote_to_file = false; + if let Ok(mut state) = self.state.lock() + && let Some(state) = state.as_mut() + { + wrote_to_file = state.file.write_all(line.as_bytes()).is_ok(); + } + if cfg!(debug_assertions) || !wrote_to_file { + let _ = std::io::stderr().lock().write_all(line.as_bytes()); + } + } + + fn flush(&self) { + if let Ok(mut state) = self.state.lock() + && let Some(state) = state.as_mut() + { + let _ = state.file.flush(); + } + } +} + +pub(crate) fn initialize() { + let _ = MAIN_THREAD.set(std::thread::current().id()); + let logger_installed = log::set_logger(&LOGGER).map(|()| log::set_max_level(LevelFilter::Info)); + let Some(root) = plotx_core::settings::data_local_dir() else { + install_panic_hook(None); + if logger_installed.is_ok() { + log::warn!("PlotX application data directory is unavailable; using stderr only"); + } + return; + }; + if let Err(error) = initialize_logger(&root) + && logger_installed.is_ok() + { + log::warn!("could not initialize the session log; using stderr only: {error}"); + } + install_panic_hook(Some(root)); + log::info!("PlotX {} session started", env!("CARGO_PKG_VERSION")); + log::logger().flush(); +} + +fn initialize_logger(root: &Path) -> io::Result<()> { + let logs = root.join("logs"); + std::fs::create_dir_all(&logs)?; + let path = unique_file_path(&logs, "session", "log"); + let file = OpenOptions::new() + .create_new(true) + .append(true) + .open(&path)?; + let mut state = LOGGER + .state + .lock() + .map_err(|_| io::Error::other("session logger lock is poisoned"))?; + *state = Some(LoggerState { + file: BufWriter::new(file), + path, + }); + drop(state); + if let Err(error) = rotate_files(&logs, "session-", LOG_KEEP_COUNT) { + log::warn!("could not rotate session logs: {error}"); + } + Ok(()) +} + +fn install_panic_hook(root: Option) { + let previous = std::panic::take_hook(); + std::panic::set_hook(Box::new(move |info| { + previous(info); + log_panic_summary(info); + if HANDLING_PANIC.swap(true, Ordering::SeqCst) { + return; + } + let report_path = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + handle_panic(root.as_deref(), info) + })) + .ok() + .flatten(); + HANDLING_PANIC.store(false, Ordering::SeqCst); + if let Some(path) = report_path { + if is_main_thread() { + show_crash_dialog(&path); + } else if let Ok(mut pending) = PENDING_DIALOG.lock() { + *pending = Some(path); + } + } + })); +} + +fn log_panic_summary(info: &PanicHookInfo<'_>) { + let message = panic_message(info); + let location = info + .location() + .map(|location| format!("{}:{}", location.file(), location.line())) + .unwrap_or_else(|| "unknown".to_owned()); + let thread = std::thread::current() + .name() + .unwrap_or("") + .to_owned(); + log::error!("panic in thread {thread} at {location}: {message}"); +} + +fn handle_panic(root: Option<&Path>, info: &PanicHookInfo<'_>) -> Option { + let root = root?; + let message = panic_message(info); + let location = info + .location() + .map(|location| format!("{}:{}", location.file(), location.line())) + .unwrap_or_else(|| "unknown".to_owned()); + let thread = std::thread::current() + .name() + .unwrap_or("") + .to_owned(); + let backtrace = Backtrace::force_capture(); + let report = format!( + "PlotX crash report\n\ + Version: {}\n\ + OS: {}\n\ + Architecture: {}\n\ + Timestamp (UTC): {}\n\ + Thread: {thread}\n\ + Location: {location}\n\ + Panic: {message}\n\n\ + Backtrace:\n{backtrace}\n\n\ + Session log tail (last {LOG_TAIL_LINES} lines):\n{}\n", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH, + utc_timestamp(SystemTime::now()), + session_log_tail(LOG_TAIL_LINES), + ); + let path = write_crash_report(root, &report).ok()?; + log::error!("crash report saved to {}", path.display()); + Some(path) +} + +fn is_main_thread() -> bool { + MAIN_THREAD + .get() + .is_some_and(|main| *main == std::thread::current().id()) +} + +fn show_crash_dialog(path: &Path) { + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title("PlotX internal error") + .set_description(format!( + "PlotX encountered an internal error.\n\nA crash report was saved to:\n{}\n\nPlease attach this file to an issue at https://github.com/nmrtist/plotx/issues.", + path.display() + )) + .show(); +} + +pub(crate) fn show_pending_crash_dialog() { + let path = PENDING_DIALOG + .lock() + .ok() + .and_then(|mut pending| pending.take()); + if let Some(path) = path { + let _ = std::panic::catch_unwind(|| show_crash_dialog(&path)); + } +} + +fn panic_message(info: &PanicHookInfo<'_>) -> String { + info.payload() + .downcast_ref::<&str>() + .map(|message| (*message).to_owned()) + .or_else(|| info.payload().downcast_ref::().cloned()) + .unwrap_or_else(|| "".to_owned()) +} + +fn write_crash_report(root: &Path, report: &str) -> io::Result { + let crashes = root.join("crashes"); + std::fs::create_dir_all(&crashes)?; + let path = unique_file_path(&crashes, "crash", "txt"); + std::fs::write(&path, report)?; + let _ = std::fs::write( + crashes.join(CRASH_MARKER), + path.as_os_str().as_encoded_bytes(), + ); + let _ = rotate_files(&crashes, "crash-", CRASH_KEEP_COUNT); + Ok(path) +} + +pub(crate) fn pending_crash_report() -> Option { + let root = plotx_core::settings::data_local_dir()?; + read_marker(&root) +} + +pub(crate) fn acknowledge_crash_report() { + let Some(root) = plotx_core::settings::data_local_dir() else { + return; + }; + if let Err(error) = clear_marker(&root) { + log::warn!("failed to clear acknowledged crash report marker: {error}"); + } +} + +fn read_marker(root: &Path) -> Option { + let bytes = std::fs::read(root.join("crashes").join(CRASH_MARKER)).ok()?; + let path = PathBuf::from(String::from_utf8_lossy(&bytes).trim()); + path.is_file().then_some(path) +} + +fn clear_marker(root: &Path) -> io::Result<()> { + match std::fs::remove_file(root.join("crashes").join(CRASH_MARKER)) { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error), + } +} + +fn session_log_tail(line_count: usize) -> String { + let path = LOGGER.state.lock().ok().and_then(|mut state| { + state.as_mut().map(|state| { + let _ = state.file.flush(); + state.path.clone() + }) + }); + path.and_then(|path| read_tail(&path, line_count).ok()) + .unwrap_or_else(|| "".to_owned()) +} + +fn read_tail(path: &Path, line_count: usize) -> io::Result { + let mut text = String::new(); + File::open(path)?.read_to_string(&mut text)?; + let lines = text.lines().collect::>(); + Ok(lines[lines.len().saturating_sub(line_count)..].join("\n")) +} + +fn rotate_files(dir: &Path, prefix: &str, keep: usize) -> io::Result<()> { + let mut files = std::fs::read_dir(dir)? + .filter_map(Result::ok) + .filter(|entry| { + entry.file_type().is_ok_and(|kind| kind.is_file()) + && entry.file_name().to_string_lossy().starts_with(prefix) + }) + .collect::>(); + files.sort_by_key(|entry| { + entry + .metadata() + .and_then(|metadata| metadata.modified()) + .unwrap_or(UNIX_EPOCH) + }); + let remove_count = files.len().saturating_sub(keep); + for entry in files.into_iter().take(remove_count) { + std::fs::remove_file(entry.path())?; + } + Ok(()) +} + +fn unique_file_path(dir: &Path, prefix: &str, extension: &str) -> PathBuf { + let seconds = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + for sequence in 0_u32.. { + let path = dir.join(format!( + "{prefix}-{seconds}-{}-{sequence}.{extension}", + std::process::id() + )); + if !path.exists() { + return path; + } + } + unreachable!("the filename sequence is unbounded") +} + +fn utc_timestamp(time: SystemTime) -> String { + let seconds = time + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let days = (seconds / 86_400) as i64; + let day_seconds = seconds % 86_400; + let (year, month, day) = civil_date(days); + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + day_seconds / 3_600, + day_seconds % 3_600 / 60, + day_seconds % 60 + ) +} + +fn civil_date(days_since_epoch: i64) -> (i64, i64, i64) { + let z = days_since_epoch + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let day_of_era = z - era * 146_097; + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let mut year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let month_piece = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * month_piece + 2) / 5 + 1; + let month = month_piece + if month_piece < 10 { 3 } else { -9 }; + year += i64::from(month <= 2); + (year, month, day) +} + +#[cfg(test)] +#[path = "observability_tests.rs"] +mod tests; diff --git a/crates/app/src/observability_tests.rs b/crates/app/src/observability_tests.rs new file mode 100644 index 0000000..f497db8 --- /dev/null +++ b/crates/app/src/observability_tests.rs @@ -0,0 +1,89 @@ +use super::*; +use std::time::Duration; + +fn temp_root(name: &str) -> PathBuf { + let root = std::env::temp_dir().join(format!( + "plotx-{name}-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::create_dir_all(&root).unwrap(); + root +} + +#[test] +fn report_creation_writes_marker_and_expected_content() { + let root = temp_root("crash-report"); + let report = "PlotX crash report\nPanic: test panic\nBacktrace:\nframes"; + let path = write_crash_report(&root, report).unwrap(); + + assert_eq!(std::fs::read_to_string(&path).unwrap(), report); + assert_eq!(read_marker(&root), Some(path)); + clear_marker(&root).unwrap(); + assert_eq!(read_marker(&root), None); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn every_report_writes_unclean_shutdown_marker() { + let root = temp_root("background-report"); + let path = write_crash_report(&root, "background panic").unwrap(); + + assert!(path.is_file()); + assert_eq!(read_marker(&root), Some(path)); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn crash_rotation_keeps_ten_newest_reports() { + let root = temp_root("crash-rotation"); + let crashes = root.join("crashes"); + std::fs::create_dir_all(&crashes).unwrap(); + for index in 0..12 { + std::fs::write( + crashes.join(format!("crash-{index:02}.txt")), + index.to_string(), + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2)); + } + + rotate_files(&crashes, "crash-", 10).unwrap(); + let names = std::fs::read_dir(&crashes) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(names.len(), 10); + assert!(!names.contains(&"crash-00.txt".to_owned())); + assert!(!names.contains(&"crash-01.txt".to_owned())); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn log_rotation_keeps_five_newest_sessions() { + let root = temp_root("log-rotation"); + for index in 0..7 { + std::fs::write( + root.join(format!("session-{index:02}.log")), + index.to_string(), + ) + .unwrap(); + std::thread::sleep(Duration::from_millis(2)); + } + rotate_files(&root, "session-", 5).unwrap(); + assert_eq!(std::fs::read_dir(&root).unwrap().count(), 5); + std::fs::remove_dir_all(root).unwrap(); +} + +#[test] +fn tail_and_utc_format_are_stable() { + let root = temp_root("tail"); + let path = root.join("session.log"); + std::fs::write(&path, "one\ntwo\nthree\nfour\n").unwrap(); + assert_eq!(read_tail(&path, 2).unwrap(), "three\nfour"); + assert_eq!(utc_timestamp(UNIX_EPOCH), "1970-01-01T00:00:00Z"); + std::fs::remove_dir_all(root).unwrap(); +} diff --git a/crates/core/src/project/persistence.rs b/crates/core/src/project/persistence.rs index d3b5c04..a446dd8 100644 --- a/crates/core/src/project/persistence.rs +++ b/crates/core/src/project/persistence.rs @@ -1,5 +1,4 @@ use super::{FileStamp, Manifest, ProjectError, RecoveryMetadata, Result, temporary_path}; -use directories::ProjectDirs; use std::collections::HashMap; use std::fs::{File, OpenOptions}; use std::io; @@ -319,8 +318,8 @@ fn conceal_backup(_path: &Path) -> io::Result<()> { } fn recovery_root() -> Result { - let root = ProjectDirs::from("", "", "plotx") - .map(|dirs| dirs.data_local_dir().join("recovery")) + let root = crate::settings::data_local_dir() + .map(|root| root.join("recovery")) .ok_or_else(|| ProjectError::Invalid("recovery directory is unavailable".to_owned()))?; std::fs::create_dir_all(&root)?; Ok(root) diff --git a/crates/core/src/settings/mod.rs b/crates/core/src/settings/mod.rs index 711d3ab..e281ad3 100644 --- a/crates/core/src/settings/mod.rs +++ b/crates/core/src/settings/mod.rs @@ -5,7 +5,7 @@ mod paths; pub use io::{load, save}; pub use model::*; -pub use paths::config_dir; +pub use paths::{config_dir, data_local_dir}; pub const SETTINGS_SCHEMA_VERSION: u32 = 1; diff --git a/crates/core/src/settings/paths.rs b/crates/core/src/settings/paths.rs index aeb739e..3cf0b3a 100644 --- a/crates/core/src/settings/paths.rs +++ b/crates/core/src/settings/paths.rs @@ -5,6 +5,10 @@ pub fn config_dir() -> Option { ProjectDirs::from("", "", "plotx").map(|dirs| dirs.config_dir().to_path_buf()) } +pub fn data_local_dir() -> Option { + ProjectDirs::from("", "", "plotx").map(|dirs| dirs.data_local_dir().to_path_buf()) +} + pub fn settings_file() -> Option { config_dir().map(|dir| dir.join("settings.json")) } diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 6c8d307..181ee20 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -89,6 +89,7 @@ export default defineConfig({ { slug: 'reference/file-formats' }, { slug: 'reference/updates' }, { slug: 'reference/troubleshooting' }, + { slug: 'reference/reporting-problems' }, ], }, ], diff --git a/docs/src/content/docs/reference/reporting-problems.md b/docs/src/content/docs/reference/reporting-problems.md new file mode 100644 index 0000000..2416a21 --- /dev/null +++ b/docs/src/content/docs/reference/reporting-problems.md @@ -0,0 +1,29 @@ +--- +title: Reporting problems +description: Find the diagnostic files to attach when reporting a PlotX crash or runtime problem. +--- + +## After a crash + +When PlotX catches an internal error, it saves a plain-text crash report and +shows its full path. On the next launch, the recovery dialog or status bar +shows that path again. Attach the report to a +[GitHub issue](https://github.com/nmrtist/plotx/issues); it contains the PlotX +version, platform, panic location, backtrace, and the tail of the session log. + +Crash reports are stored in the `crashes` subdirectory of PlotX's application +data directory. PlotX keeps the 10 most recent reports: + +- Windows: `%LOCALAPPDATA%\plotx\data\crashes` +- macOS: `~/Library/Application Support/plotx/crashes` +- Linux: `$XDG_DATA_HOME/plotx/crashes`, or `~/.local/share/plotx/crashes` + +## Logs for other problems + +Each launch creates a log in the adjacent `logs` subdirectory. PlotX keeps the +five most recent session logs. For a problem that does not crash the +application, attach the log from the affected session and describe what you +were doing. Include a sample project or input file only when it does not +contain sensitive data. + +PlotX does not upload reports or logs automatically. diff --git a/docs/src/content/docs/zh-cn/reference/reporting-problems.md b/docs/src/content/docs/zh-cn/reference/reporting-problems.md new file mode 100644 index 0000000..869e494 --- /dev/null +++ b/docs/src/content/docs/zh-cn/reference/reporting-problems.md @@ -0,0 +1,27 @@ +--- +title: 报告问题 +description: 报告 PlotX 崩溃或运行问题时,找到需要附上的诊断文件。 +--- + +## 崩溃之后 + +PlotX 捕获到内部错误时,会保存纯文本崩溃报告并显示完整路径。下次启动时, +恢复对话框或状态栏会再次显示该路径。请把报告附到 +[GitHub issue](https://github.com/nmrtist/plotx/issues);其中包含 PlotX +版本、平台、panic 位置、回溯以及会话日志末尾。 + +崩溃报告位于 PlotX 应用数据目录的 `crashes` 子目录中。PlotX 保留最近 +10 份报告: + +- Windows:`%LOCALAPPDATA%\plotx\data\crashes` +- macOS:`~/Library/Application Support/plotx/crashes` +- Linux:`$XDG_DATA_HOME/plotx/crashes`,未设置时为 + `~/.local/share/plotx/crashes` + +## 其他问题的日志 + +每次启动都会在相邻的 `logs` 子目录中新建日志。PlotX 保留最近五次会话 +日志。若问题没有导致崩溃,请附上发生问题那次会话的日志,并说明当时的 +操作。只有在项目或输入文件不含敏感数据时才附上它们。 + +PlotX 不会自动上传报告或日志。