根因:读写位置错配
launcher 的 read_app_version 函数采用了三级 fallback:
- 优先读
portable_root/version.json(USB 上的文件) - 其次读
WAKOU_LOCAL_CACHE_ROOT/version.json(缓存中的文件) - 最后回落到
CARGO_PKG_VERSION
但 OTA 的 Phase 2 只更新 cache 里的 version.json,不更新 USB。这个顺序反了。
USB 上的版本号代表"上次制作镜像时是什么版本"。Cache 代表"当前系统已安装什么版本"。读取时应该读当前状态,而非历史记录。关键原则:读优先级必须与写位置一致。
怎么形成的循环
0.14.3 用户接到 0.14.4 强制升级。OTA 链路完整跑完,但新版 launcher 启动后仍然弹升级弹窗。用户点更新,下载安装,重启。然后又弹窗。无限循环,直到 0.14.4 被撤回。
Windows 便携版 0.14.3,某个 F 盘 USB 上的流程是:
- 用户双击 Start-Wakou.cmd,0.14.3 launcher 正常启动
- Stage 5 完成 cache 准备(mirror 文件来自 USB,所有文件都是 0.14.3 era)
- Stage 6 OTA 检查调用远程 wrapper,返回 0.14.4 强制更新指令
- 写入
force-update-pending.jsonmarker,Stage 7 启动 panel - Panel 读取 marker,弹出「升级到 0.14.4」对话框
- 用户点击「立即更新」,OTA worker 开始下载、验证、准备安装
- Phase 2 trash_then_install 完成,杀掉 panel,启动新版 launcher
- 新 launcher 再次执行 OTA 检查,wrapper 仍然返回 0.14.4 强制更新
- 重新写入 marker,Panel 重启后再次弹窗
- 循环往复
用户无法正常进入新版本的面板。OTA 事件日志显示 "OTA applied to 0.14.4",但 USB 中的 version.json 仍然记录 0.14.3。
观察点是这个矛盾:OTA 事件日志显示已成功安装到 0.14.4,但重启后又触发同样的升级流程。这意味着版本号的读取出了问题。
新版 launcher 启动后,调用 read_app_version。由于优先级错配,它读到的是 USB 上的旧版本号——0.14.3。向 wrapper 传 currentVersion=0.14.3,wrapper 返回 0.14.4 强制更新。marker 重新写入,panel 重启后再次弹窗。
用户点更新,OTA Phase 2 完成,cache 中的 version.json 写入 0.14.4。但 USB 同步有 120 秒延迟,甚至更久(guardian 的定时间隔)。下一次 launcher 启动,又读到 USB 的 0.14.3。
循环永不停止——除非 guardian 的同步周期刚好赶上 launcher 的下一次启动。时间窗口里,任何人为原因的重启都能触发循环。
关键是 OTA 的写入和读取位置不一致。OTA 的 Phase 2(trash_then_install)更新的是 WAKOU_LOCAL_CACHE_ROOT(本地缓存目录)下的文件。Cache 是操作系统启动后立刻可用的工作目录,USB 上的文件是长期存储。
但新 launcher 启动后,由于优先级设置,仍然首先读取 USB 上那份——而 USB 中的版本号还没有被同步到 0.14.4,因为 guardian 进程定时同步 USB 的间隔是 120 秒。
时间线还原:
- T0:用户点"立即更新"
- T0+20s:OTA Phase 2 完成,cache 中
version.json已写入 0.14.4 - T0+21s:新 launcher 启动
- T0+22s:
read_app_version读 USB,得 0.14.3 - T0+23s:launcher 向 wrapper 传
currentVersion=0.14.3 - T0+24s:wrapper 返回 0.14.4 强制更新指令,marker 再次写入
- T0+120s:guardian 才同步 USB 到 0.14.4
这 120 秒窗口内任何一次 launcher 启动都会重复。系统重启后 guardian 周期重新开始计时,问题仍然会重现。
launcher/src/main.rs 中 read_app_version 的实现问题:
fn read_app_version(portable_root: &Path) -> String {
// 1. portable_root/version.json (USB) ← 错误优先级
// 2. WAKOU_LOCAL_CACHE_ROOT/version.json (cache)
// 3. CARGO_PKG_VERSION (last resort)
}
这个优先级顺序违反了核心原则:读优先级必须与写位置一致。USB 版本号代表"上次制作镜像时是什么版本"。Cache 代表"当前系统已安装什么版本"。读取时应该读当前状态,而非历史记录。
新 launcher 需要知道"我现在是什么版本",这个"我"指的是内存中正在运行的程序,其代码来自 cache(如果有最新版本),而不是来自 USB。读 USB 版本号等于问"USB 镜像上次更新是几天前",这不是正确的问题。
修复策略
反转 read_app_version 的优先级:
fn read_app_version(portable_root: &Path) -> String {
// 1. WAKOU_LOCAL_CACHE_ROOT/version.json (cache — 当前已安装)
// 2. portable_root/version.json (USB — 兜底)
// 3. CARGO_PKG_VERSION (last resort)
}
修复后,cache 中的 version.json 一旦更新为 0.14.4,新 launcher 立刻读到。wrapper 返回 NoUpdate,循环断掉。USB 同步滞后已不关键——cache 就是当前事实。
读取的是"此时此刻运行的是什么版本",而不是"最后一次有人更新 USB 镜像是什么时候"。
验证边界场景:
| 场景 | 旧优先级 | 新优先级 |
|---|---|---|
| 全新 USB 首次启动(无 cache) | 读 USB ✓ | cache 不存在,fallback USB ✓ |
| OTA 完成立刻重启 | USB 旧版 → 循环 ❌ | cache 已更新 ✓ |
| dev 环境(无文件) | fallback 常数 ✓ | 同上 ✓ |
防回归
加入集成测试,验证当 cache 和 USB 版本号不一致时的行为:mock cache 为 0.14.5,USB 为 0.14.4,调用 read_app_version 必须返回 0.14.5(而非 0.14.4)。
read_app_version 是 launcher boot Stage 6 的唯一版本来源。任何后续改动涉及 portable_root 或 version.json 的代码都应该 grep 一次这个函数,确保没有再把优先级搞反。
附注:同时期还有两个相关问题。一是 version.json 的 schema 分裂(schema-1 和 schema-2 并存),可能导致版本号回落到默认值 0.0.1,作为 OTA 循环的隐藏放大器——但这是独立的 schema 编码问题,不是优先级问题。二是 launcher 的两个不同代码路径(Stage 6 boot 的 read_app_version 与 OTA check 的 current_version_from_local_cache)的 fallback 链路设计不一致,这也是独立根因,表现为版本号又退化到 0.0.0,同样触发 OTA 循环。当前这篇解决的只是第一个(优先级错配),其他两个有各自的修复方案。
更宽的教训
分布式系统里同一份数据有多个副本时,读取顺序必须遵循写入顺序。OTA 系统中,cache 是主,USB 是从;应该读主再读从。任何"双位置存储"都有这个风险——没有绝对的"真理源",只有"最近写入的位置"。
同时期还有两个相关但独立的 bug。一是 version.json 的 schema 分裂(schema-1 和 schema-2 并存),可能导致版本号回落到 0.0.1,作为 OTA 循环的隐藏放大器。这是 schema 编码问题,不是优先级。二是 launcher 的两个代码路径(Stage 6 boot 的 read_app_version 与 OTA check 的 current_version_from_local_cache)的 fallback 链路不一致,版本号退化到 0.0.0,同样触发循环。这篇只解决了优先级错配,其他两个各有各的修复。
■