use std::collections::HashMap;
use std::process::Child;
use std::sync::atomic::{AtomicU64, AtomicU8, Ordering};
use std::sync::Arc;
use std::time::Duration;
use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use serde::{de::DeserializeOwned, Serialize};
use serde_json::{json, Value};
use tokio::net::TcpStream;
use tokio::sync::{broadcast, mpsc, oneshot, Mutex};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream};
mod interception;
mod process;
mod reader;
use super::network_idle::{self, IdleRoutes, IdleState};
use crate::error::{Error, Result};
pub use interception::RequestPause;
use interception::{
request_interception_command, FetchInterceptor, RequestRoute, RequestRoutes, ResponseRoute,
ResponseRoutes,
};
pub use process::launch_chrome;
use process::ChildCleanupGuard;
pub(crate) use process::{launch_chrome_with_profile_dir, stop_child};
type WsStream = WebSocketStream<MaybeTlsStream<TcpStream>>;
type WsSink = SplitSink<WsStream, Message>;
type WsSource = SplitStream<WsStream>;
type SharedSink = Arc<Mutex<WsSink>>;
type PendingMap = std::sync::Mutex<HashMap<u64, PendingRequest>>;
type PendingRequest = oneshot::Sender<Result<Value>>;
fn lock_pending(pending: &PendingMap) -> std::sync::MutexGuard<'_, HashMap<u64, PendingRequest>> {
match pending.lock() {
Ok(guard) => guard,
Err(poisoned) => {
tracing::error!("Pending CDP request map was poisoned; recovering its contents");
poisoned.into_inner()
}
}
}
#[derive(Default)]
pub struct CommandDispatch {
state: AtomicU8,
}
impl CommandDispatch {
pub fn new() -> Self {
Self::default()
}
pub fn may_have_been_sent(&self) -> bool {
self.state.load(Ordering::Acquire) == 2
}
fn claim(&self) -> Result<()> {
self.state
.compare_exchange(0, 1, Ordering::AcqRel, Ordering::Acquire)
.map(|_| ())
.map_err(|_| Error::transport("CommandDispatch token already used"))
}
}
struct PendingRegistration<'a> {
pending: &'a PendingMap,
id: u64,
}
impl Drop for PendingRegistration<'_> {
fn drop(&mut self) {
lock_pending(self.pending).remove(&self.id);
}
}
fn fail_pending(pending: &PendingMap, context: &str) -> usize {
let mut requests = lock_pending(pending);
let count = requests.len();
for (_id, sender) in requests.drain() {
drop(sender.send(Err(Error::transport(context))));
}
count
}
const EVENT_CHANNEL_CAP: usize = 1024;
fn is_blocked(method: &str) -> bool {
matches!(
method,
"Runtime.enable"
| "Runtime.disable"
| "HeapProfiler.enable"
| "HeapProfiler.disable"
| "Profiler.enable"
| "Profiler.disable"
| "Debugger.enable"
| "Debugger.disable"
| "Console.enable"
| "Console.disable"
)
}
fn is_risky(method: &str) -> bool {
matches!(
method,
"Emulation.setUserAgentOverride"
| "Emulation.setTimezoneOverride"
| "Emulation.setDeviceMetricsOverride"
| "Page.setBypassCSP"
)
}
pub struct Transport {
child: Option<Mutex<Child>>,
writer: SharedSink,
next_id: AtomicU64,
pending: Arc<PendingMap>,
event_tx: broadcast::Sender<CdpMessage>,
response_routes: ResponseRoutes,
request_routes: RequestRoutes,
idle_routes: IdleRoutes,
request_base_auth: bool,
request_base_strip: bool,
event_rx: Mutex<broadcast::Receiver<CdpMessage>>,
cmd_timeout: std::time::Duration,
reader_handle: Option<tokio::task::JoinHandle<()>>,
filter_cdp: bool,
}
#[derive(Debug, Clone)]
pub enum CdpMessage {
Response {
id: u64,
result: Result<Value>,
},
Event {
method: String,
params: Value,
session_id: Option<String>,
},
}
impl Transport {
pub fn is_closed(&self) -> bool {
self.reader_handle
.as_ref()
.is_none_or(|reader| reader.is_finished())
}
pub async fn new(child: Child, ws_url: &str) -> Result<Self> {
Self::new_with_options(child, ws_url, None, 30, false).await
}
async fn ws_connect(ws_url: &str) -> Result<WsStream> {
if !ws_url.starts_with("ws://") {
return Err(Error::transport(format!(
"Invalid WebSocket URL (expected ws://...): {}",
ws_url
)));
}
let (stream, _resp) = connect_async(ws_url)
.await
.map_err(|e| Error::transport(format!("WebSocket connect failed: {}", e)))?;
tracing::debug!("WebSocket connected to {}", ws_url);
Ok(stream)
}
fn build(
child: Option<Child>,
stream: WsStream,
proxy_auth: Option<(String, String)>,
cdp_timeout_secs: u64,
filter_cdp: bool,
strip_x_client_data: bool,
) -> Self {
let cmd_timeout = std::time::Duration::from_secs(cdp_timeout_secs);
tracing::debug!("CDP timeout set to {}s", cdp_timeout_secs);
let (sink, source) = stream.split();
let writer: SharedSink = Arc::new(Mutex::new(sink));
let pending: Arc<PendingMap> = Arc::new(std::sync::Mutex::new(HashMap::new()));
let (event_tx, event_rx) = broadcast::channel(EVENT_CHANNEL_CAP);
let pending_clone = Arc::clone(&pending);
let event_tx_clone = event_tx.clone();
let reader_writer = Arc::clone(&writer);
let response_routes = Arc::new(std::sync::Mutex::new(HashMap::new()));
let reader_responses = Arc::clone(&response_routes);
let request_routes = Arc::new(std::sync::Mutex::new(HashMap::new()));
let reader_requests = Arc::clone(&request_routes);
let idle_routes = Arc::new(std::sync::Mutex::new(HashMap::new()));
let reader_idle = Arc::clone(&idle_routes);
let request_base_auth = proxy_auth.is_some();
let reader_handle = tokio::spawn(async move {
Self::reader_loop(
source,
pending_clone,
event_tx_clone,
reader_writer,
reader_idle,
FetchInterceptor {
responses: reader_responses,
requests: reader_requests,
proxy_auth,
strip: strip_x_client_data,
},
)
.await;
});
Self {
child: child.map(Mutex::new),
writer,
next_id: AtomicU64::new(1),
pending,
event_tx,
event_rx: Mutex::new(event_rx),
request_routes,
response_routes,
idle_routes,
request_base_auth,
request_base_strip: strip_x_client_data,
cmd_timeout,
reader_handle: Some(reader_handle),
filter_cdp,
}
}
pub async fn new_with_options(
child: Child,
ws_url: &str,
proxy_auth: Option<(String, String)>,
cdp_timeout_secs: u64,
strip_x_client_data: bool,
) -> Result<Self> {
let mut child = ChildCleanupGuard::new(child);
let stream = Self::ws_connect(ws_url).await?;
Ok(Self::build(
Some(child.take()?),
stream,
proxy_auth,
cdp_timeout_secs,
true,
strip_x_client_data,
))
}
pub async fn connect(ws_url: &str, cdp_timeout_secs: u64) -> Result<Self> {
let stream = Self::ws_connect(ws_url).await?;
Ok(Self::build(
None,
stream,
None,
cdp_timeout_secs,
true,
false,
))
}
pub async fn connect_with_options(
ws_url: &str,
cdp_timeout_secs: u64,
filter_cdp: bool,
strip_x_client_data: bool,
) -> Result<Self> {
let stream = Self::ws_connect(ws_url).await?;
Ok(Self::build(
None,
stream,
None,
cdp_timeout_secs,
filter_cdp,
strip_x_client_data,
))
}
async fn send_impl<C, R>(
&self,
session_id: Option<&str>,
method: &str,
params: &C,
dispatch: Option<&CommandDispatch>,
) -> Result<R>
where
C: Serialize,
R: DeserializeOwned,
{
tokio::time::timeout(
self.cmd_timeout,
self.send_inner(session_id, method, params, dispatch),
)
.await
.map_err(|_| Error::Timeout(format!("CDP command {method} exceeded its deadline")))?
}
async fn send_inner<C, R>(
&self,
session_id: Option<&str>,
method: &str,
params: &C,
dispatch: Option<&CommandDispatch>,
) -> Result<R>
where
C: Serialize,
R: DeserializeOwned,
{
if let Some(dispatch) = dispatch {
dispatch.claim()?;
}
if self.filter_cdp && is_blocked(method) {
tracing::debug!("Blocked CDP command: {}", method);
return serde_json::from_value(json!({})).map_err(Into::into);
}
if is_risky(method) {
tracing::warn!("Risky CDP command (may be detectable): {}", method);
}
if let Some(ref handle) = self.reader_handle {
if handle.is_finished() {
return Err(Error::transport(
"CDP reader task has exited — WebSocket connection is dead",
));
}
}
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let mut msg = json!({
"id": id,
"method": method,
"params": serde_json::to_value(params)?
});
if let Some(sid) = session_id {
msg["sessionId"] = json!(sid);
}
let data = serde_json::to_string(&msg)?;
let (tx, rx) = oneshot::channel();
{
let mut pending = lock_pending(&self.pending);
pending.insert(id, tx);
}
let _registration = PendingRegistration {
pending: &self.pending,
id,
};
if let Err(e) = {
let mut writer = self.writer.lock().await;
async {
std::future::poll_fn(|cx| writer.poll_ready_unpin(cx)).await?;
if let Some(dispatch) = dispatch {
dispatch.state.store(2, Ordering::Release);
}
writer.start_send_unpin(Message::Text(data.into()))?;
writer.flush().await
}
.await
} {
return Err(Error::transport(format!("WebSocket write failed: {}", e)));
}
tracing::trace!("Sent CDP command: {} (id={})", method, id);
let result = tokio::time::timeout(self.cmd_timeout, rx)
.await
.map_err(|elapsed| {
tracing::warn!(
"CDP command '{}' timed out after {}s (id={})",
method,
self.cmd_timeout.as_secs(),
id
);
Error::transport(format!(
"CDP command '{}' timed out after {}s (id={}): {}",
method,
self.cmd_timeout.as_secs(),
id,
elapsed
))
})?
.map_err(|error| Error::transport(format!("Response channel closed: {}", error)))??;
let response: R = serde_json::from_value(result)?;
Ok(response)
}
pub async fn send<C, R>(&self, method: &str, params: &C) -> Result<R>
where
C: Serialize,
R: DeserializeOwned,
{
self.send_impl(None, method, params, None).await
}
pub async fn send_to_session<C, R>(
&self,
session_id: &str,
method: &str,
params: &C,
) -> Result<R>
where
C: Serialize,
R: DeserializeOwned,
{
self.send_impl(Some(session_id), method, params, None).await
}
pub async fn send_to_session_with_dispatch<C, R>(
&self,
session_id: &str,
method: &str,
params: &C,
dispatch: &CommandDispatch,
) -> Result<R>
where
C: Serialize,
R: DeserializeOwned,
{
self.send_impl(Some(session_id), method, params, Some(dispatch))
.await
}
pub(super) fn install_idle_route(&self, session: &str, state: &Arc<IdleState>) {
self.idle_routes
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(session.into(), Arc::downgrade(state));
}
pub(super) fn remove_idle_route(&self, session: &str) {
self.idle_routes
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(session);
}
pub fn subscribe(&self) -> broadcast::Receiver<CdpMessage> {
self.event_tx.subscribe()
}
pub async fn set_request_interception(
&self,
session: &str,
patterns: Option<Vec<Value>>,
) -> Result<()> {
let (method, params) =
request_interception_command(self.request_base_strip, self.request_base_auth, patterns);
self.send_to_session::<_, Value>(session, method, ¶ms)
.await?;
Ok(())
}
pub fn install_request_route(
&self,
session: &str,
events: mpsc::Sender<RequestPause>,
dropped: Arc<AtomicU64>,
) -> Result<()> {
let mut routes = self
.request_routes
.lock()
.unwrap_or_else(|e| e.into_inner());
if routes.contains_key(session) {
return Err(Error::cdp_msg(
"Request interception already owned for this session",
));
}
routes.insert(session.into(), RequestRoute { events, dropped });
Ok(())
}
pub fn remove_request_route(&self, session: &str) {
self.request_routes
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(session);
}
pub(crate) fn install_response_route(
&self,
session: &str,
events: mpsc::Sender<Value>,
dropped: Arc<AtomicU64>,
) {
self.response_routes
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(session.to_owned(), ResponseRoute { events, dropped });
}
pub(crate) fn remove_response_route(&self, session: &str) {
self.response_routes
.lock()
.unwrap_or_else(|e| e.into_inner())
.remove(session);
}
pub async fn recv_event(&self) -> Option<CdpMessage> {
let mut rx = self.event_rx.lock().await;
loop {
match rx.recv().await {
Ok(msg) => return Some(msg),
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Event receiver lagged, skipped {} event(s)", n);
continue;
}
Err(broadcast::error::RecvError::Closed) => return None,
}
}
}
pub async fn try_recv_event(&self) -> Option<CdpMessage> {
let mut rx = self.event_rx.lock().await;
loop {
match rx.try_recv() {
Ok(msg) => return Some(msg),
Err(broadcast::error::TryRecvError::Lagged(n)) => {
tracing::warn!("Event receiver lagged, skipped {} event(s)", n);
continue;
}
Err(_) => return None,
}
}
}
pub(crate) async fn wait_for_owned_exit(&self, timeout: Duration) -> Result<bool> {
let Some(child) = &self.child else {
return Ok(true);
};
let deadline = tokio::time::Instant::now() + timeout;
loop {
if child.lock().await.try_wait()?.is_some() {
return Ok(true);
}
if tokio::time::Instant::now() >= deadline {
return Ok(false);
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
pub async fn close(&self) -> Result<()> {
{
let mut writer = self.writer.lock().await;
if let Err(error) = writer.send(Message::Close(None)).await {
tracing::debug!("WebSocket close frame was not sent: {}", error);
}
}
if let Some(handle) = &self.reader_handle {
handle.abort();
}
network_idle::disconnect(&self.idle_routes);
fail_pending(&self.pending, "CDP transport closed");
if let Some(ref child) = self.child {
let mut c = child.lock().await;
stop_child(&mut c)?;
}
Ok(())
}
}
impl Drop for Transport {
fn drop(&mut self) {
if let Some(handle) = self.reader_handle.take() {
handle.abort();
}
network_idle::disconnect(&self.idle_routes);
fail_pending(&self.pending, "CDP transport dropped");
if let Some(ref child) = self.child {
if let Ok(mut c) = child.try_lock() {
if let Err(error) = stop_child(&mut c) {
tracing::warn!("Failed to stop Chrome while dropping transport: {}", error);
}
}
}
}
}
#[cfg(test)]
mod tests;