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}