Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs
T
lhk229 feb2e100c4 新增跨会话清扫陈旧无头浏览器,避免残留进程放大预检超时
- browser/sweep.rs:新增清扫模块,launch 时向 ga-browser-* 目录写入 owner.json 身份锚点(浏览器 root PID 与启动身份、可信 exe 路径、App 属主 PID 与身份)
- browser/sweep.rs:清扫按属主存活跳过、进程已退出或 PID 复用只删目录、root 存活且身份一致才杀整树分级处置,杀树前要求 exe canonical 匹配系统标准浏览器候选路径,防止伪造 owner.json 借刀杀进程
- browser/sweep.rs:Windows 用 ToolHelp 快照走树终止 root 及全部后代并逐轮复核 root 身份,Unix 只杀 root 由子进程级联退出;无 owner.json 的旧版本残留只删目录不按猜测杀进程,五分钟内的目录一律跳过防止误伤进行中的预检
- main.rs:App 启动时后台异步清扫并把处置记录写入应用日志,不阻塞窗口
- environment_check/web_creation.rs:每次 Web 预检前顺手清扫一遍,防同会话残留放大超时
- browser/process.rs:launch 时写 owner.json,暴露临时目录前缀常量
- process_session/tests.rs:Job 杀树测试的 timeout fixture 在 stdio 重定向下会立即退出导致空转通过,改用 ping fixture 并补杀前树非空断言
- browser/sweep.rs:新增年龄门禁、活实例跳过、死进程只删不杀、坏 owner 按旧残留、可信 exe 匹配与 Windows 快照走树收割的定向测试
2026-09-22 12:38:18 +08:00

599 lines
22 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::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 在这里逐项镜像;其余字段
/// 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,
executable: &std::path::Path,
temp_root: &std::path::Path,
) -> 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_root, pid, executable);
}
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;
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() {
break;
}
}
});
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, &discovered.executable_path, temporary.path())
.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.path(),
)
.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(),
);
}
drop(browser_temp);
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 默认值保持一致。
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() {
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(), "宿主已停止本轮浏览器验证");
}
}