From b6a3e8d5064b0414b5ce94d40b8a4dbf78ff4ed8 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 3 Oct 2026 16:31:40 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E8=BF=9B=E7=A8=8B=E5=BD=92=E5=B1=9E=E4=B8=8E=E6=B8=85=E7=90=86?= =?UTF-8?q?=E8=AF=8A=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 固定 Windows 浏览器 root 与子进程启动身份,Job 绑定失败时回收完整进程树并拒绝 PID 复用。 保留 Web 预检 shutdown 的结构化清理诊断,补充身份不匹配回归测试。 --- .../src-tauri/src/browser/process.rs | 47 +++++++++----- .../src-tauri/src/browser/sweep.rs | 64 +++++++++++++++++-- 2 files changed, 90 insertions(+), 21 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index cda4b634b..712c41fbd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -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(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs index 6bd43bbcf..0a66c5762 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/sweep.rs @@ -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() {