交付与更新

跳过一个撤回的版本,客户就少了个 DLL

增量包是相邻两版的差分,跳过中间版本会漏掉某版本引入的文件,导致客户启动崩溃。

1.0.7 客户升级到 1.0.9 后启动崩溃,报 STATUS_DLL_NOT_FOUND。根因在于增量包的构造逻辑与发版序列的交互:任何"中间 GA 版本被撤回且后续版本以它作为 baseline"的组合,都会让跳过撤回版本的客户拿到不完整的增量包。

增量包的设计前提

OTA 更新包是二进制差分产物——release.sh 在构造增量包时,只列入「FROM → TO 两个相邻版本之间 sha256 实际不同的文件」。以 1.0.8 → 1.0.9 的增量为例:

1.0.8 dist ↔ 1.0.9 dist
  vcruntime140.dll (sha256: abc...)  vcruntime140.dll (sha256: abc...)
                                    ↑ 一致,不进差分包
  msvcp140.dll (sha256: def...)     msvcp140.dll (sha256: def...)
                                    ↑ 一致,不进差分包
  launcher.exe (sha256: old)        launcher.exe (sha256: new)
                                    ↑ 不同,进差分包

相比全量包(500+ MB),增量包通常只有 100-200 MB。对于国内弱网、公网、海外长途网络来说,包体积直接影响下载时间和失败率。选择差分包的代价是必须确保客户的升级链路完整。

这个设计基于一个隐含的假设:客户严格按发布顺序逐版升级(1.0.7 → 1.0.8 → 1.0.9 → ...)。每跳一个版本,就叠加一次「FROM→TO」的相邻增量。所有累积变更都会被依次应用,最终到达完整的目标版本。只要这个假设成立,差分机制就是 sound 的。但一旦中间版本被 recall 且升序逻辑允许跳过,假设就被破坏了。

发版序列与撤回的交互

关键的发版序列是这样的:

1.0.5  GA   无 VCRuntime DLL
1.0.6  recalled
1.0.7  GA   无 VCRuntime DLL
1.0.8  GA   ↓ 首次 Copy-VcRuntime,dist 含 9 个 VC++ Runtime DLL
       后 recalled(因 OTA_TRASH_CORRUPT 现场卡死)
1.0.9  GA   继承 1.0.8 build,含 VCRuntime DLL
       baseline = 1.0.8(diff 算相邻两版)
       minClient = 1.0.8(改为 1.0.7 后触发此 bug)

当 1.0.9 的 minClient 被改为 1.0.7 后,升序逻辑会为 1.0.7 客户返回 1.0.9 的 manifest。minClient 是一个关键的版本约束字段——它表示"升级到当前版本前,客户必须至少是什么版本"。修改 minClient 的动机通常是想扩大升级适配范围(比如从 1.0.8 放宽到 1.0.7),但这个改动隐含了一个假设:"当前版本及其所有依赖都能兼容从 minClient 开始的所有中间版本"。在这个案例中,这个假设被打破了。

G26 升序查询(1.0.7 客户,seek current 之后最小 GA):
  候选 1.0.8 → recalled,跳过
  候选 1.0.9 → GA,minClient=1.0.7≤1.0.7 满足 → 命中,返回 1.0.9 manifest

1.0.7 客户 apply 1.0.9 的差分包后,系统启动时 launcher.exe 试图加载 VCRUNTIME140.dll,但客户机的 dist 里没有这个文件——1.0.8 引入的 9 个 DLL 永远没有被应用过。Windows 加载器在 LoadLibrary 阶段直接终结进程,报 STATUS_DLL_NOT_FOUND

为什么 1.0.7 看起来能启动,升级后就崩溃

这里需要澄清一个关键点。1.0.7 的 dist 里确实没有 VCRuntime DLL,但 1.0.7 版本的应用仍然能启动。原因在于 launcher.exe 是用 1.0.7 时代的 toolchain build 的,它依赖的 MSVC CRT 版本恰好在干净 Windows 的 System32 里能找到(或者该客户机预装了对应版本的 Visual C++ Redistributable)。这是一个"侥幸"——1.0.7 构建时选用的工具链版本较旧,CRT 的 ABI 足够稳定,系统库能满足。

一旦升级到 1.0.9,launcher.exe 是用更新的 toolchain build 的,它依赖的 CRT 版本更新了,链接的 API 集合也有变化。客户机的 System32 里没有这个新版 CRT。Windows PE 加载器会在 LoadLibrary 阶段就找不到 VCRUNTIME140.dll,进程在主函数之前被系统杀掉(STATUS_DLL_NOT_FOUND)。应用的错误处理机制根本没机会启动——没有 try-catch,没有日志,只有系统错误弹窗和退出码。

