桌面端连接组件在一周内连发两版。1.0.10 修了 WebSocket 端点未被消费导致的 17 分钟周期断线;1.0.11 处理紧随其后暴露的僵尸令牌问题与发版脚本漏洞。这两次发版既是问题的递进修复,也是工程实践上从被动补救走向主动设计的转折。
1.0.10:17 分钟的周期
用户反馈从五月中旬开始稳定出现:Connector 约每 17 分钟自动断线一次,断后立即重连。现象本身明确,但根因指向了一个被忽视的设计缺陷。
服务端在五月下旬已切换 WebSocket 入口地址。原来的端点经 CDN 反代,该 CDN 的 idle 超时配置在 4-9 分钟,时间不固定。一旦断开,客户端用相同的令牌重连,再次击中 idle 超时,形成周期。这本该不是问题——服务端在 /auto-pair 响应里已返回新的 WebSocket 地址,客户端只需消费这个字段切换端点即可。
但客户端代码从未读取过这个字段。ws_runner 始终硬拼 base_url 生成连接地址,App.tsx 只提取了 device_token,对 ws_endpoint 视而不见。结果是服务端返了新地址,客户端装作没看见,继续走旧路径。旧路径经 CDN,CDN idle 触发,17 分钟周期形成。
修复需要四处联动:
- 在 AppState 加一个
ws_endpoint字段,用 RwLock 包装以便运行时读取 set_device_token命令新增参数,收到服务端返的ws_endpoint时校验并持久化到 config.json- ws_runner 启动与每次重连前都从 RwLock 读取最新地址,不再拼装
- App.tsx 消费
ws_endpoint字段,传给 invoke
兼容性处理也做了:env 变量 CPA_WS_ENDPOINT 可强制指定(灰度用),服务端没返 ws_endpoint 时退化到旧路径拼装,config.json 校验失败时保留现有值不覆盖。
1.0.10 发版中的五个坑
代码改好了,但发版流程成了另一个故事。
坑 1:假 1.0.10
发版脚本在 Windows 构建机上只 scp 了 tauri.conf.json 这一个文件,没有同步整个仓源码。构建机的 git HEAD 还停在 1.0.8 的 commit,Cargo.toml workspace.package.version 还是 0.1.0。结果是 cargo build 出来的二进制是 1.0.8 代码,但 NSIS 打包时用了 tauri.conf.json 里的"1.0.10"字符串当文件名——实际上是 1.0.8 代码套上 1.0.10 的皮。
应急是用 git ls-files | tar over ssh 把真 1.0.10 源码同步过去,清掉 macOS metadata 的 ._* 文件(tar 直接打了这些,Windows 上 tauri-build 把 capabilities/._default.json 当 JSON 解析失败),重新 build。
1.0.11 的发版脚本改成了完整源码同步加 tar --exclude='._*' 和 Win 端 Remove-Item '._*' 双保险。
坑 2:ssh exit code 谜团
phase B 跑完整发版脚本时,step 3(Win build)完成了——NSIS bundle 和 minisign 签名都成功写出来了——但 ssh 仍然返 exit code 1,导致脚本静默退出,step 4-10 全跳过。
当时推测是 ssh ... | tail -10 触发 SIGPIPE,但这个推测被现场查证打脸了:tail 会完整消费 stdin,不会向上游发 SIGPIPE。真根因当时没查清楚,可能是 PowerShell 某个 cmdlet 设了非零 $LASTEXITCODE,也可能是 ssh 在大量 escape sequence 输出时本身返非零,也可能是脚本框架的 pipefail 组合出了问题。
1.0.11 用了一个兜底方案:脚本监测 build 成功的标志——Finished 1 bundle at: 和 Finished 1 updater signature at: 双命中才认为成功,忽略 ssh exit code。这个兜底已经把"ssh exit code 非零"这条问题路径变成了已知但可控的状态:即使 ssh 返错,只要 marker 出现了,就继续执行后续步骤。1.0.11 发版时 log 记录了这次抗性:「ssh exit=1 非零,但 build marker 命中,视为成功」。
坑 3:gitlink 边界
发版脚本 step 10 尝试 git add cpa-connector/.../tauri.conf.json,但因为 cpa-connector 是 gitlink(160000 mode)不是真 submodule,git 会静默跳过这条跨边界的路径。结果 tauri.conf.json 没被 add 进 root 仓的 commit。
这里需要在 cpa-connector 子仓内完成一次独立 commit(包含 Cargo.toml、Cargo.lock、package.json、tauri.conf.json),然后回到 root 仓 add 整个 gitlink 指针。1.0.11 的发版脚本改成了这个流程。
坑 4 和 5:参数漏传与版本号不同步
Task 9 重构了前端的 tryAutoPairOnce 这个 helper 时,body 从 { hostname, os, connector_version } 意外退化成了 {},导致服务端拿不到客户端版本号。同时 package.json 里的 version 字段还停留在 1.0.0,跟 Cargo.toml 的 1.0.9 完全脱节。
这两个都算是发版前的信息丢失,1.0.10 发版后才被指出。
1.0.10 的验证与回滚
1.0.10 在 5 月 27 日上午 10:34 开始滚动更新。测试机验证了 23 分钟无断线(vs 之前每 17 分钟必断),服务端日志开始出现 host=<直连 WS 域名> 的访问。4 小时窗口内,新 WS 入口命中 992 次,旧入口仍有 209566 次(因为 1.0.9 客户端的 OTA 是自然滚动,不是强推)。
但随后用户报了一个投诉,初看像是 1.0.10 的问题。经过排查发现是 openclaw-runtime 1.0.23 的一个独立 schema 问题,与 1.0.10 无关。这件事触发了一个 P0,但因为根因被快速定位到另一个模块,没有对 1.0.10 造成滚动阻断。
1.0.10 本身的回滚预案是改 apps/api/src/connector/updates.ts 的 RELEASE_CHAIN 数组,删掉 '1.0.10' 这一项,再重部署服务端。已升级的客户端不会自动回退(Tauri updater 不支持降级),但新检查的 1.0.9 用户就不会继续升级了。这一设计决策埋下了一个伏笔:一旦版本链里出现过某个版本,再想把它从升级路径上彻底抹掉就很困难,因为已升级的用户成了"污染源",他们可能带着该版本的各种遗留问题继续在线。这正是分阶段发版与步进升级设计要反复考量的权衡点。
1.0.11:两个小时内的闭环
1.0.10 发版后的第四个小时,某个客户端用失效的令牌反复击中 WebSocket 4401 拒绝,服务端 30 分钟内记录了 5638 次 reject,来自 2 个孤儿 device,token 前缀稳定但 DB 里查不到。
这是另一个被延期的问题。客户端收到 4401(token 失效)时没有自愈逻辑,只会 exponential backoff 后用同一个失效 token 再试一次,陷入"僵尸状态"——在线但永远连不上 WS。与其说这是 1.0.10 的新问题,不如说是 1.0.10 的发版暴露了原本就存在的设计缺陷。
1.0.11 同步推进了三个方向的修复:
服务端黑名单
cpa-api 新增一个 TokenBlacklist 类,维护一个 LRU 缓存。收到 4401(token 失效)后,把这个 token 加进黑名单,5 分钟内的重试直接返 4401,不走 DB 查询。这样做的好处是降低 DB 压力,坏处是增加内存占用,但 5 分钟的 TTL 和 LRU 限容使得这个成本可控。
客户端自愈
改造 ws.rs 的错误类型,让 4401 close code 能被单独识别为 WsError::TokenInvalid。ws_runner 收到这个错误时,立即清空 in-memory 的 device_token(Mutex 设为 None),emit 一个 connector://token-invalid 事件,不 sleep 直接进入下一轮 loop。frontend 端 listen 这个事件,触发 auto-pair 重新获取 token。这样一来,一次失效就能在 5-15 秒内自愈,不会陷入无限重试。
发版脚本全自动化
1.0.10 里 step 4-10 需要手工兜底。1.0.11 把 step 1(版本 bump)改成了在子仓内完成 commit(idempotent skip 检查保证重跑不会失败),step 3 加了 build marker 检查,step 4 也用同样的 exit code 捕获逻辑。最后 step 1-10 全部自动完成。
1.0.11 的数据
1.0.11 在 5 月 27 日下午 14:30 发版。23 分钟内,那个主要的 zombie token(前缀记录为 ct_UAGorMM)的命中频率从发版前的 47/分钟 降到 0.17/分钟。对比是精确的:TTL 是 5 分钟,日志里看到的恰好是 5 分钟周期的 4 次 first-hit——14:35、14:40、14:45、14:50,说明黑名单在按秒级别的精度工作。
invalid or revoked token reject 日志整体从 53/分钟 降到 3/分钟,94% 的噪声被消除。
从发版开始到完成的总耗时是 80 分钟(spec 起草 → 发版完成),远快于 1.0.10 的 12 小时——主要是因为没有了"假版本"和手工兜底这两个坑。
客户端配置清理的验证是这样的:启动 1.0.11 时,旧配置目录(%APPDATA%/cpa/cpa-connector/)存在且有历史 session.json,cleanup 把它 rename 成 .bak.20260527-141556;第二次启动时我手动造了一个 fake session.json,它又被 rename 成了不同时间戳的 .bak;第三次启动时老目录已经不存在,log 输出 DEBUG no legacy config dir to clean up,幂等。软删除策略保留了原文件,方便后续排查。
发版复盘该记录什么
这两次发版的共同教训是:预期效果与实际效果的差异往往来自非功能层面,发版前遗漏的验证会以人工兜底的成本在发版中浮现。
1.0.10 的预期是一次提交、一次 build、一个发版脚本的无缝运行。实际遇到了"代码没同步"、"metadata 污染"、"git 跨边界"、"参数漏传"这四个层次递进的问题,每一个都需要在发版当时即时判断、即时应急、即时修复。如果这些问题在代码审查或发版前的干运行中被抓到,成本会低一个数量级。
1.0.11 的收益是对 1.0.10 的这些漏洞做了结构化的修复,但更重要的是建立了验收指标清单。版本号同步检查、发版脚本的每一步都有 success marker、gitlink 操作必须在子仓内完成、参数完整性的代码审查项。这不是一次性的修补,而是对"发版这件事"的流程重塑。
回滚判据也值得明确。1.0.10 的触发条件是"ws rejected 反升或 connector 启动失败率 > 0.1%",对应的回滚操作是修改 RELEASE_CHAIN 截断升级路径。但正如前面提到的,这个方案有一个根本限制:已升级的用户成了污染源,无法通过服务端操作让他们回退。这意味着一旦发出去的版本有不可接受的 bug,修复也必须通过新版本修补,不能指望用户自动回到前一个版本。这推导出一个硬性要求:发版前的验证必须足够彻底,因为回滚的代价极高。
1.0.10 和 1.0.11 的连续发版正好说明了这一点。1.0.10 解决了一个明确的功能问题,但在过程中埋了四个流程坑;1.0.11 不光修了功能缺陷(4401 自愈),也补上了流程漏洞(发版脚本全自动)。如果 1.0.10 能在发版前避免那些坑,1.0.11 就没必要冲这么紧。如果不能,1.0.11 这一次的"加固"就成了对下一轮发版的保险——每一次发版都可能遗留新的坑,但流程上的防御等级在递增。
■