故障档案

签名文件比这次更新,早了三小时

跨版本 OTA 时复用了上一轮失败留下的旧签名文件,导致所有后续升级验签必然失败。问题根源在文件存在性判断替代了版本校验。

状态目录里有个文件时间戳不对劲。download.zip.minisig 的修改时间是 20:19:35,而本次 OTA 启动于 23:25,相差三小时。验签应该是在 OTA 最后一步才做的事,为什么这个文件早了三小时?

这是 Windows build host 上 1.0.1 升级到 1.0.2 的 OTA 流程。实测现场的完整时序如下:

  • 下载阶段正常完成:download.zip 体积 24.4 MB,hash 校验通过
  • 状态文件卡死在 state.phase = "verifying",且 state.json 记录 download.bytes_downloaded = 0(worker 进入 verify 后被杀,没有机会更新这个字段)
  • 后台 worker 进程已退出,没有活进程推进 OTA 链路
  • 状态目录(Update\<serial-hash>\)里存放签名文件 download.zip.minisig,修改时间戳是 20:19:35,而本次 OTA 启动于 23:25,相差三小时
  • launcher 和面板端陷入重试循环:launcher 看到有效状态标记,调用 chain 重新运行 → worker verify 失败 → worker 进程被杀 → launcher 重启 → 再次重新运行同一条 chain

根因:旧签名文件的复用

状态目录里的 download.zip.minisig 来自上一轮失败的 OTA。某个时刻的升级(比如从 1.0.0)在 verify 之前或之中中断,签名文件就留在了磁盘上。当前 OTA(1.0.2)下完包后进入验签,代码发现磁盘上已存在 download.zip.minisig,就直接读取,而不下载新版本对应的签名。

根本原因在 ota_worker.rsota_main_chain.rs 两处的同一个逻辑块:

// ota_worker.rs:240
let minisig_path = state_base.join("download.zip.minisig");
let sig_text = if minisig_path.exists() {
    std::fs::read_to_string(&minisig_path)?  // 文件存在 → 直接读磁盘
} else {
    let sig_url = &manifest.download.signature.url;
    let text = reqwest::blocking::get(sig_url)?.text()?;
    std::fs::write(&minisig_path, &text)?;
    text
};
verify_zip::verify_zip(&zip_path, &sig_text, ...)?;

代码本意是支持 resume(同一轮 OTA 中途退出后不重复下载),但"存在就复用"太脆弱。

触发链路简述:前一轮 OTA(1.0.0)中断 → minisig 残留 → 当前 OTA(1.0.2)看到文件存在 → 读取老签名 → 用 1.0.0 签名验 1.0.2 zip → SHA256 不匹配 → verify 失败 → worker 进程异常退出 → launcher 误认为可恢复 → 触发 RunChain → 同一条路径再次失败 → 死循环。

这次故障与之前的 FA-005~FA-007 形成递进关系。前面三篇处理的是 OTA 流程级残留(marker 文件格式、state.phase 卡死、worker 僵尸进程);这一篇处理 artifact 级残留——中间产物的生命周期跨越了多轮 OTA。

排查过程

发现 stale minisig 的关键线索是文件时间戳对比。当 worker 反复失败时,查看 state.json 的 phase 和 ota-events.jsonl 的最后一条事件:phase 卡在 verifying,但 events 里没有 verify 失败的日志,说明 worker 在 verify 时崩溃了。

接下来检查状态目录:download.zip 的时间戳是当前 OTA 启动后的(体积、hash 都对),但 download.zip.minisig 的时间戳远早三小时。minisig 在 verify 前才下载,时间相隔三小时就是上一轮 OTA 残留的文件。用磁盘上的旧 minisig 对当前 zip 进行 minisign -Vm 验证会看到 SHA256 mismatch,印证了根因。

修复方案

修复分为两处,逻辑完全相同:

  1. ota_worker.rs::phase1_download_and_verify 第 240 行
  2. ota_main_chain.rs::run_full_apply_chain Stage B verify 段第 302 行

改动是移除文件存在性判断,永远 fresh fetch

// 改前:存在就读,不存在才下载
// let sig_text = if minisig_path.exists() { ... } else { ... };

// 改后:永远删除后重新下载
let minisig_path = state_base.join("download.zip.minisig");
let _ = std::fs::remove_file(&minisig_path);  // 强制删除旧文件
let sig_url = &manifest.download.signature.url;
let sig_text = reqwest::blocking::get(sig_url)?.text()?;
std::fs::write(&minisig_path, &sig_text)?;

代价极小:minisig 约 400 字节,网络耗时几十毫秒,在整个 OTA 流程中可忽略。这个改动体现了核心原则——中间文件的生命周期必须绑定到任务/版本,而不是绑定到文件是否存在。未来可以让文件名包含版本号(如 download-1.0.2.zip.minisig),但这次为了最小变动只删除了文件存在性缓存。

验证

实测在状态目录放置老版本 minisig,触发 1.0.2 OTA,worker 自动覆盖后 verify 通过,整条 chain 完成。修复后实测 1.0.2 OTA 全链路 22 秒完成(download 8s + verify 5s + ready→applied 2s + USB sync 5s + done),不再出现 retry loop。

防回归:这是设计问题而非代码 bug。核心原则是任何中间文件的有效性不能单靠存在判断,必须包含版本号或 hash 标识。同理,chain 入口读取 minisig 和 download.zip 时都应该先校验与 manifest 的一致性,不只依赖后续的 verify_zip。

系列位置

这是 OTA 死循环系列(FA-005~FA-008)的最后一环。前三篇处理流程级残留(marker、state.phase、worker 进程);这一篇处理 artifact 级残留(中间文件生命周期)。症状相同(verify 失败 → 重试循环),但根因逐层深入。每一层修复都必要——只解决流程层而不解决 artifact 层,下次跨版本 OTA 中断还会踩坑。

星野的头像

星野 XINGYE

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