跳到主要内容

02 — CLI / daemon / CDP 三层

本章讲什么: 进程架构。为什么要常驻 daemon、CLI 怎么把自己变成 daemon、两者用什么协议说话、daemon 怎么把命令转成 CDP 发给 Chrome。

2.1 它要解决的小问题

CLI 工具天生是「一次性」的:跑完就退出,内存里的一切都没了。但浏览器自动化要连续多步且有状态——登录、跳转、点击、读结果,中间不能掉登录态、不能每步重开 Chrome(慢且丢上下文)。

解法是经典的客户端 / 守护进程(daemon)分离:

多条 CLI 命令(各是独立短命进程)
open ─┐ snapshot ─┐ click ─┐ close ─┐
▼ ▼ ▼ ▼
┌──────────────────────────────────────────┐
│ 一个常驻 daemon(持有 Chrome 连接+会话) │
└──────────────────────────────────────────┘

CLI 端薄如纸:解析参数、连 socket、发一条 JSON、收一条 JSON、打印、退出。重活全在 daemon。

2.2 同一个二进制,两种身份:re-exec 成 daemon

最巧的设计:项目只发一个可执行文件,daemon 不是单独的程序,而是 CLI 把自己再启动一遍、用环境变量切换身份。

身份开关在 main.rs:进程一启动先看 AGENT_BROWSER_DAEMON,设了就进 daemon 主循环、永不返回普通 CLI 逻辑:

// main.rs:887 附近(节选)
// Native daemon mode: when AGENT_BROWSER_DAEMON is set, run as the daemon
if /* AGENT_BROWSER_DAEMON 已设置 */ {
...
rt.block_on(native::daemon::run_daemon(&session));
// 不再往下走普通 CLI 流程
}

而「把自己再启动一遍」在 ensure_daemon(connection.rs:780):

// connection.rs:855 附近(节选)
let exe_path = env::current_exe()?; // 我自己的路径
let mut cmd = Command::new(&exe_path); // 再 exec 一遍我自己
cmd.env("AGENT_BROWSER_DAEMON", "1"); // 但这次带上 daemon 开关
apply_daemon_env(&mut cmd, session, opts); // 把客户端的环境快照传过去
unsafe {
cmd.pre_exec(|| { libc::setsid(); Ok(()) }); // 脱离控制终端,真正后台化
}
cmd.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped()); // stderr 留管道,好捕获早退错误

怎么读这段: current_exe() 拿到自己的二进制路径 → Command::new 重新拉起它 → 设 AGENT_BROWSER_DAEMON=1 让副本走 daemon 分支 → setsid() 让它脱离当前终端成为独立会话首领(否则关掉终端 daemon 也跟着死)。stdin/stdout 重定向到 null,但 stderr 接成管道(connection.rs:879/900)——不是丢弃,而是留着捕获 daemon 早退时打印的真实错误(下游 926–958 行读出来回报,如「Address already in use」)。

这套设计省掉了打包 / 分发第二个二进制的麻烦。

2.3 谁负责确保 daemon 活着:ensure_daemon

每条 CLI 命令执行前都调 ensure_daemon(connection.rs:780),逻辑分层:

ensure_daemon(session)
├─ daemon_ready(session)? ← 仅以「socket 能连上」为存活判据(不查 PID)
│ ├─ 版本不匹配 → 杀掉旧的,重启 ← 升级后旧 daemon 被换掉
│ └─ 版本一致 → 直接复用,返回
└─ 不在线 → 清理 stale 文件 → spawn(见 2.2) → 轮询最多 50 次等它 ready

两个值得记的决策:

  • 存活判据是「socket 可连」而非「PID 存在」。 注释点明:这样在不同 PID 命名空间(如 unshare 沙箱)里的调用者,只要能连到 socket 就能复用 daemon(connection.rs:785 附近)。
  • 不加 settle-sleep。 因为 ensure_daemon 每条命令都跑,固定 sleep 会拖垮所有命令延迟;它选择乐观——万一刚好撞上 daemon 退出,由请求层失败后重试解决(connection.rs:790 附近注释)。

2.4 socket 在哪、协议长什么样

socket 路径

get_socket_dir(connection.rs 顶部)按优先级挑目录:

