修复 Web 预检浏览器启动失败时泄漏无头浏览器进程树
- browser/process.rs:改用公开的 BrowserConfig::launch() 自持 spawn,root 进程从启动起归调用方所有,不再经 Browser::launch 黑盒启动 - browser/process.rs:Windows 下 spawn 后立即把 root 挂进 KILL_ON_JOB_CLOSE 的 WindowsProcessJob,失败、超时或宿主被杀时整棵进程树由 Job 保证回收,堵住无父进程无头浏览器的累积泄漏 - browser/process.rs:自行解析 stderr 的 DevTools ws 地址并做 loopback 严格校验与输出上限,给原先无超时的 CDP 连接补上 30 秒超时 - browser/process.rs:新增 OwnedBrowser/BrowserProcessGuard 统一收尾,close-wait 失败时 TerminateJobObject 杀整树并确认树空,stderr 持续 drain 防止浏览器因管道写满卡死 - browser/process.rs:check_browser_health 与 validate_local_preview_in_browser_with_cancellation 两个调用点改走新路径,对外错误文案与语义不变 - process_session/tests.rs:新增 Windows Job terminate 收割进程树的定向测试 - browser/process.rs:新增 ws URL 只接受 loopback、HandlerConfig 镜像 BrowserConfig builder 的单元测试
This commit is contained in:
@@ -1,9 +1,13 @@
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
#[cfg(windows)]
|
||||
use std::time::Instant;
|
||||
|
||||
use chromiumoxide::async_process::{Child as BrowserChild, ChildStderr as BrowserChildStderr};
|
||||
use chromiumoxide::browser::{Browser, BrowserConfig};
|
||||
use futures::StreamExt;
|
||||
use chromiumoxide::handler::HandlerConfig;
|
||||
use futures::{AsyncBufReadExt, FutureExt, StreamExt};
|
||||
use tempfile::{Builder as TempDirBuilder, TempDir};
|
||||
|
||||
use super::cdp::run_browser_validation;
|
||||
@@ -15,6 +19,8 @@ use super::model::{
|
||||
BrowserIdentity, BrowserValidationInput, BrowserValidationResult, BROWSER_TIMEOUT,
|
||||
};
|
||||
use super::network_policy::{preview_proxy_bypass_list, validate_input};
|
||||
#[cfg(windows)]
|
||||
use crate::process_session::WindowsProcessJob;
|
||||
|
||||
fn browser_process_temp_root() -> PathBuf {
|
||||
#[cfg(unix)]
|
||||
@@ -63,21 +69,257 @@ fn browser_config(
|
||||
.map_err(|error| format!("构建浏览器配置失败:{error}"))
|
||||
}
|
||||
|
||||
/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。
|
||||
/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。
|
||||
pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
|
||||
let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?;
|
||||
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")?;
|
||||
// 外层超时给 Chromiumoxide 内部启动超时后的 kill/wait 留出清理窗口。
|
||||
let (mut browser, mut handler) = tokio::time::timeout(
|
||||
BROWSER_TIMEOUT + Duration::from_secs(5),
|
||||
Browser::launch(config),
|
||||
const BROWSER_CLOSE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
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()。
|
||||
fn handler_config() -> HandlerConfig {
|
||||
let mut config = HandlerConfig::default();
|
||||
config.request_timeout = BROWSER_TIMEOUT;
|
||||
config.request_intercept = true;
|
||||
config.cache_enabled = false;
|
||||
config
|
||||
}
|
||||
|
||||
/// 从浏览器 stderr 行提取 DevTools ws 地址;只接受 loopback 端点,
|
||||
/// 避免伪造输出把 CDP 连接引导到任意地址。
|
||||
fn parse_devtools_ws_url(line: &str) -> Option<String> {
|
||||
let (_, ws) = line.rsplit_once("listening on ")?;
|
||||
let ws = ws.trim();
|
||||
if ws.is_empty() || ws.len() > MAX_WS_URL_LEN || !ws.contains("devtools/browser") {
|
||||
return None;
|
||||
}
|
||||
let authority = ws.strip_prefix("ws://")?.split('/').next()?;
|
||||
let (host, port) = authority.rsplit_once(':')?;
|
||||
if !matches!(host, "127.0.0.1" | "localhost" | "[::1]")
|
||||
|| port.is_empty()
|
||||
|| !port.bytes().all(|byte| byte.is_ascii_digit())
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some(ws.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum OwnedBrowserLaunchError {
|
||||
SpawnFailed,
|
||||
WsTimeout,
|
||||
WsFailed,
|
||||
ConnectTimeout,
|
||||
ConnectFailed,
|
||||
}
|
||||
|
||||
impl OwnedBrowserLaunchError {
|
||||
fn is_timeout(self) -> bool {
|
||||
matches!(self, Self::WsTimeout | Self::ConnectTimeout)
|
||||
}
|
||||
|
||||
fn code(self) -> &'static str {
|
||||
match self {
|
||||
Self::SpawnFailed => "browser-spawn-failed",
|
||||
Self::WsTimeout => "browser-ws-timeout",
|
||||
Self::WsFailed => "browser-ws-failed",
|
||||
Self::ConnectTimeout => "browser-cdp-connect-timeout",
|
||||
Self::ConnectFailed => "browser-cdp-connect-failed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserStderrReader = futures::io::BufReader<BrowserChildStderr>;
|
||||
|
||||
/// 复刻 chromiumoxide 私有 ws_url_from_output 的语义(读 stderr 直到
|
||||
/// DevTools listening 行、提前退出即失败、整体有界),但 root 进程始终
|
||||
/// 留在调用方手里,并把 stderr reader 交还给调用方持续排空。
|
||||
async fn devtools_ws_url_from_stderr(
|
||||
child: &mut BrowserChild,
|
||||
timeout: Duration,
|
||||
) -> Result<(String, BrowserStderrReader), OwnedBrowserLaunchError> {
|
||||
let stderr = child
|
||||
.stderr
|
||||
.take()
|
||||
.ok_or(OwnedBrowserLaunchError::SpawnFailed)?;
|
||||
let mut reader = futures::io::BufReader::new(stderr);
|
||||
let mut captured: Vec<u8> = Vec::new();
|
||||
let mut exited = Box::pin(child.wait()).fuse();
|
||||
let read = async {
|
||||
loop {
|
||||
let mut line = Vec::new();
|
||||
futures::select! {
|
||||
_status = exited => return Err(OwnedBrowserLaunchError::WsFailed),
|
||||
result = reader.read_until(b'\n', &mut line).fuse() => {
|
||||
let count = result.map_err(|_| OwnedBrowserLaunchError::WsFailed)?;
|
||||
if count == 0 {
|
||||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||||
}
|
||||
captured.extend_from_slice(&line);
|
||||
if captured.len() > MAX_LAUNCH_STDERR_BYTES {
|
||||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||||
}
|
||||
let Ok(text) = std::str::from_utf8(&line) else {
|
||||
return Err(OwnedBrowserLaunchError::WsFailed);
|
||||
};
|
||||
if let Some(url) = parse_devtools_ws_url(text) {
|
||||
return Ok(url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
match tokio::time::timeout(timeout, read).await {
|
||||
Ok(Ok(url)) => Ok((url, reader)),
|
||||
Ok(Err(error)) => Err(error),
|
||||
Err(_) => Err(OwnedBrowserLaunchError::WsTimeout),
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_stderr_drain(mut reader: BrowserStderrReader) -> tokio::task::JoinHandle<()> {
|
||||
// 持续排空 stderr:浏览器日志写满管道会整体 stall。内容有界、不留存。
|
||||
tokio::spawn(async move {
|
||||
let mut buffer = Vec::with_capacity(4096);
|
||||
loop {
|
||||
buffer.clear();
|
||||
match reader.read_until(b'\n', &mut buffer).await {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(_) => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// 自持浏览器进程:root 从 spawn 起归本结构所有;Windows 下整树挂进
|
||||
/// KILL_ON_JOB_CLOSE 的 Job——失败路径、遗忘 shutdown、甚至宿主进程
|
||||
/// 被杀,整棵树都会被回收,不再留下无父进程的无头浏览器。
|
||||
struct BrowserProcessGuard {
|
||||
child: BrowserChild,
|
||||
#[cfg(windows)]
|
||||
job: Option<WindowsProcessJob>,
|
||||
}
|
||||
|
||||
impl BrowserProcessGuard {
|
||||
/// 整树收割:Job 终止全树,再 kill+wait root 兜底(Unix 与 Job
|
||||
/// 绑定前逸出的极早期子进程依赖 root 死亡的级联退出)。
|
||||
async fn reap(&mut self) {
|
||||
#[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;
|
||||
}
|
||||
|
||||
async fn confirm_reaped(&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 Instant::now() >= deadline {
|
||||
return Err("browser-tree-reap-unconfirmed".into());
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for BrowserProcessGuard {
|
||||
fn drop(&mut self) {
|
||||
// Windows:Job 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树;
|
||||
// Unix:tokio kill_on_drop 终止 root,子进程随 IPC 断开级联退出。
|
||||
}
|
||||
}
|
||||
|
||||
struct OwnedBrowser {
|
||||
browser: Browser,
|
||||
process: BrowserProcessGuard,
|
||||
handler_task: tokio::task::JoinHandle<()>,
|
||||
drain_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl OwnedBrowser {
|
||||
fn browser(&self) -> &Browser {
|
||||
&self.browser
|
||||
}
|
||||
|
||||
/// 优雅收束:CDP close → wait;失败则整树终止并确认收割。
|
||||
async fn shutdown(mut self) -> Result<(), String> {
|
||||
let close = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.browser.close()).await;
|
||||
let waited = tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.process.child.wait()).await;
|
||||
self.handler_task.abort();
|
||||
self.drain_task.abort();
|
||||
if matches!(close, Ok(Ok(_))) && matches!(waited, Ok(Ok(_))) {
|
||||
return Ok(());
|
||||
}
|
||||
self.process.reap().await;
|
||||
self.process.confirm_reaped().await
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for OwnedBrowser {
|
||||
fn drop(&mut self) {
|
||||
self.handler_task.abort();
|
||||
self.drain_task.abort();
|
||||
// 进程侧由 BrowserProcessGuard 的 Drop 兜底。
|
||||
}
|
||||
}
|
||||
|
||||
/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出
|
||||
/// 窗口:绑定前产生的子进程未入 Job,随 root 死亡级联退出;绑定之后
|
||||
/// 的一切子进程由 Job 全覆盖。
|
||||
async fn launch_owned_browser(
|
||||
config: BrowserConfig,
|
||||
) -> Result<OwnedBrowser, OwnedBrowserLaunchError> {
|
||||
let child = config
|
||||
.launch()
|
||||
.map_err(|_| OwnedBrowserLaunchError::SpawnFailed)?;
|
||||
// 无法证明整树可收割时拒绝放行浏览器;child 随 drop 由 kill_on_drop 收尾。
|
||||
#[cfg(windows)]
|
||||
let job = match WindowsProcessJob::assign_tokio(&child.inner) {
|
||||
Ok(job) => Some(job),
|
||||
Err(_) => return Err(OwnedBrowserLaunchError::SpawnFailed),
|
||||
};
|
||||
let mut process = BrowserProcessGuard {
|
||||
child,
|
||||
#[cfg(windows)]
|
||||
job,
|
||||
};
|
||||
let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await
|
||||
{
|
||||
Ok(pair) => pair,
|
||||
Err(error) => {
|
||||
process.reap().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let drain_task = spawn_stderr_drain(reader);
|
||||
let connected = tokio::time::timeout(
|
||||
BROWSER_TIMEOUT,
|
||||
Browser::connect_with_config(url, handler_config()),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "browser-start-timeout")?
|
||||
.map_err(|_| "browser-start-failed")?;
|
||||
.await;
|
||||
let (browser, mut handler) = match connected {
|
||||
Ok(Ok(pair)) => pair,
|
||||
Ok(Err(_)) => {
|
||||
drain_task.abort();
|
||||
process.reap().await;
|
||||
return Err(OwnedBrowserLaunchError::ConnectFailed);
|
||||
}
|
||||
Err(_) => {
|
||||
drain_task.abort();
|
||||
process.reap().await;
|
||||
return Err(OwnedBrowserLaunchError::ConnectTimeout);
|
||||
}
|
||||
};
|
||||
let handler_task = tokio::spawn(async move {
|
||||
while let Some(message) = handler.next().await {
|
||||
if message.is_err() {
|
||||
@@ -85,14 +327,30 @@ pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
|
||||
}
|
||||
}
|
||||
});
|
||||
let version = tokio::time::timeout(Duration::from_secs(5), browser.version()).await;
|
||||
let close = tokio::time::timeout(Duration::from_secs(5), browser.close()).await;
|
||||
let waited = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await;
|
||||
handler_task.abort();
|
||||
let _ = handler_task.await;
|
||||
if !matches!(close, Ok(Ok(_))) || !matches!(waited, Ok(Ok(_))) {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await;
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await;
|
||||
Ok(OwnedBrowser {
|
||||
browser,
|
||||
process,
|
||||
handler_task,
|
||||
drain_task,
|
||||
})
|
||||
}
|
||||
|
||||
/// 仅验证浏览器启动和真实 CDP,不加载项目、不生成试玩凭证。
|
||||
/// 每次独立 profile;既不串行化其它工具,也不继承 Codex 的临时 HOME。
|
||||
pub(crate) async fn check_browser_health() -> Result<BrowserIdentity, String> {
|
||||
let discovered = discover_chrome_or_edge().map_err(|_| "browser-not-found")?;
|
||||
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 version = tokio::time::timeout(Duration::from_secs(5), owned.browser().version()).await;
|
||||
if owned.shutdown().await.is_err() {
|
||||
return Err("browser-cleanup-failed".into());
|
||||
}
|
||||
let version = version
|
||||
@@ -155,23 +413,16 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
|
||||
&proxy_bypass_list,
|
||||
)?;
|
||||
|
||||
let (mut browser, mut handler) = tokio::time::timeout(
|
||||
BROWSER_TIMEOUT + Duration::from_secs(5),
|
||||
Browser::launch(config),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "启动浏览器超时".to_string())?
|
||||
.map_err(|error| format!("启动浏览器失败:{error}"))?;
|
||||
let handler_task = tokio::spawn(async move {
|
||||
while let Some(message) = handler.next().await {
|
||||
if message.is_err() {
|
||||
break;
|
||||
}
|
||||
let owned = launch_owned_browser(config).await.map_err(|error| {
|
||||
if error.is_timeout() {
|
||||
"启动浏览器超时".to_string()
|
||||
} else {
|
||||
format!("启动浏览器失败:{}", error.code())
|
||||
}
|
||||
});
|
||||
})?;
|
||||
|
||||
let work = run_browser_validation(
|
||||
&browser,
|
||||
owned.browser(),
|
||||
&browser_executable,
|
||||
&preview_url,
|
||||
&input,
|
||||
@@ -191,20 +442,10 @@ pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
|
||||
_=cancelled => Err("宿主已停止本轮浏览器验证".to_string()),
|
||||
};
|
||||
|
||||
let close_result = tokio::time::timeout(Duration::from_secs(5), browser.close()).await;
|
||||
let wait_result = tokio::time::timeout(Duration::from_secs(5), browser.wait()).await;
|
||||
handler_task.abort();
|
||||
let _ = handler_task.await;
|
||||
if !matches!(close_result, Ok(Ok(_))) || !matches!(wait_result, Ok(Ok(_))) {
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), browser.kill()).await;
|
||||
if !matches!(
|
||||
tokio::time::timeout(Duration::from_secs(5), browser.wait()).await,
|
||||
Ok(Ok(_))
|
||||
) {
|
||||
return Err(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
|
||||
);
|
||||
}
|
||||
if owned.shutdown().await.is_err() {
|
||||
return Err(
|
||||
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
|
||||
);
|
||||
}
|
||||
drop(browser_temp);
|
||||
let mut result = validation?;
|
||||
@@ -231,6 +472,60 @@ mod health_tests {
|
||||
assert!(safe_version_label("HeadlessChrome/140.0.1.2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn devtools_ws_url_only_accepts_loopback_browser_endpoints() {
|
||||
assert_eq!(
|
||||
parse_devtools_ws_url(
|
||||
"DevTools listening on ws://127.0.0.1:53317/devtools/browser/abc-123\r\n"
|
||||
)
|
||||
.as_deref(),
|
||||
Some("ws://127.0.0.1:53317/devtools/browser/abc-123")
|
||||
);
|
||||
assert!(parse_devtools_ws_url(
|
||||
"DevTools listening on ws://localhost:9222/devtools/browser/x"
|
||||
)
|
||||
.is_some());
|
||||
assert!(
|
||||
parse_devtools_ws_url("DevTools listening on ws://[::1]:9222/devtools/browser/x")
|
||||
.is_some()
|
||||
);
|
||||
// 非 loopback、非 ws 协议、非 browser 端点、非法端口一律拒绝。
|
||||
assert!(parse_devtools_ws_url(
|
||||
"DevTools listening on ws://evil.example.com:9222/devtools/browser/x"
|
||||
)
|
||||
.is_none());
|
||||
assert!(parse_devtools_ws_url(
|
||||
"DevTools listening on http://127.0.0.1:9222/devtools/browser/x"
|
||||
)
|
||||
.is_none());
|
||||
assert!(
|
||||
parse_devtools_ws_url("DevTools listening on ws://127.0.0.1:9222/devtools/page/x")
|
||||
.is_none()
|
||||
);
|
||||
assert!(parse_devtools_ws_url(
|
||||
"DevTools listening on ws://127.0.0.1:notaport/devtools/browser/x"
|
||||
)
|
||||
.is_none());
|
||||
assert!(parse_devtools_ws_url("random browser log line").is_none());
|
||||
let overlong = format!(
|
||||
"DevTools listening on ws://127.0.0.1:9222/devtools/browser/{}",
|
||||
"a".repeat(MAX_WS_URL_LEN)
|
||||
);
|
||||
assert!(parse_devtools_ws_url(&overlong).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handler_config_mirrors_browser_config_builder() {
|
||||
let config = handler_config();
|
||||
assert_eq!(config.request_timeout, BROWSER_TIMEOUT);
|
||||
assert!(config.request_intercept);
|
||||
assert!(!config.cache_enabled);
|
||||
// 其余字段必须与 BrowserConfigBuilder 默认值保持一致。
|
||||
assert!(config.ignore_https_errors);
|
||||
assert!(config.ignore_invalid_messages);
|
||||
assert!(config.viewport.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"]
|
||||
async fn real_browser_health_checks_can_run_concurrently() {
|
||||
|
||||
@@ -1815,3 +1815,33 @@ setInterval(() => {}, 1000);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn windows_process_job_terminate_reaps_process_tree() {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
// cmd 启动第一个 timeout 子进程后整树存活;terminate 必须连子进程一起收割。
|
||||
let mut child = Command::new("cmd.exe")
|
||||
.args([
|
||||
"/c", "timeout", "/t", "60", "/nobreak", "&", "timeout", "/t", "60", "/nobreak",
|
||||
])
|
||||
.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.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));
|
||||
}
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user