故障档案

点更新是降级,不点就动不了

客户端版本比对逻辑缺陷导致强推已撤回的旧版本,陷入升降级循环;两层修复确保版本单调性是独立防御。

客户端激活成功,第一件事弹了个 modal:"为了安全和稳定,本版本必须升级后才能继续使用"。提示版本 0.14.5。当前客户端版本是 0.14.6,比 0.14.5 还新。

不点更新,modal 阻塞所有交互。点"立即更新",客户端被推成 0.14.5——一个已经撤回、有已知 bug 的旧版本。装上 0.14.5 重启,launcher 又推了 0.14.6。再装上,再重启。然后又是 0.14.5。升降级循环。

现象确认

触发点是激活成功后的 OTA 检查。用排查命令读取本地缓存的 manifest:

$env:LOCALAPPDATA\Microsoft\WakouAI\Update\<fingerprint>\last-known-signed-manifest.json

内容显示:

{
  "version": "0.14.5",
  "minClientVersion": "0.14.3",
  "forceUpdate": true,
  ...
}

客户端当前版本 0.14.6(CARGO_PKG_VERSION),version compare 结果是 semver_gt("0.14.5", "0.14.6") = false。按常理应该无更新。但 launcher 仍然返回 ForceUpdate,触发降级。这里有个隐藏的誤导性:弹窗文案提示"发现新版本",但实际推的是旧版本。

排查过程

看到 modal 文案直接走强制更新流程,自然怀疑是 launcher 侧的决策。先确认 cached manifest 的内容,确实 version 是 0.14.5、forceUpdate 标记为 true。

接下来的问题是:launcher 在什么条件下会返 ForceUpdate?阅读决策代码 wakou-full/launcher/src/ota_check.rsmap_outcome_to_result 函数(修复前):

Verified { manifest } => {
    if manifest.force_update || semver_gt(&manifest.min_client_version, current_version) {
        OtaCheckResult::ForceUpdate { version: manifest.version, ... }
    } else if semver_gt(&manifest.version, current_version) {
        OtaCheckResult::OptionalUpdate { ... }
    } else {
        OtaCheckResult::NoUpdate
    }
}

逻辑分支的顺序是:

  1. 第一判断force_update=truemin_client_version > current_version,直接返 ForceUpdate
  2. 第二判断version > current_version,返 OptionalUpdate
  3. 第三判断:都不满足,返 NoUpdate

问题就在第一判断:没有任何检查确保 manifest.version 真的比 current_version 新。force_update 标记被当作绝对权威,即使指向的版本是旧的。

根因链路

server 端撤回 0.14.5 时,管理后台做了软删除,但 wrapper API 仍然在某些 channel/client query 条件下返回 0.14.5 的 manifest 作为 latest,且签名仍然有效(这里假设是服务端撤回流程不完整)。launcher 拿到这个 manifest,看到 forceUpdate=true,就直接判定为强制更新,完全不检查目标版本号。

客户端此时已经是 0.14.6,一个更高的版本。但因为比对逻辑的分支顺序,force_update 优先于版本号比对,所以被推成了"必须降级"。这就是设计缺陷之处:没有版本单调性的护栏。

同样的问题也存在于 OfflineFresh 分支——当本地缓存 manifest 的 force_update 被污染后,即使无网络,后续仍然会按陈旧的 force_update 标记推版本。OfflineFresh 是为了离线场景的降级缓存机制,但它的 force_update 标记一旦错误,就会持续误导客户端。

一旦陷入循环,每次启动都会 query 新 manifest,新 manifest 可能已修复(不再返 0.14.5),但本地 cache 还在,OfflineFresh 分支会继续用污染的 cache。这样就形成了死循环:降级 → 重启 → 升级 → 重启 → 再降级。

修复方案

修复分为客户端和服务端两层。

客户端侧

核心是把版本单调性检查前置为独立决策层,在任何 force_update 判断之前。提取统一决策函数 decide_from_manifest,其中把"当前版本已满足 min_client_version 且 server 版本不高于当前版本"作为 NoUpdate 的充要条件:

fn decide_from_manifest(server_version, server_min_client, force_update, current_version, ...) {
    let server_newer = semver_gt(server_version, current_version);
    let client_obsolete = semver_gt(server_min_client, current_version);

    // 客户端满足 min_client AND server 版本不比我新 → NoUpdate
    // 即使 force_update=true,也要遵守单调性
    if !server_newer && !client_obsolete {
        return NoUpdate;
    }

    if client_obsolete || (server_newer && force_update) {
        return ForceUpdate { ... };
    }

    if server_newer {
        return OptionalUpdate { ... };
    }

    NoUpdate
}

关键改动是第一道防线:!server_newer && !client_obsolete 时直接返 NoUpdate,这个条件前置在任何 force_update 判断之前。两条路径(Verified 和 OfflineFresh)都走这个函数。

新增单测覆盖缺失的场景:

  • server 0.14.5 + force_update=true + current 0.14.6 → NoUpdate
  • server 0.14.6 + force_update=true + current 0.14.6 → NoUpdate

服务端侧

当版本被撤回时,不仅要删除 manifest 本身,还要同时清理客户端本地缓存里的 last-known-signed-manifest.json。这个文件是离线场景的降级缓存(OfflineFresh 分支会用它),如果不删除,即使 server 已修复,已下发过的陈旧 manifest 仍然会继续被本地 cache 推用。

部署步骤(本次 hotfix)包括:

  1. 编译修复后的 launcher
  2. 清除当前 launcher 进程释放文件锁
  3. 替换 launcher.exe 及两份 LocalAppData 缓存里的 launcher.exe
  4. 删除 last-known-signed-manifest.json——这是关键,不删的话 OfflineFresh 路径在下一次离线启动时仍会推旧 manifest

防回归

在构建脚本里增加正则检查:launcher 启动命令必须有 force_update 相关的单调性防御,否则 fail-build。这是构建期的静态检查,防止后续版本回退。

panel 端的展示逻辑(force_update_modal.js)也应加 sanity check:如果展示的版本号 ≤ 当前版本,不显示 modal。这是第二层防御,即使 launcher 修复被回滚,panel 也不会显示误导性的升级弹窗。

服务端撤回 API 的 e2e 测试要补充:测试覆盖撤回一个版本后,该版本在任何 query 条件下都不会被 wrapper 返回,且相关缓存已清理。这是关键的质量关卡,防止类似的撤回不彻底问题再次发生。

可迁移的判断

版本单调性不能只靠服务端签名和包体完整性保证——签名可能合法,包体可能完整,唯一的问题是方向错了。版本比对逻辑本身必须作为独立的一层防御,在所有其他条件(force_update 标记、min_client_version 约束)之前执行。强制更新的语义是"必须升级",而不是"必须更新到服务端指定的版本",这两者在版本管理上有本质的区别。这一原则对所有支持版本管理的系统都适用。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。