use crate::logger::ScopedLogLevel;
use crate::report::chrome::ResolvedChrome;
use crate::GooseError;
use headless_chrome::{protocol::cdp::Page, Browser, LaunchOptions};
use log::{debug, LevelFilter};
use std::{
ffi::OsStr,
time::{Duration, Instant},
};
mod pdf_errors {
use crate::report::chrome::ResolvedChrome;
use crate::GooseError;
use std::time::Duration;
pub fn chrome_launch_error(inner_error: String, chrome: &ResolvedChrome) -> GooseError {
GooseError::InvalidOption {
option: "--report-file".to_string(),
value: format!("Chrome launch failed: {inner_error}"),
detail: super::launch_diagnosis::diagnose(&inner_error, chrome),
}
}
pub fn chrome_config_error(inner_error: String) -> GooseError {
GooseError::InvalidOption {
option: "--report-file".to_string(),
value: format!("Chrome configuration failed: {inner_error}"),
detail: "Goose could not build the options to launch Chrome with. This is a bug in Goose; please report it at https://github.com/tag1consulting/goose/issues with this error.".to_string(),
}
}
pub fn chrome_operation_error(operation: &str, inner_error: String) -> GooseError {
GooseError::InvalidOption {
option: "--report-file".to_string(),
value: format!("{operation} failed: {inner_error}"),
detail: format!("Chrome {operation} operation failed. This may indicate Chrome instability or insufficient system resources."),
}
}
pub fn timeout_error(duration: Duration) -> GooseError {
GooseError::InvalidOption {
option: "--pdf-timeout".to_string(),
value: "timeout".to_string(),
detail: format!("Chrome operation timed out after {duration:?}. This may indicate Chrome is unresponsive or the system is under heavy load."),
}
}
pub fn content_measurement_error(inner_error: String) -> GooseError {
GooseError::InvalidOption {
option: "--report-file".to_string(),
value: format!("Failed to measure content height: {inner_error}"),
detail: "Unable to calculate content dimensions for PDF".to_string(),
}
}
pub fn pdf_generation_error(
inner_error: String,
scale: f64,
width: f64,
height: f64,
) -> GooseError {
GooseError::InvalidOption {
option: "--report-file".to_string(),
value: format!("PDF generation failed: {inner_error}"),
detail: format!("Chrome's print-to-PDF operation failed. Scale: {scale}, Paper size: {width:.1}\" x {height:.1}\". Try adjusting --pdf-scale or reducing report complexity."),
}
}
}
struct ChromeSession {
browser: Browser,
process_id: Option<u32>,
start_time: Instant,
}
impl ChromeSession {
fn new(
chrome: &ResolvedChrome,
args: Vec<&'static OsStr>,
timeout: Duration,
) -> Result<Self, GooseError> {
let start_time = Instant::now();
debug!(
"Launching {} ({}) for PDF generation",
chrome.path.display(),
chrome.source.describe()
);
let mut attempts = 0;
let browser = loop {
attempts += 1;
let launch_options = LaunchOptions::default_builder()
.path(Some(chrome.path.clone()))
.headless(true)
.args(args.clone())
.port(free_port())
.build()
.map_err(|e| pdf_errors::chrome_config_error(e.to_string()))?;
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(Browser::new(launch_options));
});
let remaining = timeout.saturating_sub(start_time.elapsed());
match rx.recv_timeout(remaining) {
Ok(Ok(browser)) => break browser,
Ok(Err(e)) if attempts < 3 && e.to_string().contains("already in use") => continue,
Ok(Err(e)) => return Err(pdf_errors::chrome_launch_error(e.to_string(), chrome)),
Err(_) => {
return Err(pdf_errors::chrome_launch_error(
format!(
"Chrome did not report its DevTools address within {} seconds",
timeout.as_secs()
),
chrome,
))
}
}
};
let process_id = browser.get_process_id();
if let Some(pid) = process_id {
debug!("Chrome browser launched with process ID: {pid}");
} else {
debug!("Chrome browser launched (process ID not available)");
}
Ok(ChromeSession {
browser,
process_id,
start_time,
})
}
fn browser(&self) -> &Browser {
&self.browser
}
fn check_timeout(&self, timeout: Duration) -> Result<(), GooseError> {
if self.start_time.elapsed() > timeout {
return Err(pdf_errors::timeout_error(timeout));
}
Ok(())
}
}
impl Drop for ChromeSession {
fn drop(&mut self) {
let elapsed = self.start_time.elapsed();
if let Some(pid) = self.process_id {
debug!("Cleaning up Chrome process {pid} (ran for {elapsed:?})");
} else {
debug!("Cleaning up Chrome browser session (ran for {elapsed:?})");
}
}
}
fn free_port() -> Option<u16> {
std::net::TcpListener::bind(("127.0.0.1", 0))
.and_then(|listener| listener.local_addr())
.map(|address| address.port())
.ok()
}
fn get_chrome_launch_args(verbose: bool) -> Vec<&'static OsStr> {
let mut args = vec![
OsStr::new("--no-sandbox"),
OsStr::new("--disable-dev-shm-usage"),
OsStr::new("--headless"),
];
if !verbose {
args.extend([
OsStr::new("--disable-logging"),
OsStr::new("--log-level=3"), OsStr::new("--silent"),
OsStr::new("--disable-dev-tools-console"),
OsStr::new("--disable-extensions"),
OsStr::new("--disable-plugins"),
OsStr::new("--disable-default-apps"),
OsStr::new("--disable-sync"),
OsStr::new("--disable-background-timer-throttling"),
OsStr::new("--disable-renderer-backgrounding"),
OsStr::new("--disable-backgrounding-occluded-windows"),
]);
} else {
args.extend([
OsStr::new("--enable-logging"),
OsStr::new("--log-level=0"), OsStr::new("--v=1"), ]);
}
args
}
pub(crate) fn generate_pdf_from_html(
html_content: &str,
scale: f64,
verbose: bool,
timeout_seconds: u64,
chrome: &ResolvedChrome,
) -> Result<Vec<u8>, GooseError> {
render_pdf(html_content, scale, verbose, timeout_seconds, chrome).map(|rendered| rendered.pdf)
}
pub(crate) fn preflight_pdf(
scale: f64,
verbose: bool,
timeout_seconds: u64,
chrome: &ResolvedChrome,
) -> Result<Option<String>, GooseError> {
render_pdf(
"<!DOCTYPE html><html><body><p>Goose PDF report check</p></body></html>",
scale,
verbose,
timeout_seconds,
chrome,
)
.map(|rendered| rendered.version)
}
struct Rendered {
pdf: Vec<u8>,
version: Option<String>,
}
fn render_pdf(
html_content: &str,
scale: f64,
verbose: bool,
timeout_seconds: u64,
chrome: &ResolvedChrome,
) -> Result<Rendered, GooseError> {
let chrome_args = get_chrome_launch_args(verbose);
let _log_guard = if !verbose {
Some(ScopedLogLevel::new(LevelFilter::Error)?)
} else {
None
};
let timeout = Duration::from_secs(timeout_seconds);
let chrome_session = ChromeSession::new(chrome, chrome_args, timeout)?;
let version = chrome_session.browser().get_version().ok().map(|version| {
match version.product.split_once('/') {
Some((_, number)) => number.to_string(),
None => version.product,
}
});
chrome_session.check_timeout(timeout)?;
let tab = chrome_session
.browser()
.new_tab()
.map_err(|e| pdf_errors::chrome_operation_error("tab creation", e.to_string()))?;
chrome_session.check_timeout(timeout)?;
tab.navigate_to("about:blank")
.and_then(|tab| tab.wait_until_navigated())
.and_then(|tab| tab.call_method(Page::GetFrameTree(None)))
.and_then(|tree| {
tab.call_method(Page::SetDocumentContent {
frame_id: tree.frame_tree.frame.id,
html: html_content.to_string(),
})
})
.map_err(|e| pdf_errors::chrome_operation_error("HTML loading", e.to_string()))?;
loop {
chrome_session.check_timeout(timeout)?;
let state = tab
.evaluate("document.readyState", false)
.map_err(|e| pdf_errors::chrome_operation_error("page loading", e.to_string()))?;
if state.value.as_ref().and_then(|value| value.as_str()) == Some("complete") {
break;
}
std::thread::sleep(Duration::from_millis(50));
}
let content_height_script = r#"
(function() {
// Primary method: Use scrollHeight which efficiently captures full content height
// This includes content that overflows the viewport and handles most edge cases
const scrollHeight = document.documentElement.scrollHeight / 96; // Convert to inches
// Fallback: Check document body height in case scrollHeight is unreliable
// Some edge cases with CSS positioning might make scrollHeight insufficient
const bodyHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight
) / 96; // Convert to inches
// Use the maximum of both methods with a small buffer for safety
const calculatedHeight = Math.max(scrollHeight, bodyHeight);
const bufferedHeight = calculatedHeight + 0.05; // ~5px buffer in inches
return Math.max(bufferedHeight, 1.0); // Minimum 1 inch for degenerate cases
})();
"#;
chrome_session.check_timeout(timeout)?;
let content_height_inches = tab
.evaluate(content_height_script, true)
.map_err(|e| pdf_errors::content_measurement_error(e.to_string()))?
.value
.unwrap_or_default()
.as_f64()
.unwrap_or(11.0);
let base_width = 8.5;
let adjusted_width = base_width * scale;
let adjusted_height = content_height_inches * scale;
chrome_session.check_timeout(timeout)?;
let pdf_data = tab
.print_to_pdf(Some(headless_chrome::types::PrintToPdfOptions {
landscape: Some(false),
display_header_footer: Some(false),
print_background: Some(true),
scale: Some(scale), paper_width: Some(adjusted_width),
paper_height: Some(adjusted_height),
margin_top: Some(0.1), margin_bottom: Some(0.1), margin_left: Some(0.1), margin_right: Some(0.1), page_ranges: None,
ignore_invalid_page_ranges: Some(false),
header_template: None,
footer_template: None,
prefer_css_page_size: Some(false),
transfer_mode: None,
generate_document_outline: Some(false),
generate_tagged_pdf: Some(false),
}))
.map_err(|e| {
pdf_errors::pdf_generation_error(e.to_string(), scale, adjusted_width, adjusted_height)
})?;
chrome_session.check_timeout(timeout)?;
Ok(Rendered {
pdf: pdf_data,
version,
})
}
mod launch_diagnosis {
use crate::report::chrome::ResolvedChrome;
use std::io::Read;
use std::path::Path;
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const HOW_TO: &str = "Install Chrome or Chromium, or set the CHROME environment variable to the full path of a working Chrome or Chromium binary.";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum Failure {
ExecFormat,
NotFound,
PermissionDenied,
Other,
}
pub(super) fn classify(error: &str) -> Failure {
if error.contains("Exec format error") || error.contains("os error 8)") {
Failure::ExecFormat
} else if error.contains("No such file or directory") || error.contains("os error 2)") {
Failure::NotFound
} else if error.contains("Permission denied") || error.contains("os error 13)") {
Failure::PermissionDenied
} else {
Failure::Other
}
}
pub(super) fn missing_libraries(ldd_output: &str) -> Vec<String> {
let mut missing: Vec<String> = Vec::new();
for line in ldd_output.lines().map(str::trim) {
let library = if let Some((library, rest)) = line.split_once("=>") {
(rest.trim() == "not found").then(|| library.trim())
} else if let Some(rest) = line.strip_prefix("Error loading shared library ") {
rest.split(':').next().map(str::trim)
} else {
None
};
if let Some(library) = library.filter(|library| !library.is_empty()) {
if !missing.iter().any(|seen| seen == library) {
missing.push(library.to_string());
}
}
}
missing
}
pub(super) struct Findings<'a> {
pub(super) failure: Failure,
pub(super) exists: bool,
pub(super) linux: bool,
pub(super) missing: &'a [String],
pub(super) version_stderr: Option<&'a str>,
}
pub(super) fn explain(path: &Path, source: &str, findings: &Findings) -> String {
let mut text = format!(
"Goose could not start the Chrome binary {} ({source}).",
path.display()
);
match findings.failure {
Failure::ExecFormat => text.push_str(&format!(
" It does not match this machine's architecture ({}).",
std::env::consts::ARCH
)),
Failure::NotFound if !findings.exists => text.push_str(" The file does not exist."),
Failure::NotFound if findings.linux => text.push_str(
" The file exists, so the ELF interpreter it was linked against is missing: usually a glibc binary on a musl system such as Alpine, or the reverse.",
),
Failure::PermissionDenied => {
text.push_str(" The file is not executable by this user.")
}
_ => {}
}
if !findings.missing.is_empty() {
text.push_str(&format!(
" It needs shared libraries that are not installed: {}. Installing your distribution's Chromium package pulls them in.",
findings.missing.join(", ")
));
}
if let Some(stderr) = findings.version_stderr.filter(|s| !s.trim().is_empty()) {
text.push_str(&format!(
" Running it with --version printed: {}",
stderr.trim()
));
}
text.push(' ');
text.push_str(HOW_TO);
text
}
pub(super) fn diagnose(error: &str, chrome: &ResolvedChrome) -> String {
let failure = classify(error);
let exists = chrome.path.is_file();
let linux = cfg!(target_os = "linux");
let probe = exists && failure != Failure::ExecFormat;
let mut missing = Vec::new();
if probe && linux {
if let Some((stdout, stderr)) = run(
Command::new("ldd").arg(&chrome.path),
Duration::from_secs(5),
) {
missing = missing_libraries(&format!("{stdout}\n{stderr}"));
}
}
let interpreter_missing = linux && exists && failure == Failure::NotFound;
let version_stderr = if probe && missing.is_empty() && !interpreter_missing {
run(
Command::new(&chrome.path).arg("--version"),
Duration::from_secs(5),
)
.map(|(_, stderr)| stderr.lines().take(5).collect::<Vec<_>>().join(" / "))
} else {
None
};
explain(
&chrome.path,
chrome.source.describe(),
&Findings {
failure,
exists,
linux,
missing: &missing,
version_stderr: version_stderr.as_deref(),
},
)
}
fn run(command: &mut Command, timeout: Duration) -> Option<(String, String)> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.ok()?;
let started = Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) if started.elapsed() < timeout => {
std::thread::sleep(Duration::from_millis(20))
}
_ => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
let mut stdout = String::new();
let mut stderr = String::new();
if let Some(mut out) = child.stdout.take() {
let _ = out.read_to_string(&mut stdout);
}
if let Some(mut err) = child.stderr.take() {
let _ = err.read_to_string(&mut stderr);
}
Some((stdout, stderr))
}
}
#[cfg(test)]
mod tests {
use super::launch_diagnosis::{classify, explain, missing_libraries, Failure, Findings};
use std::path::Path;
fn findings(failure: Failure) -> Findings<'static> {
Findings {
failure,
exists: true,
linux: false,
missing: &[],
version_stderr: None,
}
}
#[test]
fn classifies_launch_errors() {
assert_eq!(
classify("Exec format error (os error 8)"),
Failure::ExecFormat
);
assert_eq!(
classify("No such file or directory (os error 2)"),
Failure::NotFound
);
assert_eq!(
classify("Permission denied (os error 13)"),
Failure::PermissionDenied
);
assert_eq!(
classify("Could not auto detect a chrome executable"),
Failure::Other
);
assert_eq!(
classify("No space left on device (os error 28)"),
Failure::Other
);
}
#[test]
fn launch_error_names_chrome_variable_and_binary() {
let detail = explain(
Path::new("/usr/bin/chromium"),
"installed",
&findings(Failure::Other),
);
assert!(detail.contains("CHROME"), "{}", detail);
assert!(
detail.contains("/usr/bin/chromium (installed)"),
"{}",
detail
);
assert!(!detail.to_lowercase().contains("download"), "{}", detail);
assert!(!detail.contains("architecture"), "{}", detail);
}
#[test]
fn launch_error_reports_architecture_mismatch() {
let detail = explain(
Path::new("/usr/bin/chromium"),
"installed",
&findings(Failure::ExecFormat),
);
assert!(detail.contains("architecture"), "{}", detail);
assert!(detail.contains(std::env::consts::ARCH), "{}", detail);
assert!(detail.contains("CHROME"), "{}", detail);
assert!(!detail.to_lowercase().contains("download"), "{}", detail);
}
#[test]
fn launch_error_reports_missing_interpreter_on_linux() {
let mut found = findings(Failure::NotFound);
found.linux = true;
let detail = explain(Path::new("/opt/chrome"), "set by CHROME", &found);
assert!(detail.contains("ELF interpreter"), "{}", detail);
assert!(detail.contains("musl"), "{}", detail);
found.exists = false;
let detail = explain(Path::new("/opt/chrome"), "set by CHROME", &found);
assert!(detail.contains("does not exist"), "{}", detail);
assert!(!detail.contains("ELF"), "{}", detail);
found.exists = true;
found.linux = false;
let detail = explain(Path::new("/opt/chrome"), "set by CHROME", &found);
assert!(!detail.contains("ELF"), "{}", detail);
}
#[test]
fn launch_error_lists_missing_libraries_and_stderr() {
let missing = vec!["libnss3.so".to_string(), "libatk-1.0.so.0".to_string()];
let mut found = findings(Failure::Other);
found.linux = true;
found.missing = &missing;
let detail = explain(Path::new("/opt/chrome"), "installed", &found);
assert!(detail.contains("libnss3.so, libatk-1.0.so.0"), "{}", detail);
assert!(detail.contains("Chromium package"), "{}", detail);
let mut found = findings(Failure::Other);
found.version_stderr = Some(" cannot open display ");
let detail = explain(Path::new("/opt/chrome"), "installed", &found);
assert!(
detail.contains("--version printed: cannot open display"),
"{}",
detail
);
found.version_stderr = Some(" ");
let detail = explain(Path::new("/opt/chrome"), "installed", &found);
assert!(!detail.contains("--version"), "{}", detail);
}
#[test]
fn parses_missing_libraries_from_ldd() {
let glibc = "\
\tlinux-vdso.so.1 (0x0000ffff9a5e0000)
\tlibdl.so.2 => /lib/aarch64-linux-gnu/libdl.so.2 (0x0000ffff9a590000)
\tlibnss3.so => not found
\tlibnssutil3.so => not found
\tlibnss3.so => not found
\tlibc.so.6 => /lib/aarch64-linux-gnu/libc.so.6 (0x0000ffff9a3e0000)
";
assert_eq!(
missing_libraries(glibc),
vec!["libnss3.so", "libnssutil3.so"]
);
let musl = "\
\t/lib/ld-musl-x86_64.so.1 (0x7f0e1c4a6000)
Error loading shared library libglib-2.0.so.0: No such file or directory (needed by /opt/chrome)
Error loading shared library libnss3.so: No such file or directory (needed by /opt/chrome)
";
assert_eq!(
missing_libraries(musl),
vec!["libglib-2.0.so.0", "libnss3.so"]
);
assert!(missing_libraries("\tnot a dynamic executable\n").is_empty());
assert!(missing_libraries("").is_empty());
}
}