新增跨会话清扫陈旧无头浏览器,避免残留进程放大预检超时

- 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 快照走树收割的定向测试
This commit is contained in:
2026-09-22 12:38:18 +08:00
parent 2596be6afc
commit feb2e100c4
6 changed files with 474 additions and 12 deletions
@@ -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;
@@ -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<TempDir, String> {
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<OwnedBrowser, OwnedBrowserLaunchError> {
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<BrowserIdentity, String> {
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 {
@@ -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<BrowserProcessOwner> {
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<Vec<u32>, 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::<PROCESSENTRY32W>() as u32;
let mut processes = Vec::new();
// SAFETY: entry 指向可写的 PROCESSENTRY32WdwSize 已初始化。
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<String> {
sweep_stale_browser_processes_at(&browser_process_temp_root(), SWEEP_MIN_AGE)
}
fn sweep_stale_browser_processes_at(root: &Path, min_age: Duration) -> Vec<String> {
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<String>,
) {
// 另一个活着的 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();
}
}
@@ -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();
@@ -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(
@@ -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") {