Skip to main content

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}