use std::future::Future;
use std::ops::ControlFlow;
use std::path::PathBuf;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::task::{Context as TaskContext, Poll};
use std::time::{Duration, Instant};
use anyhow::{Result, anyhow, bail};
use chromiumoxide::Page;
use futures::stream::{Stream, StreamExt};
use tokio::sync::oneshot;
use tokio::task::JoinHandle;
use crate::classify::Reason;
use crate::introspect::{Introspector, JobProgress};
use crate::policy::Policy;
use crate::slim::{self, Method};
use crate::store::Persistence;
use crate::worker::{Job, Shared, WorkerPool};
pub(crate) fn per_min_interval(per_min: u32) -> Duration {
Duration::from_secs(60) / per_min.max(1)
}
#[derive(Clone, Debug)]
pub struct Domain {
pub host: String,
pub solve: bool,
pub per_ip: Option<u32>,
pub aggregate: Option<u32>,
}
impl Domain {
pub fn solve(host: impl Into<String>) -> Domain {
Domain {
host: host.into(),
solve: true,
per_ip: None,
aggregate: None,
}
}
pub fn raw(host: impl Into<String>) -> Domain {
Domain {
host: host.into(),
solve: false,
per_ip: None,
aggregate: None,
}
}
pub fn per_ip(mut self, per_min: u32) -> Domain {
self.per_ip = Some(per_min);
self
}
pub fn aggregate(mut self, per_min: u32) -> Domain {
self.aggregate = Some(per_min);
self
}
}
#[derive(Clone)]
pub struct Config {
pub browsers: usize,
pub exits: Vec<String>,
pub mullvad: bool,
pub max_latency: Option<Duration>,
pub real_display: bool,
pub cdp_click: bool,
pub no_click: bool,
pub connect_grace: Duration,
pub move_mouse: bool,
pub timeout: Duration,
pub width: u32,
pub height: u32,
pub data_dir: Option<PathBuf>,
pub policy: Policy,
pub capture_dir: Option<PathBuf>,
pub domains: Vec<Domain>,
}
impl Default for Config {
fn default() -> Self {
Config {
browsers: 4,
exits: Vec::new(),
mullvad: false,
max_latency: None,
real_display: false,
cdp_click: false,
no_click: false,
connect_grace: Duration::ZERO,
move_mouse: true,
timeout: Duration::from_secs(60),
width: 1366,
height: 768,
data_dir: None,
policy: Policy::default(),
capture_dir: None,
domains: Vec::new(),
}
}
}
#[derive(Clone, Debug, Default)]
pub struct Resource {
pub url: String,
pub method: Method,
pub headers: Vec<(String, String)>,
pub body: Option<Vec<u8>>,
pub key: Option<String>,
}
impl Resource {
pub(crate) fn to_request(&self) -> slim::Request {
slim::Request {
url: self.url.clone(),
method: self.method,
headers: self.headers.clone(),
body: self.body.clone(),
}
}
}
impl From<String> for Resource {
fn from(url: String) -> Resource {
Resource {
url,
..Default::default()
}
}
}
impl From<&str> for Resource {
fn from(url: &str) -> Resource {
Resource::from(url.to_string())
}
}
impl From<url::Url> for Resource {
fn from(url: url::Url) -> Resource {
Resource::from(url.to_string())
}
}
pub struct Outcome<T> {
pub value: T,
pub clicks: u32,
pub elapsed: Duration,
pub solve_required: bool,
pub exit: Option<String>,
}
pub struct FetchResult<T = String> {
pub index: usize,
pub url: String,
pub key: Option<String>,
pub result: Result<Outcome<T>, FetchError>,
}
pub struct FetchAll<T = String> {
rx: Pin<Box<dyn Stream<Item = FetchResult<Vec<u8>>> + Send>>,
decode: Box<dyn Fn(Vec<u8>) -> T + Send>,
feeder: Option<JoinHandle<()>>,
total: Option<usize>,
progress: Option<Arc<Progress>>,
}
struct Progress {
intro: Arc<Introspector>,
start: Instant,
total: AtomicU64, submitted: AtomicU64,
ok: AtomicU64,
err: AtomicU64,
last_publish_ms: AtomicU64,
}
impl Progress {
fn new(intro: Arc<Introspector>, total: Option<usize>) -> Arc<Self> {
Arc::new(Progress {
intro,
start: Instant::now(),
total: AtomicU64::new(total.map(|n| n as u64).unwrap_or(u64::MAX)),
submitted: AtomicU64::new(0),
ok: AtomicU64::new(0),
err: AtomicU64::new(0),
last_publish_ms: AtomicU64::new(0),
})
}
fn set_total(&self, total: Option<usize>) {
self.total.store(
total.map(|n| n as u64).unwrap_or(u64::MAX),
Ordering::Relaxed,
);
self.publish(true);
}
fn on_submit(&self) {
self.submitted.fetch_add(1, Ordering::Relaxed);
self.publish(false);
}
fn on_result(&self, ok: bool) {
if ok {
self.ok.fetch_add(1, Ordering::Relaxed);
} else {
self.err.fetch_add(1, Ordering::Relaxed);
}
self.publish(false);
}
fn snapshot(&self) -> JobProgress {
let total = match self.total.load(Ordering::Relaxed) {
u64::MAX => None,
v => Some(v),
};
let ok = self.ok.load(Ordering::Relaxed);
let err = self.err.load(Ordering::Relaxed);
let done = ok + err;
let in_flight = self.submitted.load(Ordering::Relaxed).saturating_sub(done);
let secs = self.start.elapsed().as_secs_f64().max(1e-3);
let per_sec = done as f64 / secs;
let eta_secs = total
.filter(|_| per_sec > 0.0)
.map(|t| (t.saturating_sub(done) as f64 / per_sec) as u64);
JobProgress {
total,
done,
ok,
err,
in_flight,
per_sec,
eta_secs,
}
}
fn publish(&self, force: bool) {
let now_ms = self.start.elapsed().as_millis() as u64;
if !force {
let last = self.last_publish_ms.load(Ordering::Relaxed);
if now_ms.saturating_sub(last) < 50 {
return;
}
if self
.last_publish_ms
.compare_exchange(last, now_ms, Ordering::Relaxed, Ordering::Relaxed)
.is_err()
{
return;
}
} else {
self.last_publish_ms.store(now_ms, Ordering::Relaxed);
}
self.intro.publish_progress(self.snapshot());
}
}
impl<T> FetchAll<T> {
pub fn with_total(mut self, n: usize) -> Self {
self.set_total(Some(n));
self
}
fn set_total(&mut self, total: Option<usize>) {
self.total = total;
if let Some(p) = &self.progress {
p.set_total(total);
}
}
pub fn total(&self) -> Option<usize> {
self.total
}
}
impl<T> Stream for FetchAll<T> {
type Item = FetchResult<T>;
fn poll_next(self: Pin<&mut Self>, cx: &mut TaskContext<'_>) -> Poll<Option<FetchResult<T>>> {
let this = self.get_mut(); match this.rx.as_mut().poll_next(cx) {
Poll::Ready(Some(fr)) => {
if let Some(p) = &this.progress {
p.on_result(fr.result.is_ok());
}
let FetchResult {
index,
url,
key,
result,
} = fr;
let result = result.map(|o| Outcome {
value: (this.decode)(o.value),
clicks: o.clicks,
elapsed: o.elapsed,
solve_required: o.solve_required,
exit: o.exit,
});
Poll::Ready(Some(FetchResult {
index,
url,
key,
result,
}))
}
Poll::Ready(None) => {
if let Some(p) = &this.progress {
p.publish(true); }
Poll::Ready(None)
}
Poll::Pending => Poll::Pending,
}
}
}
impl<T> Drop for FetchAll<T> {
fn drop(&mut self) {
if let Some(feeder) = self.feeder.take() {
feeder.abort();
}
}
}
fn drive<St, Sub, Fut, T>(
input: St,
results_cap: usize,
decode: impl Fn(Vec<u8>) -> T + Send + 'static,
submit: Sub,
) -> FetchAll<T>
where
St: Stream<Item = Resource> + Send + 'static,
Sub: Fn(usize, Resource, async_channel::Sender<FetchResult<Vec<u8>>>) -> Fut + Send + 'static,
Fut: Future<Output = ControlFlow<()>> + Send,
{
let (tx, rx) = async_channel::bounded(results_cap.max(1));
let feeder = tokio::spawn(async move {
futures::pin_mut!(input);
let mut index = 0usize;
while let Some(resource) = input.next().await {
if submit(index, resource, tx.clone()).await.is_break() {
break;
}
index += 1;
}
});
FetchAll {
rx: Box::pin(rx),
decode: Box::new(decode),
feeder: Some(feeder),
total: None,
progress: None,
}
}
#[derive(Debug, thiserror::Error)]
pub enum FetchError {
#[error("host `{host}` is not configured — add a Domain::solve or Domain::raw for it")]
Unconfigured {
host: String,
},
#[error("fetch gave up after exhausting attempts: {0:?}")]
GaveUp(Reason),
#[error(
"fingerprint-triple mismatch on {exit}: slim replay failed with {reason:?} immediately after a \
headed solve (check the installed Chrome major against the pinned TLS profile)"
)]
FingerprintMismatch {
exit: String,
reason: Reason,
},
#[error(
"host `{host}` redirects during solve to `{landed}`, whose clearance never validates \
against `{host}` — register `{landed}` as its own domain instead"
)]
MisconfiguredHost {
host: String,
landed: String,
},
#[error("all exits resting; {}", match retry_after { Some(d) => format!("retry in ~{}s", d.as_secs()), None => "retry later".into() })]
Resting {
retry_after: Option<std::time::Duration>,
},
#[error(transparent)]
Other(#[from] anyhow::Error),
}
impl FetchError {
pub fn is_config_error(&self) -> bool {
matches!(
self,
FetchError::Unconfigured { .. }
| FetchError::FingerprintMismatch { .. }
| FetchError::MisconfiguredHost { .. }
)
}
}
#[derive(Clone)]
pub struct Client {
inner: Arc<Inner>,
}
struct Inner {
shared: Arc<Shared>,
workers: WorkerPool,
}
impl Drop for Inner {
fn drop(&mut self) {
self.shared.introspect.stop();
}
}
impl Client {
pub async fn new(mut cfg: Config) -> Result<Self> {
if cfg.browsers == 0 {
bail!("browsers (--browser-concurrency) must be >= 1");
}
let introspect = Introspector::new();
introspect.set_mullvad_required(cfg.mullvad);
if std::env::var_os("MARA_NO_INTROSPECT").is_none() {
match introspect.clone().serve().await {
Some(url) => tracing::info!(%url, "introspect dashboard"),
None => tracing::warn!("introspection server could not bind a port"),
}
}
let data_dir = cfg
.data_dir
.clone()
.or_else(|| std::env::var_os("MARA_DATA_DIR").map(PathBuf::from));
let persistence = Arc::new(Persistence::open(data_dir, slim::PROFILE));
let egress = if cfg.mullvad {
let pool = crate::mullvad::bootstrap(
introspect.clone(),
persistence.clone(),
cfg.max_latency,
cfg.policy.probe_concurrency,
)
.await?;
if !cfg.exits.is_empty() {
pool.add_manual_exits(cfg.exits.clone());
}
pool.load_state_from_disk();
pool
} else if cfg.exits.is_empty() {
let pool = crate::pool::ExitPool::direct(introspect.clone(), persistence.clone());
pool.load_state_from_disk();
pool
} else {
let pool = crate::pool::ExitPool::manual(
cfg.exits.clone(),
introspect.clone(),
persistence.clone(),
cfg.max_latency,
cfg.policy.probe_concurrency,
);
pool.load_state_from_disk();
pool
};
cfg.browsers = cfg.browsers.min(egress.exit_count()).max(1);
let (browsers, mullvad) = (cfg.browsers, cfg.mullvad);
let located = tokio::task::spawn_blocking(crate::solver::session::locate_chrome).await?;
let fingerprint_ok = fingerprint_canary(&located);
let chrome_exec = tokio::task::spawn_blocking(move || {
crate::solver::session::ChromeExec::resolve(located)
})
.await?;
let shared = Shared::new(
cfg,
egress,
persistence,
introspect,
fingerprint_ok,
chrome_exec,
);
let workers = WorkerPool::spawn(shared.clone());
let serving = workers.count();
let client = Client {
inner: Arc::new(Inner { shared, workers }),
};
let (data_dir, persistent) = client.inner.shared.persistence.location();
tracing::info!(browsers, serving, mullvad, persistent, data_dir = %data_dir.display(), "client ready");
Ok(client)
}
pub async fn fetch_browser<F, Fut, T>(
&self,
url: &str,
extract: F,
) -> Result<Outcome<T>, FetchError>
where
F: FnOnce(Page) -> Fut + Send + 'static,
Fut: Future<Output = Result<T>> + Send + 'static,
T: Send + 'static,
{
let started = Instant::now();
let (tx, rx) = oneshot::channel();
let exec = Box::new(
move |res: Result<crate::worker::HeadedSession, FetchError>| {
Box::pin(async move {
let out = match res {
Ok(s) => match extract(s.page).await {
Ok(value) => Ok(Outcome {
value,
clicks: s.clicks,
elapsed: started.elapsed(),
solve_required: true,
exit: Some(s.exit),
}),
Err(e) => Err(FetchError::Other(e)),
},
Err(e) => Err(e),
};
let _ = tx.send(out);
}) as std::pin::Pin<Box<dyn Future<Output = ()> + Send>>
},
);
self.inner
.workers
.submit(Job::Headed {
url: url.to_string(),
exec,
attempts: self.inner.shared.cfg.policy.max_attempts,
})
.await?;
rx.await
.map_err(|_| FetchError::Other(anyhow!("worker dropped the job")))?
}
pub fn fetch_all<I>(&self, resources: I) -> FetchAll<String>
where
I: IntoIterator,
I::Item: Into<Resource> + 'static,
I::IntoIter: Send + 'static,
{
let iter = resources.into_iter().map(Into::into);
let total = iter.size_hint().1;
let mut all = self.fetch_all_decoded(futures::stream::iter(iter), utf8_lossy);
all.set_total(total);
all
}
pub fn fetch_all_bytes<I>(&self, resources: I) -> FetchAll<Vec<u8>>
where
I: IntoIterator,
I::Item: Into<Resource> + 'static,
I::IntoIter: Send + 'static,
{
let iter = resources.into_iter().map(Into::into);
let total = iter.size_hint().1;
let mut all = self.fetch_all_decoded(futures::stream::iter(iter), |b| b);
all.set_total(total);
all
}
pub fn fetch_all_stream(
&self,
resources: impl Stream<Item = Resource> + Send + 'static,
) -> FetchAll<String> {
self.fetch_all_decoded(resources, utf8_lossy)
}
fn fetch_all_decoded<T>(
&self,
resources: impl Stream<Item = Resource> + Send + 'static,
decode: impl Fn(Vec<u8>) -> T + Send + 'static,
) -> FetchAll<T> {
let client = self.clone();
let cap = self.worker_count();
let progress = Progress::new(self.inner.shared.introspect.clone(), None);
let p = progress.clone();
let mut all = drive(resources, cap, decode, move |index, resource, results| {
let client = client.clone();
let p = p.clone();
async move {
p.on_submit();
let started = Instant::now();
let url = resource.url.clone();
let key = resource.key.clone();
let job = Job::Html {
resource,
index,
started,
results: results.clone(),
attempts: client.inner.shared.cfg.policy.max_attempts,
};
match client.inner.workers.submit(job).await {
Ok(()) => ControlFlow::Continue(()),
Err(e) => {
let _ = results
.send(FetchResult {
index,
url,
key,
result: Err(e),
})
.await;
ControlFlow::Break(())
}
}
}
});
all.progress = Some(progress);
all
}
pub async fn fetch_http(&self, url: &str) -> Result<Outcome<String>, FetchError> {
match self
.fetch_all(std::iter::once(url.to_string()))
.next()
.await
{
Some(r) => r.result,
None => Err(FetchError::Other(anyhow!("worker pool produced no result"))),
}
}
pub async fn fetch_bytes(
&self,
resource: impl Into<Resource>,
) -> Result<Outcome<Vec<u8>>, FetchError> {
match self
.fetch_all_bytes(std::iter::once(resource.into()))
.next()
.await
{
Some(r) => r.result,
None => Err(FetchError::Other(anyhow!("worker pool produced no result"))),
}
}
pub fn enable_solving_for_domain(&self, domain: impl Into<String>) {
self.inner.shared.mark_solve_host(&domain.into());
}
pub fn snapshot(&self) -> crate::store::StoreSnapshot {
self.inner.shared.egress.snapshot()
}
pub fn misconfigured_domains(&self) -> Vec<(String, String)> {
self.inner.shared.misconfigured_domains()
}
pub fn worker_count(&self) -> usize {
self.inner.workers.count()
}
pub async fn shutdown(&self) {
self.inner.workers.shutdown().await;
self.inner.shared.egress.persist_all();
self.inner.shared.introspect.stop();
}
pub async fn abort(&self) {
self.inner.workers.abort().await;
self.inner.shared.introspect.stop();
}
}
fn utf8_lossy(bytes: Vec<u8>) -> String {
String::from_utf8_lossy(&bytes).into_owned()
}
fn fingerprint_canary(located: &Option<(String, String)>) -> bool {
let installed = crate::solver::session::chrome_major_from(located);
match (installed, slim::profile_major()) {
(Some(installed), Some(pinned)) if installed != pinned => {
tracing::error!(
installed,
pinned,
profile = slim::PROFILE,
"fingerprint drift: installed Chrome {installed} != pinned slim profile {pinned} ({}); \
handed-down clearances will be REJECTED and clients stay stuck on the headed solver — bump \
wreq/wreq-util AND the Chrome binary in lockstep (see the fingerprint-triple invariant)",
slim::PROFILE,
);
false
}
(Some(installed), Some(_)) => {
tracing::info!(
chrome_major = installed,
profile = slim::PROFILE,
"fingerprint canary ok"
);
true
}
_ => {
tracing::debug!(
profile = slim::PROFILE,
"fingerprint canary skipped (Chrome version unreadable)"
);
false
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
fn ok(index: usize, url: String) -> FetchResult<Vec<u8>> {
let value = url.clone().into_bytes();
FetchResult {
index,
url,
key: None,
result: Ok(Outcome {
value,
clicks: 0,
elapsed: Duration::ZERO,
solve_required: false,
exit: None,
}),
}
}
fn harness<St, H, Fut>(
input: St,
workers: usize,
work_cap: usize,
results_cap: usize,
handle: H,
) -> (FetchAll<Vec<u8>>, Arc<AtomicUsize>)
where
St: Stream<Item = String> + Send + 'static,
H: Fn(usize, String) -> Fut + Send + Sync + 'static,
Fut: Future<Output = FetchResult<Vec<u8>>> + Send,
{
let pulls = Arc::new(AtomicUsize::new(0));
let counted = {
let pulls = pulls.clone();
input.map(Resource::from).inspect(move |_| {
pulls.fetch_add(1, Ordering::SeqCst);
})
};
type Item = (usize, String, async_channel::Sender<FetchResult<Vec<u8>>>);
let (work_tx, work_rx) = async_channel::bounded::<Item>(work_cap.max(1));
let handle = Arc::new(handle);
for _ in 0..workers.max(1) {
let work_rx = work_rx.clone();
let handle = handle.clone();
tokio::spawn(async move {
while let Ok((index, url, results)) = work_rx.recv().await {
let fr = handle(index, url).await;
let _ = results.send(fr).await;
}
});
}
let fa = drive(
counted,
results_cap,
|b| b,
move |index, resource, results| {
let work_tx = work_tx.clone();
async move {
match work_tx.send((index, resource.url, results)).await {
Ok(()) => ControlFlow::Continue(()),
Err(_) => ControlFlow::Break(()),
}
}
},
);
(fa, pulls)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn delivers_every_result_then_ends() {
let n = 500usize;
let input = futures::stream::iter((0..n).map(|i| format!("u{i}")));
let (fa, pulls) = harness(input, 8, 8, 8, |index, url| async move {
for _ in 0..(index % 5) {
tokio::task::yield_now().await;
}
ok(index, url)
});
let mut indices: Vec<usize> = fa.map(|r| r.index).collect().await;
assert_eq!(indices.len(), n, "expected exactly one result per input");
indices.sort_unstable();
assert_eq!(
indices,
(0..n).collect::<Vec<_>>(),
"every input slot settled exactly once"
);
assert_eq!(
pulls.load(Ordering::SeqCst),
n,
"pulled exactly N — no over-pull, no short-pull"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn input_is_pulled_lazily() {
let huge = 1_000_000usize;
let (workers, work_cap, results_cap) = (4, 4, 4);
let gate = Arc::new(tokio::sync::Semaphore::new(0)); let input = futures::stream::iter((0..huge).map(|i| format!("u{i}")));
let (fa, pulls) = harness(input, workers, work_cap, results_cap, {
let gate = gate.clone();
move |index, url| {
let gate = gate.clone();
async move {
let _ = gate.acquire().await;
ok(index, url)
}
}
});
tokio::time::sleep(Duration::from_millis(150)).await;
let pulled = pulls.load(Ordering::SeqCst);
assert!(
pulled <= work_cap + workers + 2,
"input not lazy: pulled {pulled} of {huge}"
);
assert!(
pulled >= work_cap,
"feeder didn't fill the queue: pulled {pulled}"
);
drop(fa);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn feeder_stops_on_submit_break_instead_of_erroring_every_input() {
let huge = 1_000_000usize;
let input = futures::stream::iter((0..huge).map(|i| Resource::from(format!("u{i}"))));
let fa: FetchAll<Vec<u8>> = drive(
input,
8,
|b| b,
|index, resource, results| async move {
let _ = results
.send(FetchResult {
index,
url: resource.url,
key: None,
result: Err(FetchError::Other(anyhow!("worker pool is shut down"))),
})
.await;
ControlFlow::Break(())
},
);
let results: Vec<_> = fa.collect().await;
assert!(
results.len() <= 2,
"feeder must stop after the first failed submit, not error every input: got {}",
results.len()
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn drop_aborts_feeder_without_hanging() {
let huge = 1_000_000usize;
let (workers, work_cap, results_cap) = (4, 4, 4);
let gate = Arc::new(tokio::sync::Semaphore::new(0));
let input = futures::stream::iter((0..huge).map(|i| format!("u{i}")));
let (fa, pulls) = harness(input, workers, work_cap, results_cap, {
let gate = gate.clone();
move |index, url| {
let gate = gate.clone();
async move {
let _ = gate.acquire().await;
ok(index, url)
}
}
});
tokio::time::sleep(Duration::from_millis(150)).await;
let before = pulls.load(Ordering::SeqCst);
drop(fa); gate.add_permits(1000); tokio::time::sleep(Duration::from_millis(150)).await;
let after = pulls.load(Ordering::SeqCst);
assert!(
after < huge,
"feeder ran to completion despite the drop ({after})"
);
assert!(
after <= before + 2,
"feeder kept pulling after drop (before {before}, after {after})"
);
}
}