Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs
T
suzmii b6a3e8d506 修复浏览器进程归属与清理诊断
固定 Windows 浏览器 root 与子进程启动身份,Job 绑定失败时回收完整进程树并拒绝 PID 复用。

保留 Web 预检 shutdown 的结构化清理诊断,补充身份不匹配回归测试。
2026-10-03 16:31:40 +08:00

949 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use std::fs;
use std::future::Future;
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 chromiumoxide::handler::viewport::Viewport;
use chromiumoxide::handler::HandlerConfig;
use futures::{AsyncBufReadExt, FutureExt, StreamExt};
use tempfile::{Builder as TempDirBuilder, TempDir};
use super::cdp::run_browser_validation;
use super::discovery::discover_chrome_or_edge;
use super::evidence::{
browser_validation_result_for_report, prepare_evidence_root, unix_time_ms, write_json_report,
};
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;
pub(super) const BROWSER_TEMP_PREFIX: &str = "ga-browser-";
pub(super) fn browser_process_temp_root() -> PathBuf {
#[cfg(unix)]
{
PathBuf::from("/tmp")
}
#[cfg(not(unix))]
{
std::env::temp_dir()
}
}
pub(super) fn create_browser_process_temp_dir() -> Result<TempDir, String> {
TempDirBuilder::new()
.prefix(BROWSER_TEMP_PREFIX)
.tempdir_in(browser_process_temp_root())
.map_err(|error| format!("创建浏览器临时目录失败:{error}"))
}
fn browser_config(
executable: &std::path::Path,
temporary: &TempDir,
bypass: &str,
) -> Result<BrowserConfig, String> {
let profile_path = temporary.path().join("profile");
fs::create_dir(&profile_path)
.map_err(|error| format!("创建浏览器临时 Profile 失败:{error}"))?;
BrowserConfig::builder()
.chrome_executable(executable)
.user_data_dir(profile_path)
.env("TMPDIR", temporary.path().to_string_lossy().into_owned())
.new_headless_mode()
.enable_request_intercept()
.disable_cache()
.disable_https_first()
.request_timeout(BROWSER_TIMEOUT)
.launch_timeout(BROWSER_TIMEOUT)
.window_size(1280, 720)
.arg(("proxy-server", "http://127.0.0.1:9"))
.arg(("proxy-bypass-list", bypass))
.arg("block-new-web-contents")
.arg("deny-permission-prompts")
.arg("disable-notifications")
.arg("disable-service-worker")
.build()
.map_err(|error| format!("构建浏览器配置失败:{error}"))
}
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 在这里逐项镜像;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
}
/// 从浏览器 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 stage(self) -> BrowserLaunchStage {
match self {
Self::SpawnFailed => BrowserLaunchStage::Spawn,
Self::WsTimeout | Self::WsFailed => BrowserLaunchStage::WsHandshake,
Self::ConnectTimeout | Self::ConnectFailed => BrowserLaunchStage::CdpConnect,
}
}
fn is_recoverable(self) -> bool {
matches!(
self,
Self::WsTimeout | Self::WsFailed | Self::ConnectTimeout | Self::ConnectFailed
)
}
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",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum BrowserLaunchStage {
Setup,
Spawn,
WsHandshake,
CdpConnect,
}
impl BrowserLaunchStage {
fn as_str(self) -> &'static str {
match self {
Self::Setup => "setup",
Self::Spawn => "spawn",
Self::WsHandshake => "ws-handshake",
Self::CdpConnect => "cdp-connect",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BrowserLaunchFailure {
code: &'static str,
stage: BrowserLaunchStage,
recoverable: bool,
subprocess_exited: bool,
cleanup_confirmed: bool,
}
impl BrowserLaunchFailure {
fn setup(code: &'static str) -> Self {
Self {
code,
stage: BrowserLaunchStage::Setup,
recoverable: false,
subprocess_exited: true,
cleanup_confirmed: true,
}
}
fn from_launch_error(
error: OwnedBrowserLaunchError,
subprocess_exited: bool,
cleanup_confirmed: bool,
) -> Self {
Self {
code: error.code(),
stage: error.stage(),
recoverable: error.is_recoverable(),
subprocess_exited,
cleanup_confirmed,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct BrowserRecoveryFailure {
first: BrowserLaunchFailure,
final_attempt: Option<BrowserLaunchFailure>,
}
impl BrowserRecoveryFailure {
fn diagnostic(self) -> String {
match self.final_attempt {
Some(final_attempt) => format!(
"browser-recovery-failed: initial-stage={} final-stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=true initial-cause={} final-cause={}",
self.first.stage.as_str(),
final_attempt.stage.as_str(),
final_attempt.subprocess_exited,
final_attempt.cleanup_confirmed,
self.first.code,
final_attempt.code,
),
None => format!(
"{}: stage={} subprocess-exited={} cleanup-confirmed={} recovery-retried=false cause={}",
if self.first.recoverable {
"browser-recovery-blocked"
} else {
"browser-launch-failed"
},
self.first.stage.as_str(),
self.first.subprocess_exited,
self.first.cleanup_confirmed,
self.first.code,
),
}
}
}
async fn launch_with_one_recovery<T, F, Fut>(mut launch: F) -> Result<T, BrowserRecoveryFailure>
where
F: FnMut() -> Fut,
Fut: Future<Output = Result<T, BrowserLaunchFailure>>,
{
let first = match launch().await {
Ok(value) => return Ok(value),
Err(error) => error,
};
if !first.recoverable || !first.subprocess_exited || !first.cleanup_confirmed {
return Err(BrowserRecoveryFailure {
first,
final_attempt: None,
});
}
match launch().await {
Ok(value) => Ok(value),
Err(final_attempt) => Err(BrowserRecoveryFailure {
first,
final_attempt: Some(final_attempt),
}),
}
}
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 {
/// 整树收割:Windows 由 Job 终止全树,再 kill+wait root 兜底;
/// Unix 只终止 root,子进程依赖 root 死亡后的级联退出。
/// 返回是否已确认 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;
matches!(
tokio::time::timeout(BROWSER_CLOSE_TIMEOUT, self.child.wait()).await,
Ok(Ok(_))
)
}
async fn confirm_reaped(&mut self) -> Result<(), String> {
#[cfg(windows)]
{
let Some(job) = &self.job else {
return Err("browser-tree-reap-unconfirmed".into());
};
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;
}
}
#[cfg(not(windows))]
{
// 兜底确认 root 确已退出:否则调用方会在浏览器仍存活时删除
// 配置目录,且 browser-cleanup-unconfirmed 永远不会暴露。
match self.child.inner.try_wait() {
Ok(Some(_)) => Ok(()),
_ => Err("browser-tree-reap-unconfirmed".into()),
}
}
}
}
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<()>,
// Option 以便在收割未确认时取出并保留目录(OwnedBrowser 有 Drop,
// 不能直接移动字段)。
temp: Option<TempDir>,
}
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(_)))
&& self.process.confirm_reaped().await.is_ok()
{
return Ok(());
}
let subprocess_exited = self.process.reap().await;
if !subprocess_exited {
// 进程退出未确认:保留目录与 owner.json,留待下次启动或
// 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
return Err(
"browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=false cleanup-confirmed=false recovery-retried=false"
.into(),
);
}
if self.process.confirm_reaped().await.is_err() {
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
return Err(
"browser-cleanup-unconfirmed: stage=shutdown subprocess-exited=true cleanup-confirmed=false recovery-retried=false"
.into(),
);
}
Ok(())
}
}
impl Drop for OwnedBrowser {
fn drop(&mut self) {
self.handler_task.abort();
self.drain_task.abort();
// 进程侧由 BrowserProcessGuard 的 Drop 兜底。
}
}
async fn cleanup_failed_launch(
mut process: BrowserProcessGuard,
temp: TempDir,
error: OwnedBrowserLaunchError,
tree_control_confirmed: bool,
) -> BrowserLaunchFailure {
let subprocess_exited = process.reap().await;
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()
} else {
// 保留 Profile 和 owner.json;后续清扫不得因证据丢失猜测归属。
let _ = temp.keep();
false
};
BrowserLaunchFailure::from_launch_error(error, subprocess_exited, cleanup_confirmed)
}
/// 自拉浏览器并完成 CDP 连接。Windows 先把 root 放入 KILL_ON_JOB_CLOSE
/// 的 Job,再把绑定瞬间已经出现的整棵子树纳入同一 Job;之后由 Job 自动
/// 覆盖新建子进程。无法证明覆盖完整时先收束整棵已知进程树,不放行浏览器。
async fn launch_owned_browser(
config: BrowserConfig,
executable: &std::path::Path,
temp: TempDir,
) -> Result<OwnedBrowser, BrowserLaunchFailure> {
let child = match config.launch() {
Ok(child) => child,
Err(_) => {
return Err(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::SpawnFailed,
true,
true,
));
}
};
#[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), Some(root_identity)) =
(child.inner.id(), root_identity.as_deref())
else {
let process = BrowserProcessGuard {
child,
job: Some(job),
};
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
false,
)
.await);
};
if super::sweep::ensure_browser_tree_in_job(root_pid, root_identity, &job).is_ok() {
Some(job)
} else {
let tree_reaped =
super::sweep::kill_browser_process_tree(root_pid, root_identity).is_ok();
let process = BrowserProcessGuard {
child,
job: Some(job),
};
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::SpawnFailed,
tree_reaped,
)
.await);
}
}
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,
tree_reaped,
)
.await);
}
};
let mut process = BrowserProcessGuard {
child,
#[cfg(windows)]
job,
};
// 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。
if let Some(pid) = process.child.inner.id() {
let _ = super::sweep::write_browser_process_owner(temp.path(), pid, executable);
}
let (url, reader) = match devtools_ws_url_from_stderr(&mut process.child, BROWSER_TIMEOUT).await
{
Ok(pair) => pair,
Err(error) => {
return Err(cleanup_failed_launch(process, temp, error, true).await);
}
};
let drain_task = spawn_stderr_drain(reader);
let connected = tokio::time::timeout(
BROWSER_TIMEOUT,
Browser::connect_with_config(url, handler_config()),
)
.await;
let (browser, mut handler) = match connected {
Ok(Ok(pair)) => pair,
Ok(Err(_)) => {
drain_task.abort();
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::ConnectFailed,
true,
)
.await);
}
Err(_) => {
drain_task.abort();
return Err(cleanup_failed_launch(
process,
temp,
OwnedBrowserLaunchError::ConnectTimeout,
true,
)
.await);
}
};
let handler_task = tokio::spawn(async move {
while let Some(message) = handler.next().await {
if message.is_err() {
break;
}
}
});
Ok(OwnedBrowser {
browser,
process,
handler_task,
drain_task,
temp: Some(temp),
})
}
async fn launch_browser_attempt(
executable: &std::path::Path,
proxy_bypass_list: &str,
) -> Result<OwnedBrowser, BrowserLaunchFailure> {
let temporary = create_browser_process_temp_dir()
.map_err(|_| BrowserLaunchFailure::setup("browser-temp-unavailable"))?;
let config = browser_config(executable, &temporary, proxy_bypass_list)
.map_err(|_| BrowserLaunchFailure::setup("browser-config-invalid"))?;
launch_owned_browser(config, executable, temporary).await
}
async fn launch_browser_with_recovery(
executable: &std::path::Path,
proxy_bypass_list: &str,
) -> Result<OwnedBrowser, String> {
launch_with_one_recovery(|| launch_browser_attempt(executable, proxy_bypass_list))
.await
.map_err(|failure| failure.diagnostic())
}
/// 仅验证浏览器启动和真实 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 owned = launch_browser_with_recovery(&discovered.executable_path, "<-loopback").await?;
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
.map_err(|_| "browser-cdp-timeout")?
.map_err(|_| "browser-cdp-failed")?;
if !safe_version_label(&version.product) || !safe_version_label(&version.protocol_version) {
return Err("browser-version-invalid".into());
}
Ok(BrowserIdentity {
kind: discovered.kind,
product: version.product,
protocol_version: version.protocol_version,
})
}
fn safe_version_label(value: &str) -> bool {
!value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'/' | b'-' | b'_'))
}
pub async fn validate_local_preview_in_browser(
input: BrowserValidationInput,
) -> Result<BrowserValidationResult, String> {
validate_local_preview_in_browser_with_interaction(input, false).await
}
/// Runs the same restricted Chromium validation, optionally adding one bounded,
/// advisory click on a visible enabled control. The interaction is evidence for
/// the direct Codex reviewer; it is not a fixed gameplay contract and never
/// changes the deterministic browser safety checks.
pub async fn validate_local_preview_in_browser_with_interaction(
input: BrowserValidationInput,
advisory_interaction: bool,
) -> Result<BrowserValidationResult, String> {
validate_local_preview_in_browser_with_cancellation(input, advisory_interaction, None).await
}
pub(crate) async fn validate_local_preview_in_browser_with_cancellation(
input: BrowserValidationInput,
advisory_interaction: bool,
cancellation: Option<std::sync::Arc<std::sync::atomic::AtomicBool>>,
) -> Result<BrowserValidationResult, String> {
if cancellation
.as_ref()
.is_some_and(|flag| flag.load(std::sync::atomic::Ordering::Acquire))
{
return Err("宿主已停止本轮浏览器验证".into());
}
let preview_url = validate_input(&input)?;
prepare_evidence_root(&input.evidence_root)?;
let browser_executable = discover_chrome_or_edge()?;
let proxy_bypass_list = preview_proxy_bypass_list(&preview_url);
let owned =
launch_browser_with_recovery(&browser_executable.executable_path, &proxy_bypass_list)
.await
.map_err(|error| {
if error.starts_with("browser-recovery-")
|| error.starts_with("browser-launch-failed")
{
error
} else {
format!("启动浏览器失败:{error}")
}
})?;
let work = run_browser_validation(
owned.browser(),
&browser_executable,
&preview_url,
&input,
advisory_interaction,
);
let cancelled = async {
let Some(flag) = cancellation else {
std::future::pending::<()>().await;
return;
};
while !flag.load(std::sync::atomic::Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(50)).await;
}
};
let validation = tokio::select! {
result=work => result,
_=cancelled => Err("宿主已停止本轮浏览器验证".to_string()),
};
if let Err(error) = owned.shutdown().await {
return Err(error);
}
let mut result = validation?;
result.completed_at_unix_ms = unix_time_ms();
let persisted_result = browser_validation_result_for_report(&result)?;
write_json_report(&result.evidence.report_path, &persisted_result)?;
Ok(result)
}
#[cfg(test)]
mod health_tests {
use super::*;
#[test]
fn health_launch_uses_isolated_profiles_and_same_restricted_configuration() {
let first = create_browser_process_temp_dir().unwrap();
let second = create_browser_process_temp_dir().unwrap();
let executable = std::env::current_exe().unwrap();
let first_config = browser_config(&executable, &first, "<-loopback>").unwrap();
let second_config = browser_config(&executable, &second, "<-loopback>").unwrap();
assert_ne!(first_config.user_data_dir, second_config.user_data_dir);
assert!(first_config.user_data_dir.unwrap().is_dir());
assert!(!safe_version_label("Chrome/123\nTOKEN=secret"));
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 默认值保持一致
// (builder 默认 viewport 为 Some(Viewport::default()),即 800x600)。
assert!(config.ignore_https_errors);
assert!(config.ignore_invalid_messages);
assert_eq!(config.viewport, Some(Viewport::default()));
}
#[tokio::test]
async fn browser_ws_failure_retries_after_confirmed_cleanup_with_new_profile() {
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let profiles = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let attempts_for_launch = std::sync::Arc::clone(&attempts);
let profiles_for_launch = std::sync::Arc::clone(&profiles);
let result = launch_with_one_recovery(move || {
let attempt = attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
let profiles = std::sync::Arc::clone(&profiles_for_launch);
async move {
let temporary = tempfile::tempdir().unwrap();
profiles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.push(temporary.path().to_path_buf());
if attempt == 0 {
drop(temporary);
Err(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::WsFailed,
true,
true,
))
} else {
drop(temporary);
Ok(())
}
}
})
.await;
assert!(result.is_ok());
assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 2);
let profiles = profiles
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert_eq!(profiles.len(), 2);
assert_ne!(profiles[0], profiles[1]);
}
#[tokio::test]
async fn browser_recovery_does_not_retry_when_cleanup_is_unconfirmed() {
let attempts = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
let attempts_for_launch = std::sync::Arc::clone(&attempts);
let failure = launch_with_one_recovery(move || {
attempts_for_launch.fetch_add(1, std::sync::atomic::Ordering::AcqRel);
async {
Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::ConnectTimeout,
false,
false,
))
}
})
.await
.unwrap_err();
let diagnostic = failure.diagnostic();
assert_eq!(attempts.load(std::sync::atomic::Ordering::Acquire), 1);
assert!(diagnostic.contains("stage=cdp-connect"));
assert!(diagnostic.contains("subprocess-exited=false"));
assert!(diagnostic.contains("cleanup-confirmed=false"));
assert!(diagnostic.contains("recovery-retried=false"));
}
#[tokio::test]
async fn consecutive_browser_failures_keep_both_recovery_stages_and_safe_diagnostics() {
let failure = launch_with_one_recovery(|| async {
Err::<(), BrowserLaunchFailure>(BrowserLaunchFailure::from_launch_error(
OwnedBrowserLaunchError::WsFailed,
true,
true,
))
})
.await
.unwrap_err();
let diagnostic = failure.diagnostic();
assert!(diagnostic.contains("initial-stage=ws-handshake"));
assert!(diagnostic.contains("final-stage=ws-handshake"));
assert!(diagnostic.contains("subprocess-exited=true"));
assert!(diagnostic.contains("cleanup-confirmed=true"));
assert!(diagnostic.contains("recovery-retried=true"));
assert!(!diagnostic.contains("/tmp"));
}
#[tokio::test]
#[ignore = "requires an installed Chrome/Chromium/Edge; local CDP only"]
async fn real_browser_health_checks_can_run_concurrently() {
let (first, second) = tokio::join!(check_browser_health(), check_browser_health());
assert!(!first.unwrap().product.is_empty());
assert!(!second.unwrap().protocol_version.is_empty());
}
#[tokio::test]
#[ignore = "requires an installed browser; verifies cancellation after the real page is requested and cleanup completes"]
async fn real_browser_budget_cancellation_closes_an_already_started_validation() {
let page_requested = std::sync::Arc::new(tokio::sync::Notify::new());
let observed = std::sync::Arc::clone(&page_requested);
let app=axum::Router::new().route("/",axum::routing::get(move || {
let observed=std::sync::Arc::clone(&observed);
async move { observed.notify_one(); axum::response::Html("<!doctype html><canvas id='game' width='100' height='100'></canvas><script>game.getContext('2d').fillRect(0,0,100,100)</script>") }
}));
let listener = tokio::net::TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0))
.await
.unwrap();
let address = listener.local_addr().unwrap();
let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() });
let cancellation = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let flag = std::sync::Arc::clone(&cancellation);
let cancel = tokio::spawn(async move {
tokio::time::timeout(Duration::from_secs(20), page_requested.notified())
.await
.expect("real browser requested page");
flag.store(true, std::sync::atomic::Ordering::Release);
});
let temp = tempfile::tempdir().unwrap();
let result = validate_local_preview_in_browser_with_cancellation(
BrowserValidationInput {
url: format!("http://{address}/"),
viewports: vec![
crate::browser::BrowserValidationViewport::Desktop,
crate::browser::BrowserValidationViewport::Mobile,
],
expected_text: Vec::new(),
settle_ms: 4000,
fail_on_console_error: true,
playtest_scenario: None,
evidence_root: temp.path().join("evidence"),
},
false,
Some(cancellation),
)
.await;
cancel.await.unwrap();
server.abort();
let _ = server.await;
assert_eq!(result.unwrap_err(), "宿主已停止本轮浏览器验证");
}
}