#3357: report unexpected desktop exits with local diagnostics

This commit is contained in:
FredApps
2026-11-09 20:15:19 +01:00
parent 1197414255
commit fc4bcd21d4
4 changed files with 571 additions and 2 deletions
+490
View File
@@ -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<Reporter> {
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<Reporter> {
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<bool> {
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<PathBuf> {
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<PathBuf>) -> 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));
}
}
+28 -1
View File
@@ -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<crash_report::Reporter>) -> 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::<Vec<_>>()) {
@@ -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.
+32 -1
View File
@@ -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<PathBuf, String> {
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<PathBuf, String> {
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() {
+21
View File
@@ -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.