跳转至

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 绕过默认后台服务,直接:

  1. 配置 API socket;
  2. 创建 Tokio runtime;
  3. 初始化 ratatui/终端后端;
  4. 构造 App;
  5. 进入 App::run;
  6. 退出时恢复 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 可迁移的设计经验

  1. 单二进制多角色时,先做角色选择,再做重量初始化。
  2. readiness 应验证真实服务能力,而不是等待一个经验时间。
  3. 版本兼容要落到协议/capability,不要只比较 semver。
  4. session namespace 是隔离边界,必须先于所有路径和 socket 计算。
  5. 保留诊断用单进程路径有价值,但要正视双循环语义漂移成本。

下一章进入默认路径的核心:HeadlessServer 如何拥有状态、同时服务多个客户端,并把一套 TUI 转换成可协商的帧流。