Skip to main content

cdp_browser_lite/
process.rs

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// Only referenced by the Unix grace-period loop; on Windows termination is a
16// direct `start_kill()`, so gate the constant to avoid a dead-code warning.
17#[cfg(unix)]
18const TERMINATE_POLL: Duration = Duration::from_millis(50);
19
20/// Handle to a live Chrome process. Built via [`spawn`] and terminated with
21/// [`ChromeProcess::terminate`] (or left to die in `Drop` as a safety net).
22#[doc(hidden)]
23pub struct ChromeProcess {
24    child: Child,
25    /// PID on Unix; on Windows this is the opaque handle returned by `Child::id`.
26    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    /// Portable stepped termination:
50    /// - Unix: `SIGTERM` via `nix` → wait `grace` (non-blocking, via
51    ///   `tokio::time::sleep`) → if still alive, `child.start_kill()`.
52    /// - Windows: no `SIGTERM`; uses `child.start_kill()` directly.
53    ///
54    /// Returns `Ok(())` once the process has been collected. It is idempotent:
55    /// if already terminated, it just completes the pending `wait()`.
56    #[doc(hidden)]
57    // `grace` is only consumed by the Unix SIGTERM wait; on Windows we go
58    // straight to `start_kill()`, so the parameter is legitimately unused there.
59    #[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            // Safety net: the Child already carries `kill_on_drop(!keep_alive_on_drop)` (set in
94            // `spawn`), but we request an explicit kill to not rely solely on the
95            // flag. Synchronous best-effort: in `Drop` we cannot wait for the process.
96            let _ = self.child.start_kill();
97        }
98    }
99}
100
101/// Spawn specification. `port` is passed to `--remote-debugging-port`
102/// (0 = ephemeral). `env` allows injecting variables (useful for test doubles
103/// like the fake chrome).
104#[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/// Builds the Chrome command-line arguments (PURE — testable without spawn).
114///
115/// Order is stable (aligned with `start_instance` from chrome-debug-mcp):
116/// 1. Base flags
117/// 2. `--user-data-dir` unless `ProfileMode::UserDefault`
118/// 3. `--enable-automation` + `--disable-infobars` (conditional)
119/// 4. `--headless=new` (conditional)
120/// 5. `--no-sandbox` (conditional)
121/// 6. `--proxy-server=`, `--window-size=`, `--user-agent=` (conditional)
122/// 7. User `extra_args` at the end (can override previous flags)
123#[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/// Launches Chrome and waits for the port to become ready.
174///
175/// - Fixed `port`: polls `is_port_open(host, port)` every 200 ms.
176/// - `port = 0`: polls `profile.read_devtools_active_port()` every 200 ms (Chrome
177///   writes the real port to `<user-data-dir>/DevToolsActivePort`).
178///
179/// On each iteration it checks `child.try_wait()` to detect `EarlyExit` before
180/// the port comes up.
181#[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/// Time budget for the startup poll loops: an absolute `deadline` plus the
230/// original `timeout` carried through for the `StartupTimeout` error message.
231#[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    // Feature: Argument construction ──────────────────────────────────────
318
319    #[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        // Override disabling the default (which would be true because
470        // headless=false, but CHROME_NO_SANDBOX could be set in the test
471        // runner's environment). Force the override to false without relying
472        // on the env.
473        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        // PLAN §5 F5 comment: "user args at the end (can override)". Verify
491        // that the ordering allows it (Chrome applies the last occurrence).
492        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    // Feature: early-exit hint ───────────────────────────────────────────
508
509    #[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}