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}