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