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):
- 检查 releases 表里有没有「ga → recalled 但后续版本 baseline 还指着它」的组合
- 评估是否存在客户群可能跨过 recalled 版本拿增量
- 如果是,强制新版本 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= 人肉兜底。
■