Skip to main content

anchor_cli/debugger/
arena.rs

1//! Walks the profile trace directory and materializes the [`DebugSession`]
2//! the TUI consumes.
3//!
4//! Shares the core PC-driven call-stack algorithm with the flamegraph
5//! renderer via [`crate::flamegraph::trace::stream_trace`]. The two
6//! consumers differ only in their sink: the SVG folder aggregates into
7//! flat `(stack → cu)` maps, and this module emits one [`DebugStep`] per
8//! traced instruction plus DWARF-resolved source locations.
9
10use {
11    super::{
12        cargo_deps::{discover_dep_src_roots, discover_path_dep_roots},
13        highlight::highlight_asm,
14        model::{DebugNode, DebugSession, DebugStep, DebugTx, ProgramDisasm, StaticInsn},
15        source::{discover_platform_tools_stdlib_roots, SourceResolver, CI_PLATFORM_TOOLS_PREFIX},
16    },
17    crate::flamegraph::trace::{
18        discover_invocations, find_unstripped_binary, load_function_map, stream_trace,
19        InvocationFiles, INSN_ENTRY_SIZE, KNOWN_SYSCALLS, REGS_ENTRY_SIZE,
20    },
21    anyhow::{anyhow, bail, Context, Result},
22    solana_compute_budget::compute_budget::ComputeBudget,
23    solana_sbpf::{ebpf, static_analysis::Analysis},
24    std::{
25        collections::BTreeMap,
26        fs,
27        path::{Path, PathBuf},
28        sync::Arc,
29    },
30};
31
32/// Build the session from `<profile_dir>/<test_name>/` trace directories.
33///
34/// `programs` maps base58 program_id → deployed `.so` path. `manifest_dir`
35/// points the unstripped-binary search toward the right workspace root (same
36/// convention as [`crate::profile::render_all_tests`]).
37pub fn build_session(
38    profile_dir: &Path,
39    programs: &BTreeMap<String, PathBuf>,
40    manifest_dir: Option<&Path>,
41    crate_dir: Option<&Path>,
42    test_filter: Option<&str>,
43) -> Result<DebugSession> {
44    let mut txs: Vec<DebugTx> = Vec::new();
45    let mut src_roots: Vec<PathBuf> = Vec::new();
46    // Path deps (e.g. `anchor-lang-v2 = { path = "../../../../lang-v2" }`)
47    // must come before the workspace root so their `src/lib.rs` wins over
48    // a same-named file at the workspace root (`bench/src/lib.rs`). SBF
49    // DWARF omits `DW_AT_comp_dir`, so relative paths like `src/lib.rs`
50    // are ambiguous — insertion order is the only disambiguation.
51    if let Some(c) = crate_dir {
52        src_roots.extend(discover_path_dep_roots(c));
53    }
54    if let Some(p) = manifest_dir {
55        src_roots.push(p.to_path_buf());
56        src_roots.extend(discover_dep_src_roots(p));
57    }
58
59    // Rewrite the baked-in CI platform-tools prefix on stdlib frames to the
60    // local toolchain's bundled Rust source. One entry per installed
61    // platform-tools version, newest first — `resolve_src_path` picks the
62    // first rewrite whose resulting path exists.
63    let path_rewrites: Vec<(PathBuf, PathBuf)> = discover_platform_tools_stdlib_roots()
64        .into_iter()
65        .map(|replacement| (PathBuf::from(CI_PLATFORM_TOOLS_PREFIX), replacement))
66        .collect();
67
68    let cwd = crate_dir.map(|p| p.to_path_buf());
69
70    if !profile_dir.exists() {
71        return Ok(DebugSession {
72            txs,
73            src_roots,
74            path_rewrites,
75            cwd,
76            programs: BTreeMap::new(),
77        });
78    }
79
80    // Per-program context: symbol map + syscall hashes + parsed executable +
81    // text bytes + DWARF resolver. Loaded once and reused across every
82    // invocation of the same program, since parsing an SBPF ELF is expensive.
83    let mut program_ctx: BTreeMap<String, ProgramCtx> = BTreeMap::new();
84
85    for entry in fs::read_dir(profile_dir)
86        .with_context(|| format!("read profile dir {}", profile_dir.display()))?
87    {
88        let entry = entry?;
89        let dir = entry.path();
90        if !dir.is_dir() {
91            continue;
92        }
93        let Some(test_name) = dir.file_name().and_then(|s| s.to_str()).map(str::to_owned) else {
94            continue;
95        };
96        if let Some(filter) = test_filter {
97            if !test_name.contains(filter) {
98                continue;
99            }
100        }
101
102        let invocations = discover_invocations(&dir)
103            .with_context(|| format!("discover invocations in {}", dir.display()))?;
104        if invocations.is_empty() {
105            continue;
106        }
107
108        let mut tx_groups: BTreeMap<u32, Vec<&InvocationFiles>> = BTreeMap::new();
109        for inv in &invocations {
110            tx_groups.entry(inv.tx_seq).or_default().push(inv);
111        }
112
113        for (tx_seq, mut invs) in tx_groups {
114            // `iterate_vm_traces` (in v2-testing's profile callback) walks
115            // invocations bottom-up: deepest CPI first, top-level last. We
116            // want call order — top-level → CPI children — so the
117            // breadcrumb's "#1 top" matches what the user actually sent.
118            // Reverse here; inv_seq stays as written by the callback so
119            // filenames keep their stable ordering.
120            invs.reverse();
121
122            let mut nodes: Vec<DebugNode> = Vec::with_capacity(invs.len());
123            let mut tx_total_cu: u64 = 0;
124
125            for inv in invs {
126                let ctx = match load_program_ctx(
127                    &inv.program_id,
128                    programs,
129                    manifest_dir,
130                    &mut program_ctx,
131                ) {
132                    Some(c) => c,
133                    None => continue,
134                };
135
136                let regular_regs = fs::read(&inv.regs_path)
137                    .with_context(|| format!("read {}", inv.regs_path.display()))?;
138                let regular_insns = fs::read(&inv.insns_path)
139                    .with_context(|| format!("read {}", inv.insns_path.display()))?;
140                let regular_count = (regular_regs.len() / REGS_ENTRY_SIZE)
141                    .min(regular_insns.len() / INSN_ENTRY_SIZE);
142
143                let (regs, insns, exact_cu) =
144                    match load_gdb_sidecars(inv, &regular_regs, &regular_insns, regular_count)? {
145                        Some(sidecar) => (sidecar.regs, sidecar.insns, Some(sidecar.cu_costs)),
146                        None => (regular_regs, regular_insns, None),
147                    };
148
149                let count = (regs.len() / REGS_ENTRY_SIZE).min(insns.len() / INSN_ENTRY_SIZE);
150                if count == 0 {
151                    continue;
152                }
153
154                let program_label = program_label(&inv.program_id, programs);
155                let mut steps: Vec<DebugStep> = Vec::with_capacity(count);
156                let mut node_cu: u64 = 0;
157
158                let budget = ComputeBudget::new_with_defaults(false, false);
159                let mut step_idx = 0usize;
160
161                stream_trace(
162                    &regs,
163                    &insns,
164                    count,
165                    &ctx.symbols,
166                    &ctx.syscalls,
167                    &program_label,
168                    &budget,
169                    |s| {
170                        let cu_cost = exact_cu
171                            .as_ref()
172                            .and_then(|costs| costs.get(step_idx).copied())
173                            .unwrap_or(s.cu_cost);
174                        step_idx += 1;
175                        node_cu += cu_cost;
176                        let disasm = disassemble(&ctx.analysis, &s.insn, s.pc as usize);
177                        // Pre-highlight here so the redraw hot path is a
178                        // simple span clone — see `DebugStep.disasm_spans`
179                        // for the perf rationale.
180                        let disasm_spans = highlight_asm(&disasm).spans;
181                        let src_loc = ctx.source.resolve(s.pc);
182                        steps.push(DebugStep {
183                            pc: s.pc,
184                            regs: s.regs,
185                            insn: s.insn,
186                            disasm,
187                            disasm_spans,
188                            func: s.func.to_owned(),
189                            // call_stack[0] is the program label (unchanging root);
190                            // the actual function frames start at index 1.
191                            call_depth: s.call_stack.len().saturating_sub(1).max(1),
192                            cu_cost,
193                            cu_cumulative: node_cu,
194                            syscall: s.syscall,
195                            src_loc,
196                        });
197                    },
198                );
199
200                tx_total_cu += node_cu;
201                nodes.push(DebugNode {
202                    program_label,
203                    program_id: inv.program_id.clone(),
204                    steps,
205                });
206            }
207
208            if nodes.iter().all(|n| n.steps.is_empty()) {
209                continue;
210            }
211
212            txs.push(DebugTx {
213                test_name: test_name.clone(),
214                tx_seq,
215                total_cu: tx_total_cu,
216                nodes,
217            });
218        }
219    }
220
221    // Stable order: test name, then tx seq. Matches how users read the test
222    // output above the TUI.
223    txs.sort_by(|a, b| {
224        a.test_name
225            .cmp(&b.test_name)
226            .then_with(|| a.tx_seq.cmp(&b.tx_seq))
227    });
228
229    // Build the per-program static disasm map from whichever programs we
230    // ended up resolving above. Doing this after the main loop lets us
231    // reuse the already-loaded `ProgramCtx` instead of opening each ELF
232    // a second time.
233    let mut programs_disasm: BTreeMap<String, ProgramDisasm> = BTreeMap::new();
234    for (pid, ctx) in &program_ctx {
235        let mut d = build_static_disasm(ctx);
236        d.has_dwarf = !ctx.source.is_empty();
237        programs_disasm.insert(pid.clone(), d);
238    }
239
240    Ok(DebugSession {
241        txs,
242        src_roots,
243        path_rewrites,
244        cwd,
245        programs: programs_disasm,
246    })
247}
248
249struct GdbSidecars {
250    regs: Vec<u8>,
251    insns: Vec<u8>,
252    cu_costs: Vec<u64>,
253}
254
255fn load_gdb_sidecars(
256    inv: &InvocationFiles,
257    regular_regs: &[u8],
258    regular_insns: &[u8],
259    regular_count: usize,
260) -> Result<Option<GdbSidecars>> {
261    let regs_path = inv.regs_path.with_extension("gdb.regs");
262    let insns_path = inv.regs_path.with_extension("gdb.insns");
263    let cu_path = inv.regs_path.with_extension("gdb.cu");
264
265    if !regs_path.exists() && !insns_path.exists() && !cu_path.exists() {
266        return Ok(None);
267    }
268    if !regs_path.exists() || !insns_path.exists() || !cu_path.exists() {
269        bail!(
270            "incomplete gdb sidecar set for {}; expected {}, {}, and {}",
271            inv.regs_path.display(),
272            regs_path.display(),
273            insns_path.display(),
274            cu_path.display(),
275        );
276    }
277
278    let mut regs = fs::read(&regs_path).with_context(|| format!("read {}", regs_path.display()))?;
279    let insns = fs::read(&insns_path).with_context(|| format!("read {}", insns_path.display()))?;
280    let cu = fs::read(&cu_path).with_context(|| format!("read {}", cu_path.display()))?;
281    let count = (regs.len() / REGS_ENTRY_SIZE).min(insns.len() / INSN_ENTRY_SIZE);
282    if count != regular_count {
283        bail!(
284            "gdb sidecar count mismatch for {}: gdb has {count} steps, regular trace has \
285             {regular_count}",
286            inv.regs_path.display(),
287        );
288    }
289    validate_and_normalize_gdb_regs(&mut regs, regular_regs, count)
290        .with_context(|| format!("validate {}", regs_path.display()))?;
291    if insns != regular_insns {
292        bail!(
293            "gdb sidecar instruction bytes differ from regular trace for {}",
294            inv.regs_path.display(),
295        );
296    }
297    if cu.len() / std::mem::size_of::<u64>() != count {
298        bail!(
299            "gdb sidecar CU count mismatch for {}: gdb has {} CU entries, trace has {count} steps",
300            inv.regs_path.display(),
301            cu.len() / std::mem::size_of::<u64>(),
302        );
303    }
304
305    Ok(Some(GdbSidecars {
306        regs,
307        insns,
308        cu_costs: cu_costs_from_remaining(&cu, count),
309    }))
310}
311
312fn validate_and_normalize_gdb_regs(
313    gdb_regs: &mut [u8],
314    regular_regs: &[u8],
315    count: usize,
316) -> Result<()> {
317    if gdb_regs.len() < count * REGS_ENTRY_SIZE || regular_regs.len() < count * REGS_ENTRY_SIZE {
318        bail!("register trace is shorter than declared step count");
319    }
320
321    let mut text_addr = None::<u64>;
322    for i in 0..count {
323        let offset = i * REGS_ENTRY_SIZE;
324        for reg in 0..12 {
325            let start = offset + reg * 8;
326            let gdb = u64::from_le_bytes(gdb_regs[start..start + 8].try_into().unwrap());
327            let regular = u64::from_le_bytes(regular_regs[start..start + 8].try_into().unwrap());
328
329            if reg == 11 {
330                let expected_text_addr = gdb
331                    .checked_sub(
332                        regular
333                            .checked_mul(INSN_ENTRY_SIZE as u64)
334                            .ok_or_else(|| anyhow!("trace PC overflow at step {i}"))?,
335                    )
336                    .ok_or_else(|| anyhow!("gdb PC precedes trace PC at step {i}"))?;
337                match text_addr {
338                    Some(addr) if addr != expected_text_addr => bail!(
339                        "gdb PC/text address mismatch at step {i}: expected text addr {addr:#x}, \
340                         got {expected_text_addr:#x}",
341                    ),
342                    None => text_addr = Some(expected_text_addr),
343                    _ => {}
344                }
345                gdb_regs[start..start + 8].copy_from_slice(&regular.to_le_bytes());
346            } else if gdb != regular {
347                bail!("gdb register r{reg} mismatch at step {i}: gdb={gdb:#x}, trace={regular:#x}",);
348            }
349        }
350    }
351    Ok(())
352}
353
354fn cu_costs_from_remaining(data: &[u8], count: usize) -> Vec<u64> {
355    let mut costs = Vec::with_capacity(count);
356    let mut prev = None::<u64>;
357    for i in 0..count {
358        let offset = i * std::mem::size_of::<u64>();
359        let Some(bytes) = data.get(offset..offset + 8) else {
360            costs.push(0);
361            continue;
362        };
363        let remaining = u64::from_le_bytes(bytes.try_into().unwrap());
364        costs.push(prev.map(|p| p.saturating_sub(remaining)).unwrap_or(0));
365        prev = Some(remaining);
366    }
367    costs
368}
369
370/// Disassemble every PC in the program's text section in memory order,
371/// pre-highlighting each line for the TUI hot path. Function-entry PCs
372/// pick up a `func_label` so the static view renders symbol headers.
373///
374/// Cost is proportional to text-section size; for a typical 50KB SBF
375/// `.text` (~6k insns) this runs in low single-digit ms.
376fn build_static_disasm(ctx: &ProgramCtx) -> ProgramDisasm {
377    let (_, text) = ctx.executable.get_text_bytes();
378    // text_bytes is the raw text section; PCs are byte-offset / 8.
379    let n = text.len() / solana_sbpf::ebpf::INSN_SIZE;
380    let mut insns: Vec<StaticInsn> = Vec::with_capacity(n);
381    let mut pc_to_idx: BTreeMap<u64, usize> = BTreeMap::new();
382
383    let mut pc = 0;
384    while pc < n {
385        let mut raw = ebpf::get_insn_unchecked(text, pc);
386        // `lddw` is the only SBPF instruction that occupies two slots:
387        // pc N holds opcode + lower 32 bits of imm; pc N+1 holds opcode 0x0
388        // + upper 32 bits in its imm field. Disassembling N+1 standalone
389        // produces a phantom `unknown opcode=0x0` row. Merge the halves
390        // and skip the second slot so the static view matches what the
391        // VM actually executes.
392        let mut step = 1;
393        if raw.opc == ebpf::LD_DW_IMM && pc + 1 < n {
394            ebpf::augment_lddw_unchecked(text, &mut raw);
395            step = 2;
396        }
397        let disasm = ctx.analysis.disassemble_instruction(&raw, pc);
398        let disasm_spans = highlight_asm(&disasm).spans;
399        let func_label = ctx.symbols.get(&(pc as u64)).cloned();
400        pc_to_idx.insert(pc as u64, insns.len());
401        // Map the second-half PC to the same display row so any trace
402        // step that lands on N+1 (shouldn't happen, but defensively) still
403        // resolves to a valid entry.
404        if step == 2 {
405            pc_to_idx.insert((pc + 1) as u64, insns.len());
406        }
407        insns.push(StaticInsn {
408            pc: pc as u64,
409            disasm_spans,
410            func_label,
411        });
412        pc += step;
413    }
414
415    // `has_dwarf` is fixed up by the caller (`build_session`) which has
416    // the `ProgramCtx.source` resolver in scope. Default to `false` here
417    // so a future caller that forgets the patch sees the conservative
418    // "no DWARF" path instead of the wrong "PC unmapped" hint.
419    ProgramDisasm {
420        insns,
421        pc_to_idx,
422        has_dwarf: false,
423    }
424}
425
426struct ProgramCtx {
427    symbols: BTreeMap<u64, String>,
428    syscalls: BTreeMap<u32, String>,
429    /// Borrowed from a deliberately leaked `Box<Executable>`. The debugger
430    /// holds ~1 of these per deployed program for the life of the process;
431    /// leaking beats self-referential-struct gymnastics and the OS reclaims
432    /// on exit. Not called from anywhere hot.
433    analysis: Analysis<'static>,
434    /// Same leaked executable backing `analysis`. Held here so the static
435    /// disasm builder can pull the raw text bytes via `get_text_bytes`.
436    executable: &'static solana_sbpf::elf::Executable<NoopCtx>,
437    source: SourceResolver,
438}
439
440#[derive(Default)]
441struct NoopCtx;
442impl solana_sbpf::vm::ContextObject for NoopCtx {
443    fn consume(&mut self, _amount: u64) {}
444    fn get_remaining(&self) -> u64 {
445        0
446    }
447}
448
449/// No-op `BuiltinFunction<NoopCtx>` used to register syscall names in the
450/// loader's function registry. Never called — we replay traces, never
451/// execute — so the body is unreachable in practice.
452fn syscall_stub(
453    _vm: *mut solana_sbpf::vm::EbpfVm<NoopCtx>,
454    _r1: u64,
455    _r2: u64,
456    _r3: u64,
457    _r4: u64,
458    _r5: u64,
459) {
460}
461
462fn load_program_ctx<'a>(
463    program_id: &str,
464    programs: &BTreeMap<String, PathBuf>,
465    manifest_dir: Option<&Path>,
466    cache: &'a mut BTreeMap<String, ProgramCtx>,
467) -> Option<&'a ProgramCtx> {
468    if !cache.contains_key(program_id) {
469        let ctx = build_program_ctx(program_id, programs, manifest_dir)?;
470        cache.insert(program_id.to_owned(), ctx);
471    }
472    cache.get(program_id)
473}
474
475fn build_program_ctx(
476    program_id: &str,
477    programs: &BTreeMap<String, PathBuf>,
478    manifest_dir: Option<&Path>,
479) -> Option<ProgramCtx> {
480    let elf_path = programs.get(program_id)?;
481    let elf_bytes = fs::read(elf_path).ok()?;
482    let (symbols, syscalls) = match load_function_map(elf_path, manifest_dir) {
483        Ok(x) => x,
484        Err(e) => {
485            // Common cause: bench-style workspaces produce a raw cargo
486            // SBF ELF in `target/sbpf-solana-solana/release/` that
487            // solana-sbpf can't parse without `cargo build-sbf`'s
488            // post-link step. Surface this clearly so the user knows
489            // what to do instead of silently dropping the trace.
490            eprintln!(
491                "warning: can't parse {} ({e}). Run `cargo build-sbf -p <crate>` from the \
492                 workspace root to produce a debugger-compatible `target/deploy/<name>.so`.",
493                elf_path.display()
494            );
495            return None;
496        }
497    };
498
499    // Build the loader with every known syscall registered so sbpf's
500    // disassembler can name `CALL_IMM` syscalls instead of printing
501    // `[invalid]`. The function pointer is a no-op stub — we never
502    // actually invoke syscalls (we replay traces, not execute), so the
503    // registry is consulted only for the (hash → name) lookup that
504    // `disassembler::disassemble_instruction` does for CALL_IMM.
505    let mut loader_inner =
506        solana_sbpf::program::BuiltinProgram::new_loader(solana_sbpf::vm::Config {
507            enable_symbol_and_section_labels: true,
508            ..solana_sbpf::vm::Config::default()
509        });
510    for name in KNOWN_SYSCALLS {
511        // Ignore registration errors — a duplicate hash would mean two
512        // syscalls collide in `KNOWN_SYSCALLS`, which is a list bug, not
513        // a per-program issue. Continuing yields a partial registry
514        // (better than no names at all).
515        let _ = loader_inner.register_function(name, syscall_stub);
516    }
517    let loader = Arc::new(loader_inner);
518    let executable = solana_sbpf::elf::Executable::<NoopCtx>::from_elf(&elf_bytes, loader).ok()?;
519    // Leak the executable so the `Analysis` can borrow it with a `'static`
520    // lifetime. Each unique program gets leaked exactly once per debugger
521    // session; the allocation is reclaimed when the process exits.
522    let exec_ref: &'static solana_sbpf::elf::Executable<NoopCtx> = Box::leak(Box::new(executable));
523    let analysis = Analysis::from_executable(exec_ref).ok()?;
524
525    // DWARF lives in the unstripped build artifact, not `target/deploy/` —
526    // `cargo-build-sbf` strips before copying. Fall back to the deployed
527    // path if no unstripped sibling is available (third-party deploys etc.).
528    let dwarf_path =
529        find_unstripped_binary(elf_path, manifest_dir).unwrap_or_else(|| elf_path.to_path_buf());
530    let source = SourceResolver::from_elf_path(&dwarf_path);
531
532    Some(ProgramCtx {
533        symbols,
534        syscalls,
535        analysis,
536        executable: exec_ref,
537        source,
538    })
539}
540
541fn disassemble(analysis: &Analysis<'_>, insn_bytes: &[u8; 8], pc: usize) -> String {
542    let insn = ebpf::Insn {
543        ptr: pc,
544        opc: insn_bytes[0],
545        dst: insn_bytes[1] & 0x0f,
546        src: (insn_bytes[1] & 0xf0) >> 4,
547        off: i16::from_le_bytes([insn_bytes[2], insn_bytes[3]]),
548        imm: i32::from_le_bytes([insn_bytes[4], insn_bytes[5], insn_bytes[6], insn_bytes[7]])
549            as i64,
550    };
551    analysis.disassemble_instruction(&insn, pc)
552}
553
554fn program_label(program_id: &str, programs: &BTreeMap<String, PathBuf>) -> String {
555    let short = short_pid(program_id);
556    match programs.get(program_id) {
557        Some(elf) => elf
558            .file_stem()
559            .and_then(|s| s.to_str())
560            .map(|n| format!("{n} ({short})"))
561            .unwrap_or_else(|| format!("program {program_id}")),
562        None => format!("[unresolved {short}]"),
563    }
564}
565
566fn short_pid(pid: &str) -> String {
567    if pid.len() <= 13 {
568        pid.to_owned()
569    } else {
570        format!("{}…{}", &pid[..8], &pid[pid.len() - 4..])
571    }
572}
573
574#[cfg(test)]
575mod tests {
576    use {super::*, tempfile::tempdir};
577
578    fn invocation(dir: &Path) -> InvocationFiles {
579        InvocationFiles {
580            inv_seq: 1,
581            tx_seq: 1,
582            program_id: "Program1111111111111111111".to_string(),
583            regs_path: dir.join("0001__tx1.regs"),
584            insns_path: dir.join("0001__tx1.insns"),
585        }
586    }
587
588    fn regs_entries(pcs: &[u64]) -> Vec<[u64; 12]> {
589        pcs.iter()
590            .map(|pc| {
591                let mut regs = [0u64; 12];
592                regs[0] = 0x55;
593                regs[11] = *pc;
594                regs
595            })
596            .collect()
597    }
598
599    fn regs_bytes(entries: &[[u64; 12]]) -> Vec<u8> {
600        let mut out = Vec::with_capacity(entries.len() * REGS_ENTRY_SIZE);
601        for regs in entries {
602            for reg in regs {
603                out.extend_from_slice(&reg.to_le_bytes());
604            }
605        }
606        out
607    }
608
609    fn insns_bytes(count: usize, byte: u8) -> Vec<u8> {
610        vec![byte; count * INSN_ENTRY_SIZE]
611    }
612
613    fn cu_bytes(values: &[u64]) -> Vec<u8> {
614        values
615            .iter()
616            .flat_map(|value| value.to_le_bytes())
617            .collect()
618    }
619
620    fn gdb_entries_for_regular(entries: &[[u64; 12]], text_addr: u64) -> Vec<[u64; 12]> {
621        entries
622            .iter()
623            .map(|regular| {
624                let mut gdb = *regular;
625                gdb[11] = text_addr + regular[11] * INSN_ENTRY_SIZE as u64;
626                gdb
627            })
628            .collect()
629    }
630
631    fn write_sidecars(
632        inv: &InvocationFiles,
633        gdb_regs: &[[u64; 12]],
634        gdb_insns: &[u8],
635        cu_remaining: &[u64],
636    ) {
637        fs::write(
638            inv.regs_path.with_extension("gdb.regs"),
639            regs_bytes(gdb_regs),
640        )
641        .unwrap();
642        fs::write(inv.regs_path.with_extension("gdb.insns"), gdb_insns).unwrap();
643        fs::write(
644            inv.regs_path.with_extension("gdb.cu"),
645            cu_bytes(cu_remaining),
646        )
647        .unwrap();
648    }
649
650    fn sidecar_err(
651        inv: &InvocationFiles,
652        regular_regs: &[u8],
653        regular_insns: &[u8],
654        regular_count: usize,
655    ) -> String {
656        match load_gdb_sidecars(inv, regular_regs, regular_insns, regular_count) {
657            Ok(_) => panic!("expected gdb sidecar validation error"),
658            Err(err) => format!("{err:#}"),
659        }
660    }
661
662    #[test]
663    fn gdb_sidecars_normalize_virtual_pc_and_override_cu_costs() {
664        let dir = tempdir().unwrap();
665        let inv = invocation(dir.path());
666        let regular_entries = regs_entries(&[0, 1, 2]);
667        let regular_regs = regs_bytes(&regular_entries);
668        let regular_insns = insns_bytes(3, 0xab);
669        let gdb_entries = gdb_entries_for_regular(&regular_entries, 0x1000);
670        write_sidecars(&inv, &gdb_entries, &regular_insns, &[100, 93, 90]);
671
672        let sidecar = load_gdb_sidecars(&inv, &regular_regs, &regular_insns, 3)
673            .unwrap()
674            .unwrap();
675
676        assert_eq!(sidecar.regs, regular_regs);
677        assert_eq!(sidecar.insns, regular_insns);
678        assert_eq!(sidecar.cu_costs, vec![0, 7, 3]);
679    }
680
681    #[test]
682    fn gdb_sidecars_reject_incomplete_sets() {
683        let dir = tempdir().unwrap();
684        let inv = invocation(dir.path());
685        let regular_entries = regs_entries(&[0]);
686        let regular_regs = regs_bytes(&regular_entries);
687        let regular_insns = insns_bytes(1, 0xab);
688        fs::write(
689            inv.regs_path.with_extension("gdb.regs"),
690            regs_bytes(&gdb_entries_for_regular(&regular_entries, 0x1000)),
691        )
692        .unwrap();
693
694        let err = sidecar_err(&inv, &regular_regs, &regular_insns, 1);
695
696        assert!(err.contains("incomplete gdb sidecar set"));
697    }
698
699    #[test]
700    fn gdb_sidecars_reject_count_mismatch() {
701        let dir = tempdir().unwrap();
702        let inv = invocation(dir.path());
703        let regular_entries = regs_entries(&[0, 1]);
704        let regular_regs = regs_bytes(&regular_entries);
705        let regular_insns = insns_bytes(2, 0xab);
706        let gdb_entries = gdb_entries_for_regular(&regular_entries[..1], 0x1000);
707        write_sidecars(
708            &inv,
709            &gdb_entries,
710            &regular_insns[..INSN_ENTRY_SIZE],
711            &[100],
712        );
713
714        let err = sidecar_err(&inv, &regular_regs, &regular_insns, 2);
715
716        assert!(err.contains("gdb sidecar count mismatch"));
717    }
718
719    #[test]
720    fn gdb_sidecars_reject_instruction_mismatch() {
721        let dir = tempdir().unwrap();
722        let inv = invocation(dir.path());
723        let regular_entries = regs_entries(&[0, 1]);
724        let regular_regs = regs_bytes(&regular_entries);
725        let regular_insns = insns_bytes(2, 0xab);
726        let gdb_entries = gdb_entries_for_regular(&regular_entries, 0x1000);
727        write_sidecars(&inv, &gdb_entries, &insns_bytes(2, 0xcd), &[100, 99]);
728
729        let err = sidecar_err(&inv, &regular_regs, &regular_insns, 2);
730
731        assert!(err.contains("gdb sidecar instruction bytes differ"));
732    }
733
734    #[test]
735    fn gdb_sidecars_reject_register_mismatch() {
736        let dir = tempdir().unwrap();
737        let inv = invocation(dir.path());
738        let regular_entries = regs_entries(&[0]);
739        let regular_regs = regs_bytes(&regular_entries);
740        let regular_insns = insns_bytes(1, 0xab);
741        let mut gdb_entries = gdb_entries_for_regular(&regular_entries, 0x1000);
742        gdb_entries[0][0] = 0x66;
743        write_sidecars(&inv, &gdb_entries, &regular_insns, &[100]);
744
745        let err = sidecar_err(&inv, &regular_regs, &regular_insns, 1);
746
747        assert!(err.contains("gdb register r0 mismatch"));
748    }
749
750    #[test]
751    fn gdb_sidecars_reject_inconsistent_text_address() {
752        let dir = tempdir().unwrap();
753        let inv = invocation(dir.path());
754        let regular_entries = regs_entries(&[0, 1]);
755        let regular_regs = regs_bytes(&regular_entries);
756        let regular_insns = insns_bytes(2, 0xab);
757        let mut gdb_entries = gdb_entries_for_regular(&regular_entries, 0x1000);
758        gdb_entries[1][11] = 0x2000 + INSN_ENTRY_SIZE as u64;
759        write_sidecars(&inv, &gdb_entries, &regular_insns, &[100, 99]);
760
761        let err = sidecar_err(&inv, &regular_regs, &regular_insns, 2);
762
763        assert!(err.contains("gdb PC/text address mismatch"));
764    }
765}