Skip to main content

browser_automation_cli/native/cdp/
lightpanda.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2#![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; // 1 week, the documented maximum
17const MAX_LOG_LINES: usize = 40;
18
19/// Owned Lightpanda child process (RAII: kill + `wait` on Drop).
20///
21/// # Drop
22///
23/// [`Drop`] is idempotent: after an explicit [`Self::kill`], the inner
24/// [`Child`] is taken so Drop is a no-op (no double-wait). Every kill path
25/// always pairs `kill` with `wait` to avoid Unix zombies (rules: spawn→wait).
26pub struct LightpandaProcess {
27    /// `None` after successful reap via [`Self::kill`].
28    child: Option<Child>,
29    pub ws_url: String,
30    _log_drainers: Vec<std::thread::JoinHandle<()>>,
31}
32
33impl LightpandaProcess {
34    /// Kill and reap the child (idempotent). Safe to call from Drop.
35    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    /// Non-blocking exit probe (`try_wait`). `true` when already reaped or exited.
43    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                    // Process exited; drop the Child to reap fully.
49                    let _ = self.child.take().map(|mut c| c.wait());
50                    true
51                }
52                Ok(None) => false,
53                Err(_) => false,
54            },
55        }
56    }
57
58    /// OS pid while the child is still owned; `None` after reap.
59    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        // Significant drop: external process resource. Keep short, no panic.
67        self.kill();
68    }
69}
70
71/// Best-effort kill + wait (rules: every spawn path reaps the child).
72fn 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/// Bounded stdout/stderr ring for launch diagnostics.
104///
105/// # Interior mutability
106///
107/// Reader threads push lines while the launcher snapshots on failure. Uses
108/// `std::sync::Mutex` (short critical sections, no `.await`). Poison recovered
109/// via `into_inner` so a panic in one stream cannot hide the other.
110#[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    // Pure PATH walk — never shell out to `which`/`where` (multiplatform rules).
153    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            // start_log_drainers already reaps on its internal error paths; re-reap is harmless
203            // only if the child is still live (e.g. unexpected error shape).
204            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            // Give the drainer threads a brief window to flush the last log lines
276            // before we snapshot them.  This is best-effort: lines written just
277            // before exit may still be missing, but the most useful output (early
278            // startup errors) will already be in the buffer.
279            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}