为什么会漏 DLL

这是差分包设计与版本跳过的碰撞。1.0.9 的差分包不包含 vcruntime140.dll,原因看似无害:

1.0.8 dist                1.0.9 dist
  vcruntime140.dll          vcruntime140.dll
  sha256: <hash>            sha256: <hash>(同一个 build 产物,哈希相同)
  → 两边都有,且相同 → 不进差分包

但这个逻辑基于一个前提:从 1.0.8 升级来。如果客户是从 1.0.7 升级,那么它的 dist 里根本没有 vcruntime140.dll,差分包也不会帮它补上——因为 1.0.9 diff 算法看的是"1.0.8 有且 1.0.9 也有" 的文件。

任何「中间 GA 版本引入新文件 + 该版本被 recall + 后续版本用它作 baseline」的组合,都会造成同样的问题。这不是 DLL 特有的现象,而是增量包机制本身的约束。

为什么 1.0.8 会引入 DLL

顺便说一下 1.0.8 为什么成为"罪魁祸首"。1.0.8 是第一个在 build-portable.ps1 中加入 Copy-VcRuntime 的版本,它主动把整套 MSVC redist DLL(9 个)旁置到 dist,这样便携包就不再依赖系统级安装的 Visual C++ Redistributable。这个改动本身是对的,但由于 1.0.8 随后因 OTA_TRASH_CORRUPT 问题被 recall,它成了"引入新文件但被撤回"的典型。

1.0.9 继承了 1.0.8 的 build,自然也含有这 9 个 DLL,但 1.0.9 的差分包相对 1.0.8 计算,所以这些 DLL 不会再被列入。结果就是:1.0.7 客户跳过 1.0.8,直接升到 1.0.9,永远收不到这些 DLL。

同时,还有一个独立的关联问题:修复脚本 Fix-VCRuntime.bat 本身因为编码问题(UTF-8 vs GBK 的冲突)无法正常执行,所以即使用户尝试手动修复,脚本也会报错。这是打包流程和脚本编码的两个独立根因,在同一时间窗口内暴露了出来。

修复方案的权衡

面对这个问题,有两条主要路径可选:

方案 A:全量兜底

跨版本升级时直接下发完整 full.zip,而不是差分包。

优点

  • 简单粗暴,一个包包含所有文件,不存在漏 DLL 的可能
  • 不需要调整发版流程

缺点

  • 包体积大(差分包通常 100-200 MB,全量 500+ MB)
  • 客户网络环境差时升级时间长,失败率高
  • CDN 带宽成本增加

适用场景

  • 一次性应急(发 hotfix 回滚)
  • 用户基数小、网络条件好
  • 跨度非常大(比如 1.0.1 → 2.0.0)的少见场景

方案 B:严格步进

服务端按发布链下发下一个版本,不允许跨级。即使是 recalled 版本也保留在链上,只是不作为升级终点。

优点

  • 包体积小,增量包通常 100-200 MB
  • 每个客户走相同的升级链路,问题复现容易排查
  • 充分利用增量包设计的初衷

缺点

  • 升级链路长(1.0.7 → 1.0.9 时需要先升 1.0.8,即使 1.0.8 被 recalled)
  • 如果某个中间版本有严重问题(比如 OTA 卡死),客户升级时仍会卡在它上面

适用场景

  • 绝大多数常规发版
  • 网络条件多变的客户群
  • 需要严格控制升级质量的生产环境

为什么选择 B:有几个关键因素。首先,客户群分布广(企业内网、弱网、移动网络混合),300+ MB 的全量包在弱网场景下升级失败率会明显增加,反而增加了支持成本。其次,一旦引入全量兜底,就容易形成"跨版本时总是下发全量"的惯性,长期来看放弃了增量包的设计初衷。最后,严格步进虽然升级链路长,但每个版本的 DL + apply 成本都是可预测的,失败时也容易重试。

通过让 recalled 版本保持在链上(只改为 non-GA 状态),升序逻辑会自动跳过它们,下一个 GA 版本会包含完整的累积增量。1.0.9 被 recall 后,G26 升序对 1.0.7 客户会自动推荐 1.0.10;1.0.10 的差分是相对 1.0.7 算的(通过 FROM_VERSION=1.0.7 显式指定),包含了 1.0.8 引入的 9 个 DLL 以及后续的所有变更。这样既保持了包体积小的优势,又确保了跨越 recalled 版本的客户能拿到完整增量。1.0.10 的差分包最关键的是 Removes 为 0——任何跨版本的客户 apply 都不会留下废文件。

