跳转至

03 · Headless Server 与瘦客户端

后台服务化不是把 App::run 搬进 daemon 那么简单。原来的 TUI 默认只有一个屏幕尺寸、一个焦点、一个输出设备;Server/Client 模型必须回答:两个客户端尺寸不同怎么办?谁能输入?慢客户端会不会拖住 PTY?断线重连怎样获得一致帧?

3.1 边界:服务器拥有事实,客户端拥有设备

默认架构的责任表如下:

服务器 客户端
AppState、workspace、tab、pane 本地 terminal backend
PTY、子进程、Ghostty VT 物理键盘、鼠标、IME
Agent 检测、Git/worktree 状态 窗口当前 rows/cols
持久化、API、plugins 本地 theme 和显示能力
视图计算、虚拟 frame 把 semantic/ANSI frame 画到设备
输入所有权与 client registry 断线/重连体验

这条边界使 UI 退出不再等于 PTY 退出,也让 SSH 远程只需传协议消息,不必把 Rust UI 状态复制到两端。

3.2 HeadlessServer 保存什么

HeadlessServer 是协调聚合体。它大致拥有:

  • AppState 与 terminal runtime registry;
  • client listener、API receiver、内部 AppEvent receiver;
  • 每个客户端的 transport、capability、尺寸、render baseline;
  • render signal、deadline、deferred action 和 shutdown 状态;
  • virtual terminal/backend 与 retained render plan;
  • EventHub、插件运行上下文、持久化节流信息。

这不是说所有业务都应该继续堆进一个巨型 server 文件。它说明在当前演进阶段,HeadlessServer 是单写者调度边界:许多并发生产者发消息,真正改变领域状态的动作回到这个循环串行执行。

3.3 主循环的八段工作

HeadlessServer::run 不是一个只等 socket 的 select。按逻辑可以拆成八段:

flowchart TD
    A["1. 消费 render signal / 同步标题"] --> B["2. 有界 drain 内部 AppEvent"]
    B --> C["3. 处理 API requests"]
    C --> D["4. accept 新 client"]
    D --> E["5. 处理 client/server events"]
    E --> F["6. 到期任务与 deferred actions"]
    F --> G["7. virtual render + stream"]
    G --> H{"现在还有立即工作?"}
    H -->|是| A
    H -->|否| I["8. tokio::select! 等待任一信号"]
    I --> A

为什么先主动 drain,再 select!

如果每轮只 select! 一个事件,PTY 大量输出时会产生高调度成本;如果把一个队列一次清空,其他来源又可能饥饿。Herdr 对内部事件做有界 drain:队列容量 256,每轮至多处理 64 条,再给 API、client 和 timer 机会。

这是一种“批量但公平”的事件循环:

throughput  <- 一轮处理多条
fairness    <- drain 上限
memory bound<- channel 容量
latency     <- 有立即工作就不睡眠

为什么 render 放在状态处理后

一轮中多个事件可能共同改变同一视图。先合并状态变化,再渲染一次,避免中间帧和重复工作。PTY 高频输出也只设置 coalesced render signal,而不是每批字节创建一帧任务。

3.4 Wire protocol 的层次

客户端协议定义在 src/protocol/wire.rs,固定快照的协议版本是 20。

flowchart TB
    TCP["Unix socket / SSH stdio bridge"] --> Len["4-byte little-endian length"]
    Len --> Msg["serialized ClientMessage / ServerMessage"]
    Msg --> Handshake["hello / version / capabilities"]
    Msg --> Control["input / resize / focus / takeover"]
    Msg --> Frame["semantic frame / ANSI bytes / graphics"]

外层 framing

每条消息前有 4 字节小端长度。解码前先验证上限:普通二进制消息约束在 2 MiB,图形相关帧允许更高的 32 MiB;剪贴板图片另有 16 MiB 限制。上限必须在分配大 buffer 之前检查,否则本地 socket 也能成为内存放大器。

消息层

ClientMessage 包含握手、输入、尺寸、焦点、控制权和 client lifecycle 等意图。

ServerMessage 返回握手结果、frame、标题、错误、退出/重连等服务端事实。

呈现层

服务器不只发送“整个屏幕的字符串”。协议可协商两种主要编码:

编码 内容 优点 代价
SemanticFrame cell、style、cursor、metadata 客户端可稳定重绘和 diff;语义清楚 序列化对象更多
TerminalAnsi 把差异编码成终端控制序列 对终端设备直接、带宽紧凑 依赖基线,恢复和能力差异更敏感

语义帧核心形状可从 CellData 与 FrameData 理解。

3.5 握手不是礼节,而是兼容契约

连接建立后的握手要对齐:

  • protocol version;
  • client role(app、直接 terminal、observer/control);
  • 支持的 render encoding;
  • terminal 尺寸、颜色/theme、键盘 profile;
  • 可能的重连/attach 目标。

服务端只有完成握手,才能把 socket 加入正常 client registry。否则一个旧客户端可能把新版枚举误解为另一消息,或声明了无法绘制的 frame 格式。

握手与 transport 代码集中在 src/server/client_transport.rs。

3.6 每个客户端都有自己的渲染基线

同一份 server state,不能只维护一个“上一帧”。原因是客户端:

  • 加入时间不同;
  • 尺寸不同;
  • encoding 不同;
  • 发送速度不同;
  • 可能错过某一帧并要求全量 repaint。

