1use std::path::Path;
2use std::process::Stdio;
3use std::time::Duration;
4
5use tokio::process::Child;
6use tokio::time::Instant;
7
8use crate::config::{BrowserConfig, ProfileMode};
9use crate::error::BrowserError;
10use crate::probe::is_port_open;
11use crate::profile::Profile;
12
13const POLL_INTERVAL: Duration = Duration::from_millis(200);
14const PROBE_TIMEOUT: Duration = Duration::from_millis(500);
15const TERMINATE_POLL: Duration = Duration::from_millis(50);
16
17#[doc(hidden)]
20pub struct ChromeProcess {
21 child: Child,
22 pid: u32,
24 keep_alive: bool,
25}
26
27impl std::fmt::Debug for ChromeProcess {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 f.debug_struct("ChromeProcess")
30 .field("pid", &self.pid)
31 .finish_non_exhaustive()
32 }
33}
34
35impl ChromeProcess {
36 #[doc(hidden)]
37 pub fn id(&self) -> u32 {
38 self.pid
39 }
40
41 #[doc(hidden)]
42 pub fn is_running(&mut self) -> bool {
43 matches!(self.child.try_wait(), Ok(None))
44 }
45
46 #[doc(hidden)]
54 pub async fn terminate(&mut self, grace: Duration) -> Result<(), BrowserError> {
55 if !self.is_running() {
56 let _ = self.child.wait().await;
57 return Ok(());
58 }
59
60 #[cfg(unix)]
61 {
62 use nix::sys::signal::{Signal, kill};
63 use nix::unistd::Pid;
64 if self.pid != 0 {
65 let _ = kill(Pid::from_raw(self.pid as i32), Signal::SIGTERM);
66 }
67
68 let deadline = Instant::now() + grace;
69 while Instant::now() < deadline {
70 if let Ok(Some(_)) = self.child.try_wait() {
71 let _ = self.child.wait().await;
72 return Ok(());
73 }
74 tokio::time::sleep(TERMINATE_POLL).await;
75 }
76 }
77
78 let _ = self.child.start_kill();
79 let _ = self.child.wait().await;
80 Ok(())
81 }
82}
83
84impl Drop for ChromeProcess {
85 fn drop(&mut self) {
86 if !self.keep_alive {
87 let _ = self.child.start_kill();
91 }
92 }
93}
94
95#[doc(hidden)]
99pub struct SpawnSpec<'a> {
100 pub path: &'a Path,
101 pub port: u16,
102 pub profile: &'a Profile,
103 pub config: &'a BrowserConfig,
104 pub env: Vec<(String, String)>,
105}
106
107#[doc(hidden)]
118pub fn build_args(config: &BrowserConfig, profile: &Profile, port: u16) -> Vec<String> {
119 let mut args = Vec::new();
120
121 args.push(format!("--remote-debugging-port={port}"));
122 args.push("--no-first-run".to_string());
123 args.push("--no-default-browser-check".to_string());
124 args.push("--disable-session-crashed-bubble".to_string());
125 args.push("--noerrdialogs".to_string());
126 args.push("--disable-dev-shm-usage".to_string());
127
128 if !matches!(profile.mode, ProfileMode::UserDefault)
129 && let Some(dir) = &profile.dir
130 {
131 args.push(format!("--user-data-dir={}", dir.display()));
132 }
133
134 if config.enable_automation {
135 args.push("--enable-automation".to_string());
136 args.push("--disable-infobars".to_string());
137 }
138
139 if config.headless {
140 args.push("--headless=new".to_string());
141 }
142
143 let no_sandbox = config
144 .no_sandbox
145 .unwrap_or_else(|| config.headless || std::env::var_os("CHROME_NO_SANDBOX").is_some());
146 if no_sandbox {
147 args.push("--no-sandbox".to_string());
148 }
149
150 if let Some(proxy) = &config.proxy {
151 args.push(format!("--proxy-server={proxy}"));
152 }
153
154 if let Some((w, h)) = config.window_size {
155 args.push(format!("--window-size={w},{h}"));
156 }
157
158 if let Some(ua) = &config.user_agent {
159 args.push(format!("--user-agent={ua}"));
160 }
161
162 args.extend(config.extra_args.iter().cloned());
163
164 args
165}
166
167#[doc(hidden)]
176pub async fn spawn(spec: SpawnSpec<'_>) -> Result<(ChromeProcess, u16), BrowserError> {
177 let args = build_args(spec.config, spec.profile, spec.port);
178
179 let mut command = tokio::process::Command::new(spec.path);
180 command
181 .args(&args)
182 .stdin(Stdio::null())
183 .stdout(Stdio::null())
184 .stderr(Stdio::null())
185 .kill_on_drop(!spec.config.keep_alive_on_drop);
186
187 for (k, v) in &spec.env {
188 command.env(k, v);
189 }
190
191 let mut child = command
192 .spawn()
193 .map_err(|source| BrowserError::SpawnFailed {
194 path: spec.path.to_path_buf(),
195 source,
196 })?;
197
198 let pid = child.id().unwrap_or(0);
199
200 let startup_timeout = spec.config.startup_timeout;
201 let budget = StartupBudget {
202 deadline: Instant::now() + startup_timeout,
203 timeout: startup_timeout,
204 };
205 let host = spec.config.host.clone();
206
207 let effective_port = if spec.port == 0 {
208 wait_for_ephemeral_port(&mut child, spec.profile, &host, budget).await?
209 } else {
210 wait_for_fixed_port(&mut child, &host, spec.port, budget).await?
211 };
212
213 Ok((
214 ChromeProcess {
215 child,
216 pid,
217 keep_alive: spec.config.keep_alive_on_drop,
218 },
219 effective_port,
220 ))
221}
222
223#[derive(Clone, Copy)]
226struct StartupBudget {
227 deadline: Instant,
228 timeout: Duration,
229}
230
231async fn wait_for_ephemeral_port(
232 child: &mut Child,
233 profile: &Profile,
234 host: &str,
235 budget: StartupBudget,
236) -> Result<u16, BrowserError> {
237 loop {
238 if let Some(_status) = child.try_wait().map_err(BrowserError::Io)? {
239 let hint = early_exit_hint(&profile.mode);
240 return Err(BrowserError::EarlyExit { hint });
241 }
242 if let Some(port) = profile.read_devtools_active_port()
243 && is_port_open(host, port, PROBE_TIMEOUT).await
244 {
245 return Ok(port);
246 }
247 if Instant::now() >= budget.deadline {
248 let _ = child.start_kill();
249 return Err(BrowserError::StartupTimeout {
250 timeout: budget.timeout,
251 });
252 }
253 tokio::time::sleep(POLL_INTERVAL).await;
254 }
255}
256
257async fn wait_for_fixed_port(
258 child: &mut Child,
259 host: &str,
260 port: u16,
261 budget: StartupBudget,
262) -> Result<u16, BrowserError> {
263 loop {
264 if let Some(_status) = child.try_wait().map_err(BrowserError::Io)? {
265 let hint = String::new();
266 return Err(BrowserError::EarlyExit { hint });
267 }
268 if is_port_open(host, port, PROBE_TIMEOUT).await {
269 return Ok(port);
270 }
271 if Instant::now() >= budget.deadline {
272 let _ = child.start_kill();
273 return Err(BrowserError::StartupTimeout {
274 timeout: budget.timeout,
275 });
276 }
277 tokio::time::sleep(POLL_INTERVAL).await;
278 }
279}
280
281fn early_exit_hint(mode: &ProfileMode) -> String {
282 if matches!(mode, ProfileMode::UserDefault) {
283 " (close existing Chrome windows or pass an explicit --user-data-dir via ProfileMode::Persistent)".to_string()
284 } else {
285 String::new()
286 }
287}
288
289#[cfg(test)]
290mod tests {
291 use super::*;
292 use crate::config::{BrowserConfig, LaunchMode, ProfileMode};
293 use crate::profile::Profile;
294
295 fn base_config() -> BrowserConfig {
296 BrowserConfig::builder().mode(LaunchMode::LaunchNew).build()
297 }
298
299 fn has_arg(args: &[String], needle: &str) -> bool {
300 args.iter().any(|a| a == needle)
301 }
302
303 fn has_arg_prefix(args: &[String], prefix: &str) -> bool {
304 args.iter().any(|a| a.starts_with(prefix))
305 }
306
307 fn arg_pos(args: &[String], needle: &str) -> Option<usize> {
308 args.iter().position(|a| a == needle)
309 }
310
311 #[test]
314 fn given_default_config_when_building_args_then_base_flags_and_user_data_dir() {
315 let cfg = base_config();
316 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
317
318 let args = build_args(&cfg, &profile, 9222);
319
320 assert!(has_arg(&args, "--no-first-run"));
321 assert!(has_arg(&args, "--no-default-browser-check"));
322 assert!(has_arg(&args, "--disable-session-crashed-bubble"));
323 assert!(has_arg(&args, "--noerrdialogs"));
324 assert!(has_arg(&args, "--disable-dev-shm-usage"));
325 assert!(has_arg(&args, "--remote-debugging-port=9222"));
326 assert!(
327 has_arg_prefix(&args, "--user-data-dir="),
328 "Ephemeral profile must inject --user-data-dir, got: {args:?}"
329 );
330 assert!(!has_arg(&args, "--headless=new"));
331 assert!(!has_arg(&args, "--no-sandbox"));
332 assert!(!has_arg(&args, "--enable-automation"));
333 assert!(!has_arg_prefix(&args, "--proxy-server="));
334 assert!(!has_arg_prefix(&args, "--window-size="));
335 assert!(!has_arg_prefix(&args, "--user-agent="));
336 }
337
338 #[test]
339 fn given_headless_when_building_args_then_headless_new_and_no_sandbox() {
340 let cfg = BrowserConfig::builder()
341 .mode(LaunchMode::LaunchNew)
342 .headless(true)
343 .build();
344 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
345
346 let args = build_args(&cfg, &profile, 0);
347
348 assert!(has_arg(&args, "--headless=new"));
349 assert!(
350 has_arg(&args, "--no-sandbox"),
351 "headless must imply --no-sandbox by default, got: {args:?}"
352 );
353 }
354
355 #[test]
356 fn given_user_default_profile_when_building_args_then_user_data_dir_omitted() {
357 let cfg = base_config();
358 let profile = Profile::prepare(&ProfileMode::UserDefault).unwrap();
359
360 let args = build_args(&cfg, &profile, 9222);
361
362 assert!(
363 !has_arg_prefix(&args, "--user-data-dir="),
364 "UserDefault must omit --user-data-dir, got: {args:?}"
365 );
366 }
367
368 #[test]
369 fn given_proxy_window_size_and_user_agent_when_building_args_then_present() {
370 let cfg = BrowserConfig::builder()
371 .mode(LaunchMode::LaunchNew)
372 .proxy("http://p:8080")
373 .window_size(1280, 800)
374 .user_agent("ua/1.0")
375 .build();
376 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
377
378 let args = build_args(&cfg, &profile, 9222);
379
380 assert!(has_arg(&args, "--proxy-server=http://p:8080"));
381 assert!(has_arg(&args, "--window-size=1280,800"));
382 assert!(has_arg(&args, "--user-agent=ua/1.0"));
383 }
384
385 #[test]
386 fn given_user_args_when_building_args_then_appended_last_and_preserved_literally() {
387 let cfg = BrowserConfig::builder()
388 .mode(LaunchMode::LaunchNew)
389 .arg("--foo=1")
390 .arg("--weird-flag with spaces")
391 .build();
392 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
393
394 let args = build_args(&cfg, &profile, 9222);
395
396 let foo_pos = arg_pos(&args, "--foo=1").expect("--foo=1 must be present");
397 let weird_pos = arg_pos(&args, "--weird-flag with spaces")
398 .expect("weird arg with spaces must be preserved verbatim");
399 assert!(
400 foo_pos > 0,
401 "--foo=1 must not be at position 0 (port flag goes first), got {foo_pos}"
402 );
403 assert!(
404 weird_pos > foo_pos,
405 "user args must be in insertion order, foo@{foo_pos} weird@{weird_pos}"
406 );
407 assert_eq!(args[args.len() - 2], "--foo=1", "user args must come last");
408 assert_eq!(
409 args[args.len() - 1],
410 "--weird-flag with spaces",
411 "user args must come last"
412 );
413 }
414
415 #[test]
416 fn given_port_zero_when_building_args_then_remote_debugging_port_is_zero() {
417 let cfg = base_config();
418 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
419
420 let args = build_args(&cfg, &profile, 0);
421
422 assert!(
423 has_arg(&args, "--remote-debugging-port=0"),
424 "port 0 must be passed verbatim (Chrome picks ephemeral), got: {args:?}"
425 );
426 }
427
428 #[test]
429 fn given_enable_automation_when_building_args_then_includes_automation_flags() {
430 let cfg = BrowserConfig::builder()
431 .mode(LaunchMode::LaunchNew)
432 .enable_automation(true)
433 .build();
434 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
435
436 let args = build_args(&cfg, &profile, 9222);
437
438 assert!(has_arg(&args, "--enable-automation"));
439 assert!(has_arg(&args, "--disable-infobars"));
440 }
441
442 #[test]
443 fn given_persistent_profile_when_building_args_then_user_data_dir_matches_dir() {
444 let tmp = tempfile::tempdir().unwrap();
445 let dir = tmp.path().to_path_buf();
446 let cfg = base_config();
447 let profile = Profile::prepare(&ProfileMode::Persistent(dir.clone())).unwrap();
448
449 let args = build_args(&cfg, &profile, 9222);
450
451 let udd = args
452 .iter()
453 .find(|a| a.starts_with("--user-data-dir="))
454 .expect("--user-data-dir must be set for Persistent");
455 assert!(
456 udd.contains(dir.to_str().unwrap()),
457 "--user-data-dir must point to the persistent dir, got: {udd}"
458 );
459 }
460
461 #[test]
462 fn given_explicit_no_sandbox_false_when_not_headless_then_no_sandbox_omitted() {
463 let cfg = BrowserConfig::builder()
468 .mode(LaunchMode::LaunchNew)
469 .headless(false)
470 .no_sandbox(false)
471 .build();
472 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
473
474 let args = build_args(&cfg, &profile, 9222);
475
476 assert!(
477 !has_arg(&args, "--no-sandbox"),
478 "explicit no_sandbox(false) without env must omit the flag, got: {args:?}"
479 );
480 }
481
482 #[test]
483 fn given_user_args_when_building_args_then_can_override_base_flags() {
484 let cfg = BrowserConfig::builder()
487 .mode(LaunchMode::LaunchNew)
488 .arg("--remote-debugging-port=55555")
489 .build();
490 let profile = Profile::prepare(&ProfileMode::Ephemeral).unwrap();
491
492 let args = build_args(&cfg, &profile, 9222);
493
494 let first_pos = arg_pos(&args, "--remote-debugging-port=9222")
495 .expect("configured port must appear first");
496 let user_pos = arg_pos(&args, "--remote-debugging-port=55555")
497 .expect("user override must appear last");
498 assert!(user_pos > first_pos, "user override must come last");
499 }
500
501 #[test]
504 fn given_user_default_mode_when_computing_hint_then_mentions_existing_chrome() {
505 let hint = early_exit_hint(&ProfileMode::UserDefault);
506 assert!(
507 hint.to_lowercase().contains("close") || hint.contains("Chrome"),
508 "UserDefault hint must guide the user to close existing windows, got: {hint:?}"
509 );
510 }
511
512 #[test]
513 fn given_ephemeral_mode_when_computing_hint_then_empty() {
514 let hint = early_exit_hint(&ProfileMode::Ephemeral);
515 assert!(
516 hint.is_empty(),
517 "ephemeral hint must be empty, got: {hint:?}"
518 );
519 }
520
521 #[test]
522 fn given_persistent_mode_when_computing_hint_then_empty() {
523 let hint = early_exit_hint(&ProfileMode::Persistent(std::path::PathBuf::from("/tmp/p")));
524 assert!(
525 hint.is_empty(),
526 "persistent hint must be empty, got: {hint:?}"
527 );
528 }
529}