dbg_cli/session_db/collectors/
disasm.rs1use 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
19pub 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
86fn 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 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
106pub 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 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 let needle = ctx.symbol.replace("::", ":");
146
147 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
176fn 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 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") .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 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
239fn 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 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
295pub 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 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 #[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 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 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}