13 · 连接监督、快照与本地状态
单一重试所有者、环境租约、离线感知和快照加增量如何组成跨端运行时。
客户端最难的不是 new WebSocket()
Web 和 Mobile 需要连接多个执行环境:本机、局域网、T3 Connect、Tailscale,甚至桌面管理的 SSH/WSL。 每个环境都有 endpoint、凭据、连接状态、快照缓存与正在运行的订阅。真正的问题是所有权:
- 谁可以发起重连?
- 页面切换时连接是否关闭?
- 网络离线算一次失败吗?
- App 从后台唤醒时 probe 还是重连?
- 环境被删除时,哪些缓存必须一起销毁?
packages/client-runtime 把这些语义从 Web/Mobile UI 抽离。
四层模型
Catalog / resolver
Catalog 保存已知环境描述;resolver 根据 access/launch method 解析实际 endpoint 和 credential。环境身份与 endpoint 分开, 所以同一环境可以从直连切到 relay/tailscale,而不丢失 project/thread cache identity。
Registry
connection/registry.ts
拥有每个 environment 的 scope、supervisor 和状态。调用者获取 lease,而不是自行创建连接;最后一个租约释放或环境删除时,
registry 可以关闭 scope 并清除 environment-owned caches。
Supervisor
connection/supervisor.ts
是唯一重试所有者。其他层只报告一次连接成功/失败,不各自套 retry。
RPC session factory
rpc/session.ts
完成一次 WebSocket attempt:打开 socket、构造 Effect RPC client、读取初始 config、执行 probe,并提供 closed signal。
它不决定何时再次尝试。
为什么重试只能有一个所有者
若 WebSocket factory、RPC client、React hook 和页面 error boundary 都重试,一个断网会产生指数倍连接,退避相互重置,
状态在 connecting/disconnected 间闪烁。T3 Code 把策略全部放入 supervisor:
3s → 4s → 8s → 16s → 16s → ...本快照的关键时间参数:
- 建立/probe 超时约 15 秒;
- 移动端特定 probe 窗口约 3 秒;
- 连续稳定约 30 秒后重置 backoff;
- 退避上限约 16 秒,之后持续尝试而非永久放弃。
数字会演化,模式更重要:短暂成功不能立刻清零 attempt,否则一个每 5 秒掉线的 endpoint 会永远以最高频重试。
离线不消耗 attempt
系统网络状态明确 offline 时,连接失败不是 endpoint 健康信号。Supervisor 等待 online wakeup,不增加 backoff attempt; 否则用户坐十分钟地铁后恢复网络,会因为“累计失败”被迫再等很久。
Wakeup 不是一律重连
前台恢复、网络恢复、凭据变化、endpoint 变化和手动刷新含义不同:
- 已连接且只是 App 回到前台:先 probe,避免无意义打断运行中的 stream;
- 网络从 offline 恢复:若旧 socket 已死则立即重连;
- credential/endpoint 变化:关闭旧 session,用新解析结果重连;
- environment 删除:停止 supervisor,不再重试。
connection/wakeups.ts 将来源规范化,supervisor 仍是最终决策者。
Snapshot + event reducer
客户端状态包在 state/ 下按领域拆分:thread shell/detail、projects、sessions、VCS、terminal、preview、source control 等。
典型同步流程:
- HTTP/RPC snapshot loader 取得基线和 sequence;
- atom/store 写入 environment namespace;
- shell/event subscription 从 sequence 继续;
- reducer 幂等应用事件,推进 cursor;
- gap 过大或 reducer 不满足 invariant 时重拉 snapshot。
为什么有些快照走 HTTP?大对象/分页读取未必适合绑在持续 RPC stream 上,而且 HTTP 可以独立做认证、缓存与取消。 重要的是两条 transport 共享 environment auth 和 sequence 语义。
Environment-owned cache
缓存键必须包含 environment identity。不同 Server 的 thread/project ID 即使碰巧相同,也不能共享 atom。 Registry scope 关闭时要清除:
- RPC client 与订阅;
- thread/project snapshots;
- pending queries 与 errors;
- environment-specific credential/endpoint derivation;
- terminal/session handles。
局部纯 UI 状态可以留在应用层,但任何包含 Server 事实的 cache 都要随 environment 生命周期销毁。
Presentation state 与 transport state 分开
“正在连接”“离线”“需要认证”“权限不足”“Server 不兼容”对用户是不同界面。connection/presentation.ts 从底层 model 派生可展示状态,
避免每个页面重新解释 error。Transport error 的 safe logging 还会剥离敏感信息,不把 bearer/ticket 写入诊断。
故障矩阵
| 场景 | 错误所有者 | 恢复 |
|---|---|---|
| socket 建立超时 | session attempt | supervisor backoff |
| 系统离线 | connectivity | 等 online,不消耗 attempt |
| ticket 过期 | authorization/runtime | 刷新/重新授权后 wakeup |
| scope 不足 | RPC operation | 不重连;提示权限 |
| 快照与事件 gap 过大 | state sync | 丢弃局部基线并重拉 |
| 环境被删除 | registry | 关闭 scope、清 cache、不重试 |
| App 前台恢复 | wakeup | probe,必要时重连 |
可迁移经验
- 一次连接 attempt 与重试策略必须分层,且只有 supervisor 重试。
- Offline 是外部条件,不是失败次数;稳定窗口后再重置退避。
- 用租约表达“还有谁需要连接”,不要让 React component mount 直接拥有 socket。
- 环境身份、endpoint 和 access method 分开,才能安全切换传输路径。
- 快照 + cursor 增量必须提供 gap 重建,不要把 reducer 当永远不会丢事件。