Skip to content

CI Release Reliability

Yao Jingxi edited this page Sep 12, 2026 · 1 revision

CI、进程与发布:所有等待都必须有边界

CI 和发布问题的危险之处在于:业务代码可能“看起来没错”,但测试 runner、WebKit、管道、子进程、更新 helper 或 workflow gate 没有明确的完成条件,于是问题表现为随机红、永久卡住或产物缺失。

1. 同步等待会占满 executor,也无法正确收尾

#284 — macOS 测试同步阻塞导致 CI 超时

根因包含多个互相放大的无界边界:

  • Task.detached 中的同步 semaphore 等待可能占满 Swift cooperative executor。
  • Git fixture 使用同步等待;失败/取消时可能留下子进程。
  • runner 超时只发 SIGTERM,忽略信号的进程使测试继续等。
  • 大 stdin 在子进程不读取时阻塞;关闭管道又可能触发 SIGPIPE。
  • 主进程退出后,继承输出管道的后代进程使 EOF 永远不到达。

方案:

  • 将可能阻塞的同步协议替身放到 GCD worker;TestGate 使用一次性、带 deadline 的释放,并由 defer 清理。
  • TestProcess/MacProcessRunner 异步持续 drain stdout/stderr,支持启动前取消、5 秒超时、TERM 后升级 KILL,continuation 只 resume 一次。
  • stdin 写入移出主线程,超时从写入前就开始;输入管道使用 F_SETNOSIGPIPE,把关闭转成普通写错误。
  • 手动 stop 也安排有界强制终止;运行时探测和更新安装 helper 使用明确 timeout。
  • 将 GitStatusObservationTests 拆到独立测试进程,隔离文件监听和 Git child lifecycle。

验证:PR 报告聚焦回归 37/37、进程抗阻塞 3/3、macOS CI 主测试 528/528,静态审计确认没有 waitUntilExit()、无参 .wait() 和 readDataToEndOfFile() 残留。

可迁移经验:任何 wait 都要回答四个问题:谁会唤醒?最长多久?超时后如何终止?所有 child/pipe/observer 谁清理?

2. watchdog 要测“无进展”,不要误判缓冲输出

#371 — Swift 测试 watchdog 把 buffered output 误归因给单个测试

根因:旧 watchdog 以单个测试输出和固定计时判断超时;输出缓冲、teardown hang 或 runner 自身 stall 时,最后看到的测试名不一定是实际阻塞者。

方案:改为 stall watchdog,记录输出时间戳、termination status,并在 stall 时 best-effort 采样 macOS thread;增加 stall、teardown hang、reported duration budget 和 spawn cleanup 覆盖。

经验:诊断工具必须记录“最后一次进展”与“进程是否退出”,不能把日志中的最后一个名字当作因果证据。

3. 真实 WebKit 不应该混入普通并行生命周期测试

#224 — LinuxDo 插件测试偶发挂起,随后暴露 Swift IRGen 崩溃

根因有两个独立层次:

  • 普通测试直接创建真实 WKWebView;并行运行时 WebKit browser process、delegate 生命周期和测试进程退出竞态,导致 CI timeout。
  • 拆出 WebKit 后,插件校验脚本又执行了完整 Swift App build,Swift 6.2.1 在 IRGen/debug type 生成阶段 crash。这不是插件断言失败。

方案:把普通生命周期测试与串行 WebKit integration test 分离;补 Swift 6.2 actor/weak 兼容处理;按变更路径选择 CI lane;拆分 Plugin/Database/macOS/Windows workflow;加固最终 gate;将 macOS release verification 升级为真实 App/DB helper/plugin/DMG smoke test。

经验:外部 GUI runtime 要单独隔离,编译器/SDK 版本也应当作为测试矩阵的一部分,而不是把所有失败归为 flaky。

4. 更新安装是进程生命周期,不是一个按钮回调

#430 — DMG 下载完成后卡在 Installing update…

根因:replacement helper 无限等待 Lithe 退出;NSApp.terminate 还可能被未保存文档 prompt 或 module/session shutdown 卡住。

方案:在下载/安装前确认 unsaved work;使用专用 update termination path;helper 以 nohup 脱离当前进程;等待退出约 30 秒后按 TERM/KILL 有界升级,soft quit 不完成时有 hard-exit fallback。

