Skip to main content

chromiumoxide/
conn.rs

1use std::collections::VecDeque;
2use std::marker::PhantomData;
3use std::pin::Pin;
4use std::task::ready;
5
6use futures_util::stream::{FuturesOrdered, SplitSink};
7use futures_util::{SinkExt, Stream, StreamExt};
8use std::future::Future;
9use std::task::{Context, Poll};
10use tokio::sync::mpsc;
11use tokio_tungstenite::tungstenite::Message as WsMessage;
12use tokio_tungstenite::MaybeTlsStream;
13use tokio_tungstenite::{tungstenite::protocol::WebSocketConfig, WebSocketStream};
14
15use chromiumoxide_cdp::cdp::browser_protocol::target::SessionId;
16use chromiumoxide_types::{CallId, EventMessage, Message, MethodCall, MethodId};
17
18use crate::error::CdpError;
19use crate::error::Result;
20
21type ConnectStream = MaybeTlsStream<tokio::net::TcpStream>;
22
23/// Exchanges the messages with the websocket
24#[must_use = "streams do nothing unless polled"]
25#[derive(Debug)]
26pub struct Connection<T: EventMessage> {
27    /// Queue of commands to send.
28    pending_commands: VecDeque<MethodCall>,
29    /// The websocket of the chromium instance
30    ws: WebSocketStream<ConnectStream>,
31    /// The identifier for a specific command
32    next_id: usize,
33    /// Whether the write buffer has unsent data that needs flushing.
34    needs_flush: bool,
35    /// The phantom marker.
36    _marker: PhantomData<T>,
37}
38
39lazy_static::lazy_static! {
40    /// Nagle's algorithm disabled?
41    static ref DISABLE_NAGLE: bool = match std::env::var("DISABLE_NAGLE") {
42        Ok(disable_nagle) => disable_nagle == "true",
43        _ => true
44    };
45    /// Websocket config defaults
46    static ref WEBSOCKET_DEFAULTS: bool = match std::env::var("WEBSOCKET_DEFAULTS") {
47        Ok(d) => d == "true",
48        _ => false
49    };
50}
51
52/// Default number of WebSocket connection retry attempts.
53pub const DEFAULT_CONNECTION_RETRIES: u32 = 4;
54
55/// Initial backoff delay between connection retries (in milliseconds).
56const INITIAL_BACKOFF_MS: u64 = 50;
57
58/// Maximum backoff delay between connection retries (in milliseconds).
59pub(crate) const MAX_BACKOFF_MS: u64 = 2_000;
60
61impl<T: EventMessage + Unpin> Connection<T> {
62    pub async fn connect(debug_ws_url: impl AsRef<str>) -> Result<Self> {
63        Self::connect_with_retries(debug_ws_url, DEFAULT_CONNECTION_RETRIES).await
64    }
65
66    pub async fn connect_with_retries(debug_ws_url: impl AsRef<str>, retries: u32) -> Result<Self> {
67        let mut config = WebSocketConfig::default();
68
69        // Cap the internal write buffer so a slow receiver cannot cause
70        // unbounded memory growth (default is usize::MAX).
71        config.max_write_buffer_size = 4 * 1024 * 1024;
72
73        if !*WEBSOCKET_DEFAULTS {
74            config.max_message_size = None;
75            config.max_frame_size = None;
76        }
77
78        let url = debug_ws_url.as_ref();
79        let use_uring = crate::uring_fs::is_enabled();
80        let mut last_err = None;
81
82        for attempt in 0..=retries {
83            let result = if use_uring {
84                Self::connect_uring(url, config).await
85            } else {
86                Self::connect_default(url, config).await
87            };
88
89            match result {
90                Ok(ws) => {
91                    return Ok(Self {
92                        pending_commands: Default::default(),
93                        ws,
94                        next_id: 0,
95                        needs_flush: false,
96                        _marker: Default::default(),
97                    });
98                }
99                Err(e) => {
100                    // Detect non-retriable errors early to avoid wasting time
101                    // on connections that will never succeed.
102                    let should_retry = match &e {
103                        // Connection refused — nothing is listening on this port.
104                        CdpError::Io(io_err)
105                            if io_err.kind() == std::io::ErrorKind::ConnectionRefused =>
106                        {
107                            false
108                        }
109                        // HTTP response to a WebSocket upgrade (e.g. wrong path
110                        // returns 404 / redirect) — retrying the same URL won't help.
111                        CdpError::Ws(tungstenite_err) => !matches!(
112                            tungstenite_err,
113                            tokio_tungstenite::tungstenite::Error::Http(_)
114                                | tokio_tungstenite::tungstenite::Error::HttpFormat(_)
115                        ),
116                        _ => true,
117                    };
118
119                    last_err = Some(e);
120
121                    if !should_retry {
122                        break;
123                    }
124
125                    if attempt < retries {
126                        let backoff_ms =
127                            (INITIAL_BACKOFF_MS * 3u64.saturating_pow(attempt)).min(MAX_BACKOFF_MS);
128                        tokio::time::sleep(std::time::Duration::from_millis(backoff_ms)).await;
129                    }
130                }
131            }
132        }
133
134        Err(last_err.unwrap_or_else(|| CdpError::msg("connection failed")))
135    }
136
137    /// Default path: let tokio-tungstenite handle TCP connect + WS handshake.
138    ///
139    /// For a plaintext `ws://` endpoint addressed by **hostname**, the resolution
140    /// is served from [`crate::dns`] (a short-TTL cache) and the handshake runs
141    /// over a pre-connected socket — the same shape the io_uring path uses — so
142    /// repeated (re)connects skip `getaddrinfo`. The original request is passed
143    /// to `client_async_with_config`, preserving the `Host` header. Everything
144    /// else — `wss://` (TLS), IP-literal hosts (no DNS), or any URL parse issue —
145    /// falls through to `connect_async_with_config` exactly as before, so TLS
146    /// handling and behavior are unchanged. The cache only maps host→IP; the port
147    /// and path always come from `url`, so it can never retarget a connection.
148    async fn connect_default(
149        url: &str,
150        config: WebSocketConfig,
151    ) -> Result<WebSocketStream<ConnectStream>> {
152        use tokio_tungstenite::tungstenite::client::IntoClientRequest;
153
154        if let Ok(request) = url.into_client_request() {
155            let uri = request.uri();
156            let is_plain_ws = uri.scheme_str() == Some("ws");
157            let host = uri.host().map(str::to_string);
158            let port = uri.port_u16().unwrap_or(9222);
159
160            if is_plain_ws && crate::dns::enabled() {
161                if let Some(h) = host {
162                    // Hostname only — IP literals need no DNS and are left to the
163                    // default path below.
164                    if !crate::dns::is_ip_literal(&h) {
165                        if let Ok(addrs) = crate::dns::resolve(&h, port).await {
166                            match tokio::net::TcpStream::connect(&addrs[..]).await {
167                                Ok(stream) => {
168                                    if *DISABLE_NAGLE {
169                                        let _ = stream.set_nodelay(true);
170                                    }
171                                    let (ws, _) = tokio_tungstenite::client_async_with_config(
172                                        request,
173                                        MaybeTlsStream::Plain(stream),
174                                        Some(config),
175                                    )
176                                    .await?;
177                                    return Ok(ws);
178                                }
179                                Err(e) => {
180                                    // A cached address that won't connect may be
181                                    // stale — drop it so the retry re-resolves.
182                                    crate::dns::invalidate(&h, port);
183                                    return Err(CdpError::Io(e));
184                                }
185                            }
186                        }
187                        // resolve failed → fall through to the default path, which
188                        // surfaces the real connection error.
189                    }
190                }
191            }
192        }
193
194        let (ws, _) =
195            tokio_tungstenite::connect_async_with_config(url, Some(config), *DISABLE_NAGLE).await?;
196        Ok(ws)
197    }
198
199    /// io_uring path: pre-connect the TCP socket via io_uring, then do WS
200    /// handshake over the pre-connected stream.
201    async fn connect_uring(
202        url: &str,
203        config: WebSocketConfig,
204    ) -> Result<WebSocketStream<ConnectStream>> {
205        use tokio_tungstenite::tungstenite::client::IntoClientRequest;
206
207        let request = url.into_client_request()?;
208        let host = request
209            .uri()
210            .host()
211            .ok_or_else(|| CdpError::msg("no host in CDP WebSocket URL"))?;
212        let port = request.uri().port_u16().unwrap_or(9222);
213
214        // Resolve host → SocketAddr (CDP is always localhost, so this is fast).
215        let addr_str = format!("{}:{}", host, port);
216        let addr: std::net::SocketAddr = match addr_str.parse() {
217            Ok(a) => a,
218            Err(_) => {
219                // Hostname needs DNS — fall back to default path.
220                return Self::connect_default(url, config).await;
221            }
222        };
223
224        // TCP connect via io_uring.
225        let std_stream = crate::uring_fs::tcp_connect(addr)
226            .await
227            .map_err(CdpError::Io)?;
228
229        // Set non-blocking + Nagle.
230        std_stream.set_nonblocking(true).map_err(CdpError::Io)?;
231        if *DISABLE_NAGLE {
232            let _ = std_stream.set_nodelay(true);
233        }
234
235        // Wrap in tokio TcpStream.
236        let tokio_stream = tokio::net::TcpStream::from_std(std_stream).map_err(CdpError::Io)?;
237
238        // WebSocket handshake over the pre-connected stream.
239        let (ws, _) = tokio_tungstenite::client_async_with_config(
240            request,
241            MaybeTlsStream::Plain(tokio_stream),
242            Some(config),
243        )
244        .await?;
245
246        Ok(ws)
247    }
248}
249
250impl<T: EventMessage> Connection<T> {
251    fn next_call_id(&mut self) -> CallId {
252        let id = CallId::new(self.next_id);
253        self.next_id = self.next_id.wrapping_add(1);
254        id
255    }
256
257    /// Queue in the command to send over the socket and return the id for this
258    /// command
259    pub fn submit_command(
260        &mut self,
261        method: MethodId,
262        session_id: Option<SessionId>,
263        params: serde_json::Value,
264    ) -> serde_json::Result<CallId> {
265        let id = self.next_call_id();
266        let call = MethodCall {
267            id,
268            method,
269            session_id: session_id.map(Into::into),
270            params,
271        };
272        self.pending_commands.push_back(call);
273        Ok(id)
274    }
275
276    /// Buffer all queued commands into the WebSocket sink, then flush once.
277    ///
278    /// This batches multiple CDP commands into a single TCP write instead of
279    /// flushing after every individual message.
280    fn start_send_next(&mut self, cx: &mut Context<'_>) -> Result<()> {
281        // Complete any pending flush from a previous poll first.
282        if self.needs_flush {
283            match self.ws.poll_flush_unpin(cx) {
284                Poll::Ready(Ok(())) => self.needs_flush = false,
285                Poll::Ready(Err(e)) => return Err(e.into()),
286                Poll::Pending => return Ok(()),
287            }
288        }
289
290        // Buffer as many queued commands as the sink will accept.
291        let mut sent_any = false;
292        while !self.pending_commands.is_empty() {
293            match self.ws.poll_ready_unpin(cx) {
294                Poll::Ready(Ok(())) => {
295                    let Some(cmd) = self.pending_commands.pop_front() else {
296                        break;
297                    };
298                    tracing::trace!("Sending {:?}", cmd);
299                    let msg = serde_json::to_string(&cmd)?;
300                    self.ws.start_send_unpin(msg.into())?;
301                    sent_any = true;
302                }
303                _ => break,
304            }
305        }
306
307        // Flush the entire batch in one write.
308        if sent_any {
309            match self.ws.poll_flush_unpin(cx) {
310                Poll::Ready(Ok(())) => {}
311                Poll::Ready(Err(e)) => return Err(e.into()),
312                Poll::Pending => self.needs_flush = true,
313            }
314        }
315
316        Ok(())
317    }
318}
319
320/// Capacity of the bounded channel feeding the background WS writer task.
321/// Large enough that bursts of CDP commands never block the handler, small
322/// enough to apply back-pressure before memory grows without bound.
323const WS_CMD_CHANNEL_CAPACITY: usize = 2048;
324
325/// Capacity of the bounded channel from the background WS reader task to
326/// the Handler. Keeps decoded CDP messages buffered so the reader task
327/// can keep reading the socket while the Handler processes a backlog;
328/// applies TCP-level back-pressure on Chrome when the Handler is slow
329/// (the reader awaits channel capacity, stops draining the socket).
330const WS_READ_CHANNEL_CAPACITY: usize = 1024;
331
332/// Maximum number of in-flight decodes the reader pipeline holds at
333/// once. While any of these is still running on the blocking pool,
334/// the reader can keep draining the socket and starting new decodes,
335/// up to this cap. Applies per-connection; the resulting decoded
336/// messages are emitted to the Handler in strict WS arrival order
337/// via a `FuturesOrdered` queue — no behavior change versus the
338/// serial loop, just concurrent execution of independent decodes.
339const MAX_IN_FLIGHT_DECODES: usize = 32;
340
341/// Payload size at/above which `decode_message` runs via
342/// `tokio::task::spawn_blocking` instead of inline on the reader task.
343///
344/// `serde_json::from_slice` is CPU-bound with no `.await` points, so
345/// a multi-MB payload can occupy one tokio worker thread for tens of
346/// milliseconds. Offloading to the blocking thread pool above a
347/// threshold keeps the reader task cooperatively yielding — critical
348/// on single-threaded runtimes where the reader shares its worker
349/// with the Handler, user tasks, and timers.
350///
351/// The threshold is chosen so that typical CDP traffic (events,
352/// responses, small evaluates) stays on the inline fast path and
353/// doesn't pay the ~10-30 µs `spawn_blocking` hand-off cost, while
354/// screenshot payloads, wide network events, and huge console
355/// payloads take the offloaded path.
356const LARGE_FRAME_THRESHOLD: usize = 256 * 1024; // 256 KiB
357
358/// Split parts returned by [`Connection::into_async`].
359#[derive(Debug)]
360pub struct AsyncConnection<T: EventMessage> {
361    /// Receive half for decoded CDP messages. Backed by a bounded mpsc
362    /// fed by a dedicated background reader task — decode runs on that
363    /// task, never on the Handler task, so large CDP responses (multi-MB
364    /// screenshots, huge event payloads) cannot stall the Handler's
365    /// event loop.
366    pub reader: WsReader<T>,
367    /// Sender half for submitting outgoing CDP commands.
368    pub cmd_tx: mpsc::Sender<MethodCall>,
369    /// Handle to the background writer task.
370    pub writer_handle: tokio::task::JoinHandle<Result<()>>,
371    /// Handle to the background reader task (reads + decodes WS frames).
372    pub reader_handle: tokio::task::JoinHandle<()>,
373    /// Next command-call-id counter (continue numbering from where Connection left off).
374    pub next_id: usize,
375}
376
377impl<T: EventMessage + Unpin + Send + 'static> Connection<T> {
378    /// Consume the connection and split into a background reader + writer
379    /// pair, exposing the Handler-facing ends via `AsyncConnection`.
380    ///
381    /// Two `tokio::spawn`'d tasks are created:
382    ///
383    /// * `ws_write_loop` — batches outgoing commands and flushes them in
384    ///   one write per wakeup.
385    /// * `ws_read_loop`  — reads WS frames, decodes them to typed
386    ///   `Message<T>`, and forwards them via a bounded mpsc to the
387    ///   Handler. Ping/pong/malformed frames are skipped on this task
388    ///   and never reach the Handler. Large-message decode (SerDe CPU
389    ///   work) runs here, **not** on the Handler task, so the Handler's
390    ///   poll loop never stalls for tens of milliseconds on a 10 MB
391    ///   screenshot response.
392    ///
393    /// The design uses only `tokio::spawn` (cooperative async) — no
394    /// `spawn_blocking` or blocking thread-pool — so it scales with the
395    /// tokio runtime's worker threads on multi-threaded runtimes, and
396    /// interleaves cleanly with the Handler task on single-threaded
397    /// runtimes.
398    pub fn into_async(self) -> AsyncConnection<T> {
399        let (ws_sink, ws_stream) = self.ws.split();
400        let (cmd_tx, cmd_rx) = mpsc::channel(WS_CMD_CHANNEL_CAPACITY);
401        let (msg_tx, msg_rx) = mpsc::channel::<Result<Box<Message<T>>>>(WS_READ_CHANNEL_CAPACITY);
402
403        // Replay any commands queued via `submit_command` before the
404        // split — most notably the boot `Target.setDiscoverTargets`
405        // pushed by `Handler::new`. Without this, real Chrome never
406        // emits `Target.targetCreated` and `new_page` hangs forever.
407        // Capacity is `WS_CMD_CHANNEL_CAPACITY`, so the boot batch fits
408        // easily — `try_send` would only fail in a pathological case
409        // and we'd lose those commands either way.
410        for call in self.pending_commands {
411            let _ = cmd_tx.try_send(call);
412        }
413
414        let writer_handle = tokio::spawn(ws_write_loop(ws_sink, cmd_rx));
415        let reader_handle = tokio::spawn(ws_read_loop::<T, _>(ws_stream, msg_tx));
416
417        let reader = WsReader {
418            rx: msg_rx,
419            _marker: PhantomData,
420        };
421
422        AsyncConnection {
423            reader,
424            cmd_tx,
425            writer_handle,
426            reader_handle,
427            next_id: self.next_id,
428        }
429    }
430}
431
432/// An entry in the reader's decode pipeline.
433///
434/// Small frames have been decoded inline on the reader task and sit
435/// in `Ready(Some(result))` waiting their turn to emit — zero
436/// allocation beyond the `Option`. Large frames were offloaded to
437/// `tokio::task::spawn_blocking`, so their entry is the
438/// corresponding `JoinHandle`.
439///
440/// A single concrete enum means `FuturesOrdered<InFlightDecode<T>>`
441/// can hold either kind without `Box<dyn Future>`, keeping the
442/// pipeline cost-proportional to the workload.
443enum InFlightDecode<T: EventMessage + Send + 'static> {
444    /// Small-frame fast path: already decoded inline. `take()`'d
445    /// exactly once when `FuturesOrdered` first polls it to Ready.
446    Ready(Option<Result<Box<Message<T>>>>),
447    /// Large-frame path: decoding on the blocking thread pool.
448    Blocking(tokio::task::JoinHandle<Result<Box<Message<T>>>>),
449}
450
451impl<T: EventMessage + Send + 'static> Future for InFlightDecode<T> {
452    type Output = Result<Box<Message<T>>>;
453
454    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
455        // Safety: both variants are structurally pin-agnostic —
456        // `Option<Result<..>>` is `Unpin`, and `tokio::task::JoinHandle`
457        // is documented as `Unpin`. So we can project out a `&mut`
458        // without unsafe.
459        match self.get_mut() {
460            InFlightDecode::Ready(slot) => Poll::Ready(
461                slot.take()
462                    .expect("InFlightDecode::Ready polled after completion"),
463            ),
464            InFlightDecode::Blocking(handle) => match Pin::new(handle).poll(cx) {
465                Poll::Ready(Ok(res)) => Poll::Ready(res),
466                Poll::Ready(Err(join_err)) => Poll::Ready(Err(CdpError::msg(format!(
467                    "WS decode blocking task join error: {join_err}"
468                )))),
469                Poll::Pending => Poll::Pending,
470            },
471        }
472    }
473}
474
475/// Emit a single decoded-frame result to the Handler, logging parse
476/// errors. Returns `true` if the channel is still open, `false` if
477/// the Handler has dropped the receiver (caller should exit).
478async fn emit_decoded<T>(
479    tx: &mpsc::Sender<Result<Box<Message<T>>>>,
480    res: Result<Box<Message<T>>>,
481) -> bool
482where
483    T: EventMessage + Send + 'static,
484{
485    match res {
486        Ok(msg) => tx.send(Ok(msg)).await.is_ok(),
487        Err(err) => {
488            tracing::debug!(
489                target: "chromiumoxide::conn::raw_ws::parse_errors",
490                "Dropping malformed WS frame: {err}",
491            );
492            true
493        }
494    }
495}
496
497/// Background task that reads frames from the WebSocket, decodes them to
498/// typed CDP `Message<T>`, and forwards them to the Handler over a
499/// bounded mpsc.
500///
501/// Runs on a `tokio::spawn`'d task. Small-to-medium frames are
502/// decoded inline (fast path); payloads at or above
503/// [`LARGE_FRAME_THRESHOLD`] are offloaded to `spawn_blocking` so
504/// multi-MB deserialization doesn't monopolise a tokio worker
505/// thread — especially important on single-threaded runtimes where
506/// the reader, Handler, and user tasks share the same worker.
507///
508/// Flow per frame:
509///
510/// * `Text` / `Binary` → [`decode_ws_frame`]; decoded `Ok(msg)` is
511///   sent to the Handler. Decode errors are logged and the frame is
512///   dropped (same behavior as the legacy inline decode path).
513/// * `Close` → loop exits cleanly, dropping `tx`. The Handler's
514///   `next_message().await` returns `None` on the next call.
515/// * `Ping` / `Pong` / unexpected frame types → skipped silently; they
516///   never cross the channel to the Handler.
517/// * Transport error → forwarded as `Err(CdpError::Ws(..))`, then the
518///   loop exits (the WS half is considered dead after an error).
519///
520/// Back-pressure: the outbound `tx` is bounded. If the Handler is busy
521/// and the channel fills, `tx.send(..).await` parks this task, which
522/// stops draining the WS socket. TCP flow control then applies
523/// back-pressure to Chrome instead of letting memory grow without bound.
524async fn ws_read_loop<T, S>(mut stream: S, tx: mpsc::Sender<Result<Box<Message<T>>>>)
525where
526    T: EventMessage + Send + 'static,
527    S: Stream<Item = std::result::Result<WsMessage, tokio_tungstenite::tungstenite::Error>> + Unpin,
528{
529    // Pipeline of decodes in strict arrival order. Small-frame decodes
530    // are produced inline (zero allocation, borrowing the frame body);
531    // large-frame decodes are offloaded to `spawn_blocking`. Both
532    // variants share a single concrete `InFlightDecode<T>` so the
533    // queue avoids `Box<dyn Future>` overhead.
534    let mut in_flight: FuturesOrdered<InFlightDecode<T>> = FuturesOrdered::new();
535
536    // Shutdown state. When the stream signals `Close`, transport
537    // error, or end-of-stream, we stop reading new frames but keep
538    // running the select loop so the emit arm can flush any still
539    // in-flight decodes *interleaved with* whatever else the runtime
540    // is doing. A pending transport error is surfaced to the Handler
541    // only after the in-order flush completes.
542    let mut stream_terminated = false;
543    let mut pending_err: Option<CdpError> = None;
544
545    loop {
546        tokio::select! {
547            // Bias: emit already-ready decodes before reading more
548            // frames. Keeps the pipeline small in the steady state
549            // while still allowing concurrency under burst, and —
550            // critically during shutdown — drains the pipeline one
551            // ready item at a time inside the select loop instead
552            // of blocking in a dedicated drain helper.
553            biased;
554
555            // Emit the head of the pipeline as soon as it is ready.
556            // `FuturesOrdered::next` preserves submit order, so
557            // downstream delivery is byte-identical to the serial
558            // loop's ordering guarantee.
559            Some(res) = in_flight.next(), if !in_flight.is_empty() => {
560                if !emit_decoded(&tx, res).await {
561                    return;
562                }
563            }
564
565            // Read the next frame if the pipeline has capacity and
566            // the stream hasn't terminated. Disabled once the stream
567            // signals end (Close / None / Err) so subsequent loop
568            // iterations only do emit work.
569            maybe_frame = stream.next(),
570                if !stream_terminated && in_flight.len() < MAX_IN_FLIGHT_DECODES =>
571            {
572                match maybe_frame {
573                    Some(Ok(WsMessage::Text(text))) => {
574                        // Zero-copy enqueue. The small-frame fast
575                        // path decodes inline *now* (borrowing
576                        // `text`, keeping the `raw_text_for_logging`
577                        // preview); the large-frame path moves the
578                        // `Utf8Bytes` (`Send + 'static`) directly
579                        // into `spawn_blocking` without an
580                        // intermediate allocation.
581                        if text.len() >= LARGE_FRAME_THRESHOLD {
582                            in_flight.push_back(InFlightDecode::Blocking(
583                                tokio::task::spawn_blocking(move || {
584                                    decode_message::<T>(text.as_bytes(), None)
585                                }),
586                            ));
587                        } else {
588                            let res = decode_message::<T>(text.as_bytes(), Some(&text));
589                            in_flight.push_back(InFlightDecode::Ready(Some(res)));
590                        }
591                    }
592                    Some(Ok(WsMessage::Binary(buf))) => {
593                        // Same shape as Text: move `Bytes`
594                        // (`Send + 'static`) into `spawn_blocking`
595                        // for large payloads, decode inline for
596                        // small ones.
597                        if buf.len() >= LARGE_FRAME_THRESHOLD {
598                            in_flight.push_back(InFlightDecode::Blocking(
599                                tokio::task::spawn_blocking(move || {
600                                    decode_message::<T>(&buf, None)
601                                }),
602                            ));
603                        } else {
604                            let res = decode_message::<T>(&buf, None);
605                            in_flight.push_back(InFlightDecode::Ready(Some(res)));
606                        }
607                    }
608                    Some(Ok(WsMessage::Close(_))) => {
609                        stream_terminated = true;
610                    }
611                    Some(Ok(WsMessage::Ping(_))) | Some(Ok(WsMessage::Pong(_))) => {}
612                    Some(Ok(msg)) => {
613                        tracing::debug!(
614                            target: "chromiumoxide::conn::raw_ws::parse_errors",
615                            "Unexpected WS message type: {:?}",
616                            msg
617                        );
618                    }
619                    Some(Err(err)) => {
620                        // Defer the error until after the already
621                        // in-flight decodes have emitted — preserves
622                        // the ordering contract that callers see
623                        // frames up to the failure point before the
624                        // error itself.
625                        stream_terminated = true;
626                        pending_err = Some(CdpError::Ws(err));
627                    }
628                    None => {
629                        // Stream ended (connection closed without a
630                        // `Close` frame). No more input, but
631                        // in_flight may still hold pending decodes.
632                        stream_terminated = true;
633                    }
634                }
635            }
636
637            // Both arms disabled: `in_flight` is empty AND
638            // `stream_terminated`. We have nothing more to do.
639            else => {
640                break;
641            }
642        }
643    }
644
645    if let Some(err) = pending_err {
646        let _ = tx.send(Err(err)).await;
647    }
648}
649
650/// Background task that batches and flushes outgoing CDP commands.
651async fn ws_write_loop(
652    mut sink: SplitSink<WebSocketStream<ConnectStream>, WsMessage>,
653    mut rx: mpsc::Receiver<MethodCall>,
654) -> Result<()> {
655    while let Some(call) = rx.recv().await {
656        let msg = crate::serde_json::to_string(&call)?;
657        sink.feed(WsMessage::Text(msg.into()))
658            .await
659            .map_err(CdpError::Ws)?;
660
661        // Batch: drain all buffered commands without waiting.
662        while let Ok(call) = rx.try_recv() {
663            let msg = crate::serde_json::to_string(&call)?;
664            sink.feed(WsMessage::Text(msg.into()))
665                .await
666                .map_err(CdpError::Ws)?;
667        }
668
669        // Flush the entire batch in one write.
670        sink.flush().await.map_err(CdpError::Ws)?;
671    }
672
673    // Cmd channel closed → the Handler is shutting down. Send a graceful
674    // WebSocket Close frame so the remote endpoint (esp. for
675    // `Browser::connect()` to a remote DevTools URL, where there is no
676    // child process whose exit closes the socket) tears the connection
677    // down promptly instead of waiting for an idle timeout. Errors are
678    // expected during shutdown (e.g. `AlreadyClosed` if Chrome closed
679    // first) and are intentionally ignored.
680    let _ = sink.close().await;
681    Ok(())
682}
683
684/// Handler-facing read half of the split WebSocket connection.
685///
686/// Decoded CDP messages are produced by a dedicated background task
687/// (see [`ws_read_loop`]) and forwarded over a bounded mpsc. `WsReader`
688/// itself is a thin `Receiver` wrapper — calling `next_message()` does
689/// a single `rx.recv().await` with no per-message decoding work on the
690/// caller's task. This keeps the Handler's poll loop free of CPU-bound
691/// deserialize time, which matters for large (multi-MB) CDP responses
692/// such as screenshots and wide-header network events.
693#[derive(Debug)]
694pub struct WsReader<T: EventMessage> {
695    rx: mpsc::Receiver<Result<Box<Message<T>>>>,
696    _marker: PhantomData<T>,
697}
698
699impl<T: EventMessage + Unpin> WsReader<T> {
700    /// Read the next CDP message from the WebSocket.
701    ///
702    /// Returns `None` when the background reader task has exited
703    /// (connection closed or sender dropped). This call does only a
704    /// channel `recv` — the actual WS read + JSON decode happens on
705    /// the background `ws_read_loop` task.
706    pub async fn next_message(&mut self) -> Option<Result<Box<Message<T>>>> {
707        self.rx.recv().await
708    }
709}
710
711impl<T: EventMessage + Unpin> Stream for Connection<T> {
712    type Item = Result<Box<Message<T>>>;
713
714    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
715        let pin = self.get_mut();
716
717        // Send and flush outgoing messages
718        if let Err(err) = pin.start_send_next(cx) {
719            return Poll::Ready(Some(Err(err)));
720        }
721
722        // Read from the websocket, skipping non-data frames (pings,
723        // pongs, malformed messages) without yielding back to the
724        // executor.  This avoids a full round-trip per skipped frame.
725        //
726        // Cap consecutive skips so a flood of non-data frames (many
727        // pings, malformed/unexpected types) cannot starve the
728        // runtime — yield Pending after `MAX_SKIPS_PER_POLL` and
729        // self-wake so we resume on the next tick.
730        const MAX_SKIPS_PER_POLL: u32 = 16;
731        let mut skips: u32 = 0;
732        loop {
733            match ready!(pin.ws.poll_next_unpin(cx)) {
734                Some(Ok(WsMessage::Text(text))) => {
735                    match decode_message::<T>(text.as_bytes(), Some(&text)) {
736                        Ok(msg) => return Poll::Ready(Some(Ok(msg))),
737                        Err(err) => {
738                            tracing::debug!(
739                                target: "chromiumoxide::conn::raw_ws::parse_errors",
740                                "Dropping malformed text WS frame: {err}",
741                            );
742                            skips += 1;
743                        }
744                    }
745                }
746                Some(Ok(WsMessage::Binary(buf))) => match decode_message::<T>(&buf, None) {
747                    Ok(msg) => return Poll::Ready(Some(Ok(msg))),
748                    Err(err) => {
749                        tracing::debug!(
750                            target: "chromiumoxide::conn::raw_ws::parse_errors",
751                            "Dropping malformed binary WS frame: {err}",
752                        );
753                        skips += 1;
754                    }
755                },
756                Some(Ok(WsMessage::Close(_))) => return Poll::Ready(None),
757                Some(Ok(WsMessage::Ping(_))) | Some(Ok(WsMessage::Pong(_))) => {
758                    skips += 1;
759                }
760                Some(Ok(msg)) => {
761                    tracing::debug!(
762                        target: "chromiumoxide::conn::raw_ws::parse_errors",
763                        "Unexpected WS message type: {:?}",
764                        msg
765                    );
766                    skips += 1;
767                }
768                Some(Err(err)) => return Poll::Ready(Some(Err(CdpError::Ws(err)))),
769                None => return Poll::Ready(None),
770            }
771
772            if skips >= MAX_SKIPS_PER_POLL {
773                cx.waker().wake_by_ref();
774                return Poll::Pending;
775            }
776        }
777    }
778}
779
780/// Shared decode path for both text and binary WS frames.
781/// `raw_text_for_logging` is only provided for textual frames so we can log the original
782/// payload on parse failure if desired.
783#[cfg(not(feature = "serde_stacker"))]
784fn decode_message<T: EventMessage>(
785    bytes: &[u8],
786    raw_text_for_logging: Option<&str>,
787) -> Result<Box<Message<T>>> {
788    match serde_json::from_slice::<Box<Message<T>>>(bytes) {
789        Ok(msg) => {
790            tracing::trace!("Received {:?}", msg);
791            Ok(msg)
792        }
793        Err(err) => {
794            if let Some(txt) = raw_text_for_logging {
795                let preview = &txt[..txt.len().min(512)];
796                tracing::debug!(
797                    target: "chromiumoxide::conn::raw_ws::parse_errors",
798                    msg_len = txt.len(),
799                    "Skipping unrecognized WS message {err} preview={preview}",
800                );
801            } else {
802                tracing::debug!(
803                    target: "chromiumoxide::conn::raw_ws::parse_errors",
804                    "Skipping unrecognized binary WS message {err}",
805                );
806            }
807            Err(err.into())
808        }
809    }
810}
811
812/// Shared decode path for both text and binary WS frames.
813/// `raw_text_for_logging` is only provided for textual frames so we can log the original
814/// payload on parse failure if desired.
815#[cfg(feature = "serde_stacker")]
816fn decode_message<T: EventMessage>(
817    bytes: &[u8],
818    raw_text_for_logging: Option<&str>,
819) -> Result<Box<Message<T>>> {
820    use serde::Deserialize;
821    let mut de = serde_json::Deserializer::from_slice(bytes);
822
823    de.disable_recursion_limit();
824
825    let de = serde_stacker::Deserializer::new(&mut de);
826
827    match Box::<Message<T>>::deserialize(de) {
828        Ok(msg) => {
829            tracing::trace!("Received {:?}", msg);
830            Ok(msg)
831        }
832        Err(err) => {
833            if let Some(txt) = raw_text_for_logging {
834                let preview = &txt[..txt.len().min(512)];
835                tracing::debug!(
836                    target: "chromiumoxide::conn::raw_ws::parse_errors",
837                    msg_len = txt.len(),
838                    "Skipping unrecognized WS message {err} preview={preview}",
839                );
840            } else {
841                tracing::debug!(
842                    target: "chromiumoxide::conn::raw_ws::parse_errors",
843                    "Skipping unrecognized binary WS message {err}",
844                );
845            }
846            Err(err.into())
847        }
848    }
849}
850
851#[cfg(test)]
852mod ws_read_loop_tests {
853    //! Unit tests for the `ws_read_loop` background reader task.
854    //!
855    //! These tests feed a synthetic `Stream<Item = Result<WsMessage, _>>`
856    //! into `ws_read_loop` — no real WebSocket, no Chrome — and observe
857    //! what comes out the other side of the mpsc channel.
858    //!
859    //! The properties under test are the ones that make the reader-task
860    //! decoupling safe: FIFO ordering, no-deadlock on a bounded channel
861    //! under back-pressure, silent drop of non-data frames, graceful
862    //! transport-error propagation, and clean exit on `Close`.
863    //!
864    //! The typed events are `chromiumoxide_cdp::cdp::CdpEventMessage` —
865    //! the same instantiation the real Handler uses — so these tests
866    //! exercise the actual decode path (`serde_json::from_slice`), not
867    //! a simplified fake.
868    use super::*;
869    use chromiumoxide_cdp::cdp::CdpEventMessage;
870    use chromiumoxide_types::CallId;
871    use futures_util::stream;
872    use tokio::sync::mpsc;
873    use tokio_tungstenite::tungstenite::Message as WsMessage;
874
875    /// Build a CDP `Response` WS frame as text — the smallest valid CDP
876    /// message. `id` tags the frame for ordering assertions.
877    fn response_frame(id: u64) -> WsMessage {
878        WsMessage::Text(
879            format!(r#"{{"id":{id},"result":{{"ok":true}}}}"#)
880                .to_string()
881                .into(),
882        )
883    }
884
885    /// Build a frame far larger than a typical socket chunk, to exercise
886    /// the "large message" path that motivated this refactor. The blob
887    /// field pushes serde_json through a big allocation even though the
888    /// envelope is tiny.
889    fn large_response_frame(id: u64, blob_bytes: usize) -> WsMessage {
890        let blob = "x".repeat(blob_bytes);
891        WsMessage::Text(
892            format!(r#"{{"id":{id},"result":{{"blob":"{blob}"}}}}"#)
893                .to_string()
894                .into(),
895        )
896    }
897
898    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
899    async fn forwards_messages_in_stream_order() {
900        let frames = vec![
901            Ok(response_frame(1)),
902            Ok(response_frame(2)),
903            Ok(response_frame(3)),
904        ];
905        let stream = stream::iter(frames);
906        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(8);
907        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
908
909        for expected in [1u64, 2, 3] {
910            let msg = rx.recv().await.expect("msg").expect("decode ok");
911            if let Message::Response(resp) = *msg {
912                assert_eq!(resp.id, CallId::new(expected as usize));
913            } else {
914                panic!("expected Response");
915            }
916        }
917        assert!(rx.recv().await.is_none(), "channel must close on EOF");
918        task.await.expect("reader task join");
919    }
920
921    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
922    async fn pings_and_pongs_never_reach_the_handler() {
923        let frames = vec![
924            Ok(WsMessage::Ping(vec![1, 2, 3].into())),
925            Ok(response_frame(7)),
926            Ok(WsMessage::Pong(vec![].into())),
927            Ok(response_frame(8)),
928        ];
929        let stream = stream::iter(frames);
930        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(8);
931        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
932
933        for expected in [7u64, 8] {
934            let msg = rx.recv().await.expect("msg").expect("decode ok");
935            if let Message::Response(resp) = *msg {
936                assert_eq!(resp.id, CallId::new(expected as usize));
937            }
938        }
939        assert!(rx.recv().await.is_none());
940        task.await.expect("reader task join");
941    }
942
943    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
944    async fn malformed_frames_do_not_block_subsequent_valid_frames() {
945        let frames = vec![
946            Ok(WsMessage::Text("{not valid json".to_string().into())),
947            Ok(response_frame(42)),
948        ];
949        let stream = stream::iter(frames);
950        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(8);
951        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
952
953        let msg = rx.recv().await.expect("msg").expect("decode ok");
954        if let Message::Response(resp) = *msg {
955            assert_eq!(resp.id, CallId::new(42));
956        }
957        assert!(rx.recv().await.is_none());
958        task.await.expect("reader task join");
959    }
960
961    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
962    async fn close_frame_terminates_the_reader() {
963        let frames = vec![
964            Ok(response_frame(1)),
965            Ok(WsMessage::Close(None)),
966            Ok(response_frame(2)), // unreachable after Close
967        ];
968        let stream = stream::iter(frames);
969        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(8);
970        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
971
972        let msg = rx.recv().await.expect("msg").expect("decode ok");
973        if let Message::Response(resp) = *msg {
974            assert_eq!(resp.id, CallId::new(1));
975        }
976        assert!(
977            rx.recv().await.is_none(),
978            "reader must exit on Close; frames after Close must not appear"
979        );
980        task.await.expect("reader task join");
981    }
982
983    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
984    async fn transport_error_is_forwarded_once_then_reader_exits() {
985        let frames = vec![
986            Ok(response_frame(1)),
987            Err(tokio_tungstenite::tungstenite::Error::ConnectionClosed),
988            Ok(response_frame(2)),
989        ];
990        let stream = stream::iter(frames);
991        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(8);
992        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
993
994        let msg = rx.recv().await.expect("msg").expect("ok");
995        assert!(matches!(*msg, Message::Response(_)));
996        match rx.recv().await {
997            Some(Err(CdpError::Ws(_))) => {}
998            other => panic!("expected forwarded Ws error, got {other:?}"),
999        }
1000        assert!(rx.recv().await.is_none());
1001        task.await.expect("reader task join");
1002    }
1003
1004    /// Back-pressure property: with the smallest possible channel and
1005    /// many frames, the reader task awaits capacity after each send and
1006    /// never deadlocks. This is the core "no deadlock" proof for the
1007    /// new design — if the reader held anything across its `.await` that
1008    /// the consumer needed, the consumer's `recv().await` would block
1009    /// forever. Completion under a 5s watchdog proves it doesn't.
1010    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1011    async fn bounded_channel_does_not_deadlock_under_backpressure() {
1012        const N: u64 = 512;
1013        let frames: Vec<_> = (1..=N).map(|id| Ok(response_frame(id))).collect();
1014        let stream = stream::iter(frames);
1015
1016        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(1);
1017        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
1018
1019        let deadline = std::time::Duration::from_secs(5);
1020        let collected = tokio::time::timeout(deadline, async {
1021            let mut seen = 0u64;
1022            while let Some(frame) = rx.recv().await {
1023                let msg = frame.expect("decode ok");
1024                if let Message::Response(resp) = *msg {
1025                    seen += 1;
1026                    assert_eq!(
1027                        resp.id,
1028                        CallId::new(seen as usize),
1029                        "back-pressure must preserve FIFO order"
1030                    );
1031                }
1032            }
1033            seen
1034        })
1035        .await
1036        .expect("reader must make forward progress despite cap-1 back-pressure");
1037
1038        assert_eq!(collected, N, "all frames must arrive");
1039        task.await.expect("reader task join");
1040    }
1041
1042    /// Large message (>1 MB) is decoded correctly on the background
1043    /// task. This is the specific scenario the reader-task refactor
1044    /// was built for — we don't measure time here (benches cover that),
1045    /// we just prove the end-to-end path works without corruption or
1046    /// deadlock.
1047    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1048    async fn large_message_decodes_without_corruption() {
1049        let big = 2 * 1024 * 1024; // 2 MB payload
1050        let frames = vec![Ok(large_response_frame(100, big)), Ok(response_frame(101))];
1051        let stream = stream::iter(frames);
1052        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(4);
1053        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
1054
1055        let first = rx.recv().await.expect("msg").expect("ok");
1056        if let Message::Response(resp) = *first {
1057            assert_eq!(resp.id, CallId::new(100));
1058        }
1059        let second = rx.recv().await.expect("msg").expect("ok");
1060        if let Message::Response(resp) = *second {
1061            assert_eq!(resp.id, CallId::new(101));
1062        }
1063        assert!(rx.recv().await.is_none());
1064        task.await.expect("reader task join");
1065    }
1066
1067    /// FIFO ordering under the pipelined reader when large-frame
1068    /// decodes run in parallel via `spawn_blocking`.
1069    ///
1070    /// This test submits an interleaved sequence of large and small
1071    /// frames. Large frames take the `spawn_blocking` path (decode
1072    /// on the blocking pool, variable completion order); small
1073    /// frames take the inline path (decode immediately). The
1074    /// pipeline's `FuturesOrdered` queue must emit them to the
1075    /// Handler in strict arrival order regardless of which
1076    /// blocking-pool thread finishes first.
1077    ///
1078    /// If the ordering guarantee were ever broken — e.g. by
1079    /// accidentally swapping `FuturesOrdered` for `FuturesUnordered`
1080    /// — id sequence checks here would catch it immediately.
1081    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1082    async fn pipelined_large_and_small_frames_keep_fifo_order() {
1083        let big = 2 * 1024 * 1024; // 2 MB payload — forces spawn_blocking
1084        let frames = vec![
1085            Ok(large_response_frame(1, big)),
1086            Ok(response_frame(2)),
1087            Ok(response_frame(3)),
1088            Ok(large_response_frame(4, big)),
1089            Ok(response_frame(5)),
1090            Ok(large_response_frame(6, big)),
1091            Ok(response_frame(7)),
1092            Ok(response_frame(8)),
1093        ];
1094        let expected: Vec<usize> = (1..=8).collect();
1095
1096        let stream = stream::iter(frames);
1097        let (tx, mut rx) = mpsc::channel::<Result<Box<Message<CdpEventMessage>>>>(16);
1098        let task = tokio::spawn(ws_read_loop::<CdpEventMessage, _>(stream, tx));
1099
1100        let deadline = std::time::Duration::from_secs(10);
1101        let observed = tokio::time::timeout(deadline, async {
1102            let mut ids = Vec::with_capacity(expected.len());
1103            while let Some(frame) = rx.recv().await {
1104                let msg = frame.expect("decode ok");
1105                if let Message::Response(resp) = *msg {
1106                    ids.push(CallId::new(ids.len() + 1));
1107                    assert_eq!(
1108                        resp.id,
1109                        *ids.last().unwrap(),
1110                        "pipelined reader must emit frames in strict arrival order \
1111                         regardless of per-frame decode latency"
1112                    );
1113                }
1114            }
1115            ids
1116        })
1117        .await
1118        .expect("pipelined reader should make forward progress within 10s");
1119
1120        assert_eq!(
1121            observed.len(),
1122            expected.len(),
1123            "all {} frames must reach the Handler",
1124            expected.len()
1125        );
1126        task.await.expect("reader task join");
1127    }
1128}