Skip to main content

browser_control/transport/
mod.rs

1//! Shared WebSocket JSON-RPC transport (`WsRpc`) used by both the CDP and
2//! BiDi clients.
3//!
4//! CDP and BiDi are two JSON-RPC-over-WebSocket dialects with identical
5//! connect / writer-task / reader-frame-decode / `pending`-correlation /
6//! `next_id` / broadcast / timeout-ladder machinery. They previously each
7//! reimplemented all of it, and the two copies had drifted in ways that were
8//! genuine bugs on the BiDi side (pending entries leaked on writer failure;
9//! no typed disconnect error; no `close()`, no stored `JoinHandle`s, so
10//! dropping a cached client orphaned parked reader tasks). This module is the
11//! single shared implementation; the protocol-specific framing/typing stays in
12//! `crate::cdp` / `crate::bidi` via the [`Protocol`] trait.
13//!
14//! The constants that had drifted (`REQUEST_TIMEOUT` vs `SEND_TIMEOUT`, the
15//! event-channel capacity, the connect timeout) are converged here.
16
17use std::collections::HashMap;
18use std::sync::Arc;
19use std::time::Duration;
20
21use anyhow::{anyhow, Result};
22use futures_util::{SinkExt, StreamExt};
23use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
24use tokio_tungstenite::tungstenite::Message;
25
26/// Per-request reply timeout. One value for both protocols (previously
27/// `REQUEST_TIMEOUT` on the CDP side and `SEND_TIMEOUT` on the BiDi side,
28/// both 30s).
29pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
30
31/// Capacity of the per-client event broadcast channel.
32///
33/// Sized for the capture hub (`crate::session::capture`): a page load
34/// emits several `Network.*` events per request plus per-chunk
35/// `dataReceived`, so a 200-request page can push well over a thousand
36/// events in under 100 ms. Receivers have independent cursors, so a lagging
37/// receiver only loses its own events (reported as `Lagged`), never another
38/// subscriber's. Entries are only retained while a receiver exists.
39pub const EVENT_CHANNEL_CAPACITY: usize = 2048;
40
41/// Bound on `connect_async` / HTTP discovery during initial bringup.
42///
43/// A dead browser process or a stale endpoint can otherwise stall the
44/// WebSocket upgrade (or the underlying TCP connect) for the OS's connect
45/// timeout — multiple seconds to over a minute on macOS/Linux. Five seconds
46/// matches the `--version` probe in `crate::detect` and is short enough that
47/// agents don't perceive a hang.
48pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
49
50/// Protocol adapter: the dialect-specific framing/typing that the shared
51/// transport delegates to. CDP and BiDi each implement this; everything else
52/// (socket, tasks, correlation, timeouts, lifecycle) is shared.
53pub trait Protocol: Send + Sync + 'static {
54    /// Per-reply protocol error type (e.g. `CdpError` / `BidiError`).
55    type ProtoError: Send + 'static;
56    /// Broadcast event type delivered to subscribers.
57    type Event: Clone + Send + 'static;
58
59    /// Serialize an outbound request to the wire text. `session_id` carries
60    /// the CDP flat-session id; BiDi ignores it.
61    fn encode_request(
62        id: u64,
63        method: &str,
64        params: serde_json::Value,
65        session_id: Option<&str>,
66    ) -> Result<String>;
67
68    /// Decode an inbound text frame into a [`Decoded`] outcome.
69    fn decode_frame(text: &str) -> Decoded<Self::ProtoError, Self::Event>;
70
71    /// Build the protocol's "connection closed" error used to fail pending
72    /// requests when the reader task exits (socket close / I/O error). This is
73    /// what makes a disconnect surface as the protocol's typed error instead of
74    /// silently dropping the oneshot.
75    fn closed_error() -> Self::ProtoError;
76}
77
78/// Outcome of decoding one inbound frame.
79pub enum Decoded<E, Ev> {
80    /// A reply correlated to a pending request `id`.
81    Reply {
82        id: u64,
83        result: Result<serde_json::Value, E>,
84    },
85    /// An unsolicited event for the broadcast channel.
86    Event(Ev),
87    /// Nothing actionable (event/error without an id, unparseable frame, etc.).
88    Ignore,
89}
90
91type PendingMap<E> = HashMap<u64, oneshot::Sender<Result<serde_json::Value, E>>>;
92
93/// Shared JSON-RPC-over-WebSocket transport. Owns the socket, the reader and
94/// writer tasks (and their `JoinHandle`s), the pending-request correlation
95/// map, the id counter, and the event broadcast channel.
96pub struct WsRpc<P: Protocol> {
97    next_id: Mutex<u64>,
98    pending: Arc<Mutex<PendingMap<P::ProtoError>>>,
99    events_tx: broadcast::Sender<P::Event>,
100    write_tx: mpsc::UnboundedSender<String>,
101    // `Option` so both the graceful `close()` and the `Drop` guard can take the
102    // handles without moving out of a `Drop` type.
103    reader_handle: Option<tokio::task::JoinHandle<()>>,
104    writer_handle: Option<tokio::task::JoinHandle<()>>,
105}
106
107impl<P: Protocol> WsRpc<P> {
108    /// Connect by full WebSocket URL (ws:// or wss://) and spawn the reader and
109    /// writer tasks. `label` is used only in the connect-timeout error message
110    /// (e.g. `"CDP"` / `"BiDi"`).
111    pub async fn connect(ws_url: &str, label: &str) -> Result<Self> {
112        let (ws_stream, _) =
113            tokio::time::timeout(CONNECT_TIMEOUT, tokio_tungstenite::connect_async(ws_url))
114                .await
115                .map_err(|_| {
116                    anyhow!(
117                        "{label} WebSocket connect to {ws_url} timed out after {:?}",
118                        CONNECT_TIMEOUT
119                    )
120                })??;
121        let (mut ws_sink, mut ws_stream) = ws_stream.split();
122
123        let pending: Arc<Mutex<PendingMap<P::ProtoError>>> = Arc::new(Mutex::new(HashMap::new()));
124        let (events_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
125        let (write_tx, mut write_rx) = mpsc::unbounded_channel::<String>();
126
127        let writer_handle = tokio::spawn(async move {
128            while let Some(text) = write_rx.recv().await {
129                if ws_sink.send(Message::Text(text)).await.is_err() {
130                    break;
131                }
132            }
133            let _ = ws_sink.close().await;
134        });
135
136        let pending_r = pending.clone();
137        let events_r = events_tx.clone();
138        let reader_handle = tokio::spawn(async move {
139            while let Some(msg) = ws_stream.next().await {
140                let text = match msg {
141                    Ok(Message::Text(t)) => t,
142                    Ok(Message::Binary(b)) => match String::from_utf8(b) {
143                        Ok(s) => s,
144                        Err(e) => {
145                            tracing::debug!(
146                                bytes = e.as_bytes().len(),
147                                "dropping non-UTF-8 binary frame"
148                            );
149                            continue;
150                        }
151                    },
152                    Ok(Message::Close(_)) | Err(_) => break,
153                    Ok(_) => continue,
154                };
155                match P::decode_frame(&text) {
156                    Decoded::Reply { id, result } => {
157                        if let Some(tx) = pending_r.lock().await.remove(&id) {
158                            let _ = tx.send(result);
159                        }
160                    }
161                    Decoded::Event(ev) => {
162                        let _ = events_r.send(ev);
163                    }
164                    // Unparseable or id-less frame: can't be correlated to any
165                    // waiter, so it's dropped — but log it (truncated) so a
166                    // hung request has a breadcrumb instead of silence.
167                    Decoded::Ignore => {
168                        tracing::debug!(frame = %truncate_frame(&text), "dropping undecodable/idless frame");
169                    }
170                }
171            }
172            // Reader closed (socket close / I/O error): fail every pending
173            // request with the protocol's typed "connection closed" error so
174            // no waiter rides the full request timeout and no oneshot is
175            // dropped silently.
176            let mut p = pending_r.lock().await;
177            for (_, tx) in p.drain() {
178                let _ = tx.send(Err(P::closed_error()));
179            }
180        });
181
182        Ok(Self {
183            next_id: Mutex::new(1),
184            pending,
185            events_tx,
186            write_tx,
187            reader_handle: Some(reader_handle),
188            writer_handle: Some(writer_handle),
189        })
190    }
191
192    /// Allocate the next monotonically increasing request id.
193    async fn next_id(&self) -> u64 {
194        let mut n = self.next_id.lock().await;
195        let id = *n;
196        *n += 1;
197        id
198    }
199
200    /// Send a request and await its correlated reply, bounded by
201    /// [`REQUEST_TIMEOUT`]. On writer-channel failure the pending entry is
202    /// removed before returning (no leak); on timeout it is likewise removed.
203    /// A protocol error reply comes back as [`RequestError::Protocol`];
204    /// transport faults (writer closed / channel dropped / serialization) as
205    /// [`RequestError::Transport`]; a no-reply timeout as
206    /// [`RequestError::Timeout`].
207    ///
208    /// Callers map these through their own classifier so the protocol-specific
209    /// typing stays in `crate::cdp` / `crate::bidi`.
210    #[allow(clippy::result_large_err)]
211    pub async fn request(
212        &self,
213        method: &str,
214        params: serde_json::Value,
215        session_id: Option<&str>,
216    ) -> std::result::Result<serde_json::Value, RequestError<P::ProtoError>> {
217        let id = self.next_id().await;
218        let text =
219            P::encode_request(id, method, params, session_id).map_err(RequestError::Transport)?;
220
221        let (tx, rx) = oneshot::channel();
222        self.pending.lock().await.insert(id, tx);
223
224        if self.write_tx.send(text).is_err() {
225            self.pending.lock().await.remove(&id);
226            return Err(RequestError::Transport(anyhow!("writer task closed")));
227        }
228
229        match tokio::time::timeout(REQUEST_TIMEOUT, rx).await {
230            Ok(Ok(Ok(v))) => Ok(v),
231            Ok(Ok(Err(e))) => Err(RequestError::Protocol(e)),
232            Ok(Err(_)) => Err(RequestError::Transport(anyhow!("response channel dropped"))),
233            Err(_) => {
234                self.pending.lock().await.remove(&id);
235                Err(RequestError::Timeout)
236            }
237        }
238    }
239
240    /// Subscribe to the broadcast event stream. Drop the receiver to
241    /// unsubscribe.
242    pub fn subscribe(&self) -> broadcast::Receiver<P::Event> {
243        self.events_tx.subscribe()
244    }
245
246    /// Gracefully shut down: close the writer (flushing the socket), then abort
247    /// and join the reader. Mirrors the previous `CdpClient::close`. Taking the
248    /// handles here leaves `Drop` with nothing to abort.
249    pub async fn close(mut self) {
250        // Closing the writer channel lets the writer task drain and close the
251        // socket before we await it.
252        let (write_tx, _) = mpsc::unbounded_channel::<String>();
253        let dead = std::mem::replace(&mut self.write_tx, write_tx);
254        drop(dead);
255        if let Some(h) = self.writer_handle.take() {
256            let _ = h.await;
257        }
258        if let Some(h) = self.reader_handle.take() {
259            h.abort();
260            let _ = h.await;
261        }
262    }
263}
264
265impl<P: Protocol> Drop for WsRpc<P> {
266    /// Ensure dropping the transport (e.g. a cached `Arc<CdpClient>` cleared by
267    /// `switch_browser`) never leaks the parked reader/writer tasks, even when
268    /// the graceful `close()` was not called.
269    fn drop(&mut self) {
270        if let Some(h) = self.reader_handle.take() {
271            h.abort();
272        }
273        if let Some(h) = self.writer_handle.take() {
274            h.abort();
275        }
276    }
277}
278
279/// Truncate a frame to a bounded prefix for logging, so a multi-megabyte
280/// screenshot payload or a runaway log line never floods the diagnostics.
281fn truncate_frame(text: &str) -> std::borrow::Cow<'_, str> {
282    const MAX: usize = 200;
283    if text.len() <= MAX {
284        std::borrow::Cow::Borrowed(text)
285    } else {
286        let end = text
287            .char_indices()
288            .take_while(|(i, _)| *i < MAX)
289            .last()
290            .map(|(i, c)| i + c.len_utf8())
291            .unwrap_or(0);
292        std::borrow::Cow::Owned(format!("{}… ({} bytes total)", &text[..end], text.len()))
293    }
294}
295
296/// Result of a [`WsRpc::request`]: either a protocol error reply, or a
297/// transport-level fault (timeout / writer closed / serialization).
298pub enum RequestError<E> {
299    /// The peer replied with a protocol error (e.g. `CdpError` / `BidiError`).
300    Protocol(E),
301    /// The request exceeded [`REQUEST_TIMEOUT`] with no reply.
302    Timeout,
303    /// A transport fault: serialization failed, the writer task is gone, or the
304    /// reply channel was dropped (typically a disconnect).
305    Transport(anyhow::Error),
306}