Skip to main content

cdp_browser_lite/
browser.rs

1use std::fmt;
2use std::sync::Arc;
3use std::time::Duration;
4
5use cdp_lite::client::CdpClient;
6use cdp_lite::error::CdpError;
7use tokio::sync::Mutex as TokioMutex;
8
9use crate::config::{BrowserConfig, LaunchMode};
10use crate::discovery;
11use crate::error::BrowserError;
12use crate::ports;
13use crate::probe;
14use crate::process::{ChromeProcess, SpawnSpec, spawn};
15use crate::profile::Profile;
16
17const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
18const PORT_SEARCH_TRIES: u16 = 100;
19const TERMINATE_GRACE: Duration = Duration::from_millis(500);
20
21#[derive(Debug)]
22/// Represents a managed or attached browser instance.
23pub struct Browser {
24    config: BrowserConfig,
25    state: Arc<TokioMutex<BrowserState>>,
26}
27
28#[doc(hidden)]
29pub struct BrowserState {
30    process: Option<ChromeProcess>,
31    profile: Option<Profile>,
32    actual_port: u16,
33    managed: bool,
34    stopped: bool,
35    client_cache: Option<CdpClient>,
36}
37
38impl fmt::Debug for BrowserState {
39    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
40        f.debug_struct("BrowserState")
41            .field("process", &self.process)
42            .field("profile", &self.profile)
43            .field("actual_port", &self.actual_port)
44            .field("managed", &self.managed)
45            .field("stopped", &self.stopped)
46            .field(
47                "client_cache",
48                &self.client_cache.as_ref().map(|_| "CdpClient"),
49            )
50            .finish()
51    }
52}
53
54impl BrowserState {
55    #[doc(hidden)]
56    pub fn new(
57        process: Option<ChromeProcess>,
58        profile: Option<Profile>,
59        actual_port: u16,
60        managed: bool,
61    ) -> Self {
62        Self {
63            process,
64            profile,
65            actual_port,
66            managed,
67            stopped: false,
68            client_cache: None,
69        }
70    }
71}
72
73impl Browser {
74    /// Ensures a browser is available based on the provided configuration.
75    /// Depending on `LaunchMode`, it will attach, launch a new instance, or try both.
76    pub async fn ensure(config: BrowserConfig) -> Result<Browser, BrowserError> {
77        config.validate()?;
78
79        match config.mode {
80            LaunchMode::AttachOnly => {
81                let port = config.port;
82                if probe::is_chrome_cdp(&config.host, port).await {
83                    Ok(Browser {
84                        config,
85                        state: Arc::new(TokioMutex::new(BrowserState::new(
86                            None, None, port, false,
87                        ))),
88                    })
89                } else {
90                    Err(BrowserError::RemoteUnavailable {
91                        host: config.host.clone(),
92                        port: config.port,
93                    })
94                }
95            }
96            LaunchMode::LaunchNew => {
97                if config.port == 0 {
98                    Self::spawn_managed(config).await
99                } else if probe::is_port_open(&config.host, config.port, PROBE_TIMEOUT).await {
100                    Err(BrowserError::PortConflict { port: config.port })
101                } else {
102                    Self::spawn_managed(config).await
103                }
104            }
105            LaunchMode::Auto => Self::ensure_auto(config).await,
106        }
107    }
108
109    async fn ensure_auto(mut config: BrowserConfig) -> Result<Browser, BrowserError> {
110        if !probe::is_port_open(&config.host, config.port, PROBE_TIMEOUT).await {
111            return Self::spawn_managed(config).await;
112        }
113
114        if !probe::is_chrome_cdp(&config.host, config.port).await {
115            let port =
116                ports::find_free_port_near(&config.host, config.port, PORT_SEARCH_TRIES).await?;
117            config.port = port;
118            return Self::spawn_managed(config).await;
119        }
120
121        let profile = Profile::prepare(&config.profile)?;
122        if profile.singleton_lock_exists() {
123            let port =
124                ports::find_free_port_near(&config.host, config.port, PORT_SEARCH_TRIES).await?;
125            config.port = port;
126            Self::spawn_managed_with_profile(config, profile).await
127        } else {
128            let port = config.port;
129            Ok(Browser {
130                config,
131                state: Arc::new(TokioMutex::new(BrowserState::new(None, None, port, false))),
132            })
133        }
134    }
135
136    async fn spawn_managed(config: BrowserConfig) -> Result<Browser, BrowserError> {
137        let profile = Profile::prepare(&config.profile)?;
138        Self::spawn_managed_with_profile(config, profile).await
139    }
140
141    async fn spawn_managed_with_profile(
142        config: BrowserConfig,
143        profile: Profile,
144    ) -> Result<Browser, BrowserError> {
145        profile.remove_singleton_lock();
146        let chrome_path = config
147            .chrome_path
148            .clone()
149            .map(Ok)
150            .unwrap_or_else(discovery::discover_default)?;
151
152        let spec = SpawnSpec {
153            path: &chrome_path,
154            port: config.port,
155            profile: &profile,
156            config: &config,
157            env: vec![],
158        };
159
160        let (process, actual_port) = spawn(spec).await?;
161
162        Ok(Browser {
163            config,
164            state: Arc::new(TokioMutex::new(BrowserState::new(
165                Some(process),
166                Some(profile),
167                actual_port,
168                true,
169            ))),
170        })
171    }
172
173    /// Opens a fresh CDP connection to `host:port`.
174    ///
175    /// Connection establishment (the `/json/version` lookup plus the WebSocket
176    /// handshake) is bounded by `connect_timeout`; `command_timeout` becomes the
177    /// per-command response timeout carried by the returned [`CdpClient`].
178    async fn connect_cdp(&self, port: u16) -> Result<CdpClient, BrowserError> {
179        let addr = format!("{}:{}", self.config.host, port);
180        match tokio::time::timeout(
181            self.config.connect_timeout,
182            CdpClient::new(&addr, self.config.command_timeout),
183        )
184        .await
185        {
186            Ok(result) => result.map_err(BrowserError::Cdp),
187            Err(_elapsed) => Err(BrowserError::RemoteUnavailable {
188                host: self.config.host.clone(),
189                port,
190            }),
191        }
192    }
193
194    /// Retrieves a CDP client connected to this browser.
195    /// Reuses existing client connection if available and alive.
196    pub async fn client(&self) -> Result<CdpClient, BrowserError> {
197        let mut state = self.state.lock().await;
198
199        if state.stopped {
200            return Err(BrowserError::Stopped);
201        }
202
203        // Check cached client liveness
204        let cached_dead = if let Some(ref client) = state.client_cache {
205            matches!(
206                client
207                    .send_raw_command("Browser.getVersion", serde_json::json!({}))
208                    .await,
209                Err(CdpError::Disconnected) | Err(CdpError::Timeout { .. })
210            )
211        } else {
212            true
213        };
214
215        if !cached_dead {
216            return Ok(state.client_cache.as_ref().unwrap().clone());
217        }
218
219        if cached_dead {
220            state.client_cache = None;
221        }
222
223        if state.managed {
224            let process_alive = state
225                .process
226                .as_mut()
227                .map(|p| p.is_running())
228                .unwrap_or(false);
229
230            if !process_alive {
231                if self.config.auto_relaunch {
232                    drop(state);
233                    return self.relaunch_and_connect().await;
234                } else {
235                    return Err(BrowserError::Stopped);
236                }
237            }
238        }
239
240        let client = self.connect_cdp(state.actual_port).await?;
241
242        state.client_cache = Some(client.clone());
243        Ok(client)
244    }
245
246    async fn relaunch_and_connect(&self) -> Result<CdpClient, BrowserError> {
247        let new_browser = Self::ensure(self.config.clone()).await?;
248
249        let new_port = {
250            let new_state = new_browser.state.lock().await;
251            new_state.actual_port
252        };
253
254        let client = self.connect_cdp(new_port).await?;
255
256        let (new_process, new_profile, new_actual_port, new_managed) = {
257            let mut new_state = new_browser.state.lock().await;
258            (
259                new_state.process.take(),
260                new_state.profile.take(),
261                new_state.actual_port,
262                new_state.managed,
263            )
264        };
265
266        {
267            let mut state = self.state.lock().await;
268            state.process = new_process;
269            state.profile = new_profile;
270            state.actual_port = new_actual_port;
271            state.managed = new_managed;
272            state.stopped = false;
273            state.client_cache = Some(client.clone());
274        }
275
276        Ok(client)
277    }
278
279    /// Stops the browser if managed, or simply closes the connection if attached.
280    /// Also cleans up ephemeral profiles.
281    pub async fn stop(&self) -> Result<(), BrowserError> {
282        let mut state = self.state.lock().await;
283
284        if state.stopped {
285            return Ok(());
286        }
287
288        state.client_cache = None;
289
290        if state.managed {
291            if let Some(ref mut proc) = state.process {
292                proc.terminate(TERMINATE_GRACE).await?;
293            }
294
295            if let Some(ref mut profile) = state.profile {
296                profile.cleanup();
297            }
298        }
299
300        state.process = None;
301        state.profile = None;
302        state.stopped = true;
303
304        Ok(())
305    }
306
307    /// Restarts the browser instance using the original configuration.
308    pub async fn restart(&self) -> Result<(), BrowserError> {
309        self.stop().await?;
310        let new = Self::ensure(self.config.clone()).await?;
311        let mut new_state = new.state.lock().await;
312        let mut state = self.state.lock().await;
313        *state = BrowserState {
314            process: new_state.process.take(),
315            profile: new_state.profile.take(),
316            actual_port: new_state.actual_port,
317            managed: new_state.managed,
318            stopped: false,
319            client_cache: None,
320        };
321        Ok(())
322    }
323
324    /// Returns true if this browser was launched (and is managed) by us.
325    pub fn is_managed(&self) -> bool {
326        self.state.try_lock().map(|s| s.managed).unwrap_or(false)
327    }
328
329    /// Returns true if the browser is alive (running and not stopped).
330    pub fn is_alive(&self) -> bool {
331        let mut state = match self.state.try_lock() {
332            Ok(s) => s,
333            Err(_) => return false,
334        };
335
336        if state.stopped {
337            return false;
338        }
339        if state.managed {
340            state
341                .process
342                .as_mut()
343                .map(|p| p.is_running())
344                .unwrap_or(false)
345        } else {
346            true
347        }
348    }
349
350    /// Returns the remote debugging host and port this browser is listening on.
351    pub fn debug_address(&self) -> (&str, u16) {
352        // Config reference lives in `self`, so &str is valid.
353        let port = self
354            .state
355            .try_lock()
356            .map(|s| s.actual_port)
357            .unwrap_or(self.config.port);
358        (&self.config.host, port)
359    }
360
361    #[doc(hidden)]
362    pub fn pid(&self) -> Option<u32> {
363        let mut state = self.state.try_lock().ok()?;
364        state.process.as_mut().map(|p| p.id())
365    }
366
367    #[doc(hidden)]
368    pub fn test_from_state(config: BrowserConfig, state: BrowserState) -> Self {
369        Browser {
370            config,
371            state: Arc::new(TokioMutex::new(state)),
372        }
373    }
374}
375
376impl Drop for Browser {
377    fn drop(&mut self) {
378        if let Ok(mut state) = self.state.try_lock()
379            && state.managed
380            && !self.config.keep_alive_on_drop
381            && !state.stopped
382        {
383            // Force synchronous kill best-effort
384            if let Some(process) = state.process.take() {
385                drop(process); // will trigger ChromeProcess::drop which start_kill()s
386            }
387            if let Some(mut profile) = state.profile.take() {
388                profile.cleanup();
389            }
390        }
391    }
392}
393
394#[cfg(test)]
395mod tests {
396    use super::*;
397    use crate::config::ProfileMode;
398
399    fn make_attach_only_config(port: u16) -> BrowserConfig {
400        BrowserConfig::builder()
401            .mode(LaunchMode::AttachOnly)
402            .port(port)
403            .build()
404    }
405
406    fn make_launch_new_config(port: u16) -> BrowserConfig {
407        BrowserConfig::builder()
408            .mode(LaunchMode::LaunchNew)
409            .port(port)
410            .startup_timeout(Duration::from_millis(800))
411            .build()
412    }
413
414    fn make_auto_config(port: u16) -> BrowserConfig {
415        BrowserConfig::builder()
416            .mode(LaunchMode::Auto)
417            .port(port)
418            .startup_timeout(Duration::from_millis(800))
419            .build()
420    }
421
422    #[test]
423    fn given_managed_browser_when_is_managed_then_true() {
424        let browser = Browser {
425            config: make_launch_new_config(9222),
426            state: Arc::new(TokioMutex::new(BrowserState::new(None, None, 9222, true))),
427        };
428        assert!(browser.is_managed());
429    }
430
431    #[test]
432    fn given_attached_browser_when_is_managed_then_false() {
433        let browser = Browser {
434            config: make_attach_only_config(9222),
435            state: Arc::new(TokioMutex::new(BrowserState::new(None, None, 9222, false))),
436        };
437        assert!(!browser.is_managed());
438    }
439
440    #[test]
441    fn given_stopped_browser_when_is_alive_then_false() {
442        let browser = Browser {
443            config: make_launch_new_config(9222),
444            state: Arc::new(TokioMutex::new({
445                let mut s = BrowserState::new(None, None, 9222, true);
446                s.stopped = true;
447                s
448            })),
449        };
450        assert!(!browser.is_alive());
451    }
452
453    #[test]
454    fn given_attached_not_stopped_when_is_alive_then_true() {
455        let browser = Browser {
456            config: make_attach_only_config(9222),
457            state: Arc::new(TokioMutex::new(BrowserState::new(None, None, 9222, false))),
458        };
459        assert!(browser.is_alive());
460    }
461
462    #[test]
463    fn given_managed_no_process_when_is_alive_then_false() {
464        let browser = Browser {
465            config: make_launch_new_config(9222),
466            state: Arc::new(TokioMutex::new(BrowserState::new(None, None, 9222, true))),
467        };
468        assert!(!browser.is_alive());
469    }
470
471    #[test]
472    fn given_browser_when_debug_address_then_returns_host_and_actual_port() {
473        let browser = Browser {
474            config: BrowserConfig::builder()
475                .mode(LaunchMode::LaunchNew)
476                .host("127.0.0.1")
477                .port(9222)
478                .startup_timeout(Duration::from_secs(1))
479                .build(),
480            state: Arc::new(TokioMutex::new(BrowserState::new(None, None, 37251, true))),
481        };
482        let (host, port) = browser.debug_address();
483        assert_eq!(host, "127.0.0.1");
484        assert_eq!(port, 37251);
485    }
486
487    #[test]
488    fn given_attach_only_valid_port_when_validated_then_ok() {
489        make_attach_only_config(9222)
490            .validate()
491            .expect("AttachOnly with port must validate");
492    }
493
494    #[test]
495    fn given_attach_only_port_zero_when_validated_then_err() {
496        let cfg = make_attach_only_config(0);
497        assert!(
498            cfg.validate().is_err(),
499            "AttachOnly with port 0 must fail validation"
500        );
501    }
502
503    #[test]
504    fn given_auto_port_zero_when_validated_then_err() {
505        let cfg = make_auto_config(0);
506        assert!(
507            cfg.validate().is_err(),
508            "Auto with port 0 must fail validation"
509        );
510    }
511
512    #[test]
513    fn given_auto_remote_host_when_validated_then_err() {
514        let cfg = BrowserConfig::builder()
515            .mode(LaunchMode::Auto)
516            .host("10.0.0.5")
517            .port(9222)
518            .build();
519        assert!(
520            cfg.validate().is_err(),
521            "Auto with remote host must fail validation"
522        );
523    }
524
525    #[test]
526    fn given_launch_new_remote_host_when_validated_then_err() {
527        let cfg = BrowserConfig::builder()
528            .mode(LaunchMode::LaunchNew)
529            .host("10.0.0.5")
530            .port(9222)
531            .build();
532        assert!(
533            cfg.validate().is_err(),
534            "LaunchNew with remote host must fail validation"
535        );
536    }
537
538    #[test]
539    fn given_launch_new_port_zero_when_validated_then_ok() {
540        make_launch_new_config(0)
541            .validate()
542            .expect("LaunchNew with ephemeral port must validate");
543    }
544
545    #[test]
546    fn given_stopped_browser_when_stop_again_then_ok_and_idempotent() {
547        let browser = Browser {
548            config: make_attach_only_config(9222),
549            state: Arc::new(TokioMutex::new({
550                let mut s = BrowserState::new(None, None, 9222, false);
551                s.stopped = true;
552                s
553            })),
554        };
555        let rt = tokio::runtime::Runtime::new().unwrap();
556        rt.block_on(async {
557            browser
558                .stop()
559                .await
560                .expect("stop on already-stopped browser must be Ok");
561        });
562    }
563
564    #[test]
565    fn given_ephemeral_profile_when_singleton_lock_check_then_false() {
566        let profile =
567            Profile::prepare(&ProfileMode::Ephemeral).expect("ephemeral profile must succeed");
568        assert!(!profile.singleton_lock_exists());
569    }
570
571    #[test]
572    fn given_persistent_profile_no_lock_when_check_then_false() {
573        let tmp = tempfile::tempdir().unwrap();
574        let dir = tmp.path().to_path_buf();
575        let profile = Profile::prepare(&ProfileMode::Persistent(dir))
576            .expect("persistent profile must succeed");
577        assert!(!profile.singleton_lock_exists());
578    }
579
580    #[test]
581    fn given_persistent_profile_with_lock_when_check_then_true() {
582        let tmp = tempfile::tempdir().unwrap();
583        let dir = tmp.path().to_path_buf();
584        std::fs::write(dir.join("SingletonLock"), "").unwrap();
585        let profile = Profile::prepare(&ProfileMode::Persistent(dir))
586            .expect("persistent profile must succeed");
587        assert!(profile.singleton_lock_exists());
588    }
589}