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 6bfd61417..098f43841 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 @@ -206,8 +206,8 @@ struct BrowserProcessGuard { } impl BrowserProcessGuard { - /// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job - /// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。 + /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; + /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 /// 返回是否已确认 root 进程退出。 async fn reap(&mut self) -> bool { #[cfg(windows)] @@ -265,6 +265,9 @@ struct OwnedBrowser { process: BrowserProcessGuard, handler_task: tokio::task::JoinHandle<()>, drain_task: tokio::task::JoinHandle<()>, + // Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop, + // 不能直接移动字段)。 + temp: Option, } impl OwnedBrowser { @@ -282,9 +285,20 @@ impl OwnedBrowser { return Ok(()); } if !self.process.reap().await { + // 进程退出未确认:保留目录与 owner.json,留待下次启动或 + // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } return Err("browser-cleanup-unconfirmed".into()); } - self.process.confirm_reaped().await + let confirmed = self.process.confirm_reaped().await; + if confirmed.is_err() { + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + } + confirmed } } @@ -297,12 +311,14 @@ impl Drop for OwnedBrowser { } /// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 -/// 窗口:绑定前产生的子进程未入 Job,随 root 死亡级联退出;绑定之后 -/// 的一切子进程由 Job 全覆盖。 +/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; +/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 +/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 +/// 绑定之后的一切子进程由 Job 全覆盖。 async fn launch_owned_browser( config: BrowserConfig, executable: &std::path::Path, - temp_root: &std::path::Path, + temp: TempDir, ) -> Result { let child = config .launch() @@ -320,13 +336,16 @@ async fn launch_owned_browser( }; // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 if let Some(pid) = process.child.inner.id() { - super::sweep::write_browser_process_owner(temp_root, pid, executable); + 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) => { - let _ = process.reap().await; + // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 + if !process.reap().await { + std::mem::forget(temp); + } return Err(error); } }; @@ -340,12 +359,16 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - let _ = process.reap().await; + if !process.reap().await { + std::mem::forget(temp); + } return Err(OwnedBrowserLaunchError::ConnectFailed); } Err(_) => { drain_task.abort(); - let _ = process.reap().await; + if !process.reap().await { + std::mem::forget(temp); + } return Err(OwnedBrowserLaunchError::ConnectTimeout); } }; @@ -361,6 +384,7 @@ async fn launch_owned_browser( process, handler_task, drain_task, + temp: Some(temp), }) } @@ -371,7 +395,7 @@ pub(crate) async fn check_browser_health() -> Result { 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.path()) + let owned = launch_owned_browser(config, &discovered.executable_path, temporary) .await .map_err(|error| { if error.is_timeout() { @@ -444,19 +468,15 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( &proxy_bypass_list, )?; - let owned = launch_owned_browser( - config, - &browser_executable.executable_path, - browser_temp.path(), - ) - .await - .map_err(|error| { - if error.is_timeout() { - "启动浏览器超时".to_string() - } else { - format!("启动浏览器失败:{}", error.code()) - } - })?; + 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 work = run_browser_validation( owned.browser(), @@ -484,7 +504,6 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( "browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(), ); } - drop(browser_temp); let mut result = validation?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; 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 d5f6effaa..21c681161 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 @@ -116,8 +116,12 @@ fn trusted_browser_executable(path: &Path) -> bool { /// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 /// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 /// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 +/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 #[cfg(windows)] -fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { +fn live_process_executable_matches(pid: u32, expected: &Path, _profile_dir: &Path) -> bool { + // 已知残余风险:Windows 读他进程命令行成本高(PEB/WMI),此处只核对 + // 镜像;同用户伪造 owner.json 指向正在使用的浏览器时可误杀它。 use windows_sys::Win32::Foundation::CloseHandle; use windows_sys::Win32::System::Threading::{ OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION, @@ -141,16 +145,28 @@ fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { } #[cfg(target_os = "linux")] -fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { +fn live_process_executable_matches(pid: u32, expected: &Path, profile_dir: &Path) -> bool { let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { return false; }; - same_executable_path(&actual, expected) + if !same_executable_path(&actual, expected) { + return false; + } + // 命令行必须携带 --user-data-dir=<本目录>/profile:镜像一致只证明是 + // 浏览器,不能证明是“这轮预检的”浏览器。 + let Ok(cmdline) = fs::read(format!("/proc/{pid}/cmdline")) else { + return false; + }; + let marker = format!("--user-data-dir={}", profile_dir.to_string_lossy()); + cmdline + .split(|byte| *byte == 0) + .any(|arg| arg == marker.as_bytes()) } -// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。 +// macOS 等无 /proc 的平台没有廉价的镜像与命令行核对手段,由目录属主检查 +// 兜底;已知残余风险:同用户伪造 owner.json 时可终止任意存活 PID。 #[cfg(all(unix, not(target_os = "linux")))] -fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool { +fn live_process_executable_matches(_pid: u32, expected: &Path, _profile_dir: &Path) -> bool { expected.is_file() } @@ -169,27 +185,35 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( { // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 // 成功——root 先死而子进程残留时不再误报收割完成。 - let mut tracked: Vec = Vec::new(); + let mut tracked: Vec<(u32, String)> = Vec::new(); for _ in 0..TREE_KILL_MAX_PASSES { let snapshot = windows_process_snapshot()?; let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid); if root_present && process_identity_matches(root_pid, expected_identity) { // root 仍是目标浏览器:发现并追踪当前整棵子树。 for pid in windows_process_tree_pids_from(&snapshot, root_pid)? { - if !tracked.contains(&pid) { - tracked.push(pid); + track_process(&mut tracked, pid); + } + } else if !root_present { + // root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父 + // PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认 + // 复用者的子进程。 + for (pid, ppid) in &snapshot { + if *ppid == root_pid { + track_process(&mut tracked, *pid); } } - } else { - // root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。 - tracked.retain(|pid| *pid != root_pid); } - // 只统计仍存活的成员;全部消失才算收割完成。 - tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid)); + // 只统计仍存活且启动身份未变的成员(复核排除 PID 复用); + // 全部消失才算收割完成。 + tracked.retain(|(pid, identity)| { + snapshot.iter().any(|(live, _)| live == pid) + && process_identity_matches(*pid, identity) + }); if tracked.is_empty() { return Ok(()); } - for pid in &tracked { + for (pid, _) in &tracked { // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 // OpenProcess 的瞬时失败会在下一轮自然消解。 windows_terminate_process(*pid); @@ -220,9 +244,24 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( } } +#[cfg(windows)] +/// 记录待收割成员及其启动身份:之后每轮终止前复核身份,排除 PID 复用 +/// 后误杀无关进程。拿不到身份的进程不追踪、不触碰(fail-closed)。 +#[cfg(windows)] +fn track_process(tracked: &mut Vec<(u32, String)>, pid: u32) { + if tracked.iter().any(|(known, _)| *known == pid) { + return; + } + if let Ok(Some(identity)) = crate::runner::external_agent_runner_process_start_identity(pid) { + tracked.push((pid, identity)); + } +} + #[cfg(windows)] fn windows_process_snapshot() -> Result, String> { - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::Foundation::{ + CloseHandle, GetLastError, ERROR_NO_MORE_FILES, INVALID_HANDLE_VALUE, + }; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, TH32CS_SNAPPROCESS, @@ -235,16 +274,25 @@ fn windows_process_snapshot() -> Result, String> { let mut entry = PROCESSENTRY32W::default(); entry.dwSize = std::mem::size_of::() as u32; let mut processes = Vec::new(); - // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。返回 0 + // 只在 ERROR_NO_MORE_FILES 时是正常结束,其余错误按截断处理 + // (fail-closed:残缺快照会导致误报收割完成)。 let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; - while available != 0 { + loop { + if available == 0 { + // SAFETY: 紧跟失败的枚举调用读取错误码。 + let error = unsafe { GetLastError() }; + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + if error == ERROR_NO_MORE_FILES { + return Ok(processes); + } + return Err("browser-sweep-snapshot-truncated".into()); + } processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); // SAFETY: 同上。 available = unsafe { Process32NextW(snapshot, &mut entry) }; } - // SAFETY: snapshot 是本函数持有的合法句柄。 - unsafe { CloseHandle(snapshot) }; - Ok(processes) } #[cfg(windows)] @@ -358,8 +406,13 @@ fn sweep_owned_directory( return; } // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 - // 终止本用户的无关进程。 - if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) { + // 终止本用户的无关进程。profile 子目录名与 process.rs 的 + // browser_config 保持一致;Linux 上借此把 PID 绑到本目录。 + if !live_process_executable_matches( + owner.browser_pid, + Path::new(&owner.executable), + &dir.join("profile"), + ) { notes.push(format!("kept-image-mismatch:{name}")); return; } @@ -542,10 +595,19 @@ mod tests { #[test] fn live_process_executable_matches_current_process_image() { let exe = std::env::current_exe().unwrap(); - assert!(live_process_executable_matches(std::process::id(), &exe)); + let profile = Path::new("C:\\fixture\\ga-browser-x\\profile"); + // Windows 只核对镜像;Linux 还要求命令行绑定 profile,本测试进程 + // 不具备该标记,正例只在 Windows 断言。 + #[cfg(windows)] + assert!(live_process_executable_matches( + std::process::id(), + &exe, + profile + )); assert!(!live_process_executable_matches( std::process::id(), - Path::new("C:\\fixture\\chrome.exe") + Path::new("C:\\fixture\\chrome.exe"), + profile )); } }