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