use kernal_api::symbolize::wire::{FrameStatus, ModuleSymbolStatus, SymFrame, SymbolReport};
pub fn render_text(report: &SymbolReport) -> String {
let mut out = String::new();
if report.threads.is_empty() {
out.push_str("(no threads in report)\n");
return out;
}
let problems: Vec<_> = report
.modules
.iter()
.filter(|m| m.status == ModuleSymbolStatus::Mismatched)
.collect();
if !problems.is_empty() {
out.push_str("Symbol problems\n");
for module in problems {
out.push_str(&format!(
" {}: {} candidate(s) found but none matched this build\n",
module.name, module.rejected_candidates
));
}
out.push('\n');
}
for (position, thread) in report.threads.iter().enumerate() {
if position > 0 {
out.push('\n');
}
match &thread.name {
Some(name) => out.push_str(&format!("Thread {} ({name})\n", thread.os_tid)),
None => out.push_str(&format!("Thread {}\n", thread.os_tid)),
}
if thread.frames.is_empty() && thread.py_frames.is_empty() {
out.push_str(" (no frames captured)\n");
continue;
}
for (depth, frame) in thread.frames.iter().enumerate() {
out.push_str(&format!(" #{depth:<3} {}\n", render_frame(frame)));
for inline in &frame.inline_frames {
let location = match (&inline.file, inline.line) {
(Some(file), Some(line)) => format!(" ({file}:{line})"),
(Some(file), None) => format!(" ({file})"),
_ => String::new(),
};
out.push_str(&format!(" [inlined] {}{location}\n", inline.function));
}
}
if !thread.py_frames.is_empty() {
out.push_str(" Python:\n");
for frame in &thread.py_frames {
out.push_str(&format!(
" {}:{} in {}\n",
frame.file, frame.line, frame.func
));
}
}
}
out
}
fn render_frame(frame: &SymFrame) -> String {
let location = format!("{}+{:#x}", frame.module, frame.relative_address);
match frame.status {
FrameStatus::Resolved => {
let name = frame.function.as_deref().unwrap_or("<unnamed>");
match (&frame.file, frame.line) {
(Some(file), Some(line)) => format!("{location} {name} ({file}:{line})"),
(Some(file), None) => format!("{location} {name} ({file})"),
_ => format!("{location} {name}"),
}
}
FrameStatus::RawOnly => format!("{location} <no symbols for this module>"),
FrameStatus::ModuleUnknown => format!("{location} <address matched no module>"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kernal_api::symbolize::wire::{InlineFrame, PyFrame, SymThread};
fn frame(module: &str, addr: u64, status: FrameStatus, function: Option<&str>) -> SymFrame {
SymFrame {
module: module.into(),
relative_address: addr,
function: function.map(str::to_owned),
file: None,
line: None,
inline_frames: Vec::new(),
status,
}
}
fn report(threads: Vec<SymThread>) -> SymbolReport {
SymbolReport {
threads,
..Default::default()
}
}
#[test]
fn mismatched_modules_are_reported_above_the_stacks() {
use kernal_api::symbolize::wire::{ModuleReport, ModuleSymbolStatus};
let mut r = report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![frame("a.dll", 0x10, FrameStatus::RawOnly, None)],
py_frames: Vec::new(),
}]);
r.modules = vec![ModuleReport {
name: "a.dll".into(),
status: ModuleSymbolStatus::Mismatched,
rejected_candidates: 2,
..Default::default()
}];
let text = render_text(&r);
assert!(text.contains("Symbol problems"), "{text}");
assert!(text.contains("a.dll: 2 candidate"), "{text}");
let problems_at = text.find("Symbol problems").unwrap();
let thread_at = text.find("Thread 1").unwrap();
assert!(problems_at < thread_at, "problems must precede the stacks");
}
#[test]
fn a_clean_report_has_no_problems_section() {
let r = report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![frame("a.dll", 0x10, FrameStatus::Resolved, Some("f"))],
py_frames: Vec::new(),
}]);
assert!(!render_text(&r).contains("Symbol problems"));
}
#[test]
fn a_resolved_frame_shows_its_name_module_and_offset() {
let text = render_text(&report(vec![SymThread {
os_tid: 42,
name: None,
frames: vec![frame(
"a.dll",
0x1234,
FrameStatus::Resolved,
Some("do_work"),
)],
py_frames: Vec::new(),
}]));
assert!(text.contains("Thread 42"), "{text}");
assert!(text.contains("a.dll+0x1234"), "{text}");
assert!(text.contains("do_work"), "{text}");
}
#[test]
fn the_two_degradations_are_distinguishable() {
let text = render_text(&report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![
frame("a.dll", 0x10, FrameStatus::RawOnly, None),
frame("<unknown>", 0x20, FrameStatus::ModuleUnknown, None),
],
py_frames: Vec::new(),
}]));
assert!(text.contains("<no symbols for this module>"), "{text}");
assert!(text.contains("<address matched no module>"), "{text}");
}
#[test]
fn an_unresolved_frame_never_renders_an_empty_name() {
let text = render_text(&report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![frame("a.dll", 0x10, FrameStatus::RawOnly, None)],
py_frames: Vec::new(),
}]));
for line in text.lines().filter(|l| l.contains("a.dll")) {
assert!(
!line.trim_end().ends_with("a.dll+0x10"),
"the frame line stops after the offset, leaving the name column \
blank: {line:?}"
);
}
}
#[test]
fn offsets_are_preserved_even_when_unresolved() {
let text = render_text(&report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![frame("a.dll", 0xDEAD, FrameStatus::RawOnly, None)],
py_frames: Vec::new(),
}]));
assert!(
text.contains("0xdead"),
"the offset is the residual value: {text}"
);
}
#[test]
fn python_frames_are_labelled_separately_from_native_ones() {
let text = render_text(&report(vec![SymThread {
os_tid: 5,
name: None,
frames: vec![frame(
"a.dll",
0x10,
FrameStatus::Resolved,
Some("native_fn"),
)],
py_frames: vec![PyFrame {
file: "app.py".into(),
line: 12,
func: "handler".into(),
}],
}]));
assert!(text.contains("Python:"), "{text}");
assert!(text.contains("app.py:12 in handler"), "{text}");
let python_at = text.find("Python:").unwrap();
let native_at = text.find("native_fn").unwrap();
assert!(native_at < python_at, "{text}");
}
#[test]
fn frames_are_numbered_by_depth() {
let text = render_text(&report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![
frame("a.dll", 0x10, FrameStatus::Resolved, Some("inner")),
frame("a.dll", 0x20, FrameStatus::Resolved, Some("outer")),
],
py_frames: Vec::new(),
}]));
let inner = text.find("inner").unwrap();
let outer = text.find("outer").unwrap();
assert!(inner < outer, "innermost frame must print first: {text}");
assert!(text.contains("#0"), "{text}");
assert!(text.contains("#1"), "{text}");
}
#[test]
fn inlined_frames_are_marked_and_nested() {
let mut f = frame("a.dll", 0x10, FrameStatus::Resolved, Some("outer"));
f.inline_frames = vec![InlineFrame {
function: "folded".into(),
file: Some("src/x.rs".into()),
line: Some(7),
}];
let text = render_text(&report(vec![SymThread {
os_tid: 1,
name: None,
frames: vec![f],
py_frames: Vec::new(),
}]));
assert!(text.contains("[inlined] folded"), "{text}");
assert!(text.contains("src/x.rs:7"), "{text}");
}
#[test]
fn a_thread_name_is_shown_when_known() {
let text = render_text(&report(vec![SymThread {
os_tid: 9,
name: Some("rp-probe".into()),
frames: Vec::new(),
py_frames: Vec::new(),
}]));
assert!(text.contains("Thread 9 (rp-probe)"), "{text}");
}
#[test]
fn an_empty_report_says_so() {
let text = render_text(&report(Vec::new()));
assert!(!text.trim().is_empty());
assert!(text.contains("no threads"), "{text}");
}
#[test]
fn a_thread_with_no_frames_is_reported_as_such() {
let text = render_text(&report(vec![SymThread {
os_tid: 3,
name: None,
frames: Vec::new(),
py_frames: Vec::new(),
}]));
assert!(text.contains("Thread 3"), "{text}");
assert!(text.contains("no frames captured"), "{text}");
}
}