use std::fs; use std::future::Future; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] use std::time::Instant; use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr}; use chromiumoxide::browser::{Browser, BrowserConfig}; use chromiumoxide::handler::viewport::Viewport; use chromiumoxide::handler::HandlerConfig; use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; use super::discovery::discover_chrome_or_edge; use super::evidence::{ browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report, }; use super::model::{ BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT, }; use super::network_policy::{preview_proxy_bypass_list, validate_input}; #[cfg(windows)] use crate::process_session::WindowsProcessJob; pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") } #[cfg(not(unix))] { std::env::temp_dir() } } pub(super) fn create_browser_process_temp_dir() -> Result { TempDirBuilder::new() .prefix(BROWSER_TEMP_PREFIX) .tempdir_in(browser_process_temp_root()) .map_err(|error| format!("创建浏览器临时目录失败:{error}")) } fn browser_config( executable: &std::path::Path, temporary: &TempDir, bypass: &str, ) -> Result { let profile_path = temporary.path().join("profile"); fs::create_dir(&profile_path) .map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?; BrowserConfig::builder() .chrome_executable(executable) .user_data_dir(profile_path) .env("TMPDIR", temporary.path().to_string_lossy().into_owned()) .new_headless_mode() .enable_request_intercept() .disable_cache() .disable_https_first() .request_timeout(BROWSER_TIMEOUT) .launch_timeout(BROWSER_TIMEOUT) .window_size(1280, 720) .arg(("proxy-server", "http://127.0.0.1:9")) .arg(("proxy-bypass-list", bypass)) .arg("block-new-web-contents") .arg("deny-permission-prompts") .arg("disable-notifications") .arg("disable-service-worker") .build() .map_err(|error| format!("构建浏览器配置失败:{error}")) } const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5); const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024; const MAX_WS_URL_LEN: usize = 512; /// 必须与 browser_config 的 builder 调用保持一致:request_timeout / /// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像 /// BrowserConfigBuilder 的默认值 Some(Viewport::default())(800x600,旧 /// Browser::launch 会把它带入 HandlerConfig);其余字段 /// (ignore_https_errors / ignore_invalid_messages)沿用 /// HandlerConfig::default(),与 builder 默认值相同。 fn handler_config() -> HandlerConfig { let mut config = HandlerConfig::default(); config.request_timeout = BROWSER_TIMEOUT; config.request_intercept = true; config.cache_enabled = false; config.viewport = Some(Viewport::default()); config } /// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点, /// 避免伪造输出把 CDP 连接引导到任意地址。 fn parse_devtools_ws_url(line: &str) -> Option { let (_, ws) = line.rsplit_once("listening on ")?; let ws = ws.trim(); if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") { return None; } let authority = ws.strip_prefix("ws://")?.split('/').next()?; let (host, port) = authority.rsplit_once(':')?; if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") || port.is_empty() || !port.bytes().all(|byte| byte.is_ascii_digit()) { return None; } Some(ws.to_string()) } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum OwnedBrowserLaunchError { SpawnFailed, WsTimeout, WsFailed, ConnectTimeout, ConnectFailed, } impl OwnedBrowserLaunchError { fn stage(self) -> BrowserLaunchStage { match self { Self::SpawnFailed => BrowserLaunchStage::Spawn, Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake, Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect, } } fn is_recoverable(self) -> bool { matches!( self, Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed ) } fn code(self) -> &'static str { match self { Self::SpawnFailed => "browser-spawn-failed", Self::WsTimeout => "browser-ws-timeout", Self::WsFailed => "browser-ws-failed", Self::ConnectTimeout => "browser-cdp-connect-timeout", Self::ConnectFailed => "browser-cdp-connect-failed", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum BrowserLaunchStage { Setup, Spawn, WsHandshake, CdpConnect, } impl BrowserLaunchStage { fn as_str(self) -> &'static str { match self { Self::Setup => "setup", Self::Spawn => "spawn", Self::WsHandshake => "ws-handshake", Self::CdpConnect => "cdp-connect", } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BrowserLaunchFailure { code: &'static str, stage: BrowserLaunchStage, recoverable: bool, subprocess_exited: bool, cleanup_confirmed: bool, } impl BrowserLaunchFailure { fn setup(code: &'static str) -> Self { Self { code, stage: BrowserLaunchStage::Setup, recoverable: false, subprocess_exited: true, cleanup_confirmed: true, } } fn from_launch_error( error: OwnedBrowserLaunchError, subprocess_exited: bool, cleanup_confirmed: bool, ) -> Self { Self { code: error.code(), stage: error.stage(), recoverable: error.is_recoverable(), subprocess_exited, cleanup_confirmed, } } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct BrowserRecoveryFailure { first: BrowserLaunchFailure, final_attempt: Option, } impl BrowserRecoveryFailure { fn diagnostic(self) -> String { match self.final_attempt { Some(final_attempt) => format!( "browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}", self.first.stage.as_str(), final_attempt.stage.as_str(), final_attempt.subprocess_exited, final_attempt.cleanup_confirmed, self.first.code, final_attempt.code, ), None => format!( "{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}", if self.first.recoverable { "browser-recovery-blocked" } else { "browser-launch-failed" }, self.first.stage.as_str(), self.first.subprocess_exited, self.first.cleanup_confirmed, self.first.code, ), } } } async fn launch_with_one_recovery(mut launch: F) -> Result where F: FnMut() -> Fut, Fut: Future>, { let first = match launch().await { Ok(value) => return Ok(value), Err(error) => error, }; if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed { return Err(BrowserRecoveryFailure { first, final_attempt: None, }); } match launch().await { Ok(value) => Ok(value), Err(final_attempt) => Err(BrowserRecoveryFailure { first, final_attempt: Some(final_attempt), }), } } type BrowserStderrReader = futures::io::BufReader; /// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到 /// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终 /// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。 async fn devtools_ws_url_from_stderr( child: &mut BrowserChild, timeout: Duration, ) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> { let stderr = child .stderr .take() .ok_or(OwnedBrowserLaunchError::SpawnFailed)?; let mut reader = futures::io::BufReader::new(stderr); let mut captured: Vec = Vec::new(); let mut exited = Box::pin(child.wait()).fuse(); let read = async { loop { let mut line = Vec::new(); futures::select! { _status = exited => return Err(OwnedBrowserLaunchError::WsFailed), result = reader.read_until(b'\n', &mut line).fuse() => { let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?; if count == 0 { return Err(OwnedBrowserLaunchError::WsFailed); } captured.extend_from_slice(&line); if captured.len() > MAX_LAUNCH_STDERR_BYTES { return Err(OwnedBrowserLaunchError::WsFailed); } let Ok(text) = std::str::from_utf8(&line) else { return Err(OwnedBrowserLaunchError::WsFailed); }; if let Some(url) = parse_devtools_ws_url(text) { return Ok(url); } } } } }; match tokio::time::timeout(timeout, read).await { Ok(Ok(url)) => Ok((url, reader)), Ok(Err(error)) => Err(error), Err(_) => Err(OwnedBrowserLaunchError::WsTimeout), } } fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> { // 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。 tokio::spawn(async move { let mut buffer = Vec::with_capacity(4096); loop { buffer.clear(); match reader.read_until(b'\n', &mut buffer).await { Ok(0) | Err(_) => break, Ok(_) => {} } } }) } /// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进 /// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程 /// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。 struct BrowserProcessGuard { child: BrowserChild, #[cfg(windows)] job: Option, } impl BrowserProcessGuard { /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 /// 返回是否已确认 root 进程退出。 async fn reap(&mut self) -> bool { #[cfg(windows)] if let Some(job) = &self.job { let _ = job.terminate(); } let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; matches!( tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, Ok(Ok(_)) ) } async fn confirm_reaped(&mut self) -> Result<(), String> { #[cfg(windows)] { let Some(job) = &self.job else { return Err("browser-tree-reap-unconfirmed".into()); }; let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; loop { if job .is_empty() .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? { return Ok(()); } if Instant::now() >= deadline { return Err("browser-tree-reap-unconfirmed".into()); } tokio::time::sleep(Duration::from_millis(20)).await; } } #[cfg(not(windows))] { // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 match self.child.inner.try_wait() { Ok(Some(_)) => Ok(()), _ => Err("browser-tree-reap-unconfirmed".into()), } } } } impl Drop for BrowserProcessGuard { fn drop(&mut self) { // Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树; // Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。 } } struct OwnedBrowser { browser: Browser, process: BrowserProcessGuard, handler_task: tokio::task::JoinHandle<()>, drain_task: tokio::task::JoinHandle<()>, // Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop, // 不能直接移动字段)。 temp: Option, } impl OwnedBrowser { fn browser(&self) -> &Browser { &self.browser } /// 优雅收束:CDP close → wait;失败则整树终止并确认收割。 async fn shutdown(mut self) -> Result<(), String> { let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await; let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await; self.handler_task.abort(); self.drain_task.abort(); if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) && self.process.confirm_reaped().await.is_ok() { return Ok(()); } let subprocess_exited = self.process.reap().await; if !subprocess_exited { // 进程退出未确认:保留目录与 owner.json,留待下次启动或 // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 if let Some(temp) = self.temp.take() { std::mem::forget(temp); } return Err( "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" .into(), ); } if self.process.confirm_reaped().await.is_err() { if let Some(temp) = self.temp.take() { std::mem::forget(temp); } return Err( "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false" .into(), ); } Ok(()) } } impl Drop for OwnedBrowser { fn drop(&mut self) { self.handler_task.abort(); self.drain_task.abort(); // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 } } async fn cleanup_failed_launch( mut process: BrowserProcessGuard, temp: TempDir, error: OwnedBrowserLaunchError, tree_control_confirmed: bool, ) -> BrowserLaunchFailure { let subprocess_exited = process.reap().await; let tree_reaped = if tree_control_confirmed && subprocess_exited { #[cfg(windows)] { process.job.is_none() || process.confirm_reaped().await.is_ok() } #[cfg(not(windows))] { process.confirm_reaped().await.is_ok() } } else { false }; drop(process); let cleanup_confirmed = if tree_reaped { temp.close().is_ok() } else { // 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。 let _ = temp.keep(); false }; BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) } /// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE /// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动 /// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp: TempDir, ) -> Result { let child = match config.launch() { Ok(child) => child, Err(_) => { return Err(BrowserLaunchFailure::from_launch_error( OwnedBrowserLaunchError::SpawnFailed, true, true, )); } }; #[cfg(windows)] let root_identity = child.inner.id().and_then(|pid| { crate::process_identity::external_agent_runner_process_start_identity(pid) .ok() .flatten() }); #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { Ok(job) => { let (Some(root_pid), Some(root_identity)) = (child.inner.id(), root_identity.as_deref()) else { let process = BrowserProcessGuard { child, job: Some(job), }; return Err(cleanup_failed_launch( process, temp, OwnedBrowserLaunchError::SpawnFailed, false, ) .await); }; if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() { Some(job) } else { let tree_reaped = super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok(); let process = BrowserProcessGuard { child, job: Some(job), }; return Err(cleanup_failed_launch( process, temp, OwnedBrowserLaunchError::SpawnFailed, tree_reaped, ) .await); } } Err(_) => { let tree_reaped = match (child.inner.id(), root_identity.as_deref()) { (Some(root_pid), Some(root_identity)) => { super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok() } _ => false, }; let process = BrowserProcessGuard { child, job: None }; return Err(cleanup_failed_launch( process, temp, OwnedBrowserLaunchError::SpawnFailed, tree_reaped, ) .await); } }; let mut process = BrowserProcessGuard { child, #[cfg(windows)] job, }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable); } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, Err(error) => { return Err(cleanup_failed_launch(process, temp, error, true).await); } }; let drain_task = spawn_stderr_drain(reader); let connected = tokio::time::timeout( BROWSER_TIMEOUT, Browser::connect_with_config(url, handler_config()), ) .await; let (browser, mut handler) = match connected { Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, OwnedBrowserLaunchError::ConnectFailed, true, ) .await); } Err(_) => { drain_task.abort(); return Err(cleanup_failed_launch( process, temp, OwnedBrowserLaunchError::ConnectTimeout, true, ) .await); } }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { break; } } }); Ok(OwnedBrowser { browser, process, handler_task, drain_task, temp: Some(temp), }) } async fn launch_browser_attempt( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { let temporary = create_browser_process_temp_dir() .map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?; let config = browser_config(executable, &temporary, proxy_bypass_list) .map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?; launch_owned_browser(config, executable, temporary).await } async fn launch_browser_with_recovery( executable: &std::path::Path, proxy_bypass_list: &str, ) -> Result { launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list)) .await .map_err(|failure| failure.diagnostic()) } /// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 /// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 pub(crate) async fn check_browser_health() -> Result { let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; let owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?; let version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await; if owned.shutdown().await.is_err() { return Err("browser-cleanup-failed".into()); } let version = version .map_err(|_| "browser-cdp-timeout")? .map_err(|_| "browser-cdp-failed")?; if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) { return Err("browser-version-invalid".into()); } Ok(BrowserIdentity { kind: discovered.kind, product: version.product, protocol_version: version.protocol_version, }) } fn safe_version_label(value: &str) -> bool { !value.is_empty() && value.len() <= 128 && value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_')) } pub async fn validate_local_preview_in_browser( input: BrowserValidationInput, ) -> Result { validate_local_preview_in_browser_with_interaction(input, false).await } /// Runs the same restricted Chromium validation, optionally adding one bounded, /// advisory click on a visible enabled control. The interaction is evidence for /// the direct Codex reviewer; it is not a fixed gameplay contract and never /// changes the deterministic browser safety checks. pub async fn validate_local_preview_in_browser_with_interaction( input: BrowserValidationInput, advisory_interaction: bool, ) -> Result { validate_local_preview_in_browser_with_cancellation(input, advisory_interaction, None).await } pub(crate) async fn validate_local_preview_in_browser_with_cancellation( input: BrowserValidationInput, advisory_interaction: bool, cancellation: Option>, ) -> Result { if cancellation .as_ref() .is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire)) { return Err("宿主已停止本轮浏览器验证".into()); } let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); let owned = launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list) .await .map_err(|error| { if error.starts_with("browser-recovery-") || error.starts_with("browser-launch-failed") { error } else { format!("启动浏览器失败:{error}") } })?; let work = run_browser_validation( owned.browser(), &browser_executable, &preview_url, &input, advisory_interaction, ); let cancelled = async { let Some(flag) = cancellation else { std::future::pending::<()>().await; return; }; while !flag.load(std::sync::atomic::Ordering::Acquire) { tokio::time::sleep(Duration::from_millis(50)).await; } }; let validation = tokio::select! { result=work => result, _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; if let Err(error) = owned.shutdown().await { return Err(error); } let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; write_json_report(&result.evidence.report_path, &persisted_result)?; Ok(result) } #[cfg(test)] mod health_tests { use super::*; #[test] fn health_launch_uses_isolated_profiles_and_same_restricted_configuration() { let first = create_browser_process_temp_dir().unwrap(); let second = create_browser_process_temp_dir().unwrap(); let executable = std::env::current_exe().unwrap(); let first_config = browser_config(&executable, &first, "<-loopback>").unwrap(); let second_config = browser_config(&executable, &second, "<-loopback>").unwrap(); assert_ne!(first_config.user_data_dir, second_config.user_data_dir); assert!(first_config.user_data_dir.unwrap().is_dir()); assert!(!safe_version_label("Chrome/123\nTOKEN=secret")); assert!(safe_version_label("HeadlessChrome/140.0.1.2")); } #[test] fn devtools_ws_url_only_accepts_loopback_browser_endpoints() { assert_eq!( parse_devtools_ws_url( "DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n" ) .as_deref(), Some("ws://127.0.0.1:53317/devtools/browser/abc-123") ); assert!(parse_devtools_ws_url( "DevTools listening on ws://localhost:9222/devtools/browser/x" ) .is_some()); assert!( parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x") .is_some() ); // 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。 assert!(parse_devtools_ws_url( "DevTools listening on ws://evil.example.com:9222/devtools/browser/x" ) .is_none()); assert!(parse_devtools_ws_url( "DevTools listening on http://127.0.0.1:9222/devtools/browser/x" ) .is_none()); assert!( parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x") .is_none() ); assert!(parse_devtools_ws_url( "DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x" ) .is_none()); assert!(parse_devtools_ws_url("random browser log line").is_none()); let overlong = format!( "DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}", "a".repeat(MAX_WS_URL_LEN) ); assert!(parse_devtools_ws_url(&overlong).is_none()); } #[test] fn handler_config_mirrors_browser_config_builder() { let config = handler_config(); assert_eq!(config.request_timeout, BROWSER_TIMEOUT); assert!(config.request_intercept); assert!(!config.cache_enabled); // 其余字段必须与 BrowserConfigBuilder 默认值保持一致 // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 assert!(config.ignore_https_errors); assert!(config.ignore_invalid_messages); assert_eq!(config.viewport, Some(Viewport::default())); } #[tokio::test] async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() { let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); let attempts_for_launch = std::sync::Arc::clone(&attempts); let profiles_for_launch = std::sync::Arc::clone(&profiles); let result = launch_with_one_recovery(move || { let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); let profiles = std::sync::Arc::clone(&profiles_for_launch); async move { let temporary = tempfile::tempdir().unwrap(); profiles .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) .push(temporary.path().to_path_buf()); if attempt == 0 { drop(temporary); Err(BrowserLaunchFailure::from_launch_error( OwnedBrowserLaunchError::WsFailed, true, true, )) } else { drop(temporary); Ok(()) } } }) .await; assert!(result.is_ok()); assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2); let profiles = profiles .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); assert_eq!(profiles.len(), 2); assert_ne!(profiles[0], profiles[1]); } #[tokio::test] async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() { let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); let attempts_for_launch = std::sync::Arc::clone(&attempts); let failure = launch_with_one_recovery(move || { attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel); async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( OwnedBrowserLaunchError::ConnectTimeout, false, false, )) } }) .await .unwrap_err(); let diagnostic = failure.diagnostic(); assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1); assert!(diagnostic.contains("stage=cdp-connect")); assert!(diagnostic.contains("subprocess-exited=false")); assert!(diagnostic.contains("cleanup-confirmed=false")); assert!(diagnostic.contains("recovery-retried=false")); } #[tokio::test] async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() { let failure = launch_with_one_recovery(|| async { Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error( OwnedBrowserLaunchError::WsFailed, true, true, )) }) .await .unwrap_err(); let diagnostic = failure.diagnostic(); assert!(diagnostic.contains("initial-stage=ws-handshake")); assert!(diagnostic.contains("final-stage=ws-handshake")); assert!(diagnostic.contains("subprocess-exited=true")); assert!(diagnostic.contains("cleanup-confirmed=true")); assert!(diagnostic.contains("recovery-retried=true")); assert!(!diagnostic.contains("/tmp")); } #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { let (first, second) = tokio::join!(check_browser_health(), check_browser_health()); assert!(!first.unwrap().product.is_empty()); assert!(!second.unwrap().protocol_version.is_empty()); } #[tokio::test] #[ignore = "requires an installed browser; verifies cancellation after the real page is requested and cleanup completes"] async fn real_browser_budget_cancellation_closes_an_already_started_validation() { let page_requested = std::sync::Arc::new(tokio::sync::Notify::new()); let observed = std::sync::Arc::clone(&page_requested); let app=axum::Router::new().route("/",axum::routing::get(move || { let observed=std::sync::Arc::clone(&observed); async move { observed.notify_one(); axum::response::Html("") } })); let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)) .await .unwrap(); let address = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let flag = std::sync::Arc::clone(&cancellation); let cancel = tokio::spawn(async move { tokio::time::timeout(Duration::from_secs(20), page_requested.notified()) .await .expect("real browser requested page"); flag.store(true, std::sync::atomic::Ordering::Release); }); let temp = tempfile::tempdir().unwrap(); let result = validate_local_preview_in_browser_with_cancellation( BrowserValidationInput { url: format!("http://{address}/"), viewports: vec![ crate::browser::BrowserValidationViewport::Desktop, crate::browser::BrowserValidationViewport::Mobile, ], expected_text: Vec::new(), settle_ms: 4000, fail_on_console_error: true, playtest_scenario: None, evidence_root: temp.path().join("evidence"), }, false, Some(cancellation), ) .await; cancel.await.unwrap(); server.abort(); let _ = server.await; assert_eq!(result.unwrap_err(), "宿主已停止本轮浏览器验证"); } }