同一个弹窗,我已经修过三次了。
0.14.x 到 1.0.0,双机测试中看到的症状是一个反复循环的升级 modal:"升级到 1.0.0",点更新 panel 关闭,然后立刻被拉起,modal 又来。每 15~20 秒一个 "OTA applied to 1.0.0" 的成功记录堆进 events log,但 cache 版本从来没动过,还是 0.14.18。
前面 FA-005 和 FA-006 各修了一处。FA-005 是 admin 端的版本优先级,FA-006 是 panel 进程的 onFinished 回调。两处都修对了,dead loop 依然死循环,说明不是单一根因。
现象:三个不一致的版本号
state.json(位于 launcher 工作目录)里写着三个版本号:
{
"phase": "done",
"version": "0.14.15",
"previous_version": "0.14.14",
"local_apply": {
"applied_version": "0.14.15",
"completed_dirs": ["version.json", "WakouPanel.exe"]
},
"signed_manifest": { "version": "1.0.0", "force_update": true }
}
state.json 中记录了三个不同的版本号:
local_apply.applied_version = 0.14.15(上一轮 OTA 标记为已安装的版本)signed_manifest.version = 1.0.0(本轮要安装的目标版本)- cache 实际的
version.json = 0.14.18(当前运行的实际版本,通过热部署写过) applied_version = 0.14.15(上一轮 OTA 标记装好的)signed_manifest.version = 1.0.0(这一轮要装的目标)- cache 实际版本 = 0.14.18(热部署后的真实版本)
三个数不一样。phase 显示 done,应该表示"安装完成",但完成状态对应 0.14.15,既不是当前版本 0.14.18,也不是目标版本 1.0.0。
events log 里是这样的(路径 %LOCALAPPDATA%\Microsoft\WakouAI\Update\<serial-hash>\ota-events.jsonl):
{"ts":"...14:55:10","source":"restart_loop","outcome":"succeeded","message":"OTA applied to 1.0.0","version_target":"1.0.0"}
{"ts":"...14:55:30","source":"restart_loop","outcome":"succeeded","message":"OTA applied to 1.0.0","version_target":"1.0.0"}
{"ts":"...14:55:45","source":"restart_loop","outcome":"succeeded","message":"OTA applied to 1.0.0","version_target":"1.0.0"}
35 秒三次"成功",一条路径,同一目标版本。正常 OTA 应该装一遍就停,重复成功是链路在假报。
根因分析
代码在 launcher/src/ota_main_chain.rs 的 run_full_apply_chain 函数(550+ 行)。这个函数按顺序执行 Download、Verify、Apply、Finalization 四个 stage。每个 stage 前都有 phase 条件判断:
// Stage A: Download
if matches!(state.phase, Phase::Idle | Phase::Downloading) {
// 执行下载
state.phase = Phase::Verifying;
}
// Stage B: Verify + Extract
if matches!(state.phase, Phase::Downloading | Phase::Verifying) {
// 执行验证和解包
state.phase = Phase::ReadyToApply;
}
// Stage C: Apply
if matches!(state.phase, Phase::ReadyToApply | Phase::LocalApplying) {
// 执行部署
state.phase = Phase::Done;
}
这个设计支持 resume:网络中断或进程崩溃后下次启动可以从 phase 决定从哪一步恢复。合理的思路。
问题出在 caller 对 Done 状态的处理:
run_full_apply_chain(state_base, &manifest, ...)?;
let final_state = state_machine::load(state_base)?;
if final_state.phase == Phase::Done {
Ok(RunOutcome::UpdatedToVersion(manifest.version)) // ← 假成功,只看 phase
} else {
Ok(RunOutcome::Resumed(final_state.phase))
}
当进入 Phase::Done 后,链路到达末尾,清掉标记文件就返回。关键问题在于调用方 run_update_only_chain_inner 对 Done 状态的处理:
run_full_apply_chain(state_base, &manifest, ...)?;
let final_state = state_machine::load(state_base)?;
if final_state.phase == Phase::Done {
Ok(RunOutcome::UpdatedToVersion(manifest.version)) // ← 假成功,只看 phase
} else {
Ok(RunOutcome::Resumed(final_state.phase))
}
这段代码的逻辑是:如果 phase==Done,就返回 UpdatedToVersion(表示"成功升级到目标版本")。它完全依赖 phase 字段来判断成功,不校验实际产物。
现场状态是 phase=Done 但 applied_version=0.14.15 != target=1.0.0。当 OTA chain 重新运行时:
- 进入
run_full_apply_chain,加载 state,phase 已经是 Done - Download stage 检查
if matches!(phase, Idle | Downloading)→ 不匹配,跳过 - Verify stage 检查
if matches!(phase, Downloading | Verifying)→ 不匹配,跳过 - Apply stage 检查
if matches!(phase, ReadyToApply | LocalApplying)→ 不匹配,跳过 - 所有 stage 都被跳过,函数走到末尾清 marker 返回
- caller 看到
final_state.phase == Done→ 返回UpdatedToVersion(1.0.0)
没有任何实际的 download、verify、apply 操作发生。cache 仍是 0.14.18。applied_version 仍是 0.14.15。但链路声称"已经升级到 1.0.0"。
这个循环完全在 OTA chain 内部转圈:
- restart-loop 收到
UpdatedToVersion(1.0.0),记进 events log "OTA applied to 1.0.0",认为任务完成 - restart-loop 重启 panel
- panel 启动执行
ota_check_update,通过 wrapper 询问后端 - 后端告诉 panel:ga 版本是 1.0.0
- panel 本地 cache 还是 0.14.18(因为 apply 根本没执行),对比 1.0.0 ≠ 0.14.18
- panel 决策 ForceUpdate,弹出"升级到 1.0.0"modal
- 用户看到 modal,再点一次"立即更新"
- 回到步骤 1,循环
launcher 的 boot 自愈路径(FA-006 修的 G15/G16)也救不了,因为每一轮都是从 phase=Done 这个"合法"状态出发,boot 根本不会被触发。
修复与验证
chain 入口新增版本校验(run_full_apply_chain 开头):
let stale_done = state.phase == Phase::Done
&& state
.local_apply
.as_ref()
.and_then(|la| la.applied_version.as_deref())
.map(|applied| applied != manifest.version)
.unwrap_or(false);
if stale_done {
eprintln!(
"[ota_main_chain] G18: state.phase=Done but applied_version={} != target={}; resetting to Idle",
applied, manifest.version
);
state_machine::transition(state_base, &mut state, Phase::Idle)?;
}
如果 phase 是 Done 但 applied_version 与目标版本不一致,强制调 transition(_, _, Phase::Idle)。这个转移函数会自动清空 download、local_apply、usb_sync、signed_manifest 这些子结构(已有单测 transition_done_to_idle_clears_substructures 验证),然后 chain 后续所有 stage 都能从干净的 Idle 状态开始正常执行。
同时在 main.rs::boot_self_heal_ota_state 里也加这条检查(这个函数在 boot 启动时 Stage 5 和 Stage 6 之间调用):
let cache_ver = version_file::read(cache_root.join("version.json")).unwrap_or_default();
let stale_done = state.phase == Phase::Done
&& state.local_apply.as_ref()
.and_then(|la| la.applied_version.as_deref())
.map(|av| !cache_ver.is_empty() && av != cache_ver.as_str())
.unwrap_or(false);
if stuck || stale_done {
state.phase = Phase::Idle;
state.cancellable = true;
state.download = None;
state.local_apply = None;
state.usb_sync = None;
state.signed_manifest = None;
state_machine::save(...)?;
}
这样即使没经过 chain 直接启动系统,boot 早期也能检测出"applied_version 跟 cache 不一致"的脏 Done 状态并重置。
复测设计了两条路径,对应 G18(chain 入口)和 G16(boot 启动):
路径 A:通过 OTA chain 验证 G18
- 客户机上手动编辑 state.json:设置
phase=done + applied_version=0.14.15 - cache 保持 0.14.18(这时应该已经是通过热部署或之前的 OTA 装的)
- 启动正常的 OTA 流程(比如通过 admin 端推送新版本 1.0.0)
- chain 执行到
run_full_apply_chain开头,触发 G18 检查 - 代码检测
state.phase==Done && applied_version=0.14.15 != manifest.version=1.0.0 - G18 强制调
transition(..., Phase::Idle)清掉残留状态 - 后续 stage 从干净的 Idle 开始执行
- Download → Verify → Apply 三个阶段真正跑完
- cache version.json 真的变成 1.0.0
- local_apply.applied_version 更新为 1.0.0
- panel 重启,ota_check 对比 cache(1.0.0) == ga(1.0.0) → NoUpdate → 不弹 modal
- events log 中这一轮只有一条 "OTA applied to 1.0.0" 成功记录,没有重复
路径 B:通过 boot 启动验证 G16 扩展
- 手动编写 state.json 再次制造脏状态:phase=done,applied_version=0.14.15
- cache 故意设为 0.14.18(模拟热部署后的状态)
- 关闭 panel,让 launcher 完全退出
- 重新启动系统(VBS → launcher → Stage 1-5 初始化)
- 在 Stage 5 和 Stage 6 之间,boot_self_heal_ota_state 被调用
- G16 检测到 stale_done:
phase==Done && applied_version=0.14.15 != cache_version=0.14.18 - G16 强制清掉这个脏状态:设置 phase 为 Idle,清空所有子字段
- 进入 Stage 6 的 OTA check
- read_app_version 读到 cache 的 0.14.18,与 ga 版本 1.0.0 比对不一致
- ota_check 决策 ForceUpdate(1.0.0),写 marker,panel 弹 modal
- 用户点"立即更新",走正常的 OTA 流程,最终装成 1.0.0
- 再次重启后不再弹 modal
两条路径都验证了修复的有效性:G18 拦截了 chain 内的假成功,G16 则保证了即使跳过 chain 直接启动系统,脏状态也会被清掉。
为什么修了这么多次,这一次改了什么
同一症状,三处已知根因
回顾整个 OTA 死循环系列,这是一个典型的"多根因叠加"的故障:
| 编号 | 故障名 | 根因 | 修复位置 | 防护作用 |
|---|---|---|---|---|
| FA-005 | 版本号优先级 | admin 端推送的 ga 版本没有考虑热部署导致 cache 版本提前的情况 | admin wrapper 的版本比对逻辑 | 防止"本地已新,后端说旧"导致的错误降级 |
| FA-006 | panel 不退出 | panel 在 onFinished 回调用 reload() 而非 exit(),launcher worker 进程继续运行导致重复 spawn | panel 端的 onFinished 回调 | 防止多个 worker 进程叠加争夺 state.lock |
| FA-007 | 本篇,phase 假终态 | OTA chain 把终态只用 phase=Done 表示,不校验 applied_version 是否匹配目标版本 | chain 入口 run_full_apply_chain 的版本校验 | 防止残留的"上一轮成功"状态在目标版本不同时假报成功 |
单独看每一个根因的影响:
- FA-005 单独发生:panel 看到"版本已是 ga,不用升",用户见不到 modal,系统无害
- FA-006 单独发生:worker 进程可能叠加,但第一个 worker 拿到 state.lock,后续的失败重试,最终还能装上新版本,不会死循环
- FA-007 单独发生:如果每次启动 state 都是干净的 Idle,这个 bug 也不会触发,系统照常运行
但当这几个条件同时出现时,它们形成了一个完整的死循环链。FA-006 修完后,0.14.18 的 OTA 看起来可以跑通。然而升级到 1.0.0 时,由于某些原因(可能是 0.14.17 到 1.0.0 的架构变更),state.json 中残留了一个"applied_version=0.14.15"的 Done 状态。这时 FA-007 就被触发了:chain 看到 Done,所有 stage 跳过,假报成功,触发 FA-005 的对比逻辑,最终弹 modal,形成死循环。
这一次改了什么
FA-007 要修的是:任何时候进入 chain,都必须先校验 applied_version 与目标版本是否匹配。如果不匹配,不能相信之前的 Done 状态,必须强制重置到 Idle 重新执行。
这个修复摧毁了死循环链中的"假成功报告"这一环。即使状态机上还有其他缺陷(如 FA-005,或尚未发现的残留问题),由于 chain 不再假报成功,它们也更难找到触发的机会。修完这一层也不能断言链路上不存在第四个根因——只能说已知的三个都各自被堵上了。每一处修复都切断了死循环的一条传播路径,修完 FA-005 + FA-006 + FA-007 之后,形成完整死循环的条件才真正被破坏。
防回归
代码层面
单元测试 transition_done_to_idle_clears_substructures 已覆盖 Done → Idle 的转移路径,验证状态重置后所有相关字段都被清空。
schema 兼容性:applied_version 字段在 v4 版本的 review 阶段引入,旧版本 launcher 写的 state.json 中可能缺失这个字段。G18 的代码使用了 Rust 的 and_then().map().unwrap_or() 链,确保字段缺失时返回默认值,不触发假检测。
worker 路径兼容:ota_worker 不经过 run_full_apply_chain,而是走独立的 phase1_download_and_verify 函数。但因为 G16 在 boot 阶段早期就会重置脏状态,worker 进来时的 state 已经干净,无需额外改动。
运维观察
events log 是诊断 FA-007 的重要信号。具体特征:
- 同一个
source(如 restart_loop) - 在短时间内(< 1 分钟)
- 多次(≥ 3 次)出现 "outcome":"succeeded"
- 但实际 cache/version.json 没有变化
这个组合高度可疑,可以作为自动化巡检的告警触发条件。正常的 OTA 应该一次装完(除非是主动的 resume 路径),重复成功通常意味着链路在假报。
兼容性总结
- 旧版本 launcher 的 state.json 兼容:缺失的 applied_version 字段不会误触 G18 检测
- G15/G16/G17(来自 FA-006 的 launcher 防御)全部保留,不冲突
- panel 端无变更,G13/G14(FA-006 修的 onFinished 退出逻辑)仍在
- worker 进程的 phase1_download_and_verify 逻辑不变,不经过 run_full_apply_chain,但因为 G16 在 boot 阶段早期就会重置脏状态,worker 进来时的 state 已经干净,无需额外改动
- 构建脚本、CI 流程无需改动
关键设计原则
状态机的终态判定不能只依赖枚举值,必须验证实际产物。没有这层校验,"上一轮残留"的终态会让整个执行链路跳过,转化成假成功报告,最终驱动上层重复触发同一个已经"完成"的操作。
对于 phase-conditional 执行链:
- 链的入口做合法性检查:当前 phase 是否与执行目标兼容
- 残留终态(如 phase=Done)不能盲目信任,必须校验是否指向正确版本
- 校验失败强制重置,不假设"上一轮可能对的"
- 任何状态转移都应该有明确的幂等性保证——重复执行同一状态转移应该得到相同结果
后续扩展 OTA 协议时,新增 phase 和 RunOutcome 都必须纳入 chain entrypoint 的合法性检查框架内。这是分布式系统设计中的普遍问题,不仅限于 OTA 场景。
总结
状态机的终态不能只由阶段枚举表示,必须同时校验实际产物。没有这层校验,"上一轮残留"的终态会让整个执行链路跳过,转化成假成功报告,最终驱动上层重复触发同一个已经"完成"的操作。这也说明了为什么同一个死循环症状需要改多处——每一处修复都在阻止不同的"跨越"路径达到假终态,改完 FA-005 + FA-006 + FA-007 之后才能真正阻断死循环的形成。
■