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 3330832ff..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 @@ -1,9 +1,14 @@ 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::viewport::Viewport; +use chromiumoxide::handler::HandlerConfig; +use futures::{AsyncBufReadExt, FutureExt, StreamExt}; use tempfile::{Builder as TempDirBuilder, TempDir}; use super::cdp::run_browser_validation; @@ -15,8 +20,12 @@ 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 { +pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-"; + +pub(super) fn browser_process_temp_root() -> PathBuf { #[cfg(unix)] { PathBuf::from("/tmp") @@ -29,7 +38,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}")) } @@ -63,21 +72,306 @@ 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 在这里逐项镜像;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 +} + +/// 从浏览器 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 { + /// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底; + /// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。 + /// 返回是否已确认 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; + matches!( + tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await, + Ok(Ok(_)) + ) + } + + async fn confirm_reaped(&mut self) -> Result<(), String> { + #[cfg(windows)] + { + if let Some(job) = &self.job { + let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT; + loop { + if job + .is_empty() + .map_err(|_| "browser-tree-reap-unconfirmed".to_string())? + { + return Ok(()); + } + if Instant::now() >= deadline { + return Err("browser-tree-reap-unconfirmed".into()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + Ok(()) + } + #[cfg(not(windows))] + { + // 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除 + // 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。 + match self.child.inner.try_wait() { + Ok(Some(_)) => Ok(()), + _ => Err("browser-tree-reap-unconfirmed".into()), + } + } + } +} + +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<()>, + // Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop, + // 不能直接移动字段)。 + temp: Option, +} + +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(()); + } + if !self.process.reap().await { + // 进程退出未确认:保留目录与 owner.json,留待下次启动或 + // 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。 + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + return Err("browser-cleanup-unconfirmed".into()); + } + let confirmed = self.process.confirm_reaped().await; + if confirmed.is_err() { + if let Some(temp) = self.temp.take() { + std::mem::forget(temp); + } + } + confirmed + } +} + +impl Drop for OwnedBrowser { + fn drop(&mut self) { + self.handler_task.abort(); + self.drain_task.abort(); + // 进程侧由 BrowserProcessGuard 的 Drop 兜底。 + } +} + +/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出 +/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出; +/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子 +/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。 +/// 绑定之后的一切子进程由 Job 全覆盖。 +async fn launch_owned_browser( + config: BrowserConfig, + executable: &std::path::Path, + temp: TempDir, +) -> Result { + let child = config + .launch() + .map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?; + // 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。 + #[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, + }; + // 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。 + if let Some(pid) = process.child.inner.id() { + super::sweep::write_browser_process_owner(temp.path(), pid, executable); + } + let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await + { + Ok(pair) => pair, + Err(error) => { + // 收割未确认时保留目录与 owner.json,交给跨会话清扫。 + if !process.reap().await { + std::mem::forget(temp); + } + return Err(error); + } + }; + 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(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectFailed); + } + Err(_) => { + drain_task.abort(); + if !process.reap().await { + std::mem::forget(temp); + } + return Err(OwnedBrowserLaunchError::ConnectTimeout); + } + }; let handler_task = tokio::spawn(async move { while let Some(message) = handler.next().await { if message.is_err() { @@ -85,14 +379,33 @@ 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, + temp: Some(temp), + }) +} + +/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。 +/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。 +pub(crate) async fn check_browser_health() -> Result { + let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?; + let temporary = create_browser_process_temp_dir().map_err(|_| "browser-temp-unavailable")?; + let config = browser_config(&discovered.executable_path, &temporary, "<-loopback>") + .map_err(|_| "browser-config-invalid")?; + let owned = launch_owned_browser(config, &discovered.executable_path, temporary) + .await + .map_err(|error| { + if error.is_timeout() { + "browser-start-timeout" + } else { + "browser-start-failed" + } + })?; + let 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 +468,18 @@ 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, &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( - &browser, + owned.browser(), &browser_executable, &preview_url, &input, @@ -191,22 +499,11 @@ 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?; result.completed_at_unix_ms = unix_time_ms(); let persisted_result = browser_validation_result_for_report(&result)?; @@ -231,6 +528,61 @@ 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 默认值保持一致 + // (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。 + assert!(config.ignore_https_errors); + assert!(config.ignore_invalid_messages); + assert_eq!(config.viewport, Some(Viewport::default())); + } + #[tokio::test] #[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"] async fn real_browser_health_checks_can_run_concurrently() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs new file mode 100644 index 000000000..21c681161 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -0,0 +1,613 @@ +//! 跨会话清扫陈旧的无头浏览器进程与临时目录。 +//! +//! 运行期 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) +} + +/// 可执行文件路径等价性:双方规范化后比较,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()); + if !canonical.is_file() { + return false; + } + system_browser_candidates() + .into_iter() + .any(|(candidate, _)| same_executable_path(&candidate, path)) +} + +/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行 +/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造 +/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。 +/// Linux 进一步要求命令行声明本目录的 profile,把 PID 绑到这份配置 +/// 目录,挡住同用户伪造 owner.json 指向正在使用的浏览器。 +#[cfg(windows)] +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, + }; + // 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, profile_dir: &Path) -> bool { + let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else { + return false; + }; + 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 的平台没有廉价的镜像与命令行核对手段,由目录属主检查 +// 兜底;已知残余风险:同用户伪造 owner.json 时可终止任意存活 PID。 +#[cfg(all(unix, not(target_os = "linux")))] +fn live_process_executable_matches(_pid: u32, expected: &Path, _profile_dir: &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<(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)? { + 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); + } + } + } + // 只统计仍存活且启动身份未变的成员(复核排除 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 { + // 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程 + // OpenProcess 的瞬时失败会在下一轮自然消解。 + 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) { + return 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)] +/// 记录待收割成员及其启动身份:之后每轮终止前复核身份,排除 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, GetLastError, ERROR_NO_MORE_FILES, 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 已初始化。返回 0 + // 只在 ERROR_NO_MORE_FILES 时是正常结束,其余错误按截断处理 + // (fail-closed:残缺快照会导致误报收割完成)。 + let mut available = unsafe { Process32FirstW(snapshot, &mut entry) }; + 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) }; + } +} + +#[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()); + } + 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, +) { + // /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; + } + 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; + } + // 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫 + // 终止本用户的无关进程。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; + } + 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_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"); + // 不仅 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(); + 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"), + profile + )); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs b/apps/ai-game-creator-shell/src-tauri/src/environment_check/web_creation.rs index 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 5f3cfe2e6..263f74d52 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 d963ea811..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 @@ -1810,3 +1810,112 @@ setInterval(() => {}, 1000); thread::sleep(Duration::from_millis(25)); } } + +#[cfg(windows)] +#[test] +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") + .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 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 进程树必须先存活,否则收割断言是空转" + ); + 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)); + } + // 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(); +}