From 2596be6afc90117c5903f95e4ce57e68bf6a766f Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 12:06:42 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20Web=20=E9=A2=84?= =?UTF-8?q?=E6=A3=80=E6=B5=8F=E8=A7=88=E5=99=A8=E5=90=AF=E5=8A=A8=E5=A4=B1?= =?UTF-8?q?=E8=B4=A5=E6=97=B6=E6=B3=84=E6=BC=8F=E6=97=A0=E5=A4=B4=E6=B5=8F?= =?UTF-8?q?=E8=A7=88=E5=99=A8=E8=BF=9B=E7=A8=8B=E6=A0=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browser/process.rs:改用公开的 BrowserConfig::launch() 自持 spawn,root 进程从启动起归调用方所有,不再经 Browser::launch 黑盒启动 - browser/process.rs:Windows 下 spawn 后立即把 root 挂进 KILL_ON_JOB_CLOSE 的 WindowsProcessJob,失败、超时或宿主被杀时整棵进程树由 Job 保证回收,堵住无父进程无头浏览器的累积泄漏 - browser/process.rs:自行解析 stderr 的 DevTools ws 地址并做 loopback 严格校验与输出上限,给原先无超时的 CDP 连接补上 30 秒超时 - browser/process.rs:新增 OwnedBrowser/BrowserProcessGuard 统一收尾,close-wait 失败时 TerminateJobObject 杀整树并确认树空,stderr 持续 drain 防止浏览器因管道写满卡死 - browser/process.rs:check_browser_health 与 validate_local_preview_in_browser_with_cancellation 两个调用点改走新路径,对外错误文案与语义不变 - process_session/tests.rs:新增 Windows Job terminate 收割进程树的定向测试 - browser/process.rs:新增 ws URL 只接受 loopback、HandlerConfig 镜像 BrowserConfig builder 的单元测试 --- .../src-tauri/src/browser/process.rs | 397 +++++++++++++++--- .../src-tauri/src/process_session/tests.rs | 30 ++ 2 files changed, 376 insertions(+), 51 deletions(-) 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(); +} -- 2.52.0 From feb2e100c49d8dfb80c842cc8640ffbdceb9bebc Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 12:38:18 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B7=A8=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E6=B8=85=E6=89=AB=E9=99=88=E6=97=A7=E6=97=A0=E5=A4=B4?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AE=8B?= =?UTF-8?q?=E7=95=99=E8=BF=9B=E7=A8=8B=E6=94=BE=E5=A4=A7=E9=A2=84=E6=A3=80?= =?UTF-8?q?=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - browser/sweep.rs:新增清扫模块,launch 时向 ga-browser-* 目录写入 owner.json 身份锚点(浏览器 root PID 与启动身份、可信 exe 路径、App 属主 PID 与身份) - browser/sweep.rs:清扫按属主存活跳过、进程已退出或 PID 复用只删目录、root 存活且身份一致才杀整树分级处置,杀树前要求 exe canonical 匹配系统标准浏览器候选路径,防止伪造 owner.json 借刀杀进程 - browser/sweep.rs:Windows 用 ToolHelp 快照走树终止 root 及全部后代并逐轮复核 root 身份,Unix 只杀 root 由子进程级联退出;无 owner.json 的旧版本残留只删目录不按猜测杀进程,五分钟内的目录一律跳过防止误伤进行中的预检 - main.rs:App 启动时后台异步清扫并把处置记录写入应用日志,不阻塞窗口 - environment_check/web_creation.rs:每次 Web 预检前顺手清扫一遍,防同会话残留放大超时 - browser/process.rs:launch 时写 owner.json,暴露临时目录前缀常量 - process_session/tests.rs:Job 杀树测试的 timeout fixture 在 stdio 重定向下会立即退出导致空转通过,改用 ping fixture 并补杀前树非空断言 - browser/sweep.rs:新增年龄门禁、活实例跳过、死进程只删不杀、坏 owner 按旧残留、可信 exe 匹配与 Windows 快照走树收割的定向测试 --- .../src-tauri/src/browser.rs | 2 + .../src-tauri/src/browser/process.rs | 36 +- .../src-tauri/src/browser/sweep.rs | 417 ++++++++++++++++++ .../src/environment_check/web_creation.rs | 2 + .../src-tauri/src/main.rs | 11 + .../src-tauri/src/process_session/tests.rs | 18 +- 6 files changed, 474 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index 919fb7467..16b4ed4bd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -6,6 +6,7 @@ mod model; mod network_policy; mod playtest; mod process; +mod sweep; pub use discovery::discover_chrome_or_edge; #[allow(unused_imports)] @@ -22,6 +23,7 @@ pub(crate) use process::validate_local_preview_in_browser_with_cancellation; pub use process::{ validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, }; +pub(crate) use sweep::sweep_stale_browser_processes; pub(crate) use model::required_viewport_playtests_passed; pub(crate) use playtest::browser_playtest_scenario_fingerprint; 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 38545eed1..129cc17ef 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 @@ -22,7 +22,9 @@ use super::network_policy::{preview_proxy_bypass_list, validate_input}; #[cfg(windows)] use crate::process_session::WindowsProcessJob; -fn browser_process_temp_root() -> PathBuf { +pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; + +pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") @@ -35,7 +37,7 @@ fn browser_process_temp_root() -> PathBuf { pub(super) fn create_browser_process_temp_dir() -> Result { TempDirBuilder::new() - .prefix("ga-browser-") + .prefix(BROWSER_TEMP_PREFIX) .tempdir_in(browser_process_temp_root()) .map_err(|error| format!("创建浏览器临时目录失败:{error}")) } @@ -278,6 +280,8 @@ impl Drop for OwnedBrowser { /// 的一切子进程由 Job 全覆盖。 async fn launch_owned_browser( config: BrowserConfig, + executable: &std::path::Path, + temp_root: &std::path::Path, ) -> Result { let child = config .launch() @@ -293,6 +297,10 @@ async fn launch_owned_browser( #[cfg(windows)] job, }; + // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 + if let Some(pid) = process.child.inner.id() { + super::sweep::write_browser_process_owner(temp_root, pid, executable); + } let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await { Ok(pair) => pair, @@ -342,13 +350,15 @@ 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).await.map_err(|error| { - if error.is_timeout() { - "browser-start-timeout" - } else { - "browser-start-failed" - } - })?; + let owned = launch_owned_browser(config, &discovered.executable_path, temporary.path()) + .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()); @@ -413,7 +423,13 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation( &proxy_bypass_list, )?; - let owned = launch_owned_browser(config).await.map_err(|error| { + let owned = launch_owned_browser( + config, + &browser_executable.executable_path, + browser_temp.path(), + ) + .await + .map_err(|error| { if error.is_timeout() { "启动浏览器超时".to_string() } else { 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 new file mode 100644 index 000000000..d5042ca9c --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -0,0 +1,417 @@ +//! 跨会话清扫陈旧的无头浏览器进程与临时目录。 +//! +//! 运行期 launch 会把 owner.json 写进 ga-browser-* 目录;启动与每次预检前 +//! 按「进程身份 + 可信浏览器路径」双重校验收割遗留进程树并删除目录。 +//! 没有 owner.json 的旧版本残留只删目录,绝不按猜测杀进程。 + +use std::fs; +use std::path::Path; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use super::discovery::system_browser_candidates; +use super::process::{browser_process_temp_root, BROWSER_TEMP_PREFIX}; + +const OWNER_FILE: &str = "owner.json"; +const OWNER_SCHEMA: &str = "agc-browser-process.v1"; +/// 过新的目录可能属于正在进行中的预检(含 owner.json 写失败的极端情况),跳过。 +const SWEEP_MIN_AGE: Duration = Duration::from_secs(5 * 60); +const TREE_KILL_MAX_PASSES: u32 = 20; +const TREE_KILL_PASS_INTERVAL: Duration = Duration::from_millis(50); + +#[derive(Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(super) struct BrowserProcessOwner { + schema_version: String, + browser_pid: u32, + browser_start_identity: String, + executable: String, + owner_pid: u32, + owner_start_identity: String, + created_unix_ms: u64, +} + +/// 运行期 launch 的身份锚点;任何一步拿不到身份证明都不写, +/// 该目录之后按旧残留只删不杀。 +pub(super) fn write_browser_process_owner(temp_root: &Path, browser_pid: u32, executable: &Path) { + let identity = crate::runner::external_agent_runner_process_start_identity(browser_pid); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()); + let (Ok(Some(identity)), Ok(Some(owner_identity))) = (identity, owner_identity) else { + return; + }; + let owner = BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: identity, + executable: executable.to_string_lossy().into_owned(), + owner_pid: std::process::id(), + owner_start_identity: owner_identity, + created_unix_ms: super::evidence::unix_time_ms(), + }; + let Ok(bytes) = serde_json::to_vec_pretty(&owner) else { + return; + }; + let _ = fs::write(temp_root.join(OWNER_FILE), bytes); +} + +fn read_browser_process_owner(dir: &Path) -> Option { + let bytes = fs::read(dir.join(OWNER_FILE)).ok()?; + if bytes.len() > 16 * 1024 { + return None; + } + let owner: BrowserProcessOwner = serde_json::from_slice(&bytes).ok()?; + if owner.schema_version != OWNER_SCHEMA + || owner.browser_pid == 0 + || owner.owner_pid == 0 + || owner.browser_start_identity.is_empty() + || owner.browser_start_identity.len() > 128 + || owner.owner_start_identity.is_empty() + || owner.owner_start_identity.len() > 128 + || owner.executable.is_empty() + || owner.executable.len() > 1024 + { + return None; + } + Some(owner) +} + +/// PID 存活且启动身份一致(创建时间相同),排除 PID 复用。 +fn process_identity_matches(pid: u32, identity: &str) -> bool { + crate::runner::external_agent_runner_process_start_identity(pid) + .ok() + .flatten() + .is_some_and(|live| live == identity) +} + +/// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。 +fn trusted_browser_executable(path: &Path) -> bool { + let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + if !canonical.is_file() { + return false; + } + system_browser_candidates() + .into_iter() + .any(|(candidate, _)| candidate.canonicalize().unwrap_or(candidate) == canonical) +} + +fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { + #[cfg(windows)] + { + for _ in 0..TREE_KILL_MAX_PASSES { + // 每轮先复核 root 身份:进程已退出或 PID 被复用时立即停止。 + if !process_identity_matches(root_pid, expected_identity) { + return Ok(()); + } + let pids = windows_process_tree_pids(root_pid)?; + if pids.is_empty() { + return Ok(()); + } + for pid in &pids { + windows_terminate_process(*pid); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + return Err("browser-sweep-tree-not-reaped".into()); + } + #[cfg(not(windows))] + { + // 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。 + if process_identity_matches(root_pid, expected_identity) { + unsafe { libc::kill(root_pid as i32, libc::SIGKILL) }; + } + Ok(()) + } +} + +#[cfg(windows)] +fn windows_process_tree_pids(root_pid: u32) -> Result, String> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Err("browser-sweep-snapshot-failed".into()); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + while available != 0 { + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + if !processes.iter().any(|(pid, _)| *pid == root_pid) { + return Ok(Vec::new()); + } + let mut tree = vec![root_pid]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in &processes { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + if tree.len() > 512 { + return Err("browser-sweep-tree-too-large".into()); + } + } + Ok(tree) +} + +#[cfg(windows)] +fn windows_terminate_process(pid: u32) { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::System::Threading::{OpenProcess, TerminateProcess}; + const PROCESS_TERMINATE: u32 = 0x0001; + // SAFETY: 句柄非空时由 CloseHandle 释放;空句柄直接返回。 + let handle = unsafe { OpenProcess(PROCESS_TERMINATE, 0, pid) }; + if handle.is_null() { + return; + } + // SAFETY: handle 是刚打开的合法进程句柄,退出码仅作占位。 + unsafe { TerminateProcess(handle, 1) }; + // SAFETY: 同上。 + unsafe { CloseHandle(handle) }; +} + +/// 清扫陈旧的无头浏览器进程与临时目录,返回逐条处置记录供启动日志留痕。 +pub(crate) fn sweep_stale_browser_processes() -> Vec { + sweep_stale_browser_processes_at(&browser_process_temp_root(), SWEEP_MIN_AGE) +} + +fn sweep_stale_browser_processes_at(root: &Path, min_age: Duration) -> Vec { + let mut notes = Vec::new(); + let Ok(entries) = fs::read_dir(root) else { + return notes; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if !name.starts_with(BROWSER_TEMP_PREFIX) { + continue; + } + let dir = entry.path(); + let Ok(metadata) = entry.metadata() else { + continue; + }; + if !metadata.is_dir() { + continue; + } + let too_young = metadata + .created() + .or_else(|_| metadata.modified()) + .ok() + .and_then(|time| time.elapsed().ok()) + .is_some_and(|age| age < min_age); + if too_young { + continue; + } + match read_browser_process_owner(&dir) { + Some(owner) => sweep_owned_directory(&dir, &name, &owner, &mut notes), + None => match fs::remove_dir_all(&dir) { + Ok(()) => notes.push(format!("removed-legacy-dir:{name}")), + Err(_) => notes.push(format!("kept-legacy-dir-in-use:{name}")), + }, + } + } + notes +} + +fn sweep_owned_directory( + dir: &Path, + name: &str, + owner: &BrowserProcessOwner, + notes: &mut Vec, +) { + // 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。 + if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) { + return; + } + if !process_identity_matches(owner.browser_pid, &owner.browser_start_identity) { + // 进程已退出或 PID 已被复用:不动任何进程,仅删目录。 + if fs::remove_dir_all(dir).is_ok() { + notes.push(format!("removed-exited-dir:{name}")); + } + return; + } + if !trusted_browser_executable(Path::new(&owner.executable)) { + notes.push(format!("kept-untrusted-executable:{name}")); + return; + } + match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) { + Ok(()) => { + let _ = fs::remove_dir_all(dir); + notes.push(format!( + "killed-stale-tree:{name}:pid={}", + owner.browser_pid + )); + } + Err(_) => notes.push(format!("kill-unconfirmed:{name}:pid={}", owner.browser_pid)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn exited_child_pid() -> u32 { + #[cfg(windows)] + let mut command = std::process::Command::new("cmd.exe"); + #[cfg(windows)] + command.args(["/c", "exit", "0"]); + #[cfg(not(windows))] + let mut command = std::process::Command::new("/bin/true"); + let mut child = command.spawn().expect("spawn exit fixture"); + let pid = child.id(); + child.wait().expect("reap exit fixture"); + pid + } + + fn write_owner(dir: &Path, owner: &BrowserProcessOwner) { + fs::write( + dir.join(OWNER_FILE), + serde_json::to_vec_pretty(owner).unwrap(), + ) + .unwrap(); + } + + fn owner_fixture(browser_pid: u32, owner_pid: u32) -> BrowserProcessOwner { + BrowserProcessOwner { + schema_version: OWNER_SCHEMA.into(), + browser_pid, + browser_start_identity: "fixture-identity".into(), + executable: "C:\\fixture\\chrome.exe".into(), + owner_pid, + owner_start_identity: "fixture-owner-identity".into(), + created_unix_ms: super::super::evidence::unix_time_ms(), + } + } + + #[test] + fn legacy_directory_without_owner_file_is_removed_only_when_old_enough() { + let root = tempfile::tempdir().unwrap(); + let stale = root.path().join(format!("{BROWSER_TEMP_PREFIX}stale")); + fs::create_dir(&stale).unwrap(); + // 默认年龄门禁:新建的目录视为可能属于进行中的预检,跳过。 + assert!(sweep_stale_browser_processes_at(root.path(), SWEEP_MIN_AGE).is_empty()); + assert!(stale.is_dir()); + // 零门禁等价于“足够旧”:无 owner.json 的旧残留只删目录。 + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!stale.exists()); + } + + #[test] + fn directory_owned_by_a_live_app_instance_is_skipped() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}owned")); + fs::create_dir(&dir).unwrap(); + let owner_identity = + crate::runner::external_agent_runner_process_start_identity(std::process::id()) + .expect("self identity") + .expect("self identity present"); + let mut owner = owner_fixture(exited_child_pid(), std::process::id()); + owner.owner_start_identity = owner_identity; + write_owner(&dir, &owner); + assert!(sweep_stale_browser_processes_at(root.path(), Duration::ZERO).is_empty()); + assert!(dir.is_dir()); + } + + #[test] + fn exited_or_reused_browser_pid_deletes_directory_without_touching_processes() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}exited")); + fs::create_dir(&dir).unwrap(); + let owner = owner_fixture(exited_child_pid(), exited_child_pid()); + write_owner(&dir, &owner); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-exited-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn owner_file_with_bad_schema_or_unknown_fields_is_treated_as_legacy() { + let root = tempfile::tempdir().unwrap(); + let dir = root.path().join(format!("{BROWSER_TEMP_PREFIX}bad-owner")); + fs::create_dir(&dir).unwrap(); + fs::write( + dir.join(OWNER_FILE), + br#"{"schemaVersion":"other","extra":true}"#, + ) + .unwrap(); + assert!(read_browser_process_owner(&dir).is_none()); + let notes = sweep_stale_browser_processes_at(root.path(), Duration::ZERO); + assert!(notes + .iter() + .any(|note| note.starts_with("removed-legacy-dir:"))); + assert!(!dir.exists()); + } + + #[test] + fn trusted_browser_executable_matches_system_candidates_only() { + assert!(!trusted_browser_executable(Path::new( + "C:\\fixture\\chrome.exe" + ))); + let Some((installed, _)) = system_browser_candidates() + .into_iter() + .find(|(candidate, _)| candidate.is_file()) + else { + // 没有安装浏览器的机器上无可信目标可断言。 + return; + }; + assert!(trusted_browser_executable(&installed)); + } + + #[cfg(windows)] + #[test] + fn kill_browser_process_tree_reaps_fixture_tree() { + use std::process::{Command, Stdio}; + + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null 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 identity = crate::runner::external_agent_runner_process_start_identity(pid) + .expect("fixture identity") + .expect("fixture identity present"); + let before = windows_process_tree_pids(pid).expect("tree snapshot"); + assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}"); + kill_browser_process_tree(pid, &identity).expect("kill tree"); + assert!(windows_process_tree_pids(pid) + .map(|tree| tree.is_empty()) + .unwrap_or(true)); + let _ = child.kill(); + let _ = child.wait(); + } +} 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 532503c75..4378f32d7 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 @@ -304,6 +304,8 @@ async fn host_npm( pub(crate) async fn host_web_creation_preflight() -> Value { let started = Instant::now(); + // 预检前顺手清扫陈旧的无头浏览器,避免残留进程放大本轮超时。 + let _ = tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes).await; let run = async { let fixture = tempfile::tempdir().map_err(|_| "web-preflight-temp-unavailable")?; let root = fixture.path(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 151a18cfe..32b340123 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2467,6 +2467,17 @@ fn main() { if let Some(directory) = game_creator_runtime_config_dir() { setup_log.set(directory.join("diagnostics/startup.log")); } + // 跨会话清扫陈旧的无头浏览器(上次异常退出/被杀留下的 ga-browser-*)。 + // 后台执行,不阻塞启动;杀树前按进程身份与可信浏览器路径双重校验。 + tauri::async_runtime::spawn(async move { + let notes = + tokio::task::spawn_blocking(crate::browser::sweep_stale_browser_processes) + .await + .unwrap_or_default(); + if !notes.is_empty() { + app_log!("startup.browser-sweep: {}", notes.join("; ")); + } + }); setup_log.append("startup.appdata.configure.complete"); let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { let error = std::io::Error::new( 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 1463a6c08..d421a372f 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 @@ -1821,10 +1821,20 @@ setInterval(() => {}, 1000); fn windows_process_job_terminate_reaps_process_tree() { use std::process::{Command, Stdio}; - // cmd 启动第一个 timeout 子进程后整树存活;terminate 必须连子进程一起收割。 + // cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。 + // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 let mut child = Command::new("cmd.exe") .args([ - "/c", "timeout", "/t", "60", "/nobreak", "&", "timeout", "/t", "60", "/nobreak", + "/c", + "ping", + "127.0.0.1", + "-n", + "60", + "&", + "ping", + "127.0.0.1", + "-n", + "60", ]) .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -1833,6 +1843,10 @@ fn windows_process_job_terminate_reaps_process_tree() { .expect("spawn cmd fixture"); let job = WindowsProcessJob::assign_std(&child).expect("assign job"); thread::sleep(Duration::from_millis(500)); + assert!( + !job.is_empty().expect("query job"), + "fixture 进程树必须先存活,否则收割断言是空转" + ); job.terminate().expect("terminate job"); let deadline = std::time::Instant::now() + Duration::from_secs(10); while !job.is_empty().expect("query job") { -- 2.52.0 From 2b24da255794e559b878c9bf6e3947ae2d045859 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 17:06:09 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=8A=A0=E5=9B=BA=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E6=B8=85=E6=89=AB=E4=B8=8E=E6=94=B6=E6=9D=9F=EF=BC=9A?= =?UTF-8?q?=E6=9D=80=E5=89=8D=E9=95=9C=E5=83=8F=E5=A4=8D=E6=A0=B8=E3=80=81?= =?UTF-8?q?=E6=94=B6=E5=89=B2=E7=BB=93=E6=9E=9C=E7=A1=AE=E8=AE=A4=E3=80=81?= =?UTF-8?q?=E9=95=9C=E5=83=8F=E9=BB=98=E8=AE=A4=E8=A7=86=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sweep.rs:杀前新增活进程镜像路径复核(Windows QueryFullProcessImageNameW / Linux /proc/pid/exe),打开失败 fail-closed 拒绝 sweep.rs:Unix 新增属主目录 euid 检查,拒绝他人在 /tmp 伪造的 ga-browser-* 目录 sweep.rs:Windows 杀树改为追踪制,全部成员从快照消失才报成功,root 先死子进程残留不再误报 sweep.rs:Unix 检查 kill 返回值并以 kill(pid,0) 确认进程消失后才报成功 process.rs:reap 返回 root 退出确认,非 Windows confirm_reaped 用 try_wait 兜底,挂起/杀失败暴露为 browser-cleanup-unconfirmed process.rs:handler_config 镜像 BrowserConfigBuilder 默认 viewport(Some 800x600),与旧 Browser::launch 行为一致 测试:杀树两个用例断言整树成员真实消失,新增镜像复核测试 --- .../src-tauri/src/browser/process.rs | 72 +++++--- .../src-tauri/src/browser/sweep.rs | 168 ++++++++++++++++-- .../src-tauri/src/process_session/tests.rs | 65 +++++++ 3 files changed, 263 insertions(+), 42 deletions(-) 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 129cc17ef..6bfd61417 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 @@ -6,6 +6,7 @@ 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}; @@ -76,14 +77,17 @@ 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()。 +/// 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 } @@ -204,33 +208,48 @@ struct BrowserProcessGuard { impl BrowserProcessGuard { /// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job /// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。 - async fn reap(&mut self) { + /// 返回是否已确认 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; - let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await; + matches!( + tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, + Ok(Ok(_)) + ) } - async fn confirm_reaped(&self) -> Result<(), String> { + 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 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; } - if Instant::now() >= deadline { - return Err("browser-tree-reap-unconfirmed".into()); - } - tokio::time::sleep(Duration::from_millis(20)).await; + } + Ok(()) + } + #[cfg(not(windows))] + { + // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 + // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 + match self.child.inner.try_wait() { + Ok(Some(_)) => Ok(()), + _ => Err("browser-tree-reap-unconfirmed".into()), } } - Ok(()) } } @@ -262,7 +281,9 @@ impl OwnedBrowser { if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) { return Ok(()); } - self.process.reap().await; + if !self.process.reap().await { + return Err("browser-cleanup-unconfirmed".into()); + } self.process.confirm_reaped().await } } @@ -305,7 +326,7 @@ async fn launch_owned_browser( { Ok(pair) => pair, Err(error) => { - process.reap().await; + let _ = process.reap().await; return Err(error); } }; @@ -319,12 +340,12 @@ async fn launch_owned_browser( Ok(Ok(pair)) => pair, Ok(Err(_)) => { drain_task.abort(); - process.reap().await; + let _ = process.reap().await; return Err(OwnedBrowserLaunchError::ConnectFailed); } Err(_) => { drain_task.abort(); - process.reap().await; + let _ = process.reap().await; return Err(OwnedBrowserLaunchError::ConnectTimeout); } }; @@ -536,10 +557,11 @@ mod health_tests { assert_eq!(config.request_timeout, BROWSER_TIMEOUT); assert!(config.request_intercept); assert!(!config.cache_enabled); - // 其余字段必须与 BrowserConfigBuilder 默认值保持一致。 + // 其余字段必须与 BrowserConfigBuilder 默认值保持一致 + // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 assert!(config.ignore_https_errors); assert!(config.ignore_invalid_messages); - assert!(config.viewport.is_none()); + assert_eq!(config.viewport, Some(Viewport::default())); } #[tokio::test] 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 d5042ca9c..d5f6effaa 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 @@ -85,6 +85,23 @@ fn process_identity_matches(pid: u32, identity: &str) -> bool { .is_some_and(|live| live == identity) } +/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。 +fn same_executable_path(actual: &Path, expected: &Path) -> bool { + let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); + let actual = canonical(actual); + let expected = canonical(expected); + #[cfg(windows)] + { + return actual + .to_string_lossy() + .eq_ignore_ascii_case(&expected.to_string_lossy()); + } + #[cfg(not(windows))] + { + actual == expected + } +} + /// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。 fn trusted_browser_executable(path: &Path) -> bool { let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); @@ -93,22 +110,88 @@ fn trusted_browser_executable(path: &Path) -> bool { } system_browser_candidates() .into_iter() - .any(|(candidate, _)| candidate.canonicalize().unwrap_or(candidate) == canonical) + .any(|(candidate, _)| same_executable_path(&candidate, path)) +} + +/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 +/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 +/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +#[cfg(windows)] +fn live_process_executable_matches(pid: u32, expected: &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) +} + +#[cfg(target_os = "linux")] +fn live_process_executable_matches(pid: u32, expected: &Path) -> bool { + let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + same_executable_path(&actual, expected) +} + +// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。 +#[cfg(all(unix, not(target_os = "linux")))] +fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool { + expected.is_file() +} + +/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。 +#[cfg(unix)] +fn directory_owned_by_current_user(dir: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + dir.metadata() + // SAFETY: geteuid 无前置条件,总是安全的。 + .map(|metadata| metadata.uid() == unsafe { libc::geteuid() }) + .unwrap_or(false) } fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> { #[cfg(windows)] { + // 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判 + // 成功——root 先死而子进程残留时不再误报收割完成。 + let mut tracked: Vec = Vec::new(); for _ in 0..TREE_KILL_MAX_PASSES { - // 每轮先复核 root 身份:进程已退出或 PID 被复用时立即停止。 - if !process_identity_matches(root_pid, expected_identity) { + 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); + } + } + } else { + // root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。 + tracked.retain(|pid| *pid != root_pid); + } + // 只统计仍存活的成员;全部消失才算收割完成。 + tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid)); + if tracked.is_empty() { return Ok(()); } - let pids = windows_process_tree_pids(root_pid)?; - if pids.is_empty() { - return Ok(()); - } - for pid in &pids { + for pid in &tracked { + // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 + // OpenProcess 的瞬时失败会在下一轮自然消解。 windows_terminate_process(*pid); } std::thread::sleep(TREE_KILL_PASS_INTERVAL); @@ -118,15 +201,27 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<( #[cfg(not(windows))] { // 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。 - if process_identity_matches(root_pid, expected_identity) { - unsafe { libc::kill(root_pid as i32, libc::SIGKILL) }; + if !process_identity_matches(root_pid, expected_identity) { + return Ok(()); } - Ok(()) + // SAFETY: 目标 PID 与启动身份均已复核。 + if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 { + return Err("browser-sweep-kill-failed".into()); + } + // SIGKILL 后必须确认进程真正消失再报成功。 + for _ in 0..TREE_KILL_MAX_PASSES { + // SAFETY: signal 0 仅做存活探测,无副作用。 + if unsafe { libc::kill(root_pid as i32, 0) } != 0 { + return Ok(()); + } + std::thread::sleep(TREE_KILL_PASS_INTERVAL); + } + Err("browser-sweep-tree-not-reaped".into()) } } #[cfg(windows)] -fn windows_process_tree_pids(root_pid: u32) -> Result, String> { +fn windows_process_snapshot() -> Result, String> { use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; use windows_sys::Win32::System::Diagnostics::ToolHelp::{ CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, @@ -149,6 +244,14 @@ fn windows_process_tree_pids(root_pid: u32) -> Result, String> { } // SAFETY: snapshot 是本函数持有的合法句柄。 unsafe { CloseHandle(snapshot) }; + Ok(processes) +} + +#[cfg(windows)] +fn windows_process_tree_pids_from( + processes: &[(u32, u32)], + root_pid: u32, +) -> Result, String> { if !processes.iter().any(|(pid, _)| *pid == root_pid) { return Ok(Vec::new()); } @@ -156,7 +259,7 @@ fn windows_process_tree_pids(root_pid: u32) -> Result, String> { let mut index = 0; while index < tree.len() { let parent = tree[index]; - for (pid, ppid) in &processes { + for (pid, ppid) in processes { if *ppid == parent && !tree.contains(pid) { tree.push(*pid); } @@ -233,6 +336,12 @@ fn sweep_owned_directory( owner: &BrowserProcessOwner, notes: &mut Vec, ) { + // /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。 + #[cfg(unix)] + if !directory_owned_by_current_user(dir) { + notes.push(format!("kept-foreign-dir:{name}")); + return; + } // 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。 if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) { return; @@ -248,6 +357,12 @@ fn sweep_owned_directory( notes.push(format!("kept-untrusted-executable:{name}")); return; } + // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 + // 终止本用户的无关进程。 + if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) { + notes.push(format!("kept-image-mismatch:{name}")); + return; + } match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) { Ok(()) => { let _ = fs::remove_dir_all(dir); @@ -405,13 +520,32 @@ mod tests { let identity = crate::runner::external_agent_runner_process_start_identity(pid) .expect("fixture identity") .expect("fixture identity present"); - let before = windows_process_tree_pids(pid).expect("tree snapshot"); + let before = + windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), pid) + .expect("tree snapshot"); assert!(before.len() >= 2, "fixture 必须包含子进程: {before:?}"); kill_browser_process_tree(pid, &identity).expect("kill tree"); - assert!(windows_process_tree_pids(pid) - .map(|tree| tree.is_empty()) - .unwrap_or(true)); + // 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须 + // 真正被收割,不能只凭 root 不在就判成功。 + let alive = windows_process_snapshot().expect("snapshot after kill"); + for member in &before { + assert!( + !alive.iter().any(|(live, _)| live == member), + "fixture 进程 {member} 必须被收割: {before:?}" + ); + } let _ = child.kill(); let _ = child.wait(); } + + #[cfg(any(windows, target_os = "linux"))] + #[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)); + assert!(!live_process_executable_matches( + std::process::id(), + Path::new("C:\\fixture\\chrome.exe") + )); + } } 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 3485912ec..c96ef974c 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 @@ -1816,6 +1816,33 @@ setInterval(() => {}, 1000); fn windows_process_job_terminate_reaps_process_tree() { use std::process::{Command, Stdio}; + // 系统进程快照(pid, ppid),用于证明 ping 子进程真实存在并被收割。 + fn windows_process_snapshot_for_test() -> Vec<(u32, u32)> { + use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; + use windows_sys::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + // SAFETY: 快照句柄非 INVALID_HANDLE_VALUE 时由 CloseHandle 释放。 + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) }; + if snapshot == INVALID_HANDLE_VALUE { + return Vec::new(); + } + let mut entry = PROCESSENTRY32W::default(); + entry.dwSize = std::mem::size_of::() as u32; + let mut processes = Vec::new(); + // SAFETY: entry 指向可写的 PROCESSENTRY32W,dwSize 已初始化。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + while available != 0 { + processes.push((entry.th32ProcessID, entry.th32ParentProcessID)); + // SAFETY: 同上。 + available = unsafe { Process32NextW(snapshot, &mut entry) }; + } + // SAFETY: snapshot 是本函数持有的合法句柄。 + unsafe { CloseHandle(snapshot) }; + processes + } + // cmd 启动第一个 ping 子进程后整树存活;terminate 必须连子进程一起收割。 // timeout.exe 在 stdio 被重定向时会立即退出,ping 才能在 null stdio 下存活。 let mut child = Command::new("cmd.exe") @@ -1838,6 +1865,26 @@ fn windows_process_job_terminate_reaps_process_tree() { .expect("spawn cmd fixture"); let job = WindowsProcessJob::assign_std(&child).expect("assign job"); thread::sleep(Duration::from_millis(500)); + // 前置检查不能只凭 Job 非空:快照里必须真的看到 cmd 拉起了 ping 子进程。 + let tree_before: Vec = { + let snapshot = windows_process_snapshot_for_test(); + let mut tree = vec![child.id()]; + let mut index = 0; + while index < tree.len() { + let parent = tree[index]; + for (pid, ppid) in &snapshot { + if *ppid == parent && !tree.contains(pid) { + tree.push(*pid); + } + } + index += 1; + } + tree + }; + assert!( + tree_before.len() >= 2, + "fixture 必须包含 ping 子进程,否则收割断言是空转: {tree_before:?}" + ); assert!( !job.is_empty().expect("query job"), "fixture 进程树必须先存活,否则收割断言是空转" @@ -1851,6 +1898,24 @@ fn windows_process_job_terminate_reaps_process_tree() { ); thread::sleep(Duration::from_millis(50)); } + // Job 为空之外,快照里的整棵树(含 ping 子进程)也必须真实消失。 + let deadline = std::time::Instant::now() + Duration::from_secs(10); + loop { + let snapshot = windows_process_snapshot_for_test(); + let survivors: Vec = tree_before + .iter() + .copied() + .filter(|pid| snapshot.iter().any(|(live, _)| live == pid)) + .collect(); + if survivors.is_empty() { + break; + } + assert!( + std::time::Instant::now() < deadline, + "Job terminate 后 fixture 子进程仍存活: {survivors:?}" + ); + thread::sleep(Duration::from_millis(50)); + } let _ = child.kill(); let _ = child.wait(); } -- 2.52.0 From 19ed2f2c2f898e810f49c5fbf53d2b46708cdf73 Mon Sep 17 00:00:00 2001 From: Linghong Date: Tue, 22 Sep 2026 17:57:09 +0800 Subject: [PATCH 4/4] =?UTF-8?q?=E6=B8=85=E6=89=AB=E4=B8=8E=E6=94=B6?= =?UTF-8?q?=E6=9D=9F=E5=86=8D=E5=8A=A0=E5=9B=BA=EF=BC=9ALinux=20=E5=91=BD?= =?UTF-8?q?=E4=BB=A4=E8=A1=8C=E7=BB=91=E5=AE=9A=E3=80=81=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=20fail-closed=E3=80=81=E6=94=B6=E5=89=B2=E6=9C=AA=E7=A1=AE?= =?UTF-8?q?=E8=AE=A4=E4=BF=9D=E7=95=99=E7=9B=AE=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sweep.rs:Linux 杀前要求 /proc/pid/cmdline 含 --user-data-dir=<本目录>/profile,把 PID 绑到配置目录;Windows/macOS 注释标明已知残余风险 sweep.rs:进程快照枚举错误一律报错(仅 ERROR_NO_MORE_FILES 为正常结束),残缺快照不再误报收割完成 sweep.rs:追踪列表记录每个成员的启动身份并在终止前复核,排除 PID 复用误杀;root 死后按旧父 PID 继续发现孤儿(PID 已复用则不发现) process.rs:修正注释——Windows 没有 root 死亡级联退出,spawn 与 Job 绑定的逸出窗口为毫秒级 process.rs:launch_owned_browser 持有 TempDir,启动失败或收束未确认时保留目录与 owner.json,交跨会话清扫收割 --- .../src-tauri/src/browser/process.rs | 69 +++++++---- .../src-tauri/src/browser/sweep.rs | 110 ++++++++++++++---- 2 files changed, 130 insertions(+), 49 deletions(-) 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 )); } } -- 2.52.0