Skip to main content

dbg_cli/session_db/collectors/
mod.rs

1//! On-demand collectors.
2//!
3//! These populate the cross-track shared tables (`disassembly`,
4//! `source_snapshots`, `alloc_sites`) in response to a specific agent
5//! question — `dbg disasm <sym>`, `dbg source <sym>`, etc. They're
6//! lightweight: cheap enough to run mid-debug at a breakpoint.
7//!
8//! Wiring:
9//!   * A collector may have access to the live debugger session via
10//!     `LiveDebugger`. Collectors that can reuse the existing PTY do
11//!     so (lldb's `disassemble` runs cleanly inside an active session);
12//!     collectors that need a fresh process (.NET jitdasm, which must
13//!     set `DOTNET_JitDisasm` before the runtime starts) always spawn
14//!     one — never restarting the live debug session.
15//!   * Results are deduplicated on `(symbol_id, source, tier)` unless
16//!     `CollectCtx::refresh = true` is set.
17
18pub mod disasm;
19
20use std::path::Path;
21
22use anyhow::Result;
23use rusqlite::{OptionalExtension, params};
24
25use super::canonicalizer::{CanonicalSymbol, for_lang};
26use super::{SessionDb, TargetClass};
27
28/// What drove this collection — informational, stored on the row so
29/// agents can see whether disasm was requested at a stop point,
30/// drilled into from a hotspot, or asked for explicitly.
31#[derive(Clone, Copy, Debug, PartialEq, Eq)]
32pub enum CollectTrigger {
33    BreakpointHit,
34    HotspotDrill,
35    Explicit,
36}
37
38impl CollectTrigger {
39    pub fn as_str(self) -> &'static str {
40        match self {
41            CollectTrigger::BreakpointHit => "breakpoint-hit",
42            CollectTrigger::HotspotDrill => "hotspot-drill",
43            CollectTrigger::Explicit => "explicit",
44        }
45    }
46}
47
48/// Context passed to every collector invocation.
49pub struct CollectCtx<'a> {
50    pub target: &'a str,
51    pub target_class: TargetClass,
52    /// The symbol to collect for. May be a raw (pre-canonical) or
53    /// canonical form; the collector canonicalizes internally.
54    pub symbol: &'a str,
55    /// If `true`, overwrite any existing row matching
56    /// `(symbol_id, source, tier)` instead of returning the cached one.
57    pub refresh: bool,
58    pub trigger: CollectTrigger,
59    pub cwd: &'a Path,
60}
61
62/// The output of a disasm collector, before it's written to the DB.
63/// Separated so tests can exercise the shell-out + parse path without
64/// an actual SessionDb attached.
65#[derive(Clone, Debug, PartialEq, Eq)]
66pub struct DisasmOutput {
67    pub source: &'static str,
68    pub tier: Option<String>,
69    pub code_bytes: Option<i64>,
70    pub asm_text: String,
71    pub asm_lines_json: Option<String>,
72}
73
74/// A minimal handle the daemon passes to collectors so they can issue
75/// commands on the live debugger session when useful. None when the
76/// collector is invoked from a profile-only context (no live PTY).
77pub trait LiveDebugger: Send + Sync {
78    /// Send a native-tool command to the active debugger and return
79    /// its (cleaned) output.
80    fn send(&self, cmd: &str) -> Result<String>;
81    /// Tool name, for logging / error messages.
82    fn tool_name(&self) -> &'static str;
83}
84
85/// The on-demand collector trait.
86pub trait OnDemandCollector: Send + Sync {
87    /// Stable identifier — also stored in `disassembly.source`.
88    fn kind(&self) -> &'static str;
89
90    /// Does this collector handle the given target class?
91    fn supports(&self, class: TargetClass) -> bool;
92
93    /// Collect disassembly (or equivalent) for `ctx.symbol`.
94    fn collect(
95        &self,
96        ctx: &CollectCtx<'_>,
97        live: Option<&dyn LiveDebugger>,
98    ) -> Result<DisasmOutput>;
99}
100
101/// Upsert a symbols row for the given canonical form and return its id.
102/// Called by `persist_disasm` before inserting the disassembly row so
103/// `symbol_id` joins are always valid.
104fn upsert_symbol(db: &SessionDb, sym: &CanonicalSymbol) -> Result<i64> {
105    let session_id = current_session_id(db)?;
106    db.conn().execute(
107        "INSERT OR IGNORE INTO symbols (session_id, lang, fqn, file, line, demangled, raw, is_synthetic)
108         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
109        params![
110            session_id,
111            sym.lang,
112            sym.fqn,
113            sym.file,
114            sym.line,
115            sym.demangled,
116            sym.raw,
117            sym.is_synthetic as i64,
118        ],
119    )?;
120    Ok(db.conn().query_row(
121        "SELECT id FROM symbols WHERE session_id=?1 AND lang=?2 AND fqn=?3",
122        params![session_id, sym.lang, sym.fqn],
123        |r| r.get(0),
124    )?)
125}
126
127fn current_session_id(db: &SessionDb) -> Result<String> {
128    Ok(db
129        .conn()
130        .query_row("SELECT id FROM sessions LIMIT 1", [], |r| {
131            r.get::<_, String>(0)
132        })?)
133}
134
135/// Write a `DisasmOutput` into the `disassembly` table, keyed to the
136/// canonicalized symbol. Respects `ctx.refresh` — returns the existing
137/// row's id when a match is found and refresh is off.
138///
139/// Returns the `disassembly.id` of the stored row.
140pub fn persist_disasm(db: &SessionDb, ctx: &CollectCtx<'_>, output: &DisasmOutput) -> Result<i64> {
141    db.conn().execute_batch("BEGIN IMMEDIATE")?;
142    let result = persist_disasm_inner(db, ctx, output);
143    match result {
144        Ok(id) => {
145            db.conn().execute_batch("COMMIT")?;
146            Ok(id)
147        }
148        Err(error) => {
149            let _ = db.conn().execute_batch("ROLLBACK");
150            Err(error)
151        }
152    }
153}
154
155fn persist_disasm_inner(
156    db: &SessionDb,
157    ctx: &CollectCtx<'_>,
158    output: &DisasmOutput,
159) -> Result<i64> {
160    let lang = lang_for_class(ctx.target_class);
161    let canon = match for_lang(lang) {
162        Some(c) => c.canonicalize(ctx.symbol),
163        None => CanonicalSymbol {
164            lang: "unknown",
165            fqn: ctx.symbol.to_string(),
166            file: None,
167            line: None,
168            demangled: None,
169            raw: ctx.symbol.to_string(),
170            is_synthetic: false,
171        },
172    };
173    let symbol_id = upsert_symbol(db, &canon)?;
174    let session_id = current_session_id(db)?;
175
176    if !ctx.refresh {
177        let existing: Option<i64> = db
178            .conn()
179            .query_row(
180                "SELECT id FROM disassembly
181                 WHERE session_id=?1 AND symbol_id=?2 AND source=?3
182                       AND tier IS ?4",
183                params![session_id, symbol_id, output.source, output.tier],
184                |r| r.get(0),
185            )
186            .optional()?;
187        if let Some(id) = existing {
188            return Ok(id);
189        }
190    } else {
191        db.conn().execute(
192            "DELETE FROM disassembly
193             WHERE session_id=?1 AND symbol_id=?2 AND source=?3
194                   AND (tier IS ?4 OR tier=?4)",
195            params![session_id, symbol_id, output.source, output.tier],
196        )?;
197    }
198
199    db.conn().execute(
200        "INSERT INTO disassembly
201            (session_id, symbol_id, source, tier, code_bytes,
202             asm_text, asm_lines_json, collected_at, trigger)
203         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, datetime('now'), ?8)",
204        params![
205            session_id,
206            symbol_id,
207            output.source,
208            output.tier,
209            output.code_bytes,
210            output.asm_text,
211            output.asm_lines_json,
212            ctx.trigger.as_str(),
213        ],
214    )?;
215    Ok(db.conn().last_insert_rowid())
216}
217
218/// Pick the canonicalizer language for a target class. For
219/// `NativeCpu` we default to `cpp` — demangling covers C/C++/Rust
220/// equivalently for disasm purposes. Callers with language-specific
221/// knowledge may use the canonicalizer modules directly.
222fn lang_for_class(class: TargetClass) -> &'static str {
223    match class {
224        TargetClass::Gpu => "cuda",
225        TargetClass::NativeCpu => "cpp",
226        TargetClass::ManagedDotnet => "dotnet",
227        TargetClass::Jvm => "jvm",
228        TargetClass::Python => "python",
229        TargetClass::JsNode => "js",
230        TargetClass::Ruby => "ruby",
231        TargetClass::Php => "php",
232    }
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238    use crate::session_db::{CreateOptions, SessionKind};
239    use tempfile::TempDir;
240
241    fn test_db(tmp: &TempDir, class: TargetClass) -> SessionDb {
242        SessionDb::create(CreateOptions {
243            kind: SessionKind::Debug,
244            target: "./t",
245            target_class: class,
246            cwd: tmp.path(),
247            db_path: None,
248            label: Some("t".into()),
249            target_hash: Some("h".into()),
250        })
251        .unwrap()
252    }
253
254    fn ctx<'a>(tmp: &'a TempDir, symbol: &'a str, refresh: bool) -> CollectCtx<'a> {
255        CollectCtx {
256            target: "./t",
257            target_class: TargetClass::NativeCpu,
258            symbol,
259            refresh,
260            trigger: CollectTrigger::Explicit,
261            cwd: tmp.path(),
262        }
263    }
264
265    #[test]
266    fn persist_inserts_symbol_and_disasm() {
267        let tmp = TempDir::new().unwrap();
268        let db = test_db(&tmp, TargetClass::NativeCpu);
269        let out = DisasmOutput {
270            source: "lldb-disassemble",
271            tier: None,
272            code_bytes: Some(128),
273            asm_text: "mov rax, rbx\nret".into(),
274            asm_lines_json: None,
275        };
276        let id = persist_disasm(&db, &ctx(&tmp, "main", false), &out).unwrap();
277        assert!(id > 0);
278
279        let count: i64 = db
280            .conn()
281            .query_row("SELECT COUNT(*) FROM symbols", [], |r| r.get(0))
282            .unwrap();
283        assert_eq!(count, 1);
284        let dcount: i64 = db
285            .conn()
286            .query_row("SELECT COUNT(*) FROM disassembly", [], |r| r.get(0))
287            .unwrap();
288        assert_eq!(dcount, 1);
289    }
290
291    #[test]
292    fn persist_dedups_without_refresh() {
293        let tmp = TempDir::new().unwrap();
294        let db = test_db(&tmp, TargetClass::NativeCpu);
295        let out = DisasmOutput {
296            source: "lldb-disassemble",
297            tier: None,
298            code_bytes: None,
299            asm_text: "a".into(),
300            asm_lines_json: None,
301        };
302        let a = persist_disasm(&db, &ctx(&tmp, "main", false), &out).unwrap();
303        let b = persist_disasm(&db, &ctx(&tmp, "main", false), &out).unwrap();
304        assert_eq!(a, b, "second call should return cached id");
305
306        let count: i64 = db
307            .conn()
308            .query_row("SELECT COUNT(*) FROM disassembly", [], |r| r.get(0))
309            .unwrap();
310        assert_eq!(count, 1);
311    }
312
313    #[test]
314    fn persist_refresh_replaces_row() {
315        let tmp = TempDir::new().unwrap();
316        let db = test_db(&tmp, TargetClass::NativeCpu);
317        let v1 = DisasmOutput {
318            source: "lldb-disassemble",
319            tier: None,
320            code_bytes: None,
321            asm_text: "old asm".into(),
322            asm_lines_json: None,
323        };
324        let v2 = DisasmOutput {
325            source: "lldb-disassemble",
326            tier: None,
327            code_bytes: None,
328            asm_text: "new asm".into(),
329            asm_lines_json: None,
330        };
331        let _ = persist_disasm(&db, &ctx(&tmp, "main", false), &v1).unwrap();
332        let _ = persist_disasm(&db, &ctx(&tmp, "main", true), &v2).unwrap();
333
334        let count: i64 = db
335            .conn()
336            .query_row("SELECT COUNT(*) FROM disassembly", [], |r| r.get(0))
337            .unwrap();
338        assert_eq!(count, 1);
339        let text: String = db
340            .conn()
341            .query_row("SELECT asm_text FROM disassembly", [], |r| r.get(0))
342            .unwrap();
343        assert_eq!(text, "new asm");
344    }
345
346    #[test]
347    fn persist_distinguishes_by_tier() {
348        let tmp = TempDir::new().unwrap();
349        let db = test_db(&tmp, TargetClass::ManagedDotnet);
350        let t0 = DisasmOutput {
351            source: "jitdasm",
352            tier: Some("tier0".into()),
353            code_bytes: None,
354            asm_text: "tier-0".into(),
355            asm_lines_json: None,
356        };
357        let t1 = DisasmOutput {
358            source: "jitdasm",
359            tier: Some("tier1".into()),
360            code_bytes: None,
361            asm_text: "tier-1".into(),
362            asm_lines_json: None,
363        };
364        let c = CollectCtx {
365            target: "./t",
366            target_class: TargetClass::ManagedDotnet,
367            symbol: "MyApp.Foo",
368            refresh: false,
369            trigger: CollectTrigger::Explicit,
370            cwd: tmp.path(),
371        };
372        persist_disasm(&db, &c, &t0).unwrap();
373        persist_disasm(&db, &c, &t1).unwrap();
374
375        let count: i64 = db
376            .conn()
377            .query_row("SELECT COUNT(*) FROM disassembly", [], |r| r.get(0))
378            .unwrap();
379        assert_eq!(count, 2, "tier0 and tier1 are distinct rows");
380    }
381
382    #[test]
383    fn lang_mapping_covers_every_class() {
384        assert_eq!(lang_for_class(TargetClass::Gpu), "cuda");
385        assert_eq!(lang_for_class(TargetClass::NativeCpu), "cpp");
386        assert_eq!(lang_for_class(TargetClass::ManagedDotnet), "dotnet");
387        assert_eq!(lang_for_class(TargetClass::Jvm), "jvm");
388        assert_eq!(lang_for_class(TargetClass::Python), "python");
389        assert_eq!(lang_for_class(TargetClass::JsNode), "js");
390    }
391}