//! In-memory table of connected extension clients. //! //! Each WS connection that completes the handshake registers a //! [`BrowserClient`] keyed by its `instance_id` (treated as the public //! BrowserId). Reconnects re-use the same id and replace the previous //! entry so `bsk status` shows a single row per physical browser. use std::collections::HashMap; use std::fmt; use std::sync::Mutex; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; use anyhow::Result; use bsk_protocol::{Frame, RpcId}; use serde::{Deserialize, Serialize}; use thiserror::Error; use tokio::sync::{mpsc, oneshot}; use bsk_protocol::system::BrowserStatusEntry; /// After a daemon cold start the MV3 service worker may take several /// seconds to wake, discover the WS port, and finish `system.handshake`. /// `session.start` polls the registry for up to this long before /// returning `no_browser_connected`. /// /// Sized to comfortably cover one MV3 keepalive-alarm period (30s, the /// smallest Chrome allows) plus handshake slack: after a long idle the /// service worker is only revived on its next alarm tick, so a shorter /// window would make the first command after idle fail with /// `no_browser_connected` even though the extension is about to /// reconnect. Callers that want a snapshot (`bsk status`/`browsers`) /// pass their own, shorter wait instead of this constant. pub const EXTENSION_CONNECT_WAIT: Duration = Duration::from_secs(35); const EXTENSION_CONNECT_POLL: Duration = Duration::from_millis(50); /// A connected browser is considered dead if no frame (including the /// ~20s `system.heartbeat`) has arrived within this window. Set to three /// missed heartbeats so a briefly-busy service worker (GC, heavy CDP) is /// not reaped on a single late beat. Used by the liveness reaper to drop /// half-open connections that never delivered a socket close — e.g. after /// the OS resumed from sleep and killed the worker. pub const BROWSER_LIVENESS_TIMEOUT: Duration = Duration::from_secs(60); /// How often the liveness reaper scans the registry for stale browsers. pub const BROWSER_LIVENESS_TICK: Duration = Duration::from_secs(15); /// Process-wide monotonic counter for [`BrowserClient::generation`]. Used /// by the reconnect-race guard: when an old WS task tears down it only /// removes itself from the registry if the registry's current /// `Arc` matches the same generation (review M4/M5 round /// 2 Important #1). static NEXT_BROWSER_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); pub fn next_browser_generation() -> u64 { NEXT_BROWSER_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) } /// Logical identifier of a connected extension. #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] pub struct BrowserId(pub String); impl fmt::Display for BrowserId { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(&self.0) } } /// Outbound channel handle used to push frames to a specific extension. #[derive(Debug, Clone)] pub struct BrowserSink { pub tx: mpsc::UnboundedSender, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Error)] #[error("browser sink closed")] pub struct BrowserSinkClosed; impl BrowserSink { pub fn send(&self, frame: Frame) -> Result<(), BrowserSinkClosed> { self.tx.send(frame).map_err(|_| BrowserSinkClosed) } } /// Pending in-flight RPCs that the daemon is awaiting a response for from /// a specific browser, keyed by the request's `id`. #[derive(Debug, Default)] pub struct Pending { waiters: HashMap>, } impl Pending { pub fn register(&mut self, id: RpcId) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); self.waiters.insert(id, tx); rx } pub fn resolve(&mut self, frame: bsk_protocol::ResponseFrame) -> bool { if let Some(tx) = self.waiters.remove(&frame.id) { let _ = tx.send(frame); true } else { false } } /// Drop a registered waiter without delivering a response. Used on /// caller-side error/timeout paths so the `HashMap` does not /// accumulate stale entries (review M4/M5 I1). The corresponding /// `oneshot::Sender` is dropped, which closes its receiver so any /// still-pending caller observes a `TransportClosed`-style error. pub fn cancel(&mut self, id: &RpcId) { self.waiters.remove(id); } } #[derive(Debug)] pub struct BrowserClient { /// Logical identifier (the extension's self-allocated stable short id /// from `chrome.storage.local.bsk_instance_id`, NOT the Chromium /// extension id which only flows through the Origin allow-list). pub id: BrowserId, pub browser_name: String, pub browser_version: String, pub extension_version: String, /// `protocol_version` from the extension handshake. pub extension_protocol_version: String, pub label: String, pub sink: BrowserSink, pub pending: Mutex, /// Monotonic counter assigned at registration time. A reconnect /// inserts a new `BrowserClient` under the same `id`; the old WS /// driver task only removes itself if the current entry still has /// the same generation, otherwise the new connection has taken /// over and we leave it alone. pub generation: u64, /// Unix epoch milliseconds when the registration completed (i.e. /// when the WS handshake landed and this client took its slot in /// the registry). Surfaces in `BrowserStatusEntry::connected_at_ms` /// so `bsk browsers` and the M10.1 `multiple_browsers_online` /// error.data can render a stable "online since" hint. pub connected_at_ms: i64, /// `true` when the peer's `protocol_version` differs from ours /// (same major, minor drift). Set during WS handshake (M10.4). pub version_skew: bool, /// Monotonic timestamp of the most recent inbound frame from this /// browser (any response/event, including `system.heartbeat`). /// Bumped by [`BrowserClient::touch`] from the WS read loop and read /// by the liveness reaper to detect a silently-dead connection. pub last_seen: Mutex, /// `true` once this browser has sent at least one `system.heartbeat`. /// The liveness reaper only acts on heartbeat-capable browsers: a /// pre-heartbeat extension never sets this, so it is never reaped on /// silence and instead relies on socket-close detection exactly as /// before — otherwise a new daemon paired with an old extension would /// wrongly drop a live-but-idle connection. pub heartbeat_seen: AtomicBool, } impl BrowserClient { /// Record that a frame just arrived from this browser. pub fn touch(&self) { if let Ok(mut last) = self.last_seen.lock() { *last = Instant::now(); } } /// Note that a `system.heartbeat` arrived, opting this browser in to /// liveness reaping. pub fn mark_heartbeat_seen(&self) { self.heartbeat_seen.store(true, Ordering::Relaxed); } /// Whether this browser has ever sent a heartbeat. pub fn has_heartbeat(&self) -> bool { self.heartbeat_seen.load(Ordering::Relaxed) } /// How long since the last inbound frame from this browser. pub fn idle_for(&self) -> Duration { match self.last_seen.lock() { Ok(last) => last.elapsed(), Err(poisoned) => poisoned.into_inner().elapsed(), } } pub fn status_entry(&self, session_count: u32) -> BrowserStatusEntry { BrowserStatusEntry { instance_id: self.id.0.clone(), browser_name: self.browser_name.clone(), browser_version: self.browser_version.clone(), extension_version: self.extension_version.clone(), label: self.label.clone(), session_count, connected_at_ms: self.connected_at_ms, version_skew: self.version_skew, extension_protocol_version: self.extension_protocol_version.clone(), } } } #[derive(Debug, Default)] pub struct BrowserRegistry { inner: Mutex>>, } impl BrowserRegistry { pub fn new() -> Self { Self { inner: Mutex::new(HashMap::new()), } } pub fn insert(&self, client: std::sync::Arc) { let mut guard = self.inner.lock().expect("browser registry poisoned"); if let Some(prev) = guard.get(&client.id) { tracing::info!( id = %client.id, old_generation = prev.generation, new_generation = client.generation, "browser reconnect: replacing previous registration" ); } guard.insert(client.id.clone(), client); } pub fn remove(&self, id: &BrowserId) -> Option> { self.inner .lock() .expect("browser registry poisoned") .remove(id) } /// Drop the entry for `id` only if the current registered /// [`BrowserClient`] still matches `generation`. Used by WS /// drivers on cleanup to avoid clobbering a reconnect that already /// took over the same `id` while the old socket was tearing down /// (review M4/M5 round 2 Important #1). Returns the removed entry /// when a removal actually happened. pub fn remove_if_generation_matches( &self, id: &BrowserId, generation: u64, ) -> Option> { let mut guard = self.inner.lock().expect("browser registry poisoned"); match guard.get(id) { Some(current) if current.generation == generation => guard.remove(id), _ => None, } } pub fn get(&self, id: &BrowserId) -> Option> { self.inner .lock() .expect("browser registry poisoned") .get(id) .cloned() } pub fn snapshot(&self) -> Vec> { let guard = self.inner.lock().expect("browser registry poisoned"); guard.values().cloned().collect() } /// Return every heartbeat-capable browser whose last inbound frame is /// older than `threshold`. Browsers that have never sent a heartbeat /// (legacy extensions) are excluded so the reaper never drops a /// live-but-idle connection it cannot probe. The caller is /// responsible for dropping the returned entries (via /// [`Self::remove_if_generation_matches`]) and purging their sessions /// — done outside the lock so session teardown never blocks the /// registry. pub fn stale_browsers(&self, threshold: Duration) -> Vec> { let guard = self.inner.lock().expect("browser registry poisoned"); guard .values() .filter(|c| c.has_heartbeat() && c.idle_for() >= threshold) .cloned() .collect() } pub fn len(&self) -> usize { self.inner.lock().expect("browser registry poisoned").len() } pub fn is_empty(&self) -> bool { self.len() == 0 } /// Poll until at least one browser is registered or `connect_wait` elapses. /// Returns immediately when the registry is already non-empty. pub async fn wait_for_any_connected(&self, connect_wait: Duration) { if connect_wait.is_zero() || !self.is_empty() { return; } let deadline = Instant::now() + connect_wait; loop { if !self.is_empty() { return; } if Instant::now() >= deadline { return; } tokio::time::sleep(EXTENSION_CONNECT_POLL).await; } } /// Pick the only connected browser when exactly one is online, or /// match a specific id / label when supplied. Returns an error code /// aligned with §4.5. /// /// Lookup precedence when `requested` is `Some(value)`: /// 1. Exact `instance_id` match — returns immediately. Instance ids /// are opaque hex strings allocated by the extension; collisions /// with a user label are vanishingly unlikely but if both /// matched we still prefer the id (M10.1). /// 2. Exact, case-sensitive `label` match against non-empty labels. /// A single match returns that client; multiple matches surface /// [`SelectError::AmbiguousLabel`] so the CLI can prompt the /// user to disambiguate by `instance_id`. /// 3. Otherwise [`SelectError::NotFound`]. pub fn select( &self, requested: Option<&str>, ) -> Result, SelectError> { let guard = self.inner.lock().expect("browser registry poisoned"); match requested { Some(needle) if !needle.is_empty() => { if let Some(client) = guard.get(&BrowserId(needle.to_string())) { return Ok(client.clone()); } let label_matches: Vec> = guard .values() .filter(|c| !c.label.is_empty() && c.label == needle) .cloned() .collect(); match label_matches.len() { 0 => Err(SelectError::NotFound), 1 => Ok(label_matches.into_iter().next().unwrap()), _ => Err(SelectError::AmbiguousLabel { label: needle.to_string(), instance_ids: label_matches.into_iter().map(|c| c.id.0.clone()).collect(), }), } } Some(_) | None => { let mut iter = guard.values(); let Some(first) = iter.next() else { return Err(SelectError::NoBrowserConnected); }; if iter.next().is_some() { return Err(SelectError::MultipleBrowsersOnline); } Ok(first.clone()) } } } /// Like [`BrowserRegistry::select`] but retries while no matching /// browser is registered yet, so a freshly started daemon can wait /// for the extension to finish its WS handshake. pub async fn select_with_connect_wait( &self, requested: Option<&str>, connect_wait: Duration, ) -> Result, SelectError> { let deadline = Instant::now() + connect_wait; loop { match self.select(requested) { Ok(client) => return Ok(client), Err(SelectError::NoBrowserConnected) => { if Instant::now() >= deadline { return self.select(requested); } tokio::time::sleep(EXTENSION_CONNECT_POLL).await; } // Only wait for a missing selector while no browser has // registered yet (cold start). If other browsers are // already online, an unknown id/label is a real miss. Err(SelectError::NotFound) if self.is_empty() => { if Instant::now() >= deadline { return self.select(requested); } tokio::time::sleep(EXTENSION_CONNECT_POLL).await; } Err(err) => return Err(err), } } } } #[derive(Debug, Clone, PartialEq, Eq)] pub enum SelectError { NoBrowserConnected, MultipleBrowsersOnline, NotFound, /// The supplied label matches more than one connected browser. /// The CLI rendering layer surfaces the candidate `instance_ids` /// so the user can rerun with an unambiguous selector. AmbiguousLabel { label: String, instance_ids: Vec, }, } #[cfg(test)] mod tests { use super::*; fn fake_client(id: &str, label: &str) -> std::sync::Arc { let (tx, _rx) = mpsc::unbounded_channel::(); std::sync::Arc::new(BrowserClient { id: BrowserId(id.into()), browser_name: "chrome".into(), browser_version: "131".into(), extension_version: "0.1.0-dev.0".into(), extension_protocol_version: "1.0".into(), label: label.into(), sink: BrowserSink { tx }, pending: Mutex::new(Pending::default()), generation: next_browser_generation(), connected_at_ms: 0, version_skew: false, last_seen: Mutex::new(Instant::now()), heartbeat_seen: AtomicBool::new(false), }) } #[test] fn select_none_with_zero_browsers_is_no_browser_connected() { let reg = BrowserRegistry::new(); match reg.select(None) { Err(SelectError::NoBrowserConnected) => {} other => panic!("expected NoBrowserConnected, got {other:?}"), } } #[test] fn select_none_with_two_browsers_is_multiple() { let reg = BrowserRegistry::new(); reg.insert(fake_client("a", "")); reg.insert(fake_client("b", "")); match reg.select(None) { Err(SelectError::MultipleBrowsersOnline) => {} other => panic!("expected MultipleBrowsersOnline, got {other:?}"), } } #[test] fn select_by_instance_id_returns_match() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "Personal")); reg.insert(fake_client("beta", "Work")); let picked = reg.select(Some("alpha")).expect("alpha should match"); assert_eq!(picked.id, BrowserId("alpha".into())); } #[test] fn select_by_unique_label_returns_match() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "Personal")); reg.insert(fake_client("beta", "Work")); let picked = reg.select(Some("Work")).expect("Work label should match"); assert_eq!(picked.id, BrowserId("beta".into())); } #[test] fn select_by_unknown_string_is_not_found() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "Personal")); match reg.select(Some("nope")) { Err(SelectError::NotFound) => {} other => panic!("expected NotFound, got {other:?}"), } } #[test] fn select_by_ambiguous_label_lists_candidates() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "Shared")); reg.insert(fake_client("beta", "Shared")); match reg.select(Some("Shared")) { Err(SelectError::AmbiguousLabel { label, instance_ids, }) => { assert_eq!(label, "Shared"); assert_eq!(instance_ids.len(), 2); let set: std::collections::HashSet<&str> = instance_ids.iter().map(String::as_str).collect(); assert!(set.contains("alpha")); assert!(set.contains("beta")); } other => panic!("expected AmbiguousLabel, got {other:?}"), } } #[test] fn select_prefers_instance_id_over_label_when_both_match() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "alpha")); reg.insert(fake_client("beta", "alpha")); // Even though "alpha" also occurs as label on beta, the // exact instance_id match wins so the user always gets the // browser they pointed at directly. let picked = reg.select(Some("alpha")).expect("instance id wins"); assert_eq!(picked.id, BrowserId("alpha".into())); } #[test] fn select_ignores_empty_labels() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "")); reg.insert(fake_client("beta", "")); match reg.select(Some("")) { Err(SelectError::MultipleBrowsersOnline) => {} other => panic!("expected MultipleBrowsersOnline for empty needle, got {other:?}"), } } #[tokio::test] async fn select_with_connect_wait_times_out_when_no_browser_arrives() { let reg = BrowserRegistry::new(); let started = Instant::now(); match reg .select_with_connect_wait(None, Duration::from_millis(120)) .await { Err(SelectError::NoBrowserConnected) => {} other => panic!("expected NoBrowserConnected, got {other:?}"), } assert!( started.elapsed() >= Duration::from_millis(100), "should wait before failing" ); } #[tokio::test] async fn select_with_connect_wait_succeeds_when_browser_registers_late() { let reg = std::sync::Arc::new(BrowserRegistry::new()); let reg_bg = reg.clone(); let waiter = tokio::spawn(async move { reg_bg .select_with_connect_wait(None, Duration::from_millis(500)) .await }); tokio::time::sleep(Duration::from_millis(80)).await; reg.insert(fake_client("late", "")); let picked = waiter.await.expect("join").expect("browser should appear"); assert_eq!(picked.id, BrowserId("late".into())); } #[tokio::test] async fn select_with_connect_wait_does_not_wait_on_multiple_browsers() { let reg = BrowserRegistry::new(); reg.insert(fake_client("a", "")); reg.insert(fake_client("b", "")); let started = Instant::now(); match reg .select_with_connect_wait(None, Duration::from_secs(1)) .await { Err(SelectError::MultipleBrowsersOnline) => {} other => panic!("expected MultipleBrowsersOnline, got {other:?}"), } assert!( started.elapsed() < Duration::from_millis(50), "ambiguous selection should fail immediately" ); } #[tokio::test] async fn select_with_connect_wait_waits_for_requested_instance_id() { let reg = std::sync::Arc::new(BrowserRegistry::new()); let reg_bg = reg.clone(); let waiter = tokio::spawn(async move { reg_bg .select_with_connect_wait(Some("target"), Duration::from_millis(500)) .await }); tokio::time::sleep(Duration::from_millis(80)).await; reg.insert(fake_client("target", "Work")); reg.insert(fake_client("other", "Work")); let picked = waiter.await.expect("join").expect("target should appear"); assert_eq!(picked.id, BrowserId("target".into())); } #[tokio::test] async fn select_with_connect_wait_does_not_wait_on_not_found_when_browsers_online() { let reg = BrowserRegistry::new(); reg.insert(fake_client("alpha", "Personal")); let started = Instant::now(); match reg .select_with_connect_wait(Some("missing"), Duration::from_secs(1)) .await { Err(SelectError::NotFound) => {} other => panic!("expected NotFound, got {other:?}"), } assert!( started.elapsed() < Duration::from_millis(50), "unknown selector with browsers online should fail immediately" ); } #[tokio::test] async fn wait_for_any_connected_returns_when_browser_registers_late() { let reg = std::sync::Arc::new(BrowserRegistry::new()); let reg_bg = reg.clone(); let waiter = tokio::spawn(async move { reg_bg .wait_for_any_connected(Duration::from_millis(500)) .await; }); tokio::time::sleep(Duration::from_millis(80)).await; reg.insert(fake_client("late", "")); waiter.await.expect("join"); assert_eq!(reg.len(), 1); } #[test] fn touch_resets_idle_for() { let client = fake_client("a", ""); // Force the last_seen well into the past, then touch. *client.last_seen.lock().unwrap() = Instant::now() - Duration::from_secs(120); assert!(client.idle_for() >= Duration::from_secs(120)); client.touch(); assert!(client.idle_for() < Duration::from_secs(1)); } #[test] fn stale_browsers_reports_only_silent_heartbeat_capable_connections() { let reg = BrowserRegistry::new(); let fresh = fake_client("fresh", ""); fresh.mark_heartbeat_seen(); let stale = fake_client("stale", ""); stale.mark_heartbeat_seen(); *stale.last_seen.lock().unwrap() = Instant::now() - Duration::from_secs(90); reg.insert(fresh); reg.insert(stale); let stale_ids: Vec = reg .stale_browsers(Duration::from_secs(60)) .into_iter() .map(|c| c.id.0.clone()) .collect(); assert_eq!(stale_ids, vec!["stale".to_string()]); } #[test] fn stale_browsers_never_reaps_a_browser_that_never_sent_a_heartbeat() { // A legacy extension that predates the heartbeat must fall back // to socket-close detection, never liveness reaping — even when // it has been silent well past the threshold. let reg = BrowserRegistry::new(); let legacy = fake_client("legacy", ""); *legacy.last_seen.lock().unwrap() = Instant::now() - Duration::from_secs(600); reg.insert(legacy); assert!( reg.stale_browsers(Duration::from_secs(60)).is_empty(), "a browser that never heartbeated must not be reaped" ); } #[tokio::test] async fn wait_for_any_connected_skips_when_already_populated() { let reg = BrowserRegistry::new(); reg.insert(fake_client("a", "")); let started = Instant::now(); reg.wait_for_any_connected(Duration::from_secs(1)).await; assert!( started.elapsed() < Duration::from_millis(50), "should return immediately when browsers already connected" ); } }