Chrome 进程管理与多 Backend 架构
本文是「agent-browser 代码原理」系列第 4 篇,深入剖析 agent-browser 的 Chrome 进程管理和多 Backend 抽象。
一、Chrome 进程模型
Chrome 是多进程架构浏览器,启动后会创建多个进程:
| 进程类型 | 作用 |
|---|---|
| Browser Process | 主进程,管理 UI、标签页、网络请求 |
| GPU Process | 图形渲染 |
| Renderer Process | 每个标签页一个,执行 JS、渲染页面 |
| Utility Process | 音频、视频解码等 |
| Crashpad Handler | 崩溃报告 |
自动化工具的噩梦:如果 Browser Process 被 kill 但 Renderer Process 还在运行,这些孤儿进程会占用资源,甚至阻塞正常 Chrome 的启动。
二、ChromeProcess 结构
cdp/chrome.rs 中的 ChromeProcess 结构体负责管理 Chrome 的完整生命周期:
rust
pub struct ChromeProcess {
child: Child, // Rust std::process::Child
pub ws_url: String, // WebSocket 调试 URL
temp_user_data_dir: Option<PathBuf>, // 临时用户数据目录
#[cfg(unix)]
pgid: Option<i32>, // Unix 进程组 ID
}
2.1 Drop trait:自动清理
rust
impl Drop for ChromeProcess {
fn drop(&mut self) {
// 1. 杀掉 Chrome 进程
self.kill();
// 2. 清理临时用户数据目录
if let Some(ref dir) = self.temp_user_data_dir {
for attempt in 0..3 {
match std::fs::remove_dir_all(dir) {
Ok(()) => break,
Err(_) if attempt < 2 => {
std::thread::sleep(Duration::from_millis(100));
}
Err(e) => {
let _ = writeln!(
std::io::stderr(),
"Warning: failed to clean up temp profile {}: {}",
dir.display(), e
);
}
}
}
}
}
}
为什么重试 3 次? Chrome 退出时可能还在写入文件,立即删除会失败。等待 100ms 后重试提高成功率。
三、进程组 kill:解决孤儿进程问题
3.1 问题的根源
在 Unix 系统上,child.kill() 只发送信号给父进程(Browser Process),但 Renderer、GPU 等子进程属于同一个进程组,需要一起杀掉。
3.2 进程组 kill 实现
rust
impl ChromeProcess {
pub fn kill(&mut self) {
// 第一步:尝试正常 kill
let _ = self.child.kill();
// 第二步:Unix 系统杀掉整个进程组
#[cfg(unix)]
if let Some(pgid) = self.pgid {
unsafe {
// kill(-pgid, SIGKILL) 发送给进程组中所有进程
libc::kill(-pgid, libc::SIGKILL);
}
}
// 第三步:等待子进程退出(reap zombie)
let _ = self.child.wait();
}
}
关键点 : - kill(-pgid, SIGKILL) 中的负号表示"进程组" - SIGKILL(信号 9)无法被捕获或忽略,强制终止 - 这是解决 issue #1113(孤儿 Chrome 进程阻塞正常 Chrome)的方案
3.3 启动时设置进程组
rust
#[cfg(unix)]
fn setup_process_group(cmd: &mut Command) {
unsafe {
cmd.pre_exec(|| {
// 设置子进程为新的进程组 leader
libc::setpgid(0, 0);
Ok(())
});
}
}
四、Chrome 发现与启动
4.1 查找 Chrome 可执行文件
agent-browser 会自动检测系统中已有的 Chrome:
检测顺序:
1. 用户指定的 executable_path
2. 环境变量 CHROME_PATH
3. Google Chrome(macOS: /Applications/Google Chrome.app)
4. Microsoft Edge
5. Brave Browser
6. Playwright 安装的 Chromium
7. Puppeteer 安装的 Chromium
8. Chrome for Testing(agent-browser install 下载的)
4.2 启动参数构建
cdp/chrome.rs 中 build_chrome_args 函数构建启动参数:
rust
fn build_chrome_args(opts: &LaunchOptions) -> Result<ChromeArgs, String> {
let mut args = vec![
"--remote-debugging-port=0", // 随机分配调试端口
"--no-first-run", // 跳过首次运行向导
"--no-default-browser-check", // 不检查默认浏览器
"--password-store=basic", // 使用内存密码存储
"--use-mock-keychain", // 使用 mock keychain(macOS)
"--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding",
];
// 根据选项添加参数
if opts.headless {
args.push("--headless=new"); // 新版 headless 模式
}
if let Some(ref proxy) = opts.proxy {
args.push(format!("--proxy-server={}", proxy));
}
if let Some((w, h)) = opts.viewport_size {
args.push(format!("--window-size={}, {}", w, h));
}
// user-data-dir
if let Some(ref profile) = opts.profile {
args.push(format!("--user-data-dir={}", profile));
} else {
// 创建临时目录
let temp_dir = tempfile::tempdir()?.into_path();
args.push(format!("--user-data-dir={}", temp_dir.display()));
}
// 用户自定义参数
args.extend(opts.args.iter().cloned());
Ok(ChromeArgs { args, temp_user_data_dir: ... })
}
4.3 关键启动参数解析
| 参数 | 作用 |
|---|---|
--remote-debugging-port=0 |
随机端口,避免冲突 |
--headless=new |
新版 headless,支持扩展和 DevTools |
--password-store=basic |
避免弹窗请求钥匙串访问(macOS) |
--no-sandbox |
容器环境必需(Docker、CI) |
--disable-dev-shm-usage |
避免 /dev/shm 空间不足(Docker) |
--disable-gpu |
无 GPU 环境必需 |
五、CDP URL 发现
Chrome 启动后,agent-browser 需要找到其 WebSocket 调试 URL。
5.1 DevToolsActivePort 文件
Chrome 启动后会在用户数据目录创建 DevToolsActivePort 文件:
<port>\n<ws_path>\n
例如:
51234
/devtools/browser/3f2c1e5a-7b8d-4c9f-a1b2-c3d4e5f6g7h8
5.2 发现流程
rust
pub async fn discover_cdp_url(user_data_dir: &Path) -> Result<String, String> {
let port_file = user_data_dir.join("DevToolsActivePort");
// 轮询等待文件出现(最多 30 秒)
for attempt in 0..300 {
if port_file.exists() {
let content = std::fs::read_to_string(&port_file)?;
let mut lines = content.lines();
let port = lines.next().ok_or("Missing port")?;
let ws_path = lines.next().unwrap_or("/json/list");
// 尝试直接连接 ws_path
if let Ok(url) = resolve_cdp_from_active_port(port.parse()?, ws_path).await {
return Ok(url);
}
// 回退到 HTTP 发现
return discover_cdp_url_http(port.parse()?).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
}
Err("Timeout waiting for DevToolsActivePort".to_string())
}
5.3 HTTP 发现回退
如果 DevToolsActivePort 文件中没有 ws_path,回退到 HTTP 发现:
rust
async fn discover_cdp_url_http(port: u16) -> Result<String, String> {
let url = format!("http://127.0.0.1:{}/json/version", port);
let resp = reqwest::get(&url).await?;
let json: Value = resp.json().await?;
json.get("webSocketDebuggerUrl")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or("Missing webSocketDebuggerUrl".to_string())
}
六、多 Backend 架构
agent-browser 的核心设计哲学是**"协议无关的浏览器操作"**------上层代码不关心底层是 CDP、WebDriver 还是 Appium。
6.1 Backend Trait
rust
#[async_trait]
pub trait BrowserBackend: Send + Sync {
async fn navigate(&self, url: &str) -> Result<(), String>;
async fn get_url(&self) -> Result<String, String>;
async fn get_title(&self) -> Result<String, String>;
async fn evaluate(&self, script: &str) -> Result<Value, String>;
async fn screenshot(&self, options: &ScreenshotOptions) -> Result<Vec<u8>, String>;
// ... 其他通用操作
}
6.2 Backend 枚举
rust
pub enum BrowserBackendType {
Cdp(CdpBackend),
WebDriver(WebDriverBackend),
Appium(AppiumManager),
}
impl BrowserBackendType {
pub async fn navigate(&self, url: &str) -> Result<(), String> {
match self {
Self::Cdp(b) => b.navigate(url).await,
Self::WebDriver(b) => b.navigate(url).await,
Self::Appium(b) => b.navigate(url).await,
}
}
// ... 其他方法委托
}
6.3 CDP Backend
功能最全的 backend:
rust
pub struct CdpBackend {
client: CdpClient,
browser: BrowserManager,
}
impl BrowserBackend for CdpBackend {
async fn navigate(&self, url: &str) -> Result<(), String> {
let session_id = self.browser.active_session_id()?;
let params = json!({ "url": url });
self.client.send_command("Page.navigate", Some(params), Some(session_id)).await?;
Ok(())
}
async fn evaluate(&self, script: &str) -> Result<Value, String> {
let session_id = self.browser.active_session_id()?;
let params = EvaluateParams {
expression: script.to_string(),
return_by_value: Some(true),
await_promise: Some(false),
};
let result: EvaluateResult = self.client
.send_command_typed("Runtime.evaluate", ¶ms, Some(session_id))
.await?;
Ok(json!(result.result.value))
}
}
6.4 WebDriver Backend
通过 HTTP REST API 与 Selenium Grid / BrowserStack 通信:
rust
pub struct WebDriverBackend {
client: reqwest::Client,
session_url: String,
}
impl BrowserBackend for WebDriverBackend {
async fn navigate(&self, url: &str) -> Result<(), String> {
let payload = json!({ "url": url });
self.client.post(format!("{}/url", self.session_url))
.json(&payload)
.send().await?;
Ok(())
}
async fn evaluate(&self, script: &str) -> Result<Value, String> {
let payload = json!({ "script": script, "args": [] });
let resp = self.client.post(format!("{}/execute/sync", self.session_url))
.json(&payload)
.send().await?;
let json: Value = resp.json().await?;
Ok(json.get("value").cloned().unwrap_or(Value::Null))
}
}
6.5 功能对比
| 功能 | CDP | WebDriver | Appium |
|---|---|---|---|
| snapshot | ✅ | ❌ | ❌ |
| network 拦截 | ✅ | ❌ | ❌ |
| 录屏 | ✅ | ❌ | ❌ |
| 基本导航 | ✅ | ✅ | ✅ |
| 元素点击 | ✅ | ✅ | ✅ |
| 表单填写 | ✅ | ✅ | ✅ |
| 截图 | ✅ | ✅ | ✅ |
| 移动端 | ❌ | ❌ | ✅ |
七、Target / Session 管理
CDP 中,每个标签页、iframe、Service Worker 都是一个 Target。agent-browser 需要管理这些 Target 的 Session。
7.1 Target 生命周期
Chrome 启动
→ Target.createTarget (创建新标签页)
→ Target.attachToTarget (attach 获取 sessionId)
→ 通过 sessionId 发送命令
→ Target.detachFromTarget (detach)
→ Target.closeTarget (关闭)
7.2 Session 切换
rust
impl BrowserManager {
pub async fn switch_to_target(&mut self, target_id: &str) -> Result<(), String> {
let result: AttachToTargetResult = self.client
.send_command_typed(
"Target.attachToTarget",
&AttachToTargetParams {
target_id: target_id.to_string(),
flatten: Some(true),
},
Some(&self.browser_session_id),
).await?;
self.active_session_id = Some(result.session_id);
Ok(())
}
}
八、用户数据目录管理
8.1 临时 Profile
默认情况下,agent-browser 创建临时用户数据目录,退出时自动清理:
rust
let temp_dir = tempfile::tempdir()?;
// Chrome 使用这个目录
// Drop 时自动删除
8.2 复用现有 Profile
用户可以指定现有 Chrome Profile,保留登录态:
bash
agent-browser open example.com --profile "~/Library/Application Support/Google/Chrome"
注意:复用 Profile 时,Chrome 可能无法启动(因为已有实例在运行)。agent-browser 会: 1. 复制 Profile 到临时目录 2. 在副本上启动 Chrome 3. 退出时将修改同步回原始 Profile
九、总结
agent-browser 的 Chrome 进程管理和多 Backend 架构体现了工程可靠性 和协议抽象的设计思想:
| 设计点 | 实现 | 价值 |
|---|---|---|
| 进程组 kill | libc::kill(-pgid, SIGKILL) |
彻底清理孤儿进程 |
| Drop trait | 自动 kill + 清理临时目录 | 防止资源泄漏 |
| 多 Backend Trait | BrowserBackend async trait |
协议无关的上层代码 |
| CDP URL 发现 | DevToolsActivePort + HTTP 回退 | 可靠连接 |
| Target Session | attachToTarget / detachFromTarget |
多标签页管理 |
系列文章 : 1. agent-browser 架构概览:从 CLI 到 CDP 的分层设计 2. CDP WebSocket 客户端实现:命令/响应匹配与事件广播 3. Accessibility Tree 快照原理:如何让 AI 看懂网页 4. Chrome 进程管理与多 Backend 架构 ← 本文 5. Network 拦截与路由:Fetch Domain 实战