mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-10 10:03:34 +00:00
#3357: report unexpected desktop exits with local diagnostics
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user