diff --git a/apps/effectcraft/src/crash_report.rs b/apps/effectcraft/src/crash_report.rs new file mode 100644 index 000000000..1dd25c571 --- /dev/null +++ b/apps/effectcraft/src/crash_report.rs @@ -0,0 +1,490 @@ +//! A small, separate process reports an unexpected desktop exit (#3357). +//! +//! Allocation failure can abort without a panic hook. The GUI therefore owns a pipe to a +//! helper which never loads a project, font database or GPU. Only an explicit marker after +//! normal teardown suppresses the report. EOF alone identifies an unexpected exit, not its +//! cause. System-wide exhaustion can still prevent the helper or its native dialog working. + +use std::ffi::{OsStr, OsString}; +use std::fs::{File, OpenOptions}; +use std::io::{self, Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +const FLAG: &str = "--internal-crash-reporter"; +const CLEAN: &[u8] = b"EFFECTCRAFT_CLEAN_EXIT_V1\n"; +const LOG_BYTES: usize = 64 * 1024; +const HEADER_BYTES: usize = 128; + +/// Owned only by the GUI, never handed to another child. Dropping it without `finish` closes +/// the pipe and reports an unexpected exit; there is intentionally no clean-exit Drop hook. +pub struct Reporter { + pipe: ChildStdin, + child: Child, +} + +impl Reporter { + /// Call only after application teardown, including handled startup/device-loss errors. + pub fn finish(mut self) -> io::Result<()> { + let sent = self.pipe.write_all(CLEAN).and_then(|()| self.pipe.flush()); + drop(self.pipe); + // Do not let a suspended/broken helper hang shutdown. Reap it before the GUI + // exits where possible, so Windows updates do not race an old executable. + let cleanup = finish_child(&mut self.child, Duration::from_secs(2)); + match (sent, cleanup) { + (Err(sent), Err(cleanup)) => Err(io::Error::other(format!("clean marker failed: {sent}; cleanup failed: {cleanup}"))), + (Err(error), _) | (_, Err(error)) => Err(error), + (Ok(()), Ok(())) => Ok(()), + } + } +} + +fn finish_child(child: &mut Child, grace: Duration) -> io::Result<()> { + let start = Instant::now(); + while start.elapsed() < grace { + if let Some(status) = child.try_wait()? { + return if status.success() { Ok(()) } else { Err(io::Error::other(format!("crash reporter exited with {status}"))) }; + } + std::thread::sleep(Duration::from_millis(20)); + } + child.kill()?; + // Even after termination, do not introduce an unbounded wait on a stuck OS process. + let start = Instant::now(); + while start.elapsed() < Duration::from_secs(1) { + if child.try_wait()?.is_some() { + return Err(io::Error::other("crash reporter did not finish promptly and was terminated")); + } + std::thread::sleep(Duration::from_millis(20)); + } + Err(io::Error::other("crash reporter termination could not be confirmed")) +} + +/// Each process/run has its own report, including simultaneous launches. +pub fn run_id() -> String { + let nanos = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos(); + format!("{}-{nanos}", std::process::id()) +} + +pub fn session_marker(id: &str) -> String { + format!("crash-report session {id}") +} + +/// Start before heavy work. A missing helper is a warning, never a reason to refuse the app. +pub fn start(id: &str, log: Option<&Path>, reports: &Path, interactive: bool) -> io::Result { + let exe = std::env::current_exe()?; + let args = [ + OsString::from(FLAG), + OsString::from(id), + log.map_or_else(OsString::new, |p| p.as_os_str().to_owned()), + reports.as_os_str().to_owned(), + OsString::from(if interactive { "1" } else { "0" }), + ]; + spawn(&exe, &args) +} + +fn spawn(exe: &Path, args: &[OsString]) -> io::Result { + let mut child = Command::new(exe).args(args).stdin(Stdio::piped()).stdout(Stdio::null()).spawn()?; + match child.stdin.take() { + Some(pipe) => Ok(Reporter { pipe, child }), + None => { + let _ = child.kill(); + let _ = child.wait(); + Err(io::Error::other("crash reporter has no control pipe")) + } + } +} + +/// Handle the internal mode before logging, argument parsing, updates or GUI initialization. +/// The callback is a native dialog on this helper's main thread, not on the failed GUI's. +pub fn maybe_run(args: &[OsString], show: impl FnOnce(&str)) -> bool { + if args.first().map(OsString::as_os_str) != Some(OsStr::new(FLAG)) { + return false; + } + let parsed = match args { + [_, id, log, reports, interactive] => id + .to_str() + .filter(|id| valid_id(id)) + .zip(match interactive.to_str() { + Some("0") => Some(false), + Some("1") => Some(true), + _ => None, + }) + .map(|(id, interactive)| (id, (!log.is_empty()).then(|| Path::new(log)), Path::new(reports), interactive)), + _ => None, + }; + let Some((id, log, reports, interactive)) = parsed else { + eprintln!("EffectCraft crash reporter: invalid internal arguments"); + return true; + }; + // Keep the exact file open while the GUI is alive: a later rotation must not substitute + // another instance's log. If rotation won the startup race, the session marker rejects it. + let mut log = SessionLog::open(log, id); + match clean_exit(&mut io::stdin().lock()) { + Ok(true) => {} + result => { + let failure = result.err().map(|e| e.to_string()); + let saved = write_report(reports, id, &mut log, failure.as_deref()); + let description = description(&saved); + eprintln!("{description}"); + if interactive { + show(&description); + } + } + } + true +} + +fn valid_id(id: &str) -> bool { + !id.is_empty() && id.len() <= 80 && id.bytes().all(|c| c.is_ascii_digit() || c == b'-') +} + +/// Fixed-size comparison: arbitrary/truncated input never grows a buffer or counts as clean. +fn clean_exit(input: &mut impl Read) -> io::Result { + let mut marker = [0u8; CLEAN.len()]; + let mut filled = 0; + while filled < marker.len() { + let remaining = marker.get_mut(filled..).ok_or_else(|| io::Error::other("invalid control marker offset"))?; + match input.read(remaining) { + Ok(0) => return Ok(false), + Ok(n) => filled += n, + Err(e) if e.kind() == io::ErrorKind::Interrupted => {} + Err(e) => return Err(e), + } + } + Ok(marker == CLEAN) +} + +enum SessionLog { + Captured(File), + Unavailable(String), +} + +impl SessionLog { + fn open(path: Option<&Path>, id: &str) -> Self { + let opened = (|| { + let path = path.ok_or_else(|| io::Error::other("no session log was opened"))?; + let mut file = File::open(path)?; + let mut header = [0u8; HEADER_BYTES]; + let marker = format!("{}\n", session_marker(id)); + let prefix = header.get_mut(..marker.len()).ok_or_else(|| io::Error::other("session marker too long"))?; + file.read_exact(prefix)?; + if prefix != marker.as_bytes() { + return Err(io::Error::other("matching session marker unavailable; the log may have rotated")); + } + Ok(file) + })(); + match opened { + Ok(file) => Self::Captured(file), + Err(e) => Self::Unavailable(e.to_string()), + } + } + + fn write_tail(&mut self, report: &mut File) -> io::Result<()> { + match self { + Self::Unavailable(reason) => writeln!(report, "Recent log unavailable: {reason}"), + Self::Captured(file) => { + let size = file.metadata()?.len(); + file.seek(SeekFrom::Start(size.saturating_sub(LOG_BYTES as u64)))?; + writeln!(report, "\nRecorded session log tail (last {LOG_BYTES} bytes at most; logging filters/size caps may omit later events):")?; + io::copy(&mut file.take(LOG_BYTES as u64), report)?; + Ok(()) + } + } + } +} + +fn write_report(reports: &Path, id: &str, log: &mut SessionLog, pipe_error: Option<&str>) -> io::Result { + if !valid_id(id) { + return Err(io::Error::other("invalid report identity")); + } + std::fs::create_dir_all(reports)?; + let path = reports.join(format!("crash-{id}.txt")); + let mut report = OpenOptions::new().write(true).create_new(true).open(&path)?; + writeln!( + report, + "EffectCraft unexpected-exit diagnostic\nVersion: {}\nPlatform: {} / {}\nSession: {id}", + env!("CARGO_PKG_VERSION"), + std::env::consts::OS, + std::env::consts::ARCH + )?; + writeln!( + report, + "The GUI's control pipe ended without its clean-exit marker.\nThe exit status and exact cause are not available to this reporter.\nMemory exhaustion, a native crash or external termination are possible.\nThis report was saved locally; it was not sent anywhere." + )?; + if let Some(error) = pipe_error { + writeln!(report, "Control pipe error: {error}")?; + } + if let Err(error) = log.write_tail(&mut report) { + writeln!(report, "\nCould not read the recent session log: {error}")?; + } + report.flush()?; + Ok(path) +} + +fn description(saved: &io::Result) -> String { + let diagnostic = match saved { + Ok(path) => format!("A diagnostic report was saved to:\n{}", path.display()), + Err(error) => format!("A diagnostic report could not be saved: {error}"), + }; + format!( + "EffectCraft stopped unexpectedly.\n\n{diagnostic}\n\nThe exact cause is unknown. Reopen EffectCraft to check for an existing auto-save. Changes since the last auto-save may be lost.\n\nNo report has been sent anywhere." + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::thread; + use std::time::{Duration, Instant}; + + static NEXT: AtomicUsize = AtomicUsize::new(0); + const ROLE: &str = "EFFECTCRAFT_CRASH_REPORT_TEST_ROLE"; + + fn folder() -> PathBuf { + let p = std::env::temp_dir().join(format!("ec-crash-report-{}-{}", run_id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir_all(&p).unwrap(); + p + } + + fn test_name(name: &str) -> String { + // Also usable as a standalone std-only rustc test while the app's build cache is busy. + let prefix = module_path!().split_once("::").map_or("", |(_, rest)| rest); + format!("{prefix}::{name}") + } + + fn helper(dir: &Path, id: &str) -> Reporter { + helper_mode(dir, id, false) + } + + fn helper_mode(dir: &Path, id: &str, interactive: bool) -> Reporter { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("reporter_process"), "--ignored", "--nocapture"]) + .env(ROLE, "reporter") + .env("EC_REPORT_DIR", dir) + .env("EC_REPORT_ID", id) + .env("EC_REPORT_INTERACTIVE", if interactive { "1" } else { "0" }) + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + Reporter { pipe: child.stdin.take().unwrap(), child } + } + + fn wait_file(path: &Path) { + let until = Instant::now() + Duration::from_secs(10); + while !path.is_file() && Instant::now() < until { + thread::sleep(Duration::from_millis(20)); + } + assert!(path.is_file(), "report did not arrive: {}", path.display()); + } + + #[test] + fn bounded_protocol_rejects_eof_and_partial_or_wrong_markers() { + let mut clean = CLEAN; + assert!(clean_exit(&mut clean).unwrap()); + for mut input in [b"".as_slice(), &CLEAN[..CLEAN.len() - 1], b"clean", b"EFFECTCRAFT_CLEAN_EXIT_V0\n"] { + assert!(!clean_exit(&mut input).unwrap()); + } + assert!(!clean_exit(&mut &vec![b'x'; 100_000][..]).unwrap()); + } + + #[test] + fn report_is_bounded_and_keeps_its_session_after_log_rotation() { + let dir = folder(); + let original = dir.join("effectcraft.log"); + let id = "123-456"; + std::fs::write(&original, format!("{}\n", session_marker(id))).unwrap(); + let mut log = SessionLog::open(Some(&original), id); + std::fs::rename(&original, dir.join("effectcraft.1.log")).unwrap(); + std::fs::write(&original, "OTHER SESSION MUST NOT APPEAR").unwrap(); + let mut old = OpenOptions::new().append(true).open(dir.join("effectcraft.1.log")).unwrap(); + old.write_all(&vec![b'a'; LOG_BYTES * 2]).unwrap(); + old.write_all(b"\nGPU context retired\n").unwrap(); + let report = write_report(&dir, id, &mut log, None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.len() < LOG_BYTES + 2048); + assert!(text.contains("GPU context retired")); + assert!(!text.contains("OTHER SESSION")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn wrong_session_and_missing_logs_are_reported_without_reading_other_content() { + let dir = folder(); + let path = dir.join("log"); + std::fs::write(&path, "private other session").unwrap(); + for (id, source) in [("1-1", Some(path.as_path())), ("1-2", None)] { + let report = write_report(&dir, id, &mut SessionLog::open(source, id), None).unwrap(); + let text = std::fs::read_to_string(report).unwrap(); + assert!(text.contains("Recent log unavailable")); + assert!(!text.contains("private other session")); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn session_identity_requires_the_exact_first_line() { + let dir = folder(); + let path = dir.join("log"); + for header in ["crash-report session 1-20\n", "unrelated\ncrash-report session 1-2\n"] { + std::fs::write(&path, header).unwrap(); + assert!(matches!(SessionLog::open(Some(&path), "1-2"), SessionLog::Unavailable(_))); + } + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn report_write_failure_is_described_and_existing_report_is_not_overwritten() { + let dir = folder(); + let mut log = SessionLog::Unavailable("no log".into()); + let first = write_report(&dir, "2-1", &mut log, None).unwrap(); + let before = std::fs::read(&first).unwrap(); + let result = write_report(&dir, "2-1", &mut log, None); + assert!(result.is_err()); + assert!(description(&result).contains("could not be saved")); + assert_eq!(std::fs::read(first).unwrap(), before); + assert!(write_report(&dir, "../escape", &mut log, None).is_err()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn normal_and_handled_shutdown_send_clean_marker_without_a_report() { + for id in ["3-1", "3-2"] { + let dir = folder(); + let r = helper(&dir, id); + r.finish().unwrap(); + // A completion file proves the helper consumed the marker, not merely that it + // hadn't yet produced a report. + wait_file(&dir.join(format!("ready-{id}"))); + assert!(!dir.join(format!("crash-{id}.txt")).exists()); + std::fs::remove_dir_all(dir).unwrap(); + } + } + + #[test] + fn an_aborting_parent_leaves_one_diagnostic_report() { + let dir = folder(); + let status = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("aborting_parent"), "--ignored", "--nocapture"]) + .env(ROLE, "abort") + .env("EC_REPORT_DIR", &dir) + .stdout(Stdio::null()) + .status() + .unwrap(); + assert!(!status.success()); + wait_file(&dir.join("ready-4-1")); + let text = std::fs::read_to_string(dir.join("crash-4-1.txt")).unwrap(); + assert!(text.contains("exact cause are not available")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unrelated_child_does_not_keep_the_control_pipe_open() { + let dir = folder(); + let r = helper(&dir, "5-1"); + let mut unrelated = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdout(Stdio::null()) + .spawn() + .unwrap(); + drop(r); + wait_file(&dir.join("ready-5-1")); + assert!(unrelated.try_wait().unwrap().is_none()); + unrelated.kill().unwrap(); + unrelated.wait().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn an_unresponsive_helper_cannot_hang_clean_shutdown() { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", &test_name("lingering_child"), "--ignored", "--nocapture"]) + .env(ROLE, "linger") + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .spawn() + .unwrap(); + let pipe = child.stdin.take().unwrap(); + let started = Instant::now(); + let result = Reporter { pipe, child }.finish(); + assert!(result.unwrap_err().to_string().contains("was terminated")); + assert!(started.elapsed() < Duration::from_secs(5)); + } + + #[test] + fn simultaneous_sessions_write_separate_reports() { + let dir = folder(); + let a = helper(&dir, "6-1"); + let b = helper(&dir, "6-2"); + drop(a); + drop(b); + wait_file(&dir.join("ready-6-1")); + wait_file(&dir.join("ready-6-2")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn interactive_failure_calls_dialog_once_after_saving_the_report() { + let dir = folder(); + drop(helper_mode(&dir, "7-1", true)); + wait_file(&dir.join("ready-7-1")); + let text = std::fs::read_to_string(dir.join("dialog-7-1")).unwrap(); + assert!(text.contains("crash-7-1.txt")); + assert!(text.contains("No report has been sent")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn internal_mode_rejects_invalid_arguments_without_launching_the_app() { + assert!(!maybe_run(&[], |_| panic!("no dialog"))); + assert!(!maybe_run(&[OsString::from("--version")], |_| panic!("no dialog"))); + assert!(maybe_run(&[OsString::from(FLAG)], |_| panic!("no dialog"))); + let args = [FLAG, "../escape", "", "", "1"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + let args = [FLAG, "1-1", "", "", "invalid"].map(OsString::from); + assert!(maybe_run(&args, |_| panic!("no dialog"))); + } + + #[test] + fn missing_helper_is_an_error_not_a_panic() { + assert!(spawn(Path::new("/nonexistent-effectcraft-crash-helper"), &[]).is_err()); + } + + // Roles are ignored by ordinary test runs. Explicit subprocess invocations exercise + // real EOF/abort behavior without allocating a huge image or killing the test runner. + #[test] + #[ignore] + fn reporter_process() { + assert_eq!(std::env::var(ROLE).unwrap(), "reporter"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let id = std::env::var("EC_REPORT_ID").unwrap(); + let interactive = std::env::var("EC_REPORT_INTERACTIVE").unwrap(); + let args = [OsString::from(FLAG), OsString::from(&id), OsString::new(), dir.as_os_str().to_owned(), OsString::from(&interactive)]; + assert!(maybe_run(&args, |description| { + assert_eq!(interactive, "1", "non-interactive reporter must not show a dialog"); + assert!(dir.join(format!("crash-{id}.txt")).exists()); + let mut shown = OpenOptions::new().write(true).create_new(true).open(dir.join(format!("dialog-{id}"))).unwrap(); + shown.write_all(description.as_bytes()).unwrap(); + })); + std::fs::write(dir.join(format!("ready-{id}")), b"done").unwrap(); + } + + #[test] + #[ignore] + fn aborting_parent() { + assert_eq!(std::env::var(ROLE).unwrap(), "abort"); + let dir = PathBuf::from(std::env::var_os("EC_REPORT_DIR").unwrap()); + let _reporter = helper(&dir, "4-1"); + std::process::abort(); + } + + #[test] + #[ignore] + fn lingering_child() { + assert_eq!(std::env::var(ROLE).unwrap(), "linger"); + thread::sleep(Duration::from_secs(30)); + } +} diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs index 5350addbe..ce4bcdd84 100644 --- a/apps/effectcraft/src/main.rs +++ b/apps/effectcraft/src/main.rs @@ -22,6 +22,7 @@ mod appimage; mod apple_events; mod audio_out; mod control_server; +mod crash_report; mod launch_guard; #[cfg(target_os = "macos")] mod native_menu; @@ -180,6 +181,25 @@ fn start_helpers_if_opted_in(session: &mut effectcraft_engine::Session) { /// Builds the session, loads settings, plugins, the requested project/media and the optional control server, then /// reports startup failure through `startup_failed`. Returns the event loop's result. fn main() -> eframe::Result { + let args: Vec<_> = std::env::args_os().skip(1).collect(); + if crash_report::maybe_run(&args, |description| { + rfd::MessageDialog::new().set_level(rfd::MessageLevel::Error).set_title("EffectCraft stopped unexpectedly").set_description(description).show(); + }) { + return Ok(()); + } + let mut reporter = None; + let result = run_desktop(&mut reporter); + // run_desktop has dropped the app and handled any startup error with its existing dialog. + // An unwinding panic or abort never reaches this marker. + if let Some(reporter) = reporter + && let Err(error) = reporter.finish() + { + log::warn!("could not mark crash reporter clean: {error}"); + } + result +} + +fn run_desktop(reporter: &mut Option) -> eframe::Result { // Windows: the update helper is a copy of this program started with `--finish-update`. #[cfg(windows)] if let Some(result) = craft_update::self_update::finish(&effectcraft_ui_egui::UPDATE_APP, &std::env::args_os().skip(1).collect::>()) { @@ -205,12 +225,19 @@ fn main() -> eframe::Result { // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so // `--version` leaves no file behind; records logged until now are written to it first. + let run_id = crash_report::run_id(); if let Some(dir) = effectcraft_host::config_dir() { - match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) { + let header = format!("{}\n", crash_report::session_marker(&run_id)); + match effectcraft_engine::logging::attach_session_log_with_header(&effectcraft_engine::logging::log_dir(Some(&dir)), &header) { Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()), Err(e) => log::warn!("no log file: {e}"), } } + let logs = effectcraft_engine::logging::log_dir(effectcraft_host::config_dir().as_deref()); + match crash_report::start(&run_id, effectcraft_engine::logging::session_file().as_deref(), &logs.join("Crash Reports"), control_port.is_none()) { + Ok(started) => *reporter = Some(started), + Err(error) => log::warn!("crash reporter could not start: {error}"), + } // The font menus list the installed fonts: read their names while the window opens. effectcraft_engine::text::fonts::scan_system_in_background(); // Wayland shows the window's icon from its desktop entry: an AppImage brings its own. diff --git a/crates/engine/src/system/logging.rs b/crates/engine/src/system/logging.rs index 857a3e90d..198aa3b46 100644 --- a/crates/engine/src/system/logging.rs +++ b/crates/engine/src/system/logging.rs @@ -221,9 +221,18 @@ fn rotate(dir: &Path) -> std::io::Result<(PathBuf, File)> { /// Rotate the session logs in `dir` (the `Logs` folder, see [`log_dir`]) and write records to /// a fresh `effectcraft.log` there, first the ones logged so far; returns its path. pub fn attach_session_log(dir: &Path) -> Result { + attach_session_log_with_header(dir, "") +} + +/// Open a session log with a bounded, unfiltered identity header before buffered records. +/// A crash reporter can then recognize this run even with logging disabled or verbose startup. +pub fn attach_session_log_with_header(dir: &Path, header: &str) -> Result { + if header.len() > 1024 { + return Err("session log header exceeds 1024 bytes".into()); + } let (path, file) = rotate(dir).map_err(|e| format!("{}: {e}", dir.display()))?; let mut sink = SESSION.lock().unwrap_or_else(PoisonError::into_inner); - sink.attach(file); + sink.attach_with_header(file, header); sink.path = Some(path.clone()); Ok(path) } @@ -264,8 +273,14 @@ impl Sink { } /// Start writing to `file`, first the lines kept so far. + #[cfg(test)] fn attach(&mut self, file: File) { + self.attach_with_header(file, ""); + } + + fn attach_with_header(&mut self, file: File, header: &str) { self.file = Some(file); + self.append(header); for line in std::mem::take(&mut self.pending) { self.append(&line); } @@ -333,6 +348,22 @@ mod tests { use super::*; use std::sync::atomic::AtomicUsize; + #[test] + fn identity_header_precedes_buffered_records_independent_of_log_filter() { + let dir = temp_dir("identity-header"); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session"); + let mut sink = Sink::new(MAX_FILE_BYTES); + sink.write(&"earlier startup record".repeat(1000)); + sink.attach_with_header(File::create(&path).unwrap(), "crash-report session 1-2\n"); + let text = std::fs::read_to_string(&path).unwrap(); + assert!(text.starts_with("crash-report session 1-2\n")); + assert!(text.contains("earlier startup record")); + assert!(attach_session_log_with_header(&dir, &"x".repeat(1025)).is_err()); + drop(sink); + std::fs::remove_dir_all(dir).unwrap(); + } + /// `RUST_LOG` picks what start-up prints to stderr (#234). #[test] fn rust_log_filters_by_level_and_target() { diff --git a/docs/testing.md b/docs/testing.md index e84a9a23b..6c3d91626 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -112,3 +112,24 @@ and line instead of being discarded. `--fail-over PCT` requires the same run key successful latest runs, and at least one comparable resource metric per run. Failed latest runs, missing coverage, empty results and wall-time-only measurements cannot pass the gate. Omit `--fail-over` for an exploratory partial comparison; failed latest runs remain visible. + +## Unexpected desktop exit diagnostics + +The desktop starts a separate, lightweight reporter before loading fonts, projects or the GPU. +A normal exit (including a handled startup error) sends a clean marker only after desktop +teardown. An abort or other unexpected process termination closes the pipe without that marker. +The helper saves a local report under the session log folder's `Crash Reports` directory and +shows a native error dialog. Control-server launches save the report without a modal dialog. +Reports contain version/platform and at most the last 64 KiB of the matching session's log. +Logging filters and the session file size cap can omit later events from that tail. +They are never uploaded automatically. Review logs for private project paths before sharing. + +Run `cargo test -p effectcraft crash_report::tests` for bounded protocol, normal shutdown, +actual subprocess abort, unrelated child handles, concurrent sessions, missing log/helper, +log rotation and report-write failure coverage. The three ignored tests are subprocess entry +points launched by these tests, not manual tests. + +This is diagnosis, not memory recovery: a pipe closing does not prove an out-of-memory failure +or provide the process's exit code. System-wide exhaustion, killing the whole process tree, +or an unavailable native dialog service can also prevent the helper from reporting. Existing +auto-save recovery remains responsible for restoring previously saved work.