browser_control/launch/
mod.rs1use 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 pub endpoint: String,
25 pub engine: Engine,
26 pub profile_dir: PathBuf,
27 pub(crate) child: Option<tokio::process::Child>,
31}
32
33impl LaunchedHandle {
34 pub fn forget(mut self) -> u32 {
38 let pid = self.pid;
39 if let Some(child) = self.child.take() {
40 drop(child);
43 }
44 pid
45 }
46
47 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
56pub 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
62pub 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
71pub(crate) fn configure_session_detachment(cmd: &mut tokio::process::Command) {
89 #[cfg(unix)]
90 {
91 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
111pub(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 #[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}