src/server/render_stream.rs 为每个客户端保存状态。

SemanticFrame 基线

保存上一份完整 FrameData,新帧相同则跳过。连接刚建立或尺寸/能力变化时发送完整帧。

TerminalAnsi 基线

BlitEncoder 根据上一帧生成最小 ANSI 操作,并维护 sequence / repaint pending。它更像一个复制状态机:下一份 diff 的含义依赖客户端确实收到了上一份。

最关键的不变量

只有 frame 成功进入该客户端的发送路径后,才能提交新的基线。

若先更新 baseline,再发现队列满而丢弃 frame,下一次 diff 会建立在客户端从未见过的屏幕上,最终产生永久花屏。正确顺序是:

render candidate
  -> encode against committed baseline
  -> enqueue/send succeeds?
       yes: commit baseline
       no : keep old baseline + mark repaint if needed

这条规则可以迁移到任何增量复制系统:游标、offset、checkpoint 都必须在下游确认接收后推进。

3.7 多客户端尺寸语义

真实 PTY 只有一个 rows/cols,而客户端可以有不同窗口大小。Herdr 采用“前台客户端决定共享终端尺寸”的模型:

  1. 最新取得前台/控制权的 app client 成为尺寸权威;
  2. server 根据它的布局结果 resize 共享 PTY;
  3. 非前台客户端仍可按自己的 viewport 计算一个呈现帧;
  4. 非前台计算不能反向 resize 真实 PTY。

这解释了为什么 compute_view 需要一个“是否允许 resize”的边界。否则只读 observer 打开一个 80×24 窗口,会把正在 200×60 工作的 Agent PTY 改小。

3.8 输入与控制权

多个观察者可以同时存在,但写入必须有明确所有者。

stateDiagram-v2
    [*] --> Observer
    Observer --> Controller: acquire / takeover
    Controller --> Observer: release
    Controller --> Disconnected: socket closes
    Disconnected --> Observer: another client remains
    Disconnected --> [*]: no clients, server stays alive

控制权改变不仅影响“接收谁的按键”,还影响:

  • active terminal size;
  • focus in/out 转发;
  • 本地 keybinding profile;
  • 输入 lease 和已按下键的清理;
  • title/theme 等前台客户端属性。

takeover 需要显式协议语义,不能简单用“最后一个发包的人”。否则 observer 的 resize 或网络重放会悄悄夺权。

3.9 慢客户端与背压

服务器的首要可靠性目标是保护 PTY 和全局状态,不能为一个慢客户端无限积压 frame。

典型策略是:

  • client writer 有界排队;
  • 中间 render 可以合并或触发 full repaint,而不是逐帧保真;
  • 控制消息与大 graphics frame 有明确上限;
  • transport 错误只移除该 client;
  • server 和 Agent 继续运行;
  • 重连客户端从完整基线恢复。

这基于一个重要语义:终端画面是最新状态流,不是审计日志。慢客户端需要最终看到最新完整画面,不必回放每个中间动画帧。

3.10 保留渲染与影响分类

服务器会把状态变化分为类似:

  • None:不影响帧;
  • Graphics:图形/局部呈现需要更新;
  • Full:布局、overlay 或其他条件要求完整计算。

retained render plan 使无关状态变化不必重绘所有 pane。隐藏终端仍要解析 PTY 字节和更新 Agent 检测,但可以跳过昂贵的呈现快照。

render_and_stream 是把虚拟 render、影响判断和客户端流连接起来的关键入口。

3.11 断线和失败边界

失败 应影响什么 不应影响什么
一个 client EOF 移除 client、释放其 lease server、其他 client、PTY
frame 队列满 丢/合并呈现并保留正确 baseline 领域状态
协议版本不兼容 拒绝该连接并说明版本 已有 session
graphics frame 超限 拒绝消息/连接 其他 transport
foreground 离开 选择新权威或进入无前台状态 子进程
server shutdown 有序持久化/关闭或 handoff 不能留下半绑定 socket

3.12 与单进程 App 的关系

两条路径共享领域动作、UI 计算、terminal runtime 和持久化,但设备边界不同:

monolithic:
  OS input -> App::run -> ratatui backend -> physical terminal

headless:
  ClientMessage -> HeadlessServer::run -> virtual backend
                -> FrameData/ANSI -> client physical terminal

这也是测试应关注“语义等价”而非实现相同的地方:同一用户动作在两种路径中应得到同样 workspace/pane/Agent 状态,但 transport 和呈现细节自然不同。

3.13 本章结论

Server/Client 重构的核心不是 IPC,而是重新定义所有权:

  1. server 是状态、PTY 和生命周期的唯一权威;
  2. client 提供设备能力、输入和当前 viewport;
  3. 每个 client 都有独立、只在发送成功后推进的 frame baseline;
  4. 前台权威控制共享 PTY 尺寸,observer 不能产生副作用;
  5. 慢呈现消费者必须被隔离,最新状态优先于中间帧完整回放。

下一章会拆开 server 内部最重要的一层:为什么 AppState、TerminalState、TerminalRuntime、PaneState 和 PaneRuntime 必须同时存在,以及三套 ID 分别解决什么问题。