Skip to main content

dbg_cli/session_db/collectors/
disasm.rs

1//! Three disasm collectors for Phase 1: lldb (native), jitdasm (.NET),
2//! go-objdump (Go).
3//!
4//! Each implements `OnDemandCollector` and produces a `DisasmOutput`
5//! the caller can feed to `persist_disasm`. Collectors do NOT touch
6//! the DB directly — that keeps the shell-out + parse logic testable
7//! in isolation and lets the daemon batch multiple collections.
8
9use std::process::{Command, Stdio};
10use std::sync::OnceLock;
11
12use anyhow::{Context, Result, bail};
13use regex::Regex;
14
15use super::{CollectCtx, DisasmOutput, LiveDebugger, OnDemandCollector};
16use crate::jitdasm::JitIndex;
17use crate::session_db::TargetClass;
18
19// ============================================================
20// lldb `disassemble --name <sym>`
21// ============================================================
22
23pub struct LldbDisassembleCollector;
24
25impl OnDemandCollector for LldbDisassembleCollector {
26    fn kind(&self) -> &'static str {
27        "lldb-disassemble"
28    }
29
30    fn supports(&self, class: TargetClass) -> bool {
31        matches!(class, TargetClass::NativeCpu)
32    }
33
34    fn collect(
35        &self,
36        ctx: &CollectCtx<'_>,
37        live: Option<&dyn LiveDebugger>,
38    ) -> Result<DisasmOutput> {
39        let cmd = format!("disassemble --name {}", ctx.symbol);
40        let raw = match live {
41            Some(l) => l.send(&cmd)?,
42            None => run_oneshot_lldb(ctx.target, &cmd)?,
43        };
44        let asm_text = raw.trim().to_string();
45        if asm_text.is_empty() {
46            bail!("lldb produced no disassembly for {}", ctx.symbol);
47        }
48        let code_bytes = count_instruction_bytes(&asm_text);
49        Ok(DisasmOutput {
50            source: "lldb-disassemble",
51            tier: None,
52            code_bytes,
53            asm_text,
54            asm_lines_json: None,
55        })
56    }
57}
58
59fn run_oneshot_lldb(target: &str, disasm_cmd: &str) -> Result<String> {
60    let bin = std::env::var("LLDB_BIN").unwrap_or_else(|_| "lldb".into());
61    let output = Command::new(&bin)
62        .args([
63            "--batch",
64            "--no-use-colors",
65            "-o",
66            &format!("target create \"{}\"", target.replace('"', "\\\"")),
67            "-o",
68            disasm_cmd,
69            "-o",
70            "quit",
71        ])
72        .stdout(Stdio::piped())
73        .stderr(Stdio::piped())
74        .output()
75        .with_context(|| format!("invoking {bin} for disasm"))?;
76    if !output.status.success() && output.stdout.is_empty() {
77        bail!(
78            "{bin} exited {}: {}",
79            output.status,
80            String::from_utf8_lossy(&output.stderr)
81        );
82    }
83    Ok(String::from_utf8_lossy(&output.stdout).to_string())
84}
85
86/// Very rough upper bound on code size: span between first and last
87/// `0x<hex>:` address column emitted by lldb's disassemble (works for
88/// both x86-64 and aarch64 dumps).
89fn count_instruction_bytes(asm: &str) -> Option<i64> {
90    let re = lldb_addr_regex();
91    let mut addrs = asm
92        .lines()
93        .filter_map(|l| re.captures(l).and_then(|c| u64::from_str_radix(&c[1], 16).ok()));
94    let first = addrs.next()?;
95    // `next_back()` walks from the end; `last()` would re-scan the
96    // entire iterator just to find the final element.
97    let last = addrs.next_back()?;
98    Some((last as i64) - (first as i64))
99}
100
101fn lldb_addr_regex() -> &'static Regex {
102    static RE: OnceLock<Regex> = OnceLock::new();
103    RE.get_or_init(|| Regex::new(r"(?:->\s+)?\s*0x([0-9a-fA-F]+)\s*[:<]").unwrap())
104}
105
106// ============================================================
107// .NET jitdasm (via `DOTNET_JitDisasm` env on a fresh process)
108// ============================================================
109
110pub struct JitDasmCollector;
111
112impl OnDemandCollector for JitDasmCollector {
113    fn kind(&self) -> &'static str {
114        "jitdasm"
115    }
116
117    fn supports(&self, class: TargetClass) -> bool {
118        matches!(class, TargetClass::ManagedDotnet)
119    }
120
121    /// Prefers the `jitdasm` backend's pre-captured `capture.asm` (the
122    /// backend ran `DOTNET_JitDisasm='*'` once at session start and
123    /// dumped every method to a file). When that file is missing —
124    /// e.g. the session was started with a different .NET backend —
125    /// falls back to spawning a fresh `dotnet` with `DOTNET_JitDisasm`
126    /// scoped to the requested symbol. The live debug session is
127    /// untouched either way.
128    fn collect(
129        &self,
130        ctx: &CollectCtx<'_>,
131        _live: Option<&dyn LiveDebugger>,
132    ) -> Result<DisasmOutput> {
133        let capture = std::env::var_os("DBG_JITDASM_CAPTURE").map(std::path::PathBuf::from);
134        let (text, source_desc) = match capture.as_ref().filter(|p| p.is_file()) {
135            Some(p) => (
136                std::fs::read_to_string(p)
137                    .with_context(|| format!("reading {}", p.display()))?,
138                p.display().to_string(),
139            ),
140            None => (run_jitdasm_fresh(ctx.target, ctx.symbol)?, "fresh dotnet run".into()),
141        };
142
143        // `::` is a C++/docs-style separator; .NET uses a single `:`.
144        // Normalise so the same symbol works in either shape.
145        let needle = ctx.symbol.replace("::", ":");
146
147        // Use the shared `JitIndex` fallback path so `dbg disasm` from
148        // a shell behaves exactly like the interactive REPL: when the
149        // target has no standalone body but the call graph knows
150        // callers, return the callers' bodies banner-separated (the
151        // inlined code lives embedded inside them). A plain header
152        // scan would bail with "no matching header" and leave the
153        // agent stuck.
154        let index = JitIndex::parse(&text);
155        let asm_text = index.disasm_with_parent_fallback(&needle).ok_or_else(|| {
156            anyhow::anyhow!(
157                "jitdasm produced no assembly for {} — no standalone body and no caller \
158                 references the name (so the method is either truly absent from this capture, \
159                 the pattern is misspelled, or every caller was also inlined away). Searched {}",
160                ctx.symbol,
161                source_desc,
162            )
163        })?;
164
165        let tier = parse_jitdasm_tier(&asm_text);
166        Ok(DisasmOutput {
167            source: "jitdasm",
168            tier,
169            code_bytes: None,
170            asm_text,
171            asm_lines_json: None,
172        })
173    }
174}
175
176/// Fallback when no pre-captured `capture.asm` is available.
177///
178/// `dotnet run --project <csproj>` appears to drop `DOTNET_JitDisasm`
179/// (and friends) somewhere between the `dotnet` CLI host and the user
180/// process — an empty capture with zero `; Assembly listing` headers
181/// is the observable symptom. `dotnet exec <dll>` bypasses the
182/// intermediate host and the env vars reach the JIT. So: for a
183/// project target, first `dotnet build` and then resolve the output
184/// dll via the `runtimeconfig.json` sibling (only executable outputs
185/// emit one); for an already-built dll/exe target, exec it directly.
186fn run_jitdasm_fresh(target: &str, symbol: &str) -> Result<String> {
187    let dotnet = std::env::var("DOTNET").unwrap_or_else(|_| "dotnet".into());
188
189    let dll_path: std::path::PathBuf = if target.ends_with(".csproj") || target.ends_with(".fsproj")
190    {
191        // Build the project so bin/Release/net*/ is populated.
192        let build = Command::new(&dotnet)
193            .args(["build", target, "-c", "Release", "--nologo", "-v", "q"])
194            .stdout(Stdio::piped())
195            .stderr(Stdio::piped())
196            .output()
197            .with_context(|| format!("invoking {dotnet} build for {target}"))?;
198        if !build.status.success() {
199            bail!(
200                "dotnet build {} failed:\n{}",
201                target,
202                String::from_utf8_lossy(&build.stderr)
203            );
204        }
205        let proj_dir = std::path::Path::new(target)
206            .parent()
207            .unwrap_or_else(|| std::path::Path::new("."));
208        locate_executable_dll(proj_dir).with_context(|| {
209            format!(
210                "locating built dll under {}/bin/Release/net*/",
211                proj_dir.display()
212            )
213        })?
214    } else {
215        std::path::PathBuf::from(target)
216    };
217
218    let output = Command::new(&dotnet)
219        .arg("exec")
220        .arg(&dll_path)
221        .env("DOTNET_JitDisasm", symbol)
222        .env("DOTNET_TieredCompilation", "0") // deterministic tier
223        .env("DOTNET_JitDiffableDasm", "1")
224        .stdout(Stdio::piped())
225        .stderr(Stdio::piped())
226        .output()
227        .with_context(|| format!("invoking {dotnet} exec {}", dll_path.display()))?;
228    // JitDisasm writes to stdout under `dotnet exec` (stderr-vs-stdout
229    // varies by host); concatenate both so the parser sees everything.
230    let mut text = String::from_utf8_lossy(&output.stdout).into_owned();
231    text.push('\n');
232    text.push_str(&String::from_utf8_lossy(&output.stderr));
233    if text.trim().is_empty() {
234        bail!("dotnet exited {} with no output", output.status);
235    }
236    Ok(text)
237}
238
239/// Find the executable dll in a project's Release output directory.
240///
241/// Executable projects emit a `<AssemblyName>.runtimeconfig.json` next
242/// to the dll — library projects do not. Using its presence as a
243/// marker is more reliable than globbing *.dll and guessing which one
244/// is the entry point when dependencies are side-by-side.
245fn locate_executable_dll(proj_dir: &std::path::Path) -> Result<std::path::PathBuf> {
246    let release_root = proj_dir.join("bin").join("Release");
247    let tfm_dirs = std::fs::read_dir(&release_root)
248        .with_context(|| format!("reading {}", release_root.display()))?;
249    // Multiple `net*` TFMs may coexist (e.g. net6.0 + net8.0); prefer
250    // the most recently modified so a fresh build wins.
251    let mut candidates: Vec<(std::time::SystemTime, std::path::PathBuf)> = Vec::new();
252    for tfm in tfm_dirs.flatten() {
253        let tfm_path = tfm.path();
254        if !tfm_path.is_dir() {
255            continue;
256        }
257        let Ok(entries) = std::fs::read_dir(&tfm_path) else { continue };
258        for entry in entries.flatten() {
259            let p = entry.path();
260            if p.extension().and_then(|e| e.to_str()) == Some("json")
261                && p.file_name()
262                    .and_then(|n| n.to_str())
263                    .is_some_and(|n| n.ends_with(".runtimeconfig.json"))
264            {
265                let dll = p.with_extension("").with_extension("dll");
266                if dll.is_file() {
267                    let mtime = entry
268                        .metadata()
269                        .and_then(|m| m.modified())
270                        .unwrap_or(std::time::UNIX_EPOCH);
271                    candidates.push((mtime, dll));
272                }
273            }
274        }
275    }
276    candidates.sort_by(|a, b| b.0.cmp(&a.0));
277    candidates
278        .into_iter()
279        .next()
280        .map(|(_, p)| p)
281        .ok_or_else(|| anyhow::anyhow!("no <name>.runtimeconfig.json found under {}/net*/", release_root.display()))
282}
283
284fn parse_jitdasm_tier(asm: &str) -> Option<String> {
285    static RE: OnceLock<Regex> = OnceLock::new();
286    let re = RE.get_or_init(|| Regex::new(r"\((Tier-?\d|OSR|MinOpts|FullOpts)\)").unwrap());
287    for line in asm.lines().take(3) {
288        if let Some(c) = re.captures(line) {
289            return Some(c[1].to_lowercase().replace('-', ""));
290        }
291    }
292    None
293}
294
295// ============================================================
296// Go: `go tool objdump -s <sym> <target>`
297// ============================================================
298
299pub struct GoDisassCollector;
300
301impl OnDemandCollector for GoDisassCollector {
302    fn kind(&self) -> &'static str {
303        "go-objdump"
304    }
305
306    fn supports(&self, class: TargetClass) -> bool {
307        // Go binaries compile to native, but we expose them as
308        // NativeCpu in the target-class taxonomy. This collector is
309        // safe to register alongside the lldb collector — the
310        // dispatcher (task 8) picks one per invocation.
311        matches!(class, TargetClass::NativeCpu)
312    }
313
314    fn collect(
315        &self,
316        ctx: &CollectCtx<'_>,
317        _live: Option<&dyn LiveDebugger>,
318    ) -> Result<DisasmOutput> {
319        let output = Command::new("go")
320            .args(["tool", "objdump", "-s", ctx.symbol, ctx.target])
321            .stdout(Stdio::piped())
322            .stderr(Stdio::piped())
323            .output()
324            .context("invoking `go tool objdump`")?;
325        if !output.status.success() {
326            bail!(
327                "go tool objdump failed: {}",
328                String::from_utf8_lossy(&output.stderr).trim()
329            );
330        }
331        let asm_text = String::from_utf8_lossy(&output.stdout).trim().to_string();
332        if asm_text.is_empty() {
333            bail!("go tool objdump produced no output for {}", ctx.symbol);
334        }
335        Ok(DisasmOutput {
336            source: "go-objdump",
337            tier: None,
338            code_bytes: None,
339            asm_text,
340            asm_lines_json: None,
341        })
342    }
343}
344
345#[cfg(test)]
346mod tests {
347    use super::*;
348
349    // Parser-level tests — don't require the tool to be installed.
350
351    #[test]
352    fn jitdasm_collector_isolates_target_method() {
353        let text = "\
354Hello from pre-JIT chatter.
355; Assembly listing for method MyApp.Foo:Bar() (Tier1)
356 mov rax, rbx
357 ret
358; Assembly listing for method MyApp.Baz:Qux()
359 mov rcx, rdx";
360        let got = JitIndex::parse(text)
361            .disasm_with_parent_fallback("MyApp.Foo:Bar")
362            .expect("expected match");
363        assert!(got.contains("MyApp.Foo:Bar"));
364        assert!(got.contains("mov rax, rbx"));
365        assert!(!got.contains("MyApp.Baz:Qux"));
366    }
367
368    #[test]
369    fn jitdasm_collector_normalizes_double_colon() {
370        // Callers (and human intuition) often write C++/docs-style
371        // `Namespace::Type::Method`; the .NET jitdasm header uses a
372        // single `:` between type and method. The collector
373        // normalises `::` → `:` before lookup so `dbg disasm
374        // "Broken.Program::SumFast"` still works.
375        let text = "\
376; Assembly listing for method Broken.Program:SumFast(System.Int32[]):int (Tier1)
377 vaddps ymm0, ymm0, ymm1
378 ret";
379        let needle = "Broken.Program::SumFast".replace("::", ":");
380        let got = JitIndex::parse(text)
381            .disasm_with_parent_fallback(&needle)
382            .expect("expected match");
383        assert!(
384            got.contains("vaddps"),
385            "double-colon form did not match single-colon header: got={got:?}"
386        );
387    }
388
389    #[test]
390    fn jitdasm_collector_empty_when_no_match_and_no_callers() {
391        let text = "; Assembly listing for method MyApp.X:Y\n mov rax, rbx";
392        let got = JitIndex::parse(text).disasm_with_parent_fallback("Other.Method");
393        assert!(
394            got.is_none(),
395            "no standalone body and no caller references should return None: {got:?}"
396        );
397    }
398
399    #[test]
400    fn jitdasm_collector_falls_back_to_parent_on_inlined_target() {
401        // Inlined target: no standalone `Helper:Probe` header, but
402        // `Outer:Run` contains a call to it. The collector should
403        // surface `Outer:Run`'s body with the banner.
404        let text = "\
405; Assembly listing for method MyNs.Outer:Run():int (FullOpts)
406       push     rbp
407       call     [MyNs.Helper:Probe(long):int]
408       pop      rbp
409       ret
410; Total bytes of code 9";
411        let got = JitIndex::parse(text)
412            .disasm_with_parent_fallback("Helper:Probe")
413            .expect("expected fallback-to-parent hit");
414        assert!(
415            got.contains("no standalone body") && got.contains("parent:"),
416            "fallback output should carry the banner+parent header: {got}"
417        );
418        assert!(
419            got.contains("MyNs.Outer:Run"),
420            "parent method name should be shown: {got}"
421        );
422        assert!(
423            got.contains("call     [MyNs.Helper:Probe"),
424            "parent body should be embedded verbatim: {got}"
425        );
426    }
427
428    #[test]
429    fn parse_tier_from_header() {
430        let asm = "; Assembly listing for method MyApp.Foo:Bar() (Tier1)\n mov rax, rbx";
431        assert_eq!(parse_jitdasm_tier(asm), Some("tier1".into()));
432        let asm = "; Assembly listing for method ... (Tier-0)\nnop";
433        assert_eq!(parse_jitdasm_tier(asm), Some("tier0".into()));
434        let asm = "; Assembly listing for method ... (FullOpts)\nnop";
435        assert_eq!(parse_jitdasm_tier(asm), Some("fullopts".into()));
436        let asm = "; Assembly listing for method ... (no tier mark)\nnop";
437        assert_eq!(parse_jitdasm_tier(asm), None);
438    }
439
440    #[test]
441    fn count_bytes_from_address_column() {
442        let asm = "\
443test`main:
444    0x100003f80 <+0>:   push   rbp
445    0x100003f84 <+4>:   mov    rbp, rsp
446    0x100003f88 <+8>:   mov    eax, 0x0
447    0x100003f8d <+13>:  pop    rbp
448    0x100003f8e <+14>:  ret";
449        let bytes = count_instruction_bytes(asm);
450        assert_eq!(bytes, Some(0x100003f8e - 0x100003f80));
451    }
452
453    #[test]
454    fn count_bytes_none_on_empty() {
455        assert_eq!(count_instruction_bytes(""), None);
456        assert_eq!(count_instruction_bytes("no addrs here"), None);
457    }
458
459    #[test]
460    fn collector_supports_matrix() {
461        let l = LldbDisassembleCollector;
462        assert!(l.supports(TargetClass::NativeCpu));
463        assert!(!l.supports(TargetClass::ManagedDotnet));
464        assert!(!l.supports(TargetClass::Python));
465
466        let j = JitDasmCollector;
467        assert!(j.supports(TargetClass::ManagedDotnet));
468        assert!(!j.supports(TargetClass::NativeCpu));
469
470        let g = GoDisassCollector;
471        assert!(g.supports(TargetClass::NativeCpu));
472        assert!(!g.supports(TargetClass::Python));
473    }
474
475    #[test]
476    fn kinds_match_source_column() {
477        assert_eq!(LldbDisassembleCollector.kind(), "lldb-disassemble");
478        assert_eq!(JitDasmCollector.kind(), "jitdasm");
479        assert_eq!(GoDisassCollector.kind(), "go-objdump");
480    }
481}