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/// Best-effort hide/background step for GUI browser launches. Some browsers
112/// on macOS ignore `--start-minimized` during initial app activation; hiding
113/// the just-launched process after the debug endpoint is ready keeps normal
114/// automation from leaving the browser frontmost. Explicit `show` reverses
115/// this by activating the app and bringing a target to front.
116pub(crate) fn background_after_launch(pid: u32) {
117    #[cfg(target_os = "macos")]
118    {
119        let script = format!(
120            "tell application \"System Events\" to set visible of first application process whose unix id is {pid} to false"
121        );
122        match std::process::Command::new("osascript")
123            .arg("-e")
124            .arg(script)
125            .status()
126        {
127            Ok(status) if status.success() => {}
128            Ok(status) => {
129                tracing::warn!(
130                    target = "launch",
131                    pid,
132                    %status,
133                    "failed to hide launched browser process"
134                );
135            }
136            Err(err) => {
137                tracing::warn!(
138                    target = "launch",
139                    pid,
140                    error = %err,
141                    "failed to run osascript to hide launched browser process"
142                );
143            }
144        }
145    }
146    #[cfg(not(target_os = "macos"))]
147    {
148        let _ = pid;
149    }
150}
151
152/// Poll `http://127.0.0.1:<port>/json/version` at 50ms cadence for up to 15s.
153/// Returns the `webSocketDebuggerUrl` once available.
154///
155/// On each iteration, also checks whether the child process has already
156/// exited; if so, returns an error including the tail of the browser log
157/// file (since stdio is redirected there, not piped to us).
158pub(crate) async fn wait_for_endpoint(
159    port: u16,
160    child: &mut tokio::process::Child,
161    log_path: &std::path::Path,
162) -> Result<String> {
163    use anyhow::{bail, Context};
164    use std::time::Duration;
165
166    let client = reqwest::Client::builder()
167        .timeout(Duration::from_millis(500))
168        .build()
169        .context("building reqwest client")?;
170    let url = format!("http://127.0.0.1:{port}/json/version");
171
172    let deadline = std::time::Instant::now() + Duration::from_secs(15);
173    loop {
174        if let Some(status) = child.try_wait().context("polling child status")? {
175            let log = std::fs::read_to_string(log_path).unwrap_or_default();
176            bail!(
177                "browser process exited before endpoint came up (status: {status}); \
178                 log ({}):\n{}",
179                log_path.display(),
180                log
181            );
182        }
183
184        if let Ok(resp) = client.get(&url).send().await {
185            if resp.status().is_success() {
186                if let Ok(json) = resp.json::<serde_json::Value>().await {
187                    if let Some(ws) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
188                        return Ok(ws.to_string());
189                    }
190                }
191            }
192        }
193
194        if std::time::Instant::now() >= deadline {
195            let _ = child.start_kill();
196            bail!(
197                "timed out waiting for browser endpoint on port {port}; see log at {}",
198                log_path.display()
199            );
200        }
201        tokio::time::sleep(Duration::from_millis(50)).await;
202    }
203}
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use crate::detect::{Engine, Installed, Kind};
209    use tempfile::TempDir;
210
211    fn build_fake_browser() -> std::path::PathBuf {
212        let status = std::process::Command::new(env!("CARGO"))
213            .args(["build", "--example", "fake_browser", "--quiet"])
214            .status()
215            .expect("invoke cargo build");
216        assert!(status.success(), "failed to build fake_browser example");
217        let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
218        p.push("target");
219        p.push("debug");
220        p.push("examples");
221        #[cfg(windows)]
222        p.push("fake_browser.exe");
223        #[cfg(not(windows))]
224        p.push("fake_browser");
225        assert!(
226            p.exists(),
227            "fake_browser binary not found at {}",
228            p.display()
229        );
230        p
231    }
232
233    #[tokio::test]
234    async fn allocate_free_port_returns_nonzero() {
235        let p = allocate_free_port().unwrap();
236        assert!(p > 0);
237    }
238
239    #[tokio::test]
240    async fn chromium_launch_against_fake() {
241        let exe = build_fake_browser();
242        let tmp = TempDir::new().unwrap();
243        let installed = Installed {
244            kind: Kind::Chrome,
245            executable: exe,
246            version: "fake".into(),
247            engine: Engine::Cdp,
248        };
249        let opts = LaunchOpts {
250            headless: true,
251            profile_dir: tmp.path().join("profile"),
252        };
253        let h = launch(&installed, opts).await.expect("launch chromium");
254        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
255        assert!(h.port > 0);
256        assert_eq!(h.engine, Engine::Cdp);
257        h.kill().await.unwrap();
258    }
259
260    #[tokio::test]
261    async fn firefox_launch_against_fake() {
262        let exe = build_fake_browser();
263        let tmp = TempDir::new().unwrap();
264        let installed = Installed {
265            kind: Kind::Firefox,
266            executable: exe,
267            version: "fake".into(),
268            engine: Engine::Bidi,
269        };
270        let opts = LaunchOpts {
271            headless: true,
272            profile_dir: tmp.path().join("profile"),
273        };
274        let h = launch(&installed, opts).await.expect("launch firefox");
275        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
276        assert!(h.port > 0);
277        assert_eq!(h.engine, Engine::Bidi);
278        h.kill().await.unwrap();
279    }
280
281    #[tokio::test]
282    async fn launch_fails_when_process_exits_immediately() {
283        // Use `/usr/bin/true`-style: a binary that exits immediately.
284        // We use the system `true` on unix; on windows skip.
285        #[cfg(unix)]
286        {
287            let tmp = TempDir::new().unwrap();
288            let installed = Installed {
289                kind: Kind::Chrome,
290                executable: std::path::PathBuf::from("/usr/bin/true"),
291                version: "fake".into(),
292                engine: Engine::Cdp,
293            };
294            let opts = LaunchOpts {
295                headless: true,
296                profile_dir: tmp.path().join("profile"),
297            };
298            let err = launch(&installed, opts).await.unwrap_err();
299            let msg = format!("{err:#}");
300            assert!(
301                msg.contains("exited") || msg.contains("timed out"),
302                "unexpected error: {msg}"
303            );
304        }
305    }
306}