diff --git a/Cargo.lock b/Cargo.lock index 6380be4..9fb6f96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -142,7 +142,7 @@ dependencies = [ [[package]] name = "deno_panic" -version = "0.2.0" +version = "0.2.1" dependencies = [ "anyhow", "libc", diff --git a/Cargo.toml b/Cargo.toml index c246ee7..a4a1129 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "deno_panic" -version = "0.2.0" +version = "0.2.1" repository = "https://github.com/denoland/panic" description = "stack trace and symbolication for deno" license = "MIT" @@ -8,6 +8,7 @@ edition = "2021" [features] default = [] +frame-pointer = [] symbolicate = ["symbolic", "symbolic-demangle", "serde", "anyhow"] [dependencies] diff --git a/README.md b/README.md index 8c18146..c8abaeb 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,17 @@ Panics in Deno are rare—but when they happen, understanding them is crucial. Including debug info directly in binaries makes them bulky. This tool lets users symbolicate traces remotely, so Deno ship small binaries without losing observability. + +## Linux frame-pointer traces + +The optional `frame-pointer` feature adds a `trace_frame_pointer()` walker on +x86-64 and AArch64 Linux. The existing libunwind-based `trace()` API remains +available, so enabling the feature is additive. The frame-pointer walker lets +release binaries remove `.eh_frame` and `.eh_frame_hdr` while retaining +remotely symbolicated panic traces. + +Every frame between the panic hook and the panic site must preserve frame +pointers. Compile Rust code with `-Cforce-frame-pointers=yes` and rebuild the +standard library with the same flag (for example, with Cargo's `-Zbuild-std`). +Native code on the call path must likewise preserve frame pointers. Link with +`--no-eh-frame-hdr` before removing the unwind sections from the final ELF. diff --git a/example/Cargo.lock b/example/Cargo.lock index 65265cb..2660232 100644 --- a/example/Cargo.lock +++ b/example/Cargo.lock @@ -4,7 +4,10 @@ version = 4 [[package]] name = "deno_panic" -version = "0.1.0" +version = "0.2.1" +dependencies = [ + "libc", +] [[package]] name = "example" @@ -12,3 +15,9 @@ version = "0.1.0" dependencies = [ "deno_panic", ] + +[[package]] +name = "libc" +version = "0.2.170" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875b3680cb2f8f71bdcf9a30f38d48282f5d3c95cbf9b3fa57269bb5d5c06828" diff --git a/example/Cargo.toml b/example/Cargo.toml index 0531ad4..89c452c 100644 --- a/example/Cargo.toml +++ b/example/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -deno_panic = { path = "../" } +deno_panic = { path = "../", features = ["frame-pointer"] } [profile.release] debug = true diff --git a/example/src/main.rs b/example/src/main.rs index 9e9ac13..676ee46 100644 --- a/example/src/main.rs +++ b/example/src/main.rs @@ -1,3 +1,10 @@ fn main() { - println!("{}", deno_panic::trace()); + // Enabling frame-pointer keeps the original API available. + println!("{}", deno_panic::trace()); + + #[cfg(all( + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") + ))] + println!("{}", deno_panic::trace_frame_pointer()); } diff --git a/src/frame_pointer.rs b/src/frame_pointer.rs new file mode 100644 index 0000000..d787ba2 --- /dev/null +++ b/src/frame_pointer.rs @@ -0,0 +1,282 @@ +use std::ffi::c_int; +use std::ffi::c_void; +use std::mem::MaybeUninit; +use std::ops::Range; + +use crate::vlq::vlq_encode; + +const MAX_FRAMES: usize = 256; + +#[inline(always)] +fn frame_pointer() -> usize { + let fp: usize; + + #[cfg(target_arch = "x86_64")] + unsafe { + // SAFETY: Reading the frame-pointer register has no side effects. + core::arch::asm!( + "mov {}, rbp", + out(reg) fp, + options(nomem, nostack, preserves_flags) + ); + } + + #[cfg(target_arch = "aarch64")] + unsafe { + // SAFETY: Reading the frame-pointer register has no side effects. + core::arch::asm!( + "mov {}, x29", + out(reg) fp, + options(nomem, nostack, preserves_flags) + ); + } + + fp +} + +struct MemoryAccessor { + // -1 means unopened and -2 means unavailable. + mem_fd: c_int, +} + +impl MemoryAccessor { + fn new() -> Self { + Self { mem_fd: -1 } + } + + fn read_usize(&mut self, address: usize) -> Option { + let mut bytes = [0; size_of::()]; + if self.read(address, &mut bytes) { + Some(usize::from_ne_bytes(bytes)) + } else { + None + } + } + + fn read(&mut self, address: usize, output: &mut [u8]) -> bool { + if self.mem_fd == -1 { + let local = libc::iovec { + iov_base: output.as_mut_ptr().cast(), + iov_len: output.len(), + }; + let remote = libc::iovec { + iov_base: address as *mut c_void, + iov_len: output.len(), + }; + let bytes_read = unsafe { + // SAFETY: Both iovecs describe valid buffers for their stated lengths. + libc::process_vm_readv( + libc::getpid(), + &raw const local, + 1, + &raw const remote, + 1, + 0, + ) + }; + if bytes_read >= 0 { + return bytes_read as usize == output.len(); + } + + self.mem_fd = unsafe { + // SAFETY: The path is a static NUL-terminated string. + libc::open(c"/proc/self/mem".as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) + }; + if self.mem_fd < 0 { + self.mem_fd = -2; + } + } + + if self.mem_fd < 0 { + return false; + } + + let bytes_read = unsafe { + // SAFETY: mem_fd is open and output is writable for output.len() bytes. + libc::pread( + self.mem_fd, + output.as_mut_ptr().cast(), + output.len(), + address as libc::off_t, + ) + }; + bytes_read >= 0 && bytes_read as usize == output.len() + } +} + +impl Drop for MemoryAccessor { + fn drop(&mut self) { + if self.mem_fd >= 0 { + unsafe { + // SAFETY: mem_fd is a file descriptor opened by MemoryAccessor. + libc::close(self.mem_fd); + } + } + } +} + +struct FramePointerIter { + fp: usize, + stack: Option>, + memory: MemoryAccessor, +} + +impl FramePointerIter { + fn new(fp: usize) -> Self { + Self { + fp, + stack: current_stack_bounds(), + memory: MemoryAccessor::new(), + } + } +} + +impl Iterator for FramePointerIter { + type Item = usize; + + fn next(&mut self) -> Option { + let stack = self.stack.as_ref()?; + let frame_end = self.fp.checked_add(2 * size_of::())?; + if self.fp == 0 + || self.fp % align_of::() != 0 + || self.fp < stack.start + || frame_end > stack.end + { + return None; + } + + let parent_fp = self.memory.read_usize(self.fp)?; + let return_address = self + .memory + .read_usize(self.fp.checked_add(size_of::())?)?; + + // The stack grows downward, so every caller's frame must be above its + // callee. This also rejects self-linked and corrupt frame chains. + if parent_fp != 0 + && (parent_fp <= self.fp + || parent_fp % align_of::() != 0 + || parent_fp >= stack.end) + { + return None; + } + self.fp = parent_fp; + + Some(return_address) + } +} + +fn current_stack_bounds() -> Option> { + let mut attributes = MaybeUninit::::uninit(); + if unsafe { + // SAFETY: attributes points to writable storage and pthread_self returns + // the calling thread's valid handle. + libc::pthread_getattr_np(libc::pthread_self(), attributes.as_mut_ptr()) + } != 0 + { + return None; + } + + let mut attributes = unsafe { + // SAFETY: pthread_getattr_np initialized attributes on its success path. + attributes.assume_init() + }; + let mut stack_start = std::ptr::null_mut(); + let mut stack_size = 0; + let result = unsafe { + // SAFETY: attributes was initialized above and both output pointers are + // valid for writes. + libc::pthread_attr_getstack(&attributes, &mut stack_start, &mut stack_size) + }; + unsafe { + // SAFETY: attributes was initialized and has not yet been destroyed. + libc::pthread_attr_destroy(&mut attributes); + } + if result != 0 { + return None; + } + + let start = stack_start as usize; + Some(start..start.checked_add(stack_size)?) +} + +unsafe fn image_relative_address(address: usize) -> Option { + struct Data { + address: usize, + relative: Option, + } + + unsafe extern "C" fn callback( + info: *mut libc::dl_phdr_info, + _size: usize, + opaque: *mut c_void, + ) -> c_int { + let data = unsafe { &mut *opaque.cast::() }; + let info = unsafe { &*info }; + let is_main_executable = + info.dlpi_name.is_null() || unsafe { *info.dlpi_name.cast::() == 0 }; + if !is_main_executable { + return 0; + } + let mut phdr = info.dlpi_phdr; + let end = unsafe { phdr.add(info.dlpi_phnum as usize) }; + + while phdr < end { + let header = unsafe { &*phdr }; + if header.p_type == libc::PT_LOAD { + let start = info.dlpi_addr.wrapping_add(header.p_vaddr) as usize; + let end = start.saturating_add(header.p_memsz as usize); + if data.address >= start && data.address < end { + data.relative = + Some(data.address.saturating_sub(info.dlpi_addr as usize)); + return 1; + } + } + phdr = unsafe { phdr.add(1) }; + } + 0 + } + + let mut data = Data { + address, + relative: None, + }; + unsafe { + libc::dl_iterate_phdr(Some(callback), (&mut data as *mut Data).cast()); + } + data.relative +} + +pub fn trace() -> String { + let mut encoded = Vec::new(); + let fp = frame_pointer(); + + for return_address in FramePointerIter::new(fp).take(MAX_FRAMES) { + // A return address points just after the call. Move it inside the calling + // instruction so an address on a function boundary is symbolicated as the + // caller rather than the next function. + let address = return_address.saturating_sub(1); + if let Some(relative) = unsafe { image_relative_address(address) } { + vlq_encode(relative as i32, &mut encoded); + } + } + + unsafe { String::from_utf8_unchecked(encoded) } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn invalid_frame_pointer_stops_safely() { + assert_eq!(FramePointerIter::new(1).next(), None); + assert_eq!(FramePointerIter::new(usize::MAX & !7).next(), None); + } + + #[test] + fn trace_is_not_empty() { + let trace = trace(); + println!("{trace}"); + assert!(!trace.is_empty()); + } +} diff --git a/src/lib.rs b/src/lib.rs index eea0821..29a254e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,13 +1,31 @@ - - pub mod vlq; -#[cfg(all(target_os = "windows", any(target_arch = "x86_64", target_arch = "aarch64")))] +#[cfg(all( + target_os = "windows", + any(target_arch = "x86_64", target_arch = "aarch64") +))] mod win64; -#[cfg(all(target_os = "windows", any(target_arch = "x86_64", target_arch = "aarch64")))] +#[cfg(all( + target_os = "windows", + any(target_arch = "x86_64", target_arch = "aarch64") +))] pub use win64::trace; +#[cfg(all( + feature = "frame-pointer", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] +mod frame_pointer; + +#[cfg(all( + feature = "frame-pointer", + target_os = "linux", + any(target_arch = "x86_64", target_arch = "aarch64") +))] +pub use frame_pointer::trace as trace_frame_pointer; + #[cfg(unix)] mod libunwind; diff --git a/wasm/Cargo.lock b/wasm/Cargo.lock index 13f73e0..ce3d7e6 100644 --- a/wasm/Cargo.lock +++ b/wasm/Cargo.lock @@ -133,7 +133,7 @@ dependencies = [ [[package]] name = "deno_panic" -version = "0.1.0" +version = "0.2.1" dependencies = [ "anyhow", "libc", diff --git a/wasm/deno.json b/wasm/deno.json index 7e490b0..28bfe6f 100644 --- a/wasm/deno.json +++ b/wasm/deno.json @@ -1,6 +1,6 @@ { "name": "@deno/panic", - "version": "0.1.0", + "version": "0.2.1", "license": "MIT", "exports": "./mod.ts", "tasks": {