browser_automation_cli/native/cdp/
lightpanda.rs1#![allow(missing_docs)]
2use std::collections::VecDeque;
3use std::io::{BufRead, BufReader};
4use std::net::TcpListener;
5use std::path::PathBuf;
6use std::process::{Child, Command, Stdio};
7use std::sync::{Arc, Mutex};
8use std::time::Duration;
9
10use super::discovery::discover_cdp_url_with_timeout;
11
12const LIGHTPANDA_STARTUP_TIMEOUT: Duration = Duration::from_secs(10);
13const LIGHTPANDA_POLL_INTERVAL: Duration = Duration::from_millis(100);
14const LIGHTPANDA_DISCOVERY_TIMEOUT: Duration = Duration::from_millis(500);
15const LIGHTPANDA_SESSION_TIMEOUT_SECS: u64 = 604800; const MAX_LOG_LINES: usize = 40;
17
18pub struct LightpandaProcess {
19 child: Child,
20 pub ws_url: String,
21 _log_drainers: Vec<std::thread::JoinHandle<()>>,
22}
23
24impl LightpandaProcess {
25 pub fn kill(&mut self) {
26 let _ = self.child.kill();
27 let _ = self.child.wait();
28 }
29}
30
31impl Drop for LightpandaProcess {
32 fn drop(&mut self) {
33 self.kill();
34 }
35}
36
37#[derive(Default)]
38pub struct LightpandaLaunchOptions {
39 pub executable_path: Option<String>,
40 pub proxy: Option<String>,
41 pub port: Option<u16>,
42}
43
44fn build_lightpanda_serve_args(port: u16, proxy: Option<&str>) -> Vec<String> {
45 let mut args = vec![
46 "serve".to_string(),
47 "--host".to_string(),
48 "127.0.0.1".to_string(),
49 "--port".to_string(),
50 port.to_string(),
51 "--timeout".to_string(),
52 LIGHTPANDA_SESSION_TIMEOUT_SECS.to_string(),
53 ];
54
55 if let Some(proxy) = proxy {
56 args.push("--http_proxy".to_string());
57 args.push(proxy.to_string());
58 }
59
60 args
61}
62
63#[derive(Clone, Default)]
64struct LaunchLogBuffer {
65 stdout: Arc<Mutex<VecDeque<String>>>,
66 stderr: Arc<Mutex<VecDeque<String>>>,
67}
68
69impl LaunchLogBuffer {
70 fn push_stdout(&self, line: String) {
71 push_bounded(&self.stdout, line);
72 }
73
74 fn push_stderr(&self, line: String) {
75 push_bounded(&self.stderr, line);
76 }
77
78 fn snapshot_stdout(&self) -> Vec<String> {
79 self.stdout
80 .lock()
81 .expect("stdout log buffer poisoned")
82 .iter()
83 .cloned()
84 .collect()
85 }
86
87 fn snapshot_stderr(&self) -> Vec<String> {
88 self.stderr
89 .lock()
90 .expect("stderr log buffer poisoned")
91 .iter()
92 .cloned()
93 .collect()
94 }
95}
96
97fn push_bounded(buffer: &Mutex<VecDeque<String>>, line: String) {
98 let mut guard = buffer.lock().expect("log buffer poisoned");
99 if guard.len() >= MAX_LOG_LINES {
100 guard.pop_front();
101 }
102 guard.push_back(line);
103}
104
105pub fn find_lightpanda() -> Option<PathBuf> {
106 #[cfg(unix)]
107 {
108 if let Ok(output) = Command::new("which").arg("lightpanda").output() {
109 if output.status.success() {
110 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
111 if !path.is_empty() {
112 return Some(PathBuf::from(path));
113 }
114 }
115 }
116 }
117
118 #[cfg(windows)]
119 {
120 if let Ok(output) = Command::new("where").arg("lightpanda").output() {
121 if output.status.success() {
122 let path = String::from_utf8_lossy(&output.stdout)
123 .lines()
124 .next()
125 .unwrap_or("")
126 .trim()
127 .to_string();
128 if !path.is_empty() {
129 return Some(PathBuf::from(path));
130 }
131 }
132 }
133 }
134
135 if let Some(home) = dirs::home_dir() {
136 let candidates = [
137 home.join(".lightpanda/lightpanda"),
138 home.join(".local/bin/lightpanda"),
139 ];
140 for c in &candidates {
141 if c.exists() {
142 return Some(c.clone());
143 }
144 }
145 }
146
147 None
148}
149
150pub async fn launch_lightpanda(
151 options: &LightpandaLaunchOptions,
152) -> Result<LightpandaProcess, String> {
153 let binary_path = match &options.executable_path {
154 Some(p) => PathBuf::from(p),
155 None => find_lightpanda().ok_or(
156 "Lightpanda not found. Install it from https://lightpanda.io/docs/open-source/installation or use --executable-path.",
157 )?,
158 };
159
160 let port = match options.port {
161 Some(p) => p,
162 None => TcpListener::bind("127.0.0.1:0")
163 .and_then(|l| l.local_addr())
164 .map(|a| a.port())
165 .map_err(|e| format!("Failed to find an available port for Lightpanda: {}", e))?,
166 };
167 let args = build_lightpanda_serve_args(port, options.proxy.as_deref());
168
169 let mut child = Command::new(&binary_path)
170 .args(&args)
171 .stdin(Stdio::null())
172 .stdout(Stdio::piped())
173 .stderr(Stdio::piped())
174 .spawn()
175 .map_err(|e| format!("Failed to launch Lightpanda at {:?}: {}", binary_path, e))?;
176
177 let (log_buffer, log_drainers) = start_log_drainers(&mut child)?;
178
179 let ws_url =
180 match wait_for_lightpanda_ready(&mut child, port, &log_buffer, LIGHTPANDA_STARTUP_TIMEOUT)
181 .await
182 {
183 Ok(url) => url,
184 Err(e) => {
185 let _ = child.kill();
186 let _ = child.wait();
187 return Err(e);
188 }
189 };
190
191 Ok(LightpandaProcess {
192 child,
193 ws_url,
194 _log_drainers: log_drainers,
195 })
196}
197
198fn start_log_drainers(
199 child: &mut Child,
200) -> Result<(LaunchLogBuffer, Vec<std::thread::JoinHandle<()>>), String> {
201 let stdout = child.stdout.take().ok_or_else(|| {
202 let _ = child.kill();
203 "Failed to capture Lightpanda stdout".to_string()
204 })?;
205 let stderr = child.stderr.take().ok_or_else(|| {
206 let _ = child.kill();
207 "Failed to capture Lightpanda stderr".to_string()
208 })?;
209
210 let logs = LaunchLogBuffer::default();
211 let stdout_logs = logs.clone();
212 let stderr_logs = logs.clone();
213
214 let stdout_handle =
215 std::thread::spawn(move || drain_reader(stdout, move |line| stdout_logs.push_stdout(line)));
216 let stderr_handle =
217 std::thread::spawn(move || drain_reader(stderr, move |line| stderr_logs.push_stderr(line)));
218
219 Ok((logs, vec![stdout_handle, stderr_handle]))
220}
221
222fn drain_reader<R, F>(reader: R, mut push: F)
223where
224 R: std::io::Read,
225 F: FnMut(String),
226{
227 for line in BufReader::new(reader).lines() {
228 match line {
229 Ok(line) => push(line),
230 Err(_) => break,
231 }
232 }
233}
234
235async fn wait_for_lightpanda_ready(
236 child: &mut Child,
237 port: u16,
238 logs: &LaunchLogBuffer,
239 startup_timeout: Duration,
240) -> Result<String, String> {
241 let deadline = std::time::Instant::now() + startup_timeout;
242 let mut last_probe_error = None;
243
244 loop {
245 if let Ok(Some(status)) = child.try_wait() {
246 tokio::time::sleep(Duration::from_millis(25)).await;
251 return Err(lightpanda_launch_error(
252 &format!(
253 "Lightpanda exited before CDP became ready (status: {})",
254 status
255 ),
256 logs,
257 last_probe_error.as_deref(),
258 ));
259 }
260
261 match discover_cdp_url_with_timeout("127.0.0.1", port, None, LIGHTPANDA_DISCOVERY_TIMEOUT)
262 .await
263 {
264 Ok(ws_url) => return Ok(ws_url),
265 Err(err) => last_probe_error = Some(err),
266 }
267
268 if std::time::Instant::now() >= deadline {
269 return Err(lightpanda_launch_error(
270 &format!(
271 "Timed out after {}ms waiting for Lightpanda CDP endpoint on port {}",
272 startup_timeout.as_millis(),
273 port
274 ),
275 logs,
276 last_probe_error.as_deref(),
277 ));
278 }
279
280 tokio::time::sleep(LIGHTPANDA_POLL_INTERVAL).await;
281 }
282}
283
284fn lightpanda_launch_error(
285 message: &str,
286 logs: &LaunchLogBuffer,
287 last_probe_error: Option<&str>,
288) -> String {
289 let stdout_lines = logs.snapshot_stdout();
290 let stderr_lines = logs.snapshot_stderr();
291 let mut details = Vec::new();
292
293 if let Some(err) = last_probe_error {
294 details.push(format!("Last probe error: {}", err));
295 }
296
297 if !stderr_lines.is_empty() {
298 details.push(format!(
299 "Lightpanda stderr (last {} lines):\n {}",
300 stderr_lines.len(),
301 stderr_lines.join("\n ")
302 ));
303 }
304
305 if !stdout_lines.is_empty() {
306 details.push(format!(
307 "Lightpanda stdout (last {} lines):\n {}",
308 stdout_lines.len(),
309 stdout_lines.join("\n ")
310 ));
311 }
312
313 if details.is_empty() {
314 format!("{} (no stdout/stderr output from Lightpanda)", message)
315 } else {
316 format!("{}\n{}", message, details.join("\n"))
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323 use tokio::io::{AsyncReadExt, AsyncWriteExt};
324 use tokio::net::TcpListener as TokioTcpListener;
325
326 fn unused_port() -> u16 {
327 std::net::TcpListener::bind("127.0.0.1:0")
328 .unwrap()
329 .local_addr()
330 .unwrap()
331 .port()
332 }
333
334 async fn serve_json_version_once_after_delay(port: u16, delay_ms: u64, body: &'static str) {
335 tokio::time::sleep(Duration::from_millis(delay_ms)).await;
336 let listener = TokioTcpListener::bind(("127.0.0.1", port)).await.unwrap();
337 let (mut socket, _) = listener.accept().await.unwrap();
338 let mut buf = [0u8; 1024];
339 let _ = socket.read(&mut buf).await;
340 let response = format!(
341 "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: application/json\r\n\r\n{}",
342 body.len(),
343 body
344 );
345 socket.write_all(response.as_bytes()).await.unwrap();
346 }
347
348 #[cfg(unix)]
349 #[tokio::test]
350 async fn waits_for_ready_without_logs() {
351 let port = unused_port();
352 tokio::spawn(serve_json_version_once_after_delay(
353 port,
354 150,
355 r#"{"webSocketDebuggerUrl":"ws://127.0.0.1:9222/"}"#,
356 ));
357
358 let mut child = Command::new("/bin/sh")
359 .args(["-c", "sleep 5"])
360 .stdin(Stdio::null())
361 .stdout(Stdio::piped())
362 .stderr(Stdio::piped())
363 .spawn()
364 .unwrap();
365
366 let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
367 let ws_url = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
368 .await
369 .unwrap();
370
371 assert_eq!(ws_url, format!("ws://127.0.0.1:{}/", port));
372 let _ = child.kill();
373 let _ = child.wait();
374 }
375
376 #[cfg(unix)]
377 #[tokio::test]
378 async fn child_exit_surfaces_logs() {
379 let port = unused_port();
380 let mut child = Command::new("/bin/sh")
381 .args(["-c", "echo boom >&2; sleep 0.1; exit 23"])
382 .stdin(Stdio::null())
383 .stdout(Stdio::piped())
384 .stderr(Stdio::piped())
385 .spawn()
386 .unwrap();
387
388 let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
389 let err = wait_for_lightpanda_ready(&mut child, port, &logs, LIGHTPANDA_STARTUP_TIMEOUT)
390 .await
391 .unwrap_err();
392
393 assert!(err.contains("Lightpanda exited before CDP became ready"));
394 assert!(err.contains("boom"));
395 }
396
397 #[cfg(unix)]
398 #[tokio::test]
399 async fn timeout_reports_last_probe_error() {
400 let port = unused_port();
401 let mut child = Command::new("/bin/sh")
402 .args(["-c", "sleep 30"])
403 .stdin(Stdio::null())
404 .stdout(Stdio::piped())
405 .stderr(Stdio::piped())
406 .spawn()
407 .unwrap();
408
409 let timeout = Duration::from_millis(300);
410 let (logs, _drainers) = start_log_drainers(&mut child).unwrap();
411 let err = tokio::time::timeout(
412 Duration::from_secs(2),
413 wait_for_lightpanda_ready(&mut child, port, &logs, timeout),
414 )
415 .await
416 .expect("ready wait should return before outer timeout")
417 .unwrap_err();
418
419 assert!(err.contains("Timed out after 300ms waiting for Lightpanda CDP endpoint"));
420 assert!(
421 err.contains("Failed to connect to CDP") || err.contains("Timeout connecting to CDP")
422 );
423
424 let _ = child.kill();
425 let _ = child.wait();
426 }
427
428 #[test]
429 fn test_find_lightpanda_returns_none_when_missing() {
430 let _ = find_lightpanda();
431 }
432
433 #[test]
434 fn test_lightpanda_launch_error_no_logs() {
435 let logs = LaunchLogBuffer::default();
436 let msg = lightpanda_launch_error("Lightpanda exited", &logs, None);
437 assert!(msg.contains("no stdout/stderr output"));
438 }
439
440 #[test]
441 fn test_lightpanda_launch_error_with_lines() {
442 let logs = LaunchLogBuffer::default();
443 logs.push_stdout("stdout line".to_string());
444 logs.push_stderr("stderr line".to_string());
445 let msg = lightpanda_launch_error("Lightpanda exited", &logs, Some("connect failed"));
446 assert!(msg.contains("stdout line"));
447 assert!(msg.contains("stderr line"));
448 assert!(msg.contains("Last probe error: connect failed"));
449 }
450
451 #[test]
452 fn test_default_options() {
453 let opts = LightpandaLaunchOptions::default();
454 assert!(opts.executable_path.is_none());
455 assert!(opts.proxy.is_none());
456 assert!(opts.port.is_none());
457 }
458
459 #[test]
460 fn test_build_lightpanda_serve_args_sets_explicit_session_timeout() {
461 let args = build_lightpanda_serve_args(9222, None);
462
463 assert_eq!(
464 args,
465 vec![
466 "serve".to_string(),
467 "--host".to_string(),
468 "127.0.0.1".to_string(),
469 "--port".to_string(),
470 "9222".to_string(),
471 "--timeout".to_string(),
472 "604800".to_string(),
473 ]
474 );
475 }
476
477 #[test]
478 fn test_build_lightpanda_serve_args_with_proxy() {
479 let args = build_lightpanda_serve_args(9333, Some("http://127.0.0.1:8080"));
480
481 assert_eq!(
482 args,
483 vec![
484 "serve".to_string(),
485 "--host".to_string(),
486 "127.0.0.1".to_string(),
487 "--port".to_string(),
488 "9333".to_string(),
489 "--timeout".to_string(),
490 "604800".to_string(),
491 "--http_proxy".to_string(),
492 "http://127.0.0.1:8080".to_string(),
493 ]
494 );
495 }
496}