Skip to main content

dbg_cli/session_db/
lifecycle.rs

1//! SessionDb lifecycle: create, open (with strict schema-version check),
2//! save-to, prune, session groups, raw-file paths.
3
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, bail};
9use rusqlite::{Connection, OptionalExtension, params};
10
11use super::schema::{self, SCHEMA_VERSION};
12use super::{SessionKind, TargetClass};
13
14/// An open SessionDb — either in-memory (fresh) or backed by a file.
15pub struct SessionDb {
16    conn: Connection,
17    session_id: String,
18    label: String,
19    kind: SessionKind,
20    target_class: TargetClass,
21    target: String,
22    db_path: Option<PathBuf>,
23}
24
25impl std::fmt::Debug for SessionDb {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        f.debug_struct("SessionDb")
28            .field("session_id", &self.session_id)
29            .field("label", &self.label)
30            .field("kind", &self.kind)
31            .field("target_class", &self.target_class)
32            .field("target", &self.target)
33            .field("db_path", &self.db_path)
34            .finish()
35    }
36}
37
38/// Parameters for `SessionDb::create`.
39pub struct CreateOptions<'a> {
40    pub kind: SessionKind,
41    pub target: &'a str,
42    pub target_class: TargetClass,
43    pub cwd: &'a Path,
44    /// `None` → in-memory DB. Call `save_to` later to persist.
45    pub db_path: Option<&'a Path>,
46    /// `None` → auto-generate `"{basename}-{yyyymmdd-hhmmss}"`.
47    pub label: Option<String>,
48    /// `None` → auto-compute `size:mtime_ns` if `target` is a file.
49    pub target_hash: Option<String>,
50}
51
52/// Whether `prune` deletes only auto-created sessions or everything over age.
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
54pub enum PrunePolicy {
55    AutoOnly,
56    All,
57}
58
59impl SessionDb {
60    /// Create a fresh SessionDb. Applies DDL, stamps `PRAGMA user_version`,
61    /// inserts the session row, and records session-group metadata.
62    pub fn create(opts: CreateOptions<'_>) -> Result<Self> {
63        let conn = match opts.db_path {
64            Some(p) => {
65                if let Some(parent) = p.parent() {
66                    fs::create_dir_all(parent).ok();
67                }
68                Connection::open(p)?
69            }
70            None => Connection::open_in_memory()?,
71        };
72        conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")
73            .ok(); // WAL unsupported in-memory — non-fatal.
74        schema::apply(&conn, opts.target_class)?;
75        conn.execute(
76            &format!("PRAGMA user_version = {SCHEMA_VERSION}"),
77            [],
78        )?;
79
80        let session_id = random_id(&conn)?;
81        let label = match opts.label {
82            Some(l) => l,
83            None => auto_label(opts.target)?,
84        };
85        let target_hash = match opts.target_hash {
86            Some(h) => Some(h),
87            None => compute_target_hash(Path::new(opts.target)).ok(),
88        };
89
90        conn.execute(
91            "INSERT INTO sessions
92                (id, kind, target, target_class, target_hash,
93                 started_at, label, created_by)
94             VALUES (?1, ?2, ?3, ?4, ?5, datetime('now'), ?6, 'auto')",
95            params![
96                session_id,
97                opts.kind.as_str(),
98                opts.target,
99                opts.target_class.as_str(),
100                target_hash,
101                label,
102            ],
103        )?;
104
105        // Record session-group key under meta so peers can be discovered
106        // by scanning `.dbg/sessions/*.db` and matching on this value.
107        let cwd_canonical = opts
108            .cwd
109            .canonicalize()
110            .unwrap_or_else(|_| opts.cwd.to_path_buf());
111        let group = group_key(&cwd_canonical, target_hash.as_deref().unwrap_or(""));
112        conn.execute(
113            "INSERT INTO meta (session_id, key, value) VALUES (?1, 'session_group_key', ?2)",
114            params![session_id, group],
115        )?;
116        conn.execute(
117            "INSERT INTO meta (session_id, key, value) VALUES (?1, 'cwd', ?2)",
118            params![session_id, cwd_canonical.to_string_lossy().as_ref()],
119        )?;
120
121        Ok(SessionDb {
122            conn,
123            session_id,
124            label,
125            kind: opts.kind,
126            target_class: opts.target_class,
127            target: opts.target.to_string(),
128            db_path: opts.db_path.map(Path::to_path_buf),
129        })
130    }
131
132    /// Open an existing SessionDb from disk. Refuses to open DBs with a
133    /// mismatched `PRAGMA user_version`: the caller must re-collect.
134    pub fn open(path: &Path) -> Result<Self> {
135        if !path.exists() {
136            bail!("session DB not found: {}", path.display());
137        }
138        let conn = Connection::open(path)
139            .with_context(|| format!("opening session DB {}", path.display()))?;
140
141        let found: i64 = conn
142            .query_row("PRAGMA user_version", [], |r| r.get(0))
143            .with_context(|| format!("reading user_version from {}", path.display()))?;
144        if found != SCHEMA_VERSION {
145            bail!(
146                "session DB schema_version={found}, expected {SCHEMA_VERSION} at {path}.\n\
147                 No migration path. Re-collect against the raw files in {raw} \
148                 (or delete the DB and re-run the originating command).",
149                found = found,
150                SCHEMA_VERSION = SCHEMA_VERSION,
151                path = path.display(),
152                raw = path
153                    .parent()
154                    .map(|p| p.join(path.file_stem().unwrap_or_default()).join("raw"))
155                    .map(|p| p.display().to_string())
156                    .unwrap_or_else(|| "(no raw dir)".into()),
157            );
158        }
159
160        // A well-formed DB has exactly one session row (the owning session).
161        let (session_id, label, kind_s, class_s, target): (String, String, String, String, String) =
162            conn.query_row(
163                "SELECT id, label, kind, target_class, target FROM sessions LIMIT 1",
164                [],
165                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?)),
166            )
167            .with_context(|| format!("reading session row from {}", path.display()))?;
168
169        let kind = match kind_s.as_str() {
170            "debug" => SessionKind::Debug,
171            "profile" => SessionKind::Profile,
172            other => bail!("unknown session kind in DB: {other}"),
173        };
174        let target_class: TargetClass = class_s
175            .parse()
176            .with_context(|| format!("parsing target_class {class_s}"))?;
177
178        Ok(SessionDb {
179            conn,
180            session_id,
181            label,
182            kind,
183            target_class,
184            target,
185            db_path: Some(path.to_path_buf()),
186        })
187    }
188
189    /// Copy the current in-memory (or on-disk) DB to `path` using the SQLite
190    /// online backup API. The source DB stays usable.
191    pub fn save_to(&self, path: &Path) -> Result<()> {
192        if let Some(parent) = path.parent() {
193            fs::create_dir_all(parent)?;
194        }
195        let mut dst = Connection::open(path)?;
196        let backup = rusqlite::backup::Backup::new(&self.conn, &mut dst)?;
197        backup.run_to_completion(128, Duration::from_millis(50), None)?;
198        // The backup object cannot borrow `dst` after run_to_completion
199        // returns, so set user_version on the freshly-populated file.
200        drop(backup);
201        dst.execute(&format!("PRAGMA user_version = {SCHEMA_VERSION}"), [])?;
202        Ok(())
203    }
204
205    /// True iff the session has accumulated data worth keeping — either a
206    /// breakpoint hit was captured (debug track) or a data layer landed
207    /// (profile track). Used by the daemon shutdown path to decide whether
208    /// to persist or discard an auto session.
209    pub fn has_captured_data(&self) -> Result<bool> {
210        let hits: i64 = self.conn.query_row(
211            "SELECT COUNT(*) FROM breakpoint_hits WHERE session_id = ?1",
212            params![self.session_id],
213            |r| r.get(0),
214        )?;
215        if hits > 0 {
216            return Ok(true);
217        }
218        let layers: i64 = self.conn.query_row(
219            "SELECT COUNT(*) FROM layers WHERE session_id = ?1",
220            params![self.session_id],
221            |r| r.get(0),
222        )?;
223        if layers > 0 {
224            return Ok(true);
225        }
226        // Profile-mode sessions (dotnet-trace, perf, callgrind, import)
227        // stash their source content in meta.profile_raw rather than
228        // creating a layers row. Treat that as captured data so
229        // `persist_session_on_exit` actually saves the DB and
230        // `dbg replay <label>` can rehydrate the profile.
231        let profile_raw: i64 = self.conn.query_row(
232            "SELECT COUNT(*) FROM meta WHERE session_id = ?1 AND key = 'profile_raw'",
233            params![self.session_id],
234            |r| r.get(0),
235        )?;
236        Ok(profile_raw > 0)
237    }
238
239    /// Promote this session so `prune` will never delete it.
240    pub fn promote_to_user(&self) -> Result<()> {
241        self.conn.execute(
242            "UPDATE sessions SET created_by = 'user' WHERE id = ?1",
243            params![self.session_id],
244        )?;
245        Ok(())
246    }
247
248    pub fn set_meta(&self, key: &str, value: &str) -> Result<()> {
249        self.conn.execute(
250            "INSERT INTO meta (session_id, key, value) VALUES (?1, ?2, ?3)
251             ON CONFLICT(session_id, key) DO UPDATE SET value = excluded.value",
252            params![self.session_id, key, value],
253        )?;
254        Ok(())
255    }
256
257    pub fn meta(&self, key: &str) -> Result<Option<String>> {
258        Ok(self.conn
259            .query_row(
260                "SELECT value FROM meta WHERE session_id = ?1 AND key = ?2",
261                params![self.session_id, key],
262                |r| r.get(0),
263            )
264            .optional()?)
265    }
266
267    pub fn conn(&self) -> &Connection { &self.conn }
268    pub fn session_id(&self) -> &str { &self.session_id }
269    pub fn label(&self) -> &str { &self.label }
270    pub fn kind(&self) -> SessionKind { self.kind }
271    pub fn target_class(&self) -> TargetClass { self.target_class }
272    pub fn target(&self) -> &str { &self.target }
273    pub fn db_path(&self) -> Option<&Path> { self.db_path.as_deref() }
274}
275
276/// `<cwd>/.dbg/sessions/` — where saved session DBs live.
277pub fn sessions_dir(cwd: &Path) -> PathBuf {
278    cwd.join(".dbg").join("sessions")
279}
280
281/// `<cwd>/.dbg/sessions/<label>/raw/` — where raw captures (`perf.data`,
282/// `nsys-rep`, `.nettrace`, etc.) are preserved verbatim. Per principle 5
283/// (adaptation layer), these files are the durable artifact.
284pub fn raw_dir(cwd: &Path, label: &str) -> PathBuf {
285    sessions_dir(cwd).join(label).join("raw")
286}
287
288/// Deterministic session-group id derived from `(cwd, target_hash)`.
289/// Two sessions share a group iff they were launched against the same
290/// target from the same working directory.
291pub fn group_key(cwd: &Path, target_hash: &str) -> String {
292    format!("{}|{}", cwd.display(), target_hash)
293}
294
295/// Cheap target fingerprint: `"<size>:<mtime_ns>"`. Good enough to detect
296/// "target has been rebuilt since last session". Not a cryptographic hash.
297pub fn compute_target_hash(target: &Path) -> Result<String> {
298    let md = fs::metadata(target)
299        .with_context(|| format!("stat {}", target.display()))?;
300    let size = md.len();
301    let mtime = md
302        .modified()
303        .ok()
304        .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
305        .map(|d| d.as_nanos())
306        .unwrap_or(0);
307    Ok(format!("{size}:{mtime}"))
308}
309
310/// `"{basename}-{yyyymmdd-hhmmss}"` in the local-ish (system time) zone,
311/// computed from `SystemTime::now()` without pulling in chrono.
312pub fn auto_label(target: &str) -> Result<String> {
313    let basename = Path::new(target)
314        .file_name()
315        .and_then(|s| s.to_str())
316        .unwrap_or("session")
317        .to_string();
318    let secs = SystemTime::now()
319        .duration_since(UNIX_EPOCH)
320        .map(|d| d.as_secs())
321        .unwrap_or(0);
322    Ok(format!("{basename}-{}", format_timestamp(secs)))
323}
324
325/// UTC `yyyymmdd-hhmmss`. Proleptic Gregorian, no leap seconds —
326/// sufficient for session labels.
327fn format_timestamp(secs: u64) -> String {
328    let (y, mo, d, h, mi, s) = decompose_utc(secs);
329    format!("{y:04}{mo:02}{d:02}-{h:02}{mi:02}{s:02}")
330}
331
332fn decompose_utc(mut secs: u64) -> (u32, u32, u32, u32, u32, u32) {
333    let s = (secs % 60) as u32;
334    secs /= 60;
335    let mi = (secs % 60) as u32;
336    secs /= 60;
337    let h = (secs % 24) as u32;
338    secs /= 24;
339    let mut days = secs as i64;
340
341    // Convert days-since-1970-01-01 to (year, month, day) — algorithm from
342    // Howard Hinnant's date library, public-domain.
343    days += 719_468;
344    let era = if days >= 0 { days / 146_097 } else { (days - 146_096) / 146_097 };
345    let doe = (days - era * 146_097) as u32;
346    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
347    let y = (yoe as i64) + era * 400;
348    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
349    let mp = (5 * doy + 2) / 153;
350    let d = doy - (153 * mp + 2) / 5 + 1;
351    let mo = if mp < 10 { mp + 3 } else { mp - 9 };
352    let y = if mo <= 2 { y + 1 } else { y };
353    (y as u32, mo, d, h, mi, s)
354}
355
356fn random_id(conn: &Connection) -> Result<String> {
357    // 16-byte randomblob → 32-hex-char id. SQLite's CSPRNG.
358    let id: String =
359        conn.query_row("SELECT lower(hex(randomblob(16)))", [], |r| r.get(0))?;
360    Ok(id)
361}
362
363/// Walk `sessions_dir`, delete `.db` files for auto sessions whose mtime
364/// is older than `older_than`. Returns the list of deleted paths.
365/// User-promoted sessions are never touched under `PrunePolicy::AutoOnly`.
366pub fn prune(
367    sessions_dir: &Path,
368    older_than: Duration,
369    policy: PrunePolicy,
370) -> Result<Vec<PathBuf>> {
371    if !sessions_dir.exists() {
372        return Ok(vec![]);
373    }
374    let cutoff = SystemTime::now() - older_than;
375    let mut deleted = vec![];
376
377    for entry in fs::read_dir(sessions_dir)? {
378        let entry = entry?;
379        let path = entry.path();
380        if path.extension().and_then(|s| s.to_str()) != Some("db") {
381            continue;
382        }
383        let md = match entry.metadata() {
384            Ok(m) => m,
385            Err(_) => continue,
386        };
387        let mtime = md.modified().unwrap_or(SystemTime::now());
388        if mtime > cutoff {
389            continue;
390        }
391
392        // Inspect created_by without opening via SessionDb (which would
393        // fail on version mismatch — intentional, but the prune path
394        // should still be able to remove obsolete files).
395        let should_delete = match policy {
396            PrunePolicy::All => true,
397            PrunePolicy::AutoOnly => is_auto_session(&path).unwrap_or(false),
398        };
399        if !should_delete {
400            continue;
401        }
402
403        // Remove the DB plus any adjacent raw-capture directory named
404        // after the label (file stem).
405        fs::remove_file(&path).ok();
406        if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
407            let raw = sessions_dir.join(stem);
408            if raw.is_dir() {
409                fs::remove_dir_all(&raw).ok();
410            }
411        }
412        deleted.push(path);
413    }
414
415    Ok(deleted)
416}
417
418fn is_auto_session(path: &Path) -> Result<bool> {
419    let conn = Connection::open(path)?;
420    let v: i64 = conn
421        .query_row("PRAGMA user_version", [], |r| r.get(0))
422        .unwrap_or(-1);
423    if v != SCHEMA_VERSION {
424        // Unknown/old format — treat as auto so prune can clean it up.
425        return Ok(true);
426    }
427    let created_by: String = conn
428        .query_row("SELECT created_by FROM sessions LIMIT 1", [], |r| r.get(0))
429        .unwrap_or_else(|_| "auto".to_string());
430    Ok(created_by == "auto")
431}
432
433#[cfg(test)]
434mod tests {
435    use super::*;
436    use tempfile::TempDir;
437
438    fn opts<'a>(cwd: &'a Path, target: &'a str) -> CreateOptions<'a> {
439        CreateOptions {
440            kind: SessionKind::Debug,
441            target,
442            target_class: TargetClass::NativeCpu,
443            cwd,
444            db_path: None,
445            label: Some(format!("t-{target}")),
446            target_hash: Some("test".into()),
447        }
448    }
449
450    #[test]
451    fn create_inserts_session_row() {
452        let tmp = TempDir::new().unwrap();
453        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
454        assert_eq!(db.kind(), SessionKind::Debug);
455        assert_eq!(db.target(), "/bin/ls");
456        let (label, class, created_by): (String, String, String) = db
457            .conn
458            .query_row(
459                "SELECT label, target_class, created_by FROM sessions",
460                [],
461                |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?)),
462            )
463            .unwrap();
464        assert_eq!(label, "t-/bin/ls");
465        assert_eq!(class, "native-cpu");
466        assert_eq!(created_by, "auto");
467    }
468
469    #[test]
470    fn save_and_open_roundtrip() {
471        let tmp = TempDir::new().unwrap();
472        let path = tmp.path().join("roundtrip.db");
473        let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
474        src.save_to(&path).unwrap();
475
476        let opened = SessionDb::open(&path).unwrap();
477        assert_eq!(opened.session_id(), src.session_id());
478        assert_eq!(opened.label(), src.label());
479        assert_eq!(opened.target_class(), TargetClass::NativeCpu);
480    }
481
482    #[test]
483    fn open_refuses_mismatched_version() {
484        let tmp = TempDir::new().unwrap();
485        let path = tmp.path().join("bad.db");
486        {
487            let conn = Connection::open(&path).unwrap();
488            // Pretend this DB was written by a far-future version.
489            conn.execute("PRAGMA user_version = 9999", []).unwrap();
490        }
491        let err = SessionDb::open(&path).unwrap_err().to_string();
492        assert!(
493            err.contains("schema_version=9999") && err.contains("No migration path"),
494            "error missing expected phrases: {err}"
495        );
496    }
497
498    #[test]
499    fn has_captured_data_false_on_fresh_session() {
500        let tmp = TempDir::new().unwrap();
501        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
502        assert!(!db.has_captured_data().unwrap());
503    }
504
505    #[test]
506    fn has_captured_data_true_after_breakpoint_hit() {
507        let tmp = TempDir::new().unwrap();
508        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
509        db.conn
510            .execute(
511                "INSERT INTO breakpoint_hits
512                    (session_id, location_key, hit_seq, ts)
513                 VALUES (?1, 'main.c:42', 1, datetime('now'))",
514                params![db.session_id()],
515            )
516            .unwrap();
517        assert!(db.has_captured_data().unwrap());
518    }
519
520    #[test]
521    fn has_captured_data_true_after_layer() {
522        let tmp = TempDir::new().unwrap();
523        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
524        db.conn
525            .execute(
526                "INSERT INTO layers (session_id, source) VALUES (?1, 'perf')",
527                params![db.session_id()],
528            )
529            .unwrap();
530        assert!(db.has_captured_data().unwrap());
531    }
532
533    #[test]
534    fn promote_to_user_sticks_through_save() {
535        let tmp = TempDir::new().unwrap();
536        let path = tmp.path().join("user.db");
537        let src = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
538        src.promote_to_user().unwrap();
539        src.save_to(&path).unwrap();
540        let opened = SessionDb::open(&path).unwrap();
541        let cb: String = opened
542            .conn
543            .query_row("SELECT created_by FROM sessions", [], |r| r.get(0))
544            .unwrap();
545        assert_eq!(cb, "user");
546    }
547
548    #[test]
549    fn set_and_get_meta() {
550        let tmp = TempDir::new().unwrap();
551        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
552        db.set_meta("custom", "hello").unwrap();
553        assert_eq!(db.meta("custom").unwrap(), Some("hello".into()));
554        db.set_meta("custom", "world").unwrap();
555        assert_eq!(db.meta("custom").unwrap(), Some("world".into()));
556    }
557
558    #[test]
559    fn session_group_key_persisted_at_create() {
560        let tmp = TempDir::new().unwrap();
561        let db = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
562        let g = db.meta("session_group_key").unwrap().unwrap();
563        assert!(g.ends_with("|test"), "unexpected group key: {g}");
564    }
565
566    #[test]
567    fn prune_auto_only_keeps_user_sessions() {
568        let tmp = TempDir::new().unwrap();
569        let dir = tmp.path().to_path_buf();
570
571        let auto_path = dir.join("auto.db");
572        let user_path = dir.join("user.db");
573        let a = SessionDb::create(CreateOptions {
574            db_path: None,
575            ..opts(&dir, "/bin/ls")
576        })
577        .unwrap();
578        a.save_to(&auto_path).unwrap();
579
580        let u = SessionDb::create(CreateOptions {
581            db_path: None,
582            ..opts(&dir, "/bin/ls")
583        })
584        .unwrap();
585        u.promote_to_user().unwrap();
586        u.save_to(&user_path).unwrap();
587
588        // Give the fs a tick so our ZERO-age cutoff sits at-or-after the
589        // file mtimes (required on coarse-grained timestamp filesystems).
590        std::thread::sleep(Duration::from_millis(10));
591
592        let deleted = prune(&dir, Duration::ZERO, PrunePolicy::AutoOnly).unwrap();
593        assert_eq!(deleted.len(), 1, "deleted: {deleted:?}");
594        assert!(deleted[0].ends_with("auto.db"));
595        assert!(user_path.exists());
596        assert!(!auto_path.exists());
597    }
598
599    #[test]
600    fn prune_skips_files_newer_than_cutoff() {
601        let tmp = TempDir::new().unwrap();
602        let dir = tmp.path().to_path_buf();
603        let p = dir.join("fresh.db");
604        let s = SessionDb::create(CreateOptions {
605            db_path: None,
606            ..opts(&dir, "/bin/ls")
607        })
608        .unwrap();
609        s.save_to(&p).unwrap();
610        // One-day cutoff should keep a just-created file.
611        let deleted = prune(&dir, Duration::from_secs(86_400), PrunePolicy::AutoOnly).unwrap();
612        assert!(deleted.is_empty());
613        assert!(p.exists());
614    }
615
616    #[test]
617    fn auto_label_has_expected_shape() {
618        let lbl = auto_label("/usr/bin/ls").unwrap();
619        assert!(lbl.starts_with("ls-"), "label: {lbl}");
620        let suffix = lbl.trim_start_matches("ls-");
621        assert_eq!(suffix.len(), 15); // yyyymmdd-hhmmss
622        let (date, time) = suffix.split_once('-').unwrap();
623        assert_eq!(date.len(), 8);
624        assert_eq!(time.len(), 6);
625        assert!(date.chars().all(|c| c.is_ascii_digit()));
626        assert!(time.chars().all(|c| c.is_ascii_digit()));
627    }
628
629    #[test]
630    fn raw_dir_is_sibling_of_label() {
631        let d = raw_dir(Path::new("/tmp/proj"), "myapp-20260415-120000");
632        assert_eq!(
633            d,
634            PathBuf::from("/tmp/proj/.dbg/sessions/myapp-20260415-120000/raw")
635        );
636    }
637
638    #[test]
639    fn group_key_stable_across_create_calls() {
640        let tmp = TempDir::new().unwrap();
641        let a = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
642        let b = SessionDb::create(opts(tmp.path(), "/bin/ls")).unwrap();
643        assert_eq!(
644            a.meta("session_group_key").unwrap(),
645            b.meta("session_group_key").unwrap()
646        );
647    }
648}