为什么撤回版本不能从链上摘掉

这里有一个容易忽视的细节:即使某个版本被 recall,也不能从版本链中物理删除。

如果把 1.0.8 从数据库里删掉,升序逻辑会直接跳过它,导致 1.0.7 客户被推荐到 1.0.9——此时就回到了原来的问题。修复的关键是让升序逻辑仍然"看到" 1.0.8 的存在(用于确定差分链路),但把它的 rollout 状态改为 non-GA,使得它永远不会被作为升级终点。

-- 这样的做法是错的(会复现问题):
DELETE FROM releases WHERE version = '1.0.8';

-- 正确的做法:
UPDATE releases SET rollout_status = 'recalled' WHERE version = '1.0.8';

换句话说,版本链的完整性是优于减少数据库记录的。每次新版本发布时,baseline 计算和 diff 生成都要以完整的版本序列为基础。所以即使 1.0.8 和 1.0.9 都被 recall,它们仍然需要在数据库中占据一个位置,作为"这个版本存在过,但用户不应该升级到它"的标记。升序逻辑看到 recalled 标记后会直接跳过,继续往后找第一个 GA 版本。

修复后的数据库状态是:

1.0.7   GA         minClient=1.0.5
1.0.8   recalled
1.0.9   recalled   ← 这一步至关重要
1.0.10  GA         minClient=1.0.7

现在 1.0.7 客户升序时会依次检查 1.0.8(recalled,跳)→ 1.0.9(recalled,跳)→ 1.0.10(GA,命中)。1.0.10 的差分包是通过 FROM_VERSION=1.0.7 显式指定的,所以它包含 1.0.8 和 1.0.9 引入的所有文件。

预防和后续

立即修复是发 1.0.10 并 recall 1.0.9,已在 2026-05-11 19:42 完成。发布 1.0.10 时的关键操作是显式指定 baseline:

FROM_VERSION=1.0.7 \
MINISIGN_PASSWORD='...' ADMIN_PASS='...' BUILD_HOST_PASSWORD='...' \
ROLLOUT_STATUS=whitelist \
  bash scripts/release.sh 1.0.10

这样 diff 生成引擎会计算 1.0.7 ↔ 1.0.10 的完整差异,而不是默认的"上一个 GA"(1.0.9)。然后执行 PATCH 和 recall 操作:

# 1. PATCH 1.0.10 → ga
curl -X PATCH "$ADMIN/admin/releases/$ID/rollout" -d '{"status":"ga"}'

# 2. recall 1.0.9(防止升序仍然推荐给 1.0.7 客户)
bash scripts/release/recall.sh <release-id>

# 3. 清 redis rollout-wrapper cache(立即生效,不等待 cache 过期)
redis-cli --scan --pattern 'cache:rollout-wrapper:*' | xargs -r redis-cli DEL

预防层面,在 wakou-release skill 中加了三条新检测项(G27/G28/G29):

  1. 检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合
  2. 评估是否存在客户群可能跨过 recalled 版本拿增量
  3. 如果是,强制新版本 specify FROM_VERSION=<最老活跃 GA 版本>

这三条检测在每次发版前自动跑,有问题的组合会在 release.sh 阶段被拦住,不允许走到"上传 CDN"这一步。

后续架构层改进(未实施):admin server 的 G26 解析逻辑可以加「baseline 链路完整性检查」。具体是:当升序逻辑返回 manifest 给客户时,校验 client.version → release.baseline_version 之间的所有版本是否都是 GA(无 recalled)。如果链路中存在 recalled 版本,admin 直接返回错误码(比如 EXTRACT_FROM_OLDER_BASELINE)给 OTA worker,让客户 fallback 到全量包请求,而不是无声给一个不完整的差分。

这个改进的收益是"防御性更强"——即使人肉操作出了问题(比如遗漏了 FROM_VERSION= 指定),系统也能主动检测并避免分发坏包。但代价是需要修改:

  • release-manifest.json 的 schema(添加链路检查标记)
  • admin 的 update.ts::resolveBonjourCliPath 逻辑
  • OTA worker 的错误码处理(识别新的 fallback 信号)
  • CDN 需要同时提供 full-from-empty.zip(或允许 diff 降级到 full)

鉴于目前的发版流程和人肉检测已经能防住这个问题,架构层改进被标记为后续单独需求,暂时先依赖流程检查和 FROM_VERSION= 人肉兜底。

星野的头像

星野 XINGYE

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