use crate::{Error, Result};
use std::{
io::{BufRead, BufReader},
path::Path,
process::{Child, Stdio},
time::{Duration, Instant},
};
pub(crate) fn stop_child(child: &mut Child) -> std::io::Result<()> {
if child.try_wait()?.is_some() {
return Ok(());
}
if let Err(kill_error) = child.kill() {
if child.try_wait()?.is_none() {
return Err(kill_error);
}
return Ok(());
}
child.wait()?;
Ok(())
}
pub(super) struct ChildCleanupGuard {
child: Option<Child>,
}
impl ChildCleanupGuard {
pub(super) fn new(child: Child) -> Self {
Self { child: Some(child) }
}
pub(super) fn take(&mut self) -> Result<Child> {
self.child
.take()
.ok_or_else(|| Error::transport("Chrome child ownership was already transferred"))
}
}
impl Drop for ChildCleanupGuard {
fn drop(&mut self) {
if let Some(mut child) = self.child.take() {
if let Err(error) = stop_child(&mut child) {
tracing::warn!("Failed to clean up an unclaimed Chrome child: {}", error);
}
}
}
}
pub fn launch_chrome(path: &std::path::Path, args: &[String]) -> Result<(Child, String)> {
launch_chrome_impl(path, args, None, false)
}
pub(crate) fn launch_chrome_with_profile_dir(
path: &std::path::Path,
args: &[String],
profile_dir: &Path,
live_session: bool,
) -> Result<(Child, String)> {
launch_chrome_impl(path, args, Some(profile_dir), live_session)
}
fn debugging_port_argument(args: &[String], live_session: bool) -> Result<Option<String>> {
if args
.iter()
.any(|arg| arg == "--remote-debugging-port" || arg.starts_with("--remote-debugging-port="))
{
return Ok(None);
}
let port = if live_session {
std::net::TcpListener::bind("127.0.0.1:0")?
.local_addr()?
.port()
} else {
0
};
Ok(Some(format!("--remote-debugging-port={port}")))
}
fn launch_chrome_impl(
path: &std::path::Path,
args: &[String],
profile_dir: Option<&Path>,
live_session: bool,
) -> Result<(Child, String)> {
use std::process::Command;
let mut cmd = Command::new(path);
cmd.args(args);
let debugging_argument = debugging_port_argument(args, live_session)?;
if let Some(argument) = &debugging_argument {
cmd.arg(argument);
}
let effective_port = debugging_argument.as_deref().or_else(|| {
args.iter()
.rev()
.find(|arg| {
arg.as_str() == "--remote-debugging-port"
|| arg.starts_with("--remote-debugging-port=")
})
.map(String::as_str)
});
let fallback_profile_dir = if effective_port == Some("--remote-debugging-port=0") {
profile_dir
} else {
None
};
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| Error::Launch(format!("Failed to launch Chrome: {}", e)))?;
let stderr = child
.stderr
.take()
.ok_or(Error::Launch("No stderr from Chrome".into()))?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || drain_chrome_stderr(BufReader::new(stderr), tx));
let ws_url = wait_for_chrome_devtools_url(&mut child, &rx, fallback_profile_dir)?;
tracing::info!("Chrome DevTools URL: {}", ws_url);
Ok((child, ws_url))
}
fn drain_chrome_stderr(
reader: impl BufRead,
sender: std::sync::mpsc::Sender<std::result::Result<String, String>>,
) {
let mut sender = Some(sender);
let mut stderr_tail = Vec::new();
for line in reader.lines() {
let line = match line {
Ok(line) => line,
Err(error) => {
if let Some(sender) = sender.take() {
let _ = sender.send(Err(format!("failed to read Chrome stderr: {error}")));
} else {
tracing::warn!("Chrome stderr reader failed after startup: {error}");
}
return;
}
};
tracing::trace!("Chrome stderr: {}", line);
if sender.is_none() {
continue;
}
if let Some((_, endpoint)) = line.split_once("DevTools listening on ws://") {
let _ = sender
.take()
.unwrap()
.send(Ok(format!("ws://{}", endpoint.trim())));
stderr_tail.clear();
} else {
stderr_tail.push(line);
if stderr_tail.len() > 20 {
stderr_tail.remove(0);
}
}
}
if let Some(sender) = sender {
let message = if stderr_tail.is_empty() {
"Chrome exited before printing DevTools URL and produced no stderr".to_string()
} else {
format!(
"Chrome exited before printing DevTools URL. stderr:\n{}",
stderr_tail.join("\n")
)
};
let _ = sender.send(Err(message));
}
}
fn wait_for_chrome_devtools_url(
child: &mut Child,
rx: &std::sync::mpsc::Receiver<std::result::Result<String, String>>,
profile_dir: Option<&Path>,
) -> Result<String> {
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match rx.try_recv() {
Ok(Ok(url)) => return Ok(url),
Ok(Err(stderr)) => {
let status = child
.try_wait()
.map(|status| status.map(|s| s.to_string()))
.unwrap_or(None)
.unwrap_or_else(|| "still running".to_string());
stop_child_after_failed_launch(child);
return Err(Error::Launch(format!(
"Failed waiting for Chrome DevTools URL: process {}; {}",
status, stderr
)));
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {
stop_child_after_failed_launch(child);
return Err(Error::Launch(
"Failed waiting for Chrome DevTools URL: stderr reader exited unexpectedly"
.into(),
));
}
}
if let Some(dir) = profile_dir {
if let Some(port) = crate::cdp::discover::read_devtools_active_port(dir) {
return crate::cdp::discover::discover_browser_ws("127.0.0.1", port).map_err(
|error| {
Error::Launch(format!(
"Chrome wrote DevToolsActivePort ({}) but discovery failed: {}",
port, error
))
},
);
}
}
if let Ok(Some(status)) = child.try_wait() {
let stderr = rx
.recv_timeout(Duration::from_millis(250))
.ok()
.and_then(|result| result.err())
.unwrap_or_else(|| "Chrome exited before printing DevTools URL".to_string());
stop_child_after_failed_launch(child);
return Err(Error::Launch(format!(
"Failed waiting for Chrome DevTools URL: process {}; {}",
status, stderr
)));
}
if Instant::now() >= deadline {
stop_child_after_failed_launch(child);
return Err(Error::Launch(
"Failed waiting for Chrome DevTools URL after 30s".into(),
));
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn stop_child_after_failed_launch(child: &mut Child) {
if let Err(cleanup_error) = stop_child(child) {
tracing::warn!(
"Failed to clean up Chrome after launch-channel error: {}",
cleanup_error
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chrome_stderr_is_drained_after_the_devtools_url() {
let input = b"startup\nDevTools listening on ws://127.0.0.1:9333/devtools/browser/test\nGPU log after startup\n";
let mut reader = std::io::Cursor::new(input);
let (sender, receiver) = std::sync::mpsc::channel();
drain_chrome_stderr(&mut reader, sender);
assert_eq!(
receiver.recv().unwrap().unwrap(),
"ws://127.0.0.1:9333/devtools/browser/test"
);
assert_eq!(reader.position(), input.len() as u64);
assert!(receiver.try_recv().is_err());
let (sender, receiver) = std::sync::mpsc::channel();
drop(receiver);
let mut reader = std::io::Cursor::new(input);
drain_chrome_stderr(&mut reader, sender);
assert_eq!(reader.position(), input.len() as u64);
}
#[test]
fn debugging_ports_preserve_explicit_configuration_and_native_launches() {
for native in [false, true] {
assert_eq!(
debugging_port_argument(&["--remote-debugging-port=9333".into()], native).unwrap(),
None
);
assert_eq!(
debugging_port_argument(&["--remote-debugging-port=0".into()], native).unwrap(),
None
);
}
assert_eq!(
debugging_port_argument(&[], false).unwrap(),
Some("--remote-debugging-port=0".into())
);
let native = debugging_port_argument(&[], true).unwrap().unwrap();
let port: u16 = native
.strip_prefix("--remote-debugging-port=")
.unwrap()
.parse()
.unwrap();
assert_ne!(port, 0);
assert!(
debugging_port_argument(&["--remote-debugging-portability=1".into()], true)
.unwrap()
.is_some()
);
}
}