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