diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 3330832ff..38545eed1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -1,9 +1,13 @@ use std::fs; 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 futures::StreamExt; +use chromiumoxide::handler::HandlerConfig; +use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; @@ -15,6 +19,8 @@ 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; fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] @@ -63,21 +69,257 @@ fn browser_config( .map_err(|error| format!("构建浏览器配置失败:{error}")) } -/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 -/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 -pub(crate) async fn check_browser_health() -> Result { - let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; - let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; - let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") - .map_err(|_| "browser-config-invalid")?; - // 外层超时给 Chromiumoxide 内部启动超时后的 kill/wait 留出清理窗口。 - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), +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 在这里逐项镜像;其余字段 +/// (ignore_https_errors / ignore_invalid_messages / viewport)与 +/// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。 +fn handler_config() -> HandlerConfig { + let mut config = HandlerConfig::default(); + config.request_timeout = BROWSER_TIMEOUT; + config.request_intercept = true; + config.cache_enabled = false; + 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 is_timeout(self) -> bool { + matches!(self, Self::WsTimeout | Self::ConnectTimeout) + } + + 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", + } + } +} + +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 { + /// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job + /// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。 + async fn reap(&mut self) { + #[cfg(windows)] + if let Some(job) = &self.job { + let _ = job.terminate(); + } + let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await; + let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await; + } + + async fn confirm_reaped(&self) -> Result<(), String> { + #[cfg(windows)] + if let Some(job) = &self.job { + 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; + } + } + Ok(()) + } +} + +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<()>, +} + +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(_))) { + return Ok(()); + } + self.process.reap().await; + self.process.confirm_reaped().await + } +} + +impl Drop for OwnedBrowser { + fn drop(&mut self) { + self.handler_task.abort(); + self.drain_task.abort(); + // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 + } +} + +/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 +/// 窗口:绑定前产生的子进程未入 Job,随 root 死亡级联退出;绑定之后 +/// 的一切子进程由 Job 全覆盖。 +async fn launch_owned_browser( + config: BrowserConfig, +) -> Result { + let child = config + .launch() + .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; + // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 + #[cfg(windows)] + let job = match WindowsProcessJob::assign_tokio(&child.inner) { + Ok(job) => Some(job), + Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + }; + let mut process = BrowserProcessGuard { + child, + #[cfg(windows)] + job, + }; + let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await + { + Ok(pair) => pair, + Err(error) => { + process.reap().await; + return Err(error); + } + }; + let drain_task = spawn_stderr_drain(reader); + let connected = tokio::time::timeout( + BROWSER_TIMEOUT, + Browser::connect_with_config(url, handler_config()), ) - .await - .map_err(|_| "browser-start-timeout")? - .map_err(|_| "browser-start-failed")?; + .await; + let (browser, mut handler) = match connected { + Ok(Ok(pair)) => pair, + Ok(Err(_)) => { + drain_task.abort(); + process.reap().await; + return Err(OwnedBrowserLaunchError::ConnectFailed); + } + Err(_) => { + drain_task.abort(); + process.reap().await; + return Err(OwnedBrowserLaunchError::ConnectTimeout); + } + }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { @@ -85,14 +327,30 @@ pub(crate) async fn check_browser_health() -> Result { } } }); - let version = tokio::time::timeout(Duration::from_secs(5), browser.version()).await; - let close = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let waited = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close, Ok(Ok(_))) || !matches!(waited, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - let _ = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; + Ok(OwnedBrowser { + browser, + process, + handler_task, + drain_task, + }) +} + +/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 +/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 +pub(crate) async fn check_browser_health() -> Result { + let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; + let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") + .map_err(|_| "browser-config-invalid")?; + let owned = launch_owned_browser(config).await.map_err(|error| { + if error.is_timeout() { + "browser-start-timeout" + } else { + "browser-start-failed" + } + })?; + 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 @@ -155,23 +413,16 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( &proxy_bypass_list, )?; - let (mut browser, mut handler) = tokio::time::timeout( - BROWSER_TIMEOUT + Duration::from_secs(5), - Browser::launch(config), - ) - .await - .map_err(|_| "启动浏览器超时".to_string())? - .map_err(|error| format!("启动浏览器失败:{error}"))?; - let handler_task = tokio::spawn(async move { - while let Some(message) = handler.next().await { - if message.is_err() { - break; - } + let owned = launch_owned_browser(config).await.map_err(|error| { + if error.is_timeout() { + "启动浏览器超时".to_string() + } else { + format!("启动浏览器失败:{}", error.code()) } - }); + })?; let work = run_browser_validation( - &browser, + owned.browser(), &browser_executable, &preview_url, &input, @@ -191,20 +442,10 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( _=cancelled => Err("宿主已停止本轮浏览器验证".to_string()), }; - let close_result = tokio::time::timeout(Duration::from_secs(5), browser.close()).await; - let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await; - handler_task.abort(); - let _ = handler_task.await; - if !matches!(close_result, Ok(Ok(_))) || !matches!(wait_result, Ok(Ok(_))) { - let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await; - if !matches!( - tokio::time::timeout(Duration::from_secs(5), browser.wait()).await, - Ok(Ok(_)) - ) { - return Err( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), - ); - } + if owned.shutdown().await.is_err() { + return Err( + "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), + ); } drop(browser_temp); let mut result = validation?; @@ -231,6 +472,60 @@ mod health_tests { 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 默认值保持一致。 + assert!(config.ignore_https_errors); + assert!(config.ignore_invalid_messages); + assert!(config.viewport.is_none()); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs index 3a890ead3..1463a6c08 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/tests.rs @@ -1815,3 +1815,33 @@ setInterval(() => {}, 1000); thread::sleep(Duration::from_millis(25)); } } + +#[cfg(windows)] +#[test] +fn windows_process_job_terminate_reaps_process_tree() { + use std::process::{Command, Stdio}; + + // cmd 启动第一个 timeout 子进程后整树存活;terminate 必须连子进程一起收割。 + let mut child = Command::new("cmd.exe") + .args([ + "/c", "timeout", "/t", "60", "/nobreak", "&", "timeout", "/t", "60", "/nobreak", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn cmd fixture"); + let job = WindowsProcessJob::assign_std(&child).expect("assign job"); + thread::sleep(Duration::from_millis(500)); + job.terminate().expect("terminate job"); + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !job.is_empty().expect("query job") { + assert!( + std::time::Instant::now() < deadline, + "Windows Job 进程树未被收割" + ); + thread::sleep(Duration::from_millis(50)); + } + let _ = child.kill(); + let _ = child.wait(); +}