authorgravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 17:21:07-07:00
committergravatar for git@paperclover.netclover caruso <git@paperclover.net> 2026-10-01 17:43:01-07:00
logb614f34cb7b240eb16060ae6e02f6ecc42624dbc
tree50c921521d7d8b4547bcd070df35514ef776928c
parent2ab751892691170d86c0c09e585a9534a9c90b28
signature Signed by SSH key SHA256:52mNGHRsVFBDED9IAX5pe+LRWUefqTbxEReunq21QvU

fix: the Linux crash log names the functions a signal stopped in

glibc's backtrace_symbols_fd reads only dynamic symbols, so a SIGSEGV's frames showed bare offsets. The handler now starts the executable again with --symbolize, which reads the frames' functions from its own symbol table; the crashed process makes only async-signal-safe calls, and the raw frames still come first. Assisted-by: claude-opus-5.5

5 files changed, 198 insertions(+), 13 deletions(-)

Cargo.lock+7
......@@ -3091,6 +3091,12 @@ dependencies = [
30913091 "sqlite-wasm-rs",
30923092]
30933093
3094[[package]]
3095name = "rustc-demangle"
3096version = "0.1.28"
3097source = "registry+https://github.com/rust-lang/crates.io-index"
3098checksum = "b74b56ffa8bb2830709a538c2cbcae9aa062db0d2a42563bfb09bdaae44020eb"
3099
30943100[[package]]
30953101name = "rustc-hash"
30963102version = "1.1.0"
......@@ -3505,6 +3511,7 @@ dependencies = [
35053511 "png",
35063512 "pollster",
35073513 "ring",
3514 "rustc-demangle",
35083515 "sctk-adwaita",
35093516 "serde",
35103517 "serde_json",
crates/snowbound/Cargo.toml+2
......@@ -81,6 +81,8 @@ smithay-clipboard = "0.7.3"
8181# Loads fontconfig at run time, so builds need no fontconfig headers or library.
8282fontique = { workspace = true, features = ["fontconfig-dlopen"] }
8383libc = "0.2"
84# Names Rust functions in the crash log.
85rustc-demangle = "0.1"
8486zbus = { version = "5.19", default-features = false, features = ["async-io", "blocking-api"] }
8587# The colours of winit's own window frame, which the toolbar continues.
8688sctk-adwaita = { version = "0.10.1", default-features = false }
crates/snowbound/src/linux.rs+179-10
......@@ -6,15 +6,16 @@
66use crate::dialog::Ask;
77use canvas::date::DateField;
88use std::{
9 cell::UnsafeCell,
910 collections::HashMap,
1011 error::Error,
11 ffi::CStr,
12 ffi::{CStr, CString, OsString, c_char, c_void},
1213 os::unix::ffi::OsStringExt,
1314 path::PathBuf,
1415 process::Command,
1516 sync::{
1617 OnceLock,
17 atomic::{AtomicU32, Ordering},
18 atomic::{AtomicBool, AtomicU32, Ordering},
1819 },
1920 time::Duration,
2021};
......@@ -732,6 +733,11 @@ fn log_crashes() {
732733 );
733734 crashed(|fd| write_all(fd, report.as_bytes()));
734735 }));
736 if let Ok(path) = crate::loader::executable()
737 && let Ok(path) = CString::new(path.into_os_string().into_vec())
738 {
739 let _ = EXECUTABLE.set((path, main_base()));
740 }
735741 // glibc's backtrace loads its unwinder on first use, which a signal handler can't.
736742 unsafe { libc::backtrace([std::ptr::null_mut(); 1].as_mut_ptr(), 1) };
737743 for signal in FATAL.map(|(signal, _)| signal) {
......@@ -760,14 +766,10 @@ extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_voi
760766 let mut thread = [0u8; 16];
761767 unsafe { libc::prctl(libc::PR_GET_NAME, thread.as_mut_ptr()) };
762768 let thread = &thread[..thread.iter().position(|&byte| byte == 0).unwrap_or(15)];
763 let mut address = *b"0x0000000000000000";
764 let mut value = unsafe { (*info).si_addr() } as usize;
765 for digit in address[2..].iter_mut().rev() {
766 *digit = b"0123456789abcdef"[value & 15];
767 value >>= 4;
768 }
769 let mut frames = [std::ptr::null_mut(); 128];
769 let address = hex(unsafe { (*info).si_addr() } as usize);
770 let mut frames = [std::ptr::null_mut(); FRAMES];
770771 let count = unsafe { libc::backtrace(frames.as_mut_ptr(), frames.len() as i32) };
772 let functions = functions(&frames[..count.max(0) as usize]);
771773 crashed(|fd| {
772774 let parts: [&[u8]; 7] = [
773775 b"Thread \"",
......@@ -775,18 +777,185 @@ extern "C" fn fatal(signal: i32, info: *mut libc::siginfo_t, _: *mut libc::c_voi
775777 b"\" received ",
776778 name.as_bytes(),
777779 b" at ",
778 &address,
780 &address[..18],
779781 b"\n",
780782 ];
781783 for part in parts {
782784 write_all(fd, part);
783785 }
784786 unsafe { libc::backtrace_symbols_fd(frames.as_ptr(), count, fd) };
787 write_all(fd, functions);
785788 write_all(fd, b"\n");
786789 });
787790 unsafe { libc::raise(signal) };
788791}
789792
793const FRAMES: usize = 128;
794
795/// `0x` and 16 hex digits, NUL-terminated.
796fn hex(mut value: usize) -> [u8; 19] {
797 let mut text = *b"0x0000000000000000\0";
798 for digit in text[2..18].iter_mut().rev() {
799 *digit = b"0123456789abcdef"[value & 15];
800 value >>= 4;
801 }
802 text
803}
804
805/// The executable's path and where it is loaded, for `functions`.
806static EXECUTABLE: OnceLock<(CString, usize)> = OnceLock::new();
807
808/// Where the dynamic loader placed the executable: symbol values plus this are addresses.
809fn main_base() -> usize {
810 unsafe extern "C" fn first(info: *mut libc::dl_phdr_info, _: usize, base: *mut c_void) -> i32 {
811 unsafe { *base.cast::<usize>() = (*info).dlpi_addr as usize };
812 1
813 }
814 let mut base = 0usize;
815 unsafe { libc::dl_iterate_phdr(Some(first), (&raw mut base).cast()) };
816 base
817}
818
819/// The argument that runs Snowbound as `functions`' symbolizer, followed by frames' offsets.
820pub const SYMBOLIZE: &CStr = c"--symbolize";
821
822/// The symbolizer's arguments and output, which only the first crashing thread touches.
823struct Scratch {
824 offsets: [[u8; 19]; FRAMES],
825 arguments: [*const c_char; FRAMES + 3],
826 text: [u8; 1 << 14],
827}
828struct Shared(UnsafeCell<Scratch>);
829unsafe impl Sync for Shared {}
830static SCRATCH: Shared = Shared(UnsafeCell::new(Scratch {
831 offsets: [[0; 19]; FRAMES],
832 arguments: [std::ptr::null(); FRAMES + 3],
833 text: [0; 1 << 14],
834}));
835
836/// Names the frames in Snowbound's own code from its symbol table, which a crashed process
837/// can't safely read: a new process started from the executable does. Empty where it can't.
838fn functions(frames: &[*mut c_void]) -> &'static [u8] {
839 static TAKEN: AtomicBool = AtomicBool::new(false);
840 let Some((path, base)) = EXECUTABLE.get() else {
841 return b"";
842 };
843 if TAKEN.swap(true, Ordering::Relaxed) {
844 return b"";
845 }
846 let scratch = unsafe { &mut *SCRATCH.0.get() };
847 scratch.arguments[0] = path.as_ptr();
848 scratch.arguments[1] = SYMBOLIZE.as_ptr();
849 for (index, frame) in frames.iter().enumerate() {
850 scratch.offsets[index] = hex((*frame as usize).wrapping_sub(*base));
851 scratch.arguments[index + 2] = scratch.offsets[index].as_ptr().cast();
852 }
853 scratch.arguments[frames.len() + 2] = std::ptr::null();
854 let mut pipe = [0; 2];
855 if unsafe { libc::pipe2(pipe.as_mut_ptr(), libc::O_CLOEXEC) } != 0 {
856 return b"";
857 }
858 // A bare clone: glibc's fork takes malloc's locks, which the crashed thread may hold.
859 let child = unsafe { libc::syscall(libc::SYS_clone, libc::SIGCHLD, 0, 0, 0, 0) };
860 if child == 0 {
861 unsafe {
862 libc::dup2(pipe[1], 1);
863 // One that hangs ends, and the crash with it.
864 libc::alarm(10);
865 libc::execv(path.as_ptr(), scratch.arguments.as_ptr());
866 libc::_exit(127);
867 }
868 }
869 unsafe { libc::close(pipe[1]) };
870 let mut length = 0;
871 if child > 0 {
872 while length < scratch.text.len() {
873 let read = unsafe {
874 libc::read(
875 pipe[0],
876 scratch.text[length..].as_mut_ptr().cast(),
877 scratch.text.len() - length,
878 )
879 };
880 if read <= 0 {
881 break;
882 }
883 length += read as usize;
884 }
885 unsafe { libc::waitpid(child as i32, std::ptr::null_mut(), 0) };
886 }
887 unsafe { libc::close(pipe[0]) };
888 &scratch.text[..length]
889}
890
891/// The symbolizer `functions` starts: lists each frame offset that falls in a function of the
892/// executable's symbol table, by its place in the backtrace.
893pub fn symbolize(offsets: impl Iterator<Item = OsString>) -> Result<(), Box<dyn Error>> {
894 use std::{fmt::Write as _, io::Write as _, os::unix::fs::FileExt};
895 let file = std::fs::File::open(crate::loader::executable()?)?;
896 let read = |offset: usize, length: usize| -> std::io::Result<Vec<u8>> {
897 let mut bytes = vec![0; length];
898 file.read_exact_at(&mut bytes, offset as u64)?;
899 Ok(bytes)
900 };
901 let field = |bytes: &[u8], at: usize, size: usize| {
902 let mut value = [0; 8];
903 value[..size].copy_from_slice(&bytes[at..at + size]);
904 usize::from_le_bytes(value)
905 };
906 // ELF64, little-endian, as both Linux builds are.
907 let header = read(0, 64)?;
908 let size = field(&header, 58, 2);
909 let sections = read(field(&header, 40, 8), size * field(&header, 60, 2))?;
910 let sections: Vec<&[u8]> = sections.chunks_exact(size).collect();
911 let table = sections
912 .iter()
913 .find(|section| field(section, 4, 4) == 2)
914 .ok_or("no symbol table")?;
915 let symbols = read(field(table, 24, 8), field(table, 32, 8))?;
916 let strings = sections[field(table, 40, 4)];
917 let strings = read(field(strings, 24, 8), field(strings, 32, 8))?;
918 let mut ranges: Vec<(usize, usize, &[u8])> = symbols
919 .chunks_exact(24)
920 .filter(|symbol| symbol[4] & 15 == 2 && field(symbol, 16, 8) > 0)
921 .map(|symbol| {
922 let name = &strings[field(symbol, 0, 4)..];
923 let start = field(symbol, 8, 8);
924 let end = name
925 .iter()
926 .position(|&byte| byte == 0)
927 .unwrap_or(name.len());
928 (start, start + field(symbol, 16, 8), &name[..end])
929 })
930 .collect();
931 ranges.sort_unstable_by_key(|range| range.0);
932 let mut report = String::from("In Snowbound, by place in the backtrace:\n");
933 let mut found = false;
934 for (place, offset) in offsets.enumerate() {
935 let offset = offset.to_str().and_then(|offset| offset.strip_prefix("0x"));
936 let Some(offset) = offset.and_then(|offset| usize::from_str_radix(offset, 16).ok()) else {
937 continue;
938 };
939 let index = ranges.partition_point(|range| range.0 <= offset);
940 if let Some(&(start, end, name)) = index.checked_sub(1).map(|index| &ranges[index])
941 && offset < end
942 {
943 let name = String::from_utf8_lossy(name);
944 writeln!(
945 report,
946 "{place:4} {:#} + {:#x}",
947 rustc_demangle::demangle(&name),
948 offset - start
949 )?;
950 found = true;
951 }
952 }
953 if found {
954 std::io::stdout().write_all(report.as_bytes())?;
955 }
956 Ok(())
957}
958
790959/// Writes a report, through `write` on a descriptor, to stderr and the log, then says where the
791960/// log is.
792961fn crashed(write: impl Fn(i32)) {
crates/snowbound/src/main.rs+7
......@@ -5927,6 +5927,13 @@ fn replay(script: String, proxy: EventLoopProxy<UserEvent>) -> Result<(), Box<dy
59275927}
59285928
59295929fn main() -> Result<(), Box<dyn Error>> {
5930 #[cfg(target_os = "linux")]
5931 if std::env::args_os()
5932 .nth(1)
5933 .is_some_and(|arg| arg.as_encoded_bytes() == platform::SYMBOLIZE.to_bytes())
5934 {
5935 return platform::symbolize(std::env::args_os().skip(2));
5936 }
59305937 #[cfg(target_os = "linux")]
59315938 loader::preload();
59325939 // Before the crash log, which the app showing the dialog keeps writing.
tools/RELEASE.md+3-3
......@@ -158,9 +158,9 @@ already carried them. Line tables would take an executable to some 250 MB, and
158158a dSYM adds 34 MB zipped per Mac architecture, so neither ships; build with
159159`CARGO_PROFILE_RELEASE_DEBUG=line-tables-only` for files and lines. Windows
160160tools that read only PDBs see no names: the Rust targets here emit DWARF, from
161which lld's PDB keeps only global symbols. Linux's crash log gives a signal's
162frames as `snowbound(+0x1a2b3c)`, since glibc's `backtrace_symbols_fd` reads only
163dynamic symbols; `addr2line -f -e snowbound 0x1a2b3c` names them.
161which lld's PDB keeps only global symbols. glibc's `backtrace_symbols_fd` reads only
162dynamic symbols, so for a signal Linux's crash log starts the executable again
163with `--symbolize` to name its frames from the symbol table.
164164
165165## In the app
166166