use {
anyhow::{anyhow, Context, Result},
object::{Object, ObjectSection, ObjectSymbol, SymbolKind},
rustc_demangle::demangle,
solana_compute_budget::compute_budget::ComputeBudget,
solana_sbpf::{
ebpf,
elf::Executable,
program::BuiltinProgram,
static_analysis::Analysis,
vm::{Config, ContextObject},
},
std::{collections::BTreeMap, fs, path::Path, sync::Arc},
};
fn syscall_cost(budget: &ComputeBudget, syscall_name: &str) -> u64 {
match syscall_name {
"sol_log_64_" => budget.log_64_units,
"sol_log_pubkey" => budget.log_pubkey_units,
"sol_sha256" | "sol_keccak256" | "sol_blake3" => budget.sha256_base_cost,
"sol_secp256k1_recover" => budget.secp256k1_recover_cost,
"sol_invoke_signed_c" | "sol_invoke_signed_rust" => budget.invoke_units,
"sol_create_program_address" | "sol_try_find_program_address" => {
budget.create_program_address_units
}
"sol_memcpy_" | "sol_memmove_" | "sol_memset_" | "sol_memcmp_" => budget.mem_op_base_cost,
"sol_get_clock_sysvar"
| "sol_get_epoch_schedule_sysvar"
| "sol_get_fees_sysvar"
| "sol_get_rent_sysvar"
| "sol_get_last_restart_slot"
| "sol_get_epoch_rewards_sysvar"
| "sol_get_sysvar" => budget.sysvar_base_cost,
"sol_curve_validate_point" => budget.curve25519_edwards_validate_point_cost,
"sol_curve_group_op" => budget.curve25519_edwards_add_cost,
"sol_big_mod_exp" => budget.big_modular_exponentiation_base_cost,
"sol_remaining_compute_units" => budget.get_remaining_compute_units_cost,
"sol_alt_bn128_compression" => budget.alt_bn128_g1_compress,
"sol_alt_bn128_group_op" => budget.alt_bn128_addition_cost,
"sol_poseidon" => budget.poseidon_cost_coefficient_c,
_ => budget.syscall_base_cost,
}
}
pub struct FlamegraphReport {
pub program_name: String,
pub total_cu: u64,
pub stacks: BTreeMap<Vec<String>, u64>,
}
type FunctionSymbolMap = BTreeMap<u64, String>;
type SyscallSymbolMap = BTreeMap<u32, String>;
type SymbolCache = BTreeMap<String, (FunctionSymbolMap, SyscallSymbolMap)>;
pub const REGS_ENTRY_SIZE: usize = 12 * std::mem::size_of::<u64>();
pub const INSN_ENTRY_SIZE: usize = 8;
pub struct StreamStep<'a> {
pub pc: u64,
pub regs: [u64; 12],
pub insn: [u8; 8],
pub call_stack: &'a [String],
pub func: &'a str,
pub syscall: Option<String>,
pub cu_cost: u64,
}
#[derive(Default)]
struct NoopContext;
impl ContextObject for NoopContext {
fn consume(&mut self, _amount: u64) {}
fn get_remaining(&self) -> u64 {
0
}
}
#[allow(clippy::too_many_arguments)]
pub fn stream_trace(
regs_data: &[u8],
insns_data: &[u8],
count: usize,
symbol_map: &BTreeMap<u64, String>,
syscall_names: &BTreeMap<u32, String>,
program_name: &str,
budget: &ComputeBudget,
mut visit: impl FnMut(StreamStep<'_>),
) {
let mut call_stack: Vec<String> = vec![program_name.to_owned()];
for i in 0..count {
let regs = read_regs(regs_data, i);
let insn = read_insn(insns_data, i);
let pc = regs[11];
let (current_name, current_entry_pc) = lookup_function_with_pc(symbol_map, pc);
let current_frame = format!("{current_name} @ {current_entry_pc:#x}");
if call_stack.last().map(String::as_str) != Some(current_frame.as_str()) {
if let Some(depth) = call_stack.iter().rposition(|f| f == ¤t_frame) {
call_stack.truncate(depth + 1);
} else {
call_stack.push(current_frame);
}
}
if insn[0] == ebpf::CALL_IMM {
let imm = u32::from_le_bytes(insn[4..8].try_into().unwrap());
let is_syscall = if i + 1 < count {
let next_regs = read_regs(regs_data, i + 1);
let next_pc = next_regs[11];
next_pc == pc + 1
} else {
false
};
if is_syscall {
let syscall_name = syscall_names
.get(&imm)
.cloned()
.unwrap_or_else(|| format!("syscall_{imm:#x}"));
let cost = syscall_cost(budget, &syscall_name);
visit(StreamStep {
pc,
regs,
insn,
call_stack: &call_stack,
func: ¤t_name,
syscall: Some(syscall_name),
cu_cost: cost,
});
continue;
}
}
visit(StreamStep {
pc,
regs,
insn,
call_stack: &call_stack,
func: ¤t_name,
syscall: None,
cu_cost: 1,
});
}
}
fn process_trace(
regs_data: &[u8],
insns_data: &[u8],
count: usize,
symbol_map: &BTreeMap<u64, String>,
syscall_names: &BTreeMap<u32, String>,
program_name: &str,
budget: &ComputeBudget,
) -> (BTreeMap<Vec<String>, u64>, u64) {
let mut folded_stacks: BTreeMap<Vec<String>, u64> = BTreeMap::new();
let mut total_cu: u64 = 0;
stream_trace(
regs_data,
insns_data,
count,
symbol_map,
syscall_names,
program_name,
budget,
|step| {
total_cu += step.cu_cost;
if let Some(syscall) = &step.syscall {
let mut stack: Vec<String> = step.call_stack.to_vec();
stack.push(format!("[syscall] {syscall}"));
*folded_stacks.entry(stack).or_default() += step.cu_cost;
} else {
*folded_stacks.entry(step.call_stack.to_vec()).or_default() += step.cu_cost;
}
},
);
(folded_stacks, total_cu)
}
pub fn read_regs(data: &[u8], i: usize) -> [u64; 12] {
let offset = i * REGS_ENTRY_SIZE;
let mut regs = [0u64; 12];
for (r, reg) in regs.iter_mut().enumerate() {
let start = offset + r * 8;
let bytes: [u8; 8] = data[start..start + 8].try_into().unwrap();
*reg = u64::from_le_bytes(bytes);
}
regs
}
pub fn read_insn(data: &[u8], i: usize) -> [u8; 8] {
let offset = i * INSN_ENTRY_SIZE;
data[offset..offset + 8].try_into().unwrap()
}
pub fn lookup_function_with_pc(symbol_map: &BTreeMap<u64, String>, pc: u64) -> (String, u64) {
symbol_map
.range(..=pc)
.next_back()
.map(|(entry_pc, name)| (name.clone(), *entry_pc))
.unwrap_or_else(|| (format!("unknown_{pc:#x}"), pc))
}
pub struct InvocationFiles {
pub inv_seq: u32,
pub tx_seq: u32,
pub program_id: String,
pub regs_path: std::path::PathBuf,
pub insns_path: std::path::PathBuf,
}
pub fn discover_invocations(trace_dir: &Path) -> Result<Vec<InvocationFiles>> {
let mut found = Vec::new();
if !trace_dir.exists() {
return Ok(found);
}
let entries = fs::read_dir(trace_dir)
.with_context(|| format!("Failed to read trace directory {}", trace_dir.display()))?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("regs") {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
let Some((inv_part, tx_part)) = stem.split_once("__") else {
continue;
};
let Some(tx_digits) = tx_part.strip_prefix("tx") else {
continue;
};
let Ok(inv_seq) = inv_part.parse::<u32>() else {
continue;
};
let Ok(tx_seq) = tx_digits.parse::<u32>() else {
continue;
};
let insns_path = path.with_extension("insns");
let pid_path = path.with_extension("program_id");
if !insns_path.exists() || !pid_path.exists() {
continue;
}
let program_id = match fs::read_to_string(&pid_path) {
Ok(s) => s.trim().to_owned(),
Err(_) => continue,
};
found.push(InvocationFiles {
inv_seq,
tx_seq,
program_id,
regs_path: path,
insns_path,
});
}
found.sort_by_key(|f| (f.tx_seq, f.inv_seq));
Ok(found)
}
pub fn build_tx_reports(
test_name: &str,
test_dir: &Path,
programs: &std::collections::BTreeMap<String, std::path::PathBuf>,
manifest_dir: Option<&Path>,
) -> Result<std::collections::BTreeMap<u32, FlamegraphReport>> {
let invocations = discover_invocations(test_dir)?;
if invocations.is_empty() {
return Ok(std::collections::BTreeMap::new());
}
let mut symbol_cache: SymbolCache = BTreeMap::new();
for (pid, elf) in programs {
if let Ok(maps) = load_function_map(elf, manifest_dir) {
symbol_cache.insert(pid.clone(), maps);
}
}
let empty_symbols: BTreeMap<u64, String> = BTreeMap::new();
let empty_syscalls: BTreeMap<u32, String> = BTreeMap::new();
let mut reports: std::collections::BTreeMap<u32, (BTreeMap<Vec<String>, u64>, u64)> =
std::collections::BTreeMap::new();
let budget = ComputeBudget::new_with_defaults(false, false);
for inv in &invocations {
let regs = fs::read(&inv.regs_path)
.with_context(|| format!("read {}", inv.regs_path.display()))?;
let insns = fs::read(&inv.insns_path)
.with_context(|| format!("read {}", inv.insns_path.display()))?;
let count = (regs.len() / REGS_ENTRY_SIZE).min(insns.len() / INSN_ENTRY_SIZE);
if count == 0 {
continue;
}
let (symbols, syscalls) = match symbol_cache.get(&inv.program_id) {
Some((s, c)) => (s, c),
None => (&empty_symbols, &empty_syscalls),
};
let program_label = match programs.get(&inv.program_id) {
Some(elf_path) => {
let short_pid = short_pid(&inv.program_id);
elf_path
.file_stem()
.and_then(|s| s.to_str())
.map(|n| format!("[program {n} ({short_pid})]"))
.unwrap_or_else(|| format!("[program {}]", inv.program_id))
}
None => format!("[unresolved {}]", short_pid(&inv.program_id)),
};
let (stacks, cu) = process_trace(
®s,
&insns,
count,
symbols,
syscalls,
&program_label,
&budget,
);
let entry = reports
.entry(inv.tx_seq)
.or_insert_with(|| (BTreeMap::new(), 0));
for (stack, cost) in stacks {
*entry.0.entry(stack).or_default() += cost;
}
entry.1 += cu;
}
Ok(reports
.into_iter()
.filter(|(_, (_, cu))| *cu > 0)
.map(|(tx_seq, (stacks, cu))| {
(
tx_seq,
FlamegraphReport {
program_name: format!("{test_name} · tx{tx_seq}"),
total_cu: cu,
stacks,
},
)
})
.collect())
}
fn short_pid(pid: &str) -> String {
if pid.len() <= 13 {
pid.to_owned()
} else {
format!("{}…{}", &pid[..8], &pid[pid.len() - 4..])
}
}
pub fn load_function_map(
elf_path: &Path,
manifest_dir: Option<&Path>,
) -> Result<(BTreeMap<u64, String>, BTreeMap<u32, String>)> {
let elf_bytes =
fs::read(elf_path).with_context(|| format!("Failed to read ELF {}", elf_path.display()))?;
let loader = Arc::new(BuiltinProgram::new_loader(Config {
enable_symbol_and_section_labels: true,
..Config::default()
}));
let executable = Executable::<NoopContext>::from_elf(&elf_bytes, loader)
.map_err(|err| anyhow!("Failed to parse SBPF ELF {}: {err}", elf_path.display()))?;
let analysis = Analysis::from_executable(&executable)
.map_err(|err| anyhow!("Failed to analyze SBPF executable: {err}"))?;
let mut symbols: BTreeMap<u64, String> = BTreeMap::new();
for (pc, (_key, name)) in analysis.functions.iter() {
let normalized = normalize_symbol(name, *pc);
symbols.entry(*pc as u64).or_insert(normalized);
}
if let Ok(extra) = load_elf_symbols(&elf_bytes) {
for (pc, name) in extra {
symbols.entry(pc as u64).or_insert(name);
}
}
if let Some(unstripped_path) = find_unstripped_binary(elf_path, manifest_dir) {
if let Ok(unstripped_bytes) = fs::read(&unstripped_path) {
if let Ok(extra) = load_elf_symbols(&unstripped_bytes) {
for (pc, name) in extra {
let entry = symbols.entry(pc as u64);
match entry {
std::collections::btree_map::Entry::Vacant(v) => {
v.insert(name);
}
std::collections::btree_map::Entry::Occupied(mut o) => {
if o.get().starts_with("function_") {
o.insert(name);
}
}
}
}
}
}
}
Ok((symbols, syscall_hash_map()))
}
pub const KNOWN_SYSCALLS: &[&str] = &[
"abort",
"sol_log_",
"sol_log_64_",
"sol_log_compute_units_",
"sol_log_data",
"sol_log_pubkey",
"sol_sha256",
"sol_keccak256",
"sol_blake3",
"sol_poseidon",
"sol_secp256k1_recover",
"sol_curve_validate_point",
"sol_curve_group_op",
"sol_curve_multiscalar_mul",
"sol_curve_pairing_map",
"sol_alt_bn128_group_op",
"sol_alt_bn128_compression",
"sol_big_mod_exp",
"sol_invoke_signed_c",
"sol_invoke_signed_rust",
"sol_set_return_data",
"sol_get_return_data",
"sol_create_program_address",
"sol_try_find_program_address",
"sol_memcpy_",
"sol_memmove_",
"sol_memset_",
"sol_memcmp_",
"sol_get_clock_sysvar",
"sol_get_epoch_schedule_sysvar",
"sol_get_fees_sysvar",
"sol_get_rent_sysvar",
"sol_get_last_restart_slot",
"sol_get_epoch_rewards_sysvar",
"sol_get_sysvar",
"sol_panic_",
"sol_get_processed_sibling_instruction",
"sol_get_stack_height",
"sol_remaining_compute_units",
"sol_get_epoch_stake",
];
fn syscall_hash_map() -> BTreeMap<u32, String> {
KNOWN_SYSCALLS
.iter()
.map(|name| {
(
solana_sbpf::ebpf::hash_symbol_name(name.as_bytes()),
(*name).to_owned(),
)
})
.collect()
}
pub fn find_unstripped_binary(
deployed_path: &Path,
manifest_dir: Option<&Path>,
) -> Option<std::path::PathBuf> {
let file_name = deployed_path.file_name()?.to_str()?.to_owned();
if let Some(manifest) = manifest_dir {
if let Some(root) = find_workspace_root(manifest) {
let direct = root
.join("target")
.join("sbpf-solana-solana")
.join("release")
.join(&file_name);
if direct.exists() {
return Some(direct);
}
let deps = root
.join("target")
.join("sbpf-solana-solana")
.join("release")
.join("deps")
.join(&file_name);
if deps.exists() {
return Some(deps);
}
}
}
let mut root = deployed_path.parent()?;
loop {
let parent = root.parent()?;
let has_bench = parent.join("bench").is_dir();
let has_target = parent.join("target").is_dir();
if has_bench || has_target {
root = parent;
break;
}
root = parent;
}
search_for_unstripped(root, &file_name, 0)
}
fn find_workspace_root(manifest_dir: &Path) -> Option<std::path::PathBuf> {
let mut current: std::path::PathBuf = manifest_dir.to_path_buf();
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists() {
if let Ok(contents) = fs::read_to_string(&cargo_toml) {
if contents.contains("\n[workspace]") || contents.starts_with("[workspace]") {
return Some(current);
}
}
}
let parent = current.parent()?;
current = parent.to_path_buf();
}
}
fn search_for_unstripped(dir: &Path, file_name: &str, depth: usize) -> Option<std::path::PathBuf> {
if depth > 6 {
return None;
}
let direct = dir
.join("target")
.join("sbpf-solana-solana")
.join("release")
.join(file_name);
if direct.exists() {
return Some(direct);
}
let deps = dir
.join("target")
.join("sbpf-solana-solana")
.join("release")
.join("deps")
.join(file_name);
if deps.exists() {
return Some(deps);
}
let entries = fs::read_dir(dir).ok()?;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_dir() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
if name.starts_with('.')
|| name == "node_modules"
|| name == "deploy"
|| name == "deploy-debug"
{
continue;
}
if let Some(found) = search_for_unstripped(&path, file_name, depth + 1) {
return Some(found);
}
}
None
}
fn load_elf_symbols(elf_bytes: &[u8]) -> Result<BTreeMap<usize, String>> {
let file = object::File::parse(elf_bytes)
.map_err(|err| anyhow!("Failed to parse ELF for symbols: {err}"))?;
let text_section = file
.sections()
.find(|s| s.name().ok() == Some(".text"))
.or_else(|| {
file.sections()
.find(|s| s.kind() == object::SectionKind::Text)
})
.ok_or_else(|| anyhow!("No .text section"))?;
let text_address = text_section.address();
let text_end = text_address.saturating_add(text_section.size());
let mut symbols = BTreeMap::new();
for symbol in file.symbols().chain(file.dynamic_symbols()) {
if symbol.kind() != SymbolKind::Text || symbol.address() == 0 {
continue;
}
let Ok(name) = symbol.name() else {
continue;
};
if name.is_empty() {
continue;
}
let address = symbol.address();
if address < text_address || address >= text_end {
continue;
}
let relative = address - text_address;
if relative % ebpf::INSN_SIZE as u64 != 0 {
continue;
}
let pc = (relative / ebpf::INSN_SIZE as u64) as usize;
symbols
.entry(pc)
.or_insert_with(|| normalize_symbol(name, pc));
}
Ok(symbols)
}
fn normalize_symbol(name: &str, pc: usize) -> String {
let trimmed = name.trim_matches(char::from(0));
let normalized = if trimmed.is_empty() {
format!("function_{pc}")
} else {
demangle(trimmed).to_string()
};
let cleaned = strip_rust_hash_suffix(&normalized)
.replace(';', ":")
.replace('\n', " ");
shorten_qualified_name(&cleaned)
}
fn strip_rust_hash_suffix(symbol: &str) -> &str {
let Some((prefix, suffix)) = symbol.rsplit_once("::h") else {
return symbol;
};
if suffix.len() == 16 && suffix.bytes().all(|b| b.is_ascii_hexdigit()) {
prefix
} else {
symbol
}
}
fn shorten_qualified_name(name: &str) -> String {
if name.contains('<') || name.contains('{') || name.matches("::").count() <= 2 {
return name.to_owned();
}
let parts: Vec<&str> = name.split("::").collect();
if parts.len() <= 3 {
return name.to_owned();
}
parts[parts.len() - 3..].join("::")
}
#[cfg(test)]
mod tests {
use {super::*, std::path::Path, tempfile::tempdir};
fn regs_bytes(pcs: &[u64]) -> Vec<u8> {
let mut out = Vec::with_capacity(pcs.len() * REGS_ENTRY_SIZE);
for pc in pcs {
let mut regs = [0u64; 12];
regs[11] = *pc;
for reg in regs {
out.extend_from_slice(®.to_le_bytes());
}
}
out
}
fn insns_bytes(insns: &[[u8; INSN_ENTRY_SIZE]]) -> Vec<u8> {
insns.iter().flat_map(|insn| insn.iter().copied()).collect()
}
fn plain_insns(count: usize) -> Vec<[u8; INSN_ENTRY_SIZE]> {
vec![[0; INSN_ENTRY_SIZE]; count]
}
fn call_imm(imm: u32) -> [u8; INSN_ENTRY_SIZE] {
let mut insn = [0; INSN_ENTRY_SIZE];
insn[0] = ebpf::CALL_IMM;
insn[4..8].copy_from_slice(&imm.to_le_bytes());
insn
}
fn write_invocation(dir: &Path, stem: &str, program_id: &str, pcs: &[u64]) {
std::fs::create_dir_all(dir).unwrap();
let insns = plain_insns(pcs.len());
std::fs::write(dir.join(format!("{stem}.regs")), regs_bytes(pcs)).unwrap();
std::fs::write(dir.join(format!("{stem}.insns")), insns_bytes(&insns)).unwrap();
std::fs::write(dir.join(format!("{stem}.program_id")), program_id).unwrap();
}
#[test]
fn lookup_function_with_pc_uses_nearest_lower_symbol() {
let symbols = BTreeMap::from([
(10, "entry".to_string()),
(20, "callee".to_string()),
(40, "tail".to_string()),
]);
assert_eq!(
lookup_function_with_pc(&symbols, 20),
("callee".to_string(), 20)
);
assert_eq!(
lookup_function_with_pc(&symbols, 27),
("callee".to_string(), 20)
);
assert_eq!(
lookup_function_with_pc(&symbols, 9),
("unknown_0x9".to_string(), 9)
);
}
#[test]
fn stream_trace_resyncs_call_stack_from_pc_flow() {
let symbols = BTreeMap::from([
(0, "entry".to_string()),
(10, "callee".to_string()),
(20, "tail".to_string()),
]);
let pcs = [0, 1, 10, 11, 2, 20, 21, 3];
let insns = plain_insns(pcs.len());
let mut observed = Vec::new();
stream_trace(
®s_bytes(&pcs),
&insns_bytes(&insns),
pcs.len(),
&symbols,
&BTreeMap::new(),
"program",
&ComputeBudget::new_with_defaults(false, false),
|step| observed.push((step.pc, step.func.to_owned(), step.call_stack.to_vec())),
);
assert_eq!(
observed
.iter()
.map(|(_, _, stack)| stack.len())
.collect::<Vec<_>>(),
vec![2, 2, 3, 3, 2, 3, 3, 2]
);
assert_eq!(observed[2].1, "callee");
assert_eq!(observed[4].1, "entry");
assert_eq!(observed[5].1, "tail");
assert_eq!(observed[7].1, "entry");
}
#[test]
fn stream_trace_attributes_sequential_call_imm_as_syscall_leaf() {
let symbols = BTreeMap::from([(0, "entry".to_string())]);
let syscall_hash = ebpf::hash_symbol_name(b"sol_log_64_");
let syscall_names = BTreeMap::from([(syscall_hash, "sol_log_64_".to_string())]);
let pcs = [0, 1];
let insns = [call_imm(syscall_hash), [0; INSN_ENTRY_SIZE]];
let mut observed = Vec::new();
stream_trace(
®s_bytes(&pcs),
&insns_bytes(&insns),
pcs.len(),
&symbols,
&syscall_names,
"program",
&ComputeBudget::new_with_defaults(false, false),
|step| {
observed.push((
step.pc,
step.syscall.clone(),
step.cu_cost,
step.call_stack.to_vec(),
))
},
);
assert_eq!(observed[0].0, 0);
assert_eq!(observed[0].1.as_deref(), Some("sol_log_64_"));
assert!(observed[0].2 > 1);
assert_eq!(observed[0].3, vec!["program", "entry @ 0x0"]);
assert_eq!(observed[1].1, None);
assert_eq!(observed[1].3, vec!["program", "entry @ 0x0"]);
}
#[test]
fn stream_trace_does_not_treat_last_call_imm_as_syscall() {
let symbols = BTreeMap::from([(0, "entry".to_string())]);
let syscall_hash = ebpf::hash_symbol_name(b"sol_log_64_");
let syscall_names = BTreeMap::from([(syscall_hash, "sol_log_64_".to_string())]);
let mut observed = Vec::new();
stream_trace(
®s_bytes(&[0]),
&insns_bytes(&[call_imm(syscall_hash)]),
1,
&symbols,
&syscall_names,
"program",
&ComputeBudget::new_with_defaults(false, false),
|step| observed.push((step.syscall.clone(), step.cu_cost)),
);
assert_eq!(observed, vec![(None, 1)]);
}
#[test]
fn discover_invocations_sorts_by_tx_then_inv_and_ignores_incomplete_files() {
let dir = tempdir().unwrap();
write_invocation(dir.path(), "0002__tx1", "pid_b", &[0]);
write_invocation(dir.path(), "0001__tx2", "pid_c", &[0]);
write_invocation(dir.path(), "0001__tx1", "pid_a", &[0]);
std::fs::write(dir.path().join("bad.regs"), regs_bytes(&[0])).unwrap();
std::fs::write(dir.path().join("0003__tx1.regs"), regs_bytes(&[0])).unwrap();
std::fs::write(dir.path().join("0004__tx1.regs"), regs_bytes(&[0])).unwrap();
std::fs::write(
dir.path().join("0004__tx1.insns"),
insns_bytes(&plain_insns(1)),
)
.unwrap();
std::fs::write(dir.path().join("0005__tx1.gdb.regs"), regs_bytes(&[0])).unwrap();
let found = discover_invocations(dir.path()).unwrap();
assert_eq!(found.len(), 3);
assert_eq!(
found
.iter()
.map(|inv| (inv.tx_seq, inv.inv_seq, inv.program_id.as_str()))
.collect::<Vec<_>>(),
vec![(1, 1, "pid_a"), (1, 2, "pid_b"), (2, 1, "pid_c")]
);
}
#[test]
fn build_tx_reports_separates_transactions_and_keeps_unresolved_program_cu() {
let dir = tempdir().unwrap();
let pid = "Program111111111111111111111111111111111";
write_invocation(dir.path(), "0001__tx1", pid, &[0, 1]);
write_invocation(dir.path(), "0002__tx2", pid, &[0]);
let reports = build_tx_reports("case", dir.path(), &BTreeMap::new(), None).unwrap();
assert_eq!(reports.keys().copied().collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(reports[&1].program_name, "case · tx1");
assert_eq!(reports[&1].total_cu, 2);
assert_eq!(reports[&2].total_cu, 1);
assert!(reports[&1]
.stacks
.keys()
.any(|stack| stack.first().is_some_and(|f| f.starts_with("[unresolved "))));
}
#[test]
fn build_tx_reports_merges_multiple_invocations_in_same_tx() {
let dir = tempdir().unwrap();
let pid = "Program222222222222222222222222222222222";
write_invocation(dir.path(), "0001__tx7", pid, &[0]);
write_invocation(dir.path(), "0002__tx7", pid, &[0]);
let reports = build_tx_reports("case", dir.path(), &BTreeMap::new(), None).unwrap();
assert_eq!(reports.keys().copied().collect::<Vec<_>>(), vec![7]);
let report = &reports[&7];
assert_eq!(report.total_cu, 2);
assert_eq!(report.stacks.len(), 1);
assert_eq!(*report.stacks.values().next().unwrap(), 2);
}
}