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") {