加固浏览器清扫与收束:杀前镜像复核、收割结果确认、镜像默认视口
sweep.rs:杀前新增活进程镜像路径复核(Windows QueryFullProcessImageNameW / Linux /proc/pid/exe),打开失败 fail-closed 拒绝 sweep.rs:Unix 新增属主目录 euid 检查,拒绝他人在 /tmp 伪造的 ga-browser-* 目录 sweep.rs:Windows 杀树改为追踪制,全部成员从快照消失才报成功,root 先死子进程残留不再误报 sweep.rs:Unix 检查 kill 返回值并以 kill(pid,0) 确认进程消失后才报成功 process.rs:reap 返回 root 退出确认,非 Windows confirm_reaped 用 try_wait 兜底,挂起/杀失败暴露为 browser-cleanup-unconfirmed process.rs:handler_config 镜像 BrowserConfigBuilder 默认 viewport(Some 800x600),与旧 Browser::launch 行为一致 测试:杀树两个用例断言整树成员真实消失,新增镜像复核测试
This commit is contained in:
@@ -6,6 +6,7 @@ use std::time::Instant;
|
||||
|
||||
use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr};
|
||||
use chromiumoxide::browser::{Browser, BrowserConfig};
|
||||
use chromiumoxide::handler::viewport::Viewport;
|
||||
use chromiumoxide::handler::HandlerConfig;
|
||||
use futures::{AsyncBufReadExt, FutureExt, StreamExt};
|
||||
use tempfile::{Builder as TempDirBuilder, TempDir};
|
||||
@@ -76,14 +77,17 @@ const MAX_LAUNCH_STDERR_BYTES: usize = 64 * 1024;
|
||||
const MAX_WS_URL_LEN: usize = 512;
|
||||
|
||||
/// 必须与 browser_config 的 builder 调用保持一致:request_timeout /
|
||||
/// enable_request_intercept / disable_cache 在这里逐项镜像;其余字段
|
||||
/// (ignore_https_errors / ignore_invalid_messages / viewport)与
|
||||
/// BrowserConfigBuilder 默认值相同,沿用 HandlerConfig::default()。
|
||||
/// enable_request_intercept / disable_cache 在这里逐项镜像;viewport 镜像
|
||||
/// BrowserConfigBuilder 的默认值 Some(Viewport::default())(800x600,旧
|
||||
/// Browser::launch 会把它带入 HandlerConfig);其余字段
|
||||
/// (ignore_https_errors / ignore_invalid_messages)沿用
|
||||
/// HandlerConfig::default(),与 builder 默认值相同。
|
||||
fn handler_config() -> HandlerConfig {
|
||||
let mut config = HandlerConfig::default();
|
||||
config.request_timeout = BROWSER_TIMEOUT;
|
||||
config.request_intercept = true;
|
||||
config.cache_enabled = false;
|
||||
config.viewport = Some(Viewport::default());
|
||||
config
|
||||
}
|
||||
|
||||
@@ -204,33 +208,48 @@ struct BrowserProcessGuard {
|
||||
impl BrowserProcessGuard {
|
||||
/// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job
|
||||
/// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。
|
||||
async fn reap(&mut self) {
|
||||
/// 返回是否已确认 root 进程退出。
|
||||
async fn reap(&mut self) -> bool {
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = &self.job {
|
||||
let _ = job.terminate();
|
||||
}
|
||||
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.kill()).await;
|
||||
let _ = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await;
|
||||
matches!(
|
||||
tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await,
|
||||
Ok(Ok(_))
|
||||
)
|
||||
}
|
||||
|
||||
async fn confirm_reaped(&self) -> Result<(), String> {
|
||||
async fn confirm_reaped(&mut self) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
if let Some(job) = &self.job {
|
||||
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
|
||||
loop {
|
||||
if job
|
||||
.is_empty()
|
||||
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
|
||||
{
|
||||
return Ok(());
|
||||
{
|
||||
if let Some(job) = &self.job {
|
||||
let deadline = Instant::now() + BROWSER_CLOSE_TIMEOUT;
|
||||
loop {
|
||||
if job
|
||||
.is_empty()
|
||||
.map_err(|_| "browser-tree-reap-unconfirmed".to_string())?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除
|
||||
// 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。
|
||||
match self.child.inner.try_wait() {
|
||||
Ok(Some(_)) => Ok(()),
|
||||
_ => Err("browser-tree-reap-unconfirmed".into()),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,7 +281,9 @@ impl OwnedBrowser {
|
||||
if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) {
|
||||
return Ok(());
|
||||
}
|
||||
self.process.reap().await;
|
||||
if !self.process.reap().await {
|
||||
return Err("browser-cleanup-unconfirmed".into());
|
||||
}
|
||||
self.process.confirm_reaped().await
|
||||
}
|
||||
}
|
||||
@@ -305,7 +326,7 @@ async fn launch_owned_browser(
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
process.reap().await;
|
||||
let _ = process.reap().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
@@ -319,12 +340,12 @@ async fn launch_owned_browser(
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(_)) => {
|
||||
drain_task.abort();
|
||||
process.reap().await;
|
||||
let _ = process.reap().await;
|
||||
return Err(OwnedBrowserLaunchError::ConnectFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
drain_task.abort();
|
||||
process.reap().await;
|
||||
let _ = process.reap().await;
|
||||
return Err(OwnedBrowserLaunchError::ConnectTimeout);
|
||||
}
|
||||
};
|
||||
@@ -536,10 +557,11 @@ mod health_tests {
|
||||
assert_eq!(config.request_timeout, BROWSER_TIMEOUT);
|
||||
assert!(config.request_intercept);
|
||||
assert!(!config.cache_enabled);
|
||||
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致。
|
||||
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致
|
||||
// (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。
|
||||
assert!(config.ignore_https_errors);
|
||||
assert!(config.ignore_invalid_messages);
|
||||
assert!(config.viewport.is_none());
|
||||
assert_eq!(config.viewport, Some(Viewport::default()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -85,6 +85,23 @@ fn process_identity_matches(pid: u32, identity: &str) -> bool {
|
||||
.is_some_and(|live| live == identity)
|
||||
}
|
||||
|
||||
/// 可执行文件路径等价性:双方规范化后比较,Windows 下忽略大小写。
|
||||
fn same_executable_path(actual: &Path, expected: &Path) -> bool {
|
||||
let canonical = |path: &Path| path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
let actual = canonical(actual);
|
||||
let expected = canonical(expected);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
return actual
|
||||
.to_string_lossy()
|
||||
.eq_ignore_ascii_case(&expected.to_string_lossy());
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
actual == expected
|
||||
}
|
||||
}
|
||||
|
||||
/// exe 必须仍是系统标准路径下的可信浏览器,防止伪造 owner.json 借刀杀进程。
|
||||
fn trusted_browser_executable(path: &Path) -> bool {
|
||||
let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
|
||||
@@ -93,22 +110,88 @@ fn trusted_browser_executable(path: &Path) -> bool {
|
||||
}
|
||||
system_browser_candidates()
|
||||
.into_iter()
|
||||
.any(|(candidate, _)| candidate.canonicalize().unwrap_or(candidate) == canonical)
|
||||
.any(|(candidate, _)| same_executable_path(&candidate, path))
|
||||
}
|
||||
|
||||
/// 杀前复核:PID 对应的活进程镜像必须就是 owner.json 声明的那个可执行
|
||||
/// 文件。仅核对字符串不够——/tmp 全局可写时,同机其他用户可以伪造
|
||||
/// owner.json 把 browser_pid 指到本用户的任意进程借清扫杀之。
|
||||
#[cfg(windows)]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
|
||||
use windows_sys::Win32::Foundation::CloseHandle;
|
||||
use windows_sys::Win32::System::Threading::{
|
||||
OpenProcess, QueryFullProcessImageNameW, PROCESS_QUERY_LIMITED_INFORMATION,
|
||||
};
|
||||
// SAFETY: 句柄非空时由 CloseHandle 释放;打开失败按不匹配处理(fail-closed)。
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut buffer = [0u16; 1024];
|
||||
let mut length = buffer.len() as u32;
|
||||
// SAFETY: buffer 可写,length 先传入容量、返回实际长度。
|
||||
let okay = unsafe { QueryFullProcessImageNameW(handle, 0, buffer.as_mut_ptr(), &mut length) };
|
||||
// SAFETY: handle 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(handle) };
|
||||
if okay == 0 || length == 0 {
|
||||
return false;
|
||||
}
|
||||
let actual = std::path::PathBuf::from(String::from_utf16_lossy(&buffer[..length as usize]));
|
||||
same_executable_path(&actual, expected)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn live_process_executable_matches(pid: u32, expected: &Path) -> bool {
|
||||
let Ok(actual) = fs::read_link(format!("/proc/{pid}/exe")) else {
|
||||
return false;
|
||||
};
|
||||
same_executable_path(&actual, expected)
|
||||
}
|
||||
|
||||
// macOS 等无 /proc 的平台没有廉价的镜像核对手段,由目录属主检查兜底。
|
||||
#[cfg(all(unix, not(target_os = "linux")))]
|
||||
fn live_process_executable_matches(_pid: u32, expected: &Path) -> bool {
|
||||
expected.is_file()
|
||||
}
|
||||
|
||||
/// /tmp 是全局可写目录:属主目录必须由本用户创建,否则视为伪造并拒绝处置。
|
||||
#[cfg(unix)]
|
||||
fn directory_owned_by_current_user(dir: &Path) -> bool {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
dir.metadata()
|
||||
// SAFETY: geteuid 无前置条件,总是安全的。
|
||||
.map(|metadata| metadata.uid() == unsafe { libc::geteuid() })
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// 追踪所有经确认属于这棵树的 PID;只有它们全部从快照中消失才判
|
||||
// 成功——root 先死而子进程残留时不再误报收割完成。
|
||||
let mut tracked: Vec<u32> = Vec::new();
|
||||
for _ in 0..TREE_KILL_MAX_PASSES {
|
||||
// 每轮先复核 root 身份:进程已退出或 PID 被复用时立即停止。
|
||||
if !process_identity_matches(root_pid, expected_identity) {
|
||||
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) {
|
||||
// root 仍是目标浏览器:发现并追踪当前整棵子树。
|
||||
for pid in windows_process_tree_pids_from(&snapshot, root_pid)? {
|
||||
if !tracked.contains(&pid) {
|
||||
tracked.push(pid);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// root 已退出或 PID 被复用:复用者与本任务无关,不再追踪 root。
|
||||
tracked.retain(|pid| *pid != root_pid);
|
||||
}
|
||||
// 只统计仍存活的成员;全部消失才算收割完成。
|
||||
tracked.retain(|pid| snapshot.iter().any(|(live, _)| live == pid));
|
||||
if tracked.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let pids = windows_process_tree_pids(root_pid)?;
|
||||
if pids.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
for pid in &pids {
|
||||
for pid in &tracked {
|
||||
// 终止结果以“下一轮快照中是否消失”验证;对正在退出的进程
|
||||
// OpenProcess 的瞬时失败会在下一轮自然消解。
|
||||
windows_terminate_process(*pid);
|
||||
}
|
||||
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
|
||||
@@ -118,15 +201,27 @@ fn kill_browser_process_tree(root_pid: u32, expected_identity: &str) -> Result<(
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
// 只杀 root:子进程随 IPC 断开级联退出(与运行期收割语义一致)。
|
||||
if process_identity_matches(root_pid, expected_identity) {
|
||||
unsafe { libc::kill(root_pid as i32, libc::SIGKILL) };
|
||||
if !process_identity_matches(root_pid, expected_identity) {
|
||||
return Ok(());
|
||||
}
|
||||
Ok(())
|
||||
// SAFETY: 目标 PID 与启动身份均已复核。
|
||||
if unsafe { libc::kill(root_pid as i32, libc::SIGKILL) } != 0 {
|
||||
return Err("browser-sweep-kill-failed".into());
|
||||
}
|
||||
// SIGKILL 后必须确认进程真正消失再报成功。
|
||||
for _ in 0..TREE_KILL_MAX_PASSES {
|
||||
// SAFETY: signal 0 仅做存活探测,无副作用。
|
||||
if unsafe { libc::kill(root_pid as i32, 0) } != 0 {
|
||||
return Ok(());
|
||||
}
|
||||
std::thread::sleep(TREE_KILL_PASS_INTERVAL);
|
||||
}
|
||||
Err("browser-sweep-tree-not-reaped".into())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
|
||||
fn windows_process_snapshot() -> Result<Vec<(u32, u32)>, String> {
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
|
||||
use windows_sys::Win32::System::Diagnostics::ToolHelp::{
|
||||
CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
|
||||
@@ -149,6 +244,14 @@ fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
|
||||
}
|
||||
// SAFETY: snapshot 是本函数持有的合法句柄。
|
||||
unsafe { CloseHandle(snapshot) };
|
||||
Ok(processes)
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn windows_process_tree_pids_from(
|
||||
processes: &[(u32, u32)],
|
||||
root_pid: u32,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
if !processes.iter().any(|(pid, _)| *pid == root_pid) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
@@ -156,7 +259,7 @@ fn windows_process_tree_pids(root_pid: u32) -> Result<Vec<u32>, String> {
|
||||
let mut index = 0;
|
||||
while index < tree.len() {
|
||||
let parent = tree[index];
|
||||
for (pid, ppid) in &processes {
|
||||
for (pid, ppid) in processes {
|
||||
if *ppid == parent && !tree.contains(pid) {
|
||||
tree.push(*pid);
|
||||
}
|
||||
@@ -233,6 +336,12 @@ fn sweep_owned_directory(
|
||||
owner: &BrowserProcessOwner,
|
||||
notes: &mut Vec<String>,
|
||||
) {
|
||||
// /tmp 全局可写:属主目录必须是本用户创建,否则视为伪造,拒绝处置。
|
||||
#[cfg(unix)]
|
||||
if !directory_owned_by_current_user(dir) {
|
||||
notes.push(format!("kept-foreign-dir:{name}"));
|
||||
return;
|
||||
}
|
||||
// 另一个活着的 App 实例仍持有这轮浏览器:整目录跳过。
|
||||
if process_identity_matches(owner.owner_pid, &owner.owner_start_identity) {
|
||||
return;
|
||||
@@ -248,6 +357,12 @@ fn sweep_owned_directory(
|
||||
notes.push(format!("kept-untrusted-executable:{name}"));
|
||||
return;
|
||||
}
|
||||
// 活进程镜像必须与声明的浏览器一致:防止伪造 owner.json 借清扫
|
||||
// 终止本用户的无关进程。
|
||||
if !live_process_executable_matches(owner.browser_pid, Path::new(&owner.executable)) {
|
||||
notes.push(format!("kept-image-mismatch:{name}"));
|
||||
return;
|
||||
}
|
||||
match kill_browser_process_tree(owner.browser_pid, &owner.browser_start_identity) {
|
||||
Ok(()) => {
|
||||
let _ = fs::remove_dir_all(dir);
|
||||
@@ -405,13 +520,32 @@ mod tests {
|
||||
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");
|
||||
let before =
|
||||
windows_process_tree_pids_from(&windows_process_snapshot().expect("snapshot"), 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));
|
||||
// 不仅 root 消失,快照里记录到的每个树成员(含 ping 子进程)都必须
|
||||
// 真正被收割,不能只凭 root 不在就判成功。
|
||||
let alive = windows_process_snapshot().expect("snapshot after kill");
|
||||
for member in &before {
|
||||
assert!(
|
||||
!alive.iter().any(|(live, _)| live == member),
|
||||
"fixture 进程 {member} 必须被收割: {before:?}"
|
||||
);
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[test]
|
||||
fn live_process_executable_matches_current_process_image() {
|
||||
let exe = std::env::current_exe().unwrap();
|
||||
assert!(live_process_executable_matches(std::process::id(), &exe));
|
||||
assert!(!live_process_executable_matches(
|
||||
std::process::id(),
|
||||
Path::new("C:\\fixture\\chrome.exe")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1816,6 +1816,33 @@ setInterval(() => {}, 1000);
|
||||
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")
|
||||
@@ -1838,6 +1865,26 @@ 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));
|
||||
// 前置检查不能只凭 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 进程树必须先存活,否则收割断言是空转"
|
||||
@@ -1851,6 +1898,24 @@ fn windows_process_job_terminate_reaps_process_tree() {
|
||||
);
|
||||
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