Skip to main content

browser_control/registry/
mod.rs

1//! SQLite-backed registry of running browser instances.
2
3pub mod naming;
4pub mod schema;
5pub mod words;
6
7use anyhow::{anyhow, bail, Context, Result};
8use fs2::FileExt;
9use rusqlite::{params, OpenFlags};
10use serde::{Deserialize, Serialize};
11use std::fs::{File, OpenOptions};
12use std::net::{SocketAddr, TcpStream};
13use std::path::{Path, PathBuf};
14use std::time::Duration;
15
16use crate::detect::{Engine, Kind};
17
18/// One row in the `browsers` table.
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct BrowserRow {
21    pub name: String,
22    pub kind: Kind,
23    pub engine: Engine,
24    pub pid: u32,
25    pub endpoint: String,
26    pub port: u16,
27    pub profile_dir: PathBuf,
28    pub executable: PathBuf,
29    pub headless: bool,
30    pub started_at: String,
31}
32
33/// SQLite registry handle. Holds an advisory exclusive file lock for its lifetime
34/// (except for in-memory registries).
35pub struct Registry {
36    conn: rusqlite::Connection,
37    #[allow(dead_code)]
38    db_path: PathBuf,
39    // Held to keep the advisory lock alive; dropped releases the lock.
40    #[allow(dead_code)]
41    lock: Option<File>,
42}
43
44impl Registry {
45    /// Open the registry at the OS-standard location.
46    pub fn open() -> Result<Self> {
47        let p = crate::paths::registry_db_path()?;
48        Self::open_at(&p)
49    }
50
51    /// Open the registry at an explicit path.
52    pub fn open_at(path: &Path) -> Result<Self> {
53        if let Some(parent) = path.parent() {
54            if !parent.as_os_str().is_empty() {
55                std::fs::create_dir_all(parent)
56                    .with_context(|| format!("creating registry dir {}", parent.display()))?;
57            }
58        }
59
60        let lock_path = lock_path_for(path);
61        let lock_file = OpenOptions::new()
62            .create(true)
63            .read(true)
64            .write(true)
65            .truncate(false)
66            .open(&lock_path)
67            .with_context(|| format!("opening lock file {}", lock_path.display()))?;
68        FileExt::lock_exclusive(&lock_file)
69            .with_context(|| format!("acquiring exclusive lock on {}", lock_path.display()))?;
70
71        let conn = rusqlite::Connection::open_with_flags(
72            path,
73            OpenFlags::SQLITE_OPEN_READ_WRITE | OpenFlags::SQLITE_OPEN_CREATE,
74        )
75        .with_context(|| format!("opening registry db {}", path.display()))?;
76
77        configure_conn(&conn)?;
78        schema::apply(&conn)?;
79
80        Ok(Self {
81            conn,
82            db_path: path.to_path_buf(),
83            lock: Some(lock_file),
84        })
85    }
86
87    /// Open an in-memory registry (tests only). No file lock taken.
88    pub fn open_in_memory() -> Result<Self> {
89        let conn = rusqlite::Connection::open_in_memory().context("opening in-memory registry")?;
90        // WAL is not supported for :memory:; only set synchronous.
91        let _ = conn.pragma_update(None, "synchronous", "NORMAL");
92        schema::apply(&conn)?;
93        Ok(Self {
94            conn,
95            db_path: PathBuf::from(":memory:"),
96            lock: None,
97        })
98    }
99
100    /// Insert (or replace) a row.
101    pub fn insert(&self, row: &BrowserRow) -> Result<()> {
102        self.conn
103            .execute(
104                "INSERT OR REPLACE INTO browsers
105                    (name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at)
106                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
107                params![
108                    row.name,
109                    kind_to_str(row.kind),
110                    engine_to_str(row.engine),
111                    row.pid as i64,
112                    row.endpoint,
113                    row.port as i64,
114                    row.profile_dir.to_string_lossy(),
115                    row.executable.to_string_lossy(),
116                    row.headless as i64,
117                    row.started_at,
118                ],
119            )
120            .with_context(|| format!("inserting registry row {}", row.name))?;
121        Ok(())
122    }
123
124    /// Delete a row by name. No error if it does not exist.
125    pub fn delete(&self, name: &str) -> Result<()> {
126        self.conn
127            .execute("DELETE FROM browsers WHERE name = ?1", params![name])
128            .with_context(|| format!("deleting registry row {name}"))?;
129        Ok(())
130    }
131
132    /// Look up a row by name.
133    pub fn get_by_name(&self, name: &str) -> Result<Option<BrowserRow>> {
134        let mut stmt = self
135            .conn
136            .prepare("SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at FROM browsers WHERE name = ?1")?;
137        let mut rows = stmt.query(params![name])?;
138        if let Some(r) = rows.next()? {
139            Ok(Some(row_from_sqlite(r)?))
140        } else {
141            Ok(None)
142        }
143    }
144
145    /// All rows, no liveness check, ordered by started_at DESC.
146    pub fn list_all(&self) -> Result<Vec<BrowserRow>> {
147        let mut stmt = self.conn.prepare(
148            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
149             FROM browsers ORDER BY started_at DESC",
150        )?;
151        let mut rows = stmt.query([])?;
152        let mut out = Vec::new();
153        while let Some(r) = rows.next()? {
154            out.push(row_from_sqlite(r)?);
155        }
156        Ok(out)
157    }
158
159    /// All rows of a given kind ordered by started_at DESC, without liveness check.
160    pub(crate) fn list_by_kind_all(&self, kind: Kind) -> Result<Vec<BrowserRow>> {
161        let mut stmt = self.conn.prepare(
162            "SELECT name, kind, engine, pid, endpoint, port, profile_dir, executable, headless, started_at
163             FROM browsers WHERE kind = ?1 ORDER BY started_at DESC",
164        )?;
165        let mut rows = stmt.query(params![kind_to_str(kind)])?;
166        let mut out = Vec::new();
167        while let Some(r) = rows.next()? {
168            out.push(row_from_sqlite(r)?);
169        }
170        Ok(out)
171    }
172
173    /// All alive rows. Stale rows are deleted as a side-effect.
174    pub fn list_alive(&self) -> Result<Vec<BrowserRow>> {
175        let all = self.list_all()?;
176        let mut alive = Vec::with_capacity(all.len());
177        for row in all {
178            if is_alive(&row) {
179                alive.push(row);
180            } else {
181                self.delete(&row.name)?;
182            }
183        }
184        Ok(alive)
185    }
186
187    /// First alive row of the given kind (most recent first). Stale matches are pruned.
188    pub fn first_alive_by_kind(&self, kind: Kind) -> Result<Option<BrowserRow>> {
189        for row in self.list_by_kind_all(kind)? {
190            if is_alive(&row) {
191                return Ok(Some(row));
192            } else {
193                self.delete(&row.name)?;
194            }
195        }
196        Ok(None)
197    }
198
199    /// Most recently started alive row across all kinds.
200    pub fn most_recent_alive(&self) -> Result<Option<BrowserRow>> {
201        for row in self.list_all()? {
202            if is_alive(&row) {
203                return Ok(Some(row));
204            } else {
205                self.delete(&row.name)?;
206            }
207        }
208        Ok(None)
209    }
210}
211
212fn configure_conn(conn: &rusqlite::Connection) -> Result<()> {
213    // WAL improves concurrent reader/writer behavior; ignore the row callback.
214    conn.pragma_update(None, "journal_mode", "WAL")
215        .context("setting journal_mode = WAL")?;
216    conn.pragma_update(None, "synchronous", "NORMAL")
217        .context("setting synchronous = NORMAL")?;
218    Ok(())
219}
220
221fn lock_path_for(db: &Path) -> PathBuf {
222    let mut name = db
223        .file_name()
224        .map(|n| n.to_os_string())
225        .unwrap_or_else(|| std::ffi::OsString::from("registry.db"));
226    name.push(".lock");
227    match db.parent() {
228        Some(p) if !p.as_os_str().is_empty() => p.join(name),
229        _ => PathBuf::from(name),
230    }
231}
232
233fn row_from_sqlite(r: &rusqlite::Row<'_>) -> Result<BrowserRow> {
234    let name: String = r.get(0)?;
235    let kind_s: String = r.get(1)?;
236    let engine_s: String = r.get(2)?;
237    let pid: i64 = r.get(3)?;
238    let endpoint: String = r.get(4)?;
239    let port: i64 = r.get(5)?;
240    let profile_dir: String = r.get(6)?;
241    let executable: String = r.get(7)?;
242    let headless: i64 = r.get(8)?;
243    let started_at: String = r.get(9)?;
244
245    Ok(BrowserRow {
246        name,
247        kind: parse_kind(&kind_s)?,
248        engine: parse_engine(&engine_s)?,
249        pid: pid as u32,
250        endpoint,
251        port: port as u16,
252        profile_dir: PathBuf::from(profile_dir),
253        executable: PathBuf::from(executable),
254        headless: headless != 0,
255        started_at,
256    })
257}
258
259fn kind_to_str(k: Kind) -> &'static str {
260    k.as_str()
261}
262
263fn parse_kind(s: &str) -> Result<Kind> {
264    Kind::parse(s).ok_or_else(|| anyhow!("invalid kind {s}"))
265}
266
267fn engine_to_str(e: Engine) -> &'static str {
268    match e {
269        Engine::Cdp => "cdp",
270        Engine::Bidi => "bidi",
271    }
272}
273
274fn parse_engine(s: &str) -> Result<Engine> {
275    match s {
276        "cdp" => Ok(Engine::Cdp),
277        "bidi" => Ok(Engine::Bidi),
278        _ => bail!("invalid engine {s}"),
279    }
280}
281
282/// Liveness check: PID exists AND a TCP connect to the local port succeeds.
283pub fn is_alive(row: &BrowserRow) -> bool {
284    let mut sys = sysinfo::System::new();
285    sys.refresh_processes(sysinfo::ProcessesToUpdate::All, true);
286    if sys.process(sysinfo::Pid::from_u32(row.pid)).is_none() {
287        return false;
288    }
289    let addr = SocketAddr::from(([127, 0, 0, 1], row.port));
290    TcpStream::connect_timeout(&addr, Duration::from_millis(200)).is_ok()
291}
292
293// -- ISO-8601 helpers --------------------------------------------------------
294
295/// Current time formatted as `YYYY-MM-DDTHH:MM:SSZ` (UTC).
296pub fn now_iso8601() -> String {
297    let secs = std::time::SystemTime::now()
298        .duration_since(std::time::UNIX_EPOCH)
299        .map(|d| d.as_secs() as i64)
300        .unwrap_or(0);
301    format_unix_seconds_as_iso8601(secs)
302}
303
304/// Format a Unix epoch second count as `YYYY-MM-DDTHH:MM:SSZ`.
305///
306/// Uses Howard Hinnant's `civil_from_days` algorithm.
307pub fn format_unix_seconds_as_iso8601(secs: i64) -> String {
308    // Split into days and time-of-day, handling negative seconds correctly.
309    let days = secs.div_euclid(86_400);
310    let tod = secs.rem_euclid(86_400);
311    let hour = (tod / 3600) as u32;
312    let minute = ((tod % 3600) / 60) as u32;
313    let second = (tod % 60) as u32;
314
315    let (y, m, d) = civil_from_days(days);
316    format!(
317        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
318        y, m, d, hour, minute, second
319    )
320}
321
322/// Convert days since 1970-01-01 to (year, month, day) using Hinnant's algorithm.
323fn civil_from_days(z: i64) -> (i64, u32, u32) {
324    let z = z + 719_468;
325    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
326    let doe = (z - era * 146_097) as u64; // [0, 146096]
327    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399]
328    let y = yoe as i64 + era * 400;
329    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
330    let mp = (5 * doy + 2) / 153; // [0, 11]
331    let d = (doy - (153 * mp + 2) / 5 + 1) as u32; // [1, 31]
332    let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; // [1, 12]
333    let y = if m <= 2 { y + 1 } else { y };
334    (y, m, d)
335}
336
337// ---------------------------------------------------------------------------
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342
343    fn sample_row(name: &str, kind: Kind, port: u16, started_at: &str) -> BrowserRow {
344        BrowserRow {
345            name: name.to_string(),
346            kind,
347            engine: kind.engine(),
348            pid: 99_999_999, // unlikely to exist
349            endpoint: format!("http://127.0.0.1:{port}"),
350            port,
351            profile_dir: PathBuf::from(format!("/tmp/profiles/{name}")),
352            executable: PathBuf::from("/usr/bin/example"),
353            headless: false,
354            started_at: started_at.to_string(),
355        }
356    }
357
358    #[test]
359    fn insert_then_get_round_trip() {
360        let reg = Registry::open_in_memory().unwrap();
361        let row = sample_row("alpha-bravo", Kind::Chrome, 9222, "2024-01-02T03:04:05Z");
362        reg.insert(&row).unwrap();
363        let got = reg.get_by_name("alpha-bravo").unwrap().unwrap();
364        assert_eq!(got, row);
365        assert!(reg.get_by_name("missing").unwrap().is_none());
366    }
367
368    #[test]
369    fn list_all_returns_all_rows() {
370        let reg = Registry::open_in_memory().unwrap();
371        reg.insert(&sample_row("a", Kind::Chrome, 9001, "2024-01-01T00:00:00Z"))
372            .unwrap();
373        reg.insert(&sample_row(
374            "b",
375            Kind::Firefox,
376            9002,
377            "2024-01-02T00:00:00Z",
378        ))
379        .unwrap();
380        reg.insert(&sample_row("c", Kind::Edge, 9003, "2024-01-03T00:00:00Z"))
381            .unwrap();
382        let all = reg.list_all().unwrap();
383        assert_eq!(all.len(), 3);
384        // ordered DESC by started_at
385        assert_eq!(all[0].name, "c");
386        assert_eq!(all[2].name, "a");
387    }
388
389    #[test]
390    fn delete_removes_row() {
391        let reg = Registry::open_in_memory().unwrap();
392        let row = sample_row("x", Kind::Brave, 9010, "2024-05-05T05:05:05Z");
393        reg.insert(&row).unwrap();
394        reg.delete("x").unwrap();
395        assert!(reg.get_by_name("x").unwrap().is_none());
396        // deleting a missing row is a no-op
397        reg.delete("ghost").unwrap();
398    }
399
400    #[test]
401    fn first_alive_by_kind_returns_most_recent() {
402        // We can't easily mock `is_alive`. Instead verify the underlying SQL ordering
403        // via the pub(crate) helper.
404        let reg = Registry::open_in_memory().unwrap();
405        reg.insert(&sample_row(
406            "older",
407            Kind::Chrome,
408            9101,
409            "2024-01-01T00:00:00Z",
410        ))
411        .unwrap();
412        reg.insert(&sample_row(
413            "newer",
414            Kind::Chrome,
415            9102,
416            "2024-06-01T00:00:00Z",
417        ))
418        .unwrap();
419        reg.insert(&sample_row(
420            "ff",
421            Kind::Firefox,
422            9103,
423            "2024-07-01T00:00:00Z",
424        ))
425        .unwrap();
426        let chromes = reg.list_by_kind_all(Kind::Chrome).unwrap();
427        assert_eq!(chromes.len(), 2);
428        assert_eq!(chromes[0].name, "newer");
429        assert_eq!(chromes[1].name, "older");
430
431        // first_alive_by_kind on these synthetic rows should find none alive
432        // and prune stale entries.
433        assert!(reg.first_alive_by_kind(Kind::Chrome).unwrap().is_none());
434        assert!(reg.list_by_kind_all(Kind::Chrome).unwrap().is_empty());
435    }
436
437    #[test]
438    fn list_alive_prunes_stale() {
439        let reg = Registry::open_in_memory().unwrap();
440        reg.insert(&sample_row("a", Kind::Chrome, 9201, "2024-01-01T00:00:00Z"))
441            .unwrap();
442        reg.insert(&sample_row("b", Kind::Chrome, 9202, "2024-01-02T00:00:00Z"))
443            .unwrap();
444        let alive = reg.list_alive().unwrap();
445        assert!(alive.is_empty());
446        assert!(reg.list_all().unwrap().is_empty());
447    }
448
449    #[test]
450    fn most_recent_alive_with_no_live_rows_is_none() {
451        let reg = Registry::open_in_memory().unwrap();
452        reg.insert(&sample_row("a", Kind::Chrome, 9301, "2024-01-01T00:00:00Z"))
453            .unwrap();
454        assert!(reg.most_recent_alive().unwrap().is_none());
455    }
456
457    #[test]
458    fn now_iso8601_format() {
459        let s = now_iso8601();
460        assert_eq!(s.len(), 20, "got {s}");
461        assert!(s.ends_with('Z'));
462        assert_eq!(&s[4..5], "-");
463        assert_eq!(&s[7..8], "-");
464        assert_eq!(&s[10..11], "T");
465        assert_eq!(&s[13..14], ":");
466        assert_eq!(&s[16..17], ":");
467
468        // Epoch sanity.
469        assert_eq!(format_unix_seconds_as_iso8601(0), "1970-01-01T00:00:00Z");
470    }
471
472    #[test]
473    fn iso8601_known_dates() {
474        let cases = [
475            (0_i64, "1970-01-01T00:00:00Z"),
476            (951_782_400, "2000-02-29T00:00:00Z"), // leap day
477            (1_700_000_000, "2023-11-14T22:13:20Z"),
478            (1_583_020_799, "2020-02-29T23:59:59Z"), // last second of leap day
479            (1_583_020_800, "2020-03-01T00:00:00Z"),
480            (1_577_836_799, "2019-12-31T23:59:59Z"), // end of year
481        ];
482        for (secs, want) in cases {
483            assert_eq!(format_unix_seconds_as_iso8601(secs), want, "epoch {secs}");
484        }
485    }
486
487    #[test]
488    fn concurrent_file_lock_serializes() {
489        use std::thread;
490
491        let tmp = tempfile::TempDir::new().unwrap();
492        let db_path = tmp.path().join("registry.db");
493
494        let p1 = db_path.clone();
495        let p2 = db_path.clone();
496        let t1 = thread::spawn(move || {
497            let reg = Registry::open_at(&p1).unwrap();
498            reg.insert(&BrowserRow {
499                name: "one".to_string(),
500                kind: Kind::Chrome,
501                engine: Engine::Cdp,
502                pid: 1,
503                endpoint: "http://127.0.0.1:9001".to_string(),
504                port: 9001,
505                profile_dir: PathBuf::from("/tmp/p1"),
506                executable: PathBuf::from("/usr/bin/chrome"),
507                headless: false,
508                started_at: "2024-01-01T00:00:00Z".to_string(),
509            })
510            .unwrap();
511        });
512        let t2 = thread::spawn(move || {
513            let reg = Registry::open_at(&p2).unwrap();
514            reg.insert(&BrowserRow {
515                name: "two".to_string(),
516                kind: Kind::Firefox,
517                engine: Engine::Bidi,
518                pid: 2,
519                endpoint: "ws://127.0.0.1:9002".to_string(),
520                port: 9002,
521                profile_dir: PathBuf::from("/tmp/p2"),
522                executable: PathBuf::from("/usr/bin/firefox"),
523                headless: false,
524                started_at: "2024-01-02T00:00:00Z".to_string(),
525            })
526            .unwrap();
527        });
528        t1.join().unwrap();
529        t2.join().unwrap();
530
531        let reg = Registry::open_at(&db_path).unwrap();
532        let all = reg.list_all().unwrap();
533        assert_eq!(all.len(), 2);
534        let names: Vec<&str> = all.iter().map(|r| r.name.as_str()).collect();
535        assert!(names.contains(&"one"));
536        assert!(names.contains(&"two"));
537    }
538
539    #[test]
540    fn open_at_creates_parent_dir_and_db() {
541        let tmp = tempfile::TempDir::new().unwrap();
542        let nested = tmp.path().join("a/b/c/registry.db");
543        let reg = Registry::open_at(&nested).unwrap();
544        reg.insert(&sample_row("x", Kind::Chrome, 9999, "2024-01-01T00:00:00Z"))
545            .unwrap();
546        assert!(nested.exists());
547        let lock = nested.parent().unwrap().join("registry.db.lock");
548        assert!(lock.exists());
549    }
550}