修复浏览器进程归属与清理诊断

固定 Windows 浏览器 root 与子进程启动身份,Job 绑定失败时回收完整进程树并拒绝 PID 复用。

保留 Web 预检 shutdown 的结构化清理诊断,补充身份不匹配回归测试。
This commit is contained in:
2026-10-03 16:31:40 +08:00
parent e7530d0c8e
commit b6a3e8d506
2 changed files with 90 additions and 21 deletions
@@ -451,8 +451,18 @@ async fn cleanup_failed_launch(
tree_control_confirmed: bool,
) -> BrowserLaunchFailure {
let subprocess_exited = process.reap().await;
let tree_reaped =
tree_control_confirmed && subprocess_exited && process.confirm_reaped().await.is_ok();
let tree_reaped = if tree_control_confirmed && subprocess_exited {
#[cfg(windows)]
{
process.job.is_none() || process.confirm_reaped().await.is_ok()
}
#[cfg(not(windows))]
{
process.confirm_reaped().await.is_ok()
}
} else {
false
};
drop(process);
let cleanup_confirmed = if tree_reaped {
temp.close().is_ok()
@@ -483,9 +493,17 @@ async fn launch_owned_browser(
}
};
#[cfg(windows)]
let root_identity = child.inner.id().and_then(|pid| {
crate::process_identity::external_agent_runner_process_start_identity(pid)
.ok()
.flatten()
});
#[cfg(windows)]
let job = match WindowsProcessJob::assign_tokio(&child.inner) {
Ok(job) => {
let Some(root_pid) = child.inner.id() else {
let (Some(root_pid), Some(root_identity)) =
(child.inner.id(), root_identity.as_deref())
else {
let process = BrowserProcessGuard {
child,
job: Some(job),
@@ -498,16 +516,11 @@ async fn launch_owned_browser(
)
.await);
};
if super::sweep::ensure_browser_tree_in_job(root_pid, &job).is_ok() {
if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() {
Some(job)
} else {
let tree_reaped =
crate::process_identity::external_agent_runner_process_start_identity(root_pid)
.ok()
.flatten()
.is_some_and(|identity| {
super::sweep::kill_browser_process_tree(root_pid, &identity).is_ok()
});
super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok();
let process = BrowserProcessGuard {
child,
job: Some(job),
@@ -522,12 +535,18 @@ async fn launch_owned_browser(
}
}
Err(_) => {
let tree_reaped = match (child.inner.id(), root_identity.as_deref()) {
(Some(root_pid), Some(root_identity)) => {
super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok()
}
_ => false,
};
let process = BrowserProcessGuard { child, job: None };
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
false,
tree_reaped,
)
.await);
}
@@ -709,10 +728,8 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
_=cancelled => Err("宿主已停止本轮浏览器验证".to_string()),
};
if owned.shutdown().await.is_err() {
return Err(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
);
if let Err(error) = owned.shutdown().await {
return Err(error);
}
let mut result = validation?;
result.completed_at_unix_ms = unix_time_ms();
@@ -307,15 +307,17 @@ pub(super) fn kill_browser_process_tree(
for _ in 0..TREE_KILL_MAX_PASSES {
let snapshot = windows_process_snapshot()?;
let root_present = snapshot.iter().any(|(pid, _)| *pid == root_pid);
if root_present && process_identity_matches(root_pid, expected_identity) {
if root_present {
if !process_identity_matches(root_pid, expected_identity) {
return Err("browser-sweep-root-identity-mismatch".into());
}
// root 仍是目标浏览器:发现并追踪当前整棵子树。
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
track_process(&mut tracked, pid);
}
} else if !root_present {
} else {
// root 已退出且 PID 未被复用:发现临终前才拉起、仍挂在旧父
// PID 上的孤儿子进程。PID 已被复用时不做发现,避免误认
// 复用者的子进程。
// PID 上的孤儿子进程。PID 已被复用时不会进入此分支。
for (pid, ppid) in &snapshot {
if *ppid == root_pid {
track_process(&mut tracked, *pid);
@@ -443,23 +445,48 @@ fn windows_process_tree_pids_from(
#[cfg(windows)]
pub(super) fn ensure_browser_tree_in_job(
root_pid: u32,
root_identity: &str,
job: &WindowsProcessJob,
) -> Result<(), String> {
for _ in 0..TREE_KILL_MAX_PASSES {
let snapshot = windows_process_snapshot()?;
if !snapshot.iter().any(|(pid, _)| *pid == root_pid)
|| !process_identity_matches(root_pid, root_identity)
{
return Err("browser-job-root-identity-unconfirmed".into());
}
let tree = windows_process_tree_pids_from(&snapshot, root_pid)?;
if tree.is_empty() {
return Err("browser-job-root-exited-before-coverage".into());
}
let mut members = Vec::with_capacity(tree.len());
for pid in tree {
let identity = if pid == root_pid {
root_identity.to_string()
} else {
crate::process_identity::external_agent_runner_process_start_identity(pid)
.ok()
.flatten()
.ok_or_else(|| "browser-job-process-identity-unconfirmed".to_string())?
};
members.push((pid, identity));
}
for (pid, identity) in members {
if !process_identity_matches(pid, &identity) {
return Err("browser-job-process-identity-changed".into());
}
if !job.contains_pid(pid)? {
job.assign_pid(pid)?;
}
if !process_identity_matches(pid, &identity) {
return Err("browser-job-process-identity-changed".into());
}
}
let verified = windows_process_snapshot()?;
let verified_tree = windows_process_tree_pids_from(&verified, root_pid)?;
if !verified_tree.is_empty()
if process_identity_matches(root_pid, root_identity)
&& !verified_tree.is_empty()
&& verified_tree
.iter()
.all(|pid| job.contains_pid(*pid).unwrap_or(false))
@@ -754,9 +781,12 @@ mod tests {
.spawn()
.expect("spawn fixture");
let pid = child.id();
let identity = crate::process_identity::external_agent_runner_process_start_identity(pid)
.expect("fixture identity")
.expect("fixture identity present");
std::thread::sleep(Duration::from_millis(500));
let job = WindowsProcessJob::assign_std(&child).expect("assign fixture job");
ensure_browser_tree_in_job(pid, &job).expect("adopt fixture tree");
ensure_browser_tree_in_job(pid, &identity, &job).expect("adopt fixture tree");
let snapshot = windows_process_snapshot().expect("snapshot after adoption");
let tree = windows_process_tree_pids_from(&snapshot, pid).expect("tree after adoption");
assert!(tree
@@ -767,6 +797,28 @@ mod tests {
assert!(job.is_empty().expect("fixture job empty"));
}
#[cfg(windows)]
#[test]
fn kill_browser_process_tree_rejects_root_identity_mismatch() {
use std::process::{Command, Stdio};
let mut child = Command::new("cmd.exe")
.args(["/c", "ping", "127.0.0.1", "-n", "60"])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn fixture");
let pid = child.id();
let result = kill_browser_process_tree(pid, "not-the-fixture-identity");
let _ = child.kill();
let _ = child.wait();
assert_eq!(
result.expect_err("identity mismatch must fail closed"),
"browser-sweep-root-identity-mismatch"
);
}
#[cfg(windows)]
#[test]
fn kill_browser_process_tree_reaps_fixture_tree() {