Skip to main content

glass/browser/
chrome.rs

1//! Chrome process lifecycle management.
2//!
3//! Handles Chrome/Chromium binary resolution, process launch with
4//! configurable flags, managed Chrome for Testing installation,
5//! port locking, and health checks.
6
7use fs2::FileExt;
8use futures_util::StreamExt;
9use serde::Deserialize;
10use sha2::{Digest, Sha256};
11use std::fs::{File, OpenOptions};
12use std::io::ErrorKind;
13use std::path::{Component, Path, PathBuf};
14use std::process::Stdio;
15use std::time::{Duration, Instant};
16use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
17use tokio::net::TcpStream;
18use tokio::process::{Child, Command};
19use tokio::task::JoinHandle;
20use tracing::{info, warn};
21
22const PORT_LAUNCH_LOCK_TIMEOUT: Duration = Duration::from_secs(20);
23const PORT_LAUNCH_LOCK_RETRY: Duration = Duration::from_millis(25);
24const CHROME_STARTUP_TIMEOUT: Duration = Duration::from_secs(15);
25const CHROME_STARTUP_POLL_INTERVAL: Duration = Duration::from_millis(100);
26const CHROME_SHUTDOWN_GRACE: Duration = Duration::from_secs(10);
27const PINNED_CHROME_VERSION: &str = "150.0.7871.115";
28const MAX_CHROME_ARCHIVE_BYTES: u64 = 256 * 1024 * 1024;
29const MAX_CHROME_EXTRACTED_BYTES: u64 = 1024 * 1024 * 1024;
30const MAX_CHROME_ARCHIVE_ENTRIES: usize = 20_000;
31
32/// An advisory, cross-process lock for an owned Chrome launch on one CDP port.
33///
34/// The lock file is intentionally retained after release. The OS-level lock is
35/// owned by this file handle and is released automatically if the holder exits,
36/// so a stale file from a crash never blocks a later launch.
37pub struct PortLaunchLock {
38    file: File,
39}
40
41impl PortLaunchLock {
42    /// Wait for exclusive ownership of the selected CDP port's launch lock.
43    pub async fn acquire(port: u16) -> Result<Self, Box<dyn std::error::Error>> {
44        let directory = std::env::temp_dir().join("glass").join("launch-locks");
45        std::fs::create_dir_all(&directory)?;
46        let path = directory.join(format!("cdp-{port}.lock"));
47        let file = OpenOptions::new()
48            .read(true)
49            .write(true)
50            .create(true)
51            .truncate(false)
52            .open(path)?;
53
54        let deadline = Instant::now() + PORT_LAUNCH_LOCK_TIMEOUT;
55        loop {
56            match file.try_lock_exclusive() {
57                Ok(()) => return Ok(Self { file }),
58                Err(error) if error.kind() == ErrorKind::WouldBlock => {
59                    if Instant::now() >= deadline {
60                        return Err(format!(
61                            "timed out waiting to launch Chrome on CDP port {port}; use --attach for an existing endpoint or choose another --port"
62                        )
63                        .into());
64                    }
65                    tokio::time::sleep(PORT_LAUNCH_LOCK_RETRY).await;
66                }
67                Err(error) => {
68                    return Err(format!(
69                        "could not lock CDP port {port} for an owned Chrome launch: {error}"
70                    )
71                    .into());
72                }
73            }
74        }
75    }
76}
77
78impl Drop for PortLaunchLock {
79    fn drop(&mut self) {
80        let _ = FileExt::unlock(&self.file);
81    }
82}
83
84/// A Chrome process started by Glass.
85pub struct ChromeProcess {
86    pub port: u16,
87    pub pid: u32,
88    child: Option<Child>,
89    stderr_drain: Option<JoinHandle<()>>,
90}
91
92impl ChromeProcess {
93    pub fn ws_debug_url(&self) -> String {
94        format!("http://127.0.0.1:{}/json/version", self.port)
95    }
96
97    /// Stop a Chrome process owned by this instance.
98    pub async fn shutdown(&mut self) -> Result<(), Box<dyn std::error::Error>> {
99        let shutdown_result = match self.child.take() {
100            Some(mut child) => match child.try_wait() {
101                Ok(None) => match tokio::time::timeout(CHROME_SHUTDOWN_GRACE, child.wait()).await {
102                    Ok(Ok(_)) => Ok(()),
103                    Ok(Err(error)) => Err(Box::new(error) as Box<dyn std::error::Error>),
104                    Err(_) => match child.kill().await {
105                        Ok(()) => {
106                            let _ = child.wait().await;
107                            Ok(())
108                        }
109                        Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
110                    },
111                },
112                Ok(Some(_)) => Ok(()),
113                Err(error) => Err(Box::new(error) as Box<dyn std::error::Error>),
114            },
115            None => Ok(()),
116        };
117        if let Some(stderr_drain) = self.stderr_drain.take() {
118            let _ = stderr_drain.await;
119        }
120        shutdown_result
121    }
122}
123
124impl Drop for ChromeProcess {
125    fn drop(&mut self) {
126        if let Some(child) = self.child.as_mut() {
127            let _ = child.start_kill();
128        }
129        if let Some(stderr_drain) = self.stderr_drain.take() {
130            stderr_drain.abort();
131        }
132    }
133}
134
135/// Detect Chrome/Chromium installation on the system.
136pub fn detect_chrome() -> Option<PathBuf> {
137    let candidates = [
138        "google-chrome",
139        "google-chrome-stable",
140        "chromium-browser",
141        "chromium",
142        "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
143        "/Applications/Chromium.app/Contents/MacOS/Chromium",
144        "/usr/bin/google-chrome",
145        "/usr/bin/google-chrome-stable",
146        "/usr/bin/chromium-browser",
147        "/usr/bin/chromium",
148        "/snap/bin/chromium",
149    ];
150
151    for candidate in candidates {
152        let path = Path::new(candidate);
153        if path.is_file() {
154            return Some(path.to_path_buf());
155        }
156        if !candidate.contains('/')
157            && let Ok(output) = std::process::Command::new("which").arg(candidate).output()
158            && output.status.success()
159        {
160            let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
161            if !path.is_empty() {
162                return Some(PathBuf::from(path));
163            }
164        }
165    }
166
167    None
168}
169
170/// Return the directory used by `install-chromium` for Chrome for Testing.
171pub fn chromium_install_dir() -> PathBuf {
172    dirs::data_local_dir()
173        .unwrap_or_else(|| PathBuf::from("."))
174        .join("glass")
175        .join("chromium")
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq)]
179enum ManagedChromePlatform {
180    LinuxX64,
181    MacX64,
182    MacArm64,
183    WindowsX64,
184    WindowsX86,
185}
186
187impl ManagedChromePlatform {
188    fn current() -> Option<Self> {
189        Self::for_target(std::env::consts::OS, std::env::consts::ARCH)
190    }
191
192    fn for_target(os: &str, architecture: &str) -> Option<Self> {
193        match (os, architecture) {
194            ("linux", "x86_64") => Some(Self::LinuxX64),
195            ("macos", "x86_64") => Some(Self::MacX64),
196            ("macos", "aarch64") => Some(Self::MacArm64),
197            ("windows", "x86_64") => Some(Self::WindowsX64),
198            ("windows", "x86") => Some(Self::WindowsX86),
199            _ => None,
200        }
201    }
202
203    fn download_platform(self) -> &'static str {
204        match self {
205            Self::LinuxX64 => "linux64",
206            Self::MacX64 => "mac-x64",
207            Self::MacArm64 => "mac-arm64",
208            Self::WindowsX64 => "win64",
209            Self::WindowsX86 => "win32",
210        }
211    }
212
213    fn executable_relative_path(self) -> &'static str {
214        match self {
215            Self::LinuxX64 => "chrome-linux64/chrome",
216            Self::MacX64 => {
217                "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
218            }
219            Self::MacArm64 => {
220                "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing"
221            }
222            Self::WindowsX64 => "chrome-win64/chrome.exe",
223            Self::WindowsX86 => "chrome-win32/chrome.exe",
224        }
225    }
226
227    fn pinned_archive(self) -> Option<(&'static str, u64)> {
228        match self {
229            Self::LinuxX64 => Some((
230                "1be2db033133c5e2dd1a4e8664bf67b19a61bcf6ed28d2b00f433b3f0b4f9585",
231                187_332_810,
232            )),
233            Self::MacX64 => Some((
234                "1ede6b48b674a0ac0189f77debbc9bca21881d129eefa30252060117df705cf7",
235                191_275_197,
236            )),
237            Self::MacArm64 => Some((
238                "c418e4a29046bb62e340dfd9479b329bdf53ddd14a127b1362444a1e1b734a48",
239                180_511_523,
240            )),
241            Self::WindowsX64 => Some((
242                "90e0d112cb2f2743a7fd723f030c15b6421940be61dcbda7758563f821dd81da",
243                194_764_577,
244            )),
245            Self::WindowsX86 => Some((
246                "7df8d60ff06fb9a6bf7728091fd9b816e9b172ebce665de3a36cdd924e958628",
247                168_049_061,
248            )),
249        }
250    }
251}
252
253fn managed_chrome_path_in(install_dir: &Path, platform: ManagedChromePlatform) -> Option<PathBuf> {
254    let records = std::fs::read_dir(install_dir.join("current")).ok()?;
255    let version_name = records
256        .filter_map(Result::ok)
257        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_file()))
258        .max_by_key(|entry| entry.file_name())
259        .and_then(|entry| std::fs::read_to_string(entry.path()).ok())?;
260    let version_name = version_name.trim();
261    if !version_name.starts_with(&format!("{PINNED_CHROME_VERSION}-")) {
262        return None;
263    }
264    let root = install_dir.join("versions").join(version_name);
265    let marker = std::fs::read_to_string(root.join(".complete")).ok()?;
266    if marker.trim() != PINNED_CHROME_VERSION {
267        return None;
268    }
269    let path = root.join(platform.executable_relative_path());
270    path.is_file().then_some(path)
271}
272
273/// Find the Chrome for Testing executable installed by `glass install-chromium`.
274pub fn managed_chrome_path() -> Option<PathBuf> {
275    let platform = ManagedChromePlatform::current()?;
276    managed_chrome_path_in(&chromium_install_dir(), platform)
277}
278
279/// Resolve an explicitly configured browser, then managed Chrome, then a
280/// system Chrome/Chromium installation.
281pub fn resolve_chrome_path(explicit: Option<PathBuf>) -> Option<PathBuf> {
282    explicit.or_else(managed_chrome_path).or_else(detect_chrome)
283}
284
285#[cfg(test)]
286fn choose_chrome_path(
287    explicit: Option<PathBuf>,
288    managed: Option<PathBuf>,
289    detected: Option<PathBuf>,
290) -> Option<PathBuf> {
291    explicit.or(managed).or(detected)
292}
293
294/// Atomically install the Chrome for Testing version pinned by this release.
295pub async fn download_chromium(update: bool) -> Result<PathBuf, Box<dyn std::error::Error>> {
296    let install_dir = chromium_install_dir();
297    let platform = ManagedChromePlatform::current()
298        .ok_or("Chrome for Testing is unavailable for this platform")?;
299    let (expected_sha256, expected_size) = platform
300        .pinned_archive()
301        .ok_or("Chrome for Testing publishes no managed build for this target")?;
302
303    std::fs::create_dir_all(&install_dir)?;
304    let lock_file = OpenOptions::new()
305        .create(true)
306        .truncate(false)
307        .read(true)
308        .write(true)
309        .open(install_dir.join("install.lock"))?;
310    lock_file.lock_exclusive()?;
311    cleanup_install_staging(&install_dir)?;
312    if !update && let Some(path) = managed_chrome_path_in(&install_dir, platform) {
313        prune_managed_chrome(&install_dir)?;
314        info!(path = %path.display(), "Chromium already installed");
315        return Ok(path);
316    }
317    let nonce = format!(
318        "{}-{}",
319        chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0),
320        std::process::id()
321    );
322    let staging = install_dir.join(format!(".staging-{nonce}"));
323    std::fs::create_dir(&staging)?;
324    let guard = InstallStagingGuard(staging.clone());
325    let archive_path = staging.join("chrome.zip.part");
326    let download_url = format!(
327        "https://storage.googleapis.com/chrome-for-testing-public/{PINNED_CHROME_VERSION}/{}/chrome-{}.zip",
328        platform.download_platform(),
329        platform.download_platform()
330    );
331    info!(
332        version = PINNED_CHROME_VERSION,
333        platform = platform.download_platform(),
334        "downloading pinned Chrome for Testing"
335    );
336    let response = reqwest::Client::new()
337        .get(&download_url)
338        .timeout(Duration::from_secs(120))
339        .send()
340        .await?
341        .error_for_status()?;
342    if response.content_length() != Some(expected_size) || expected_size > MAX_CHROME_ARCHIVE_BYTES
343    {
344        return Err("pinned Chrome archive length did not match the release manifest".into());
345    }
346    let mut archive = tokio::fs::File::create(&archive_path).await?;
347    let mut stream = response.bytes_stream();
348    let mut hasher = Sha256::new();
349    let mut downloaded = 0_u64;
350    while let Some(chunk) = stream.next().await {
351        let chunk = chunk?;
352        downloaded = downloaded
353            .checked_add(chunk.len() as u64)
354            .ok_or("Chrome archive length overflowed")?;
355        if downloaded > expected_size || downloaded > MAX_CHROME_ARCHIVE_BYTES {
356            return Err("Chrome archive exceeded its pinned byte budget".into());
357        }
358        hasher.update(&chunk);
359        archive.write_all(&chunk).await?;
360    }
361    archive.flush().await?;
362    archive.sync_all().await?;
363    if downloaded != expected_size {
364        return Err("Chrome archive was truncated".into());
365    }
366    let actual_sha256 = format!("{:x}", hasher.finalize());
367    if actual_sha256 != expected_sha256 {
368        return Err("Chrome archive integrity digest did not match the release manifest".into());
369    }
370    // Windows denies removal while this writer still owns the archive handle.
371    drop(archive);
372    let extract_root = staging.join("payload");
373    std::fs::create_dir(&extract_root)?;
374    let archive_for_extract = archive_path.clone();
375    let root_for_extract = extract_root.clone();
376    tokio::task::spawn_blocking(move || {
377        extract_chrome_zip(&archive_for_extract, &root_for_extract)
378    })
379    .await?
380    .map_err(|error| format!("Chrome archive extraction failed: {error}"))?;
381    tokio::fs::remove_file(&archive_path).await?;
382    let path = managed_chrome_path_in_payload(&extract_root, platform)
383        .ok_or("Chrome archive did not contain a browser executable")?;
384    #[cfg(unix)]
385    {
386        use std::os::unix::fs::PermissionsExt;
387        let mut permissions = tokio::fs::metadata(&path).await?.permissions();
388        permissions.set_mode(0o755);
389        tokio::fs::set_permissions(&path, permissions).await?;
390    }
391    #[cfg(not(unix))]
392    let _ = path;
393    let mut marker = File::create(extract_root.join(".complete"))?;
394    use std::io::Write as _;
395    marker.write_all(format!("{PINNED_CHROME_VERSION}\n").as_bytes())?;
396    marker.sync_all()?;
397    sync_directory(&extract_root)?;
398    let versions = install_dir.join("versions");
399    std::fs::create_dir_all(&versions)?;
400    let final_dir = versions.join(format!("{PINNED_CHROME_VERSION}-{nonce}"));
401    std::fs::rename(&extract_root, &final_dir)?;
402    sync_directory(&versions)?;
403    let current = install_dir.join("current");
404    std::fs::create_dir_all(&current)?;
405    let generation = next_current_generation(&current)?;
406    let pending_record = staging.join("current-record");
407    let mut record = File::create(&pending_record)?;
408    record.write_all(final_dir.file_name().unwrap().to_string_lossy().as_bytes())?;
409    record.sync_all()?;
410    std::fs::rename(&pending_record, current.join(format!("{generation:020}")))?;
411    sync_directory(&current)?;
412    drop(guard);
413    prune_managed_chrome(&install_dir)?;
414    let path = managed_chrome_path_in(&install_dir, platform)
415        .ok_or("installed Chrome executable was not discoverable")?;
416    info!(path = %path.display(), version = PINNED_CHROME_VERSION, "Chrome for Testing installed atomically");
417    Ok(path)
418}
419
420#[cfg(unix)]
421fn sync_directory(path: &Path) -> std::io::Result<()> {
422    File::open(path)?.sync_all()
423}
424
425#[cfg(windows)]
426fn sync_directory(_path: &Path) -> std::io::Result<()> {
427    // Windows does not permit opening directories with ordinary std file
428    // handles. File and marker contents are synced; publication still uses an
429    // atomic same-volume rename, while directory metadata durability is left
430    // to the filesystem.
431    Ok(())
432}
433
434fn next_current_generation(current: &Path) -> std::io::Result<u64> {
435    let greatest = std::fs::read_dir(current)?
436        .filter_map(Result::ok)
437        .filter_map(|entry| entry.file_name().to_string_lossy().parse::<u64>().ok())
438        .max()
439        .unwrap_or(0);
440    greatest
441        .checked_add(1)
442        .ok_or_else(|| std::io::Error::other("managed Chrome generation overflowed"))
443}
444
445fn prune_managed_chrome(install_dir: &Path) -> std::io::Result<()> {
446    let current = install_dir.join("current");
447    let mut records = std::fs::read_dir(&current)?
448        .filter_map(Result::ok)
449        .collect::<Vec<_>>();
450    records.sort_by_key(|entry| entry.file_name());
451    let retained = records
452        .iter()
453        .rev()
454        .take(2)
455        .filter_map(|entry| std::fs::read_to_string(entry.path()).ok())
456        .map(|name| name.trim().to_owned())
457        .collect::<std::collections::HashSet<_>>();
458    for entry in records.into_iter().rev().skip(2) {
459        std::fs::remove_file(entry.path())?;
460    }
461    for entry in std::fs::read_dir(install_dir.join("versions"))? {
462        let entry = entry?;
463        if !retained.contains(&entry.file_name().to_string_lossy().into_owned()) {
464            make_removable(&entry.path())?;
465            std::fs::remove_dir_all(entry.path())?;
466        }
467    }
468    sync_directory(&current)?;
469    sync_directory(&install_dir.join("versions"))?;
470    Ok(())
471}
472
473struct InstallStagingGuard(PathBuf);
474
475impl Drop for InstallStagingGuard {
476    fn drop(&mut self) {
477        let _ = make_removable(&self.0);
478        let _ = std::fs::remove_dir_all(&self.0);
479    }
480}
481
482fn cleanup_install_staging(install_dir: &Path) -> Result<(), Box<dyn std::error::Error>> {
483    for entry in std::fs::read_dir(install_dir)? {
484        let entry = entry?;
485        if entry.file_name().to_string_lossy().starts_with(".staging-") {
486            let _ = make_removable(&entry.path());
487            std::fs::remove_dir_all(entry.path())?;
488        }
489    }
490    Ok(())
491}
492
493fn managed_chrome_path_in_payload(root: &Path, platform: ManagedChromePlatform) -> Option<PathBuf> {
494    let path = root.join(platform.executable_relative_path());
495    path.is_file().then_some(path)
496}
497
498fn extract_chrome_zip(
499    archive: &Path,
500    destination: &Path,
501) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
502    let file = File::open(archive)?;
503    let mut archive = zip::ZipArchive::new(file)?;
504    if archive.len() > MAX_CHROME_ARCHIVE_ENTRIES {
505        return Err("Chrome archive contained too many entries".into());
506    }
507    let mut extracted = 0_u64;
508    let mut symlinks: Vec<(PathBuf, PathBuf)> = Vec::new();
509    for index in 0..archive.len() {
510        let mut entry = archive.by_index(index)?;
511        let relative = entry
512            .enclosed_name()
513            .ok_or("Chrome archive contained an unsafe path")?;
514        let is_symlink = entry
515            .unix_mode()
516            .is_some_and(|mode| mode & 0o170000 == 0o120000);
517        extracted = extracted
518            .checked_add(entry.size())
519            .ok_or("Chrome extracted size overflowed")?;
520        if extracted > MAX_CHROME_EXTRACTED_BYTES {
521            return Err("Chrome archive exceeded the extracted byte budget".into());
522        }
523        let output = destination.join(&relative);
524        if is_symlink {
525            #[cfg(not(unix))]
526            return Err("Chrome archive contained a symbolic link on a non-Unix target".into());
527            #[cfg(unix)]
528            {
529                use std::io::Read as _;
530                if entry.size() > 4096 {
531                    return Err("Chrome archive symlink target was too long".into());
532                }
533                let mut target = String::new();
534                entry.read_to_string(&mut target)?;
535                let target = PathBuf::from(target);
536                if !safe_relative_symlink(&relative, &target) {
537                    return Err("Chrome archive symlink escaped the extraction root".into());
538                }
539                symlinks.push((output, target));
540                continue;
541            }
542        }
543        if entry.is_dir() {
544            std::fs::create_dir_all(&output)?;
545            continue;
546        }
547        if let Some(parent) = output.parent() {
548            std::fs::create_dir_all(parent)?;
549        }
550        let mut file = File::create(&output)?;
551        std::io::copy(&mut entry, &mut file)?;
552        file.sync_all()?;
553        #[cfg(unix)]
554        if let Some(mode) = entry.unix_mode() {
555            use std::os::unix::fs::PermissionsExt;
556            std::fs::set_permissions(&output, std::fs::Permissions::from_mode(mode & 0o777))?;
557        }
558        #[cfg(windows)]
559        {
560            let metadata = std::fs::metadata(&output)?;
561            if metadata.permissions().readonly() {
562                let mut perms = metadata.permissions();
563                perms.set_readonly(false);
564                std::fs::set_permissions(&output, perms)?;
565            }
566        }
567    }
568    #[cfg(unix)]
569    for (link, target) in symlinks {
570        if let Some(parent) = link.parent() {
571            std::fs::create_dir_all(parent)?;
572        }
573        std::os::unix::fs::symlink(target, link)?;
574    }
575    sync_directory_tree(destination)?;
576    Ok(())
577}
578
579fn sync_directory_tree(root: &Path) -> std::io::Result<()> {
580    for entry in std::fs::read_dir(root)? {
581        let entry = entry?;
582        if entry.file_type()?.is_dir() {
583            sync_directory_tree(&entry.path())?;
584        }
585    }
586    sync_directory(root)
587}
588
589/// On Windows, recursively clear the read-only attribute from all files so
590/// that `remove_dir_all` can delete them. On Unix this is a no-op because
591/// directory write permission is sufficient for deletion.
592#[cfg(windows)]
593fn make_removable(path: &Path) -> std::io::Result<()> {
594    if path.is_dir() {
595        for entry in std::fs::read_dir(path)? {
596            let entry = entry?;
597            make_removable(&entry.path())?;
598        }
599    }
600    let metadata = std::fs::metadata(path)?;
601    if metadata.permissions().readonly() {
602        let mut perms = metadata.permissions();
603        perms.set_readonly(false);
604        std::fs::set_permissions(path, perms)?;
605    }
606    Ok(())
607}
608
609#[cfg(not(windows))]
610fn make_removable(_path: &Path) -> std::io::Result<()> {
611    Ok(())
612}
613
614fn safe_relative_symlink(link: &Path, target: &Path) -> bool {
615    if target.is_absolute() {
616        return false;
617    }
618    let mut depth = link
619        .parent()
620        .map_or(0, |parent| parent.components().count());
621    for component in target.components() {
622        match component {
623            Component::CurDir => {}
624            Component::Normal(_) => depth += 1,
625            Component::ParentDir if depth > 0 => depth -= 1,
626            Component::ParentDir | Component::RootDir | Component::Prefix(_) => return false,
627        }
628    }
629    true
630}
631
632/// Launch Chrome with remote debugging enabled in headless mode.
633pub async fn launch_chrome(
634    chrome_path: &Path,
635    port: u16,
636    profile_dir: Option<&Path>,
637) -> Result<ChromeProcess, Box<dyn std::error::Error>> {
638    launch_chrome_with_options(chrome_path, port, profile_dir, false, false, None).await
639}
640
641/// Launch Chrome with remote debugging enabled.
642pub async fn launch_chrome_with_options(
643    chrome_path: &Path,
644    port: u16,
645    profile_dir: Option<&Path>,
646    headed: bool,
647    incognito: bool,
648    host_resolver_rules: Option<&str>,
649) -> Result<ChromeProcess, Box<dyn std::error::Error>> {
650    let args = chrome_arguments(port, profile_dir, headed, incognito);
651
652    info!(binary = %chrome_path.display(), arguments = %args.join(" "), "launching Chrome");
653    let mut command = Command::new(chrome_path);
654    command
655        .args(&args)
656        .stdin(Stdio::null())
657        .stdout(Stdio::null())
658        .stderr(Stdio::piped());
659    if let Some(rules) = host_resolver_rules {
660        command.arg(format!("--host-resolver-rules={rules}"));
661    }
662    let mut child = command.spawn()?;
663    let pid = child.id().unwrap_or(0);
664    let stderr = child
665        .stderr
666        .take()
667        .ok_or("Chrome startup did not provide a stderr stream")?;
668    let mut stderr = BufReader::new(stderr).lines();
669
670    let child_debugger_url = match wait_for_child_debugger_url(&mut child, &mut stderr, port).await
671    {
672        Ok(url) => url,
673        Err(error) => {
674            stop_startup_child(&mut child).await;
675            return Err(error);
676        }
677    };
678    if let Err(error) =
679        wait_for_owned_debugger_endpoint(&mut child, port, &child_debugger_url).await
680    {
681        stop_startup_child(&mut child).await;
682        return Err(error);
683    }
684
685    // Chrome can continue to write diagnostic output after startup. Drain it
686    // rather than retaining an unread pipe that could eventually block the
687    // child, but do not retain any diagnostic text in Glass memory.
688    let mut stderr = stderr.into_inner();
689    let stderr_drain = tokio::spawn(async move {
690        let mut sink = tokio::io::sink();
691        let _ = tokio::io::copy(&mut stderr, &mut sink).await;
692    });
693
694    Ok(ChromeProcess {
695        port,
696        pid,
697        child: Some(child),
698        stderr_drain: Some(stderr_drain),
699    })
700}
701
702/// Wait for Chrome's own DevTools listener announcement.
703///
704/// A health check alone is insufficient: another process could have claimed
705/// the port after the caller checked it. Chrome emits a unique browser
706/// WebSocket URL on stderr only after its own DevTools listener starts; that
707/// URL is subsequently compared against `/json/version` before the endpoint
708/// is accepted.
709async fn wait_for_child_debugger_url(
710    child: &mut Child,
711    stderr: &mut tokio::io::Lines<BufReader<tokio::process::ChildStderr>>,
712    port: u16,
713) -> Result<String, Box<dyn std::error::Error>> {
714    let deadline = tokio::time::Instant::now() + CHROME_STARTUP_TIMEOUT;
715    loop {
716        tokio::select! {
717            line = stderr.next_line() => match line? {
718                Some(line) => {
719                    if let Some(debugger_url) = child_debugger_url_from_stderr(&line, port) {
720                        return Ok(debugger_url);
721                    }
722                }
723                None => {
724                    if let Some(status) = child.try_wait()? {
725                        return Err(format!("Chrome exited during startup with status {status}").into());
726                    }
727                    return Err(format!("Chrome closed stderr before reporting its DevTools listener on port {port}").into());
728                }
729            },
730            _ = tokio::time::sleep(CHROME_STARTUP_POLL_INTERVAL) => {
731                if let Some(status) = child.try_wait()? {
732                    return Err(format!("Chrome exited during startup with status {status}").into());
733                }
734                if tokio::time::Instant::now() >= deadline {
735                    return Err(format!("Chrome did not report its DevTools listener on port {port}").into());
736                }
737            }
738        }
739    }
740}
741
742fn child_debugger_url_from_stderr(line: &str, port: u16) -> Option<String> {
743    let marker = "DevTools listening on ";
744    let offset = line.find(marker)? + marker.len();
745    let debugger_url = line[offset..].trim();
746    let port_and_path = format!(":{port}/devtools/browser/");
747    (debugger_url.starts_with("ws://") && debugger_url.contains(&port_and_path))
748        .then(|| debugger_url.to_string())
749}
750
751async fn wait_for_owned_debugger_endpoint(
752    child: &mut Child,
753    port: u16,
754    child_debugger_url: &str,
755) -> Result<(), Box<dyn std::error::Error>> {
756    let deadline = tokio::time::Instant::now() + CHROME_STARTUP_TIMEOUT;
757    loop {
758        if let Some(status) = child.try_wait()? {
759            return Err(format!("Chrome exited during startup with status {status}").into());
760        }
761        if let Some(debugger_url) = debugger_url_at(port).await {
762            if debugger_url == child_debugger_url {
763                return Ok(());
764            }
765            return Err(format!(
766                "CDP port {port} is serving a different Chrome endpoint; use --attach to connect to it or choose another --port"
767            )
768            .into());
769        }
770        if tokio::time::Instant::now() >= deadline {
771            return Err(format!("Chrome did not become ready on port {port}").into());
772        }
773        tokio::time::sleep(CHROME_STARTUP_POLL_INTERVAL).await;
774    }
775}
776
777async fn stop_startup_child(child: &mut Child) {
778    let _ = child.kill().await;
779    let _ = child.wait().await;
780}
781
782fn chrome_arguments(
783    port: u16,
784    profile_dir: Option<&Path>,
785    headed: bool,
786    incognito: bool,
787) -> Vec<String> {
788    let mut args = vec![
789        format!("--remote-debugging-port={port}"),
790        "--remote-debugging-address=127.0.0.1".to_string(),
791        "--no-first-run".to_string(),
792        "--no-default-browser-check".to_string(),
793        "--disable-background-networking".to_string(),
794        "--disable-sync".to_string(),
795        "--disable-translate".to_string(),
796        "--disable-extensions".to_string(),
797        "--disable-default-apps".to_string(),
798        "--disable-session-crashed-bubble".to_string(),
799        "--disable-restore-session-state".to_string(),
800        "--disable-features=Translate,BackForwardCache".to_string(),
801        "--window-size=1280,720".to_string(),
802    ];
803
804    if !headed {
805        args.push("--headless=new".to_string());
806        args.push("--hide-scrollbars".to_string());
807    }
808    if chrome_sandbox_disabled(std::env::var_os("GLASS_DISABLE_CHROME_SANDBOX").as_deref()) {
809        args.push("--no-sandbox".to_string());
810    }
811    if let Some(profile) = profile_dir {
812        args.push(format!("--user-data-dir={}", profile.display()));
813    }
814    if incognito {
815        args.push("--incognito".to_string());
816    }
817    args.push("about:blank".to_string());
818    args
819}
820
821fn chrome_sandbox_disabled(value: Option<&std::ffi::OsStr>) -> bool {
822    value == Some(std::ffi::OsStr::new("1"))
823}
824
825/// Check if Chrome is running and responsive on the given port.
826pub async fn check_chrome_health(port: u16) -> bool {
827    debugger_url_at(port).await.is_some()
828}
829
830async fn debugger_url_at(port: u16) -> Option<String> {
831    #[derive(Deserialize)]
832    struct BrowserVersion {
833        #[serde(rename = "webSocketDebuggerUrl")]
834        websocket_debugger_url: Option<String>,
835    }
836
837    let url = format!("http://127.0.0.1:{port}/json/version");
838    let response = reqwest::Client::new()
839        .get(url)
840        .timeout(Duration::from_millis(500))
841        .send()
842        .await
843        .ok()?;
844    if !response.status().is_success() {
845        return None;
846    }
847    response
848        .json::<BrowserVersion>()
849        .await
850        .ok()?
851        .websocket_debugger_url
852}
853
854/// Return whether any process is currently listening on the local CDP port.
855///
856/// This is intentionally broader than the Chrome health check: an owned Glass
857/// session must not launch onto a port already controlled by another process.
858pub async fn is_port_occupied(port: u16) -> bool {
859    matches!(
860        tokio::time::timeout(
861            Duration::from_millis(250),
862            TcpStream::connect(("127.0.0.1", port)),
863        )
864        .await,
865        Ok(Ok(_))
866    )
867}
868
869/// Kill processes listening on a debugging port.
870pub async fn kill_chrome(port: u16) -> Result<(), Box<dyn std::error::Error>> {
871    let output = Command::new("lsof")
872        .args(["-ti", &format!(":{port}")])
873        .output()
874        .await?;
875
876    if output.status.success() {
877        for pid in String::from_utf8_lossy(&output.stdout).lines() {
878            if let Ok(pid_num) = pid.trim().parse::<u32>() {
879                warn!(pid = pid_num, "killing Chrome process");
880                #[cfg(unix)]
881                unsafe {
882                    libc::kill(pid_num as i32, libc::SIGTERM);
883                }
884            }
885        }
886    }
887    Ok(())
888}
889
890/// A page target advertised by Chrome's HTTP DevTools endpoint.
891#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
892struct PageTarget {
893    id: String,
894    #[serde(rename = "type")]
895    target_type: String,
896    #[serde(rename = "webSocketDebuggerUrl")]
897    websocket_debugger_url: Option<String>,
898    #[serde(default)]
899    url: String,
900}
901
902#[derive(Deserialize)]
903struct BrowserVersionEndpoint {
904    #[serde(rename = "webSocketDebuggerUrl")]
905    websocket_debugger_url: String,
906}
907
908/// Fetch the browser-level WebSocket debugger URL from Chrome's `/json/version`
909/// endpoint on the given port. Returns the `webSocketDebuggerUrl` field.
910pub async fn get_browser_ws_url(port: u16) -> Result<String, Box<dyn std::error::Error>> {
911    let response = reqwest::Client::new()
912        .get(format!("http://127.0.0.1:{port}/json/version"))
913        .timeout(Duration::from_secs(2))
914        .send()
915        .await?;
916    if !response.status().is_success() {
917        return Err(format!("Chrome version endpoint failed with {}", response.status()).into());
918    }
919    Ok(response
920        .json::<BrowserVersionEndpoint>()
921        .await?
922        .websocket_debugger_url)
923}
924
925/// Get the WebSocket debugger URL for an explicitly selected page target.
926///
927/// A single page target is selected automatically. Multiple page targets are
928/// never silently adopted: callers must provide the matching Chrome target ID.
929pub async fn get_ws_url(
930    port: u16,
931    target_id: Option<&str>,
932) -> Result<String, Box<dyn std::error::Error>> {
933    let targets = page_targets_at(port).await?;
934    select_page_target(&targets, target_id)
935        .map(|target| {
936            target
937                .websocket_debugger_url
938                .clone()
939                .expect("filtered above")
940        })
941        .map_err(Into::into)
942}
943
944/// Get a page target for a browser owned by Glass. A persistent profile can
945/// restore its last page while Chrome also opens Glass's startup page. Owned
946/// sessions may adopt the first page target in that case; attached sessions
947/// retain strict selection.
948pub async fn get_owned_ws_url(
949    port: u16,
950    target_id: Option<&str>,
951) -> Result<String, Box<dyn std::error::Error>> {
952    let targets = page_targets_at(port).await?;
953    let target = select_owned_page_target(&targets, target_id)?;
954    Ok(target
955        .websocket_debugger_url
956        .clone()
957        .expect("filtered above"))
958}
959
960fn select_owned_page_target<'a>(
961    targets: &'a [PageTarget],
962    target_id: Option<&str>,
963) -> Result<&'a PageTarget, String> {
964    match select_page_target(targets, target_id) {
965        Ok(target) => Ok(target),
966        Err(error) if target_id.is_none() && error.starts_with("multiple page targets") => {
967            let candidates = targets
968                .iter()
969                .filter(|target| {
970                    target.target_type == "page"
971                        && target.websocket_debugger_url.is_some()
972                        && target.url != "about:blank"
973                })
974                .collect::<Vec<_>>();
975            match candidates.as_slice() {
976                [target] => Ok(target),
977                [] => targets
978                    .iter()
979                    .find(|target| {
980                        target.target_type == "page" && target.websocket_debugger_url.is_some()
981                    })
982                    .ok_or(error),
983                _ => Err(error),
984            }
985        }
986        Err(error) => Err(error),
987    }
988}
989
990async fn page_targets_at(port: u16) -> Result<Vec<PageTarget>, Box<dyn std::error::Error>> {
991    let url = format!("http://127.0.0.1:{port}/json");
992    let response = reqwest::Client::new()
993        .get(url)
994        .timeout(Duration::from_secs(2))
995        .send()
996        .await?;
997    if !response.status().is_success() {
998        return Err(format!("Chrome target listing failed with {}", response.status()).into());
999    }
1000    Ok(response.json().await?)
1001}
1002
1003fn select_page_target<'a>(
1004    targets: &'a [PageTarget],
1005    target_id: Option<&str>,
1006) -> Result<&'a PageTarget, String> {
1007    let pages: Vec<&PageTarget> = targets
1008        .iter()
1009        .filter(|target| target.target_type == "page" && target.websocket_debugger_url.is_some())
1010        .collect();
1011
1012    if let Some(target_id) = target_id {
1013        return pages
1014            .into_iter()
1015            .find(|target| target.id == target_id)
1016            .ok_or_else(|| format!("page target '{target_id}' was not found"));
1017    }
1018
1019    match pages.as_slice() {
1020        [] => Err("No page target with a WebSocket debugger URL was found".to_string()),
1021        [target] => Ok(target),
1022        pages => {
1023            let ids = pages
1024                .iter()
1025                .map(|target| target.id.as_str())
1026                .collect::<Vec<_>>()
1027                .join(", ");
1028            Err(format!(
1029                "multiple page targets are available ({ids}); pass --target-id <id> to choose one"
1030            ))
1031        }
1032    }
1033}
1034
1035#[cfg(test)]
1036mod tests {
1037    use super::*;
1038    use std::sync::atomic::{AtomicU64, Ordering};
1039    use tokio::net::TcpListener;
1040
1041    static NEXT_TEST_DIRECTORY: AtomicU64 = AtomicU64::new(0);
1042
1043    fn test_directory() -> PathBuf {
1044        let id = NEXT_TEST_DIRECTORY.fetch_add(1, Ordering::Relaxed);
1045        std::env::temp_dir().join(format!("glass-chrome-test-{}-{id}", std::process::id()))
1046    }
1047
1048    async fn unused_port() -> u16 {
1049        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1050        let port = listener.local_addr().unwrap().port();
1051        drop(listener);
1052        port
1053    }
1054
1055    fn page_target(id: &str) -> PageTarget {
1056        PageTarget {
1057            id: id.to_string(),
1058            target_type: "page".to_string(),
1059            websocket_debugger_url: Some(format!("ws://example.test/{id}")),
1060            url: "about:blank".to_string(),
1061        }
1062    }
1063
1064    #[test]
1065    fn owned_target_selection_adopts_one_restored_nonblank_page() {
1066        let mut restored = page_target("restored");
1067        restored.url = "http://127.0.0.1:1234/".to_string();
1068        let targets = vec![page_target("startup"), restored];
1069
1070        assert_eq!(
1071            select_owned_page_target(&targets, None).unwrap().id,
1072            "restored"
1073        );
1074    }
1075
1076    #[test]
1077    fn chrome_path_prefers_explicit_then_managed_then_system() {
1078        let explicit = PathBuf::from("/explicit/chrome");
1079        let managed = PathBuf::from("/managed/chrome");
1080        let system = PathBuf::from("/system/chrome");
1081
1082        assert_eq!(
1083            choose_chrome_path(
1084                Some(explicit.clone()),
1085                Some(managed.clone()),
1086                Some(system.clone())
1087            ),
1088            Some(explicit)
1089        );
1090        assert_eq!(
1091            choose_chrome_path(None, Some(managed.clone()), Some(system.clone())),
1092            Some(managed)
1093        );
1094        assert_eq!(
1095            choose_chrome_path(None, None, Some(system.clone())),
1096            Some(system)
1097        );
1098    }
1099
1100    #[test]
1101    fn managed_chrome_discovery_uses_each_supported_platform_layout() {
1102        let layouts = [
1103            ("linux", "x86_64", "chrome-linux64/chrome"),
1104            (
1105                "macos",
1106                "x86_64",
1107                "chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
1108            ),
1109            (
1110                "macos",
1111                "aarch64",
1112                "chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing",
1113            ),
1114            ("windows", "x86_64", "chrome-win64/chrome.exe"),
1115            ("windows", "x86", "chrome-win32/chrome.exe"),
1116        ];
1117
1118        for (os, architecture, expected_relative_path) in layouts {
1119            let root = test_directory();
1120            let platform = ManagedChromePlatform::for_target(os, architecture).unwrap();
1121            let version_name = format!("{PINNED_CHROME_VERSION}-test");
1122            let version_root = root.join("versions").join(&version_name);
1123            let executable = version_root.join(expected_relative_path);
1124            std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
1125            std::fs::write(&executable, "managed chrome").unwrap();
1126            std::fs::write(
1127                version_root.join(".complete"),
1128                format!("{PINNED_CHROME_VERSION}\n"),
1129            )
1130            .unwrap();
1131            std::fs::create_dir_all(root.join("current")).unwrap();
1132            std::fs::write(root.join("current/1"), version_name).unwrap();
1133
1134            assert_eq!(platform.executable_relative_path(), expected_relative_path);
1135            assert_eq!(managed_chrome_path_in(&root, platform), Some(executable));
1136
1137            let _ = std::fs::remove_dir_all(root);
1138        }
1139    }
1140
1141    #[test]
1142    fn managed_chrome_platform_rejects_unsupported_targets() {
1143        assert_eq!(ManagedChromePlatform::for_target("freebsd", "x86_64"), None);
1144        assert_eq!(ManagedChromePlatform::for_target("linux", "aarch64"), None);
1145        assert_eq!(
1146            ManagedChromePlatform::for_target("windows", "aarch64"),
1147            None
1148        );
1149    }
1150
1151    #[test]
1152    fn managed_discovery_ignores_incomplete_installations() {
1153        let root = test_directory();
1154        let executable = root.join("versions/150-incomplete/chrome-linux64/chrome");
1155        std::fs::create_dir_all(executable.parent().unwrap()).unwrap();
1156        std::fs::write(&executable, "partial").unwrap();
1157        assert_eq!(
1158            managed_chrome_path_in(&root, ManagedChromePlatform::LinuxX64),
1159            None
1160        );
1161        let _ = std::fs::remove_dir_all(root);
1162    }
1163
1164    #[test]
1165    fn zip_extraction_rejects_parent_traversal() {
1166        use std::io::Write;
1167        let root = test_directory();
1168        std::fs::create_dir_all(&root).unwrap();
1169        let archive_path = root.join("malicious.zip");
1170        let file = File::create(&archive_path).unwrap();
1171        let mut writer = zip::ZipWriter::new(file);
1172        writer
1173            .start_file("../escape", zip::write::SimpleFileOptions::default())
1174            .unwrap();
1175        writer.write_all(b"escape").unwrap();
1176        writer.finish().unwrap();
1177        let output = root.join("output");
1178        std::fs::create_dir(&output).unwrap();
1179        assert!(extract_chrome_zip(&archive_path, &output).is_err());
1180        assert!(!root.join("escape").exists());
1181        let _ = std::fs::remove_dir_all(root);
1182    }
1183
1184    #[test]
1185    fn symlink_targets_must_remain_inside_the_archive_root() {
1186        assert!(safe_relative_symlink(
1187            Path::new("App/Framework/Versions/Current"),
1188            Path::new("150.0.0")
1189        ));
1190        assert!(safe_relative_symlink(
1191            Path::new("App/Framework/Resources"),
1192            Path::new("Versions/Current/Resources")
1193        ));
1194        assert!(!safe_relative_symlink(
1195            Path::new("App/link"),
1196            Path::new("../../escape")
1197        ));
1198        assert!(!safe_relative_symlink(
1199            Path::new("App/link"),
1200            Path::new("/tmp/escape")
1201        ));
1202    }
1203
1204    #[test]
1205    fn chrome_arguments_add_incognito_and_disposable_profile_dir() {
1206        let profile = Path::new("/tmp/glass-incognito");
1207        let args = chrome_arguments(9222, Some(profile), false, true);
1208
1209        assert!(args.contains(&"--incognito".to_string()));
1210        assert!(args.contains(&"--user-data-dir=/tmp/glass-incognito".to_string()));
1211        assert!(args.contains(&"--disable-restore-session-state".to_string()));
1212    }
1213
1214    #[test]
1215    fn chrome_sandbox_requires_exact_explicit_opt_out() {
1216        use std::ffi::OsStr;
1217
1218        assert!(!chrome_sandbox_disabled(None));
1219        assert!(!chrome_sandbox_disabled(Some(OsStr::new("0"))));
1220        assert!(!chrome_sandbox_disabled(Some(OsStr::new("true"))));
1221        assert!(chrome_sandbox_disabled(Some(OsStr::new("1"))));
1222    }
1223
1224    #[test]
1225    fn child_debugger_announcement_must_match_the_requested_port() {
1226        assert_eq!(
1227            child_debugger_url_from_stderr(
1228                "DevTools listening on ws://127.0.0.1:9222/devtools/browser/child",
1229                9222,
1230            ),
1231            Some("ws://127.0.0.1:9222/devtools/browser/child".to_string())
1232        );
1233        assert!(
1234            child_debugger_url_from_stderr(
1235                "DevTools listening on ws://127.0.0.1:9223/devtools/browser/child",
1236                9222,
1237            )
1238            .is_none()
1239        );
1240    }
1241
1242    #[tokio::test]
1243    async fn port_launch_lock_serializes_competing_owned_starts() {
1244        let port = unused_port().await;
1245        let first = PortLaunchLock::acquire(port).await.unwrap();
1246        let second = tokio::spawn(async move { PortLaunchLock::acquire(port).await.unwrap() });
1247
1248        tokio::time::sleep(Duration::from_millis(75)).await;
1249        assert!(!second.is_finished());
1250
1251        drop(first);
1252        drop(second.await.unwrap());
1253    }
1254
1255    #[test]
1256    fn port_launch_lock_is_exclusive_across_processes() {
1257        const HELPER_PORT: &str = "GLASS_PORT_LOCK_HELPER_PORT";
1258        const HELPER_READY: &str = "GLASS_PORT_LOCK_HELPER_READY";
1259        const HELPER_RELEASE: &str = "GLASS_PORT_LOCK_HELPER_RELEASE";
1260
1261        if let Ok(port) = std::env::var(HELPER_PORT) {
1262            let port = port.parse().unwrap();
1263            let ready = PathBuf::from(std::env::var(HELPER_READY).unwrap());
1264            let release = PathBuf::from(std::env::var(HELPER_RELEASE).unwrap());
1265            let runtime = tokio::runtime::Builder::new_current_thread()
1266                .enable_io()
1267                .enable_time()
1268                .build()
1269                .unwrap();
1270            let _lock = runtime.block_on(PortLaunchLock::acquire(port)).unwrap();
1271            std::fs::write(ready, "ready").unwrap();
1272
1273            let deadline = Instant::now() + Duration::from_secs(10);
1274            while !release.exists() && Instant::now() < deadline {
1275                std::thread::sleep(Duration::from_millis(10));
1276            }
1277            return;
1278        }
1279
1280        let runtime = tokio::runtime::Builder::new_current_thread()
1281            .enable_io()
1282            .enable_time()
1283            .build()
1284            .unwrap();
1285        let port = runtime.block_on(unused_port());
1286        let root = test_directory();
1287        std::fs::create_dir_all(&root).unwrap();
1288        let ready = root.join("ready");
1289        let release = root.join("release");
1290        let test_name = "browser::chrome::tests::port_launch_lock_is_exclusive_across_processes";
1291        let mut helper = std::process::Command::new(std::env::current_exe().unwrap())
1292            .args(["--exact", test_name, "--nocapture"])
1293            .env(HELPER_PORT, port.to_string())
1294            .env(HELPER_READY, &ready)
1295            .env(HELPER_RELEASE, &release)
1296            .spawn()
1297            .unwrap();
1298
1299        let ready_deadline = Instant::now() + Duration::from_secs(5);
1300        while !ready.exists() && Instant::now() < ready_deadline {
1301            std::thread::sleep(Duration::from_millis(10));
1302        }
1303        assert!(ready.exists(), "lock helper did not become ready");
1304
1305        let blocked = runtime.block_on(async {
1306            tokio::time::timeout(Duration::from_millis(150), PortLaunchLock::acquire(port)).await
1307        });
1308        assert!(blocked.is_err(), "another process must hold the port lock");
1309
1310        std::fs::write(&release, "release").unwrap();
1311        assert!(helper.wait().unwrap().success());
1312        drop(runtime.block_on(PortLaunchLock::acquire(port)).unwrap());
1313        let _ = std::fs::remove_dir_all(root);
1314    }
1315
1316    #[cfg(unix)]
1317    #[tokio::test]
1318    async fn launcher_rejects_a_foreign_healthy_endpoint_after_child_announcement() {
1319        use std::os::unix::fs::PermissionsExt;
1320        use tokio::io::{AsyncReadExt, AsyncWriteExt};
1321
1322        let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1323        let port = listener.local_addr().unwrap().port();
1324        let foreign_url = format!("ws://127.0.0.1:{port}/devtools/browser/foreign");
1325        let server = tokio::spawn(async move {
1326            let (mut stream, _) = listener.accept().await.unwrap();
1327            let mut request = [0; 1_024];
1328            let _ = stream.read(&mut request).await.unwrap();
1329            let body = serde_json::json!({
1330                "webSocketDebuggerUrl": foreign_url,
1331            })
1332            .to_string();
1333            let response = format!(
1334                "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
1335                body.len(),
1336                body
1337            );
1338            stream.write_all(response.as_bytes()).await.unwrap();
1339        });
1340
1341        let root = test_directory();
1342        std::fs::create_dir_all(&root).unwrap();
1343        let script = root.join("fake-chrome.sh");
1344        let child_url = format!("ws://127.0.0.1:{port}/devtools/browser/child");
1345        std::fs::write(
1346            &script,
1347            format!(
1348                "#!/bin/sh\nprintf '%s\\n' 'DevTools listening on {child_url}' >&2\nsleep 30\n"
1349            ),
1350        )
1351        .unwrap();
1352        let mut permissions = std::fs::metadata(&script).unwrap().permissions();
1353        permissions.set_mode(0o755);
1354        std::fs::set_permissions(&script, permissions).unwrap();
1355
1356        let error = launch_chrome_with_options(&script, port, Some(&root), false, false, None)
1357            .await
1358            .err()
1359            .expect("a foreign endpoint must not be adopted");
1360        assert!(error.to_string().contains("different Chrome endpoint"));
1361
1362        server.await.unwrap();
1363        let _ = std::fs::remove_dir_all(root);
1364    }
1365
1366    #[test]
1367    fn target_selection_requires_an_explicit_id_when_ambiguous() {
1368        let targets = vec![page_target("one"), page_target("two")];
1369
1370        let error = select_page_target(&targets, None).unwrap_err();
1371        assert!(error.contains("multiple page targets"));
1372        assert_eq!(select_page_target(&targets, Some("two")).unwrap().id, "two");
1373        assert!(select_page_target(&targets, Some("missing")).is_err());
1374    }
1375}