use chromiumoxide::browser::Browser;
use chromiumoxide::handler::HandlerConfig;
use futures_util::StreamExt;
use tokio::task::JoinHandle;
use crate::error::SolariError;
use crate::http::{redact_url, scrub_url_from};
use crate::types::Session;
pub struct ConnectedBrowser {
pub browser: Browser,
pub handler: JoinHandle<()>,
}
impl ConnectedBrowser {
pub fn into_parts(self) -> (Browser, JoinHandle<()>) {
(self.browser, self.handler)
}
pub async fn disconnect(self) {
drop(self.browser);
self.handler.abort();
let _ = self.handler.await;
}
}
pub async fn connect(session: &Session) -> Result<ConnectedBrowser, SolariError> {
connect_endpoint(&session.cdp_endpoint).await
}
pub async fn connect_endpoint(cdp_endpoint: &str) -> Result<ConnectedBrowser, SolariError> {
let ms = DEFAULT_DIAL_MS.load(std::sync::atomic::Ordering::Relaxed);
connect_endpoint_timeout(cdp_endpoint, std::time::Duration::from_millis(ms)).await
}
static DEFAULT_DIAL_MS: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_DIAL_TIMEOUT.as_millis() as u64);
pub const DEFAULT_DIAL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
pub async fn connect_endpoint_timeout(
cdp_endpoint: &str,
dial_timeout: std::time::Duration,
) -> Result<ConnectedBrowser, SolariError> {
let dial = Browser::connect_with_config(cdp_endpoint.to_string(), HandlerConfig::default());
let (browser, mut handler) = match tokio::time::timeout(dial_timeout, dial).await {
Ok(Ok(pair)) => pair,
Ok(Err(e)) => {
return Err(SolariError::Connect {
message: format!(
"Solari: failed to connect to {}: {}",
redact_url(cdp_endpoint),
scrub_url_from(&e.to_string(), cdp_endpoint)
),
})
}
Err(_) => {
return Err(SolariError::Connect {
message: format!(
"Solari: failed to connect to {}: no WebSocket handshake answer within {}ms (the gateway accepted the connection but did not complete the upgrade); retry, or raise the bound with connect_endpoint_timeout",
redact_url(cdp_endpoint),
dial_timeout.as_millis()
),
})
}
};
let handle = tokio::spawn(async move { while handler.next().await.is_some() {} });
Ok(ConnectedBrowser {
browser,
handler: handle,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
#[test]
fn the_shipped_default_is_30_seconds_and_is_what_connect_endpoint_reads() {
assert_eq!(DEFAULT_DIAL_TIMEOUT, Duration::from_secs(30));
assert_eq!(DEFAULT_DIAL_MS.load(Ordering::Relaxed), 30_000);
}
#[tokio::test]
async fn connect_endpoint_and_connect_use_the_default_bound() {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = listener.local_addr().unwrap().port();
tokio::spawn(async move {
let mut held = Vec::new();
loop {
let Ok((sock, _)) = listener.accept().await else { return };
held.push(sock); }
});
DEFAULT_DIAL_MS.store(300, Ordering::Relaxed);
let url = format!("ws://127.0.0.1:{port}/cdp/SECRET-SESSION-ID");
let start = Instant::now();
let a = tokio::time::timeout(Duration::from_secs(6), connect_endpoint(&url)).await;
let session = Session {
id: "s".into(),
ws_endpoint: "wss://x/ws/s".into(),
cdp_endpoint: url.clone(),
expires_at: String::new(),
storage_state: None,
proxy: None,
user_metadata: None,
};
let b = tokio::time::timeout(Duration::from_secs(6), connect(&session)).await;
DEFAULT_DIAL_MS.store(DEFAULT_DIAL_TIMEOUT.as_millis() as u64, Ordering::Relaxed);
for (name, r) in [("connect_endpoint", a), ("connect", b)] {
let err = match r.unwrap_or_else(|_| panic!("{name} waited for the caller instead of the default bound")) {
Ok(_) => panic!("{name}: a gateway that never answers cannot complete a handshake"),
Err(e) => e.to_string(),
};
assert!(err.contains("no WebSocket handshake answer within 300ms"), "{name}: {err}");
}
assert!(start.elapsed() < Duration::from_secs(4), "took {:?}", start.elapsed());
}
}