1use crate::listeners::{EventListenerRequest, EventListeners};
2use chromiumoxide_cdp::cdp::browser_protocol::browser::*;
3use chromiumoxide_cdp::cdp::browser_protocol::target::*;
4use chromiumoxide_cdp::cdp::events::CdpEvent;
5use chromiumoxide_cdp::cdp::events::CdpEventMessage;
6use chromiumoxide_types::{CallId, Message, Method, Response};
7use chromiumoxide_types::{MethodId, Request as CdpRequest};
8use fnv::FnvHashMap;
9use futures_util::Stream;
10use hashbrown::{HashMap, HashSet};
11use spider_network_blocker::intercept_manager::NetworkInterceptManager;
12use std::pin::Pin;
13use std::task::{Context, Poll};
14use std::time::{Duration, Instant};
15use tokio::sync::mpsc::Receiver;
16use tokio::sync::oneshot::Sender as OneshotSender;
17use tokio_tungstenite::tungstenite::error::ProtocolError;
18use tokio_tungstenite::tungstenite::Error;
19
20use std::sync::Arc;
21use tokio::sync::Notify;
22
23use crate::cmd::{to_command_response, CommandMessage};
24use crate::conn::Connection;
25use crate::error::{CdpError, Result};
26use crate::handler::browser::BrowserContext;
27use crate::handler::frame::FrameRequestedNavigation;
28use crate::handler::frame::{NavigationError, NavigationId, NavigationOk};
29use crate::handler::httpfuture::navigation_continues;
30use crate::handler::job::PeriodicJob;
31use crate::handler::session::Session;
32use crate::handler::target::TargetEvent;
33use crate::handler::target::{Target, TargetConfig};
34use crate::handler::viewport::Viewport;
35use crate::page::Page;
36pub(crate) use page::PageInner;
37
38pub const REQUEST_TIMEOUT: u64 = 30_000;
40
41pub mod blockers;
42pub mod browser;
43pub mod commandfuture;
44pub mod domworld;
45pub mod emulation;
46pub mod frame;
47pub mod http;
48pub mod httpfuture;
49mod job;
50pub mod network;
51pub mod network_utils;
52pub mod page;
53#[cfg(feature = "parallel-handler")]
54pub mod parallel;
55pub mod sender;
56mod session;
57pub mod target;
58pub mod target_message_future;
59pub mod viewport;
60
61#[must_use = "streams do nothing unless polled"]
64#[derive(Debug)]
65pub struct Handler {
66 pub default_browser_context: BrowserContext,
67 pub browser_contexts: HashSet<BrowserContext>,
68 pending_commands: FnvHashMap<CallId, (PendingRequest, MethodId, Instant)>,
72 from_browser: Receiver<HandlerMessage>,
74 target_ids: Vec<TargetId>,
76 targets: HashMap<TargetId, Target>,
78 navigations: FnvHashMap<NavigationId, NavigationRequest>,
80 sessions: HashMap<SessionId, Session>,
84 conn: Option<Connection<CdpEventMessage>>,
87 evict_command_timeout: PeriodicJob,
89 next_navigation_id: usize,
91 config: HandlerConfig,
93 event_listeners: EventListeners,
95 closing: bool,
97 remaining_bytes: Option<u64>,
99 budget_exhausted: bool,
101 attached_targets: HashSet<TargetId>,
103 page_wake: Option<Arc<Notify>>,
106}
107
108lazy_static::lazy_static! {
109 static ref DISCOVER_ID: (std::borrow::Cow<'static, str>, serde_json::Value) = {
111 let discover = SetDiscoverTargetsParams::new(true);
112 (discover.identifier(), serde_json::to_value(discover).expect("valid discover target params"))
113 };
114 static ref TARGET_PARAMS_ID: (std::borrow::Cow<'static, str>, serde_json::Value) = {
116 let msg = GetTargetsParams { filter: None };
117 (msg.identifier(), serde_json::to_value(msg).expect("valid paramtarget"))
118 };
119 static ref CLOSE_PARAMS_ID: (std::borrow::Cow<'static, str>, serde_json::Value) = {
121 let close_msg = CloseParams::default();
122 (close_msg.identifier(), serde_json::to_value(close_msg).expect("valid close params"))
123 };
124}
125
126fn maybe_store_attach_session_id(target: &mut Target, method: &MethodId, resp: &Response) {
127 if method.as_ref() != AttachToTargetParams::IDENTIFIER {
128 return;
129 }
130
131 if let Ok(resp) = to_command_response::<AttachToTargetParams>(resp.clone(), method.clone()) {
132 target.set_session_id(resp.result.session_id);
133 }
134}
135
136impl Handler {
137 pub(crate) fn new(
140 mut conn: Connection<CdpEventMessage>,
141 rx: Receiver<HandlerMessage>,
142 config: HandlerConfig,
143 ) -> Self {
144 let discover = DISCOVER_ID.clone();
145 let _ = conn.submit_command(discover.0, None, discover.1);
146 let conn = Some(conn);
147
148 let browser_contexts = config
149 .context_ids
150 .iter()
151 .map(|id| BrowserContext::from(id.clone()))
152 .collect();
153
154 Self {
155 pending_commands: Default::default(),
156 from_browser: rx,
157 default_browser_context: Default::default(),
158 browser_contexts,
159 target_ids: Default::default(),
160 targets: Default::default(),
161 navigations: Default::default(),
162 sessions: Default::default(),
163 conn,
164 evict_command_timeout: PeriodicJob::new(config.request_timeout),
165 next_navigation_id: 0,
166 config,
167 event_listeners: Default::default(),
168 closing: false,
169 remaining_bytes: None,
170 budget_exhausted: false,
171 attached_targets: Default::default(),
172 page_wake: None,
173 }
174 }
175
176 #[inline]
179 fn conn(&mut self) -> Result<&mut Connection<CdpEventMessage>> {
180 self.conn
181 .as_mut()
182 .ok_or_else(|| CdpError::msg("connection consumed by Handler::run()"))
183 }
184
185 pub fn get_target(&self, target_id: &TargetId) -> Option<&Target> {
187 self.targets.get(target_id)
188 }
189
190 pub fn targets(&self) -> impl Iterator<Item = &Target> + '_ {
192 self.targets.values()
193 }
194
195 pub fn default_browser_context(&self) -> &BrowserContext {
197 &self.default_browser_context
198 }
199
200 pub fn browser_contexts(&self) -> impl Iterator<Item = &BrowserContext> + '_ {
202 self.browser_contexts.iter()
203 }
204
205 fn on_navigation_response(&mut self, id: NavigationId, resp: Response) {
207 if let Some(nav) = self.navigations.remove(&id) {
208 match nav {
209 NavigationRequest::Navigate(target_id, mut nav) => {
210 let failed = resp
217 .result
218 .as_ref()
219 .and_then(|result| result.get("errorText"))
220 .and_then(serde_json::Value::as_str)
221 .is_some_and(|err| !err.is_empty() && !navigation_continues(err));
222 if failed {
223 if let Some(target) = self.targets.get_mut(&target_id) {
226 target.frame_manager_mut().abandon_navigation(id);
227 }
228 let _ = nav.tx.send(Ok(resp));
229 } else if nav.navigated {
230 let _ = nav.tx.send(Ok(resp));
231 } else {
232 nav.set_response(resp);
233 self.navigations
234 .insert(id, NavigationRequest::Navigate(target_id, nav));
235 }
236 }
237 }
238 }
239 }
240
241 fn on_navigation_lifecycle_completed(&mut self, res: Result<NavigationOk, NavigationError>) {
243 match res {
244 Ok(ok) => {
245 let id = *ok.navigation_id();
246 if let Some(nav) = self.navigations.remove(&id) {
247 match nav {
248 NavigationRequest::Navigate(target_id, mut nav) => {
249 if let Some(resp) = nav.response.take() {
250 let _ = nav.tx.send(Ok(resp));
251 } else {
252 nav.set_navigated();
253 self.navigations
254 .insert(id, NavigationRequest::Navigate(target_id, nav));
255 }
256 }
257 }
258 }
259 }
260 Err(err) => {
261 if let Some(nav) = self.navigations.remove(err.navigation_id()) {
262 match nav {
263 NavigationRequest::Navigate(_, nav) => {
264 let _ = nav.tx.send(Err(err.into()));
265 }
266 }
267 }
268 }
269 }
270 }
271
272 fn on_response(&mut self, resp: Response) {
274 if let Some((req, method, _)) = self.pending_commands.remove(&resp.id) {
275 match req {
276 PendingRequest::CreateTarget(tx) => {
277 match to_command_response::<CreateTargetParams>(resp, method) {
278 Ok(resp) => {
279 if let Some(target) = self.targets.get_mut(&resp.target_id) {
280 target.set_initiator(tx);
281 } else {
282 let _ = tx.send(Err(CdpError::NotFound)).ok();
283 }
284 }
285 Err(err) => {
286 let _ = tx.send(Err(err)).ok();
287 }
288 }
289 }
290 PendingRequest::GetTargets(tx) => {
291 match to_command_response::<GetTargetsParams>(resp, method) {
292 Ok(resp) => {
293 let targets = resp.result.target_infos;
294 let results = targets.clone();
295
296 for target_info in targets {
297 let event: EventTargetCreated = EventTargetCreated { target_info };
298 self.on_target_created(event);
299 }
300
301 let _ = tx.send(Ok(results)).ok();
302 }
303 Err(err) => {
304 let _ = tx.send(Err(err)).ok();
305 }
306 }
307 }
308 PendingRequest::Navigate(id) => {
309 self.on_navigation_response(id, resp);
310 if self.config.only_html && !self.config.created_first_target {
311 self.config.created_first_target = true;
312 }
313 }
314 PendingRequest::ExternalCommand { tx, .. } => {
315 let _ = tx.send(Ok(resp)).ok();
316 }
317 PendingRequest::InternalCommand(target_id) => {
318 if let Some(target) = self.targets.get_mut(&target_id) {
319 maybe_store_attach_session_id(target, &method, &resp);
320 target.on_response(resp, method.as_ref());
321 }
322 }
323 PendingRequest::CloseBrowser(tx) => {
324 self.closing = true;
325 let _ = tx.send(Ok(CloseReturns {})).ok();
326 }
327 }
328 }
329 }
330
331 pub(crate) fn submit_external_command(
333 &mut self,
334 msg: CommandMessage,
335 now: Instant,
336 ) -> Result<()> {
337 let target_id = msg
342 .session_id
343 .as_ref()
344 .and_then(|sid| self.sessions.get(sid.as_ref()))
345 .map(|s| s.target_id().clone());
346 let call_id =
347 self.conn()?
348 .submit_command(msg.method.clone(), msg.session_id, msg.params)?;
349 self.pending_commands.insert(
350 call_id,
351 (
352 PendingRequest::ExternalCommand {
353 tx: msg.sender,
354 target_id,
355 },
356 msg.method,
357 now,
358 ),
359 );
360 Ok(())
361 }
362
363 pub(crate) fn submit_internal_command(
364 &mut self,
365 target_id: TargetId,
366 req: CdpRequest,
367 now: Instant,
368 ) -> Result<()> {
369 let call_id = self.conn()?.submit_command(
370 req.method.clone(),
371 req.session_id.map(Into::into),
372 req.params,
373 )?;
374 self.pending_commands.insert(
375 call_id,
376 (PendingRequest::InternalCommand(target_id), req.method, now),
377 );
378 Ok(())
379 }
380
381 fn submit_fetch_targets(&mut self, tx: OneshotSender<Result<Vec<TargetInfo>>>, now: Instant) {
382 let msg = TARGET_PARAMS_ID.clone();
383
384 if let Some(conn) = self.conn.as_mut() {
385 if let Ok(call_id) = conn.submit_command(msg.0.clone(), None, msg.1) {
386 self.pending_commands
387 .insert(call_id, (PendingRequest::GetTargets(tx), msg.0, now));
388 }
389 }
390 }
391
392 fn submit_navigation(&mut self, id: NavigationId, req: CdpRequest, now: Instant) {
395 if let Some(conn) = self.conn.as_mut() {
396 if let Ok(call_id) = conn.submit_command(
397 req.method.clone(),
398 req.session_id.map(Into::into),
399 req.params,
400 ) {
401 self.pending_commands
402 .insert(call_id, (PendingRequest::Navigate(id), req.method, now));
403 }
404 }
405 }
406
407 fn submit_close(&mut self, tx: OneshotSender<Result<CloseReturns>>, now: Instant) {
408 let close_msg = CLOSE_PARAMS_ID.clone();
409
410 if let Some(conn) = self.conn.as_mut() {
411 if let Ok(call_id) = conn.submit_command(close_msg.0.clone(), None, close_msg.1) {
412 self.pending_commands.insert(
413 call_id,
414 (PendingRequest::CloseBrowser(tx), close_msg.0, now),
415 );
416 }
417 }
418 }
419
420 fn on_target_message(&mut self, target: &mut Target, msg: CommandMessage, now: Instant) {
422 if msg.is_navigation() {
423 let (req, tx) = msg.split();
424 let id = self.next_navigation_id();
425
426 target.goto(FrameRequestedNavigation::new(
427 id,
428 req,
429 self.config.request_timeout,
430 ));
431
432 self.navigations.insert(
433 id,
434 NavigationRequest::Navigate(
435 target.target_id().clone(),
436 NavigationInProgress::new(tx),
437 ),
438 );
439 } else {
440 let _ = self.submit_external_command(msg, now);
441 }
442 }
443
444 fn next_navigation_id(&mut self) -> NavigationId {
446 let id = NavigationId(self.next_navigation_id);
447 self.next_navigation_id = self.next_navigation_id.wrapping_add(1);
448 id
449 }
450
451 fn create_page(&mut self, params: CreateTargetParams, tx: OneshotSender<Result<Page>>) {
462 let about_blank = params.url == "about:blank";
463 let http_check =
464 !about_blank && params.url.starts_with("http") || params.url.starts_with("file://");
465
466 if about_blank || http_check {
467 let method = params.identifier();
468
469 let Some(conn) = self.conn.as_mut() else {
470 let _ = tx.send(Err(CdpError::msg("connection consumed"))).ok();
471 return;
472 };
473 match serde_json::to_value(params) {
474 Ok(params) => match conn.submit_command(method.clone(), None, params) {
475 Ok(call_id) => {
476 self.pending_commands.insert(
477 call_id,
478 (PendingRequest::CreateTarget(tx), method, Instant::now()),
479 );
480 }
481 Err(err) => {
482 let _ = tx.send(Err(err.into())).ok();
483 }
484 },
485 Err(err) => {
486 let _ = tx.send(Err(err.into())).ok();
487 }
488 }
489 } else {
490 let _ = tx.send(Err(CdpError::NotFound)).ok();
491 }
492 }
493
494 fn on_event(&mut self, event: CdpEventMessage) {
496 if let Some(session_id) = &event.session_id {
497 if let Some(session) = self.sessions.get(session_id.as_str()) {
498 if let Some(target) = self.targets.get_mut(session.target_id()) {
499 return target.on_event(event);
500 }
501 }
502 }
503 let CdpEventMessage { params, method, .. } = event;
504
505 match params {
506 CdpEvent::TargetTargetCreated(ref ev) => self.on_target_created((**ev).clone()),
507 CdpEvent::TargetAttachedToTarget(ref ev) => self.on_attached_to_target(ev.clone()),
508 CdpEvent::TargetTargetDestroyed(ref ev) => self.on_target_destroyed(ev.clone()),
509 CdpEvent::TargetTargetCrashed(ref ev) => self.on_target_crashed(ev.clone()),
510 CdpEvent::TargetDetachedFromTarget(ref ev) => self.on_detached_from_target(ev.clone()),
511 _ => {}
512 }
513
514 chromiumoxide_cdp::consume_event!(match params {
515 |ev| self.event_listeners.start_send(ev),
516 |json| { let _ = self.event_listeners.try_send_custom(&method, json);}
517 });
518 }
519
520 fn on_target_created(&mut self, event: EventTargetCreated) {
524 if !self.browser_contexts.is_empty() {
525 if let Some(ref context_id) = event.target_info.browser_context_id {
526 let bc = BrowserContext {
527 id: Some(context_id.clone()),
528 };
529 if !self.browser_contexts.contains(&bc) {
530 return;
531 }
532 }
533 }
534 let browser_ctx = event
535 .target_info
536 .browser_context_id
537 .clone()
538 .map(BrowserContext::from)
539 .unwrap_or_else(|| self.default_browser_context.clone());
540 let target = Target::new(
541 event.target_info,
542 TargetConfig {
543 ignore_https_errors: self.config.ignore_https_errors,
544 request_timeout: self.config.request_timeout,
545 viewport: self.config.viewport.clone(),
546 request_intercept: self.config.request_intercept,
547 cache_enabled: self.config.cache_enabled,
548 service_worker_enabled: self.config.service_worker_enabled,
549 ignore_visuals: self.config.ignore_visuals,
550 ignore_stylesheets: self.config.ignore_stylesheets,
551 ignore_javascript: self.config.ignore_javascript,
552 ignore_analytics: self.config.ignore_analytics,
553 ignore_prefetch: self.config.ignore_prefetch,
554 allow_first_party_stylesheets: self.config.allow_first_party_stylesheets,
555 allow_first_party_javascript: self.config.allow_first_party_javascript,
556 allow_first_party_visuals: self.config.allow_first_party_visuals,
557 extra_headers: self.config.extra_headers.clone(),
558 only_html: self.config.only_html && self.config.created_first_target,
559 intercept_manager: self.config.intercept_manager,
560 remote_local_policy: self.config.remote_local_policy,
561 max_bytes_allowed: self.config.max_bytes_allowed,
562 max_redirects: self.config.max_redirects,
563 max_main_frame_navigations: self.config.max_main_frame_navigations,
564 whitelist_patterns: self.config.whitelist_patterns.clone(),
565 blacklist_patterns: self.config.blacklist_patterns.clone(),
566 #[cfg(feature = "adblock")]
567 adblock_filter_rules: self.config.adblock_filter_rules.clone(),
568 page_wake: self.page_wake.clone(),
569 page_channel_capacity: self.config.page_channel_capacity,
570 },
571 browser_ctx,
572 );
573
574 let tid = target.target_id().clone();
575 self.target_ids.push(tid.clone());
576 self.targets.insert(tid, target);
577 }
578
579 fn on_attached_to_target(&mut self, event: Box<EventAttachedToTarget>) {
581 let session = Session::new(event.session_id.clone(), event.target_info.target_id);
582 if let Some(target) = self.targets.get_mut(session.target_id()) {
583 target.set_session_id(session.session_id().clone())
584 }
585 self.sessions.insert(event.session_id, session);
586 }
587
588 fn on_detached_from_target(&mut self, event: EventDetachedFromTarget) {
592 if let Some(session) = self.sessions.remove(&event.session_id) {
594 if let Some(target) = self.targets.get_mut(session.target_id()) {
595 target.session_id_mut().take();
596 }
597 }
598 }
599
600 fn on_target_destroyed(&mut self, event: EventTargetDestroyed) {
602 self.attached_targets.remove(&event.target_id);
603
604 if let Some(target) = self.targets.remove(&event.target_id) {
605 if let Some(session) = target.session_id() {
607 self.sessions.remove(session);
608 }
609 }
610 }
611
612 fn on_target_crashed(&mut self, event: EventTargetCrashed) {
635 let crashed_id = event.target_id.clone();
636 let status = event.status.clone();
637 let error_code = event.error_code;
638
639 let to_cancel: Vec<CallId> = self
645 .pending_commands
646 .iter()
647 .filter_map(|(&call_id, (req, _, _))| match req {
648 PendingRequest::ExternalCommand {
649 target_id: Some(tid),
650 ..
651 } if *tid == crashed_id => Some(call_id),
652 PendingRequest::InternalCommand(tid) if *tid == crashed_id => Some(call_id),
653 _ => None,
654 })
655 .collect();
656
657 for call_id in to_cancel {
658 if let Some((req, _, _)) = self.pending_commands.remove(&call_id) {
659 match req {
660 PendingRequest::ExternalCommand { tx, .. } => {
661 let _ = tx.send(Err(CdpError::msg(format!(
662 "target {:?} crashed: {} (errorCode={})",
663 crashed_id, status, error_code
664 ))));
665 }
666 PendingRequest::InternalCommand(_) => {
667 }
670 _ => {}
671 }
672 }
673 }
674
675 self.attached_targets.remove(&crashed_id);
677 if let Some(target) = self.targets.remove(&crashed_id) {
678 if let Some(session) = target.session_id() {
679 self.sessions.remove(session);
680 }
681 }
682 }
683
684 fn evict_timed_out_commands(&mut self, now: Instant) {
689 let deadline = match now.checked_sub(self.config.request_timeout) {
690 Some(d) => d,
691 None => return,
692 };
693
694 let timed_out: Vec<_> = self
695 .pending_commands
696 .iter()
697 .filter(|(_, (_, _, timestamp))| *timestamp < deadline)
698 .map(|(k, _)| *k)
699 .collect();
700
701 for call in timed_out {
702 if let Some((req, _, _)) = self.pending_commands.remove(&call) {
703 match req {
704 PendingRequest::CreateTarget(tx) => {
705 let _ = tx.send(Err(CdpError::Timeout));
706 }
707 PendingRequest::GetTargets(tx) => {
708 let _ = tx.send(Err(CdpError::Timeout));
709 }
710 PendingRequest::Navigate(nav) => {
711 if let Some(nav) = self.navigations.remove(&nav) {
712 match nav {
713 NavigationRequest::Navigate(_, nav) => {
714 let _ = nav.tx.send(Err(CdpError::Timeout));
715 }
716 }
717 }
718 }
719 PendingRequest::ExternalCommand { tx, .. } => {
720 let _ = tx.send(Err(CdpError::Timeout));
721 }
722 PendingRequest::InternalCommand(_) => {}
723 PendingRequest::CloseBrowser(tx) => {
724 let _ = tx.send(Err(CdpError::Timeout));
725 }
726 }
727 }
728 }
729 }
730
731 pub fn event_listeners_mut(&mut self) -> &mut EventListeners {
732 &mut self.event_listeners
733 }
734
735 pub async fn run(mut self) -> Result<()> {
765 use chromiumoxide_types::Message;
766 use tokio::time::MissedTickBehavior;
767 use tokio_tungstenite::tungstenite::{self, error::ProtocolError};
768
769 let page_wake = Arc::new(Notify::new());
771 self.page_wake = Some(page_wake.clone());
772
773 let conn = self
775 .conn
776 .take()
777 .ok_or_else(|| CdpError::msg("Handler::run() called with no connection"))?;
778 let async_conn = conn.into_async();
779 let mut ws_reader = async_conn.reader;
780 let ws_tx = async_conn.cmd_tx;
781 let mut writer_handle = async_conn.writer_handle;
782 let reader_handle = async_conn.reader_handle;
783 let mut next_call_id = async_conn.next_id;
784
785 let mut alloc_call_id = || {
787 let id = chromiumoxide_types::CallId::new(next_call_id);
788 next_call_id = next_call_id.wrapping_add(1);
789 id
790 };
791
792 let mut evict_timer = tokio::time::interval_at(
794 tokio::time::Instant::now() + self.config.request_timeout,
795 self.config.request_timeout,
796 );
797 evict_timer.set_missed_tick_behavior(MissedTickBehavior::Delay);
798
799 macro_rules! ws_submit {
801 ($method:expr, $session_id:expr, $params:expr) => {{
802 let id = alloc_call_id();
803 let call = chromiumoxide_types::MethodCall {
804 id,
805 method: $method,
806 session_id: $session_id,
807 params: $params,
808 };
809 match ws_tx.try_send(call) {
810 Ok(()) => Ok::<_, CdpError>(id),
811 Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
812 tracing::warn!("WS command channel full — dropping command");
813 Err(CdpError::msg("WS command channel full"))
814 }
815 Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
816 Err(CdpError::msg("WS writer closed"))
817 }
818 }
819 }};
820 }
821
822 let run_result: Result<()> = loop {
830 let now = std::time::Instant::now();
831
832 const PER_TARGET_DRAIN_BUDGET: usize = 128;
838
839 for n in (0..self.target_ids.len()).rev() {
840 let target_id = self.target_ids.swap_remove(n);
841
842 if let Some((id, mut target)) = self.targets.remove_entry(&target_id) {
843 {
845 let mut msgs = Vec::new();
846 if let Some(handle) = target.page_mut() {
847 while msgs.len() < PER_TARGET_DRAIN_BUDGET {
848 match handle.rx.try_recv() {
849 Ok(msg) => msgs.push(msg),
850 Err(_) => break,
851 }
852 }
853 }
854 for msg in msgs {
855 target.on_page_message(msg);
856 }
857 }
858
859 while let Some(event) = target.advance(now) {
861 match event {
862 TargetEvent::Request(req) => {
863 if let Ok(call_id) =
864 ws_submit!(req.method.clone(), req.session_id, req.params)
865 {
866 self.pending_commands.insert(
867 call_id,
868 (
869 PendingRequest::InternalCommand(
870 target.target_id().clone(),
871 ),
872 req.method,
873 now,
874 ),
875 );
876 }
877 }
878 TargetEvent::Command(msg) => {
879 if msg.is_navigation() {
880 let (req, tx) = msg.split();
881 let nav_id = self.next_navigation_id();
882 target.goto(FrameRequestedNavigation::new(
883 nav_id,
884 req.clone(),
885 self.config.request_timeout,
886 ));
887 if let Ok(call_id) =
888 ws_submit!(req.method.clone(), req.session_id, req.params)
889 {
890 self.pending_commands.insert(
891 call_id,
892 (PendingRequest::Navigate(nav_id), req.method, now),
893 );
894 }
895 self.navigations.insert(
896 nav_id,
897 NavigationRequest::Navigate(
898 target.target_id().clone(),
899 NavigationInProgress::new(tx),
900 ),
901 );
902 } else if let Ok(call_id) = ws_submit!(
903 msg.method.clone(),
904 msg.session_id.map(Into::into),
905 msg.params
906 ) {
907 let target_id = Some(target.target_id().clone());
911 self.pending_commands.insert(
912 call_id,
913 (
914 PendingRequest::ExternalCommand {
915 tx: msg.sender,
916 target_id,
917 },
918 msg.method,
919 now,
920 ),
921 );
922 }
923 }
924 TargetEvent::NavigationRequest(nav_id, req) => {
925 if let Ok(call_id) =
926 ws_submit!(req.method.clone(), req.session_id, req.params)
927 {
928 self.pending_commands.insert(
929 call_id,
930 (PendingRequest::Navigate(nav_id), req.method, now),
931 );
932 }
933 }
934 TargetEvent::NavigationResult(res) => {
935 self.on_navigation_lifecycle_completed(res);
936 }
937 TargetEvent::BytesConsumed(n) => {
938 if let Some(rem) = self.remaining_bytes.as_mut() {
939 *rem = rem.saturating_sub(n);
940 if *rem == 0 {
941 self.budget_exhausted = true;
942 }
943 }
944 }
945 }
946 }
947
948 target.event_listeners_mut().flush();
950
951 self.targets.insert(id, target);
952 self.target_ids.push(target_id);
953 }
954 }
955
956 self.event_listeners.flush();
958
959 if self.budget_exhausted {
960 for t in self.targets.values_mut() {
961 t.network_manager.set_block_all(true);
962 }
963 }
964
965 if self.closing {
966 break Ok(());
967 }
968
969 tokio::select! {
971 msg = self.from_browser.recv() => {
972 match msg {
973 Some(msg) => {
974 match msg {
975 HandlerMessage::Command(cmd) => {
976 let target_id = cmd
979 .session_id
980 .as_ref()
981 .and_then(|sid| self.sessions.get(sid.as_ref()))
982 .map(|s| s.target_id().clone());
983 if let Ok(call_id) = ws_submit!(
984 cmd.method.clone(),
985 cmd.session_id.map(Into::into),
986 cmd.params
987 ) {
988 self.pending_commands.insert(
989 call_id,
990 (
991 PendingRequest::ExternalCommand {
992 tx: cmd.sender,
993 target_id,
994 },
995 cmd.method,
996 now,
997 ),
998 );
999 }
1000 }
1001 HandlerMessage::FetchTargets(tx) => {
1002 let msg = TARGET_PARAMS_ID.clone();
1003 if let Ok(call_id) = ws_submit!(msg.0.clone(), None, msg.1) {
1004 self.pending_commands.insert(
1005 call_id,
1006 (PendingRequest::GetTargets(tx), msg.0, now),
1007 );
1008 }
1009 }
1010 HandlerMessage::CloseBrowser(tx) => {
1011 let close_msg = CLOSE_PARAMS_ID.clone();
1012 if let Ok(call_id) = ws_submit!(close_msg.0.clone(), None, close_msg.1) {
1013 self.pending_commands.insert(
1014 call_id,
1015 (PendingRequest::CloseBrowser(tx), close_msg.0, now),
1016 );
1017 }
1018 }
1019 HandlerMessage::CreatePage(params, tx) => {
1020 if let Some(ref id) = params.browser_context_id {
1021 self.browser_contexts.insert(BrowserContext::from(id.clone()));
1022 }
1023 self.create_page_async(params, tx, &mut alloc_call_id, &ws_tx, now);
1024 }
1025 HandlerMessage::GetPages(tx) => {
1026 let pages: Vec<_> = self.targets.values_mut()
1027 .filter(|p| p.is_page())
1028 .filter_map(|target| target.get_or_create_page())
1029 .map(|page| Page::from(page.clone()))
1030 .collect();
1031 let _ = tx.send(pages);
1032 }
1033 HandlerMessage::InsertContext(ctx) => {
1034 if self.default_browser_context.id().is_none() {
1035 self.default_browser_context = ctx.clone();
1036 }
1037 self.browser_contexts.insert(ctx);
1038 }
1039 HandlerMessage::DisposeContext(ctx) => {
1040 self.browser_contexts.remove(&ctx);
1041 self.attached_targets.retain(|tid| {
1042 self.targets.get(tid)
1043 .and_then(|t| t.browser_context_id())
1044 .map(|id| Some(id) != ctx.id())
1045 .unwrap_or(true)
1046 });
1047 self.closing = true;
1048 }
1049 HandlerMessage::GetPage(target_id, tx) => {
1050 let page = self.targets.get_mut(&target_id)
1051 .and_then(|target| target.get_or_create_page())
1052 .map(|page| Page::from(page.clone()));
1053 let _ = tx.send(page);
1054 }
1055 HandlerMessage::AddEventListener(req) => {
1056 self.event_listeners.add_listener(req);
1057 }
1058 }
1059 }
1060 None => break Ok(()), }
1062 }
1063
1064 frame = ws_reader.next_message() => {
1065 match frame {
1066 Some(Ok(boxed_msg)) => match *boxed_msg {
1067 Message::Response(resp) => {
1068 self.on_response(resp);
1069 }
1070 Message::Event(ev) => {
1071 self.on_event(ev);
1072 }
1073 },
1074 Some(Err(err)) => {
1075 tracing::error!("WS Connection error: {:?}", err);
1076 if let CdpError::Ws(ref ws_error) = err {
1077 match ws_error {
1078 tungstenite::Error::AlreadyClosed => break Ok(()),
1079 tungstenite::Error::Protocol(detail)
1080 if detail == &ProtocolError::ResetWithoutClosingHandshake =>
1081 {
1082 break Ok(());
1083 }
1084 _ => break Err(err),
1085 }
1086 } else {
1087 break Err(err);
1088 }
1089 }
1090 None => break Ok(()), }
1092 }
1093
1094 _ = page_wake.notified() => {
1095 }
1097
1098 _ = evict_timer.tick() => {
1099 self.evict_timed_out_commands(now);
1100 for t in self.targets.values_mut() {
1101 t.network_manager.evict_stale_entries(now);
1102 t.frame_manager_mut().evict_stale_context_ids();
1103 }
1104 }
1105
1106 result = &mut writer_handle => {
1107 match result {
1109 Ok(Ok(())) => break Ok(()),
1110 Ok(Err(e)) => break Err(e),
1111 Err(e) => break Err(CdpError::msg(format!("WS writer panicked: {e}"))),
1112 }
1113 }
1114 }
1115 };
1116
1117 drop(ws_tx);
1125
1126 if !writer_handle.is_finished() {
1131 let _ = tokio::time::timeout(std::time::Duration::from_millis(500), &mut writer_handle)
1132 .await;
1133 if !writer_handle.is_finished() {
1134 writer_handle.abort();
1135 }
1136 }
1137
1138 reader_handle.abort();
1144
1145 run_result
1146 }
1147
1148 fn create_page_async(
1150 &mut self,
1151 params: CreateTargetParams,
1152 tx: OneshotSender<Result<Page>>,
1153 alloc_call_id: &mut impl FnMut() -> chromiumoxide_types::CallId,
1154 ws_tx: &tokio::sync::mpsc::Sender<chromiumoxide_types::MethodCall>,
1155 now: std::time::Instant,
1156 ) {
1157 let about_blank = params.url == "about:blank";
1158 let http_check =
1159 !about_blank && params.url.starts_with("http") || params.url.starts_with("file://");
1160
1161 if about_blank || http_check {
1162 let method = params.identifier();
1163 match serde_json::to_value(params) {
1164 Ok(params) => {
1165 let id = alloc_call_id();
1166 let call = chromiumoxide_types::MethodCall {
1167 id,
1168 method: method.clone(),
1169 session_id: None,
1170 params,
1171 };
1172 match ws_tx.try_send(call) {
1173 Ok(()) => {
1174 self.pending_commands
1175 .insert(id, (PendingRequest::CreateTarget(tx), method, now));
1176 }
1177 Err(_) => {
1178 let _ = tx
1179 .send(Err(CdpError::msg("WS command channel full or closed")))
1180 .ok();
1181 }
1182 }
1183 }
1184 Err(err) => {
1185 let _ = tx.send(Err(err.into())).ok();
1186 }
1187 }
1188 } else {
1189 let _ = tx.send(Err(CdpError::NotFound)).ok();
1190 }
1191 }
1192
1193 #[cfg(feature = "parallel-handler")]
1199 pub async fn run_parallel(mut self) -> Result<()> {
1200 let conn = self
1204 .conn
1205 .take()
1206 .ok_or_else(|| CdpError::msg("Handler::run_parallel() called with no connection"))?;
1207 let async_conn = conn.into_async();
1208
1209 let next_id = async_conn.next_id;
1212 let boot_call_id = chromiumoxide_types::CallId::new(next_id.saturating_sub(1));
1213 let boot_method = DISCOVER_ID.0.clone();
1214
1215 let router = parallel::Router::new(
1216 self.config,
1217 self.default_browser_context,
1218 self.from_browser,
1219 async_conn.reader,
1220 async_conn.cmd_tx,
1221 boot_call_id,
1222 boot_method,
1223 next_id,
1224 );
1225 let result = router.run().await;
1226
1227 async_conn.writer_handle.abort();
1229 async_conn.reader_handle.abort();
1230
1231 result
1232 }
1233}
1234
1235impl Stream for Handler {
1236 type Item = Result<()>;
1237
1238 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
1239 const BROWSER_MSG_BUDGET: usize = 128;
1245 const PER_TARGET_DRAIN_BUDGET: usize = 128;
1246 const WS_MSG_BUDGET: usize = 512;
1247
1248 let pin = self.get_mut();
1249
1250 let mut dispose = false;
1251 let mut budget_hit = false;
1252
1253 let now = Instant::now();
1254
1255 loop {
1256 let mut browser_msgs = 0usize;
1260 while let Poll::Ready(Some(msg)) = pin.from_browser.poll_recv(cx) {
1261 match msg {
1262 HandlerMessage::Command(cmd) => {
1263 pin.submit_external_command(cmd, now)?;
1264 }
1265 HandlerMessage::FetchTargets(tx) => {
1266 pin.submit_fetch_targets(tx, now);
1267 }
1268 HandlerMessage::CloseBrowser(tx) => {
1269 pin.submit_close(tx, now);
1270 }
1271 HandlerMessage::CreatePage(params, tx) => {
1272 if let Some(ref id) = params.browser_context_id {
1273 pin.browser_contexts
1274 .insert(BrowserContext::from(id.clone()));
1275 }
1276 pin.create_page(params, tx);
1277 }
1278 HandlerMessage::GetPages(tx) => {
1279 let pages: Vec<_> = pin
1280 .targets
1281 .values_mut()
1282 .filter(|p: &&mut Target| p.is_page())
1283 .filter_map(|target| target.get_or_create_page())
1284 .map(|page| Page::from(page.clone()))
1285 .collect();
1286 let _ = tx.send(pages);
1287 }
1288 HandlerMessage::InsertContext(ctx) => {
1289 if pin.default_browser_context.id().is_none() {
1290 pin.default_browser_context = ctx.clone();
1291 }
1292 pin.browser_contexts.insert(ctx);
1293 }
1294 HandlerMessage::DisposeContext(ctx) => {
1295 pin.browser_contexts.remove(&ctx);
1296 pin.attached_targets.retain(|tid| {
1297 pin.targets
1298 .get(tid)
1299 .and_then(|t| t.browser_context_id()) .map(|id| Some(id) != ctx.id())
1301 .unwrap_or(true)
1302 });
1303 pin.closing = true;
1304 dispose = true;
1305 }
1306 HandlerMessage::GetPage(target_id, tx) => {
1307 let page = pin
1308 .targets
1309 .get_mut(&target_id)
1310 .and_then(|target| target.get_or_create_page())
1311 .map(|page| Page::from(page.clone()));
1312 let _ = tx.send(page);
1313 }
1314 HandlerMessage::AddEventListener(req) => {
1315 pin.event_listeners.add_listener(req);
1316 }
1317 }
1318 browser_msgs += 1;
1319 if browser_msgs >= BROWSER_MSG_BUDGET {
1320 budget_hit = true;
1321 break;
1322 }
1323 }
1324
1325 for n in (0..pin.target_ids.len()).rev() {
1326 let target_id = pin.target_ids.swap_remove(n);
1327
1328 if let Some((id, mut target)) = pin.targets.remove_entry(&target_id) {
1329 let mut drained = 0usize;
1330 while let Some(event) = target.poll(cx, now) {
1331 match event {
1332 TargetEvent::Request(req) => {
1333 let _ = pin.submit_internal_command(
1334 target.target_id().clone(),
1335 req,
1336 now,
1337 );
1338 }
1339 TargetEvent::Command(msg) => {
1340 pin.on_target_message(&mut target, msg, now);
1341 }
1342 TargetEvent::NavigationRequest(id, req) => {
1343 pin.submit_navigation(id, req, now);
1344 }
1345 TargetEvent::NavigationResult(res) => {
1346 pin.on_navigation_lifecycle_completed(res)
1347 }
1348 TargetEvent::BytesConsumed(n) => {
1349 if let Some(rem) = pin.remaining_bytes.as_mut() {
1350 *rem = rem.saturating_sub(n);
1351 if *rem == 0 {
1352 pin.budget_exhausted = true;
1353 }
1354 }
1355 }
1356 }
1357 drained += 1;
1358 if drained >= PER_TARGET_DRAIN_BUDGET {
1359 budget_hit = true;
1360 break;
1361 }
1362 }
1363
1364 target.event_listeners_mut().poll(cx);
1366
1367 pin.targets.insert(id, target);
1368 pin.target_ids.push(target_id);
1369 }
1370 }
1371
1372 pin.event_listeners_mut().poll(cx);
1375
1376 let mut done = true;
1377
1378 let mut ws_msgs = Vec::new();
1381 let mut ws_err = None;
1382 {
1383 let Some(conn) = pin.conn.as_mut() else {
1384 return Poll::Ready(Some(Err(CdpError::msg(
1385 "connection consumed by Handler::run()",
1386 ))));
1387 };
1388 while let Poll::Ready(Some(ev)) = Pin::new(&mut *conn).poll_next(cx) {
1389 match ev {
1390 Ok(msg) => ws_msgs.push(msg),
1391 Err(err) => {
1392 ws_err = Some(err);
1393 break;
1394 }
1395 }
1396 if ws_msgs.len() >= WS_MSG_BUDGET {
1397 budget_hit = true;
1398 break;
1399 }
1400 }
1401 }
1402
1403 for boxed_msg in ws_msgs {
1404 match *boxed_msg {
1405 Message::Response(resp) => {
1406 pin.on_response(resp);
1407 if pin.closing {
1408 return Poll::Ready(None);
1409 }
1410 }
1411 Message::Event(ev) => {
1412 pin.on_event(ev);
1413 }
1414 }
1415 done = false;
1416 }
1417
1418 if let Some(err) = ws_err {
1419 tracing::error!("WS Connection error: {:?}", err);
1420 if let CdpError::Ws(ref ws_error) = err {
1421 match ws_error {
1422 Error::AlreadyClosed => {
1423 pin.closing = true;
1424 dispose = true;
1425 }
1426 Error::Protocol(detail)
1427 if detail == &ProtocolError::ResetWithoutClosingHandshake =>
1428 {
1429 pin.closing = true;
1430 dispose = true;
1431 }
1432 _ => return Poll::Ready(Some(Err(err))),
1433 }
1434 } else {
1435 return Poll::Ready(Some(Err(err)));
1436 }
1437 }
1438
1439 if pin.evict_command_timeout.poll_ready(cx) {
1440 pin.evict_timed_out_commands(now);
1442 for t in pin.targets.values_mut() {
1445 t.network_manager.evict_stale_entries(now);
1446 t.frame_manager_mut().evict_stale_context_ids();
1447 }
1448 }
1449
1450 if pin.budget_exhausted {
1451 for t in pin.targets.values_mut() {
1452 t.network_manager.set_block_all(true);
1453 }
1454 }
1455
1456 if dispose {
1457 return Poll::Ready(None);
1458 }
1459
1460 if budget_hit {
1461 cx.waker().wake_by_ref();
1465 return Poll::Pending;
1466 }
1467
1468 if done {
1469 return Poll::Pending;
1471 }
1472 }
1473 }
1474}
1475
1476#[derive(Debug, Clone)]
1478pub struct HandlerConfig {
1479 pub ignore_https_errors: bool,
1481 pub viewport: Option<Viewport>,
1483 pub context_ids: Vec<BrowserContextId>,
1485 pub request_timeout: Duration,
1487 pub request_intercept: bool,
1489 pub cache_enabled: bool,
1491 pub service_worker_enabled: bool,
1493 pub ignore_visuals: bool,
1495 pub ignore_stylesheets: bool,
1497 pub ignore_javascript: bool,
1499 pub allow_first_party_stylesheets: bool,
1504 pub allow_first_party_javascript: bool,
1508 pub allow_first_party_visuals: bool,
1512 pub ignore_analytics: bool,
1514 pub ignore_prefetch: bool,
1516 pub ignore_ads: bool,
1518 pub extra_headers: Option<std::collections::HashMap<String, String>>,
1520 pub only_html: bool,
1522 pub created_first_target: bool,
1524 pub intercept_manager: NetworkInterceptManager,
1526 pub max_bytes_allowed: Option<u64>,
1528 pub max_redirects: Option<usize>,
1535 pub max_main_frame_navigations: Option<u32>,
1539 pub whitelist_patterns: Option<Vec<String>>,
1541 pub blacklist_patterns: Option<Vec<String>>,
1543 pub remote_local_policy: bool,
1548 #[cfg(feature = "adblock")]
1550 pub adblock_filter_rules: Option<Vec<String>>,
1551 pub channel_capacity: usize,
1554 pub page_channel_capacity: usize,
1565 pub connection_retries: u32,
1568}
1569
1570impl Default for HandlerConfig {
1571 fn default() -> Self {
1572 Self {
1573 ignore_https_errors: true,
1574 viewport: Default::default(),
1575 context_ids: Vec::new(),
1576 request_timeout: Duration::from_millis(REQUEST_TIMEOUT),
1577 request_intercept: false,
1578 cache_enabled: true,
1579 service_worker_enabled: true,
1580 ignore_visuals: false,
1581 ignore_stylesheets: false,
1582 ignore_ads: false,
1583 ignore_javascript: false,
1584 allow_first_party_stylesheets: true,
1585 allow_first_party_javascript: true,
1586 allow_first_party_visuals: true,
1587 ignore_analytics: true,
1588 ignore_prefetch: true,
1589 only_html: false,
1590 extra_headers: Default::default(),
1591 created_first_target: false,
1592 intercept_manager: NetworkInterceptManager::Unknown,
1593 max_bytes_allowed: None,
1594 max_redirects: None,
1595 max_main_frame_navigations: None,
1596 whitelist_patterns: None,
1597 blacklist_patterns: None,
1598 remote_local_policy: false,
1599 #[cfg(feature = "adblock")]
1600 adblock_filter_rules: None,
1601 channel_capacity: 4096,
1602 page_channel_capacity: crate::handler::page::DEFAULT_PAGE_CHANNEL_CAPACITY,
1603 connection_retries: crate::conn::DEFAULT_CONNECTION_RETRIES,
1604 }
1605 }
1606}
1607
1608#[derive(Debug)]
1610pub struct NavigationInProgress<T> {
1611 navigated: bool,
1613 response: Option<Response>,
1615 tx: OneshotSender<T>,
1617}
1618
1619impl<T> NavigationInProgress<T> {
1620 pub(crate) fn new(tx: OneshotSender<T>) -> Self {
1621 Self {
1622 navigated: false,
1623 response: None,
1624 tx,
1625 }
1626 }
1627
1628 pub(crate) fn set_response(&mut self, resp: Response) {
1630 self.response = Some(resp);
1631 }
1632
1633 pub(crate) fn set_navigated(&mut self) {
1635 self.navigated = true;
1636 }
1637
1638 #[cfg_attr(not(feature = "parallel-handler"), allow(dead_code))]
1642 pub(crate) fn is_navigated(&self) -> bool {
1643 self.navigated
1644 }
1645
1646 #[cfg_attr(not(feature = "parallel-handler"), allow(dead_code))]
1647 pub(crate) fn take_response(&mut self) -> Option<Response> {
1648 self.response.take()
1649 }
1650
1651 #[cfg_attr(not(feature = "parallel-handler"), allow(dead_code))]
1652 pub(crate) fn into_tx(self) -> OneshotSender<T> {
1653 self.tx
1654 }
1655}
1656
1657#[derive(Debug)]
1659enum NavigationRequest {
1660 Navigate(TargetId, NavigationInProgress<Result<Response>>),
1662 }
1664
1665#[derive(Debug)]
1668enum PendingRequest {
1669 CreateTarget(OneshotSender<Result<Page>>),
1672 GetTargets(OneshotSender<Result<Vec<TargetInfo>>>),
1674 Navigate(NavigationId),
1681 ExternalCommand {
1690 tx: OneshotSender<Result<Response>>,
1691 target_id: Option<TargetId>,
1692 },
1693 InternalCommand(TargetId),
1696 CloseBrowser(OneshotSender<Result<CloseReturns>>),
1698}
1699
1700#[derive(Debug)]
1704pub(crate) enum HandlerMessage {
1705 CreatePage(CreateTargetParams, OneshotSender<Result<Page>>),
1706 FetchTargets(OneshotSender<Result<Vec<TargetInfo>>>),
1707 InsertContext(BrowserContext),
1708 DisposeContext(BrowserContext),
1709 GetPages(OneshotSender<Vec<Page>>),
1710 Command(CommandMessage),
1711 GetPage(TargetId, OneshotSender<Option<Page>>),
1712 AddEventListener(EventListenerRequest),
1713 CloseBrowser(OneshotSender<Result<CloseReturns>>),
1714}
1715
1716#[cfg(test)]
1717mod tests {
1718 use super::*;
1719 use chromiumoxide_cdp::cdp::browser_protocol::target::{AttachToTargetReturns, TargetInfo};
1720
1721 #[test]
1722 fn attach_to_target_response_sets_session_id_before_event_arrives() {
1723 let info = TargetInfo::builder()
1724 .target_id("target-1".to_string())
1725 .r#type("page")
1726 .title("")
1727 .url("about:blank")
1728 .attached(false)
1729 .can_access_opener(false)
1730 .build()
1731 .expect("target info");
1732 let mut target = Target::new(info, TargetConfig::default(), BrowserContext::default());
1733 let method: MethodId = AttachToTargetParams::IDENTIFIER.into();
1734 let result = serde_json::to_value(AttachToTargetReturns::new("session-1".to_string()))
1735 .expect("attach result");
1736 let resp = Response {
1737 id: CallId::new(1),
1738 result: Some(result),
1739 error: None,
1740 };
1741
1742 maybe_store_attach_session_id(&mut target, &method, &resp);
1743
1744 assert_eq!(
1745 target.session_id().map(AsRef::as_ref),
1746 Some("session-1"),
1747 "attach response should seed the flat session id even before Target.attachedToTarget"
1748 );
1749 }
1750
1751 #[test]
1756 fn page_channel_capacity_defaults_to_2048_across_configs() {
1757 use crate::browser::BrowserConfigBuilder;
1758 use crate::handler::page::DEFAULT_PAGE_CHANNEL_CAPACITY;
1759 use crate::handler::target::TargetConfig;
1760
1761 assert_eq!(DEFAULT_PAGE_CHANNEL_CAPACITY, 2048);
1762 assert_eq!(
1763 HandlerConfig::default().page_channel_capacity,
1764 DEFAULT_PAGE_CHANNEL_CAPACITY,
1765 "HandlerConfig default must match the historical 2048 slot count"
1766 );
1767 assert_eq!(
1768 TargetConfig::default().page_channel_capacity,
1769 DEFAULT_PAGE_CHANNEL_CAPACITY,
1770 "TargetConfig default must match the historical 2048 slot count"
1771 );
1772 let builder = BrowserConfigBuilder::default();
1775 let bc = format!("{:?}", builder);
1776 assert!(
1777 bc.contains("page_channel_capacity: 2048"),
1778 "BrowserConfigBuilder must default page_channel_capacity to 2048, got: {bc}",
1779 );
1780 }
1781}