修复 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:
2026-09-22 12:06:42 +08:00
parent 1b951619c9
commit 2596be6afc
2 changed files with 376 additions and 51 deletions
@@ -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) {
// WindowsJob 句柄关闭触发 KILL_ON_JOB_CLOSE 收掉整树;
// Unixtokio 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();
}