authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 12:43:05-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 16:46:23-07:00
loga19933d46506ade819b3ceb6e7d68fd97f13b832
tree45e864ce8943b8bf22210ef155fb51d98651563b
parentdd5848fb96853a2c144af246475587ad09f1aefa
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: Linux logs crashes with a backtrace

Panics and the signals that end the process (SIGSEGV, SIGBUS, SIGILL, SIGFPE, SIGABRT) now write a backtrace, with each frame's module and offset, to $XDG_STATE_HOME/snowbound/snowbound.log (the last run's kept as snowbound.old.log) and say where the log is. Where stderr reaches no one, as when the desktop starts the app, stderr goes to the log as well, as it does on Windows. The signal then ends the process as before, so systemd-coredump still records it. Assisted-by: claude-opus-5.5

2 files changed, 136 insertions(+), 0 deletions(-)

crates/snowbound/linux/README.md+3
...@@ -20,6 +20,9 @@ Edits save to the notebook folder as you type. Settings live in...@@ -20,6 +20,9 @@ Edits save to the notebook folder as you type. Settings live in
20`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR`20`~/.config/snowbound` and sync state in `~/.cache/snowbound`; `--cache DIR`
21puts the latter elsewhere.21puts the latter elsewhere.
2222
23If Snowbound stops unexpectedly, `~/.local/state/snowbound/snowbound.log` says
24why; please attach it when you report a problem.
25
23**Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and26**Install Snowbound**, shown while no notebook is open, adds Snowbound to your app menu and
24to the apps that open `.one` and `.onetoc2` files, and copies it to27to the apps that open `.one` and `.onetoc2` files, and copies it to
25`~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it28`~/.local/bin/snowbound`. Options, next to the update settings, uninstalls it
crates/snowbound/src/linux.rs+133
...@@ -662,10 +662,143 @@ pub fn move_cursor() -> winit::window::CursorIcon {...@@ -662,10 +662,143 @@ pub fn move_cursor() -> winit::window::CursorIcon {
662/// No system border lies under the chrome.662/// No system border lies under the chrome.
663pub fn cover_border_line(_: &mut Ui, _: f32) {}663pub fn cover_border_line(_: &mut Ui, _: f32) {}
664664
665/// The log's path, and the log itself where stderr isn't it.
666static LOG: OnceLock<(PathBuf, Option<std::fs::File>)> = OnceLock::new();
667
668/// Runs the app. Where stderr reaches no one, as when the desktop starts the app, stderr goes
669/// to `snowbound.log`; panics and the signals that end the process log a backtrace there
670/// either way, and say where the log is.
665pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {671pub fn with_pool<R>(run: impl FnOnce() -> R) -> R {
672 log_crashes();
666 run()673 run()
667}674}
668675
676/// Opens `snowbound.log` in the state folder, keeping the last run's as `snowbound.old.log`,
677/// and takes stderr there unless it reaches a terminal, a pipe or a file.
678fn log_crashes() {
679 use std::{io::Write, os::fd::AsRawFd};
680 let Some(folder) = xdg_dir("XDG_STATE_HOME", ".local/state") else {
681 return;
682 };
683 let path = folder.join("snowbound.log");
684 let _ = std::fs::create_dir_all(&folder);
685 let _ = std::fs::rename(&path, folder.join("snowbound.old.log"));
686 let Ok(mut log) = std::fs::File::create(&path) else {
687 return;
688 };
689 let mut system: libc::utsname = unsafe { std::mem::zeroed() };
690 unsafe { libc::uname(&mut system) };
691 let field =
692 |field: &[libc::c_char]| unsafe { CStr::from_ptr(field.as_ptr()) }.to_string_lossy();
693 let session = |name| std::env::var(name).unwrap_or_default();
694 let _ = writeln!(
695 log,
696 "Snowbound {} on Linux {} {}, {} {}",
697 option_env!("SNOWBOUND_BUILD").unwrap_or("development"),
698 field(&system.release),
699 field(&system.machine),
700 session("XDG_SESSION_TYPE"),
701 session("XDG_CURRENT_DESKTOP"),
702 );
703 let mut stderr: libc::stat = unsafe { std::mem::zeroed() };
704 let seen = unsafe { libc::isatty(2) } == 1
705 || unsafe { libc::fstat(2, &mut stderr) } == 0
706 && [libc::S_IFIFO, libc::S_IFREG].contains(&(stderr.st_mode & libc::S_IFMT));
707 let copy = if seen {
708 Some(log)
709 } else {
710 unsafe { libc::dup2(log.as_raw_fd(), 2) };
711 None
712 };
713 let _ = LOG.set((path, copy));
714 std::panic::set_hook(Box::new(|info| {
715 let thread = std::thread::current();
716 let backtrace = std::backtrace::Backtrace::force_capture();
717 let report = format!(
718 "Thread {:?} {info}\n{backtrace}\n",
719 thread.name().unwrap_or("")
720 );
721 crashed(|fd| write_all(fd, report.as_bytes()));
722 }));
723 // glibc's backtrace loads its unwinder on first use, which a signal handler can't.
724 unsafe { libc::backtrace([std::ptr::null_mut(); 1].as_mut_ptr(), 1) };
725 for signal in FATAL.map(|(signal, _)| signal) {
726 let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
727 action.sa_sigaction = fatal as *const () as libc::sighandler_t;
728 action.sa_flags = libc::SA_SIGINFO | libc::SA_ONSTACK | libc::SA_RESETHAND;
729 unsafe { libc::sigaction(signal, &action, std::ptr::null_mut()) };
730 }
731}
732
733const FATAL: [(i32, &str); 5] = [
734 (libc::SIGSEGV, "SIGSEGV"),
735 (libc::SIGBUS, "SIGBUS"),
736 (libc::SIGILL, "SIGILL"),
737 (libc::SIGFPE, "SIGFPE"),
738 (libc::SIGABRT, "SIGABRT"),
739];
740
741/// Logs the signal ending the process with the stack it arrived on, then lets it end the
742/// process as it would have. Calls only what a signal handler may.
743extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_void) {
744 let name = FATAL
745 .iter()
746 .find(|(fatal, _)| *fatal == signal)
747 .map_or("", |(_, name)| name);
748 let mut thread = [0u8; 16];
749 unsafe { libc::prctl(libc::PR_GET_NAME, thread.as_mut_ptr()) };
750 let thread = &thread[..thread.iter().position(|&byte| byte == 0).unwrap_or(15)];
751 let mut address = *b"0x0000000000000000";
752 let mut value = unsafe { (*info).si_addr() } as usize;
753 for digit in address[2..].iter_mut().rev() {
754 *digit = b"0123456789abcdef"[value & 15];
755 value >>= 4;
756 }
757 let mut frames = [std::ptr::null_mut(); 128];
758 let count = unsafe { libc::backtrace(frames.as_mut_ptr(), frames.len() as i32) };
759 crashed(|fd| {
760 let parts: [&[u8]; 7] = [
761 b"Thread \"",
762 thread,
763 b"\" received ",
764 name.as_bytes(),
765 b" at ",
766 &address,
767 b"\n",
768 ];
769 for part in parts {
770 write_all(fd, part);
771 }
772 unsafe { libc::backtrace_symbols_fd(frames.as_ptr(), count, fd) };
773 write_all(fd, b"\n");
774 });
775 unsafe { libc::raise(signal) };
776}
777
778/// Writes a report, through `write` on a descriptor, to stderr and the log, then says where the
779/// log is.
780fn crashed(write: impl Fn(i32)) {
781 use std::os::{fd::AsRawFd, unix::ffi::OsStrExt};
782 write(2);
783 if let Some((path, Some(log))) = LOG.get() {
784 write(log.as_raw_fd());
785 let parts: [&[u8]; 3] = [b"The log is at ", path.as_os_str().as_bytes(), b".\n"];
786 for part in parts {
787 write_all(2, part);
788 }
789 }
790}
791
792fn write_all(fd: i32, mut bytes: &[u8]) {
793 while !bytes.is_empty() {
794 let written = unsafe { libc::write(fd, bytes.as_ptr().cast(), bytes.len()) };
795 if written <= 0 {
796 return;
797 }
798 bytes = &bytes[written as usize..];
799 }
800}
801
669/// The interface keeps fontconfig's font and scrollbars overlay the content.802/// The interface keeps fontconfig's font and scrollbars overlay the content.
670pub fn system_interface(_: &mut Ui) {}803pub fn system_interface(_: &mut Ui) {}
671804