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)]
22pub 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 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 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 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 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 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 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 pub fn is_managed(&self) -> bool {
326 self.state.try_lock().map(|s| s.managed).unwrap_or(false)
327 }
328
329 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 pub fn debug_address(&self) -> (&str, u16) {
352 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 if let Some(process) = state.process.take() {
385 drop(process); }
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}