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/// Poll `http://127.0.0.1:<port>/json/version` at 50ms cadence for up to 15s.
72/// Returns the `webSocketDebuggerUrl` once available.
73///
74/// On each iteration, also checks whether the child process has already exited;
75/// if so, returns an error including any captured stderr/stdout.
76pub(crate) async fn wait_for_endpoint(
77    port: u16,
78    child: &mut tokio::process::Child,
79) -> Result<String> {
80    use anyhow::{bail, Context};
81    use std::time::Duration;
82    use tokio::io::AsyncReadExt;
83
84    let client = reqwest::Client::builder()
85        .timeout(Duration::from_millis(500))
86        .build()
87        .context("building reqwest client")?;
88    let url = format!("http://127.0.0.1:{port}/json/version");
89
90    let deadline = std::time::Instant::now() + Duration::from_secs(15);
91    loop {
92        if let Some(status) = child.try_wait().context("polling child status")? {
93            let mut stderr_buf = String::new();
94            if let Some(mut s) = child.stderr.take() {
95                let _ = s.read_to_string(&mut stderr_buf).await;
96            }
97            let mut stdout_buf = String::new();
98            if let Some(mut s) = child.stdout.take() {
99                let _ = s.read_to_string(&mut stdout_buf).await;
100            }
101            bail!(
102                "browser process exited before endpoint came up (status: {status}); \
103                 stderr: {stderr_buf}; stdout: {stdout_buf}"
104            );
105        }
106
107        if let Ok(resp) = client.get(&url).send().await {
108            if resp.status().is_success() {
109                if let Ok(json) = resp.json::<serde_json::Value>().await {
110                    if let Some(ws) = json.get("webSocketDebuggerUrl").and_then(|v| v.as_str()) {
111                        return Ok(ws.to_string());
112                    }
113                }
114            }
115        }
116
117        if std::time::Instant::now() >= deadline {
118            // Best-effort kill; bubble up timeout.
119            let _ = child.start_kill();
120            bail!("timed out waiting for browser endpoint on port {port}");
121        }
122        tokio::time::sleep(Duration::from_millis(50)).await;
123    }
124}
125
126#[cfg(test)]
127mod tests {
128    use super::*;
129    use crate::detect::{Engine, Installed, Kind};
130    use tempfile::TempDir;
131
132    fn build_fake_browser() -> std::path::PathBuf {
133        let status = std::process::Command::new(env!("CARGO"))
134            .args(["build", "--example", "fake_browser", "--quiet"])
135            .status()
136            .expect("invoke cargo build");
137        assert!(status.success(), "failed to build fake_browser example");
138        let mut p = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
139        p.push("target");
140        p.push("debug");
141        p.push("examples");
142        #[cfg(windows)]
143        p.push("fake_browser.exe");
144        #[cfg(not(windows))]
145        p.push("fake_browser");
146        assert!(
147            p.exists(),
148            "fake_browser binary not found at {}",
149            p.display()
150        );
151        p
152    }
153
154    #[tokio::test]
155    async fn allocate_free_port_returns_nonzero() {
156        let p = allocate_free_port().unwrap();
157        assert!(p > 0);
158    }
159
160    #[tokio::test]
161    async fn chromium_launch_against_fake() {
162        let exe = build_fake_browser();
163        let tmp = TempDir::new().unwrap();
164        let installed = Installed {
165            kind: Kind::Chrome,
166            executable: exe,
167            version: "fake".into(),
168            engine: Engine::Cdp,
169        };
170        let opts = LaunchOpts {
171            headless: true,
172            profile_dir: tmp.path().join("profile"),
173        };
174        let h = launch(&installed, opts).await.expect("launch chromium");
175        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
176        assert!(h.port > 0);
177        assert_eq!(h.engine, Engine::Cdp);
178        h.kill().await.unwrap();
179    }
180
181    #[tokio::test]
182    async fn firefox_launch_against_fake() {
183        let exe = build_fake_browser();
184        let tmp = TempDir::new().unwrap();
185        let installed = Installed {
186            kind: Kind::Firefox,
187            executable: exe,
188            version: "fake".into(),
189            engine: Engine::Bidi,
190        };
191        let opts = LaunchOpts {
192            headless: true,
193            profile_dir: tmp.path().join("profile"),
194        };
195        let h = launch(&installed, opts).await.expect("launch firefox");
196        assert!(h.endpoint.starts_with("ws://"), "endpoint: {}", h.endpoint);
197        assert!(h.port > 0);
198        assert_eq!(h.engine, Engine::Bidi);
199        h.kill().await.unwrap();
200    }
201
202    #[tokio::test]
203    async fn launch_fails_when_process_exits_immediately() {
204        // Use `/usr/bin/true`-style: a binary that exits immediately.
205        // We use the system `true` on unix; on windows skip.
206        #[cfg(unix)]
207        {
208            let tmp = TempDir::new().unwrap();
209            let installed = Installed {
210                kind: Kind::Chrome,
211                executable: std::path::PathBuf::from("/usr/bin/true"),
212                version: "fake".into(),
213                engine: Engine::Cdp,
214            };
215            let opts = LaunchOpts {
216                headless: true,
217                profile_dir: tmp.path().join("profile"),
218            };
219            let err = launch(&installed, opts).await.unwrap_err();
220            let msg = format!("{err:#}");
221            assert!(
222                msg.contains("exited") || msg.contains("timed out"),
223                "unexpected error: {msg}"
224            );
225        }
226    }
227}