Skip to main content

browser_control/registry/
bidi_lock.rs

1//! `bidi_locks` SQLite table: arbitrates the Firefox single-BiDi-session
2//! limit across concurrent CLI processes.
3//!
4//! Firefox allows one BiDi session per browser at a time. Two `browser-control`
5//! invocations targeting the same Firefox would otherwise race on
6//! `session.new` and one would lose with "Maximum number of active
7//! sessions". The current mitigation (`session.end` on close +
8//! retry-on-collision) handles the common case but leaves a tight race
9//! window. This lock closes it: a CLI acquires `bidi_locks(browser_name)`
10//! before opening a BiDi session and releases on `Drop`.
11//!
12//! Crashed-CLI safety: each row carries the holder's PID. On contention,
13//! we check `pid_alive(holder_pid)`; if dead, the row is evicted and the
14//! contender takes the lock. This avoids the need for any background
15//! cleanup task.
16//!
17//! Granularity: per `browser_name`, not engine. Chromium callers don't
18//! touch this table; only `PageSession::attach` on the BiDi engine path
19//! acquires.
20
21use std::time::{Duration, Instant};
22
23use anyhow::{anyhow, Context, Result};
24use thiserror::Error;
25
26use crate::registry::{db, now_epoch_s, pid_alive, Registry};
27
28/// Default poll interval while waiting on a contended lock. Short enough
29/// to feel responsive on release, long enough to avoid burning CPU.
30const POLL_INTERVAL: Duration = Duration::from_millis(100);
31
32/// CAS-style release statement shared by the `Drop` path and the
33/// stale-row eviction path: delete the row only if it is still *ours*
34/// (`browser_name` + `holder_pid`), so a double-release or a release after
35/// another holder reacquired is harmless.
36const RELEASE_SQL: &str = "DELETE FROM bidi_locks WHERE browser_name = ?1 AND holder_pid = ?2";
37
38/// Issue the CAS release `DELETE` against `conn`. Errors are intentionally
39/// swallowed by callers (release is best-effort); centralising the SQL +
40/// params keeps the `Drop` path and eviction path from drifting apart.
41fn release(
42    conn: &rusqlite::Connection,
43    browser_name: &str,
44    holder_pid: u32,
45) -> rusqlite::Result<usize> {
46    conn.execute(RELEASE_SQL, rusqlite::params![browser_name, holder_pid])
47}
48
49/// Returned by [`Registry::bidi_lock_acquire`] when `timeout` elapses
50/// before the lock can be taken. Carries the holder PID for diagnostics
51/// so an agent can surface "another `browser-control` (PID N) is using
52/// Firefox BiDi" instead of a generic "timeout".
53#[derive(Debug, Error)]
54#[error("Firefox BiDi for {browser_name} is held by PID {holder_pid} (waited {waited_ms}ms)")]
55pub struct BidiLockBusy {
56    pub browser_name: String,
57    pub holder_pid: u32,
58    pub waited_ms: u64,
59}
60
61/// RAII guard for an acquired BiDi lock. Drop releases the row.
62///
63/// The release uses CAS-style `DELETE WHERE browser_name=? AND
64/// holder_pid=?` so a double-release or a release after the row has been
65/// evicted by a stale-PID sweep is harmless (won't delete someone else's
66/// row).
67#[derive(Debug)]
68pub struct BidiLockGuard {
69    browser_name: String,
70    holder_pid: u32,
71    db_path: std::path::PathBuf,
72}
73
74impl BidiLockGuard {
75    pub fn browser_name(&self) -> &str {
76        &self.browser_name
77    }
78    pub fn holder_pid(&self) -> u32 {
79        self.holder_pid
80    }
81}
82
83impl Drop for BidiLockGuard {
84    fn drop(&mut self) {
85        // Talk to SQLite directly via a raw `rusqlite::Connection`. We
86        // can't reach back into the parent `Registry` from Drop without
87        // significant lifetime gymnastics, and the DELETE is a single
88        // atomic statement — SQLite's own concurrency (WAL +
89        // busy_timeout) covers serialisation against other writers.
90        if let Ok(conn) = rusqlite::Connection::open(&self.db_path) {
91            let _ = conn.busy_timeout(std::time::Duration::from_secs(5));
92            let _ = release(&conn, &self.browser_name, self.holder_pid);
93        }
94    }
95}
96
97impl Registry {
98    /// Acquire the BiDi lock for `browser_name`. Blocks until granted or
99    /// `timeout` elapses. On contention, evicts a stale row whose holder
100    /// PID is no longer alive.
101    ///
102    /// Polling rather than file-locking on the row: the registry's
103    /// process-level file lock already serializes connections, and the
104    /// acquire path is single-row + atomic. We re-poll every
105    /// [`POLL_INTERVAL`] until success or `timeout`.
106    pub fn bidi_lock_acquire(
107        &self,
108        browser_name: &str,
109        timeout: Duration,
110    ) -> Result<BidiLockGuard> {
111        let my_pid = std::process::id();
112        let start = Instant::now();
113        loop {
114            // Try to insert our row. If a row already exists for this
115            // browser, the INSERT fails with a UNIQUE constraint — that's
116            // the contention path.
117            let now = now_epoch_s();
118            let attempt = self.conn.execute(
119                "INSERT INTO bidi_locks (browser_name, holder_pid, acquired_at_epoch_s) \
120                 VALUES (?1, ?2, ?3)",
121                rusqlite::params![browser_name, my_pid, now],
122            );
123            match attempt {
124                Ok(_) => {
125                    return Ok(BidiLockGuard {
126                        browser_name: browser_name.to_string(),
127                        holder_pid: my_pid,
128                        db_path: self.db_path.clone(),
129                    });
130                }
131                Err(rusqlite::Error::SqliteFailure(e, _))
132                    if e.code == rusqlite::ErrorCode::ConstraintViolation =>
133                {
134                    // Contended. Check if the holder is still alive; if not,
135                    // evict and retry immediately.
136                    if let Some(existing) = self.bidi_lock_holder(browser_name)? {
137                        if !pid_alive(existing.holder_pid) {
138                            // Stale row — evict by CAS so we don't race
139                            // with the actual holder if they came back.
140                            let _ = release(&self.conn, browser_name, existing.holder_pid);
141                            continue;
142                        }
143                        // Still alive. If we've hit the timeout, escalate
144                        // with the holder's PID in the typed error.
145                        if start.elapsed() >= timeout {
146                            return Err(BidiLockBusy {
147                                browser_name: browser_name.to_string(),
148                                holder_pid: existing.holder_pid,
149                                waited_ms: start.elapsed().as_millis() as u64,
150                            }
151                            .into());
152                        }
153                        // Wait and retry.
154                        std::thread::sleep(POLL_INTERVAL);
155                    } else {
156                        // Row vanished between INSERT and lookup — race
157                        // with another contender. Retry immediately.
158                        continue;
159                    }
160                }
161                Err(e) => {
162                    return Err(anyhow!(e))
163                        .with_context(|| format!("acquire bidi_lock for {browser_name}"));
164                }
165            }
166        }
167    }
168
169    /// Read the current lock holder for diagnostics / tests. Returns
170    /// `None` if no row exists.
171    pub fn bidi_lock_holder(&self, browser_name: &str) -> Result<Option<BidiLockRow>> {
172        db::query_optional(
173            &self.conn,
174            "SELECT browser_name, holder_pid, acquired_at_epoch_s \
175             FROM bidi_locks WHERE browser_name = ?1",
176            [browser_name],
177            |r| {
178                Ok(BidiLockRow {
179                    browser_name: r.get(0)?,
180                    holder_pid: r.get::<_, i64>(1)? as u32,
181                    acquired_at_epoch_s: r.get(2)?,
182                })
183            },
184        )
185    }
186}
187
188/// Read-only view of a row in `bidi_locks`. Used by `bidi_lock_holder`.
189#[derive(Debug, Clone, PartialEq, Eq)]
190pub struct BidiLockRow {
191    pub browser_name: String,
192    pub holder_pid: u32,
193    pub acquired_at_epoch_s: i64,
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    fn fresh_at(path: &std::path::Path) -> Registry {
201        Registry::open_at(path).unwrap()
202    }
203
204    #[test]
205    fn acquire_when_unlocked_succeeds_immediately() {
206        let tmp = tempfile::NamedTempFile::new().unwrap();
207        let reg = fresh_at(tmp.path());
208        let guard = reg
209            .bidi_lock_acquire("brave", Duration::from_secs(1))
210            .unwrap();
211        assert_eq!(guard.holder_pid(), std::process::id());
212        let holder = reg.bidi_lock_holder("brave").unwrap().unwrap();
213        assert_eq!(holder.holder_pid, std::process::id());
214    }
215
216    #[test]
217    fn drop_releases_the_lock() {
218        let tmp = tempfile::NamedTempFile::new().unwrap();
219        let reg = fresh_at(tmp.path());
220        {
221            let _guard = reg.bidi_lock_acquire("b", Duration::from_secs(1)).unwrap();
222            assert!(reg.bidi_lock_holder("b").unwrap().is_some());
223        }
224        // After drop, the row should be gone.
225        assert!(reg.bidi_lock_holder("b").unwrap().is_none());
226    }
227
228    #[test]
229    fn contention_against_self_pid_times_out_with_typed_error() {
230        let tmp = tempfile::NamedTempFile::new().unwrap();
231        let reg = fresh_at(tmp.path());
232        let _g = reg.bidi_lock_acquire("b", Duration::from_secs(1)).unwrap();
233        // Acquire again immediately — the existing row is our own PID
234        // (still alive). Should time out with BidiLockBusy.
235        let start = Instant::now();
236        let err = reg
237            .bidi_lock_acquire("b", Duration::from_millis(250))
238            .expect_err("must time out");
239        let elapsed = start.elapsed();
240        assert!(elapsed >= Duration::from_millis(200));
241        assert!(elapsed < Duration::from_secs(1));
242        let typed = err
243            .downcast_ref::<BidiLockBusy>()
244            .expect("typed BidiLockBusy");
245        assert_eq!(typed.holder_pid, std::process::id());
246    }
247
248    #[test]
249    fn blocks_until_granted_on_release() {
250        // The central grant-on-release transition: a holder takes the lock,
251        // a contender blocks waiting for it, the holder drops (releasing the
252        // row), and the contender then acquires. Both threads share this
253        // process's PID, so the INSERT genuinely conflicts until the guard's
254        // `Drop` DELETEs the row — exercising the poll-then-grant path that
255        // the timeout/eviction tests don't reach.
256        //
257        // File-backed (not `:memory:`) because `BidiLockGuard::drop` releases
258        // by opening a *fresh* connection at `db_path`; an in-memory registry
259        // would hand the guard a separate empty DB and the release would be a
260        // no-op.
261        let tmp = tempfile::NamedTempFile::new().unwrap();
262        let path = tmp.path().to_path_buf();
263
264        let reg = fresh_at(&path);
265        let guard = reg.bidi_lock_acquire("b", Duration::from_secs(1)).unwrap();
266
267        // Hold the lock for a beat on a background thread, then drop it.
268        const HOLD: Duration = Duration::from_millis(300);
269        let holder = std::thread::spawn(move || {
270            std::thread::sleep(HOLD);
271            drop(guard); // releases the row via Drop's fresh connection
272        });
273
274        // Contend from a second connection with a timeout comfortably longer
275        // than the hold. The first poll fails (row present), we sleep
276        // POLL_INTERVAL, retry until the holder releases, then succeed.
277        let reg2 = fresh_at(&path);
278        let start = Instant::now();
279        let g2 = reg2
280            .bidi_lock_acquire("b", Duration::from_secs(5))
281            .expect("must eventually acquire after release");
282        let elapsed = start.elapsed();
283
284        holder.join().unwrap();
285
286        assert_eq!(g2.holder_pid(), std::process::id());
287        // It can only have succeeded after the holder dropped — i.e. at least
288        // the hold duration must have elapsed.
289        assert!(
290            elapsed >= HOLD,
291            "acquired before release: elapsed {elapsed:?} < hold {HOLD:?}"
292        );
293        // And it must have actually been granted (row now ours).
294        let holder_row = reg2.bidi_lock_holder("b").unwrap().unwrap();
295        assert_eq!(holder_row.holder_pid, std::process::id());
296    }
297
298    #[test]
299    fn stale_pid_holder_is_evicted_on_acquire() {
300        let tmp = tempfile::NamedTempFile::new().unwrap();
301        let reg = fresh_at(tmp.path());
302        // Plant a synthetic row with a PID that almost certainly does
303        // not exist on this machine. `u32::MAX` is reserved on most
304        // platforms; using a sentinel keeps the test cheap.
305        let dead_pid: u32 = 1; // PID 1 is init; not us. We use a clearly-bogus number instead.
306        let bogus_pid: u32 = 999_999_999;
307        reg.conn
308            .execute(
309                "INSERT INTO bidi_locks (browser_name, holder_pid, acquired_at_epoch_s) \
310                 VALUES (?1, ?2, ?3)",
311                rusqlite::params!["b", bogus_pid, crate::registry::now_epoch_s()],
312            )
313            .unwrap();
314        let _ = dead_pid;
315        // Acquire should evict the bogus row and grant the lock to us.
316        let guard = reg.bidi_lock_acquire("b", Duration::from_secs(2)).unwrap();
317        assert_eq!(guard.holder_pid(), std::process::id());
318    }
319}