数据截至 (上游 commit 3667151744e3)
终端内核:PTY、内嵌 Ghostty VT、以及 state/runtime 的切分
30 秒导读: herdr 的服务端里,你看到的每一格终端(pane)不是一个"终端组件",而是四样东西拼起来的:一个被 actor 包住的 PTY 文件描述符、一份从 Ghostty 抠出来编进二进制的 VT 解析引擎、一对被刻意拆开的「纯数据 state」与「活资源 runtime」、以及一棵决定它画在屏幕哪块的 BSP 树。本章把这四层从上到下拆开讲。
本章不讲"怎么判断 agent 是在干活还是卡住了"——那是 03-agent-detection 的事。进程模型(为什么有个常驻服务端)见 01-server-client-runtime;帧怎么发给客户端见 05-render-pipeline。
1. 一个 pane 背后到底有什么
先建立直觉,再下钻。
白话定义: pane 就是你在 herdr 界面上看到的一格终端。里面跑着 shell,或者跑着 Claude Code / Codex 这类 agent CLI。
一格 pane 背后有四层,各管一件事:
| 层 | 管什么 | 核心类型 | 主要文件 |
|---|---|---|---|
| PTY 层 | 和子进程之间那根字节管子:读、写、改窗口大小 | PtyIoActorHandle | src/pty/actor/unix.rs |
| 终端仿真层 | 把 字节流解释成"屏幕上第几行第几列是什么字符什么颜色" | Terminal / RenderState | src/ghostty/mod.rs |
| 状态/运行时层 | 纯数据(可以离开 PTY 测试) vs 活资源(线程、fd、任务) | TerminalState / TerminalRuntime | src/terminal/ |
| 空间层 | 这格画在屏幕哪个矩形里、属于哪个 tab / workspace | TileLayout / Tab / Workspace | src/layout.rs、src/workspace.rs |
一张全景图,从左往右是字节的流向:
子进程 (shell / agent CLI)
│ ▲
PTY │ │ 用户输入 + 终端应答
字节流 ▼ │
┌──────────────────────┐
│ ① PTY actor │ 一个 fd + 一个专属线程
│ poll(读/写/唤醒) │ src/pty/actor/unix.rs
└───────┬──────────────┘
│ on_read(&[u8]) 回调
▼
┌──────────────────────┐ ┌────────────────────┐
│ ② 旁路嗅探器 │───▶│ cwd / 标题 / 键盘协议 │
│ OSC・kitty・xtgettcap │ │ (不进 VT 也要的事实) │
└───────┬──────────────┘ └────────────────────┘
│ 过滤后的字节
▼
┌──────────────────────┐
│ ③ Ghostty VT 引擎 │ 内嵌静态库,解析出网格
│ Terminal / RenderState│ src/ghostty/mod.rs
└───────┬──────────────┘
│ 读屏(几种口径)
▼
④ TerminalState(纯数据) + TileLayout(画在哪)
看懂这张图,本章剩下的部分就是逐个放大①②③④。
2. PTY 层:一个 fd、一个线程、一个信箱
2.1 它要解决的小问题
PTY(伪终端,pseudo terminal——内核提供的一对"假终端"设备,一端给子进程当 tty,另一端给你读写)本身很朴素:一个 fd,read 拿子进程的输出,write 送用户输入,ioctl(TIOCSWINSZ) 告诉它窗口多大。
难点在于并发。同一个 fd 上有五种互相打架的诉求:
- 子进程随时可能吐字节(要一直读);
- 用户随时敲键(要写);
- 终端仿真器自己要回应答(比如收到 DA 查询要回一串,这也是写);
- 布局变了要 resize;
- 换二进制时要"冻住"这个 fd 交接出去(见 06-persistence-and-handoff)。
2.2 思路:把 fd 关进 actor
herdr 的做法是每个 pane 一个专属 OS 线程独占那个 fd,其他人只能通过 handle 发消息。这就是 actor 模型:资源单线程独占,外界只递消息。
线程创建时就带上 pane 编号,方便排查(src/pty/actor/unix.rs:401-404,线程名 herdr-pty-{pane_id})。
对外的把手是 PtyIoActorHandle(src/pty/actor/unix.rs:88),它是 Clone 的,方法面按"数据/控制/生命周期"分三类:
| 类别 | 方法 | 做什么 |
|---|---|---|
| 数据 | write_user_input / try_write_user_input | 用户键盘输入,走 tokio mpsc 队列(容量 1024) |
| 数据 | write_terminal_response | VT 引擎产生的应答字节,走共享槽 + 顺序锁 |
| 控制 | resize | 存进共享槽,由 actor 线程真正下 ioctl |
| 控制 | nudge_child_redraw_after_handoff | 故意抖一下尺寸,骗子进程重画 |
| 生命周期 | begin_handoff / duplicate_for_handoff / rollback_handoff / release_after_commit | 交接 fd 的四步 |
| 生命周期 | shutdown | 关闸,并拒绝后续用户输入 |
注意一个设计细节:resize 不走消息队列,而是写进一个共享槽(SharedPtyControls,src/pty/actor/unix.rs:59-64),字段是 Option<PtyResizeRequest>。原因很直白——队列会满、会积压,而"窗口现在多大"这件事只有最新值有意义,旧的 resize 请求丢掉反而是对的。写完槽再敲一下唤醒管道(wake_actor,src/pty/actor/unix.rs:344)。
2.3 主循环长什么样
PtyIoActorRunner::run(src/pty/actor/unix.rs:441)是一个手写的 poll 循环,不用 tokio 的异步 I/O,因为它要同时盯 PTY fd 和一根唤醒管道:
┌─▶ ① 收命令(control 优先于 data)──── Shutdown? ──▶ 退出
│ │
│ ▼
│ ② 应用共享槽:resize / nudge / 待发应答
│ │
│ ▼
│ ③ 有待写数据就先冲一次
│ │
│ ▼
│ ④ poll(PTY fd, 唤醒管道, 超时 1000ms)
│ │
│ ├─ 唤醒管道就绪 ──▶ 排干它,回到 ①(说明有人递了新工作)
│ ├─ PTY 可读 ──▶ read_once():读 8KB → 调 on_read 回调
│ └─ PTY 可写 ──▶ 继续冲待写队列
│ │
└────────┘
怎么读这张图: ①②③是"先把手头的活干完",④才是"睡下去等事件"。1000ms 的超时只是兜底——注释明确写了它是"漏掉唤醒时的 fallback",正常响应靠 PTY 就绪和唤醒管道驱动(src/pty/actor/unix.rs:15-18)。
fd 在 spawn 时就被设成 close-on-exec + 非阻塞(src/pty/actor/unix.rs:362-363,调 fd::set_cloexec / fd::set_nonblocking),所以 read_once 里 WouldBlock 和 Interrupted 都当"没事发生"处理,只有 Ok(0) 和真错误才算 PTY 关闭(src/pty/actor/unix.rs:676-685)。
2.4 读到字节之后:on_read 回调
actor 不认识终端,它只是把读到的切片交给一个回调 on_read: Box<dyn FnMut(&[u8]) -> PtyReadResult + Send>(src/pty/actor/unix.rs:42)。回调返回的 PtyReadResult 只有一个字段:terminal_responses: Vec<Bytes>——即"解析这段字节的过程中,终端需要回给子进程的应答"(src/pty/actor/unix.rs:29-31)。
这个回调在 src/pane.rs:1932 那一段闭包里组装,它做了六件事:
content_seq自增(奇数=正在改,偶数=改完了,给读方做撕裂检测,见 §4.5);- 调
terminal.process_pty_bytes(...)把字节喂给 VT 引擎; - 把响铃、检测序号、渲染脏标记发出去;
- 把 OSC 上报的 cwd 发成事件;
- 把 OSC 52 剪贴板写入发成事件;
- 返回
PtyReadResult { terminal_responses }。
顺序锁的存在意义: read_once 在调回调时会先拿 response_order 锁(src/pty/actor/unix.rs:687-690),write_terminal_response 也拿同一把锁(src/pty/actor/unix.rs:162-166)。这保证"解析 A 段字节产生的应答"一定排在"解析 B 段字节产生的应答"前面——终端应答一旦乱序,子进程那边的状态机就会错乱。
2.5 Windows 是另一套实现
src/pty/actor.rs 用 #[cfg] 把两套实现分开:Unix 在子模块 unix(src/pty/actor.rs:1-5),Windows 是同文件内的 mod windows(src/pty/actor.rs:8)。两边导出同名类型,上层代码不用改。
| 维度 | Unix(actor/unix.rs) | Windows(actor.rs 内联模块) |
|---|---|---|
| 持有什么 | master_fd: OwnedFd(裸 fd) | master: Box<dyn MasterPty + Send> |
| 并发模型 | 1 个线程 + poll() | 4 个线程:writer / input / reader / control |
| 唤醒机制 | 自建 wake pipe | 各线程各自阻塞在自己的 channel 上 |
| resize | ioctl(TIOCSWINSZ)(src/pty/fd.rs:220) | master.resize(PtySize{..}) |
| handoff | 支持(begin_handoff 等四个方法) | 不支持(整组方法不存在) |
之所以 Unix 要绕开 portable-pty 自己拿 fd:spawn_with_portable_pty(src/pty/backend/unix.rs:12)开完 pty 后立刻 dup 一份 cloexec fd 给 actor,然后把 portable-pty 的 pair 整个 drop 掉(src/pty/backend/unix.rs:26-36)。仓库里有专门的测试盯着这件事:portable_pty_setup_leaves_one_parent_pty_fd(src/pty/backend/unix.rs:73)断言父进程里只剩一个 pty fd。多余的 fd 会让"子进程退出了但 PTY 不 EOF"这种幽灵问题出现。
2.6 为什么要给 portable-pty 打补丁
Cargo.toml:34 固定 portable-pty = "=0.9.0",然后 Cargo.toml:50-51 用 [patch.crates-io] 整个替换成 vendor/portable-pty。两个补丁都跟 Windows 有关,理由记在 vendor/portable-pty.patches.md:
| 补丁 | 上游行为 | herdr 为什么不能接受 |
|---|---|---|
| 0001 控制 ConPTY 加载 | 按 DLL 搜索路径去探测裸 conpty.dll | 等于允许从 PATH 加载别人的 DLL。herdr 改成:随包附一份钉死版本的 Microsoft ConPTY,校验 DLL 与 host 的哈希、拒绝重解析点,再用绝对路径加载并把依赖搜索限制在该目录和 System32 |
| 0002 暴露 Windows 原始命令尾 | 命令一律按 argv 表示,ArgvQuote 会转义内嵌引号 | herdr 需要 cmd.exe /d /c <用户原样命令>,转义会改变 cmd.exe 的解析结果 |
这份 patches.md 不是随手写的文档:每条都必须写清为什么、对应 issue、基线版本、改了哪些文件、怎么验证、什么条件下可以删掉。just check 里跑的维护脚本会验证补丁文件都在索引里、且能对着 vendored 树反向 apply(scripts/test_vendor_portable_pty.py,由 check recipe 调起,justfile:46-47;just test 在 justfile:6 跑的是同一批脚本)。这是"vendor 了就得管住"的工程纪律。