Skip to main content

r/interpreter/native/
stacktrace.rs

1//! Native symbol resolution for stack traces.
2//!
3//! Two layers of resolution:
4//! 1. **dladdr** — lightweight, zero deps: function name + library path
5//! 2. **addr2line/gimli** — DWARF debug info: file:line for C code
6//!
7//! The DWARF layer is optional and gracefully falls back to dladdr-only
8//! output when debug info is unavailable.
9//!
10//! Platform quirks handled:
11//! - **macOS**: dSYM bundles (`<lib>.dSYM/Contents/Resources/DWARF/<filename>`)
12//! - **Linux**: `.gnu_debuglink` section, build-id paths, `/usr/lib/debug/` mirror
13//! - **musl**: no `dladdr` or `backtrace()` — graceful no-op
14
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17
18/// A resolved native stack frame.
19#[derive(Debug, Clone)]
20pub struct ResolvedFrame {
21    /// Raw instruction pointer address.
22    pub address: usize,
23    /// Demangled function name (if available).
24    pub function: Option<String>,
25    /// Path to the shared library containing this frame.
26    pub library: Option<String>,
27    /// Offset from the start of the function.
28    pub offset: usize,
29    /// Source file (from DWARF debug info, if available).
30    pub file: Option<String>,
31    /// Source line number (from DWARF debug info, if available).
32    pub line: Option<u32>,
33}
34
35// region: dladdr FFI
36//
37// dladdr is available on macOS (always) and Linux with glibc.
38// On musl Linux it does not exist — the entire dladdr layer is a no-op.
39
40#[cfg(any(target_os = "macos", target_env = "gnu"))]
41mod dladdr_ffi {
42    use std::ffi::{c_void, CStr};
43    use std::os::raw::c_char;
44    use std::path::Path;
45
46    use super::ResolvedFrame;
47
48    #[repr(C)]
49    struct DlInfo {
50        dli_fname: *const c_char,
51        dli_fbase: *mut c_void,
52        dli_sname: *const c_char,
53        dli_saddr: *mut c_void,
54    }
55
56    extern "C" {
57        fn dladdr(addr: *const c_void, info: *mut DlInfo) -> i32;
58    }
59
60    /// Result of dladdr lookup — raw info needed for DWARF resolution.
61    pub struct DladdrResult {
62        pub frame: ResolvedFrame,
63        /// Full path to the shared library (for DWARF lookup).
64        pub library_path: Option<String>,
65        /// Base address where the library is loaded (for address rebasing).
66        pub library_base: usize,
67    }
68
69    /// Resolve a single address via dladdr.
70    pub fn resolve(addr: usize) -> DladdrResult {
71        let mut info: DlInfo = unsafe { std::mem::zeroed() };
72        let ret = unsafe { dladdr(addr as *const c_void, &mut info) };
73        if ret == 0 {
74            return DladdrResult {
75                frame: ResolvedFrame {
76                    address: addr,
77                    function: None,
78                    library: None,
79                    offset: 0,
80                    file: None,
81                    line: None,
82                },
83                library_path: None,
84                library_base: 0,
85            };
86        }
87
88        let function = if info.dli_sname.is_null() {
89            None
90        } else {
91            unsafe { CStr::from_ptr(info.dli_sname) }
92                .to_str()
93                .ok()
94                .map(String::from)
95        };
96
97        let (library, library_path) = if info.dli_fname.is_null() {
98            (None, None)
99        } else {
100            let full_path = unsafe { CStr::from_ptr(info.dli_fname) }
101                .to_str()
102                .ok()
103                .map(String::from);
104            let short_name = full_path.as_deref().map(|s| {
105                Path::new(s)
106                    .file_name()
107                    .and_then(|f| f.to_str())
108                    .unwrap_or(s)
109                    .to_string()
110            });
111            (short_name, full_path)
112        };
113
114        let offset = if info.dli_saddr.is_null() {
115            0
116        } else {
117            addr.wrapping_sub(info.dli_saddr as usize)
118        };
119
120        let library_base = info.dli_fbase as usize;
121
122        DladdrResult {
123            frame: ResolvedFrame {
124                address: addr,
125                function,
126                library,
127                offset,
128                file: None,
129                line: None,
130            },
131            library_path,
132            library_base,
133        }
134    }
135}
136
137/// Windows: use DbgHelp API for symbol resolution (function name + file:line).
138/// DbgHelp reads PDB debug info natively — no DWARF/gimli needed.
139#[cfg(windows)]
140mod dladdr_ffi {
141    use std::ffi::c_void;
142    use std::path::Path;
143
144    use super::ResolvedFrame;
145
146    // DbgHelp FFI types
147    const MAX_SYM_NAME: usize = 256;
148
149    #[repr(C)]
150    struct SymbolInfo {
151        size_of_struct: u32,
152        type_index: u32,
153        reserved: [u64; 2],
154        index: u32,
155        size: u32,
156        mod_base: u64,
157        flags: u32,
158        value: u64,
159        address: u64,
160        register: u32,
161        scope: u32,
162        tag: u32,
163        name_len: u32,
164        max_name_len: u32,
165        name: [u8; MAX_SYM_NAME],
166    }
167
168    #[repr(C)]
169    struct ImagehlpLine64 {
170        size_of_struct: u32,
171        key: *mut c_void,
172        line_number: u32,
173        file_name: *const u8,
174        address: u64,
175    }
176
177    #[repr(C)]
178    struct ImagehlpModule64 {
179        size_of_struct: u32,
180        base_of_image: u64,
181        image_size: u32,
182        time_date_stamp: u32,
183        check_sum: u32,
184        num_syms: u32,
185        sym_type: u32,
186        module_name: [u8; 32],
187        image_name: [u8; 256],
188        loaded_image_name: [u8; 256],
189        // ... more fields we don't need
190    }
191
192    extern "system" {
193        fn GetCurrentProcess() -> *mut c_void;
194        fn SymInitialize(process: *mut c_void, search_path: *const u8, invade: i32) -> i32;
195        fn SymFromAddr(
196            process: *mut c_void,
197            address: u64,
198            displacement: *mut u64,
199            symbol: *mut SymbolInfo,
200        ) -> i32;
201        fn SymGetLineFromAddr64(
202            process: *mut c_void,
203            address: u64,
204            displacement: *mut u32,
205            line: *mut ImagehlpLine64,
206        ) -> i32;
207        fn SymGetModuleInfo64(
208            process: *mut c_void,
209            address: u64,
210            module_info: *mut ImagehlpModule64,
211        ) -> i32;
212    }
213
214    use std::sync::Once;
215    static DBGHELP_INIT: Once = Once::new();
216
217    fn ensure_dbghelp_init() {
218        DBGHELP_INIT.call_once(|| unsafe {
219            let process = GetCurrentProcess();
220            SymInitialize(process, std::ptr::null(), 1);
221        });
222    }
223
224    pub struct DladdrResult {
225        pub frame: ResolvedFrame,
226        pub library_path: Option<String>,
227        pub library_base: usize,
228    }
229
230    /// Resolve using DbgHelp — gets function name, file:line, and module in one pass.
231    pub fn resolve(addr: usize) -> DladdrResult {
232        ensure_dbghelp_init();
233
234        let process = unsafe { GetCurrentProcess() };
235        let mut function = None;
236        let mut library = None;
237        let mut library_path = None;
238        let mut offset = 0usize;
239        let mut file = None;
240        let mut line = None;
241
242        // Resolve function name
243        let mut sym_info: SymbolInfo = unsafe { std::mem::zeroed() };
244        sym_info.size_of_struct =
245            std::mem::size_of::<SymbolInfo>() as u32 - MAX_SYM_NAME as u32 + 1;
246        sym_info.max_name_len = MAX_SYM_NAME as u32;
247        let mut displacement: u64 = 0;
248        if unsafe { SymFromAddr(process, addr as u64, &mut displacement, &mut sym_info) } != 0 {
249            let name_len = sym_info.name_len as usize;
250            if let Ok(name) = std::str::from_utf8(&sym_info.name[..name_len]) {
251                function = Some(name.to_string());
252            }
253            offset = displacement as usize;
254        }
255
256        // Resolve file:line
257        let mut line_info: ImagehlpLine64 = unsafe { std::mem::zeroed() };
258        line_info.size_of_struct = std::mem::size_of::<ImagehlpLine64>() as u32;
259        let mut line_displacement: u32 = 0;
260        if unsafe {
261            SymGetLineFromAddr64(process, addr as u64, &mut line_displacement, &mut line_info)
262        } != 0
263        {
264            line = Some(line_info.line_number);
265            if !line_info.file_name.is_null() {
266                let c_str = unsafe { std::ffi::CStr::from_ptr(line_info.file_name as *const _) };
267                if let Ok(s) = c_str.to_str() {
268                    file = Some(
269                        Path::new(s)
270                            .file_name()
271                            .and_then(|n| n.to_str())
272                            .unwrap_or(s)
273                            .to_string(),
274                    );
275                }
276            }
277        }
278
279        // Resolve module (library)
280        let mut mod_info: ImagehlpModule64 = unsafe { std::mem::zeroed() };
281        mod_info.size_of_struct = std::mem::size_of::<ImagehlpModule64>() as u32;
282        if unsafe { SymGetModuleInfo64(process, addr as u64, &mut mod_info) } != 0 {
283            let nul_pos = mod_info
284                .image_name
285                .iter()
286                .position(|&b| b == 0)
287                .unwrap_or(mod_info.image_name.len());
288            if let Ok(path_str) = std::str::from_utf8(&mod_info.image_name[..nul_pos]) {
289                library_path = Some(path_str.to_string());
290                library = Some(
291                    Path::new(path_str)
292                        .file_name()
293                        .and_then(|n| n.to_str())
294                        .unwrap_or(path_str)
295                        .to_string(),
296                );
297            }
298        }
299
300        DladdrResult {
301            frame: ResolvedFrame {
302                address: addr,
303                function,
304                library,
305                offset,
306                file,
307                line,
308            },
309            library_path,
310            library_base: 0,
311        }
312    }
313}
314
315/// Fallback for platforms without dladdr or DbgHelp (e.g., musl Linux).
316#[cfg(not(any(target_os = "macos", target_env = "gnu", windows)))]
317mod dladdr_ffi {
318    use super::ResolvedFrame;
319
320    pub struct DladdrResult {
321        pub frame: ResolvedFrame,
322        pub library_path: Option<String>,
323        pub library_base: usize,
324    }
325
326    pub fn resolve(addr: usize) -> DladdrResult {
327        DladdrResult {
328            frame: ResolvedFrame {
329                address: addr,
330                function: None,
331                library: None,
332                offset: 0,
333                file: None,
334                line: None,
335            },
336            library_path: None,
337            library_base: 0,
338        }
339    }
340}
341
342// endregion
343
344// region: DWARF debug info discovery (not used on Windows — DbgHelp reads PDB natively)
345//
346// Finding DWARF debug info is platform-specific:
347// - macOS: .dSYM bundles next to the library
348// - Linux: .gnu_debuglink section, build-id paths, /usr/lib/debug/ mirror
349// - Fallback: embedded in the binary itself
350
351/// Find the file containing DWARF debug info for a given library.
352#[cfg(not(windows))]
353/// Returns the library_path itself if debug info is embedded, or a
354/// separate debug file path if found via platform-specific mechanisms.
355fn find_debug_file(library_path: &str) -> PathBuf {
356    let lib = Path::new(library_path);
357
358    // macOS: check for .dSYM bundle
359    #[cfg(target_os = "macos")]
360    if let Some(dsym) = find_dsym(lib) {
361        return dsym;
362    }
363
364    // Linux: check .gnu_debuglink, build-id, and /usr/lib/debug/ mirror
365    #[cfg(target_os = "linux")]
366    if let Some(debug) = find_linux_debug_file(lib) {
367        return debug;
368    }
369
370    // Fallback: debug info embedded in the binary itself
371    lib.to_path_buf()
372}
373
374/// macOS: look for `<path>.dSYM/Contents/Resources/DWARF/<filename>`.
375#[cfg(target_os = "macos")]
376fn find_dsym(lib: &Path) -> Option<PathBuf> {
377    let filename = lib.file_name()?.to_str()?;
378    let dsym = lib
379        .parent()?
380        .join(format!("{}.dSYM", filename))
381        .join("Contents")
382        .join("Resources")
383        .join("DWARF")
384        .join(filename);
385    if dsym.exists() {
386        Some(dsym)
387    } else {
388        None
389    }
390}
391
392/// Linux: find separate debug info via multiple strategies.
393#[cfg(target_os = "linux")]
394fn find_linux_debug_file(lib: &Path) -> Option<PathBuf> {
395    // Strategy 1: .gnu_debuglink section in the binary
396    if let Some(path) = find_gnu_debuglink(lib) {
397        return Some(path);
398    }
399
400    // Strategy 2: build-id path (/usr/lib/debug/.build-id/<xx>/<rest>.debug)
401    if let Some(path) = find_build_id_debug(lib) {
402        return Some(path);
403    }
404
405    // Strategy 3: /usr/lib/debug mirror of the original path
406    let debug_mirror = Path::new("/usr/lib/debug").join(lib.strip_prefix("/").unwrap_or(lib));
407    if debug_mirror.exists() {
408        return Some(debug_mirror);
409    }
410
411    None
412}
413
414/// Read the `.gnu_debuglink` section to find a separate debug file.
415/// The section contains a filename (no directory) and a CRC32 checksum.
416/// Search order: same directory as the library, then `/usr/lib/debug/`.
417#[cfg(target_os = "linux")]
418fn find_gnu_debuglink(lib: &Path) -> Option<PathBuf> {
419    use object::{Object as _, ObjectSection as _};
420
421    let bytes = std::fs::read(lib).ok()?;
422    let object = object::File::parse(&*bytes).ok()?;
423    let section = object.section_by_name(".gnu_debuglink")?;
424    let data = section.data().ok()?;
425
426    // The section contains: null-terminated filename, padding, 4-byte CRC32.
427    // We only need the filename.
428    let nul_pos = data.iter().position(|&b| b == 0)?;
429    let debug_filename = std::str::from_utf8(&data[..nul_pos]).ok()?;
430
431    // Check same directory as the library
432    if let Some(dir) = lib.parent() {
433        let candidate = dir.join(debug_filename);
434        if candidate.exists() {
435            return Some(candidate);
436        }
437        // Also check a .debug/ subdirectory
438        let candidate = dir.join(".debug").join(debug_filename);
439        if candidate.exists() {
440            return Some(candidate);
441        }
442    }
443
444    // Check /usr/lib/debug/ + original directory
445    if let Some(dir) = lib.parent() {
446        let candidate = Path::new("/usr/lib/debug")
447            .join(dir.strip_prefix("/").unwrap_or(dir))
448            .join(debug_filename);
449        if candidate.exists() {
450            return Some(candidate);
451        }
452    }
453
454    None
455}
456
457/// Read the ELF `.note.gnu.build-id` section and check
458/// `/usr/lib/debug/.build-id/<xx>/<rest>.debug`.
459#[cfg(target_os = "linux")]
460fn find_build_id_debug(lib: &Path) -> Option<PathBuf> {
461    use object::{Object as _, ObjectSection as _};
462
463    let bytes = std::fs::read(lib).ok()?;
464    let object = object::File::parse(&*bytes).ok()?;
465
466    // The build-id is in the .note.gnu.build-id section.
467    // Format: 4-byte namesz, 4-byte descsz, 4-byte type, name, desc(build-id)
468    let section = object.section_by_name(".note.gnu.build-id")?;
469    let data = section.data().ok()?;
470    if data.len() < 16 {
471        return None;
472    }
473
474    let namesz = u32::from_le_bytes(data[0..4].try_into().ok()?) as usize;
475    let descsz = u32::from_le_bytes(data[4..8].try_into().ok()?) as usize;
476    // Skip: type (4 bytes), name (namesz aligned to 4), then desc is the build-id
477    let name_end = 12 + ((namesz + 3) & !3);
478    if data.len() < name_end + descsz || descsz < 2 {
479        return None;
480    }
481    let build_id = &data[name_end..name_end + descsz];
482
483    // Convert to hex: first byte is directory, rest is filename
484    let hex: String = build_id.iter().map(|b| format!("{:02x}", b)).collect();
485    let (dir_part, file_part) = hex.split_at(2);
486    let candidate = Path::new("/usr/lib/debug/.build-id")
487        .join(dir_part)
488        .join(format!("{}.debug", file_part));
489    if candidate.exists() {
490        Some(candidate)
491    } else {
492        None
493    }
494}
495
496// endregion
497
498// region: DWARF resolution via addr2line (not used on Windows)
499
500/// Cache of addr2line contexts, keyed by library path.
501#[cfg(not(windows))]
502/// Library bytes are leaked to get a `'static` lifetime for gimli's
503/// `EndianSlice`. This is bounded by the number of unique .so files
504/// loaded per session (typically < 20).
505struct DwarfCache {
506    /// Map from library path → addr2line Context (or None if DWARF unavailable).
507    contexts: HashMap<
508        String,
509        Option<addr2line::Context<gimli::EndianSlice<'static, gimli::RunTimeEndian>>>,
510    >,
511}
512
513#[cfg(not(windows))]
514impl DwarfCache {
515    fn new() -> Self {
516        Self {
517            contexts: HashMap::new(),
518        }
519    }
520
521    /// Get or create an addr2line Context for the given library path.
522    fn get_context(
523        &mut self,
524        library_path: &str,
525    ) -> Option<&addr2line::Context<gimli::EndianSlice<'static, gimli::RunTimeEndian>>> {
526        if !self.contexts.contains_key(library_path) {
527            let ctx = Self::load_context(library_path);
528            self.contexts.insert(library_path.to_string(), ctx);
529        }
530        self.contexts.get(library_path).and_then(|opt| opt.as_ref())
531    }
532
533    /// Try to load DWARF debug info from a shared library.
534    /// Uses platform-specific discovery to find debug info in dSYM bundles,
535    /// .gnu_debuglink targets, build-id paths, or the binary itself.
536    fn load_context(
537        library_path: &str,
538    ) -> Option<addr2line::Context<gimli::EndianSlice<'static, gimli::RunTimeEndian>>> {
539        let debug_path = find_debug_file(library_path);
540        let bytes = std::fs::read(&debug_path).ok()?;
541        // Leak the bytes to get 'static lifetime for gimli slices.
542        // Bounded by number of unique libraries per session.
543        let bytes: &'static [u8] = Vec::leak(bytes);
544
545        use object::Object as _;
546        let object = object::File::parse(bytes).ok()?;
547        let endian = if object.is_little_endian() {
548            gimli::RunTimeEndian::Little
549        } else {
550            gimli::RunTimeEndian::Big
551        };
552
553        let dwarf = gimli::Dwarf::load(|section_id| -> Result<_, gimli::Error> {
554            use object::ObjectSection as _;
555            let data = object
556                .section_by_name(section_id.name())
557                .and_then(|s: object::Section<'_, '_>| s.uncompressed_data().ok())
558                .unwrap_or(std::borrow::Cow::Borrowed(&[]));
559            let slice: &'static [u8] = match data {
560                std::borrow::Cow::Borrowed(b) => b,
561                std::borrow::Cow::Owned(v) => Vec::leak(v),
562            };
563            Ok(gimli::EndianSlice::new(slice, endian))
564        })
565        .ok()?;
566
567        addr2line::Context::from_dwarf(dwarf).ok()
568    }
569}
570
571#[cfg(not(windows))]
572thread_local! {
573    static DWARF_CACHE: std::cell::RefCell<DwarfCache> = std::cell::RefCell::new(DwarfCache::new());
574}
575
576/// Try to resolve file:line for an address using DWARF debug info.
577#[cfg(not(windows))]
578fn dwarf_resolve(
579    addr: usize,
580    library_path: &str,
581    library_base: usize,
582) -> (Option<String>, Option<u32>) {
583    // The address in the backtrace is absolute; DWARF uses relative offsets.
584    let relative_addr = (addr as u64).wrapping_sub(library_base as u64);
585
586    DWARF_CACHE
587        .with(|cache| {
588            let mut cache = cache.borrow_mut();
589            let ctx = cache.get_context(library_path)?;
590
591            if let Ok(Some(loc)) = ctx.find_location(relative_addr) {
592                let file = loc.file.map(|f| {
593                    // Show just the filename for short paths
594                    Path::new(f)
595                        .file_name()
596                        .and_then(|n| n.to_str())
597                        .unwrap_or(f)
598                        .to_string()
599                });
600                let line = loc.line;
601                Some((file, line))
602            } else {
603                None
604            }
605        })
606        .unwrap_or((None, None))
607}
608
609// endregion
610
611// region: Public API
612
613/// Resolve a native backtrace into human-readable frames.
614///
615/// Filters out internal frames (trampoline, backtrace machinery) to show
616/// only the interesting package code between the .Call entry and the error.
617pub fn resolve_native_backtrace(frames: &[usize]) -> Vec<ResolvedFrame> {
618    // First pass: dladdr resolution for all frames
619    let mut dladdr_results: Vec<dladdr_ffi::DladdrResult> = frames
620        .iter()
621        .map(|&addr| dladdr_ffi::resolve(addr))
622        .collect();
623
624    // Second pass: DWARF resolution where we have library paths.
625    // Skip on Windows — DbgHelp already resolves file:line from PDB in the first pass.
626    #[cfg(not(windows))]
627    for result in &mut dladdr_results {
628        if let Some(ref lib_path) = result.library_path {
629            if result.frame.file.is_none() {
630                let (file, line) =
631                    dwarf_resolve(result.frame.address, lib_path, result.library_base);
632                result.frame.file = file;
633                result.frame.line = line;
634            }
635        }
636    }
637
638    // Filter: skip error machinery frames, stop at trampoline
639    let resolved: Vec<ResolvedFrame> = dladdr_results.into_iter().map(|r| r.frame).collect();
640    let mut result = Vec::new();
641    let mut started = false;
642    for frame in &resolved {
643        let name = frame.function.as_deref().unwrap_or("");
644
645        if !started {
646            if name.contains("Rf_error")
647                || name.contains("Rf_errorcall")
648                || name == "backtrace"
649                || name.contains("longjmp")
650                || name.contains("_sigtramp")
651            {
652                continue;
653            }
654            started = true;
655        }
656
657        if name.contains("_minir_call_protected") || name.contains("_minir_dotC_call_protected") {
658            break;
659        }
660
661        result.push(frame.clone());
662    }
663
664    if result.is_empty() && !resolved.is_empty() {
665        return resolved;
666    }
667
668    result
669}
670
671/// Format resolved native frames as indented lines for display under an R call frame.
672pub fn format_native_frames(frames: &[ResolvedFrame]) -> String {
673    let mut lines = Vec::with_capacity(frames.len());
674    for frame in frames {
675        let addr_fallback = format!("0x{:x}", frame.address);
676        let func = frame.function.as_deref().unwrap_or(&addr_fallback);
677        let lib = frame.library.as_deref().unwrap_or("???");
678
679        // Build the location suffix: " at file.c:42" if DWARF info available
680        let location = match (&frame.file, frame.line) {
681            (Some(file), Some(line)) => format!(" at {}:{}", file, line),
682            (Some(file), None) => format!(" at {}", file),
683            _ => String::new(),
684        };
685
686        if frame.offset > 0 && location.is_empty() {
687            lines.push(format!("   [C] {}+0x{:x} ({})", func, frame.offset, lib));
688        } else {
689            lines.push(format!("   [C] {}{} ({})", func, location, lib));
690        }
691    }
692    lines.join("\n")
693}
694
695// endregion