未覆盖边界:PR 的自动化检查通过,但手工“下载→退出→替换→重启”和“未保存文件先提示、取消后状态恢复”在描述中仍标为待手工确认。文档不能把它写成已完成端到端验收。

5. 更新检查不应依赖匿名 API 配额

#206 — GitHub REST API 403 让应用误以为没有更新

根因:应用内更新依赖 GitHub anonymous REST API;共享公网 IP 触发 rate limit。网络身份和 GitHub API 配额被错误地当成产品更新能力的一部分。

方案:发布静态 latest.json,包含 schema、版本、Release URL、arm64/x86_64 DMG URL 和 SHA-256;更新器校验 schema/HTTPS/架构/哈希,下载失败清理临时文件;macOS/Windows 同版本发布串行化,避免覆盖共享 manifest;保留浏览器 Release fallback。

经验:更新元数据是兼容协议,应该可缓存、可校验、可回滚;“检查成功”只能在 manifest、架构选择和校验都成功后记录。

6. 发布物缺失要让 workflow 早失败

#301 — Windows 版本缺少 updater endpoint、public key 和签名产物

根因:updater signing 被当成 optional,因此 v0.3.6 仍能打包成功,却没有 latest.json、签名 installer 和 endpoint/public key;用户安装后无法使用 in-app update。

方案:稳定 Windows release 强制要求 signing 配置;始终打包/上传 signed installer、signature、latest.json;共享脚本校验中英文 release note 的 heading/order/content,并用测试覆盖 manifest/签名前置条件。

经验:对发布来说,“构建成功”不等于“可更新”;必须把运行时必需资产变成 gate,而不是事后人工检查。

7. Resource busy 的第一步应该是证据,不是盲目重试

#518 — DMG 打包间歇性 hdiutil: Resource busy

性质:这是一个诊断型 PR,不是最终根因修复,正因为如此很值得学习。

已确认:前面的 lipo、签名检查都通过;失败快速发生而非 timeout;同一 runner image 仍可能一成一败;失败样本残留 diskimages-help,但暂不能证明它是因还是果。

方案:在 hdiutil 前后采集已挂载 disk image、lsof 占用 staging 的进程、/Volumes 内容和 hdiutil -debug;所有诊断写 stderr,保留 stdout 最后一行是 DMG path 的机器契约;不直接加 retry,以免掩盖确定性原因。

经验:诊断脚本也有接口契约;先区分 flaky、确定性失败和环境残留,再决定修复或重试。

8. CI toolchain 和脚本参数也是产品边界

#104 — 插件构建没有使用 setup 安装的 Swift

根因:脚本通过环境中偶然的 compiler 解析,而不是 PATH 中配置的 Swift;CI setup 的 Swift 6.2.1 没有真正被使用。

方案:从 PATH 解析官方插件 compiler,SDK 仍通过 xcrun 发现;配套做 shell syntax 和 plugin verification。

#127 — Windows packaging 参数绑定错位

根因:PowerShell array splatting 把 -Configuration 当作 positional Configuration 值,参数在脚本真正启动前就错位。

方案:使用 hashtable splatting 按参数名绑定,并对 signed/unsigned 两种模式做 actionlint、PowerShell binding 和 boundary check。

经验:构建脚本的参数名称、toolchain 版本和 stdout/stderr 约定都是稳定接口,不应依赖 shell 的“通常行为”。

9. 测试和发布可靠性的不变量

  • 所有等待都有 deadline;deadline 触发后有确定的降级或终止路径。
  • 进程输出持续 drain;stdin 可写、可取消、可超时;TERM 后有 KILL 兜底。
  • continuation/回调最多恢复一次,observer、pipe、child、temporary bundle 都有 defer/生命周期清理。
  • 外部 runtime(WebKit、AppKit window、DMG/hdiutil)与纯逻辑测试隔离,必要时串行化。
  • watchdog 记录 progress timestamp、退出状态和进程树,不凭最后一行输出猜原因。
  • release gate 检查用户真正需要的产物和签名,不只看 compile/package exit code。
  • 失败诊断不能破坏已有机器契约,例如 stdout 最后一行是产物路径。

Clone this wiki locally