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