Skip to main content

headless_chrome/browser/
process.rs

1use std::{
2    borrow::BorrowMut,
3    ffi::OsStr,
4    io::{prelude::*, BufRead, BufReader},
5    net,
6    process::{Child, Command, Stdio},
7    time::Duration,
8};
9
10#[cfg(test)]
11use std::cell::RefCell;
12
13use derive_builder::Builder;
14
15use anyhow::{anyhow, Result};
16use log::*;
17use rand::rng;
18use rand::seq::SliceRandom;
19use regex::Regex;
20use thiserror::Error;
21use url::Url;
22#[cfg(windows)]
23use winreg::{enums::HKEY_LOCAL_MACHINE, RegKey};
24
25#[cfg(not(feature = "fetch"))]
26use crate::browser::default_executable;
27use crate::util;
28
29#[cfg(feature = "fetch")]
30use super::fetcher::{Fetcher, FetcherOptions};
31use std::collections::HashMap;
32
33#[cfg(test)]
34struct ForTesting;
35#[cfg(test)]
36impl ForTesting {
37    thread_local! {
38        static USER_DATA_DIR: RefCell<Option<String>> = const { RefCell::new(None) };
39    }
40}
41
42pub struct Process {
43    child_process: TemporaryProcess,
44    pub debug_ws_url: Url,
45}
46
47#[derive(Debug, Error)]
48enum ChromeLaunchError {
49    #[error("Chrome launched, but didn't give us a WebSocket URL before we timed out")]
50    PortOpenTimeout,
51    #[error("There are no available ports between 8000 and 9000 for debugging")]
52    NoAvailablePorts,
53    #[error("The chosen debugging port is already in use")]
54    DebugPortInUse,
55    #[error("You need to set the sandbox(false) option when running as root")]
56    RunningAsRootWithoutNoSandbox,
57}
58
59#[cfg(windows)]
60pub(crate) fn get_chrome_path_from_registry() -> Option<std::path::PathBuf> {
61    RegKey::predef(HKEY_LOCAL_MACHINE)
62        .open_subkey("SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\App Paths\\chrome.exe")
63        .and_then(|key| key.get_value::<String, _>(""))
64        .map(std::path::PathBuf::from)
65        .ok()
66}
67
68struct TemporaryProcess(Child, Option<tempfile::TempDir>);
69
70impl Drop for TemporaryProcess {
71    fn drop(&mut self) {
72        info!("Killing Chrome. PID: {}", self.0.id());
73        self.0.kill().and_then(|()| self.0.wait()).ok();
74        if let Some(dir) = self.1.take() {
75            if let Err(e) = dir.close() {
76                warn!("Failed to close temporary directory: {}", e);
77            }
78        };
79    }
80}
81
82/// Represents the way in which Chrome is run. By default it will search for a Chrome
83/// binary on the system, use an available port for debugging, and start in headless mode.
84#[derive(Clone, Debug, Builder)]
85pub struct LaunchOptions<'a> {
86    /// Determines whether to run headless version of the browser. Defaults to true.
87    #[builder(default = "true")]
88    pub headless: bool,
89
90    /// Determines whether to run the browser with a sandbox.
91    #[builder(default = "true")]
92    pub sandbox: bool,
93
94    /// Automatically open devtools for tabs. Forces headless to be false
95    #[builder(default = "false")]
96    pub devtools: bool,
97
98    /// Determines whether to enable GPU or not. Default to false.
99    #[builder(default = "false")]
100    pub enable_gpu: bool,
101
102    /// Determines whether to run the browser with logging enabled
103    /// (this can cause unwanted new shell window in Windows 10 and above).
104    /// Check <https://github.com/rust-headless-chrome/rust-headless-chrome/issues/371>
105    #[builder(default = "false")]
106    pub enable_logging: bool,
107
108    /// Launch the browser with a specific window width and height.
109    #[builder(default = "None")]
110    pub window_size: Option<(u32, u32)>,
111
112    /// Launch the browser with a specific debugging port.
113    #[builder(default = "None")]
114    pub port: Option<u16>,
115    /// Determines whether SSL certificates should be verified.
116    /// This is unsafe and can lead to MiTM attacks. Make sure you understand the risks
117    /// See <https://www.owasp.org/index.php/Man-in-the-middle_attack>
118    #[builder(default = "true")]
119    pub ignore_certificate_errors: bool,
120
121    /// Path for Chrome or Chromium.
122    ///
123    /// If unspecified, the create will try to automatically detect a suitable binary.
124    #[builder(default = "None")]
125    pub path: Option<std::path::PathBuf>,
126
127    /// User Data (Profile) to use.
128    ///
129    /// If unspecified, a new temp directory is created and used on every launch.
130    #[builder(default = "None")]
131    pub user_data_dir: Option<std::path::PathBuf>,
132
133    /// A list of Chrome extensions to load.
134    ///
135    /// An extension should be a path to a folder containing the extension code.
136    /// CRX files cannot be used directly and must be first extracted.
137    ///
138    /// Note that Chrome does not support loading extensions in headless-mode.
139    /// See <https://bugs.chromium.org/p/chromium/issues/detail?id=706008#c5>
140    #[builder(default)]
141    pub extensions: Vec<&'a OsStr>,
142
143    /// Additional arguments to pass to the browser instance. The list of Chromium
144    /// flags can be found: <http://peter.sh/experiments/chromium-command-line-switches/>.
145    #[builder(default)]
146    pub args: Vec<&'a OsStr>,
147
148    /// Ignore a default given flag
149    #[builder(default)]
150    pub ignore_default_args: Vec<&'a OsStr>,
151
152    /// Disable default arguments
153    #[builder(default)]
154    pub disable_default_args: bool,
155
156    /// The options to use for fetching a version of chrome when `path` is None.
157    ///
158    /// By default, we'll use a revision guaranteed to work with our API and will
159    /// download and install that revision of chrome the first time a Process is created.
160    #[cfg_attr(feature = "fetch", builder(default))]
161    #[cfg(feature = "fetch")]
162    pub fetcher_options: FetcherOptions,
163
164    /// How long to keep the WebSocket to the browser for after not receiving any events from it
165    /// Defaults to 30 seconds
166    #[builder(default = "Duration::from_secs(30)")]
167    pub idle_browser_timeout: Duration,
168
169    /// Environment variables to set for the Chromium process.
170    /// Passes value through to std::process::Command::envs.
171    #[builder(default = "None")]
172    pub process_envs: Option<HashMap<String, String>>,
173
174    /// Setup the proxy server for headless chrome instance
175    #[builder(default = "None")]
176    pub proxy_server: Option<&'a str>,
177}
178
179impl Default for LaunchOptions<'_> {
180    fn default() -> Self {
181        LaunchOptions {
182            headless: true,
183            devtools: false,
184            sandbox: true,
185            enable_gpu: false,
186            enable_logging: false,
187            idle_browser_timeout: Duration::from_secs(30),
188            window_size: None,
189            path: None,
190            user_data_dir: None,
191            port: None,
192            ignore_certificate_errors: true,
193            extensions: Vec::new(),
194            process_envs: None,
195            #[cfg(feature = "fetch")]
196            fetcher_options: Default::default(),
197            args: Vec::new(),
198            ignore_default_args: Vec::new(),
199            disable_default_args: false,
200            proxy_server: None,
201        }
202    }
203}
204
205impl<'a> LaunchOptions<'a> {
206    pub fn default_builder() -> LaunchOptionsBuilder<'a> {
207        LaunchOptionsBuilder::default()
208    }
209}
210
211/// These are passed to the Chrome binary by default.
212/// Via <https://github.com/GoogleChrome/puppeteer/blob/master/lib/Launcher.js#L38>
213pub static DEFAULT_ARGS: [&str; 23] = [
214    "--disable-background-networking",
215    "--enable-features=NetworkService,NetworkServiceInProcess",
216    "--disable-background-timer-throttling",
217    "--disable-backgrounding-occluded-windows",
218    "--disable-breakpad",
219    "--disable-client-side-phishing-detection",
220    "--disable-component-extensions-with-background-pages",
221    "--disable-default-apps",
222    "--disable-dev-shm-usage",
223    "--disable-extensions",
224    // BlinkGenPropertyTrees disabled due to crbug.com/937609
225    "--disable-features=TranslateUI,BlinkGenPropertyTrees",
226    "--disable-hang-monitor",
227    "--disable-ipc-flooding-protection",
228    "--disable-popup-blocking",
229    "--disable-prompt-on-repost",
230    "--disable-renderer-backgrounding",
231    "--disable-sync",
232    "--force-color-profile=srgb",
233    "--metrics-recording-only",
234    "--no-first-run",
235    "--enable-automation",
236    "--password-store=basic",
237    "--use-mock-keychain",
238];
239
240impl Process {
241    pub fn new(mut launch_options: LaunchOptions) -> Result<Self> {
242        if launch_options.path.is_none() {
243            #[cfg(feature = "fetch")]
244            {
245                let fetch = Fetcher::new(launch_options.fetcher_options.clone());
246                launch_options.path = Some(fetch.fetch()?);
247            }
248            #[cfg(not(feature = "fetch"))]
249            {
250                launch_options.path = Some(default_executable().map_err(|e| anyhow!("{}", e))?);
251            }
252        }
253
254        let mut process = Self::start_process(&launch_options)?;
255
256        info!("Started Chrome. PID: {}", process.0.id());
257
258        let url;
259        let mut attempts = 0;
260        loop {
261            if attempts > 10 {
262                return Err(ChromeLaunchError::NoAvailablePorts {}.into());
263            }
264
265            match Self::ws_url_from_output(process.0.borrow_mut()) {
266                Ok(debug_ws_url) => {
267                    url = debug_ws_url;
268                    debug!("Found debugging WS URL: {:?}", url);
269                    break;
270                }
271                Err(error) => {
272                    trace!("Problem getting WebSocket URL from Chrome: {}", error);
273
274                    if let Some(&ChromeLaunchError::RunningAsRootWithoutNoSandbox) =
275                        error.downcast_ref::<ChromeLaunchError>()
276                    {
277                        return Err(error);
278                    }
279
280                    if launch_options.port.is_none() {
281                        process = Self::start_process(&launch_options)?;
282                    } else {
283                        return Err(error);
284                    }
285                }
286            }
287
288            trace!(
289                "Trying again to find available debugging port. Attempts: {}",
290                attempts
291            );
292            attempts += 1;
293        }
294
295        let child = process.0.borrow_mut();
296        child.stderr = None;
297
298        Ok(Self {
299            child_process: process,
300            debug_ws_url: url,
301        })
302    }
303
304    fn start_process(launch_options: &LaunchOptions) -> Result<TemporaryProcess> {
305        let debug_port = if let Some(port) = launch_options.port {
306            port
307        } else {
308            get_available_port().ok_or(ChromeLaunchError::NoAvailablePorts {})?
309        };
310        let port_option = format!("--remote-debugging-port={debug_port}");
311
312        let window_size_option = if let Some((width, height)) = launch_options.window_size {
313            format!("--window-size={width},{height}")
314        } else {
315            String::new()
316        };
317
318        let mut temp_user_data_dir = None;
319
320        // User data directory
321        let user_data_dir = if let Some(dir) = &launch_options.user_data_dir {
322            dir.clone()
323        } else {
324            // picking random data dir so that each a new browser instance is launched
325            // (see man google-chrome)
326            let dir = ::tempfile::Builder::new()
327                .prefix("rust-headless-chrome-profile")
328                .tempdir()?;
329
330            let buf = dir.path().to_path_buf();
331            temp_user_data_dir = Some(dir);
332            buf
333        };
334        let data_dir_option = format!("--user-data-dir={}", &user_data_dir.to_str().unwrap());
335
336        #[cfg(test)]
337        ForTesting::USER_DATA_DIR.with(|dir| {
338            *dir.borrow_mut() = user_data_dir.to_str().map(std::borrow::ToOwned::to_owned);
339        });
340
341        trace!("Chrome will have profile: {}", data_dir_option);
342
343        let mut args = vec![
344            port_option.as_str(),
345            "--verbose",
346            "--log-level=0",
347            "--no-first-run",
348            data_dir_option.as_str(),
349        ];
350
351        if !launch_options.disable_default_args {
352            let ignore_default_args: Vec<&str> = launch_options
353                .ignore_default_args
354                .iter()
355                .map(|arg| arg.to_str().unwrap())
356                .collect();
357
358            let defaults: Vec<_> = DEFAULT_ARGS
359                .iter()
360                .filter(|arg| !ignore_default_args.contains(arg))
361                .collect();
362
363            args.extend(defaults);
364        }
365
366        if !launch_options.args.is_empty() {
367            let extra_args: Vec<&str> = launch_options
368                .args
369                .iter()
370                .map(|a| a.to_str().unwrap())
371                .collect();
372            args.extend(extra_args);
373        }
374
375        if !window_size_option.is_empty() {
376            args.extend([window_size_option.as_str()]);
377        }
378
379        if launch_options.headless && !launch_options.devtools {
380            args.extend(["--headless"]);
381        } else if launch_options.devtools {
382            args.extend(["--auto-open-devtools-for-tabs"]);
383        }
384
385        if launch_options.ignore_certificate_errors {
386            args.extend(["--ignore-certificate-errors"]);
387        }
388
389        if launch_options.enable_logging {
390            args.extend(["--enable-logging"]);
391        }
392
393        if !launch_options.enable_gpu {
394            args.extend(["--disable-gpu"]);
395        }
396
397        let proxy_server_option = if let Some(proxy_server) = launch_options.proxy_server {
398            format!("--proxy-server={proxy_server}")
399        } else {
400            String::new()
401        };
402
403        if !proxy_server_option.is_empty() {
404            args.extend([proxy_server_option.as_str()]);
405        }
406
407        if !launch_options.sandbox {
408            args.extend(["--no-sandbox", "--disable-setuid-sandbox"]);
409        }
410
411        let extension_args: Vec<String> = launch_options
412            .extensions
413            .iter()
414            .map(|e| format!("--load-extension={}", e.to_str().unwrap()))
415            .collect();
416
417        args.extend(extension_args.iter().map(String::as_str));
418
419        let path = launch_options
420            .path
421            .as_ref()
422            .ok_or_else(|| anyhow!("Chrome path required"))?;
423
424        info!("Launching Chrome binary at {:?}", &path);
425        trace!("with CLI arguments: {:?}", args);
426
427        let mut command = Command::new(path);
428
429        if let Some(process_envs) = launch_options.process_envs.clone() {
430            command.envs(process_envs);
431        }
432
433        // Suppress creation of a console window on windows
434        #[cfg(windows)]
435        {
436            use std::os::windows::process::CommandExt;
437            const CREATE_NO_WINDOW: u32 = 0x08000000;
438            command.creation_flags(CREATE_NO_WINDOW);
439        }
440
441        let process = TemporaryProcess(
442            command.args(&args).stderr(Stdio::piped()).spawn()?,
443            temp_user_data_dir,
444        );
445        Ok(process)
446    }
447
448    fn ws_url_from_reader<R>(reader: BufReader<R>) -> Result<Option<String>>
449    where
450        R: Read,
451    {
452        let port_taken_re = Regex::new(r"ERROR.*bind\(\)")?;
453        let root_sandbox = "Running as root without --no-sandbox is not supported";
454
455        let re = Regex::new(r"listening on (.*/devtools/browser/.*)$")?;
456
457        let extract = |text: &str| -> Option<String> {
458            let caps = re.captures(text);
459            let cap = &caps?[1];
460            Some(cap.into())
461        };
462
463        for line in reader.lines() {
464            let chrome_output = line?;
465            trace!("Chrome output: {}", chrome_output);
466
467            if chrome_output.contains(root_sandbox) {
468                return Err(ChromeLaunchError::RunningAsRootWithoutNoSandbox {}.into());
469            }
470
471            if port_taken_re.is_match(&chrome_output) {
472                return Err(ChromeLaunchError::DebugPortInUse {}.into());
473            }
474
475            if let Some(answer) = extract(&chrome_output) {
476                return Ok(Some(answer));
477            }
478        }
479
480        Ok(None)
481    }
482
483    fn ws_url_from_output(child_process: &mut Child) -> Result<Url> {
484        let chrome_output_result = util::Wait::with_timeout(Duration::from_secs(30)).until(|| {
485            let my_stderr = BufReader::new(child_process.stderr.as_mut()?);
486            match Self::ws_url_from_reader(my_stderr) {
487                Ok(output_option) => output_option.map(Ok),
488                Err(err) => Some(Err(err)),
489            }
490        });
491
492        if let Ok(output_result) = chrome_output_result {
493            Ok(Url::parse(&output_result?)?)
494        } else {
495            Err(ChromeLaunchError::PortOpenTimeout {}.into())
496        }
497    }
498
499    pub fn get_id(&self) -> u32 {
500        self.child_process.0.id()
501    }
502}
503
504fn get_available_port() -> Option<u16> {
505    let mut ports: Vec<u16> = (8000..9000).collect();
506    ports.shuffle(&mut rng());
507    ports.iter().find(|port| port_is_available(**port)).copied()
508}
509
510fn port_is_available(port: u16) -> bool {
511    net::TcpListener::bind(("127.0.0.1", port)).is_ok()
512}
513
514#[cfg(test)]
515mod tests {
516    #[cfg(feature = "fetch")]
517    use std::fs;
518    #[cfg(feature = "fetch")]
519    use std::path::PathBuf;
520
521    use std::sync::Once;
522    use std::thread;
523
524    use crate::browser::default_executable;
525
526    use super::*;
527
528    static INIT: Once = Once::new();
529
530    fn setup() {
531        INIT.call_once(|| {
532            env_logger::try_init().unwrap_or(());
533        });
534    }
535
536    #[test]
537    fn can_launch_chrome_and_get_ws_url() {
538        setup();
539        let chrome = super::Process::new(
540            LaunchOptions::default_builder()
541                .path(Some(default_executable().unwrap()))
542                .build()
543                .unwrap(),
544        )
545        .unwrap();
546        info!("{:?}", chrome.debug_ws_url);
547    }
548
549    #[test]
550    #[cfg(feature = "fetch")]
551    fn can_install_chrome_to_dir_and_launch() {
552        use crate::browser::fetcher::CUR_REV;
553        #[cfg(target_os = "linux")]
554        const PLATFORM: &str = "linux";
555        #[cfg(target_os = "macos")]
556        const PLATFORM: &str = "mac";
557        #[cfg(windows)]
558        const PLATFORM: &str = "win";
559
560        let tests_temp_dir = [env!("CARGO_MANIFEST_DIR"), "tests", "temp"]
561            .iter()
562            .collect::<PathBuf>();
563
564        setup();
565
566        // clean up any artifacts from a previous run of this test.
567        // if we do this after it fails on windows because chrome can stay running
568        // for a bit.
569        let mut installed_dir = tests_temp_dir.clone();
570        installed_dir.push(format!("{PLATFORM}-{CUR_REV}"));
571
572        if installed_dir.exists() {
573            info!("Deleting pre-existing install at {:?}", &installed_dir);
574            fs::remove_dir_all(&installed_dir).expect("Could not delete pre-existing install");
575        }
576
577        let chrome = super::Process::new(
578            LaunchOptions::default_builder()
579                .fetcher_options(FetcherOptions::default().with_install_dir(Some(&tests_temp_dir)))
580                .build()
581                .unwrap(),
582        )
583        .unwrap();
584        info!("{:?}", chrome.debug_ws_url);
585    }
586
587    #[test]
588    fn handle_errors_in_chrome_output() {
589        setup();
590        let lines = "[0228/194641.093619:ERROR:socket_posix.cc(144)] bind() returned an error, errno=0: Cannot assign requested address (99)";
591        let reader = BufReader::new(lines.as_bytes());
592        let ws_url_result = Process::ws_url_from_reader(reader);
593        assert!(ws_url_result.is_err());
594    }
595
596    #[test]
597    fn handle_errors_in_chrome_output_gvisor_netlink() {
598        // see https://github.com/atroche/rust-headless-chrome/issues/261
599        setup();
600        let lines = "[0703/145506.975691:ERROR:address_tracker_linux.cc(214)] Could not bind NETLINK socket: Permission denied (13)";
601
602        let reader = BufReader::new(lines.as_bytes());
603        let ws_url_result = Process::ws_url_from_reader(reader);
604        assert!(ws_url_result.is_ok());
605    }
606
607    #[cfg(target_os = "linux")]
608    fn current_child_pids() -> Vec<i32> {
609        use std::fs::File;
610        use std::io::prelude::*;
611        let current_pid = std::process::id();
612        let mut current_process_children_file =
613            File::open(format!("/proc/{current_pid}/task/{current_pid}/children")).unwrap();
614        let mut child_pids = String::new();
615        current_process_children_file
616            .read_to_string(&mut child_pids)
617            .unwrap();
618        child_pids
619            .split_whitespace()
620            .map(|pid_str| pid_str.parse::<i32>().unwrap())
621            .collect()
622    }
623
624    #[test]
625    #[cfg(target_os = "linux")]
626    fn kills_process_on_drop() {
627        setup();
628        {
629            let _chrome = &mut super::Process::new(
630                LaunchOptions::default_builder()
631                    .path(Some(default_executable().unwrap()))
632                    .build()
633                    .unwrap(),
634            )
635            .unwrap();
636        }
637
638        let child_pids = current_child_pids();
639        assert!(child_pids.is_empty());
640    }
641
642    #[test]
643    fn launch_multiple_non_headless_instances() {
644        setup();
645        let mut handles = Vec::new();
646
647        for _ in 0..10 {
648            let handle = thread::spawn(|| {
649                // these sleeps are to make it more likely the chrome startups will overlap
650                std::thread::sleep(std::time::Duration::from_millis(10));
651                let chrome = super::Process::new(
652                    LaunchOptions::default_builder()
653                        .path(Some(default_executable().unwrap()))
654                        .build()
655                        .unwrap(),
656                )
657                .unwrap();
658                std::thread::sleep(std::time::Duration::from_millis(100));
659                chrome.debug_ws_url
660            });
661            handles.push(handle);
662        }
663
664        for handle in handles {
665            handle.join().unwrap();
666        }
667    }
668
669    #[test]
670    fn no_instance_sharing() {
671        setup();
672
673        let mut handles = Vec::new();
674
675        for _ in 0..10 {
676            let chrome = super::Process::new(
677                LaunchOptions::default_builder()
678                    .path(Some(default_executable().unwrap()))
679                    .headless(true)
680                    .build()
681                    .unwrap(),
682            )
683            .unwrap();
684            handles.push(chrome);
685        }
686    }
687
688    #[test]
689    fn test_temporary_user_data_dir_is_removed_automatically() {
690        setup();
691
692        let options = LaunchOptions::default_builder().build().unwrap();
693
694        // Ensure we did not pass an explicit user_data_dir
695        let temp_dir = options.user_data_dir.clone();
696        assert_eq!(None, temp_dir);
697
698        let user_data_dir = {
699            let _chrome = &mut super::Process::new(options).unwrap();
700
701            ForTesting::USER_DATA_DIR.with(|dir| dir.borrow_mut().take())
702        };
703
704        match user_data_dir {
705            Some(temp_path) => {
706                let user_data_dir_exists = std::path::Path::new(&temp_path).is_dir();
707                assert!(!user_data_dir_exists);
708            }
709            None => panic!("No user data dir was created"),
710        }
711    }
712}