T3 Code 源码拆解
客户端

13 · 连接监督、快照与本地状态

单一重试所有者、环境租约、离线感知和快照加增量如何组成跨端运行时。

客户端最难的不是 new WebSocket()

Web 和 Mobile 需要连接多个执行环境:本机、局域网、T3 Connect、Tailscale,甚至桌面管理的 SSH/WSL。 每个环境都有 endpoint、凭据、连接状态、快照缓存与正在运行的订阅。真正的问题是所有权:

  • 谁可以发起重连?
  • 页面切换时连接是否关闭?
  • 网络离线算一次失败吗?
  • App 从后台唤醒时 probe 还是重连?
  • 环境被删除时,哪些缓存必须一起销毁?

packages/client-runtime 把这些语义从 Web/Mobile UI 抽离。

四层模型

客户端连接运行时
Environment Catalog Endpoint + Credential Resolver Environment Registry One Supervisor per environment RPC Session Factory One WebSocket attempt Probe + initial config Snapshot Loaders Environment-owned atoms / stores Event subscriptions Online / foreground wakeups

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; 否则用户坐十分钟地铁后恢复网络,会因为“累计失败”被迫再等很久。

Supervisor 简化状态机
session ready offline transport failure online wakeup socket closes foreground / endpoint changed stable 30s lease scope closes Idle Connecting Connected Waiting for online Backoff Probe or reconnect Reset 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 等。 典型同步流程:

  1. HTTP/RPC snapshot loader 取得基线和 sequence;
  2. atom/store 写入 environment namespace;
  3. shell/event subscription 从 sequence 继续;
  4. reducer 幂等应用事件,推进 cursor;
  5. 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 attemptsupervisor backoff
系统离线connectivity等 online,不消耗 attempt
ticket 过期authorization/runtime刷新/重新授权后 wakeup
scope 不足RPC operation不重连;提示权限
快照与事件 gap 过大state sync丢弃局部基线并重拉
环境被删除registry关闭 scope、清 cache、不重试
App 前台恢复wakeupprobe,必要时重连

可迁移经验

  • 一次连接 attempt 与重试策略必须分层,且只有 supervisor 重试。
  • Offline 是外部条件,不是失败次数;稳定窗口后再重置退避。
  • 用租约表达“还有谁需要连接”,不要让 React component mount 直接拥有 socket。
  • 环境身份、endpoint 和 access method 分开,才能安全切换传输路径。
  • 快照 + cursor 增量必须提供 gap 重建,不要把 reducer 当永远不会丢事件。

On this page