Skip to main content

anchor_cli/debugger/
gdb.rs

1//! GDB-driven trace capture for `anchor debugger --gdb`.
2//!
3//! Alternative to the register-tracing-file path. Each test thread's VM
4//! blocks on a per-thread TCP port (sbpf's built-in gdb stub, activated
5//! via the `debugger` feature on `solana-sbpf`). We drive the stub over
6//! the GDB Remote Serial Protocol, single-stepping each invocation while
7//! reading the full register set (including the pseudo-register at index
8//! 12 that the sbpf target exposes as `InstructionCountRemaining` —
9//! compute-unit remaining at each step).
10//!
11//! Files are written into the same `<profile_dir>/<test>/NNNN__txK.{regs,
12//! insns,program_id,cu}` layout `TestNameCallback` produces, so the
13//! existing arena + TUI code consumes them unchanged. The only new
14//! artifact is `.cu` — 8 bytes per step, the VM's `cu_remaining` value
15//! read via the gdb stub's register 12.
16//!
17//! ## CPI handling
18//!
19//! Each CPI frame constructs its own `EbpfVm`, which reads `VM_DEBUG_PORT`
20//! and binds another listener on the same port (sbpf drops its listener
21//! after `accept()`, so re-entrance is fine). The client side has to
22//! notice that a step command on the outer connection is taking longer
23//! than expected, open a second TCP connection to the same port, and
24//! drive the inner session to completion — then the outer step reply
25//! arrives, stepping continues. This mirrors the actual sbpf/agave call
26//! stack: outer step → CPI syscall → inner VM exec → return → outer
27//! step reply.
28
29use {
30    anyhow::{anyhow, Context, Result},
31    std::{
32        io::{BufRead, BufReader, Read, Write},
33        net::TcpStream,
34        os::unix::net::{UnixListener, UnixStream},
35        path::{Path, PathBuf},
36        sync::{
37            atomic::{AtomicBool, Ordering},
38            Arc, Mutex,
39        },
40        thread,
41        time::{Duration, Instant},
42    },
43};
44
45// sbpf gdb target layout. `g` dumps 12 x u64 = 96 bytes = 192 hex chars:
46//   0..9   — Gpr (r0..r9)
47//   10     — Sp
48//   11     — Pc
49// `InstructionCountRemaining` is a PSEUDO register at index 12; gdbstub
50// doesn't include pseudo regs in `g` by convention — it requires a
51// separate `p 0c` read. We issue that per step.
52const REG_COUNT: usize = 12;
53const REG_BYTES: usize = REG_COUNT * 8;
54const REG_HEX_LEN: usize = REG_BYTES * 2;
55
56/// Env var the gdb driver and the test process use to rendezvous on
57/// the announce socket. Single source of truth — both the driver
58/// (`std::env::set_var`) and `anchor-v2-testing` (which reads it) must
59/// use this name.
60pub const SOCKET_ENV: &str = "ANCHOR_GDB_SOCKET";
61
62/// Owns the UDS listener at `<profile_dir>/gdb.sock` and the accept
63/// thread that spawns one driver thread per VM announcement. Drop
64/// signals stop, joins the accept thread, and removes the socket.
65///
66/// Decoupled from cargo invocation so both loose mode and Anchor.toml
67/// mode can stand up the same listener+driver and just differ in how
68/// the test process gets launched.
69pub struct GdbDriver {
70    profile_dir: PathBuf,
71    sock_path: PathBuf,
72    stop: Arc<AtomicBool>,
73    accept: Option<thread::JoinHandle<Result<()>>>,
74    captures: Arc<Mutex<Vec<PendingCapture>>>,
75}
76
77impl GdbDriver {
78    pub fn sock_path(&self) -> &Path {
79        &self.sock_path
80    }
81
82    fn shutdown(&mut self) {
83        self.stop.store(true, Ordering::Release);
84        if let Some(h) = self.accept.take() {
85            let _ = h.join();
86        }
87        if let Ok(captures) = self.captures.lock() {
88            if let Err(e) = write_pending_captures(&self.profile_dir, &captures) {
89                eprintln!("gdb sidecar write error: {e}");
90            }
91        }
92        let _ = std::fs::remove_file(&self.sock_path);
93    }
94}
95
96impl Drop for GdbDriver {
97    fn drop(&mut self) {
98        self.shutdown();
99    }
100}
101
102/// Bind the UDS listener and spawn the accept thread. Caller is
103/// responsible for setting `ANCHOR_GDB_SOCKET` in the env of whatever
104/// test process consumes the socket.
105pub fn start_gdb_driver(profile_dir: &Path) -> Result<GdbDriver> {
106    // Socket lives under the profile dir — same retention semantics as
107    // the trace files themselves, cleaned on next debugger run.
108    std::fs::create_dir_all(profile_dir).ok();
109    let sock_path = profile_dir.join("gdb.sock");
110    let _ = std::fs::remove_file(&sock_path); // clear stale
111    let listener =
112        UnixListener::bind(&sock_path).with_context(|| format!("bind {}", sock_path.display()))?;
113
114    let stop = Arc::new(AtomicBool::new(false));
115    let captures = Arc::new(Mutex::new(Vec::new()));
116
117    // Accept thread: pull port announcements off the socket, spawn a
118    // driver per announcement.
119    let accept_stop = Arc::clone(&stop);
120    let profile_dir_for_accept = profile_dir.to_path_buf();
121    let captures_for_accept = Arc::clone(&captures);
122    let accept_handle = thread::spawn(move || -> Result<()> {
123        // Non-blocking accept loop so we can notice when the cargo test
124        // process has exited and stop cleanly.
125        listener
126            .set_nonblocking(true)
127            .context("set listener non-blocking")?;
128        let mut drivers: Vec<thread::JoinHandle<()>> = Vec::new();
129        while !accept_stop.load(Ordering::Acquire) {
130            match listener.accept() {
131                Ok((conn, _addr)) => {
132                    let pd = profile_dir_for_accept.clone();
133                    let captures = Arc::clone(&captures_for_accept);
134                    drivers.push(thread::spawn(move || {
135                        if let Err(e) = handle_announce(conn, &pd, captures) {
136                            eprintln!("gdb driver error: {e}");
137                        }
138                    }));
139                    // Reap finished drivers so the vec doesn't grow
140                    // unbounded across long test runs.
141                    drivers.retain(|h| !h.is_finished());
142                }
143                Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
144                    thread::sleep(Duration::from_millis(20));
145                }
146                Err(e) => return Err(e).context("accept"),
147            }
148        }
149        // Drain remaining drivers — outer-VM trace files must finish
150        // writing before shutdown returns.
151        for d in drivers {
152            let _ = d.join();
153        }
154        Ok(())
155    });
156
157    Ok(GdbDriver {
158        profile_dir: profile_dir.to_path_buf(),
159        sock_path,
160        stop,
161        accept: Some(accept_handle),
162        captures,
163    })
164}
165
166/// Loose-mode entry point. Stands up the driver, then runs `cargo
167/// test` with `ANCHOR_GDB_SOCKET` and `--test-threads=1`.
168///
169/// `--test-threads=1` is forced because `VM_DEBUG_PORT` is a
170/// process-wide env var and sbpf reads it lazily inside each
171/// `EbpfVm::new` — two test threads each running `svm()` with
172/// different ports would have the last `set_var` win and both VMs
173/// collide on bind. Proper per-thread ports would need sbpf to accept
174/// the port through a non-env channel (fork).
175#[allow(clippy::too_many_arguments)]
176pub fn run_gdb_mode(
177    cargo_cwd: &Path,
178    current_package: Option<&str>,
179    profile_feature: &str,
180    profile_dir: &Path,
181    test_filter: Option<&str>,
182) -> Result<()> {
183    let driver = start_gdb_driver(profile_dir)?;
184
185    let mut cmd = std::process::Command::new("cargo");
186    cmd.current_dir(cargo_cwd)
187        .env(SOCKET_ENV, driver.sock_path())
188        .env("ANCHOR_PROFILE_DIR", profile_dir)
189        .env("RUST_TEST_THREADS", "1")
190        .arg("test")
191        .arg("--features")
192        .arg(profile_feature);
193    if let Some(pkg) = current_package {
194        cmd.arg("-p").arg(pkg);
195    }
196    cmd.arg("--").arg("--test-threads=1");
197    if let Some(filter) = test_filter {
198        cmd.arg(filter);
199    }
200    let test_status = cmd.status().context("spawn cargo test")?;
201
202    drop(driver);
203
204    if !test_status.success() {
205        return Err(anyhow!("cargo test failed"));
206    }
207    Ok(())
208}
209
210/// One announcement = one outer VM's port. Reads `"<port>\t<test_name>\n"`
211/// off the UDS connection, connects to the port, drives the VM to
212/// termination while probing for nested CPI listeners on the same port.
213fn handle_announce(
214    conn: UnixStream,
215    profile_dir: &Path,
216    captures: Arc<Mutex<Vec<PendingCapture>>>,
217) -> Result<()> {
218    let mut rdr = BufReader::new(conn);
219    let mut line = String::new();
220    rdr.read_line(&mut line).context("read announce")?;
221    let line = line.trim_end();
222    let (port_str, test_name) = line
223        .split_once('\t')
224        .ok_or_else(|| anyhow!("malformed announce: {line:?}"))?;
225    let port: u16 = port_str.parse().context("parse port")?;
226
227    let test_dir = profile_dir.join(sanitize(test_name));
228    std::fs::create_dir_all(&test_dir).with_context(|| format!("create {}", test_dir.display()))?;
229
230    // Retry-connect: sbpf may not have bound yet when we get the announce
231    // (v2-testing announces before setting env + VM construct). A single
232    // announced LiteSVM can execute multiple outer transactions, so keep
233    // accepting sequential sessions on the same port until it goes quiet.
234    let mut deadline = Duration::from_secs(5);
235    while let Some(outer) = wait_for_connect(port, deadline) {
236        let capture = drive_session(outer, port, test_name, 0)?;
237        captures.lock().unwrap().push(PendingCapture {
238            test_name: sanitize(test_name),
239            capture,
240        });
241        deadline = Duration::from_millis(750);
242    }
243    Ok(())
244}
245
246struct PendingCapture {
247    test_name: String,
248    capture: CapturedInvocation,
249}
250
251struct CapturedInvocation {
252    regs: Vec<u8>,
253    insns: Vec<u8>,
254    cu: Vec<u8>,
255    children: Vec<CapturedInvocation>,
256}
257
258fn write_pending_captures(profile_dir: &Path, captures: &[PendingCapture]) -> Result<()> {
259    let mut by_test = std::collections::BTreeMap::<&str, Vec<&CapturedInvocation>>::new();
260    for pending in captures {
261        by_test
262            .entry(&pending.test_name)
263            .or_default()
264            .push(&pending.capture);
265    }
266
267    for (test_name, captures) in by_test {
268        let test_dir = profile_dir.join(test_name);
269        std::fs::create_dir_all(&test_dir)
270            .with_context(|| format!("create {}", test_dir.display()))?;
271
272        let mut flattened = Vec::new();
273        for capture in captures {
274            flatten_capture_postorder(capture, &mut flattened);
275        }
276
277        let mut regular = regular_invocation_stems(&test_dir)?;
278        for capture in flattened {
279            let step_count = capture.regs.len() / REG_BYTES;
280            let regular_idx = regular
281                .iter()
282                .position(|r| !r.used && r.step_count == step_count)
283                .or_else(|| regular.iter().position(|r| !r.used));
284            let stem = if let Some(idx) = regular_idx {
285                regular[idx].used = true;
286                regular[idx].stem.clone()
287            } else {
288                let fallback = regular.len() + 1;
289                test_dir.join(format!("{fallback:04}__tx1"))
290            };
291            write_capture_sidecars(capture, &stem)?;
292        }
293    }
294    Ok(())
295}
296
297fn flatten_capture_postorder<'a>(
298    capture: &'a CapturedInvocation,
299    out: &mut Vec<&'a CapturedInvocation>,
300) {
301    for child in &capture.children {
302        flatten_capture_postorder(child, out);
303    }
304    out.push(capture);
305}
306
307struct RegularInvocationStem {
308    stem: PathBuf,
309    step_count: usize,
310    used: bool,
311}
312
313fn regular_invocation_stems(test_dir: &Path) -> Result<Vec<RegularInvocationStem>> {
314    let mut found = Vec::new();
315    if !test_dir.exists() {
316        return Ok(Vec::new());
317    }
318
319    for entry in std::fs::read_dir(test_dir)? {
320        let path = entry?.path();
321        if path.extension().and_then(|e| e.to_str()) != Some("regs") {
322            continue;
323        }
324        let Some(stem_str) = path.file_stem().and_then(|s| s.to_str()) else {
325            continue;
326        };
327        if stem_str.ends_with(".gdb") {
328            continue;
329        }
330        let Some((inv_part, tx_part)) = stem_str.split_once("__") else {
331            continue;
332        };
333        let Some(tx_digits) = tx_part.strip_prefix("tx") else {
334            continue;
335        };
336        let Ok(inv_seq) = inv_part.parse::<u32>() else {
337            continue;
338        };
339        let Ok(tx_seq) = tx_digits.parse::<u32>() else {
340            continue;
341        };
342        let step_count = std::fs::metadata(&path)
343            .map(|m| m.len() as usize / REG_BYTES)
344            .unwrap_or(0);
345        found.push((
346            tx_seq,
347            inv_seq,
348            RegularInvocationStem {
349                stem: path.with_extension(""),
350                step_count,
351                used: false,
352            },
353        ));
354    }
355
356    found.sort_by_key(|(tx_seq, inv_seq, _)| (*tx_seq, *inv_seq));
357    Ok(found.into_iter().map(|(_, _, stem)| stem).collect())
358}
359
360fn write_capture_sidecars(capture: &CapturedInvocation, stem: &Path) -> Result<()> {
361    std::fs::write(stem.with_extension("gdb.regs"), &capture.regs)?;
362    std::fs::write(stem.with_extension("gdb.insns"), &capture.insns)?;
363    std::fs::write(stem.with_extension("gdb.cu"), &capture.cu)?;
364    Ok(())
365}
366
367/// Drives one gdb session (one `EbpfVm::execute_program` invocation) to
368/// termination. Recursive: if the session's step stalls (likely a CPI),
369/// spawns a helper to connect to the same port and drive the nested
370/// session, then resumes outer stepping.
371fn drive_session(
372    stream: TcpStream,
373    port: u16,
374    test_name: &str,
375    depth: usize,
376) -> Result<CapturedInvocation> {
377    stream.set_nodelay(true).ok();
378    let mut rsp = Rsp::new(stream);
379    let started = Instant::now();
380    let mut last_progress = started;
381
382    rsp.send("QStartNoAckMode");
383    let reply = rsp.recv();
384    rsp.no_ack = reply == "OK";
385
386    rsp.send("qSupported:multiprocess+;swbreak+;hwbreak+;vContSupported+");
387    let _ = rsp.recv();
388
389    rsp.send("?");
390    let mut reply = rsp.recv();
391
392    // Sidecar capture for the canonical invocation files produced by
393    // TestNameCallback. We collect in memory so nested BPF CPIs can be
394    // written in postorder, matching iterate_vm_traces' file stems.
395    let mut capture = CapturedInvocation {
396        regs: Vec::new(),
397        insns: Vec::new(),
398        cu: Vec::new(),
399        children: Vec::new(),
400    };
401
402    let mut steps: u64 = 0;
403    print_progress(test_name, port, depth, steps, started, false);
404    loop {
405        if reply.starts_with('W') || reply.starts_with('X') {
406            break;
407        }
408        if !reply.starts_with('T') && !reply.starts_with('S') {
409            eprintln!("unexpected stop reply: {reply}");
410            break;
411        }
412
413        rsp.send("g");
414        let regs_hex = rsp.recv();
415        if regs_hex.is_empty() {
416            break;
417        }
418        // Pseudo-register 12 = InstructionCountRemaining. Not included
419        // in `g`'s dump, so read it separately.
420        rsp.send("p0c");
421        let cu_hex = rsp.recv();
422
423        let (regs, pc, _cu_stub, insn) = decode_regs_hex(&regs_hex)?;
424        let cu = decode_u64_hex(&cu_hex).unwrap_or(0);
425        capture.regs.extend_from_slice(&regs);
426        capture.cu.extend_from_slice(&cu.to_le_bytes());
427        let insn_bytes = read_insn_at(&mut rsp, pc)?;
428        capture.insns.extend_from_slice(&insn_bytes);
429        let _ = insn;
430
431        // Nested CPI probe: try connecting to the same port in a
432        // background thread while we await the step reply. If the inner
433        // VM has bound the port, our connect succeeds and we recurse.
434        let nested_port = port;
435        let nested_test_name = test_name.to_owned();
436        let probe_cancel = Arc::new(AtomicBool::new(false));
437        let probe_cancel_for_thread = Arc::clone(&probe_cancel);
438        let probe = thread::spawn(move || -> Result<Option<CapturedInvocation>> {
439            if let Some(inner) = probe_for_nested(
440                nested_port,
441                Duration::from_millis(250),
442                &probe_cancel_for_thread,
443            ) {
444                return drive_session(inner, nested_port, &nested_test_name, depth + 1).map(Some);
445            }
446            Ok(None)
447        });
448
449        rsp.send("s");
450        let step_reply = rsp.recv();
451        probe_cancel.store(true, Ordering::Release);
452        if let Ok(Ok(Some(child))) = probe.join() {
453            capture.children.push(child);
454        }
455        if step_reply.is_empty() {
456            // Stream closed — VM exited. Normal termination.
457            break;
458        }
459
460        reply = step_reply;
461        steps += 1;
462        if last_progress.elapsed() >= Duration::from_secs(5) {
463            print_progress(test_name, port, depth, steps, started, false);
464            last_progress = Instant::now();
465        }
466    }
467    print_progress(test_name, port, depth, steps, started, true);
468
469    Ok(capture)
470}
471
472fn print_progress(
473    test_name: &str,
474    port: u16,
475    depth: usize,
476    steps: u64,
477    started: Instant,
478    done: bool,
479) {
480    let elapsed = started.elapsed().as_secs_f64().max(0.001);
481    let rate = steps as f64 / elapsed;
482    let indent = "  ".repeat(depth);
483    let status = if done { "captured" } else { "stepping" };
484    eprintln!(
485        "{indent}gdb {status} {test_name} :{port} depth={depth} steps={steps} elapsed={:.1}s \
486         rate={:.1}/s",
487        elapsed, rate,
488    );
489}
490
491fn wait_for_connect(port: u16, deadline: Duration) -> Option<TcpStream> {
492    let start = Instant::now();
493    while start.elapsed() < deadline {
494        if let Ok(s) = TcpStream::connect_timeout(
495            &format!("127.0.0.1:{port}").parse().ok()?,
496            Duration::from_millis(50),
497        ) {
498            return Some(s);
499        }
500        thread::sleep(Duration::from_millis(5));
501    }
502    None
503}
504
505fn probe_for_nested(port: u16, window: Duration, cancel: &AtomicBool) -> Option<TcpStream> {
506    let start = Instant::now();
507    while start.elapsed() < window && !cancel.load(Ordering::Acquire) {
508        if let Ok(s) = TcpStream::connect(("127.0.0.1", port)) {
509            return Some(s);
510        }
511        thread::yield_now();
512    }
513    None
514}
515
516fn decode_regs_hex(hex: &str) -> Result<([u8; REG_BYTES], u64, u64, [u8; 8])> {
517    if hex.len() < REG_HEX_LEN {
518        return Err(anyhow!(
519            "register hex too short: {} bytes, payload={hex:?}",
520            hex.len()
521        ));
522    }
523    let mut regs = [0u8; REG_BYTES];
524    for (i, pair) in hex.as_bytes()[..REG_HEX_LEN].chunks_exact(2).enumerate() {
525        let s = std::str::from_utf8(pair).unwrap();
526        regs[i] = u8::from_str_radix(s, 16).context("invalid hex")?;
527    }
528    // r11 = pc in sbpf's gdb target (last u64 in `g`'s dump).
529    let pc = u64::from_le_bytes(regs[11 * 8..12 * 8].try_into().unwrap());
530    let cu = 0; // CU read separately via `p 0c`.
531    let insn = [0u8; 8]; // real bytes come from ELF, not gdb
532    Ok((regs, pc, cu, insn))
533}
534
535fn decode_u64_hex(hex: &str) -> Option<u64> {
536    if hex.len() < 16 {
537        return None;
538    }
539    let mut bytes = [0u8; 8];
540    for (i, pair) in hex.as_bytes()[..16].chunks_exact(2).enumerate() {
541        bytes[i] = u8::from_str_radix(std::str::from_utf8(pair).ok()?, 16).ok()?;
542    }
543    Some(u64::from_le_bytes(bytes))
544}
545
546fn read_insn_at(rsp: &mut Rsp, pc: u64) -> Result<[u8; 8]> {
547    // The sbpf gdb stub reports PC as the program virtual byte address.
548    // Reading memory at that same address returns the raw instruction bytes.
549    rsp.send(&format!("m{pc:x},8"));
550    decode_bytes_hex::<8>(&rsp.recv())
551}
552
553fn decode_bytes_hex<const N: usize>(hex: &str) -> Result<[u8; N]> {
554    if hex.len() < N * 2 {
555        return Err(anyhow!(
556            "memory hex too short: {} bytes, payload={hex:?}",
557            hex.len()
558        ));
559    }
560    let mut out = [0u8; N];
561    for (i, pair) in hex.as_bytes()[..N * 2].chunks_exact(2).enumerate() {
562        out[i] =
563            u8::from_str_radix(std::str::from_utf8(pair).unwrap(), 16).context("invalid hex")?;
564    }
565    Ok(out)
566}
567
568fn sanitize(s: &str) -> String {
569    s.chars()
570        .map(|c| {
571            if c.is_ascii_alphanumeric() || c == '_' {
572                c
573            } else {
574                '_'
575            }
576        })
577        .collect()
578}
579
580// ---------------------------------------------------------------------------
581// Minimal GDB Remote Serial Protocol client. Just enough for our step loop.
582
583struct Rsp {
584    stream: TcpStream,
585    buf: Vec<u8>,
586    no_ack: bool,
587}
588
589impl Rsp {
590    fn new(stream: TcpStream) -> Self {
591        Self {
592            stream,
593            buf: Vec::with_capacity(512),
594            no_ack: false,
595        }
596    }
597
598    fn send(&mut self, payload: &str) {
599        let mut cksum: u32 = 0;
600        for b in payload.bytes() {
601            cksum = cksum.wrapping_add(b as u32);
602        }
603        let pkt = format!("${payload}#{:02x}", cksum & 0xff);
604        let _ = self.stream.write_all(pkt.as_bytes());
605        let _ = self.stream.flush();
606        if !self.no_ack {
607            let mut one = [0u8; 1];
608            let _ = self.stream.read_exact(&mut one);
609        }
610    }
611
612    fn recv(&mut self) -> String {
613        let mut one = [0u8; 1];
614        loop {
615            if self.stream.read_exact(&mut one).is_err() {
616                return String::new();
617            }
618            if one[0] == b'$' {
619                break;
620            }
621        }
622        self.buf.clear();
623        loop {
624            if self.stream.read_exact(&mut one).is_err() {
625                return String::new();
626            }
627            if one[0] == b'#' {
628                break;
629            }
630            self.buf.push(one[0]);
631        }
632        let mut cksum = [0u8; 2];
633        let _ = self.stream.read_exact(&mut cksum);
634        if !self.no_ack {
635            let _ = self.stream.write_all(b"+");
636            let _ = self.stream.flush();
637        }
638        // Decode GDB RLE: a `*` followed by a count byte means "repeat the
639        // previous char N more times", where N = count_byte - 28. This is
640        // what sbpf's stub uses to shrink register dumps full of zeros
641        // down from 208 bytes to ~45.
642        let decoded = rle_decode(&self.buf);
643        String::from_utf8_lossy(&decoded).into_owned()
644    }
645}
646
647fn rle_decode(src: &[u8]) -> Vec<u8> {
648    // GDB RSP RLE: `X*N` where N is an ASCII char, means the total
649    // run length of X (including the literal X) is `N - 29 + 1`. So
650    // we push `N - 29` additional copies of the preceding char.
651    let mut out = Vec::with_capacity(src.len() * 4);
652    let mut i = 0;
653    while i < src.len() {
654        let c = src[i];
655        if c == b'*' && i + 1 < src.len() && !out.is_empty() {
656            let n = (src[i + 1] as usize).saturating_sub(29);
657            let last = *out.last().unwrap();
658            for _ in 0..n {
659                out.push(last);
660            }
661            i += 2;
662        } else {
663            out.push(c);
664            i += 1;
665        }
666    }
667    out
668}
669
670#[cfg(test)]
671mod tests {
672    use {super::*, std::fmt::Write as _, tempfile::tempdir};
673
674    fn bytes_to_hex(bytes: &[u8]) -> String {
675        let mut out = String::with_capacity(bytes.len() * 2);
676        for byte in bytes {
677            write!(&mut out, "{byte:02x}").unwrap();
678        }
679        out
680    }
681
682    fn regs_hex(regs: [u64; REG_COUNT]) -> String {
683        let bytes = regs
684            .iter()
685            .flat_map(|reg| reg.to_le_bytes())
686            .collect::<Vec<_>>();
687        bytes_to_hex(&bytes)
688    }
689
690    #[test]
691    fn decode_regs_hex_reads_pc_from_r11_little_endian() {
692        let mut regs = [0u64; REG_COUNT];
693        regs[0] = 0x11;
694        regs[11] = 0x1122_3344_5566_7788;
695
696        let (raw, pc, cu, insn) = decode_regs_hex(&regs_hex(regs)).unwrap();
697
698        assert_eq!(pc, 0x1122_3344_5566_7788);
699        assert_eq!(cu, 0);
700        assert_eq!(insn, [0; 8]);
701        assert_eq!(u64::from_le_bytes(raw[0..8].try_into().unwrap()), 0x11);
702    }
703
704    #[test]
705    fn decode_bytes_hex_rejects_short_or_invalid_payload() {
706        assert!(decode_bytes_hex::<8>("00").is_err());
707        assert!(decode_bytes_hex::<1>("zz").is_err());
708    }
709
710    #[test]
711    fn rle_decode_expands_gdb_rsp_runs() {
712        assert_eq!(rle_decode(b"A* B"), b"AAAAB".to_vec());
713    }
714
715    #[test]
716    fn regular_invocation_stems_ignores_gdb_sidecars_and_sorts_by_tx_inv() {
717        let dir = tempdir().unwrap();
718        std::fs::write(dir.path().join("0002__tx1.regs"), vec![0u8; REG_BYTES]).unwrap();
719        std::fs::write(dir.path().join("0001__tx1.regs"), vec![0u8; REG_BYTES * 2]).unwrap();
720        std::fs::write(dir.path().join("0001__tx2.regs"), vec![0u8; REG_BYTES]).unwrap();
721        std::fs::write(dir.path().join("0001__tx1.gdb.regs"), vec![0u8; REG_BYTES]).unwrap();
722
723        let stems = regular_invocation_stems(dir.path()).unwrap();
724
725        assert_eq!(stems.len(), 3);
726        assert_eq!(stems[0].stem.file_name().unwrap(), "0001__tx1");
727        assert_eq!(stems[0].step_count, 2);
728        assert_eq!(stems[1].stem.file_name().unwrap(), "0002__tx1");
729        assert_eq!(stems[2].stem.file_name().unwrap(), "0001__tx2");
730    }
731}