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;
|
||||
|
||||
@@ -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 指向可写的 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<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") {
|
||||
|
||||
Reference in New Issue
Block a user