Skip to main content

browser_control/launch/
mod.rs

1//! Browser launcher: spawns a browser process and waits for its remote
2//! debugging endpoint to come up.
3
4use std::path::PathBuf;
5
6use anyhow::Result;
7
8use crate::detect::{Engine, Installed};
9
10pub mod chromium;
11pub mod firefox;
12
13#[derive(Debug, Clone)]
14pub struct LaunchOpts {
15    pub headless: bool,
16    pub profile_dir: PathBuf,
17}
18
19#[derive(Debug)]
20pub struct LaunchedHandle {
21    pub pid: u32,
22    pub port: u16,
23    /// CDP browser-level WS endpoint for Chromium, BiDi WS endpoint for Firefox.
24    pub endpoint: String,
25    pub engine: Engine,
26    pub profile_dir: PathBuf,
27    /// Held so tests can kill the child; in production the handle is `forget()`ed
28    /// so the child is dropped without being killed (Command is configured with
29    /// `kill_on_drop(false)`).
30    pub(crate) child: Option<tokio::process::Child>,
31}
32
33impl LaunchedHandle {
34    /// Release the underlying Child so dropping this handle won't try to manage it.
35    /// Returns the pid. Production callers (e.g. `cli-start`) use this to fully
36    /// detach the browser process.
37    pub fn forget(mut self) -> u32 {
38        let pid = self.pid;
39        if let Some(child) = self.child.take() {
40            // Forget the Child without killing. `kill_on_drop(false)` was set,
41            // so dropping it does not kill the process.
42            drop(child);
43        }
44        pid
45    }
46
47    /// Kill the spawned process. Mainly for tests.
48    pub async fn kill(mut self) -> Result<()> {
49        if let Some(mut c) = self.child.take() {
50            let _ = c.kill().await;
51        }
52        Ok(())
53    }
54}
55
56/// Allocate a free TCP port by binding to 0 then dropping the listener.
57pub fn allocate_free_port() -> Result<u16> {
58    let l = std::net::TcpListener::bind("127.0.0.1:0")?;
59    Ok(l.local_addr()?.port())
60}
61
62/// Top-level entry point: dispatches to chromium or firefox based on kind.
63pub async fn launch(installed: &Installed, opts: LaunchOpts) -> Result<LaunchedHandle> {
64    if installed.kind.is_chromium() {
65        chromium::launch(installed, opts).await
66    } else {
67        firefox::launch(installed, opts).await
68    }
69}
70
71/// Detach the child from the parent's controlling terminal and process group
72/// so that:
73///
74///   * SIGHUP/SIGINT/SIGQUIT sent to the parent's process group (e.g. when
75///     the terminal closes) do not reach the browser.
76///   * `browser-control` can exit immediately after the browser is up
77///     without leaving the child wedged on inherited stdio.
78///
79/// On Unix this calls `setsid(2)` in the child between fork and exec so the
80/// child becomes its own session leader. We deliberately do not call
81/// `daemon(3)`: we still want the spawn `pid` reported by tokio to be the
82/// browser itself, not an intermediate. Stdio is already redirected to a
83/// log file by the per-engine launcher.
84///
85/// On Windows the child detaches naturally from the parent's console once
86/// its handles are closed; nothing to do here today (CREATE_NEW_PROCESS_GROUP
87/// can be revisited if we observe terminal-signal propagation issues).
88pub(crate) fn configure_session_detachment(cmd: &mut tokio::process::Command) {
89    #[cfg(unix)]
90    {
91        // tokio::process::Command::pre_exec is an inherent method on Unix —
92        // no trait import needed.
93        // SAFETY: setsid is async-signal-safe and has no preconditions
94        // beyond "not currently a process-group leader", which is
95        // guaranteed by the fact that we are running in a fresh fork.
96        unsafe {
97            cmd.pre_exec(|| {
98                if libc::setsid() == -1 {
99                    return Err(std::io::Error::last_os_error());
100                }
101                Ok(())
102            });
103        }
104    }
105    #[cfg(not(unix))]
106    {
107        let _ = cmd;
108    }
109}
110
111/// Poll `http://127.0.0.1:<port>/json/version` at 50ms cadence for up to 15s.
112/// Returns the `webSocketDebuggerUrl` once available.
113///
114/// On each iteration, also checks whether the child process has already
115/// exited; if so, returns an error including the tail of the browser log
116/// file (since stdio is redirected there, not piped to us).
117pub(crate) async fn wait_for_endpoint(
118    port: u16,
119    child: &mut tokio::process::Child,
120    log_path: &std::path::Path,
121) -> Result<String> {
122    use anyhow::{bail, Context};
123    use std::time::Duration;
124
125    let client = reqwest::Client::builder()
126        .timeout(Duration::from_millis(500))
127        .build()
128        .context("building reqwest client")?;
129    let url = format!("http://127.0.0.1:{port}/json/version");
130
131    let deadline = std::time::Instant::now() + Duration::from_secs(15);
132    loop {
133        if let Some(status) = child.try_wait().context("polling child status")? {
134            let log = std::fs::read_to_string(log_path).unwrap_or_default();
135            bail!(
136                "browser process exited before endpoint came up (status: {status}); \
137                 log ({}):\n{}",
138                log_path.display(),
139                log
140            );
141        }
142
143        if let Ok(resp) = client.get(&url).send().await {
144            if resp.status().is_success() {
145                if let Ok(json) = resp.json::<serde_json::Value>().await {
146                    if let Some(ws) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
147                        return Ok(ws.to_string());
148                    }
149                }
150            }
151        }
152
153        if std::time::Instant::now() >= deadline {
154            let _ = child.start_kill();
155            bail!(
156                "timed out waiting for browser endpoint on port {port}; see log at {}",
157                log_path.display()
158            );
159        }
160        tokio::time::sleep(Duration::from_millis(50)).await;
161    }
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use crate::detect::{Engine, Installed, Kind};
168    use tempfile::TempDir;
169
170    fn build_fake_browser() -> std::path::PathBuf {
171        let status = std::process::Command::new(env!("CARGO"))
172            .args(["build", "--example", "fake_browser", "--quiet"])
173            .status()
174            .expect("invoke cargo build");
175        assert!(status.success(), "failed to build fake_browser example");
176        let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
177        p.push("target");
178        p.push("debug");
179        p.push("examples");
180        #[cfg(windows)]
181        p.push("fake_browser.exe");
182        #[cfg(not(windows))]
183        p.push("fake_browser");
184        assert!(
185            p.exists(),
186            "fake_browser binary not found at {}",
187            p.display()
188        );
189        p
190    }
191
192    #[tokio::test]
193    async fn allocate_free_port_returns_nonzero() {
194        let p = allocate_free_port().unwrap();
195        assert!(p > 0);
196    }
197
198    #[tokio::test]
199    async fn chromium_launch_against_fake() {
200        let exe = build_fake_browser();
201        let tmp = TempDir::new().unwrap();
202        let installed = Installed {
203            kind: Kind::Chrome,
204            executable: exe,
205            version: "fake".into(),
206            engine: Engine::Cdp,
207        };
208        let opts = LaunchOpts {
209            headless: true,
210            profile_dir: tmp.path().join("profile"),
211        };
212        let h = launch(&installed, opts).await.expect("launch chromium");
213        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
214        assert!(h.port > 0);
215        assert_eq!(h.engine, Engine::Cdp);
216        h.kill().await.unwrap();
217    }
218
219    #[tokio::test]
220    async fn firefox_launch_against_fake() {
221        let exe = build_fake_browser();
222        let tmp = TempDir::new().unwrap();
223        let installed = Installed {
224            kind: Kind::Firefox,
225            executable: exe,
226            version: "fake".into(),
227            engine: Engine::Bidi,
228        };
229        let opts = LaunchOpts {
230            headless: true,
231            profile_dir: tmp.path().join("profile"),
232        };
233        let h = launch(&installed, opts).await.expect("launch firefox");
234        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
235        assert!(h.port > 0);
236        assert_eq!(h.engine, Engine::Bidi);
237        h.kill().await.unwrap();
238    }
239
240    #[tokio::test]
241    async fn launch_fails_when_process_exits_immediately() {
242        // Use `/usr/bin/true`-style: a binary that exits immediately.
243        // We use the system `true` on unix; on windows skip.
244        #[cfg(unix)]
245        {
246            let tmp = TempDir::new().unwrap();
247            let installed = Installed {
248                kind: Kind::Chrome,
249                executable: std::path::PathBuf::from("/usr/bin/true"),
250                version: "fake".into(),
251                engine: Engine::Cdp,
252            };
253            let opts = LaunchOpts {
254                headless: true,
255                profile_dir: tmp.path().join("profile"),
256            };
257            let err = launch(&installed, opts).await.unwrap_err();
258            let msg = format!("{err:#}");
259            assert!(
260                msg.contains("exited") || msg.contains("timed out"),
261                "unexpected error: {msg}"
262            );
263        }
264    }
265}