From c833e30d735721ac7d737355672d111fbe7d8d45 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 12:50:18 +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=E6=81=A2=E5=A4=8D=E4=B8=8E=E5=AE=89?= =?UTF-8?q?=E5=85=A8=E6=94=B6=E6=9D=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复 Windows Job 绑定窗口内子树未纳入的问题,无法确认时收束并 fail-closed。 使用 CommandLineToArgvW 校验含空格 Profile,补充 Job 子树与 Profile 回归测试。 补齐清理失败安全诊断和浏览器环境错误码契约。 --- .../src-tauri/Cargo.toml | 2 +- .../src-tauri/src/browser/process.rs | 82 +++++++--- .../src-tauri/src/browser/sweep.rs | 140 ++++++++++++++++-- .../src/environment_check/web_creation.rs | 15 +- .../src-tauri/src/process_session/model.rs | 35 +++++ ...ž施计划】Web预检浏览器失败恢复-2026-10-02.md | 6 +- ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 2 +- 7 files changed, 240 insertions(+), 42 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index fbe737fec..5f3f50505 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -86,7 +86,7 @@ maud = "0.27.0" libc = "0.2" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging"] } +windows-sys = { version = "0.61", features = ["Wdk_Storage_FileSystem", "Win32_Foundation", "Win32_Storage_FileSystem", "Win32_System_Diagnostics_ToolHelp", "Win32_System_IO", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } [profile.dev] opt-level = 0 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 5d84d2c21..cda4b634b 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 @@ -411,21 +411,28 @@ impl OwnedBrowser { { return Ok(()); } - if !self.process.reap().await { + 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".into()); + return Err( + "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false" + .into(), + ); } - let confirmed = self.process.confirm_reaped().await; - if confirmed.is_err() { + 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(), + ); } - confirmed + Ok(()) } } @@ -441,9 +448,11 @@ 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 = subprocess_exited && process.confirm_reaped().await.is_ok(); + let tree_reaped = + tree_control_confirmed && subprocess_exited && process.confirm_reaped().await.is_ok(); drop(process); let cleanup_confirmed = if tree_reaped { temp.close().is_ok() @@ -455,11 +464,9 @@ async fn cleanup_failed_launch( BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed) } -/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 -/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; -/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 -/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 -/// 绑定之后的一切子进程由 Job 全覆盖。 +/// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE +/// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动 +/// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, @@ -475,15 +482,54 @@ async fn launch_owned_browser( )); } }; - // 无法证明整树可收割时拒绝放行浏览器;先收束 root,但不宣称 Job 子树已清空。 #[cfg(windows)] let job = match WindowsProcessJob::assign_tokio(&child.inner) { - Ok(job) => Some(job), + Ok(job) => { + let Some(root_pid) = child.inner.id() 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, &job).is_ok() { + Some(job) + } else { + let tree_reaped = + crate::process_identity::external_agent_runner_process_start_identity(root_pid) + .ok() + .flatten() + .is_some_and(|identity| { + super::sweep::kill_browser_process_tree(root_pid, &identity).is_ok() + }); + let process = BrowserProcessGuard { + child, + job: Some(job), + }; + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + tree_reaped, + ) + .await); + } + } Err(_) => { let process = BrowserProcessGuard { child, job: None }; - return Err( - cleanup_failed_launch(process, temp, OwnedBrowserLaunchError::SpawnFailed).await, - ); + return Err(cleanup_failed_launch( + process, + temp, + OwnedBrowserLaunchError::SpawnFailed, + false, + ) + .await); } }; let mut process = BrowserProcessGuard { @@ -499,7 +545,7 @@ async fn launch_owned_browser( { Ok(pair) => pair, Err(error) => { - return Err(cleanup_failed_launch(process, temp, error).await); + return Err(cleanup_failed_launch(process, temp, error, true).await); } }; let drain_task = spawn_stderr_drain(reader); @@ -516,6 +562,7 @@ async fn launch_owned_browser( process, temp, OwnedBrowserLaunchError::ConnectFailed, + true, ) .await); } @@ -525,6 +572,7 @@ async fn launch_owned_browser( process, temp, OwnedBrowserLaunchError::ConnectTimeout, + true, ) .await); } 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 f002db1b4..6bd43bbcf 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 @@ -12,6 +12,8 @@ use serde::{Deserialize, Serialize}; use super::discovery::system_browser_candidates; use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; +#[cfg(windows)] +use crate::process_session::WindowsProcessJob; const OWNER_FILE: &str = "owner.json"; const OWNER_SCHEMA: &str = "agc-browser-process.v1"; @@ -182,23 +184,56 @@ fn windows_process_command_line(pid: u32) -> Option { String::from_utf16(text).ok() } +#[cfg(windows)] +fn windows_command_line_arguments(command_line: &str) -> Option> { + use windows_sys::Win32::Foundation::LocalFree; + use windows_sys::Win32::UI::Shell::CommandLineToArgvW; + + let wide = command_line + .encode_utf16() + .chain(std::iter::once(0)) + .collect::>(); + let mut count = 0; + let argv = unsafe { CommandLineToArgvW(wide.as_ptr(), &mut count) }; + if argv.is_null() || count < 0 { + if !argv.is_null() { + unsafe { LocalFree(argv.cast()) }; + } + return None; + } + let result = unsafe { std::slice::from_raw_parts(argv, count as usize) } + .iter() + .map(|argument| { + if argument.is_null() { + return None; + } + let mut length = 0; + unsafe { + while *argument.add(length) != 0 { + length += 1; + } + String::from_utf16(std::slice::from_raw_parts(*argument, length)).ok() + } + }) + .collect::>>(); + unsafe { LocalFree(argv.cast()) }; + result +} + #[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 + let Some(arguments) = windows_command_line_arguments(command_line) else { + return false; + }; + arguments.iter().enumerate().any(|(index, argument)| { + let value = argument.strip_prefix(MARKER).or_else(|| { + (argument == "--user-data-dir") + .then(|| arguments.get(index + 1).map(String::as_str)) + .flatten() + }); + value.is_some_and(|value| same_executable_path(Path::new(value), profile_dir)) + }) } #[cfg(windows)] @@ -260,7 +295,10 @@ fn directory_owned_by_current_user(dir: &Path) -> bool { .unwrap_or(false) } -fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { +pub(super) fn kill_browser_process_tree( + root_pid: u32, + expected_identity: &str, +) -> Result<(), String> { #[cfg(windows)] { // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 @@ -402,6 +440,37 @@ fn windows_process_tree_pids_from( Ok(tree) } +#[cfg(windows)] +pub(super) fn ensure_browser_tree_in_job( + root_pid: u32, + job: &WindowsProcessJob, +) -> Result<(), String> { + for _ in 0..TREE_KILL_MAX_PASSES { + let snapshot = windows_process_snapshot()?; + let tree = windows_process_tree_pids_from(&snapshot, root_pid)?; + if tree.is_empty() { + return Err("browser-job-root-exited-before-coverage".into()); + } + for pid in tree { + if !job.contains_pid(pid)? { + job.assign_pid(pid)?; + } + } + + let verified = windows_process_snapshot()?; + let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?; + if !verified_tree.is_empty() + && verified_tree + .iter() + .all(|pid| job.contains_pid(*pid).unwrap_or(false)) + { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-job-tree-coverage-unconfirmed".into()) +} + #[cfg(windows)] fn windows_terminate_process(pid: u32) { use windows_sys::Win32::Foundation::CloseHandle; @@ -647,6 +716,10 @@ mod tests { 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" "--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 @@ -657,6 +730,43 @@ mod tests { )); } + #[cfg(windows)] + #[test] + fn browser_job_adopts_children_created_before_assignment() { + use std::process::{Command, Stdio}; + + let mut child = Command::new("cmd.exe") + .args([ + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", + ]) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn fixture"); + let pid = child.id(); + std::thread::sleep(Duration::from_millis(500)); + let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job"); + ensure_browser_tree_in_job(pid, &job).expect("adopt fixture tree"); + let snapshot = windows_process_snapshot().expect("snapshot after adoption"); + let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption"); + assert!(tree + .iter() + .all(|member| job.contains_pid(*member).expect("job membership"))); + job.terminate().expect("terminate fixture job"); + let _ = child.wait(); + assert!(job.is_empty().expect("fixture job empty")); + } + #[cfg(windows)] #[test] fn kill_browser_process_tree_reaps_fixture_tree() { 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 fc17d9f12..018e9ff92 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 @@ -350,8 +350,10 @@ 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) + (error.starts_with("browser-recovery-") + || error.starts_with("browser-launch-failed:") + || error.starts_with("browser-cleanup-unconfirmed:")) + .then_some(error) } pub(crate) async fn host_web_creation_preflight() -> Value { @@ -632,12 +634,15 @@ mod tests { browser_validation_failure_code("宿主已停止本轮浏览器验证"), "web-preflight-cancelled" ); + let cleanup_diagnostic = "browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false"; assert_eq!( - browser_validation_failure_code( - "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程" - ), + browser_validation_failure_code(cleanup_diagnostic), "web-preflight-browser-cleanup-failed" ); + assert_eq!( + browser_validation_diagnostic(cleanup_diagnostic), + Some(cleanup_diagnostic) + ); assert_eq!( browser_validation_failure_code("创建浏览器临时目录失败:拒绝访问"), "web-preflight-browser-environment-failed" diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs index 6d086e49c..f8d02848c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/model.rs @@ -236,7 +236,42 @@ impl WindowsProcessJob { .ok_or_else(|| "子进程缺少 Windows process handle".to_string())?; Self::assign_handle(handle as windows_sys::Win32::Foundation::HANDLE) } + pub(crate) fn contains_pid(&self, pid: u32) -> Result { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::IsProcessInJob; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION, + }; + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if process.is_null() { + return Err("无法打开进程核对 Windows Job 归属".into()); + } + let mut in_job = 0; + let okay = unsafe { IsProcessInJob(process, self.0, &mut in_job) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法核对进程 Windows Job 归属".into()); + } + Ok(in_job != 0) + } + pub(crate) fn assign_pid(&self, pid: u32) -> Result<(), String> { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::JobObjects::AssignProcessToJobObject; + use windows_sys::Win32::System::Threading::{ + OpenProcess, PROCESS_SET_QUOTA, PROCESS_TERMINATE, + }; + let process = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, 0, pid) }; + if process.is_null() { + return Err("无法打开进程加入 Windows Job".into()); + } + let okay = unsafe { AssignProcessToJobObject(self.0, process) }; + unsafe { CloseHandle(process) }; + if okay == 0 { + return Err("无法将进程加入 Windows Job".into()); + } + Ok(()) + } pub(crate) fn assign_tokio_named( child: &tokio::process::Child, name: &str, diff --git a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md index b887524b2..aaa18517a 100644 --- a/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md +++ b/docs/project-memory/plans/【实施计划】Web预检浏览器失败恢复-2026-10-02.md @@ -7,10 +7,10 @@ ## 实现顺序 -1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 +1. 将浏览器启动失败建模为带阶段、原因、子进程退出确认、清理确认的内部结果;Windows root spawn 后立即绑定 Job,并把绑定瞬间已经出现的子树逐 PID 纳入同一 Job,失败收束必须复用本轮 `BrowserProcessGuard`,并在成功收束时确认 Windows Job 为空。 2. 在 Web 预检使用的一次浏览器启动入口加入一次有界恢复:仅 WS/CDP 瞬态失败且首次清理确认后创建新的临时目录/Profile 重试;其余失败直接失败关闭。 -3. 保留/加强 owner 与 sweep 的归属门禁,补齐 Windows 活进程 Profile 命令行标识核对;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 -4. 将安全恢复诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 +3. 保留/加强 owner 与 sweep 的归属门禁,使用 Windows argv 解析核对完整 Profile 参数;无 owner、身份未知、PID 退出或复用均不得按猜测杀进程。 +4. 将启动失败和清理失败的安全诊断保留到预检 blocked 报告和 Tauri 错误中;稳定错误码独立于诊断文本,前端继续区分宿主阻塞与 IPC 故障。 5. 补充 Rust 纯策略测试、清理边界测试和现有真实 Edge/Chromium ignored smoke;补充首页错误展示的定向测试。 ## 验证命令 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 74c74685f..bd5343da2 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -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` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 +- 新建 Web 游戏在生图和大量实现前执行客户端环境预检,检查 Node/npm 的实际版本、浏览器启动和 CDP 可用性。报告只包含安全状态、版本、耗时和错误码;错误码必须按真实原因分流,浏览器验证只允许在确有证据时使用 `web-preflight-browser-missing` / `-launch-timeout` / `-launch-failed` / `-browser-environment-failed` / `-browser-cleanup-failed` / `-browser-recovery-failed`,取消、证据写入、输入与页面校验各有独立码,未识别原因落回专用 `web-preflight-unclassified`,不得用一个具体子系统码兜底。浏览器恢复诊断只保留阶段、进程退出、清理确认和重试状态等安全字段,不暴露命令行、路径或上游原文。缺失或异常必须尽早返回阻塞,不能指示模型改宿主环境、全盘搜索或自行下载一套运行时。编辑器工程不强制 Web 工具链。 - 预检不安装依赖、不修改项目 revision、不请求平台生成;构建仍执行项目自己的 npm 脚本。Codex 隔离 HOME 与平台凭据边界保持不变,客户端把已验证的运行时加入执行 PATH,不能把宿主凭据目录交给模型。 - 第一轮先明确本次必需玩法、素材和验收项。同批独立读取尽量合并,必需图片一次规划;已有且可用的资产复用。已有目标全部通过后给出交付结果,非阻塞的新点子列为后续工作,不在收尾时主动开启新的生产链。