故障档案

同一个弹窗,我修了三次

状态机的终态只由 phase 表示,不校验实际产物导致假报成功。修复是入口处检测 applied_version 与目标版本不一致时强制重置。

同一个弹窗,我已经修过三次了。

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.rsrun_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=Doneapplied_version=0.14.15 != target=1.0.0。当 OTA chain 重新运行时:

  1. 进入 run_full_apply_chain,加载 state,phase 已经是 Done
  2. Download stage 检查 if matches!(phase, Idle | Downloading)不匹配,跳过
  3. Verify stage 检查 if matches!(phase, Downloading | Verifying)不匹配,跳过
  4. Apply stage 检查 if matches!(phase, ReadyToApply | LocalApplying)不匹配,跳过
  5. 所有 stage 都被跳过,函数走到末尾清 marker 返回
  6. 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 内部转圈:

  1. restart-loop 收到 UpdatedToVersion(1.0.0),记进 events log "OTA applied to 1.0.0",认为任务完成
  2. restart-loop 重启 panel
  3. panel 启动执行 ota_check_update,通过 wrapper 询问后端
  4. 后端告诉 panel:ga 版本是 1.0.0
  5. panel 本地 cache 还是 0.14.18(因为 apply 根本没执行),对比 1.0.0 ≠ 0.14.18
  6. panel 决策 ForceUpdate,弹出"升级到 1.0.0"modal
  7. 用户看到 modal,再点一次"立即更新"
  8. 回到步骤 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

  1. 客户机上手动编辑 state.json:设置 phase=done + applied_version=0.14.15
  2. cache 保持 0.14.18(这时应该已经是通过热部署或之前的 OTA 装的)
  3. 启动正常的 OTA 流程(比如通过 admin 端推送新版本 1.0.0)
  4. chain 执行到 run_full_apply_chain 开头,触发 G18 检查
  5. 代码检测 state.phase==Done && applied_version=0.14.15 != manifest.version=1.0.0
  6. G18 强制调 transition(..., Phase::Idle) 清掉残留状态
  7. 后续 stage 从干净的 Idle 开始执行
  8. Download → Verify → Apply 三个阶段真正跑完
  9. cache version.json 真的变成 1.0.0
  10. local_apply.applied_version 更新为 1.0.0
  11. panel 重启,ota_check 对比 cache(1.0.0) == ga(1.0.0) → NoUpdate → 不弹 modal
  12. events log 中这一轮只有一条 "OTA applied to 1.0.0" 成功记录,没有重复

路径 B:通过 boot 启动验证 G16 扩展

  1. 手动编写 state.json 再次制造脏状态:phase=done,applied_version=0.14.15
  2. cache 故意设为 0.14.18(模拟热部署后的状态)
  3. 关闭 panel,让 launcher 完全退出
  4. 重新启动系统(VBS → launcher → Stage 1-5 初始化)
  5. 在 Stage 5 和 Stage 6 之间,boot_self_heal_ota_state 被调用
  6. G16 检测到 stale_done:phase==Done && applied_version=0.14.15 != cache_version=0.14.18
  7. G16 强制清掉这个脏状态:设置 phase 为 Idle,清空所有子字段
  8. 进入 Stage 6 的 OTA check
  9. read_app_version 读到 cache 的 0.14.18,与 ga 版本 1.0.0 比对不一致
  10. ota_check 决策 ForceUpdate(1.0.0),写 marker,panel 弹 modal
  11. 用户点"立即更新",走正常的 OTA 流程,最终装成 1.0.0
  12. 再次重启后不再弹 modal

两条路径都验证了修复的有效性:G18 拦截了 chain 内的假成功,G16 则保证了即使跳过 chain 直接启动系统,脏状态也会被清掉。

为什么修了这么多次,这一次改了什么

同一症状,三处已知根因

回顾整个 OTA 死循环系列,这是一个典型的"多根因叠加"的故障:

编号故障名根因修复位置防护作用
FA-005版本号优先级admin 端推送的 ga 版本没有考虑热部署导致 cache 版本提前的情况admin wrapper 的版本比对逻辑防止"本地已新,后端说旧"导致的错误降级
FA-006panel 不退出panel 在 onFinished 回调用 reload() 而非 exit(),launcher worker 进程继续运行导致重复 spawnpanel 端的 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 执行链:

  1. 链的入口做合法性检查:当前 phase 是否与执行目标兼容
  2. 残留终态(如 phase=Done)不能盲目信任,必须校验是否指向正确版本
  3. 校验失败强制重置,不假设"上一轮可能对的"
  4. 任何状态转移都应该有明确的幂等性保证——重复执行同一状态转移应该得到相同结果

后续扩展 OTA 协议时,新增 phase 和 RunOutcome 都必须纳入 chain entrypoint 的合法性检查框架内。这是分布式系统设计中的普遍问题,不仅限于 OTA 场景。

总结

状态机的终态不能只由阶段枚举表示,必须同时校验实际产物。没有这层校验,"上一轮残留"的终态会让整个执行链路跳过,转化成假成功报告,最终驱动上层重复触发同一个已经"完成"的操作。这也说明了为什么同一个死循环症状需要改多处——每一处修复都在阻止不同的"跨越"路径达到假终态,改完 FA-005 + FA-006 + FA-007 之后才能真正阻断死循环的形成。

星野的头像

星野 XINGYE

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