Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs
T
lhk229 e8db0d1c87
Project CI / AI game creator shell Rust lane 2/2 (push) Has been cancelled
Project CI / Backend tests (push) Has been cancelled
Project CI / Native shell tests (push) Has been cancelled
Project CI / Frontend tests (push) Has been cancelled
Project CI / Repository checks (push) Has been cancelled
Project CI / AI game creator shell Rust crates (push) Has been cancelled
Project CI / AI game creator shell web tests (push) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (push) Has been cancelled
Project CI / AI game creator shell Rust smoke (push) Has been cancelled
Codex/agc browser orphan cleanup (#461)
Reviewed-on: #461
2026-09-22 19:05:20 +08:00

640 lines
24 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::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 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 {
/// 整树收割: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)]
{
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(())
}
#[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(_))) {
return Ok(());
}
if !self.process.reap().await {
// 进程退出未确认:保留目录与 owner.json,留待下次启动或
// 预检时由跨会话清扫收割,而不是删掉证据让清扫失明。
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
return Err("browser-cleanup-unconfirmed".into());
}
let confirmed = self.process.confirm_reaped().await;
if confirmed.is_err() {
if let Some(temp) = self.temp.take() {
std::mem::forget(temp);
}
}
confirmed
}
}
impl Drop for OwnedBrowser {
fn drop(&mut self) {
self.handler_task.abort();
self.drain_task.abort();
// 进程侧由 BrowserProcessGuard 的 Drop 兜底。
}
}
/// 自拉浏览器并完成 CDP 连接。spawn 与 Job 绑定之间存在极小的逸出
/// 窗口:绑定前产生的子进程未入 Job——Unix 上随 root 死亡级联退出;
/// Windows 没有这种级联,但窗口只有毫秒级(Chrome 此时尚未拉起子
/// 进程),真发生泄漏时临时目录因被占用而保留,留待系统或用户清理。
/// 绑定之后的一切子进程由 Job 全覆盖。
async fn launch_owned_browser(
config: BrowserConfig,
executable: &std::path::Path,
temp: TempDir,
) -> 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,
};
// 跨会话清扫的身份锚点:写入失败时该目录之后按旧残留只删不杀。
if let Some(pid) = process.child.inner.id() {
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) => {
// 收割未确认时保留目录与 owner.json,交给跨会话清扫。
if !process.reap().await {
std::mem::forget(temp);
}
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;
let (browser, mut handler) = match connected {
Ok(Ok(pair)) => pair,
Ok(Err(_)) => {
drain_task.abort();
if !process.reap().await {
std::mem::forget(temp);
}
return Err(OwnedBrowserLaunchError::ConnectFailed);
}
Err(_) => {
drain_task.abort();
if !process.reap().await {
std::mem::forget(temp);
}
return Err(OwnedBrowserLaunchError::ConnectTimeout);
}
};
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),
})
}
/// 仅验证浏览器启动和真实 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, &discovered.executable_path, temporary)
.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
.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 browser_temp = create_browser_process_temp_dir()?;
let proxy_bypass_list = preview_proxy_bypass_list(&preview_url);
let config = browser_config(
&browser_executable.executable_path,
&browser_temp,
&proxy_bypass_list,
)?;
let owned = launch_owned_browser(config, &browser_executable.executable_path, browser_temp)
.await
.map_err(|error| {
if error.is_timeout() {
"启动浏览器超时".to_string()
} else {
format!("启动浏览器失败:{}", error.code())
}
})?;
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 owned.shutdown().await.is_err() {
return Err(
"browser-cleanup-unconfirmed: 浏览器收束后无法证明退出,请核对本轮验证进程".into(),
);
}
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]
#[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(), "宿主已停止本轮浏览器验证");
}
}