use kernal_api::symbolize::wire::{
CaptureFormat, FrameStatus, ModuleReport, ModuleSymbolStatus, RawCapture, RawThread, SymFrame,
SymThread, SymbolReport,
};
#[derive(Debug, thiserror::Error)]
pub enum SymbolizeError {
#[error("capture format {0:?} is not supported yet")]
UnsupportedFormat(CaptureFormat),
}
pub const UNKNOWN_MODULE: &str = "<unknown>";
pub fn symbolize(capture: &RawCapture) -> Result<SymbolReport, SymbolizeError> {
match capture.format {
CaptureFormat::CooperativeFrames => {}
other => return Err(SymbolizeError::UnsupportedFormat(other)),
}
let cache = build_symbol_cache(capture);
let lines = build_line_cache(
capture,
&cache,
crate::line_numbers::line_numbers_requested(),
);
let threads = capture
.threads
.iter()
.map(|thread| symbolize_thread(capture, &cache, &lines, thread))
.collect();
let modules = module_reports(capture, &cache);
Ok(SymbolReport { threads, modules })
}
#[cfg(target_os = "windows")]
type SymbolCache = Vec<crate::pdb_symbols::ModuleSymbols>;
#[cfg(not(target_os = "windows"))]
type SymbolCache = Vec<crate::object_symbols::ModuleSymbols>;
#[cfg(target_os = "windows")]
fn build_symbol_cache(capture: &RawCapture) -> SymbolCache {
capture
.modules
.iter()
.map(|module| crate::pdb_symbols::discover_module(module, &capture.discovery))
.collect()
}
#[cfg(not(target_os = "windows"))]
fn build_symbol_cache(capture: &RawCapture) -> SymbolCache {
capture
.modules
.iter()
.map(|module| crate::object_symbols::discover_module(module, &capture.discovery))
.collect()
}
#[cfg(target_os = "windows")]
fn module_reports(capture: &RawCapture, cache: &SymbolCache) -> Vec<ModuleReport> {
use crate::pdb_symbols::ModuleSymbols;
capture
.modules
.iter()
.zip(cache)
.map(|(module, symbols)| {
let (status, symbol_file, symbol_source, rejected) = match symbols {
ModuleSymbols::Found {
symbol_file,
source,
..
} => (
ModuleSymbolStatus::Resolved,
Some(symbol_file.clone()),
Some(*source),
0,
),
ModuleSymbols::NotFound => (ModuleSymbolStatus::NotFound, None, None, 0),
ModuleSymbols::Mismatched { rejected } => {
(ModuleSymbolStatus::Mismatched, None, None, *rejected)
}
ModuleSymbols::NoDebugDirectory => {
(ModuleSymbolStatus::NoDebugDirectory, None, None, 0)
}
};
ModuleReport {
name: module.name.clone(),
status,
symbol_file,
symbol_source,
rejected_candidates: rejected,
}
})
.collect()
}
#[cfg(not(target_os = "windows"))]
fn module_reports(capture: &RawCapture, cache: &SymbolCache) -> Vec<ModuleReport> {
use crate::object_symbols::ModuleSymbols;
capture
.modules
.iter()
.zip(cache)
.map(|(module, symbols)| {
let (status, symbol_file, symbol_source, rejected) = match symbols {
ModuleSymbols::Found {
symbol_file,
source,
..
} => (
ModuleSymbolStatus::Resolved,
Some(symbol_file.clone()),
Some(*source),
0,
),
ModuleSymbols::NotFound => (ModuleSymbolStatus::NotFound, None, None, 0),
ModuleSymbols::Mismatched { rejected } => {
(ModuleSymbolStatus::Mismatched, None, None, *rejected)
}
ModuleSymbols::NoDebugDirectory => {
(ModuleSymbolStatus::NoDebugDirectory, None, None, 0)
}
};
ModuleReport {
name: module.name.clone(),
status,
symbol_file,
symbol_source,
rejected_candidates: rejected,
}
})
.collect()
}
#[cfg(target_os = "windows")]
fn lookup(cache: &SymbolCache, module_index: usize, relative_address: u64) -> Option<String> {
let crate::pdb_symbols::ModuleSymbols::Found { table, .. } = cache.get(module_index)? else {
return None;
};
table.lookup(relative_address).map(str::to_owned)
}
#[cfg(not(target_os = "windows"))]
fn lookup(cache: &SymbolCache, module_index: usize, relative_address: u64) -> Option<String> {
let crate::object_symbols::ModuleSymbols::Found { table, .. } = cache.get(module_index)? else {
return None;
};
table.lookup(relative_address).map(str::to_owned)
}
#[cfg(target_os = "windows")]
type LineCache = Vec<std::collections::HashMap<u64, (String, u32)>>;
#[cfg(not(target_os = "windows"))]
struct LineCache;
#[cfg(target_os = "windows")]
fn build_line_cache(capture: &RawCapture, cache: &SymbolCache, requested: bool) -> LineCache {
use crate::pdb_symbols::ModuleSymbols;
use std::collections::HashMap;
let mut per_module: Vec<HashMap<u64, (String, u32)>> =
vec![HashMap::new(); capture.modules.len()];
if !requested {
return per_module;
}
let mut wanted: Vec<Vec<u64>> = vec![Vec::new(); capture.modules.len()];
for thread in &capture.threads {
for frame in &thread.frames {
if let Some(addresses) = wanted.get_mut(frame.module_index as usize) {
addresses.push(frame.relative_address);
}
}
}
for (index, addresses) in wanted.iter_mut().enumerate() {
if addresses.is_empty() {
continue;
}
addresses.sort_unstable();
addresses.dedup();
let Some(ModuleSymbols::Found {
symbol_file,
retained,
..
}) = cache.get(index)
else {
continue;
};
let path = match retained {
Some(retained) => retained.as_ref(),
None => std::path::Path::new(symbol_file.as_str()),
};
if !path.is_file() {
continue;
}
per_module[index] = crate::pdb_symbols::resolve_lines(path, addresses);
}
per_module
}
#[cfg(not(target_os = "windows"))]
fn build_line_cache(_capture: &RawCapture, _cache: &SymbolCache, _requested: bool) -> LineCache {
LineCache
}
#[cfg(not(target_os = "windows"))]
fn lookup_line(
cache: &SymbolCache,
_lines: &LineCache,
module_index: usize,
relative_address: u64,
) -> Option<(String, u32)> {
let crate::object_symbols::ModuleSymbols::Found { lines, .. } = cache.get(module_index)? else {
return None;
};
lines
.as_ref()?
.lookup(relative_address)
.map(|(file, line)| (file.to_owned(), line))
}
#[cfg(target_os = "windows")]
fn lookup_line(
_cache: &SymbolCache,
lines: &LineCache,
module_index: usize,
relative_address: u64,
) -> Option<(String, u32)> {
lines.get(module_index)?.get(&relative_address).cloned()
}
fn symbolize_thread(
capture: &RawCapture,
cache: &SymbolCache,
lines: &LineCache,
thread: &RawThread,
) -> SymThread {
let frames = thread
.frames
.iter()
.map(|frame| {
match capture.modules.get(frame.module_index as usize) {
Some(module) => {
let function =
lookup(cache, frame.module_index as usize, frame.relative_address);
let (file, line) = match lookup_line(
cache,
lines,
frame.module_index as usize,
frame.relative_address,
) {
Some((file, line)) => (Some(file), Some(line)),
None => (None, None),
};
SymFrame {
module: module.name.clone(),
relative_address: frame.relative_address,
status: if function.is_some() {
FrameStatus::Resolved
} else {
FrameStatus::RawOnly
},
function,
file,
line,
inline_frames: Vec::new(),
}
}
None => SymFrame {
module: UNKNOWN_MODULE.to_string(),
relative_address: frame.relative_address,
function: None,
file: None,
line: None,
inline_frames: Vec::new(),
status: FrameStatus::ModuleUnknown,
},
}
})
.collect();
SymThread {
os_tid: thread.os_tid,
name: thread.name.clone(),
frames,
py_frames: thread.py_frames.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use kernal_api::symbolize::wire::{ModuleRef, PyFrame, RawFrame};
fn capture_with(modules: Vec<ModuleRef>, frames: Vec<RawFrame>) -> RawCapture {
RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules,
threads: vec![RawThread {
os_tid: 11,
name: Some("t".into()),
frames,
py_frames: Vec::new(),
}],
}
}
fn module(name: &str) -> ModuleRef {
ModuleRef {
name: name.into(),
..Default::default()
}
}
#[test]
fn every_module_is_accounted_for() {
let capture = capture_with(vec![module("a.dll"), module("b.dll")], Vec::new());
let report = symbolize(&capture).unwrap();
assert_eq!(report.modules.len(), 2);
assert_eq!(report.modules[0].name, "a.dll");
assert_eq!(report.modules[1].name, "b.dll");
}
#[test]
fn a_module_without_a_path_is_not_found_rather_than_stripped() {
let capture = capture_with(vec![module("ghost.dll")], Vec::new());
let status = symbolize(&capture).unwrap().modules[0].status;
assert!(
matches!(
status,
ModuleSymbolStatus::NotFound | ModuleSymbolStatus::Unsupported
),
"got {status:?}"
);
}
#[cfg(target_os = "windows")]
#[test]
fn a_missing_binary_reports_not_found_with_no_rejections() {
let capture = RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules: vec![ModuleRef {
name: "gone.dll".into(),
path_hint: Some("no-such-binary-anywhere.dll".into()),
..Default::default()
}],
threads: Vec::new(),
};
let entry = symbolize(&capture).unwrap().modules.remove(0);
assert_eq!(entry.status, ModuleSymbolStatus::NoDebugDirectory);
assert_eq!(entry.rejected_candidates, 0);
assert!(entry.symbol_file.is_none());
}
#[test]
fn module_status_uses_stable_wire_numbers() {
assert_eq!(ModuleSymbolStatus::NotFound as i32, 0);
assert_eq!(ModuleSymbolStatus::Mismatched as i32, 2);
assert_eq!(ModuleSymbolStatus::NoDebugDirectory as i32, 3);
}
#[test]
fn frames_are_attributed_to_their_module() {
let capture = capture_with(
vec![module("a.dll"), module("b.dll")],
vec![
RawFrame {
module_index: 1,
relative_address: 0x20,
},
RawFrame {
module_index: 0,
relative_address: 0x10,
},
],
);
let report = symbolize(&capture).unwrap();
let frames = &report.threads[0].frames;
assert_eq!(frames[0].module, "b.dll");
assert_eq!(frames[0].relative_address, 0x20);
assert_eq!(frames[1].module, "a.dll");
assert_eq!(frames[1].relative_address, 0x10);
}
#[test]
fn an_unknown_module_index_keeps_the_offset() {
let capture = capture_with(
vec![module("a.dll")],
vec![RawFrame {
module_index: 99,
relative_address: 0xDEAD,
}],
);
let frame = &symbolize(&capture).unwrap().threads[0].frames[0];
assert_eq!(frame.status, FrameStatus::ModuleUnknown);
assert_eq!(frame.relative_address, 0xDEAD);
assert_eq!(frame.module, UNKNOWN_MODULE);
assert!(frame.function.is_none());
}
#[test]
fn a_bad_frame_does_not_discard_its_neighbours() {
let capture = capture_with(
vec![module("a.dll")],
vec![
RawFrame {
module_index: 0,
relative_address: 1,
},
RawFrame {
module_index: 7,
relative_address: 2,
},
RawFrame {
module_index: 0,
relative_address: 3,
},
],
);
let frames = &symbolize(&capture).unwrap().threads[0].frames;
assert_eq!(frames.len(), 3, "no frame may be dropped");
assert_eq!(frames[0].status, FrameStatus::RawOnly);
assert_eq!(frames[1].status, FrameStatus::ModuleUnknown);
assert_eq!(frames[2].status, FrameStatus::RawOnly);
}
#[test]
fn nothing_is_reported_as_resolved_without_symbols() {
let capture = capture_with(
vec![module("a.dll")],
vec![RawFrame {
module_index: 0,
relative_address: 0x40,
}],
);
for frame in &symbolize(&capture).unwrap().threads[0].frames {
assert_ne!(
frame.status,
FrameStatus::Resolved,
"a name was claimed without any symbol file being read"
);
assert!(frame.function.is_none());
}
}
#[test]
fn python_frames_pass_through_untouched() {
let py = PyFrame {
file: "app.py".into(),
line: 12,
func: "handler".into(),
};
let mut capture = capture_with(vec![module("a.dll")], Vec::new());
capture.threads[0].py_frames = vec![py.clone()];
let thread = &symbolize(&capture).unwrap().threads[0];
assert_eq!(thread.py_frames, vec![py]);
}
#[test]
fn thread_identity_and_order_survive() {
let capture = RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules: vec![module("a.dll")],
threads: vec![
RawThread {
os_tid: 100,
name: Some("first".into()),
..Default::default()
},
RawThread {
os_tid: 200,
name: None,
..Default::default()
},
],
};
let report = symbolize(&capture).unwrap();
assert_eq!(report.threads.len(), 2);
assert_eq!(report.threads[0].os_tid, 100);
assert_eq!(report.threads[0].name.as_deref(), Some("first"));
assert_eq!(report.threads[1].os_tid, 200);
assert_eq!(report.threads[1].name, None);
}
#[cfg(target_os = "windows")]
#[test]
fn a_real_address_resolves_to_a_real_function_name() {
use crate::pdb_symbols::SymbolTable;
let exe = std::env::current_exe().expect("current exe");
let pdb = exe.with_extension("pdb");
if !pdb.is_file() {
assert!(
std::env::var_os("GITHUB_ACTIONS").is_none(),
"no PDB at {} during a CI run; this test would assert nothing",
pdb.display()
);
eprintln!("skipping: no PDB beside the test binary");
return;
}
let Some(table) = SymbolTable::from_pdb(&pdb) else {
eprintln!("skipping: PDB had no public function symbols");
return;
};
let Some((rva, expected)) = table.symbol_containing_name("kernal_api") else {
eprintln!("skipping: no symbol from this crate in the PDB");
return;
};
let capture = RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules: vec![ModuleRef {
name: "self".into(),
path_hint: Some(exe.to_string_lossy().into_owned()),
..Default::default()
}],
threads: vec![RawThread {
os_tid: 1,
frames: vec![RawFrame {
module_index: 0,
relative_address: u64::from(rva),
}],
..Default::default()
}],
};
let frame = &symbolize(&capture).unwrap().threads[0].frames[0];
assert_eq!(frame.status, FrameStatus::Resolved);
assert_eq!(frame.function.as_deref(), Some(expected.as_str()));
assert_eq!(
frame.relative_address,
u64::from(rva),
"the offset must survive symbolization"
);
}
#[cfg(target_os = "windows")]
#[test]
fn real_addresses_resolve_to_files_and_lines_when_asked() {
use crate::pdb_symbols::SymbolTable;
let exe = std::env::current_exe().expect("current exe");
let pdb = exe.with_extension("pdb");
if !pdb.is_file() {
assert!(
std::env::var_os("GITHUB_ACTIONS").is_none(),
"no PDB at {} during a CI run; this test would assert nothing",
pdb.display()
);
eprintln!("skipping: no PDB beside the test binary");
return;
}
let Some(table) = SymbolTable::from_pdb(&pdb) else {
eprintln!("skipping: PDB had no public function symbols");
return;
};
let addresses = table.addresses_for_names_containing("kernal_api", 64);
assert!(
!addresses.is_empty(),
"no symbol named this crate; the anchor is wrong, not the wiring"
);
let capture = capture_for_self(&exe, &addresses);
let cache = build_symbol_cache(&capture);
let lines = build_line_cache(&capture, &cache, true);
let resolved: Vec<(String, u32)> = addresses
.iter()
.filter_map(|address| lookup_line(&cache, &lines, 0, *address))
.collect();
if resolved.is_empty() {
assert!(
std::env::var_os("GITHUB_ACTIONS").is_none(),
"none of {} sampled symbols resolved to a line during a CI run",
addresses.len()
);
eprintln!("skipping: no sampled symbol carried a line record");
return;
}
for (file, line) in &resolved {
assert!(
file.to_ascii_lowercase().ends_with(".rs"),
"resolved to {file}, which is not a Rust source file"
);
assert!(*line > 0, "line numbers are 1-based; got {line}");
}
}
#[cfg(target_os = "windows")]
#[test]
fn lines_are_absent_unless_the_caller_opts_in() {
let exe = std::env::current_exe().expect("current exe");
let capture = capture_for_self(&exe, &[0x1000]);
let cache = build_symbol_cache(&capture);
let lines = build_line_cache(&capture, &cache, false);
assert!(
lines.iter().all(|module| module.is_empty()),
"the opt-in was off, so no line program should have been parsed"
);
}
#[cfg(target_os = "windows")]
fn capture_for_self(exe: &std::path::Path, addresses: &[u64]) -> RawCapture {
RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules: vec![ModuleRef {
name: "self".into(),
path_hint: Some(exe.to_string_lossy().into_owned()),
..Default::default()
}],
threads: vec![RawThread {
os_tid: 1,
frames: addresses
.iter()
.map(|address| RawFrame {
module_index: 0,
relative_address: *address,
})
.collect(),
..Default::default()
}],
}
}
#[test]
fn a_module_without_symbols_stays_raw_only() {
let capture = RawCapture {
format: CaptureFormat::CooperativeFrames,
discovery: Default::default(),
modules: vec![ModuleRef {
name: "ghost.dll".into(),
path_hint: Some("no-such-binary-anywhere.dll".into()),
..Default::default()
}],
threads: vec![RawThread {
os_tid: 1,
frames: vec![RawFrame {
module_index: 0,
relative_address: 0x40,
}],
..Default::default()
}],
};
let frame = &symbolize(&capture).unwrap().threads[0].frames[0];
assert_eq!(frame.status, FrameStatus::RawOnly);
assert!(frame.function.is_none(), "no symbols means no name");
assert_eq!(frame.relative_address, 0x40);
}
#[test]
fn the_minidump_path_refuses_rather_than_returning_nothing() {
let capture = RawCapture {
format: CaptureFormat::Minidump,
..Default::default()
};
assert!(matches!(
symbolize(&capture),
Err(SymbolizeError::UnsupportedFormat(CaptureFormat::Minidump))
));
}
}