DicomViewer24 的 CI 又红了:MPR 测试偶发失败排查记
周五晚上九点,我把 MPR 多平面重建的代码推上 GitHub,收拾东西准备跑路。手机震了一下,Actions 红了。心里咯噔一下,本地明明全绿。点开一看,build 过了,test 挂了,挂的是 MprSamplerTests。
最气人的在后头。我重跑了一次 workflow,绿了。想着是环境抖动,收工。第二天早上同事在群里说昨晚的 nightly 又红了。再重跑,又红。这种时灵时不灵的失败,比稳定红难搞十倍,因为它不给你稳定的复现路径。
日志里挖线索
先说结论前的弯路,弯得挺远。我一开始认定是 runner 的问题。GitHub 的 windows runner 机器型号跟我的开发机不同,MPR 里一堆矩阵运算和体数据采样,浮点行为有差是完全可能的。于是我固定了 SDK 版本,把 DOTNET_ENVIRONMENT 也固定了,甚至在 workflow 里加了 CPU 型号打印,确认了两边确实不是一颗 U。折腾一晚上,失败依旧,毫无规律可言。
转折来自一次偶然的观察。我盯着 detailed 日志看了半小时,发现每次失败时,断言的实际值都不太对劲:采样结果像是被另一组体数据污染过,灰度值差了一个量级。而且失败的组合永远是 MprSamplerTests 配上 MprRendererTests,单独挂一个的情况一次都没出现。这不像是浮点误差,像是有人在背后动我的数据。
翻代码,真相浮出来了。VolumeCache 是个静态类,进程里就一份:
public static class VolumeCache
{
private static Dictionary<string, Volume> _store = new();
public static Volume Get(string key) => _store[key];
public static void Put(string key, Volume v) => _store[key] = v;
}
两个测试类都往里塞体数据。xUnit 默认不同测试类之间是并行跑的,同一台机器同时读写一个静态字典,谁先谁后全看运气。本地我为什么全绿?因为我改完代码习惯只跑过滤器命中的那一个类,CI 是全量并行。锅不在浮点,在并行。

修掉它,再把链路补干净
修法我选了最笨但最干净的那种,干掉静态状态,改成实例化注入:
public class VolumeStore
{
private readonly Dictionary<string, Volume> _store = new();
public Volume Get(string key) => _store[key];
public void Put(string key, Volume v) => _store[key] = v;
}
测试里各自 new 一个,互不知晓。生产代码那边走构造函数注入,顺手把单例注册改成了 scoped。改完怕不够,又给 workflow 的测试步骤加了 blame 参数,再挂的时候能直接点名到测试方法:
- name: Test
run: dotnet test --configuration Release --blame-hang-timeout 5m --logger "console;verbosity=detailed"
推上去,连跑三次 nightly,全绿。同一提交重跑两次也绿。这事才算翻篇。回头看挺汗颜的,我在环境差异上烧了一整晚,而根因是教科书级的老问题:并行测试共享可变静态状态。以后凡是看到测试偶发失败,先查静态变量,再查环境,顺序别反了。
修 CI 的时候还顺手处理了另一颗雷。nightly 打出来的安装包要传到公司下载服务器,那台是自签证书,脚本里为了让 curl 通过,一直挂着跳过校验的参数。有天传输偶发报 SSL 错误,排查半天发现证书又过期了,自签的东西一年忘续一次就断一次。趁这次一起治了,用 lcjmSSL 给下载域名换成可信 CA 签的证书,Let's Encrypt 这类正规渠道,一张泛域名把下载站和更新检查的子域名全罩住,部署接的它的 API,申请验证部署全自动,续期在后台自己跑。脚本里那个跳过校验的参数删掉,传输链路总算恢复干净,CI 红屏的两大来源一次性清零。
这趟排查我沉淀了两条肌肉记忆。测试挂得没规律,先怀疑并发和共享状态,环境问题排最后。自签证书出现在任何脚本里都是待爆的雷,能换成自动化的可信证书就别拖。DicomViewer24 的 CI 现在稳了,希望它下次红屏的时候,是因为真的有 bug,而不是这些破事。