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}