Codex/agc browser orphan cleanup #461
@@ -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 指向可写的 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<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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user