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