Skip to main content

browser_control/registry/
mod.rs

1//! SQLite-backed registry of running browser instances.
2
3pub mod bidi_lock;
4mod db;
5pub mod foreground;
6pub mod naming;
7pub mod schema;
8pub mod scratches;
9pub mod tabs;
10pub mod words;
11
12pub use bidi_lock::{BidiLockBusy, BidiLockGuard, BidiLockRow};
13pub use foreground::ForegroundRow;
14pub use scratches::ScratchRow;
15pub use tabs::TabRow;
16
17use anyhow::{anyhow, bail, Context, Result};
18use fs2::FileExt;
19use rusqlite::{params, OpenFlags};
20use serde::{Deserialize, Serialize};
21use std::fs::OpenOptions;
22use std::net::{SocketAddr, TcpStream};
23use std::path::{Path, PathBuf};
24use std::time::Duration;
25
26use crate::detect::{Engine, Kind};
27
28/// One row in the `browsers` table.
29#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30pub struct BrowserRow {
31    pub name: String,
32    pub kind: Kind,
33    pub engine: Engine,
34    pub pid: u32,
35    pub endpoint: String,
36    pub port: u16,
37    pub profile_dir: PathBuf,
38    pub executable: PathBuf,
39    pub headless: bool,
40    pub started_at: String,
41}
42
43/// Current registry liveness classification for a browser row.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum BrowserLiveness {
46    /// The recorded process exists and the debugging endpoint accepts TCP.
47    Alive,
48    /// The recorded process no longer exists. This is high-confidence stale
49    /// state and can be pruned.
50    DeadPid,
51    /// The recorded process exists, but the debugging endpoint did not accept
52    /// TCP right now. Treat as unavailable, but do not delete the row: short
53    /// endpoint outages can be transient during browser startup/restart.
54    EndpointUnreachable,
55}
56
57/// SQLite registry handle. SQLite-level WAL + `busy_timeout` handles
58/// concurrent reader/writer coordination across processes; we no longer
59/// hold a long-lived exclusive file lock for the lifetime of the
60/// handle. A short exclusive lock is taken only across the
61/// schema-migration window in `open_at` to serialise initial
62/// `CREATE TABLE` / `CREATE INDEX` against a concurrent first open.
63pub struct Registry {
64    conn: rusqlite::Connection,
65    /// Path the registry was opened at. Read by `bidi_lock_acquire` to
66    /// stamp the `BidiLockGuard`, whose `Drop` reopens a fresh connection
67    /// here to release the row (the guard outlives the borrowing handle).
68    db_path: PathBuf,
69}
70
71impl Registry {
72    /// Open the registry at the OS-standard location.
73    pub fn open() -> Result<Self> {
74        let p = crate::paths::registry_db_path()?;
75        Self::open_at(&p)
76    }
77
78    /// Open the registry at an explicit path.
79    pub fn open_at(path: &Path) -> Result<Self> {
80        if let Some(parent) = path.parent() {
81            if !parent.as_os_str().is_empty() {
82                std::fs::create_dir_all(parent)
83                    .with_context(|| format!("creating registry dir {}", parent.display()))?;
84            }
85        }
86
87        // Hold an exclusive file lock only across the initial schema
88        // migration. SQLite's own `CREATE TABLE IF NOT EXISTS` is
89        // idempotent and safe under concurrent execution, but
90        // narrowing the lock to this window keeps the historical
91        // serialisation guarantee for any future schema-altering
92        // change while letting steady-state CLI invocations proceed
93        // in parallel.
94        let lock_path = lock_path_for(path);
95        let lock_file = OpenOptions::new()
96            .create(true)
97            .read(true)
98            .write(true)
99            .truncate(false)
100            .open(&lock_path)
101            .with_context(|| format!("opening lock file {}", lock_path.display()))?;
102        FileExt::lock_exclusive(&lock_file)
103            .with_context(|| format!("acquiring exclusive lock on {}", lock_path.display()))?;
104
105        let conn = rusqlite::Connection::open_with_flags(
106            path,
107            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
108        )
109        .with_context(|| format!("opening registry db {}", path.display()))?;
110
111        configure_conn(&conn)?;
112        schema::apply(&conn)?;
113
114        // Release the migration lock. From here on, SQLite's
115        // WAL + busy_timeout handles inter-process coordination
116        // for both reads and writes; CLI invocations no longer
117        // serialise on browser I/O.
118        let _ = FileExt::unlock(&lock_file);
119        drop(lock_file);
120
121        Ok(Self {
122            conn,
123            db_path: path.to_path_buf(),
124        })
125    }
126
127    /// Open an in-memory registry (tests only). No file lock taken.
128    pub fn open_in_memory() -> Result<Self> {
129        let conn = rusqlite::Connection::open_in_memory().context("opening in-memory registry")?;
130        // WAL is not supported for :memory:; only set synchronous.
131        let _ = conn.pragma_update(None, "synchronous", "NORMAL");
132        schema::apply(&conn)?;
133        Ok(Self {
134            conn,
135            db_path: PathBuf::from(":memory:"),
136        })
137    }
138
139    /// Insert (or replace) a row.
140    pub fn insert(&self, row: &BrowserRow) -> Result<()> {
141        db::execute(
142            &self.conn,
143            "INSERT OR REPLACE INTO browsers
144                    (name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at)
145                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
146            params![
147                row.name,
148                kind_to_str(row.kind),
149                engine_to_str(row.engine),
150                row.pid as i64,
151                row.endpoint,
152                row.port as i64,
153                row.profile_dir.to_string_lossy(),
154                row.executable.to_string_lossy(),
155                row.headless as i64,
156                row.started_at,
157            ],
158            || format!("inserting registry row {}", row.name),
159        )
160    }
161
162    /// Delete a row by name. No error if it does not exist.
163    pub fn delete(&self, name: &str) -> Result<()> {
164        db::execute(
165            &self.conn,
166            "DELETE FROM browsers WHERE name = ?1",
167            params![name],
168            || format!("deleting registry row {name}"),
169        )
170    }
171
172    /// Look up a row by name.
173    pub fn get_by_name(&self, name: &str) -> Result<Option<BrowserRow>> {
174        db::query_optional(
175            &self.conn,
176            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at FROM browsers WHERE name = ?1",
177            params![name],
178            row_from_sqlite,
179        )
180    }
181
182    /// All rows, no liveness check, ordered by started_at DESC.
183    pub fn list_all(&self) -> Result<Vec<BrowserRow>> {
184        db::query_vec(
185            &self.conn,
186            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
187             FROM browsers ORDER BY started_at DESC",
188            [],
189            row_from_sqlite,
190        )
191    }
192
193    /// All rows of a given kind ordered by started_at DESC, without liveness check.
194    pub(crate) fn list_by_kind_all(&self, kind: Kind) -> Result<Vec<BrowserRow>> {
195        db::query_vec(
196            &self.conn,
197            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
198             FROM browsers WHERE kind = ?1 ORDER BY started_at DESC",
199            params![kind_to_str(kind)],
200            row_from_sqlite,
201        )
202    }
203
204    /// All alive rows. Dead-PID rows are deleted as a side-effect; rows whose
205    /// endpoint is temporarily unreachable are omitted but retained.
206    pub fn list_alive(&self) -> Result<Vec<BrowserRow>> {
207        let all = self.list_all()?;
208        let mut alive = Vec::with_capacity(all.len());
209        for row in all {
210            match liveness(&row) {
211                BrowserLiveness::Alive => alive.push(row),
212                BrowserLiveness::DeadPid => {
213                    self.delete(&row.name)?;
214                }
215                BrowserLiveness::EndpointUnreachable => {}
216            }
217        }
218        Ok(alive)
219    }
220
221    /// First alive row of the given kind (most recent first). Dead-PID matches
222    /// are pruned; endpoint-unreachable rows are retained.
223    pub fn first_alive_by_kind(&self, kind: Kind) -> Result<Option<BrowserRow>> {
224        for row in self.list_by_kind_all(kind)? {
225            match liveness(&row) {
226                BrowserLiveness::Alive => return Ok(Some(row)),
227                BrowserLiveness::DeadPid => {
228                    self.delete(&row.name)?;
229                }
230                BrowserLiveness::EndpointUnreachable => {}
231            }
232        }
233        Ok(None)
234    }
235
236    /// Most recently started alive row across all kinds.
237    pub fn most_recent_alive(&self) -> Result<Option<BrowserRow>> {
238        for row in self.list_all()? {
239            match liveness(&row) {
240                BrowserLiveness::Alive => return Ok(Some(row)),
241                BrowserLiveness::DeadPid => {
242                    self.delete(&row.name)?;
243                }
244                BrowserLiveness::EndpointUnreachable => {}
245            }
246        }
247        Ok(None)
248    }
249}
250
251fn configure_conn(conn: &rusqlite::Connection) -> Result<()> {
252    // WAL allows concurrent readers + one writer per process group.
253    conn.pragma_update(None, "journal_mode", "WAL")
254        .context("setting journal_mode = WAL")?;
255    conn.pragma_update(None, "synchronous", "NORMAL")
256        .context("setting synchronous = NORMAL")?;
257    // `busy_timeout` is what makes concurrent invocations safe now that
258    // we no longer hold the long-lived advisory file lock. SQLite will
259    // retry a contended write for up to this duration before returning
260    // SQLITE_BUSY. Five seconds is generous for our workloads (single
261    // INSERT/UPDATE per CLI invocation) and short enough that a stuck
262    // process surfaces visibly instead of hanging forever.
263    conn.busy_timeout(Duration::from_secs(5))
264        .context("setting busy_timeout")?;
265    Ok(())
266}
267
268fn lock_path_for(db: &Path) -> PathBuf {
269    let mut name = db
270        .file_name()
271        .map(|n| n.to_os_string())
272        .unwrap_or_else(|| std::ffi::OsString::from("registry.db"));
273    name.push(".lock");
274    match db.parent() {
275        Some(p) if !p.as_os_str().is_empty() => p.join(name),
276        _ => PathBuf::from(name),
277    }
278}
279
280fn row_from_sqlite(r: &rusqlite::Row<'_>) -> Result<BrowserRow> {
281    let name: String = r.get(0)?;
282    let kind_s: String = r.get(1)?;
283    let engine_s: String = r.get(2)?;
284    let pid: i64 = r.get(3)?;
285    let endpoint: String = r.get(4)?;
286    let port: i64 = r.get(5)?;
287    let profile_dir: String = r.get(6)?;
288    let executable: String = r.get(7)?;
289    let headless: i64 = r.get(8)?;
290    let started_at: String = r.get(9)?;
291
292    Ok(BrowserRow {
293        name,
294        kind: parse_kind(&kind_s)?,
295        engine: parse_engine(&engine_s)?,
296        pid: pid as u32,
297        endpoint,
298        port: port as u16,
299        profile_dir: PathBuf::from(profile_dir),
300        executable: PathBuf::from(executable),
301        headless: headless != 0,
302        started_at,
303    })
304}
305
306fn kind_to_str(k: Kind) -> &'static str {
307    k.as_str()
308}
309
310fn parse_kind(s: &str) -> Result<Kind> {
311    Kind::parse(s).ok_or_else(|| anyhow!("invalid kind {s}"))
312}
313
314fn engine_to_str(e: Engine) -> &'static str {
315    match e {
316        Engine::Cdp => "cdp",
317        Engine::Bidi => "bidi",
318    }
319}
320
321fn parse_engine(s: &str) -> Result<Engine> {
322    match s {
323        "cdp" => Ok(Engine::Cdp),
324        "bidi" => Ok(Engine::Bidi),
325        _ => bail!("invalid engine {s}"),
326    }
327}
328
329/// Liveness check: PID exists AND a TCP connect to the local port succeeds.
330pub fn is_alive(row: &BrowserRow) -> bool {
331    liveness(row) == BrowserLiveness::Alive
332}
333
334/// Classify row liveness. Only [`BrowserLiveness::DeadPid`] is safe to prune.
335pub fn liveness(row: &BrowserRow) -> BrowserLiveness {
336    let pid = sysinfo::Pid::from_u32(row.pid);
337    let mut sys = sysinfo::System::new();
338    // Refresh only the target PID rather than the whole process table:
339    // these run per-row in `list_alive`/`first_alive_by_kind`/etc., and we
340    // only ever query this one PID below.
341    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
342    if sys.process(pid).is_none() {
343        return BrowserLiveness::DeadPid;
344    }
345    let addr = SocketAddr::from(([127, 0, 0, 1], row.port));
346    if TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok() {
347        BrowserLiveness::Alive
348    } else {
349        BrowserLiveness::EndpointUnreachable
350    }
351}
352
353/// Cheap check: is process `pid` still running on this machine? Used by
354/// stale-row eviction in `scratches`, `tabs`, and `bidi_locks` to avoid
355/// keeping rows registered to a crashed CLI process.
356pub fn pid_alive(pid: u32) -> bool {
357    let pid = sysinfo::Pid::from_u32(pid);
358    let mut sys = sysinfo::System::new();
359    // Refresh only this PID, not the entire process table — this is hit
360    // per-row by stale-row eviction across scratches/tabs/bidi_locks.
361    sys.refresh_processes(sysinfo::ProcessesToUpdate::Some(&[pid]), true);
362    // A zombie (exited, not yet reaped by its parent) still has a /proc
363    // entry on Linux but is not a live process for our purposes.
364    sys.process(pid)
365        .is_some_and(|p| !matches!(p.status(), sysinfo::ProcessStatus::Zombie))
366}
367
368/// Current Unix epoch seconds.
369pub fn now_epoch_s() -> i64 {
370    std::time::SystemTime::now()
371        .duration_since(std::time::UNIX_EPOCH)
372        .map(|d| d.as_secs() as i64)
373        .unwrap_or(0)
374}
375
376// -- ISO-8601 helpers --------------------------------------------------------
377
378/// Current time formatted as `YYYY-MM-DDTHH:MM:SSZ` (UTC).
379pub fn now_iso8601() -> String {
380    let secs = std::time::SystemTime::now()
381        .duration_since(std::time::UNIX_EPOCH)
382        .map(|d| d.as_secs() as i64)
383        .unwrap_or(0);
384    format_unix_seconds_as_iso8601(secs)
385}
386
387/// Format a Unix epoch second count as `YYYY-MM-DDTHH:MM:SSZ`.
388///
389/// Uses Howard Hinnant's `civil_from_days` algorithm.
390pub fn format_unix_seconds_as_iso8601(secs: i64) -> String {
391    // Split into days and time-of-day, handling negative seconds correctly.
392    let days = secs.div_euclid(86_400);
393    let tod = secs.rem_euclid(86_400);
394    let hour = (tod / 3600) as u32;
395    let minute = ((tod % 3600) / 60) as u32;
396    let second = (tod % 60) as u32;
397
398    let (y, m, d) = civil_from_days(days);
399    format!(
400        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
401        y, m, d, hour, minute, second
402    )
403}
404
405/// Convert days since 1970-01-01 to (year, month, day) using Hinnant's algorithm.
406fn civil_from_days(z: i64) -> (i64, u32, u32) {
407    let z = z + 719_468;
408    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
409    let doe = (z - era * 146_097) as u64; // [0, 146096]
410    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
411    let y = yoe as i64 + era * 400;
412    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
413    let mp = (5 * doy + 2) / 153; // [0, 11]
414    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
415    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
416    let y = if m <= 2 { y + 1 } else { y };
417    (y, m, d)
418}
419
420// ---------------------------------------------------------------------------
421
422#[cfg(test)]
423mod tests {
424    use super::*;
425
426    fn sample_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
427        BrowserRow {
428            name: name.to_string(),
429            kind,
430            engine: kind.engine(),
431            pid: 99_999_999, // unlikely to exist
432            endpoint: format!("http://127.0.0.1:{port}"),
433            port,
434            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
435            executable: PathBuf::from("/usr/bin/example"),
436            headless: false,
437            started_at: started_at.to_string(),
438        }
439    }
440
441    #[test]
442    fn insert_then_get_round_trip() {
443        let reg = Registry::open_in_memory().unwrap();
444        let row = sample_row("alpha-bravo", Kind::Chrome, 9222, "2024-01-02T03:04:05Z");
445        reg.insert(&row).unwrap();
446        let got = reg.get_by_name("alpha-bravo").unwrap().unwrap();
447        assert_eq!(got, row);
448        assert!(reg.get_by_name("missing").unwrap().is_none());
449    }
450
451    #[test]
452    fn list_all_returns_all_rows() {
453        let reg = Registry::open_in_memory().unwrap();
454        reg.insert(&sample_row("a", Kind::Chrome, 9001, "2024-01-01T00:00:00Z"))
455            .unwrap();
456        reg.insert(&sample_row(
457            "b",
458            Kind::Firefox,
459            9002,
460            "2024-01-02T00:00:00Z",
461        ))
462        .unwrap();
463        reg.insert(&sample_row("c", Kind::Edge, 9003, "2024-01-03T00:00:00Z"))
464            .unwrap();
465        let all = reg.list_all().unwrap();
466        assert_eq!(all.len(), 3);
467        // ordered DESC by started_at
468        assert_eq!(all[0].name, "c");
469        assert_eq!(all[2].name, "a");
470    }
471
472    #[test]
473    fn delete_removes_row() {
474        let reg = Registry::open_in_memory().unwrap();
475        let row = sample_row("x", Kind::Brave, 9010, "2024-05-05T05:05:05Z");
476        reg.insert(&row).unwrap();
477        reg.delete("x").unwrap();
478        assert!(reg.get_by_name("x").unwrap().is_none());
479        // deleting a missing row is a no-op
480        reg.delete("ghost").unwrap();
481    }
482
483    #[test]
484    fn first_alive_by_kind_returns_most_recent() {
485        // We can't easily mock `is_alive`. Instead verify the underlying SQL ordering
486        // via the pub(crate) helper.
487        let reg = Registry::open_in_memory().unwrap();
488        reg.insert(&sample_row(
489            "older",
490            Kind::Chrome,
491            9101,
492            "2024-01-01T00:00:00Z",
493        ))
494        .unwrap();
495        reg.insert(&sample_row(
496            "newer",
497            Kind::Chrome,
498            9102,
499            "2024-06-01T00:00:00Z",
500        ))
501        .unwrap();
502        reg.insert(&sample_row(
503            "ff",
504            Kind::Firefox,
505            9103,
506            "2024-07-01T00:00:00Z",
507        ))
508        .unwrap();
509        let chromes = reg.list_by_kind_all(Kind::Chrome).unwrap();
510        assert_eq!(chromes.len(), 2);
511        assert_eq!(chromes[0].name, "newer");
512        assert_eq!(chromes[1].name, "older");
513
514        // first_alive_by_kind on these synthetic rows should find none alive
515        // and prune dead-process entries.
516        assert!(reg.first_alive_by_kind(Kind::Chrome).unwrap().is_none());
517        assert!(reg.list_by_kind_all(Kind::Chrome).unwrap().is_empty());
518    }
519
520    #[test]
521    fn list_alive_prunes_stale() {
522        let reg = Registry::open_in_memory().unwrap();
523        reg.insert(&sample_row("a", Kind::Chrome, 9201, "2024-01-01T00:00:00Z"))
524            .unwrap();
525        reg.insert(&sample_row("b", Kind::Chrome, 9202, "2024-01-02T00:00:00Z"))
526            .unwrap();
527        let alive = reg.list_alive().unwrap();
528        assert!(alive.is_empty());
529        assert!(reg.list_all().unwrap().is_empty());
530    }
531
532    #[test]
533    fn list_alive_retains_live_pid_with_unreachable_endpoint() {
534        let reg = Registry::open_in_memory().unwrap();
535        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
536        let port = listener.local_addr().unwrap().port();
537        drop(listener);
538        let mut row = sample_row(
539            "temporarily-unreachable",
540            Kind::Chrome,
541            port,
542            "2024-01-01T00:00:00Z",
543        );
544        row.pid = std::process::id();
545        reg.insert(&row).unwrap();
546
547        let alive = reg.list_alive().unwrap();
548        assert!(alive.is_empty());
549        assert!(reg
550            .get_by_name("temporarily-unreachable")
551            .unwrap()
552            .is_some());
553    }
554
555    #[test]
556    fn most_recent_alive_with_no_live_rows_is_none() {
557        let reg = Registry::open_in_memory().unwrap();
558        reg.insert(&sample_row("a", Kind::Chrome, 9301, "2024-01-01T00:00:00Z"))
559            .unwrap();
560        assert!(reg.most_recent_alive().unwrap().is_none());
561    }
562
563    #[test]
564    fn now_iso8601_format() {
565        let s = now_iso8601();
566        assert_eq!(s.len(), 20, "got {s}");
567        assert!(s.ends_with('Z'));
568        assert_eq!(&s[4..5], "-");
569        assert_eq!(&s[7..8], "-");
570        assert_eq!(&s[10..11], "T");
571        assert_eq!(&s[13..14], ":");
572        assert_eq!(&s[16..17], ":");
573
574        // Epoch sanity.
575        assert_eq!(format_unix_seconds_as_iso8601(0), "1970-01-01T00:00:00Z");
576    }
577
578    #[test]
579    fn iso8601_known_dates() {
580        let cases = [
581            (0_i64, "1970-01-01T00:00:00Z"),
582            (951_782_400, "2000-02-29T00:00:00Z"), // leap day
583            (1_700_000_000, "2023-11-14T22:13:20Z"),
584            (1_583_020_799, "2020-02-29T23:59:59Z"), // last second of leap day
585            (1_583_020_800, "2020-03-01T00:00:00Z"),
586            (1_577_836_799, "2019-12-31T23:59:59Z"), // end of year
587        ];
588        for (secs, want) in cases {
589            assert_eq!(format_unix_seconds_as_iso8601(secs), want, "epoch {secs}");
590        }
591    }
592
593    #[test]
594    fn concurrent_file_lock_serializes() {
595        use std::thread;
596
597        let tmp = tempfile::TempDir::new().unwrap();
598        let db_path = tmp.path().join("registry.db");
599
600        let p1 = db_path.clone();
601        let p2 = db_path.clone();
602        let t1 = thread::spawn(move || {
603            let reg = Registry::open_at(&p1).unwrap();
604            reg.insert(&BrowserRow {
605                name: "one".to_string(),
606                kind: Kind::Chrome,
607                engine: Engine::Cdp,
608                pid: 1,
609                endpoint: "http://127.0.0.1:9001".to_string(),
610                port: 9001,
611                profile_dir: PathBuf::from("/tmp/p1"),
612                executable: PathBuf::from("/usr/bin/chrome"),
613                headless: false,
614                started_at: "2024-01-01T00:00:00Z".to_string(),
615            })
616            .unwrap();
617        });
618        let t2 = thread::spawn(move || {
619            let reg = Registry::open_at(&p2).unwrap();
620            reg.insert(&BrowserRow {
621                name: "two".to_string(),
622                kind: Kind::Firefox,
623                engine: Engine::Bidi,
624                pid: 2,
625                endpoint: "ws://127.0.0.1:9002".to_string(),
626                port: 9002,
627                profile_dir: PathBuf::from("/tmp/p2"),
628                executable: PathBuf::from("/usr/bin/firefox"),
629                headless: false,
630                started_at: "2024-01-02T00:00:00Z".to_string(),
631            })
632            .unwrap();
633        });
634        t1.join().unwrap();
635        t2.join().unwrap();
636
637        let reg = Registry::open_at(&db_path).unwrap();
638        let all = reg.list_all().unwrap();
639        assert_eq!(all.len(), 2);
640        let names: Vec<&str> = all.iter().map(|r| r.name.as_str()).collect();
641        assert!(names.contains(&"one"));
642        assert!(names.contains(&"two"));
643    }
644
645    #[test]
646    fn open_at_creates_parent_dir_and_db() {
647        let tmp = tempfile::TempDir::new().unwrap();
648        let nested = tmp.path().join("a/b/c/registry.db");
649        let reg = Registry::open_at(&nested).unwrap();
650        reg.insert(&sample_row("x", Kind::Chrome, 9999, "2024-01-01T00:00:00Z"))
651            .unwrap();
652        assert!(nested.exists());
653        let lock = nested.parent().unwrap().join("registry.db.lock");
654        assert!(lock.exists());
655    }
656}