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