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