02 · 启动与运行模式¶
Herdr 只有一个主二进制,却能充当后台服务、前台 app、远程桥、API CLI、更新器和 Live Handoff 的替换进程。启动代码的职责不是简单“解析参数然后 run”,而是先确定这一进程在整个拓扑中的角色。
2.1 从表象到问题¶
默认执行 herdr 时,用户期待立即看到 UI;架构却希望 PTY 由不随 UI 退出的服务器拥有。启动层要同时满足:
- 第一次运行时自动拉起 daemon;
- 已有兼容 daemon 时直接附着,启动必须快;
- daemon 版本不兼容时给出可操作的升级路径;
- API 子命令不能初始化终端 UI;
- SSH 远程模式需要先建立 bridge;
- 更新和 handoff 必须在普通 auto-detect 之前截获;
- 测试、排障和受限环境仍可选择
--no-session单进程模式。
这就是 src/main.rs 看起来比典型 TUI 入口复杂的原因。
2.2 顶层决策树¶
flowchart TD
Start["process start"] --> Utf8["读取 UTF-8 args"]
Utf8 --> Session["配置 session namespace"]
Session --> Nested{"HERDR_ENV 已存在?"}
Nested -->|"是且未允许 nested"| Refuse["拒绝嵌套启动"]
Nested -->|"否"| Remote{"显式 remote / bridge?"}
Remote -->|是| RemoteFlow["remote dispatch"]
Remote -->|否| Cli{"CLI/API 子命令?"}
Cli -->|是| CliFlow["cli::maybe_run"]
Cli -->|否| Explicit{"server / client / update / handoff?"}
Explicit -->|是| ExplicitFlow["对应专用入口"]
Explicit -->|否| NoSession{"--no-session?"}
NoSession -->|是| Mono["单进程 App::run"]
NoSession -->|否| Auto["auto_detect_launch"]
Auto --> Probe{"兼容 server 已监听?"}
Probe -->|是| Attach["启动瘦客户端"]
Probe -->|否| Spawn["分离启动 daemon"]
Spawn --> Wait["等待 client socket ready"]
Wait --> Attach
这是等价的控制流摘要,不是 main.rs 的逐行复制。源码入口见 main()。
2.3 为什么先做轻量参数预处理¶
启动前几个分支会直接影响数据目录和 socket 位置,因此不能等完整 app 初始化之后再决定。
Session namespace 必须先确定¶
命名 session 会改变:
- session snapshot 路径;
- API socket 路径;
- client socket 路径;
- 日志和 handoff 协调路径。
src/session.rs 先解析并配置进程级 session。默认 session 直接使用配置目录;命名 session 放进 sessions/<name>/。名称使用严格的 ASCII 白名单,原因不是审美,而是它会参与文件系统和 socket 地址构造。
Remote 参数必须在普通 CLI 前识别¶
远程 app 表面仍是 herdr,但本地进程需要建立 SSH、发现远端平台和二进制、启动远端 server,再把本地客户端接到转发通道。若先让普通 CLI 消耗参数,会失去这一层拓扑信息。
子命令应短路 UI 初始化¶
cli::maybe_run 处理 API 风格命令时,应尽量只连接 API socket。若为了执行 herdr pane list 先初始化 terminal backend、raw mode 和 App,会引入闪屏、TTY 依赖和错误恢复负担。
2.4 默认路径:自动发现服务¶
默认分支进入 server::autodetect::auto_detect_launch。它实现的是一个本地 service discovery 协议:
sequenceDiagram
participant P as 前台 herdr
participant Sock as session client socket
participant D as daemon process
P->>Sock: probe / handshake
alt server 存在且协议兼容
Sock-->>P: compatible
P->>Sock: attach app client
else socket 不存在或 server 不可用
P->>D: detached spawn --server
loop bounded readiness wait
P->>Sock: probe
Sock-->>P: not ready / ready
end
P->>Sock: attach app client
else server 存在但协议不兼容
Sock-->>P: protocol mismatch
P-->>P: 显示升级/重启指引
end
几个细节决定体验和可靠性。
使用 socket readiness,而不是固定 sleep¶
后台进程 fork/spawn 成功只代表 OS 接受了请求,不代表 server 已完成状态恢复、socket bind 和监听。前台必须探测真实服务端点,并设置有界超时。
daemon 的初始 cwd 有语义¶
首次创建 workspace 时需要继承用户启动命令的目录。自动发现层把当前 cwd 传给新 daemon,而不是让分离进程默认为 / 或配置目录。
版本检查是协议检查,不只是字符串检查¶
客户端和服务端可执行文件版本不同,并不必然不能连接;真正决定 wire compatibility 的是 protocol version 和 capability negotiation。反过来,版本号接近也不能替代协议握手。
2.5 显式运行模式¶
| 模式 | 进程角色 | 是否拥有 PTY | 典型用途 |
|---|---|---|---|
| 默认 | auto-detect 后的 app client | 否 | 日常交互 |
--server |
headless daemon | 是 | 自动拉起或调试服务 |
| 显式 client | app/terminal client | 否 | 测试、重连、观察 |
--no-session |
monolithic App | 是 | 兼容、隔离排障、特定测试 |
| remote | SSH orchestration + local client | 远端 server 拥有 | 跨主机工作区 |
| bridge | stdio/socket 桥 | 否 | SSH 通道内部实现 |
| update | 下载/验证/替换协调者 | 间接触发 handoff | 自更新 |
| handoff replacement | 新 server | 接管旧 PTY | 无损升级 |
| API CLI | 短命 JSON API client | 否 | 自动化、脚本 |
这些角色共享一个二进制有实际好处:远端只需安装一个 artifact,handoff 可以 exec 同一发布物,CLI 与 server 的 schema/type 更容易保持一致。代价是 main 分流必须非常明确,不能让某一角色意外初始化另一角色的资源。
2.6 单进程兼容路径¶
--no-session 绕过默认后台服务,直接:
- 配置 API socket;
- 创建 Tokio runtime;
- 初始化 ratatui/终端后端;
- 构造
App; - 进入
App::run; - 退出时恢复 terminal mode。
它不是一个可以忽略的“旧代码开关”。单进程路径仍提供三个价值:
- 把 server/client transport 排除在故障范围外;
- 支持某些没有稳定本地 IPC 的约束环境;
- 保留直接 TUI 测试和渐进迁移的基线。
但它也造成维护成本:核心行为必须在 App::run 和 HeadlessServer::run 间保持语义一致。后续章节会反复看到为两条路径抽取共享 action、state 和 renderer 的努力。
2.7 嵌套启动保护¶
Herdr 管理的 shell 会带有环境标记 HERDR_ENV=1。在其中再次运行 Herdr,可能造成:
- 内层实例把外层 shell 当作自己的 Agent/terminal;
- socket/session namespace 意外复用;
- keybinding 与 alternate-screen 层层嵌套;
- 退出和信号传播变得不可预测。
因此默认检测到该环境会拒绝嵌套,只有显式实验开关才允许。这个保护属于拓扑不变量:正常模型是一层控制平面拥有多终端,不是控制平面递归套控制平面。
2.8 初始化顺序为什么重要¶
可以把资源分成三组:
| 组 | 例子 | 失败后的要求 |
|---|---|---|
| 纯配置 | args、session name、paths | 直接报错,不应改变终端 |
| 外部协调 | socket probe、spawn、SSH | 有界等待,清理临时端点 |
| 交互资源 | raw mode、alternate screen、runtime | 必须用 guard/退出路径恢复 |
安全顺序是先完成纯验证和角色选择,再创建外部协调资源,最后才改变用户终端。反向执行会让一个普通参数错误把 shell 留在 raw mode。
2.9 常见失败场景¶
陈旧 socket¶
socket 文件存在不代表有活服务。探测必须尝试连接/握手,并区分 permission error、connection refused 和协议不兼容;只有确定无人监听时才可清理陈旧端点。
daemon 启动超时¶
前台不能无限等。错误需要保留 daemon 日志位置和可复跑的显式 server 命令,否则自动化体验会变成无法诊断的“连接失败”。
当前可执行文件来自 shim¶
远程和更新路径不能盲目把 current_exe 当作可复制发布物。mise 等 shim 只是启动代理;Herdr 的远端发现/更新代码专门识别这类情况,后面第 13、14 章详解。
协议不兼容¶
不能偷偷拉起第二个 server 抢同一 session。正确行为是保留现有所有权,显式提示 stop/update/handoff 方案。
2.10 源码阅读锚点¶
| 问题 | 入口 |
|---|---|
| 顶层模式分流 | main() |
| 自动发现/拉起 | src/server/autodetect.rs |
| session 路径 | src/session.rs |
| headless server 入口 | run_server |
| 单进程循环 | App::run |
| remote 分流 | src/remote/ |
| updater | src/update/ |
2.11 可迁移的设计经验¶
- 单二进制多角色时,先做角色选择,再做重量初始化。
- readiness 应验证真实服务能力,而不是等待一个经验时间。
- 版本兼容要落到协议/capability,不要只比较 semver。
- session namespace 是隔离边界,必须先于所有路径和 socket 计算。
- 保留诊断用单进程路径有价值,但要正视双循环语义漂移成本。
下一章进入默认路径的核心:HeadlessServer 如何拥有状态、同时服务多个客户端,并把一套 TUI 转换成可协商的帧流。