Skip to main content

browser_automation_cli/native/cdp/
lightpanda.rs

1#![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; // 1 week, the documented maximum
16const 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            // Give the drainer threads a brief window to flush the last log lines
247            // before we snapshot them.  This is best-effort: lines written just
248            // before exit may still be missing, but the most useful output (early
249            // startup errors) will already be in the buffer.
250            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}