Codex/agc browser orphan cleanup #461

Merged
lhk229 merged 11 commits from codex/agc-browser-orphan-cleanup into master 2026-09-22 19:05:21 +08:00
6 changed files with 1143 additions and 54 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;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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(
@@ -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::<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) };
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<u32> = {
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<u32> = 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();
}