use chromiumoxide::cdp::browser_protocol::network::{CookieParam, CookieSameSite, TimeSinceEpoch};
use crate::client::Client;
use crate::client::CreateSessionOptions;
use crate::connect::{connect, ConnectedBrowser};
use crate::error::{SolariError, SolariErrorCode};
use crate::types::{SameSite, Session, StorageState};
#[derive(Debug, Clone)]
pub struct LaunchOptions {
pub create: CreateSessionOptions,
pub retries: u32,
pub probe: Option<bool>,
pub probe_timeout_ms: u64,
}
impl LaunchOptions {
pub fn new(create: CreateSessionOptions) -> Self {
Self {
create,
retries: 0,
probe: None,
probe_timeout_ms: 15_000,
}
}
pub fn retries(mut self, n: u32) -> Self {
self.retries = n;
self
}
pub fn probe(mut self, on: bool) -> Self {
self.probe = Some(on);
self
}
pub fn probe_timeout_ms(mut self, ms: u64) -> Self {
self.probe_timeout_ms = ms;
self
}
}
pub struct LaunchedSession {
pub browser: chromiumoxide::browser::Browser,
handler: tokio::task::JoinHandle<()>,
session: Session,
client: Client,
released: bool,
}
impl LaunchedSession {
pub fn session(&self) -> &Session {
&self.session
}
pub async fn close(mut self) -> Result<(), SolariError> {
self.released = true;
let id = self.session.id.clone();
let client = self.client.clone();
self.handler.abort();
client.sessions().release(&id).await
}
}
impl Drop for LaunchedSession {
fn drop(&mut self) {
if self.released {
return;
}
self.handler.abort();
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let client = self.client.clone();
let id = self.session.id.clone();
handle.spawn(async move {
let _ = client.sessions().release(&id).await;
});
}
}
}
impl Client {
pub async fn launch(&self, opts: LaunchOptions) -> Result<LaunchedSession, SolariError> {
let want_probe = opts.probe.unwrap_or(opts.retries > 0);
let mut attempt: u32 = 0;
loop {
let session = match self.sessions().create(opts.create.clone()).await {
Ok(session) => session,
Err(e) => {
if attempt < opts.retries && is_transient(&e) {
attempt += 1;
continue;
}
return Err(e);
}
};
let outcome = self.launch_attempt(&session, want_probe, opts.probe_timeout_ms).await;
match outcome {
Ok(connected) => {
return Ok(LaunchedSession {
browser: connected.browser,
handler: connected.handler,
session,
client: self.clone(),
released: false,
});
}
Err(e) => {
let _ = self.sessions().release(&session.id).await;
if attempt < opts.retries && is_transient(&e) {
attempt += 1;
continue;
}
return Err(e);
}
}
}
}
async fn launch_attempt(
&self,
session: &Session,
want_probe: bool,
probe_timeout_ms: u64,
) -> Result<ConnectedBrowser, SolariError> {
let connected = connect(session).await?;
if let Some(Some(state)) = &session.storage_state {
seed_cookies(&connected.browser, state).await;
}
if want_probe {
probe(&connected.browser, probe_timeout_ms).await?;
}
Ok(connected)
}
}
fn is_transient(e: &SolariError) -> bool {
matches!(
e,
SolariError::Connect { .. } | SolariError::Transport { .. } | SolariError::Protocol { .. }
) || matches!(e, SolariError::Api { status, code, .. }
if (*status == 0 || *status >= 500)
&& !matches!(code, Some(SolariErrorCode::Other(c)) if c == "PoolResponseUnparseable"))
}
async fn probe(
browser: &chromiumoxide::browser::Browser,
timeout_ms: u64,
) -> Result<(), SolariError> {
let fut = async {
let page = browser
.new_page("about:blank")
.await
.map_err(|e| SolariError::Connect { message: format!("Solari: probe new_page failed: {e}") })?;
page.evaluate("1")
.await
.map_err(|e| SolariError::Connect { message: format!("Solari: probe evaluate failed: {e}") })?;
Ok::<(), SolariError>(())
};
match tokio::time::timeout(std::time::Duration::from_millis(timeout_ms), fut).await {
Ok(r) => r,
Err(_) => Err(SolariError::Connect {
message: format!("Solari: browser health probe timed out after {timeout_ms}ms"),
}),
}
}
async fn seed_cookies(browser: &chromiumoxide::browser::Browser, state: &StorageState) {
let Some(cookies) = &state.cookies else {
return;
};
if cookies.is_empty() {
return;
}
let Ok(page) = browser.new_page("about:blank").await else {
return;
};
for c in cookies {
let mut b = CookieParam::builder().name(c.name.clone()).value(c.value.clone());
if let Some(d) = &c.domain {
b = b.domain(d.clone());
}
if let Some(p) = &c.path {
b = b.path(p.clone());
}
if let Some(e) = c.expires {
b = b.expires(TimeSinceEpoch::new(e));
}
if let Some(h) = c.http_only {
b = b.http_only(h);
}
if let Some(s) = c.secure {
b = b.secure(s);
}
if let Some(ss) = &c.same_site {
b = b.same_site(map_same_site(ss));
}
if let Ok(param) = b.build() {
let _ = page.set_cookie(param).await;
}
}
}
fn map_same_site(s: &SameSite) -> CookieSameSite {
match s {
SameSite::Strict => CookieSameSite::Strict,
SameSite::Lax => CookieSameSite::Lax,
SameSite::None => CookieSameSite::None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn api(status: u16) -> SolariError {
SolariError::Api { status, code: None, message: "x".into() }
}
#[test]
fn the_default_probe_timeout_allows_a_cold_slot() {
assert_eq!(LaunchOptions::new(CreateSessionOptions::new()).probe_timeout_ms, 15_000);
}
#[test]
fn retry_policy_classifies_transience() {
assert!(is_transient(&SolariError::Connect { message: "x".into() }));
assert!(is_transient(&SolariError::Transport { message: "x".into() }));
assert!(is_transient(&SolariError::Protocol { message: "x".into() }));
assert!(is_transient(&api(500)));
assert!(is_transient(&api(503)));
assert!(is_transient(&api(0)));
assert!(!is_transient(&api(400)));
assert!(!is_transient(&api(404)));
assert!(!is_transient(&SolariError::Config { message: "x".into() }));
assert!(!is_transient(&SolariError::Api {
status: 502,
code: Some(SolariErrorCode::Other("PoolResponseUnparseable".into())),
message: "x".into(),
}));
}
#[test]
fn same_site_maps_every_variant() {
assert!(matches!(map_same_site(&SameSite::Strict), CookieSameSite::Strict));
assert!(matches!(map_same_site(&SameSite::Lax), CookieSameSite::Lax));
assert!(matches!(map_same_site(&SameSite::None), CookieSameSite::None));
}
}