T3 Code 服务器后台服务更新机制解析:launcher 仲裁、试运行提交边界与 SQLite 回滚
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
导读
本文以 docs/internals/server-updates.md 为骨架,结合 T3 Code 仓库中 serviceLauncher.ts、serviceProtocol.ts、selfUpdate.ts 等源码,系统讲解 T3 Code 后台服务(background service)的更新架构:为什么更新只能由 stable launcher 仲裁、试运行(trial)为何必须越过"提交边界"才允许对外就绪、SQLite 三文件快照如何让迁移可回滚、客户端如何通过 update ID 区分"替换成功"与"回滚"。读完你将掌握整套服务更新的状态机、IPC 协议与崩溃恢复规则,可直接对照源码逐行验证。
架构总览:谁有资格更新服务
T3 Code 的后台服务并非由服务进程自己更新自己。从源码看,整个更新流程围绕一个常驻的stable launcher(稳定启动器)展开,其完整实现位于 apps/server/src/serviceLauncher.ts:
- launcher 是被 systemd(Linux)或 launchd(macOS)选中的服务运行时(service runtime)的唯一持有者,也是服务持久状态(durable service state)的唯一写入方;
- 服务子进程(server child)只通过继承的 IPC 通道请求更新,永不重写自己的服务定义(service definition),也不选择自己的替代者;
- 本地服务命令(如
t3 service系列命令)只允许在服务停止期间替换 launcher 与状态文件; - 前台 CLI 进程不进行自我更新(foreground CLI processes do not self-update)。
Launcher 之所以必须"稳定",注释写得很清楚:它要跨服务版本存活("keep working across server versions"),因此 serviceLauncher.ts 只用 Node 内置模块(node:child_process、node:fs、node:crypto等),不依赖任何 Effect 运行时——它是整个可执行体中唯一不能假设"其余部分可加载"的组件。
Launcher 的入口要求环境变量T3CODE_HOME,读取<baseDir>/runtime/service-state.json(由SERVICE_STATE_FILE常量定义,见 serviceProtocol.ts)中的ServiceState后进入运行循环;状态文件无效时直接抛错,而不是猜测该启动哪个运行时。
运行时目录与"精确版本安装"
服务更新针对的是**精确版本(exact version)**的不可变运行时。每个目标版本以解压后的 release 归档形式固定在:
<baseDir>/runtime/versions/<version>/ ├── t3 # 可执行文件(Windows 为 t3.exe) └── .install-complete # 安装完成哨兵文件,内容为该版本号路径构造见 serviceLauncher.ts 中的runtimePaths,安装事务则集中在 pinnedRuntime.ts:
- 精确版本安装使重启不依赖 npm 缓存驱逐(cache eviction)或漂移的发布 tag;
- 安装与预检(preflight)都在暂存(staging)阶段完成,之后才发布不可变运行时;
- 哨兵文件只在解压与校验成功后写入——仅检查可执行文件存在不够,因为 tar 会先写出可执行文件再写出原生包,被中断的安装可能留下"看似完整实则损坏"的目录树(pinnedRuntime.ts);
- 安装全程加信号量串行化(
pinnedRuntimeInstallLock),超时上限为 10 分钟。
runtimeExists校验(serviceLauncher.ts)同时要求入口文件存在且哨兵内容与版本号一致,两者任一失败都视为该运行时缺失或不完整。
预检:为什么协议版本决定能否升级
文档强调:预检检查 launcher 协议,因为需要新回滚保证的目标版本不能安全地运行在旧 launcher 之下。升级 launcher 本身需要一次本地服务更新(local service update)。
预检实现在 servicePreflight.ts:目标运行时以__service-preflight子命令被拉起(见 selfUpdate.ts):
t3 __service-preflight --database-path <dbPath> --launcher-protocol <protocol>- 若传入的 launcher 协议不等于当前
SERVICE_LAUNCHER_PROTOCOL,预检返回blocked,原因固定为:"This release requires a newer T3 Code service launcher. Update it on the server machine."; - 只有
status === "ready"且返回版本与目标版本一致才算通过;返回的 JSON 需能被decodeServicePreflightResult完整解码,否则判定暂存失败。
预检是暂存阶段(installing之前的downloading阶段)的一部分,超时 30 秒。它把"目标运行时是否具备新协议要求的回滚能力"这个问题前置到切流之前解决。
提交边界(Commit Boundary):试运行的三阶段状态机
文档将更新分为三个核心阶段:pending → trial(prepared)→ committed / rolled-back / failed。对应状态机记录在 serviceProtocol.ts 的ServiceState与ServiceUpdateRecord中。
1. 记录 pending 后才确认
子进程通过 IPC 发送request-update(携带targetVersion与dbPath),launcher 在 serviceLauncher.ts 的#handleUpdateRequest中做完整校验:
| 校验项 | 拒绝原因(reason) |
|---|---|
| 请求方必须是 active 角色 | Only the active server can request an update. |
| 请求方版本必须等于状态中的 activeVersion | The requesting server is not the selected active version. |
| 不能已有 pending 更新 | Another server update is already pending. |
| 目标必须是精确 SemVer(禁止 dist-tag/范围/路径) | The requested target is not an exact version. |
| 目标必须更新 | Remote updates must select a newer server version. |
| 数据库路径必须绝对路径 | The requested database path is not absolute. |
| 目标运行时必须已完整安装 | The requested target runtime is missing or incomplete. |
通过校验后,launcher先持久化写盘ServiceState { update: pending },再回复update-accepted(携带随机生成的updateId)。也就是说:确认发生在持久化之后,防止确认后崩溃导致"以为已受理、实际无记录"。精确版本的正则与比较器见 serviceProtocol.ts 与compareExactServiceVersions(BigInt 实现,构建元数据被忽略)。
随后 launcher 等待HANDOFF_DELAY_MS = 2000ms,再终止旧子进程并启动试运行(#beginTrial→#startTrial)。
2. 试运行必须越过激活门(activation gate)
文档给出了严格的就绪定义:试运行必须完成迁移、获取依赖、绑定 HTTP、把所有长驻根(long-running root)停在激活门(activation gate),之后才能上报prepared。仅靠"监听器已经起来"并不能证明运行时已具备提交条件——监听器可能在关键获取完成之前就开始接受流量。
prepared上报携带updateId(serviceProtocol.ts)。Launcher 收到后校验:角色必须是 trial、状态仍为 pending、updateId 匹配、目标版本匹配(serviceLauncher.ts)。一旦满足,launcher:
- 清除
PREPARED_TIMEOUT_MS = 120_000ms的试运行超时定时器; - 持久化提交:
activeVersion更新为目标版本、update.status置为committed,写盘; - 丢弃数据库快照;
- 回复
committed(携带 updateId); - 此后子进程才允许释放激活门、接受命令、发布 ready。
顺序不可颠倒:先写盘提交,再放行子进程。试运行失败或超时(prepared-timeout)则回到#returnToPrevious,把状态置为rolled-back/failed并重启旧版本;提交之后目标版本成为权威版本,此后按服务管理器(systemd/launchd)的常规重启策略运行。
3. 状态写入的持久化方式
所有运行时状态转换都使用同目录替换(same-directory replacement):写入临时文件 →fsync文件 →rename覆盖 →fsync目录(serviceLauncher.ts)。syncDirectory对 Windows 做了兼容(NTFS 自行日志化 rename,目录 fsync 会以 EPERM 失败,被吞掉)。状态文件损坏或协议不匹配时,readServiceState直接抛错阻止启动,绝不猜测该启动哪个版本。
数据库回滚:SQLite 三文件快照
文档的关键点:旧子进程退出后,launcher 对 SQLite 的主文件、WAL、共享内存文件(shared-memory file)做快照,从而让试运行的迁移在没有 down migration的情况下也可逆。
实现要点(serviceLauncher.ts):
- 三件套由
DB_FILE_SUFFIXES = ["", "-wal", "-shm"]定义,备份到<baseDir>/runtime/db-backup/<updateId>/; - 快照每个 update 只做一次:
backupDatabaseOnce若发现备份目录已存在则直接跳过,因为重启后的 launcher 可能面对的是同一试运行进程上次尝试留下的数据库写入,覆盖它可能把失败尝试的变更混进快照; - 快照先写入
.staging临时目录、逐文件fsync后整体rename并fsync父目录,保证快照原子可见; - 回滚前先写持久化恢复标记(restore marker)
.restore-pending,再覆盖三个文件并逐个 fsync——若恢复中途崩溃,重启后 launcher 在#recover里检测到标记就优先把恢复做完,任何版本启动前恢复都必须先收尾(对应 reasonrollback-interrupted); - 备份目录要保留到提交(commit)为止,或直到恢复与终态回滚记录都已持久化;提交成功后调用
discardDatabaseBackup清理。
回滚的完整路径#returnToPrevious(serviceLauncher.ts):终止试运行 → 恢复数据库备份 → 持久化rolled-back/failed终态(activeVersion回到 fromVersion)→ 丢弃备份 → 启动旧版本为 active。
边界说明:附件(attachments)及其他 SQLite 之外的文件不在此回滚边界内。文档明确将快照语义限定为"让 trial migrations 可逆",而非全量文件系统回滚。
崩溃恢复矩阵(#recover)
每次 launcher 启动(包括更新中途崩溃后)都走#recover(serviceLauncher.ts):
| 启动时观察到的状态 | 恢复动作 |
|---|---|
| 状态中无 pending 更新 | 丢弃残留备份,直接以 activeVersion 启动 |
pending 且存在.restore-pending标记 | 先完成恢复,置failed(reason:rollback-interrupted),回滚到旧版本 |
| pending 且目标运行时缺失 | 置failed(reason:target-runtime-missing),回滚到旧版本 |
| pending 且目标运行时完整 | 重新进入试运行(#startTrial) |
启动时还会清除.service-stopping停止标记:该标记是 launcher 在显式停止前同步写入的,用于让子进程区分"服务要关停"与"launcher 即将启动我的替代者"这两种场景(serviceProtocol.ts);新 launcher 启动意味着服务重新运行,旧的停止标记必须作废。
客户端确认:update ID 而非"重连即成功"
一个被接受的更新仍是 pending。文档的提醒很关键:重连本身无法区分"替换成功"与"回滚"。因此:
- 客户端把 launcher 的 update ID 与重连后的 ready 事件关联起来,再检查 outcome(committed / rolled-back / failed)与目标版本;
- 老版本服务器没有 update ID,则保留仅按版本号关联(version-only correlation)的降级路径。
协议层面对此提供了完整支撑:ServiceLauncherChildMessage中的prepared与ServiceLauncherParentMessage中的update-accepted/committed/update-rejected全部携带updateId(serviceProtocol.ts)。子进程侧的ServiceLauncherClient(serviceLauncherClient.ts)在继承的 IPC 上做请求-应答交换,30 秒无响应判定timeout;prepareTrial仅接受committed且 updateId 与 pending 匹配的回复,否则视为不可能回复。
启动上下文通过环境变量T3_SERVICE_LAUNCHER_CONTEXT注入子进程(SERVICE_LAUNCHER_CONTEXT_ENV),decodeServiceLauncherContext会强校验childVersion与当前 package 版本一致,版本不符直接判定version-mismatch——保证子进程不会在错误的身份下启动。
拒绝原因即错误面
update-rejected携带的 reason 会原样透传:ServiceLauncherRejectedError的 message 就是 launcher 的拒绝理由(serviceLauncherClient.ts)。未安装后台服务时(capability 为null),远端更新会直接失败并提示:Remote updates require the T3 Code background service. Run t3 service install on the server machine.(见 selfUpdate.ts)。
桌面端(Desktop)更新:独立的两阶段交接
桌面更新与 boot-service 更新走完全不同的两阶段交接,原因很朴素:安装桌面应用会停掉它内置的 bundled backend,若后端先关机,唯一成功的 RPC 结果可能丢失。
实现位于 DesktopAppUpdate.ts 与 selfUpdate.ts 的withRunningThreadContinuation:
- 准备阶段:后端(被桌面 app 启动、通过桌面遥测控制 FD 通信)调用 desktop app 的更新流程,等到
ready-to-install状态上报后,返回desktopUpdateToken(即本次请求的requestId),此时连接仍然存活(DesktopAppUpdate.ts); - 提交阶段:客户端只有在收到 token 之后才调用
commitDesktopUpdate(requestId)提交该 token——否则后端关机可能恰好发生在"唯一的成功 RPC 结果"之前,导致结果丢失; - 客户端随后必须在重连后观察 prepared 版本,确认安装生效;
- 若安装失败,桌面端会重启被停止的后端,并针对同一 token 回放失败(replay the failure for the same token)——token 以
HashSet形式保存在desktopContinuationTokens中,未提交的 token 在失败时会重新加回集合,保证"失败的更新可重试且幂等"。
commitDesktopUpdate还通过handoffAccepted回调与不可中断块(Effect.uninterruptible)保证提交请求一旦发出就不会被中断取消;desktopUpdates订阅先于请求发出("Subscribe before sending the request so a fast first report cannot be missed"),防止快速上报被错过。
关键常量与配置速查
| 常量 | 值 | 出处 |
|---|---|---|
SERVICE_LAUNCHER_PROTOCOL | 2(协议 2 支持试运行前快照 SQLite) | serviceProtocol.ts |
SERVICE_STATE_FILE | service-state.json | serviceProtocol.ts |
SERVICE_STOP_MARKER_FILE | .service-stopping | serviceProtocol.ts |
HANDOFF_DELAY_MS | 2_000 | serviceLauncher.ts |
PREPARED_TIMEOUT_MS | 120_000 | serviceLauncher.ts |
TERMINATE_GRACE_MS | 5_000(宽限后 SIGKILL) | serviceLauncher.ts |
| IPC 应答超时 | 30 秒 | serviceLauncherClient.ts |
| 暂存预检超时 | 30 秒 | selfUpdate.ts |
| 运行时安装超时 | 10 分钟 | pinnedRuntime.ts |
| 数据库快照件数 | 3(主文件、-wal、-shm) | serviceLauncher.ts |
| 恢复标记 | .restore-pending | serviceLauncher.ts |
总结
T3 Code 的服务更新以"launcher 是状态唯一写入方"为不可动摇的根基:精确版本暂存 + 协议预检解决"能不能升";pending 先持久化再确认 + 试运行越过激活门才提交解决"何时算成功";SQLite 三文件一次性快照 + 持久恢复标记解决"失败怎么退";update ID 关联解决"客户端怎么确认"。而桌面端用 token 两阶段交接解决"安装即断连"的最后一公里。这套设计的每一处都可在上述源码文件中逐行验证,也是理解t3 service命令族与后台服务可靠性的最佳入口。
【免费下载链接】t3code项目地址: https://gitcode.com/GitHub_Trending/t3/t3code
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考