> 断言"它拒绝了"不能用非零退出码——任何东西都会非零退出

Systems & Infrastructure Intermediate 2026-10-11 18:09 2026-10-11
#ci #gates #exit-codes #testing #failure-modes #reproducibility

一个项目里有一组"必须失败"的门禁:某工具在没有后端时必须拒绝跑,画图脚本在没有结果时 必须拒绝编数据。它们的断言写成了 exit != 0。

// TABLE_OF_CONTENTS

结论先行

一个项目里有一组"必须失败"的门禁:某工具在没有后端时必须拒绝跑,画图脚本在没有结果时 必须拒绝编数据。它们的断言写成了 exit != 0。

在这个项目自己的工作站上,画图那条门禁在一个 ModuleNotFoundError 上绿了好几周。

因为非零退出码是个大杂烩:缺一个 Python 模块、崩溃、命令打错字,全都非零。 每一种都能让一条名叫"拒绝编造数据"的门禁通过,而那段拒绝逻辑从来没有运行过。

修法是让门禁去匹配拒绝的原话:

refused() {
  # refused <exit code> <captured output> <phrase>
  [ "$1" -ne 0 ] && printf '%s' "$2" | tr -s '[:space:]' ' ' | grep -qF "$3"
}

GATE_OUT=$(./titans_llm_experiment --backend vllm --port 59999 2>&1)
check "refuses to run with no backend" \
    refused "$?" "$GATE_OUT" "will not fall back to a stand-in"

tr -s '[:space:]' ' ' 把空白压平,这样把提示信息重新折行不会让门禁变红—— 同一个项目的 CI 曾经因为相反的原因红过:它逐行 grep 一条被折行的信息。

为什么

这件事有三层,每层都踩过:

一、退出码的语义空间不能让"工具坏了"和"工具正确拒绝"撞车。 同一个项目里 generate_figures.py 缺 seaborn 的退出,和它按设计拒绝的退出, 对调用方完全不可区分。所以第三种状态是必要的:

if printf '%s' "$GATE_OUT" | grep -q 'ModuleNotFoundError'; then
  # 跑不起来,所以无法判断它会不会拒绝。报告出来,
  # 既不算通过也不算失败——在这里打印 "ok" 正是这段重写要去掉的缺陷。
  printf '  skip %s -- cannot verify on this host: %s\n' ...
else
  check "refuses to fabricate data" ...
fi

二、成功的退出码可以就是那个失败。同一个工具在"发布一个决策"模式下必须违反 新鲜度 SLO 并以 5 退出;它要是退出 0,意味着那个契约不再被执行了。 所以脚本断言的是 test $? -eq 5,不是 -eq 0,也不是 -ne 0。

三、"点数太少无法判断"必须是拒绝,不能是通过。这两者从退出码 0 看过去一模一样, 所以也单独断言成 -eq 2。

把退出码设计成语义的(0 跑了 / 1 数据错 / 2 用法错 / 3 协议违反 / 5 SLO 破了 / 6 回退), 门禁才有东西可断言。代价是:改任何一个都会弄坏构建——这正是你想要的那种代价。

怎么用

给每条"必须失败"的断言问三个问题:

  1. 这条断言能被一次崩溃满足吗? 能,就把它改成匹配拒绝的原话。
  2. 拒绝的原话和"工具坏了"的输出能撞车吗? 能,就加第三种状态(skip), 并且明确记账为"未验证",不要记成通过。
  3. 这个工具正确工作时应该以几号退出? 写进文档表格,并让脚本断言那个具体的数, 不是 != 0。

匹配原话时把空白压平(tr -s '[:space:]' ' ' + grep -qF),否则重排版就会误报。

一条判断"这类门禁是否已经坏掉"的捷径:永不触发的门禁和永远触发的门禁, 在绿色构建上看起来完全一样。见 测不出失败的门禁比没有门禁更糟——它在制造信心。

出处