Skip to main content

browser_control/registry/
scratches.rs

1//! `scratches` SQLite table: one row per browser, holding the daemon-style
2//! scratch tab's `target_id`. Used by lock-free ops (`eval`, `fetch` with no
3//! explicit tab) so the default code path never touches a user-visible tab —
4//! the architectural answer to the iLO failure mode (an admin tab whose
5//! renderer ignores `Runtime.evaluate` could otherwise be silently picked
6//! by the default selector).
7//!
8//! Lifecycle is **lazy + hybrid**:
9//! - First call: no row → create an `about:blank` via `Target.createTarget`,
10//!   insert the row, return the `target_id`.
11//! - Subsequent calls: row exists → try the op against the stored
12//!   `target_id`. If it errors with `tabHung`/`tabCrashed`/protocol-error
13//!   ("no target with given id"), close the dead target, recreate, update
14//!   the row, retry once, then escalate.
15//!
16//! Concurrent CLI processes share the row: CDP allows multiple sessions
17//! against the same target. JS execution in one renderer is single-threaded
18//! anyway, so concurrent evals serialize naturally — no locking needed.
19
20use anyhow::Result;
21
22use crate::registry::{db, now_epoch_s, Registry};
23
24/// In-memory view of a `scratches` row.
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct ScratchRow {
27    pub browser_name: String,
28    pub target_id: String,
29    pub last_used_at_epoch_s: i64,
30}
31
32impl Registry {
33    /// Read the scratch row for `browser_name`. Returns `None` if no row.
34    pub fn scratch_get(&self, browser_name: &str) -> Result<Option<ScratchRow>> {
35        db::query_optional(
36            &self.conn,
37            "SELECT browser_name, target_id, last_used_at_epoch_s \
38             FROM scratches WHERE browser_name = ?1",
39            [browser_name],
40            |r| {
41                Ok(ScratchRow {
42                    browser_name: r.get(0)?,
43                    target_id: r.get(1)?,
44                    last_used_at_epoch_s: r.get(2)?,
45                })
46            },
47        )
48    }
49
50    /// Insert or replace the scratch row for `browser_name`. Used both when
51    /// creating the first scratch and when recovering a wedged one.
52    pub fn scratch_upsert(&self, browser_name: &str, target_id: &str) -> Result<()> {
53        let now = now_epoch_s();
54        db::execute(
55            &self.conn,
56            "INSERT INTO scratches (browser_name, target_id, last_used_at_epoch_s) \
57                 VALUES (?1, ?2, ?3) \
58                 ON CONFLICT(browser_name) DO UPDATE SET \
59                    target_id = excluded.target_id, \
60                    last_used_at_epoch_s = excluded.last_used_at_epoch_s",
61            rusqlite::params![browser_name, target_id, now],
62            || format!("upsert scratch for {browser_name}"),
63        )
64    }
65
66    /// Bump `last_used_at` for the scratch row (no-op if absent). Called
67    /// after a successful op so the row reflects actual recency for
68    /// diagnostics.
69    pub fn scratch_touch(&self, browser_name: &str) -> Result<()> {
70        let now = now_epoch_s();
71        db::execute_bare(
72            &self.conn,
73            "UPDATE scratches SET last_used_at_epoch_s = ?1 WHERE browser_name = ?2",
74            rusqlite::params![now, browser_name],
75        )
76    }
77
78    /// Remove the scratch row (used by recovery to mark the prior target
79    /// dead before re-creating). Idempotent.
80    pub fn scratch_delete(&self, browser_name: &str) -> Result<()> {
81        db::execute_bare(
82            &self.conn,
83            "DELETE FROM scratches WHERE browser_name = ?1",
84            [browser_name],
85        )
86    }
87}
88
89#[cfg(test)]
90mod tests {
91    use super::*;
92
93    #[test]
94    fn upsert_then_get_round_trip() {
95        let reg = Registry::open_in_memory().unwrap();
96        assert!(reg.scratch_get("brave-twilight").unwrap().is_none());
97        reg.scratch_upsert("brave-twilight", "T1").unwrap();
98        let got = reg.scratch_get("brave-twilight").unwrap().unwrap();
99        assert_eq!(got.browser_name, "brave-twilight");
100        assert_eq!(got.target_id, "T1");
101        assert!(got.last_used_at_epoch_s > 0);
102    }
103
104    #[test]
105    fn upsert_replaces_existing_target_id() {
106        let reg = Registry::open_in_memory().unwrap();
107        reg.scratch_upsert("brave-twilight", "T1").unwrap();
108        reg.scratch_upsert("brave-twilight", "T2").unwrap();
109        let got = reg.scratch_get("brave-twilight").unwrap().unwrap();
110        assert_eq!(got.target_id, "T2");
111    }
112
113    #[test]
114    fn touch_bumps_last_used() {
115        let reg = Registry::open_in_memory().unwrap();
116        reg.scratch_upsert("a", "T1").unwrap();
117        let first = reg.scratch_get("a").unwrap().unwrap().last_used_at_epoch_s;
118        // Force a >=1s delta isn't reliable in a fast test; just assert
119        // touch doesn't error and the row is still present.
120        reg.scratch_touch("a").unwrap();
121        let later = reg.scratch_get("a").unwrap().unwrap().last_used_at_epoch_s;
122        assert!(later >= first);
123    }
124
125    #[test]
126    fn delete_removes_the_row() {
127        let reg = Registry::open_in_memory().unwrap();
128        reg.scratch_upsert("a", "T1").unwrap();
129        reg.scratch_delete("a").unwrap();
130        assert!(reg.scratch_get("a").unwrap().is_none());
131    }
132
133    #[test]
134    fn touch_on_missing_row_is_noop() {
135        let reg = Registry::open_in_memory().unwrap();
136        // No assertion needed — must not panic / error.
137        reg.scratch_touch("nonexistent").unwrap();
138    }
139}