From 2579c04c4d8449f73b2e12180a44bda41a2051e8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Fri, 2 Oct 2026 20:50:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Web=20=E9=A2=84=E6=A3=80?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E5=A4=B1=E8=B4=A5=E6=81=A2=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WS 握手与 CDP 连接瞬态失败在确认进程树清理后使用新 Profile 重试一次\n- 加强 Windows Edge 进程身份、命令行 Profile 与 Job 子树归属校验\n- 透传恢复阶段诊断并补充预检规范、里程碑计划和定向测试 --- .../src-tauri/src/browser/process.rs | 374 +++++++++++++++--- .../src-tauri/src/browser/sweep.rs | 143 ++++++- .../src/environment_check/web_creation.rs | 102 ++++- ...ž施计划】Web预检浏览器失败恢复-2026-10-02.md | 31 ++ ...里程碑】Web预检浏览器失败恢复-2026-10-02.md | 41 ++ ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 +- 6 files changed, 599 insertions(+), 96 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md create mode 100644 docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md 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 098f43841..5d84d2c21 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,4 +1,5 @@ use std::fs; +use std::future::Future; use std::path::PathBuf; use std::time::Duration; #[cfg(windows)] @@ -120,8 +121,19 @@ enum OwnedBrowserLaunchError { } impl OwnedBrowserLaunchError { - fn is_timeout(self) -> bool { - matches!(self, Self::WsTimeout | Self::ConnectTimeout) + 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 { @@ -135,6 +147,118 @@ impl OwnedBrowserLaunchError { } } +#[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 直到 @@ -224,22 +348,22 @@ impl BrowserProcessGuard { async fn confirm_reaped(&mut 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; + 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; } - Ok(()) } #[cfg(not(windows))] { @@ -281,7 +405,10 @@ impl OwnedBrowser { 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(_))) { + if matches!(close, Ok(Ok(_))) + && matches!(waited, Ok(Ok(_))) + && self.process.confirm_reaped().await.is_ok() + { return Ok(()); } if !self.process.reap().await { @@ -310,6 +437,24 @@ impl Drop for OwnedBrowser { } } +async fn cleanup_failed_launch( + mut process: BrowserProcessGuard, + temp: TempDir, + error: OwnedBrowserLaunchError, +) -> BrowserLaunchFailure { + let subprocess_exited = process.reap().await; + let tree_reaped = subprocess_exited && process.confirm_reaped().await.is_ok(); + 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 连接。spawn 与 Job 绑定之间存在极小的逸出 /// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; /// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 @@ -319,15 +464,27 @@ async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, temp: TempDir, -) -> Result { - let child = config - .launch() - .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; - // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 +) -> Result { + let child = match config.launch() { + Ok(child) => child, + Err(_) => { + return Err(BrowserLaunchFailure::from_launch_error( + OwnedBrowserLaunchError::SpawnFailed, + true, + true, + )); + } + }; + // 无法证明整树可收割时拒绝放行浏览器;先收束 root,但不宣称 Job 子树已清空。 #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { Ok(job) => Some(job), - Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed), + Err(_) => { + let process = BrowserProcessGuard { child, job: None }; + return Err( + cleanup_failed_launch(process, temp, OwnedBrowserLaunchError::SpawnFailed).await, + ); + } }; let mut process = BrowserProcessGuard { child, @@ -336,17 +493,13 @@ async fn launch_owned_browser( }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { - super::sweep::write_browser_process_owner(temp.path(), pid, executable); + 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) => { - // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 - if !process.reap().await { - std::mem::forget(temp); - } - return Err(error); + return Err(cleanup_failed_launch(process, temp, error).await); } }; let drain_task = spawn_stderr_drain(reader); @@ -359,17 +512,21 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectFailed); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectFailed, + ) + .await); } Err(_) => { drain_task.abort(); - if !process.reap().await { - std::mem::forget(temp); - } - return Err(OwnedBrowserLaunchError::ConnectTimeout); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::ConnectTimeout, + ) + .await); } }; let handler_task = tokio::spawn(async move { @@ -388,22 +545,31 @@ async fn launch_owned_browser( }) } +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 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, &discovered.executable_path, temporary) - .await - .map_err(|error| { - if error.is_timeout() { - "browser-start-timeout" - } else { - "browser-start-failed" - } - })?; + 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()); @@ -460,23 +626,19 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; let browser_executable = discover_chrome_or_edge()?; - let browser_temp = create_browser_process_temp_dir()?; let proxy_bypass_list = preview_proxy_bypass_list(&preview_url); - let config = browser_config( - &browser_executable.executable_path, - &browser_temp, - &proxy_bypass_list, - )?; - - let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp) - .await - .map_err(|error| { - if error.is_timeout() { - "启动浏览器超时".to_string() - } else { - format!("启动浏览器失败:{}", error.code()) - } - })?; + 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(), @@ -583,6 +745,88 @@ mod health_tests { 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() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index d33a4a2ef..f002db1b4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -34,13 +34,17 @@ pub(super) struct BrowserProcessOwner { /// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, /// 该目录之后按旧残留只删不杀。 -pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { +pub(super) fn write_browser_process_owner( + temp_root: &Path, + browser_pid: u32, + executable: &Path, +) -> bool { let identity = crate::process_identity::external_agent_runner_process_start_identity(browser_pid); let owner_identity = crate::process_identity::external_agent_runner_process_start_identity(std::process::id()); let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { - return; + return false; }; let owner = BrowserProcessOwner { schema_version: OWNER_SCHEMA.into(), @@ -52,9 +56,9 @@ pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, ex created_unix_ms: super::evidence::unix_time_ms(), }; let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { - return; + return false; }; - let _ = fs::write(temp_root.join(OWNER_FILE), bytes); + fs::write(temp_root.join(OWNER_FILE), bytes).is_ok() } fn read_browser_process_owner(dir: &Path) -> Option { @@ -115,34 +119,109 @@ fn trusted_browser_executable(path: &Path) -> bool { } /// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 -/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 -/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 -/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 -/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +/// 文件,命令行还必须绑定同一份临时 Profile;只核对镜像会把其它 AGC +/// 实例或用户 Edge 误认成本轮浏览器。 #[cfg(windows)] -fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { - // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 - // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 +fn windows_process_command_line(pid: u32) -> Option { + use std::ffi::c_void; + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_INFORMATION, PROCESS_VM_READ, + }; + + #[repr(C)] + struct UnicodeString { + length: u16, + maximum_length: u16, + buffer: *const u16, + } + + #[link(name = "ntdll")] + unsafe extern "system" { + fn NtQueryInformationProcess( + process: *mut c_void, + information_class: u32, + information: *mut c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + } + + let process = unsafe { OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, pid) }; + if process.is_null() { + return None; + } + let mut required = 0; + let _ = + unsafe { NtQueryInformationProcess(process, 60, std::ptr::null_mut(), 0, &mut required) }; + if required == 0 || required > 64 * 1024 { + unsafe { CloseHandle(process) }; + return None; + } + let mut buffer = vec![0u8; required as usize]; + let status = unsafe { + NtQueryInformationProcess( + process, + 60, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + }; + unsafe { CloseHandle(process) }; + if status != 0 { + return None; + } + let command_line = unsafe { &*(buffer.as_ptr().cast::()) }; + if command_line.buffer.is_null() || command_line.length == 0 || command_line.length % 2 != 0 { + return None; + } + let text = unsafe { + std::slice::from_raw_parts(command_line.buffer, command_line.length as usize / 2) + }; + String::from_utf16(text).ok() +} + +#[cfg(windows)] +fn command_line_contains_profile(command_line: &str, profile_dir: &Path) -> bool { + const MARKER: &str = "--user-data-dir="; + let mut remaining = command_line; + while let Some(index) = remaining.find(MARKER) { + let value = remaining[index + MARKER.len()..].trim_start(); + let value = if let Some(quoted) = value.strip_prefix('"') { + quoted.split('"').next().unwrap_or_default() + } else { + value.split_whitespace().next().unwrap_or_default() + }; + if same_executable_path(Path::new(value), profile_dir) { + return true; + } + remaining = &remaining[index + MARKER.len()..]; + } + false +} + +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, }; - // SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。 let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; if handle.is_null() { return false; } let mut buffer = [0u16; 1024]; let mut length = buffer.len() as u32; - // SAFETY: buffer 可写,length 先传入容量、返回实际长度。 let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) }; - // SAFETY: handle 是本函数持有的合法句柄。 unsafe { CloseHandle(handle) }; if okay == 0 || length == 0 { return false; } let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize])); same_executable_path(&actual, expected) + && windows_process_command_line(pid) + .is_some_and(|command_line| command_line_contains_profile(&command_line, profile_dir)) } #[cfg(target_os = "linux")] @@ -468,6 +547,18 @@ mod tests { } } + #[test] + fn owner_write_failure_leaves_no_partial_ownership_record() { + let root = tempfile::tempdir().unwrap(); + let missing_root = root.path().join("missing"); + assert!(!write_browser_process_owner( + &missing_root, + std::process::id(), + &std::env::current_exe().unwrap(), + )); + assert!(!missing_root.join(OWNER_FILE).exists()); + } + #[test] fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { let root = tempfile::tempdir().unwrap(); @@ -548,6 +639,24 @@ mod tests { assert!(trusted_browser_executable(&installed)); } + #[cfg(windows)] + #[test] + fn windows_browser_cleanup_requires_the_exact_profile_argument() { + let profile = Path::new(r#"C:\Users\tester\App Data\ga-browser-1\profile"#); + assert!(command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-2\profile""#, + profile + )); + assert!(!command_line_contains_profile( + r#"msedge.exe --headless --user-data-dir="C:\Users\tester\App Data\ga-browser-1\profile-copy""#, + profile + )); + } + #[cfg(windows)] #[test] fn kill_browser_process_tree_reaps_fixture_tree() { @@ -600,10 +709,8 @@ mod tests { fn live_process_executable_matches_current_process_image() { let exe = std::env::current_exe().unwrap(); let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); - // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 - // 不具备该标记,正例只在 Windows 断言。 - #[cfg(windows)] - assert!(live_process_executable_matches( + // 当前测试进程不携带目标 Profile 标识,即使镜像一致也不能清理。 + assert!(!live_process_executable_matches( std::process::id(), &exe, profile diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 8fa08dcd6..fc17d9f12 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs @@ -311,6 +311,13 @@ async fn host_npm( fn browser_validation_failure_code(error: &str) -> &'static str { if error.contains("未发现可用的") { "web-preflight-browser-missing" + } else if error.starts_with("browser-recovery-") { + "web-preflight-browser-recovery-failed" + } else if error.contains("browser-temp-unavailable") || error.contains("browser-config-invalid") + { + "web-preflight-browser-environment-failed" + } else if error.starts_with("browser-launch-failed:") { + "web-preflight-browser-launch-failed" } else if error.contains("启动浏览器超时") { "web-preflight-browser-launch-timeout" } else if error.contains("启动浏览器失败") { @@ -342,6 +349,11 @@ fn browser_validation_failure_code(error: &str) -> &'static str { } } +fn browser_validation_diagnostic(error: &str) -> Option<&str> { + (error.starts_with("browser-recovery-") || error.starts_with("browser-launch-failed:")) + .then_some(error) +} + pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 @@ -362,18 +374,60 @@ pub(crate) async fn host_web_creation_preflight() -> Value { let validation = crate::browser::validate_local_preview_in_browser(BrowserValidationInput { url: preview.url, viewports: vec![BrowserValidationViewport::Desktop, BrowserValidationViewport::Mobile], expected_text: vec![], settle_ms: 100, fail_on_console_error: true, playtest_scenario: None, evidence_root: root.join("evidence"), }).await; - let result = validation.map_err(|error| browser_validation_failure_code(&error).to_string())?; - if !result.passed || result.viewport_results.len() != 2 { return Err("web-preflight-page-check-failed".into()); } + let result = validation + .map_err(|error| { + let code = browser_validation_failure_code(&error); + browser_validation_diagnostic(&error) + .map(|diagnostic| format!("{code};diagnostic={diagnostic}")) + .unwrap_or_else(|| code.to_string()) + })?; + if !result.passed || result.viewport_results.len() != 2 { + return Err("web-preflight-page-check-failed".into()); + } let mut pngs = Vec::new(); for viewport in result.viewport_results { - let bytes = fs::read(&viewport.screenshot_path).map_err(|_| "web-preflight-screenshot-missing")?; - if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { return Err("web-preflight-screenshot-invalid".into()); } - image::load_from_memory(&bytes).map_err(|_| "web-preflight-screenshot-invalid")?; - pngs.push(json!({"viewport":viewport.viewport,"passed":viewport.passed,"pngBytes":bytes.len()})); + let bytes = fs::read(&viewport.screenshot_path) + .map_err(|_| "web-preflight-screenshot-missing")?; + if !bytes.starts_with(b"\x89PNG\r\n\x1a\n") || bytes.len() < 128 { + return Err("web-preflight-screenshot-invalid".into()); + } + image::load_from_memory(&bytes) + .map_err(|_| "web-preflight-screenshot-invalid")?; + pngs.push(json!({ + "viewport": viewport.viewport, + "passed": viewport.passed, + "pngBytes": bytes.len(), + })); } - Ok::<_, String>(json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"ready","runtime":{"source":runtime.source,"nodeVersion":node,"npmVersion":npm},"build":{"status":"ready","kind":"npm-node-fixture"},"browser":{"status":"ready","viewports":pngs}})) - }.await; - let mut result = run.unwrap_or_else(|code| json!({"schemaVersion":"agc-web-creation-preflight.v1","status":"blocked","code":code})); + Ok::<_, String>(json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "ready", + "runtime": { + "source": runtime.source, + "nodeVersion": node, + "npmVersion": npm, + }, + "build": {"status": "ready", "kind": "npm-node-fixture"}, + "browser": {"status": "ready", "viewports": pngs}, + })) + } + .await; + let mut result = run.unwrap_or_else(|error| { + let (code, diagnostic) = error + .split_once(";diagnostic=") + .map_or((error.as_str(), None), |(code, diagnostic)| { + (code, Some(diagnostic)) + }); + let mut blocked = json!({ + "schemaVersion": "agc-web-creation-preflight.v1", + "status": "blocked", + "code": code, + }); + if let Some(diagnostic) = diagnostic { + blocked["diagnostic"] = json!(diagnostic); + } + blocked + }); result["elapsedMs"] = json!(started.elapsed().as_millis()); result } @@ -382,9 +436,14 @@ pub(crate) async fn host_web_creation_preflight() -> Value { pub(crate) async fn preflight_web_game_creation() -> Result { let result = host_web_creation_preflight().await; if result["status"] != "ready" { + let diagnostic = result["diagnostic"] + .as_str() + .map(|value| format!(";诊断:{value}")) + .unwrap_or_default(); return Err(format!( - "Web 游戏环境预检未通过:{};尚未启动生成", - result["code"].as_str().unwrap_or("web-preflight-failed") + "Web 游戏环境预检未通过:{}{};尚未启动生成", + result["code"].as_str().unwrap_or("web-preflight-failed"), + diagnostic, )); } Ok(result) @@ -548,6 +607,27 @@ mod tests { browser_validation_failure_code("启动浏览器失败:2"), "web-preflight-browser-launch-failed" ); + + assert_eq!( + browser_validation_failure_code( + "browser-launch-failed: stage=setup cause=browser-temp-unavailable" + ), + "web-preflight-browser-environment-failed" + ); + + let recovery_diagnostic = "browser-recovery-failed: initial-stage=ws-handshake final-stage=cdp-connect subprocess-exited=true cleanup-confirmed=true recovery-retried=true"; + assert_eq!( + browser_validation_failure_code(recovery_diagnostic), + "web-preflight-browser-recovery-failed" + ); + assert_eq!( + browser_validation_diagnostic(recovery_diagnostic), + Some(recovery_diagnostic) + ); + assert_eq!( + browser_validation_diagnostic("启动浏览器失败:原始错误"), + None + ); assert_eq!( browser_validation_failure_code("宿主已停止本轮浏览器验证"), "web-preflight-cancelled" diff --git a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..b887524b2 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,31 @@ +# Web 预检浏览器失败恢复实现计划 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Milestone: [`Web 预检浏览器失败恢复`](./【里程碑】Web预检浏览器失败恢复-2026-10-02.md) + +## 实现顺序 + +1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 +2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。 +3. 保留/加强 owner 与 sweep 的归属门禁,补齐 Windows 活进程 Profile 命令行标识核对;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 +4. 将安全恢复诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 +5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。 + +## 验证命令 + +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell browser::` +- `cargo test --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml --bin genarrative-ai-game-creator-shell environment_check::web_creation::tests` +- `npx vitest run apps/ai-game-creator-shell/tests/homeWebPreflight.test.tsx` +- `npm run typecheck --workspace apps/ai-game-creator-shell` +- `npm run check:encoding` +- `git diff --check` + +可选真实环境证据:安装 Windows Edge 时运行现有 `real_browser_health_checks_can_run_concurrently` 及新增恢复 smoke;无浏览器时保持 ignored,不把缺失环境写成通过。 + +## 风险与回滚 + +- Windows 进程命令行读取失败按不匹配处理,不执行杀进程;这可能留下临时目录,但保证不误杀。 +- 首次失败进程树收束未确认时不自动重试,避免第二个 Edge 与残留树并存;错误返回安全诊断。 +- 回滚点为浏览器启动恢复入口和 owner/sweep 归属校验,不触及 Web 预检的 Node/npm 或项目写入流程。 diff --git a/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md new file mode 100644 index 000000000..e56e78d5c --- /dev/null +++ b/docs/project-memory/plans/【里程碑】Web预检浏览器失败恢复-2026-10-02.md @@ -0,0 +1,41 @@ +# Web 预检浏览器失败恢复 + +- Version: `1` +- Status: `active` +- Date: `2026-10-02` +- Parent Spec: [`AI游戏创作智能体 App 实施计划`](../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md#自动预检与可信脚手架) + +## 目标 + +修复 Issue #584:Web 游戏环境预检在受控 Edge/Chrome 的 WS 握手或 CDP 连接失败时,能够只针对本轮 AGC 浏览器实例完成清理、使用新的隔离 Profile 重试一次,并在仍失败时保留安全、可行动的诊断信息。 + +## 边界 + +- 只改 AGC Rust 浏览器启动/清理、Web 预检错误投影及其定向测试、对应现行专题文档。 +- 清理对象必须同时满足 AGC 临时 Profile、进程启动身份、可信浏览器可执行文件和 Windows 进程树归属;无法确认时 fail-closed。 +- 不执行全量 `taskkill /IM msedge.exe`,不影响用户 Edge、其它 AGC 实例、其它项目或 worktree。 +- 不改变 Web 预检的失败关闭、Node/npm 检查、桌面/移动双视口检查和首次生成顺序。 +- 不新增网络、自动下载、跳过浏览器验证或伪造 ready 的旁路。 + +## 行为合同 + +1. WS 握手失败、WS 超时、CDP 连接失败或 CDP 连接超时属于可恢复的浏览器启动瞬态失败。 +2. 第一次失败后,宿主必须先确认本轮进程树已退出并清理本轮 Profile / owner 记录 / 临时目录;清理未确认时不得启动第二次浏览器。 +3. 清理确认后,第二次启动必须创建新的隔离临时目录和 Profile;第二次成功后继续现有 desktop/mobile 预检。 +4. 连续失败或清理被阻断时,结果保持 blocked,并带有阶段(WS 握手或 CDP 连接)、子进程是否退出、清理是否确认、是否执行恢复重试等安全诊断。 +5. owner.json 缺失/写入失败、目录过新、进程已退出、PID 被复用、身份未知或归属不明时只能按保守路径处理:不杀不明进程;可安全删除的临时目录才删除。 + +## 验收标准 + +- 定向测试构造 `browser-ws-failed` 后证明只在清理确认时重试,且重试使用新的 Profile;第二次成功返回成功。 +- 定向测试覆盖 WS/CDP 阶段、连续失败、清理未确认、owner.json 缺失/无效、刚创建目录、进程退出、PID 复用和非 AGC 进程跳过。 +- Windows 真实 Edge 安装版 smoke 保留在现有真实浏览器测试入口中;无 Edge 的环境明确跳过而不是伪造通过。 +- 现有首页预检、正常 Edge 使用、首次生成失败关闭和前端 IPC/宿主错误区分回归通过。 + +## 依赖 + +- `apps/ai-game-creator-shell/src-tauri/src/browser/process.rs` +- `apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs` +- `apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs` +- `apps/ai-game-creator-shell/src/features/app-shell/homeWebPreflight.ts` +- 对应 Rust 与 Vitest 测试。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 70bbbd2fe..74c74685f 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -134,7 +134,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema - 对客户端刚创建且内容仍匹配可信模板的 Web 脚手架,在正式生成前由宿主执行受控依赖准备和真实 Vite 构建。依赖安装禁用生命周期脚本;不自动安装或覆盖导入/用户修改过的工程。 - 准备凭证的 `ready` 只证明初次环境准备成功,不代表当前游戏已验收,后续正常修改不得因此重新安装。`preparing` 中断恢复必须证明原拥有者已结束且其执行子树已回收;身份或归属未知时保留阻断,不重复执行。 - 输出明确区分“Node/npm 构建能力通过”与“本项目 Vite 构建通过”;任何一步失败保留可行动错误,不降级为预检成功。 -- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。 +- 首页必须区分宿主预检阻塞与 Tauri IPC 调用失败:Rust 返回的安全错误码可透传到状态栏;无错误码的瞬态 IPC 失败只允许一次有界重试后显示独立的客户端连接故障,不得统一伪装成 Node/npm 或浏览器故障。预检仍保持失败关闭,不得因为展示错误变得可绕过。浏览器 WS 握手、WS 超时和 CDP 连接失败只允许在确认本轮 AGC 浏览器进程树已退出、Profile/owner/临时目录已按归属清理后使用新的隔离 Profile 自动恢复一次;清理未确认时不得重试,连续失败必须返回包含阶段、子进程退出确认、清理确认和是否重试的安全诊断。 - 安装载荷提供相同的安全预检 CLI,验证无系统 Node 的独立运行、缺包/篡改失败关闭。NSIS 解包载荷 smoke 与真实安装器注册流程分开报告,不覆盖当前用户的既有安装。 ### 跑酷固定基线 @@ -188,7 +188,7 @@ UI 编辑器的“分析参考图”步骤、Rust 命令 `suggest_ui_design_sema ### 环境与工作流 - 客户端交付配套 Node/npm;发布包从本机已安装且与目标平台/架构一致的工具链制作受校验资源,保留许可并校验内容摘要。安装态不依赖系统 PATH 的 Node;开发态可使用已验证的宿主运行时。不得从项目或相对 PATH 加载伪造运行时。随包运行时是**单架构**官方发行版,因此 macOS 当前只构建 `aarch64-apple-darwin` 单架构包;要出 universal 必须先让 staging 支持按架构各带一份同版本运行时,在此之前 universal 目标失败关闭,不得只带宿主架构那一份糊过去。 -- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 +- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。