AGENT_BROWSER_SOCKET_DIR > XDG_RUNTIME_DIR/agent-browser > ~/.agent-browser > 临时目录

再加 AGENT_BROWSER_NAMESPACE 可把会话隔到 namespaces/<ns>/run/ 子目录——多个互不干扰的 agent 跑同机不串台。每个会话有一组文件:<session>.sock(Unix socket)、.pid.version.config.stream(daemon.rs 启动时写)。

平台差异: Windows 没有 unix socket,daemon 改监听本地 TCP 并把端口写进 <session>.port(daemon.rs#[cfg(windows)] 分支)。

线协议:每行一个 JSON

daemon 主循环 handle_connection(daemon.rs:356)用 read_line 逐行读,每行反序列化成一条命令,执行后写回一行 JSON:

// daemon.rs:356 附近(节选)
loop {
line.clear();
buf_reader.read_line(&mut line).await?; // 一行 = 一条命令
if looks_like_http(trimmed) { break; } // 防御:有人误连成 HTTP
let cmd: Value = serde_json::from_str(trimmed)?;
let response = {
let mut s = state.lock().await; // 全局状态加锁 → 串行执行
execute_command(&cmd, &mut s).await
};
writer.write_all(/* response + "\n" */).await?;
}

串行执行是关键:命令在 state.lock().await 持锁期间跑,所以同一会话内动作天然有序——不会出现「点击还没完成就开始截图」的竞态。

2.5 daemon 怎么跟 Chrome 说话:CdpClient

daemon 持有一个 CdpClient(cli/src/native/cdp/client.rs),它是对 Chrome DevTools Protocol 的封装:一条 WebSocket,上面多路复用请求/响应和事件。

核心结构(client.rs:28 附近):

CdpClient
├─ ws_tx : WebSocket 发送端(加锁)
├─ next_id : 原子自增,给每条 CDP 命令编号
├─ pending : id → oneshot 发送端 ← 「等这条命令的响应」
├─ event_tx : 广播通道,分发 CDP 事件(如 page loaded)
├─ raw_tx : 原始消息广播(给 inspect 代理转发给 DevTools)
└─ reader_handle : 后台任务,读 WS、按 id 派发响应或广播事件

工作方式(读 connect_with_headers,client.rs:54):

  1. 发命令时 next_id 取一个号,把一个 oneshot::Sender 存进 pending[id],然后 await 对应的 receiver。
  2. 后台 reader 循环收到 WS 消息:有 id 的是响应 → 从 pending 取出 sender 唤醒等待者;没 id 的是事件 → 经 event_tx 广播。
  3. 还兼容 Binary 帧(某些远程 CDP 代理如 Browserless 用二进制返回)和 30 秒一次的 keepalive ping(WS_KEEPALIVE_INTERVAL_SECS),穿透反向代理 / 负载均衡。

这就是经典的「单连接 + 请求 id 多路复用 + 事件广播」模式,让 daemon 能并发等多个 CDP 响应而不阻塞,同时把页面事件(导航、对话框)流式喂给动作层。

2.6 优雅关闭(一个真实坑)

close 命令不直接 process::exithandle_connection 检测到关闭类响应后,通知主循环优雅退出:

// daemon.rs:356 区块(节选)
if close_completed_response(&action, &response) {
tokio::time::sleep(Duration::from_millis(100)).await;
close_notify.notify_one(); // 通知主循环退出,而非 process::exit
return;
}

注释直说原因:process::exit 会跳过析构函数,可能留下孤儿 Chrome 进程(issue #1113)。改走 notify → 主循环正常返回 → Drop 跑全 → Chrome 被干净收掉。这是「资源安全收尾」的好例子。

2.7 代码地图

主题文件路径关键符号
daemon 身份开关cli/src/main.rsAGENT_BROWSER_DAEMON 分支、run_daemon
确保 / spawn daemoncli/src/connection.rsensure_daemonapply_daemon_envdaemon_ready
socket 路径cli/src/connection.rsget_socket_dir
daemon 主循环cli/src/native/daemon.rsrun_daemonhandle_connectionclose_completed_response
线协议防御cli/src/native/daemon.rslooks_like_http
CDP 客户端cli/src/native/cdp/client.rsCdpClientconnect_with_headersWS_KEEPALIVE_INTERVAL_SECS