bao_browser/lib.rs
1// @trace REQ-BRW-001 [entity:BrowserContext] [entity:PageHandle]
2// @trace REQ-BRW-004 [entity:Worker] [entity:DedicatedWorkerGlobalScope]
3// @trace REQ-BRW-4 [entity:Worker] [entity:SharedWorker] [entity:ServiceWorker]
4// @trace REQ-CLI-002
5#![allow(dead_code, unused_imports)]
6// REQ-BRW-001: Browser engine integration with servo
7// REQ-BRW-004: Worker constructor bridging to Page Realm (DF-WK-11)
8// REQ-BRW-4: Worker/SharedWorker/ServiceWorker constructors on JS global object
9// REQ-CLI-002: bao browser 子命令 → servo 初始化 + CDP 端口输出
10// REQ-LIB-004: BaoRuntime top-level coordinator
11mod cdp_handler;
12pub mod cdp_memory;
13mod config;
14mod delegate;
15mod error;
16mod page;
17mod page_pool;
18mod permission;
19mod runtime_bridge;
20mod screenshot;
21mod ws_registry;
22
23pub use config::{BaoConfig, BrowserConfig, PageConfig};
24// Bridge-command handler — the servo-side executor that drains
25// BridgeCommand during the event loop (run_with_bridge's per-command entry).
26// Public for e2e tests that drive the same loop shape as run_browser.
27pub use cdp_handler::handle_bridge_command;
28pub use delegate::{
29 crash_safe_teardown_worker, is_javascript_mime_type, AutoCloseWorker, BaoServoDelegate,
30 BaoWebViewDelegate, BaoWebViewState, DedicatedWorkerGlobalScopeState, ServiceWorkerFetchEvent,
31 ServiceWorkerFetchInterceptMode, ServiceWorkerGlobalScopeState, ServiceWorkerHandle,
32 ServiceWorkerRegistrationId, ServiceWorkerRegistrationState, ServiceWorkerRegistrationTracking,
33 ServiceWorkerScopeConfig, SharedWorkerChannelBridge, SharedWorkerConnectEvent,
34 SharedWorkerGlobalScopeState, SharedWorkerHandle, SharedWorkerId, SharedWorkerPortChannel,
35 SharedWorkerPortEndpoints, SharedWorkerPortRef, SharedWorkerScopeConfig,
36 StructuredClonePayload, WorkerChannelBridge, WorkerChannelEndpoints, WorkerErrorEvent,
37 WorkerGlobalScopeState, WorkerHandle, WorkerId, WorkerLifecycleState, WorkerLocation,
38 WorkerMessageDirection, WorkerMessageEvent, WorkerNavigator, WorkerNetworkInformation,
39 WorkerScopeConfig, WorkerScriptLoadError, WorkerScriptLoadResult, WorkerScriptLoadState,
40 WorkerScriptLoader, WorkerScriptSource, WorkerScriptType, WorkerStructuredMessage,
41 WorkerTeardownPath, WorkerTeardownResult,
42};
43pub use error::BrowserError;
44pub use page::{PageHandle, PageState};
45pub use page_pool::PagePool;
46pub use permission::{Permission, PermissionDenied, PermissionGuard};
47pub use runtime_bridge::{
48 register_worker_scope_callback_native, BridgeChannel, BridgeCommand, BridgeReceiver,
49 BridgeResponse, EvaluateResult, RuntimeBridge, WorkerScopeInitFn,
50};
51pub use screenshot::{encode_image, ScreenshotFormat};
52pub use ws_registry::BaoWsRegistry;
53
54use std::rc::Rc;
55use std::sync::Arc;
56use std::time::Duration;
57
58use servo::{Opts, Preferences, Servo, ServoBuilder};
59
60use bao_cdp::domains::ServoTargetProvider;
61use bao_cdp::servo_bridge::bridge_channel;
62use bao_cdp_client::bridge::{translate, ServoEvent};
63use cdp_server::{CdpServer, EventBroadcaster, EventSender, ServerConfig};
64
65// BAO PATCH (BCE-20260627-009): Process-global servo opts initialization.
66// servo's `opts::initialize_options` uses an `OnceLock<Opts>` that panics on re-init,
67// and `opts::get()` lazily fills it with `Default`. If ANY servo code calls `get()`
68// before our explicit `initialize_options`, the OnceLock locks to Default and bao's
69// config (force_isolate_event_loops=true) can never win.
70//
71// `BAO_SERVO_OPTS_INIT` is a `LazyLock` that runs `initialize_options` with bao's
72// config on first access. `BaoRuntime::new` forces it (`.clone()` triggers init)
73// BEFORE constructing `Servo`, winning the OnceLock race process-wide. Multi-instance
74// safety: subsequent `BaoRuntime::new` calls hit the idempotent path in the patched
75// `initialize_options` (same bao config → no-op).
76static BAO_SERVO_OPTS_INIT: std::sync::LazyLock<()> = std::sync::LazyLock::new(|| {
77 servo::opts::initialize_options(Opts {
78 force_isolate_event_loops: true,
79 disable_script_debugger: true,
80 ..Opts::default()
81 });
82});
83
84pub struct BaoRuntime {
85 servo: Rc<Servo>,
86 delegate: Rc<BaoServoDelegate>,
87 page_pool: Rc<PagePool>,
88 cdp_port: Option<u16>,
89 /// Receiver side of the memory:// CDP bridge installed into
90 /// `bao_cdp_client`'s process registry — `run()` drains it so
91 /// servo-touching in-process CDP commands get real execution.
92 cdp_bridge: Option<std::sync::Arc<cdp_memory::MemoryCdpBridge>>,
93 cdp_bridge_rx: Option<bao_cdp::servo_bridge::BridgeReceiver>,
94 /// Generation token for registry teardown (Drop clears only if this
95 /// runtime's bridge is still the installed one).
96 cdp_bridge_token: Option<usize>,
97}
98
99impl BaoRuntime {
100 pub fn new(config: BaoConfig) -> Result<Self, BrowserError> {
101 config.validate().map_err(BrowserError::Init)?;
102
103 // Force-init servo's process-global OnceLock<Opts> BEFORE any servo code
104 // calls get() (which would lazily lock in Default). This wins the race
105 // against servo's get_or_init(Default::default).
106 //
107 // Config-derived opts go in FIRST (initialize_options is
108 // first-writer-wins): the static LazyLock below only exists to win the
109 // same race for code paths that never construct a BaoRuntime, so once
110 // this call runs it is a no-op. `ignore_certificate_errors` is the one
111 // field tests set non-default (WPT posture against local self-signed
112 // TLS fixtures, e.g. the U2 h2 e2e matrix).
113 servo::opts::initialize_options(Opts {
114 force_isolate_event_loops: true,
115 disable_script_debugger: true,
116 ignore_certificate_errors: config.ignore_certificate_errors,
117 ..Opts::default()
118 });
119 std::sync::LazyLock::force(&BAO_SERVO_OPTS_INIT);
120
121 // BUG-ENG-366: `force_isolate_event_loops` only governs servo's event-loop
122 // multiplexing (per-pipeline ScriptThread vs shared). It does NOT control
123 // SpiderMonkey Compartment isolation — every page always gets its own
124 // Window global in a distinct Compartment (servo DOM invariant), and the
125 // Node Realm is created via NewCompartmentAndZone unconditionally
126 // (runtime_bridge::create_node_realm_native). Stealth noise is keyed
127 // per-Realm via bao_stealth::engine_props::set_profile_for_global, so
128 // even with `force_isolate_event_loops: false` each page's Canvas /
129 // Navigator / WebGL / Audio fingerprints remain isolated. The flag is
130 // kept `true` here purely to bound servo's resource use (one ScriptThread
131 // per page) — disabling it does not regress isolation.
132 // @trace REQ-SEC-002 [req:REQ-SEC-002] [req:BUG-ENG-366]
133 //
134 // BAO PATCH (BCE-20260627-009): Idempotent servo config init.
135 // servo's `opts::initialize_options` uses a process-global `OnceLock<Opts>`;
136 // re-initializing panics. Each `BaoRuntime::new` → `Servo::new` →
137 // `initialize_options`. Multiple BaoRuntime instances (production
138 // multi-tenant + concurrent integration tests) therefore collide.
139 // Strategy:
140 // (1) Detect whether servo config is already initialized by reading
141 // `servo::opts::get()` (returns `&'static Opts`, never panics —
142 // falls back to `Default` via `get_or_init`).
143 // (2) `force_isolate_event_loops` is `false` in `Opts::default` but
144 // `true` in our desired config, so it is a reliable sentinel for
145 // "already initialized by a prior BaoRuntime".
146 // (3) On the already-initialized path we skip `.opts(...)` — but
147 // `Servo::new` still calls `initialize_options` internally, so the
148 // vendor-side patch (idempotent `initialize_options`) is the real
149 // guarantee. This bao-layer check just avoids passing conflicting
150 // opts when we know a prior instance already configured servo.
151 let desired_opts = Opts {
152 force_isolate_event_loops: true,
153 ignore_certificate_errors: config.ignore_certificate_errors,
154 // BAO PATCH (BCE-20260621-002): Skip servo's
155 // `JS::Debugger::addDebuggee` path entirely. Bao embeds
156 // servo but uses `bao_cdp` (its own CDP) and never connects
157 // to servo's devtools server, so the servo Debugger is pure
158 // overhead and a SIGSEGV source: `fire_add_debuggee` marks
159 // every page's Realm as a debuggee
160 // (`Realm::setIsDebuggee`), which toggles
161 // BaselineInterpreter debugger instrumentation. Under bao's
162 // multi-page + navigate + later-`evaluate` workload, a
163 // subsequent JIT OSR dereferences
164 // `cx->activation_->prev()->asInterpreter()` as NULL and
165 // SIGSEGVs deterministically. Setting this flag bypasses
166 // `fire_add_debuggee` (gated upstream in
167 // `script_thread.rs`), so `setIsDebuggee` is never called
168 // and the JIT toggle never happens. Servo's default `false`
169 // keeps devtools working for normal servo embedders.
170 disable_script_debugger: true,
171 ..Opts::default()
172 };
173 // `opts::get()` is `get_or_init(Default::default)`: returns the
174 // process-wide config if already set, otherwise `Default` (where
175 // `force_isolate_event_loops == false`). Our config sets it `true`,
176 // so observing `true` here means a prior BaoRuntime already won.
177 let servo_already_initialized = servo::opts::is_initialized();
178
179 // Pref override surface (bao is the embedder; vendor defaults stay
180 // untouched). `Servo::new` ends with
181 // `prefs::set(preferences.unwrap_or_default())` — passing NO builder
182 // preferences resets every pref to `Preferences::default()`. So the
183 // only durable injection point is `ServoBuilder::preferences`, on
184 // BOTH branches (the already-initialized branch would otherwise wipe
185 // the flip below on the next BaoRuntime). Both branches start from
186 // `Preferences::default()` — exactly what `Servo::new` would install
187 // without a builder override — so the only delta is the flip.
188 //
189 // `dom_indexeddb_enabled` defaults to false upstream (experimental),
190 // but bao is a full browser runtime and servo ships a real IDB
191 // implementation; `GlobalScope::obtain_storage_key` reads this pref
192 // at runtime per IDB open, so every page (and worker) scope gets it.
193 let mut preferences = Preferences::default();
194 preferences.dom_indexeddb_enabled = true;
195
196 let servo: Rc<Servo> = Rc::new(if servo_already_initialized {
197 // Already initialized. `Servo::new` (servo.rs:877) ALWAYS calls
198 // `initialize_options(opts.unwrap_or_default())` — if we pass no
199 // `.opts(...)`, it would invoke `initialize_options(Default)`
200 // with (force_isolate_event_loops=false, disable_script_debugger=false),
201 // which DIFFERS from the already-set (true, true) and would trip
202 // the "conflicting bao config" panic in the patched
203 // `initialize_options`. To stay idempotent, we clone the
204 // already-stored opts and re-pass them: `Servo::new`'s internal
205 // `initialize_options(existing.clone())` then sees identical
206 // bao fields and becomes a no-op. This is the only way to keep
207 // `Servo::new`'s unconditional `initialize_options` call safe
208 // across multiple BaoRuntime instances.
209 //
210 // NOTE: we use `is_initialized()` (pure read, no side effect),
211 // NOT `opts::get()`. `opts::get()` uses `get_or_init(Default)`,
212 // which would itself populate the `OnceLock` with defaults on the
213 // very first call — racing against `Servo::new`'s real
214 // `initialize_options((true, true))` and causing a spurious
215 // "conflicting config" panic.
216 ServoBuilder::default()
217 .opts(servo::opts::get().clone())
218 .preferences(preferences)
219 .build()
220 } else {
221 ServoBuilder::default()
222 .opts(desired_opts)
223 .preferences(preferences)
224 .build()
225 });
226
227 let delegate = Rc::new(BaoServoDelegate::new());
228 servo.set_delegate(Rc::clone(&delegate) as Rc<dyn servo::ServoDelegate>);
229
230 let page_pool = Rc::new(PagePool::new(
231 Rc::clone(&servo),
232 Rc::clone(&delegate),
233 &config,
234 ));
235
236 // memory:// CDP transport (published consumer contract:
237 // `Browser::connect("memory://bao")` → `version()`/`pages()`).
238 // Install the host-side bridge into bao_cdp_client's process
239 // registry (last-writer-wins across runtimes); `run()` drains the
240 // receiver so servo-routed commands execute for real.
241 let (cdp_bridge, cdp_bridge_rx) = cdp_memory::MemoryCdpBridge::new("");
242 let cdp_bridge_token =
243 bao_cdp_client::browser::set_process_memory_bridge(
244 cdp_bridge.clone() as std::sync::Arc<dyn bao_cdp_client::transport::InMemoryBridge>,
245 );
246
247 Ok(BaoRuntime {
248 servo,
249 delegate,
250 page_pool,
251 cdp_port: config.cdp_port,
252 cdp_bridge: Some(cdp_bridge),
253 cdp_bridge_rx: Some(cdp_bridge_rx),
254 cdp_bridge_token: Some(cdp_bridge_token),
255 })
256 }
257
258 pub fn page_pool(&self) -> &Rc<PagePool> {
259 &self.page_pool
260 }
261
262 pub fn create_page(&self, config: &PageConfig) -> Result<PageHandle, BrowserError> {
263 let page = self.page_pool.create_page(config)?;
264
265 // Drive servo's event loop until the WebView pipeline is ready.
266 // Without this, inject_all_with_profile() → drain_callbacks() → evaluate_js_web()
267 // will SIGSEGV because servo's script thread hasn't finished setting up
268 // the pipeline for this WebView.
269 page.wait_for_pipeline_ready(Duration::from_secs(5))?;
270
271 runtime_bridge::inject_all_with_profile(&page, &config.stealth_profile)?;
272 // The memory:// flat client face follows the newest page.
273 if let Some(bridge) = &self.cdp_bridge {
274 bridge.set_default_target(page.id().to_string());
275 }
276 Ok(page)
277 }
278
279 /// Create a Dedicated Worker bridged to a page's servo Realm.
280 ///
281 /// This is the primary entry point for the Worker constructor bridging
282 /// Create a Dedicated Worker via servo's native Worker::Constructor.
283 ///
284 /// Per DEC-WK-001 (BCE-20260627-008), bao no longer spawns a
285 /// `bao_engine::WebWorker` bypass thread. Instead, this method dispatches
286 /// `new Worker(url)` into the page via servo's DOM binding, and servo
287 /// constructs the Worker thread + DedicatedWorkerGlobalScope internally.
288 /// bao's role is reduced to:
289 /// 1. Steering stealth profile + DedicatedWorkerGlobalScope Web APIs by
290 /// registering the scope callback via
291 /// `register_worker_scope_callback_native` (invoked at page-init time,
292 /// see `inject_all_with_profile`). The callback runs on the Worker
293 /// thread via the servo vendor patch `drain_worker_scope_callbacks`.
294 /// 2. Tracking the WorkerHandle for CDP observability + page-unload
295 /// termination (criterion #10, AutoCloseWorker).
296 /// 3. Providing a WorkerChannelBridge for page↔worker postMessage
297 /// (criterion #6, DF-WK-4/5) so the bao side can still observe
298 /// structured-clone traffic even though the thread is servo-owned.
299 ///
300 /// The `script` argument is treated as a Worker script URL. For inline
301 /// scripts, callers should materialize a `data:`/`blob:` URL and pass it
302 /// here (or call `create_worker_with_url`).
303 ///
304 /// @trace DEC-WK-001 servo-native Worker path (bypass removed)
305 /// @trace REQ-BRW-004 [entity:Worker] [criterion:1..10] [criterion:12..18]
306 /// @trace REQ-BRW-4 [criterion:C1..C4]
307 pub fn create_worker(
308 &self,
309 page: &PageHandle,
310 script: &str,
311 ) -> Result<WorkerHandle, BrowserError> {
312 self.create_worker_with_url(page, script)
313 }
314
315 /// Create a Dedicated Worker with a script URL resolved by servo's native
316 /// script loading pipeline.
317 ///
318 /// Per DEC-WK-001 (BCE-20260627-008) the bypass `bao_engine::WebWorker`
319 /// path is removed. The Worker is constructed by servo's DOM
320 /// `Worker::Constructor` when `new Worker(url)` is evaluated in the page.
321 /// This method:
322 /// 1. Builds the WorkerId + WorkerHandle (closing/terminated flags +
323 /// REALM_PROFILES global_addr_slot, criterion #18).
324 /// 2. Wires up the WorkerChannelBridge (criterion #6, DF-WK-4/5).
325 /// 3. Registers DedicatedWorkerGlobalScope state for CDP observability
326 /// (criteria #8, #12-17 stealth consistency).
327 /// 4. Tracks the Worker with AutoCloseWorker (criterion #10).
328 /// 5. Dispatches `new Worker(url)` into the page via servo's DOM binding.
329 ///
330 /// @trace DEC-WK-001 servo-native Worker path (bypass removed)
331 /// @trace REQ-BRW-004 [entity:Worker] [DF-WK-2]
332 /// @trace REQ-BRW-4 [criterion:C1..C4]
333 pub fn create_worker_with_url(
334 &self,
335 page: &PageHandle,
336 url: &str,
337 ) -> Result<WorkerHandle, BrowserError> {
338 let webview_state = page.webview_state();
339
340 // Get the page's WorkerScopeConfig for stealth consistency.
341 // The scope callback (registered at page-init via
342 // register_worker_scope_callback_native) inherits this profile onto
343 // the Worker's DedicatedWorkerGlobalScope.
344 // @trace REQ-BRW-004 [criterion:12..17] CRIT-STL-WK
345 let scope_config = webview_state.borrow().worker_scope_config.clone();
346
347 // Generate WorkerId
348 let worker_id = crate::delegate::WorkerId(url.to_string());
349
350 // Create WorkerHandle — tracks closing/terminated state via
351 // Arc<AtomicBool> and the worker_global_addr for REALM_PROFILES
352 // cleanup (criterion #18). The servo-native scope callback (registered
353 // globally) writes the Worker's global address here on creation.
354 // @trace REQ-BRW-004 [criterion:18] REALM_PROFILES 条目注销
355 let handle = WorkerHandle::new(url.to_string());
356
357 // Create channel bridge (DF-WK-4/5). Even though servo owns the Worker
358 // thread, bao still tracks the bidirectional structured-clone traffic
359 // for CDP observability and message logging.
360 // @trace REQ-BRW-004 [criterion:6] DF-WK-4 / DF-WK-5
361 let _endpoints = webview_state
362 .borrow_mut()
363 .create_worker_channel(worker_id.clone());
364
365 // Register DedicatedWorkerGlobalScope state for CDP observability
366 // and stealth consistency verification.
367 // @trace REQ-BRW-004 [entity:DedicatedWorkerGlobalScope]
368 let scope_state =
369 crate::delegate::DedicatedWorkerGlobalScopeState::new(worker_id.clone(), &scope_config);
370 webview_state
371 .borrow_mut()
372 .register_dedicated_worker_scope(worker_id.clone(), scope_state);
373
374 // Track the WorkerHandle with AutoCloseWorker — ensures termination on
375 // page unload (SPEC criterion #10: GlobalScope::track_worker).
376 // @trace REQ-BRW-004 [criterion:10] GlobalScope::track_worker + AutoCloseWorker
377 webview_state.borrow_mut().track_worker(handle.clone());
378
379 // Dispatch `new Worker(url)` into the page via servo's DOM binding.
380 // servo's Worker::Constructor runs the full DF-WK-2 pipeline
381 // (fetch → MIME check → decode → compile) and spawns the Worker thread
382 // internally; bao's scope callback fires on the Worker thread to install
383 // DedicatedWorkerGlobalScope APIs + stealth properties (criteria #8, #12-17).
384 // @trace DEC-WK-001 servo-native Worker path
385 // @trace REQ-BRW-004 [criterion:1] new Worker(url) creates worker thread
386 // @trace REQ-BRW-004 [DF-WK-2] Worker script loading pipeline
387 let new_worker_js = format!(
388 "(function() {{ var w = new Worker({}); return ''; }})();",
389 serde_json::Value::String(url.to_string())
390 );
391 page.evaluate_js_web(&new_worker_js).map_err(|e| {
392 BrowserError::Init(format!(
393 "Failed to dispatch new Worker({:?}) via servo DOM: {}",
394 url, e
395 ))
396 })?;
397
398 log::debug!(
399 "[bao] dispatched new Worker({:?}) via servo DOM (tracked via AutoCloseWorker, DEC-WK-001 native path)",
400 url
401 );
402
403 Ok(handle)
404 }
405
406 pub fn spin_event_loop(&self) {
407 self.servo.spin_event_loop();
408 }
409
410 /// Set the console log forwarding channel on the servo delegate.
411 /// Console messages from servo will be sent to this channel.
412 pub fn set_console_log_channel(&self, tx: std::sync::mpsc::Sender<cdp_server::ConsoleMessage>) {
413 self.delegate.set_console_log_tx(tx);
414 }
415
416 /// Set the structured event forwarding channel on the servo delegate.
417 /// When set, servo callbacks push ServoEvent (Path B) as the primary event path.
418 /// @trace REQ-CDP-006 [entity:ServoDelegateHooks]
419 pub fn set_event_channel(&self, tx: std::sync::mpsc::Sender<ServoEvent>) {
420 self.delegate.set_event_tx(tx);
421 }
422
423 pub fn run(&self) -> Result<(), BrowserError> {
424 let max_wait = Duration::from_secs(300);
425 let start = std::time::Instant::now();
426
427 while start.elapsed() < max_wait {
428 self.servo.spin_event_loop();
429 self.page_pool.check_idle_pages();
430 // memory:// CDP commands (process-registry bridge) execute here:
431 // the drain answers every in-process client command that routed
432 // through the bridge channel (Runtime.evaluate, Target listing…).
433 if let Some(rx) = &self.cdp_bridge_rx {
434 rx.drain(|cmd| cdp_handler::handle_bridge_command(cmd, &self.page_pool));
435 }
436 // Yield instead of sleep — servo spin_event_loop is non-blocking.
437 std::thread::yield_now();
438 }
439
440 let _stats = self.page_pool.stats();
441
442 Ok(())
443 }
444
445 /// Bounded single-thread CDP pump: spin the servo loop and drain the
446 /// memory:// CDP bridge for `duration`. This is the single-threaded
447 /// consumer contract for in-process CDP — the `BaoRuntime` is `!Send`
448 /// (per-thread JSContext model), so the runtime thread pumps while a
449 /// helper thread holds the `Browser` client whose dispatches arrive
450 /// through the bridge channel this drain answers.
451 ///
452 /// `BaoRuntime::run` is the unbounded version (it also drains).
453 pub fn pump_cdp(&self, duration: std::time::Duration) {
454 let start = std::time::Instant::now();
455 while start.elapsed() < duration {
456 self.servo.spin_event_loop();
457 self.page_pool.check_idle_pages();
458 if let Some(rx) = &self.cdp_bridge_rx {
459 rx.drain(|cmd| cdp_handler::handle_bridge_command(cmd, &self.page_pool));
460 }
461 std::thread::yield_now();
462 }
463 }
464
465 /// Run with a CDP bridge that processes commands during the event loop.
466 /// Also drains ServoEvent from the EventSubscriber path (Path B) and
467 /// broadcasts translated CdpEvents via the shared EventBroadcaster.
468 /// @trace REQ-CDP-006 [entity:ServoDelegateHooks]
469 pub fn run_with_bridge(
470 &self,
471 bridge_rx: bao_cdp::servo_bridge::BridgeReceiver,
472 servo_event_rx: std::sync::mpsc::Receiver<ServoEvent>,
473 broadcaster: Arc<EventBroadcaster>,
474 ) -> Result<(), BrowserError> {
475 let max_wait = Duration::from_secs(3600);
476 let start = std::time::Instant::now();
477
478 while start.elapsed() < max_wait {
479 self.servo.spin_event_loop();
480 self.page_pool.check_idle_pages();
481
482 // Process pending CDP bridge commands
483 bridge_rx.drain(|cmd| cdp_handler::handle_bridge_command(cmd, &self.page_pool));
484
485 // Drain ServoEvent from EventSubscriber (Path B) and broadcast
486 // as CDP events via the shared EventBroadcaster.
487 // @trace REQ-CDP-006 [entity:ServoDelegateHooks]
488 while let Ok(servo_event) = servo_event_rx.try_recv() {
489 let cdp_events = translate(servo_event);
490 for cdp_event in cdp_events {
491 broadcaster.send_event(&cdp_event.method, cdp_event.params);
492 }
493 }
494
495 // Yield instead of sleep — check bridge commands more frequently.
496 std::thread::yield_now();
497 }
498
499 Ok(())
500 }
501}
502
503impl Drop for BaoRuntime {
504 fn drop(&mut self) {
505 self.page_pool.close_all();
506 // Remove this runtime's memory bridge unless a newer runtime has
507 // already replaced it in the process registry.
508 if let Some(token) = self.cdp_bridge_token.take() {
509 bao_cdp_client::browser::clear_process_memory_bridge(token);
510 }
511 }
512}
513
514pub fn run_browser(config: BrowserConfig) -> Result<(), BrowserError> {
515 let _stealth = config.stealth_profile.is_some();
516 let url = config.url.clone();
517 let bao_config: BaoConfig = config.into();
518 let cdp_port = bao_config.cdp_port;
519
520 let runtime = BaoRuntime::new(bao_config)?;
521
522 // Create initial page
523 let page_config = PageConfig {
524 url: url.clone(),
525 stealth_profile: None,
526 ..Default::default()
527 };
528 let page = runtime.create_page(&page_config)?;
529 if let Some(ref page_url) = url {
530 log::debug!("[bao] navigating to {}", page_url);
531 }
532
533 if let Some(port) = cdp_port {
534 // Create bridge channel for CDP <-> servo communication
535 let (bridge_tx, bridge_rx) = bridge_channel(Duration::from_secs(30));
536
537 // Create console log forwarding channel: servo delegate → CDP Log domain
538 let (console_tx, console_rx) = std::sync::mpsc::channel::<cdp_server::ConsoleMessage>();
539 runtime.set_console_log_channel(console_tx);
540
541 // Create EventSubscriber pair for structured ServoEvent path (Path B).
542 // @trace REQ-CDP-006 [entity:ServoDelegateHooks]
543 let (event_subscriber, servo_event_rx) = bao_cdp_client::bridge::EventSubscriber::new();
544 runtime.set_event_channel(event_subscriber.sender());
545
546 // Build CdpServer and extract the shared broadcaster BEFORE moving the
547 // server into its thread. The broadcaster is Arc<EventBroadcaster> which
548 // shares the same SessionMap — events sent via this broadcaster reach all
549 // connected WebSocket sessions.
550 // @trace REQ-CDP-006 [entity:ServoDelegateHooks]
551 //
552 // REQ-CDP WS command face: the registry is the real command dispatcher
553 // (BaoWsRegistry → bao_cdp::handle_command → servo bridge), not the
554 // EmptyHandler placeholder — WS sessions get real Page.navigate /
555 // Runtime.evaluate / Target.* round-trips (Playwright direct connect).
556 let registry = Arc::new(BaoWsRegistry::new(bridge_tx.clone()));
557 let config = ServerConfig::builder().host("127.0.0.1").port(port).build();
558 // The default target is the initial page's real id (cdp_handler parses
559 // decimal page ids — a timestamp hex would never resolve to a page).
560 let target_id = page.id().to_string();
561 let mut server = CdpServer::with_registry(config, registry);
562 let provider = Arc::new(ServoTargetProvider::new(
563 bridge_tx,
564 target_id,
565 "127.0.0.1".into(),
566 port,
567 ));
568 server.set_target_provider(provider);
569 server.set_console_receiver(console_rx);
570 // Clone the broadcaster before moving server into the thread.
571 // Arc<EventBroadcaster> shares the same SessionMap with the server.
572 let broadcaster = server.broadcaster();
573
574 let _handle = std::thread::spawn(move || {
575 let _ = server.run();
576 });
577
578 let result = runtime.run_with_bridge(bridge_rx, servo_event_rx, broadcaster);
579 _handle.thread().unpark();
580 return result;
581 }
582
583 runtime.run()
584}