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