一次测试基础设施修正:验收结论不该和产生它的进程同生共死,“被打断”也不该冒充红灯。

1. 最贵的不是红灯,是“不知道”⌗

设想一条自动修复流水线:agent 改完代码,候选提交要送到一台共享测试机上跑测试,拿到绿灯才能落地。

这条链路上有三个常见现场:

  • 测试机是多方排队共享的,候选可能要等别人的运行先结束;
  • 测试机负载高时,SSH 握手、探针都会超时;
  • 发起验收的那一侧,运行时自己也会重启——升级、回滚、崩溃。

旧做法里,验收的“结论”实际上存放在两个地方:发起方进程的内存,以及那条后台 shell 会话的输出缓冲。两者都是易失的。运行时一重启,会话没了,内存没了,测试机那边哪怕已经跑出了 exit 0,这一侧也只剩一个问题:刚才那次到底过没过?

答不出来,唯一稳妥的动作就是再跑一次。再跑一次又要排队,排队期间又可能遇上重启……于是同一个修复被反复派发、反复验收,始终拿不到一个可以引用的结论。

问题不在测试慢,而在于结论没有载体。

2. 结论要有自己的文件⌗

这次改动新增了 src/testing/verification-record.ts,82 行。核心思路一句话:每次远程运行在开始之前,先在磁盘上建一条记录;之后的每个状态变化都写回这条记录;进程死了,记录还在。

runRemote()
  │
  ├─ startVerification()     → <id>.json  (state: pending, pid, host, command)
  │                          → <id>.log   (空文件)
  │
  ├─ executeRemote()  每个输出块:
  │     ├─ 追加到 <id>.log(原始字节,stdout/stderr 都写)
  │     ├─ 看到排队行   → state: queued,queue = 那一行
  │     └─ 看到取槽/开跑 → state: running
  │
  ├─ 正常返回  → finish(result)    → passed / failed / interrupted
  └─ 抛异常    → interrupt(message) → interrupted

记录里的状态是一个封闭的枚举:

状态含义谁写入
pending记录已建,还没进队列startVerification
queued在等测试机槽位,附带最近一条排队描述输出解析
running已取得槽位,命令开跑输出解析
passed命令跑完,退出码 0finish
failed命令跑完,退出码非 0finish
interrupted没有可信结论finish / interrupt / 读者

写盘用的是“写临时文件再 rename”,读者永远看不到半截 JSON。读取不依赖原进程:scripts/verification-status.ts <id> 就能把任意一条记录打印出来。运行开始时,runner 还会把记录路径和这条恢复命令打进输出流里——这样即便上游 agent 只保留了输出的前几行,它也知道去哪儿找结论。

这件事的关键不是“加了日志”。日志是给人翻的,记录是给下一个读者用的结构化真值:重启后的运行时、另一个线程里的 agent、一个只想知道“那个提交过没过”的脚本,读的都是同一份 JSON。

3. 第三种结论:被打断不是红灯⌗

只有 passed / failed 两态的验收系统,迟早要回答一个它答不了的问题:“进程半路死了,算什么?”

算 failed,会冤枉代码,触发一轮无意义的修复;算 passed,就更糟了。这次的设计是给它一个独立的名字:interrupted。

有三条路径会走到这里:

一、读者发现记录的主人已经死了。 readVerification 读到一条 endedAt === null 的记录时,会检查记录里的 pid 是否还活着:

if (record.endedAt === null && record.state !== 'interrupted' && !alive(record.pid)) {
  record.state = 'interrupted'
  record.why = '验证进程已退出,未写入结论;不是用例失败,不自动重跑'
  // The exact interruption time is unknown; do not substitute the reader's time.
  save(base, record)
}

注意那行注释:endedAt 保持 null。中断发生的准确时间没人知道,读者看到的时刻只是“发现的时刻”,把它写进 endedAt 等于伪造证据。宁可留空。

二、运行正常返回,但结果本身不是结论。 finish 的判定:

record.state =
  result.kind === 'unavailable' || result.timedOut || result.code === null
    ? 'interrupted'
    : result.code === 0 ? 'passed' : 'failed'

测试机不可达、超时、没有退出码——都没有回答“代码对不对”,统统归入 interrupted。

三、执行中抛了异常。 interrupt(message) 记下异常原文,状态同样是 interrupted。

测试把这条边界钉得很死:

测试断言
主人进程还活着状态保持 pending,不会被误判中断
主人进程已死状态变为 interrupted,endedAt 仍为 null,why 含“不是用例失败”,并已落盘
主人死后又“活了”(pid 复用)已落盘的 interrupted 不会被改回去
exit 0 / exit 1分别为 passed / failed,原始输出逐字节保留
不可达 / 抛异常interrupted,原因原样保留

“不自动重跑”这几个字是故意写进 why 的。interrupted 的语义是“没有结论”,下一步该做什么——等测试机空下来、换个时间、上报——由调用方决定,记录本身不替它决定去烧资源。

4. 把“基础设施坏了”从“测试挂了”里剥出来⌗

结论三态之后,还需要分清 interrupted 内部的原因,否则“测试机过载”和“网络断了”在调用方眼里没有区别。改动给 unavailable 结果加了一个封闭的 InfraReason:

reason触发条件
ssh-banner-timeoutSSH 连上了但握手超时——机器在,只是忙
probe-no-response探针连接后在超时时间内一字不回
ssh-connection其他 SSH 失败(拒绝连接等)
runner-environment测试机侧 runner 自身出错(拉代码、装依赖)
process-terminated进程被信号终止或 exit 143
pg-statement-timeout非零退出且输出里出现数据库语句超时

最后两条值得多说一句,因为它们改变了一些过去会被当成“红灯”的情况。

进程被 SIGTERM 杀掉,退出码是 143,以前会和断言失败一样作为一个非零退出码回到调用方。可它说明的是“有人杀了它”,不是“代码错了”。测试数据库的语句超时同理:它通常反映的是共享环境的负载,而不是被测逻辑。

这里的分寸在于不要矫枉过正。测试里专门有一条反例:

test('assertion failure stays a failing test, not infrastructure', async () => {
  expect(await runRemote(options('assertion-failed')))
    .toMatchObject({ kind: 'ran', code: 1, timedOut: false })
})

真正的断言失败,仍然是 ran + code: 1,仍然是红灯。把所有失败都甩锅给基础设施,和把所有中断都算成红灯一样,都是让结论失真。

还有一处措辞上的小改动很能说明态度:探针无应答的提示从“测试机过载?”改成了只陈述事实——“在 N 秒内无应答”。原因的猜测交给 reason 字段的分类,消息文本里不再带问号式的推断。

5. 排队要看得见⌗

被重复派发的另一个诱因,是排队看不见。agent 只看到“在等”,不知道前面还有几个、谁占着、大概还要多久,最自然的反应就是怀疑卡死,然后放弃重来。

runner 的排队行这次多了三样东西:

waiting-for-slot 等待测试机槽位 30s
  · 前面 2 个(落地队列前面 0 个)
  · 持有:<run-id>(shared,120s,bun-test ./src/a,客户端槽位预算余量约400s(含90s准备宽限))
  · 预计窗口未定(还取决于准备耗时及前序运行结束)
  · load …
  • 两个队列分开数。 测试机有一个为落地保留的槽位,落地类运行只关心自己那条队;共享队前面排了多少,对它没有意义。
  • 持有者带命令和剩余预算。 槽位的 info 文件从 4 个字段扩到 6 个,新增了超时预算和命令描述。剩余量按 budget + 90 - (now - since) 算,90 秒是准备阶段的宽限,下限截到 0。
  • 诚实地说“未定”。 剩余预算只是上限,不是预测:准备阶段多久、前序运行何时提前结束都不知道。所以排队行明写“预计窗口未定”,不编一个 ETA。

而这一行,恰好也是 verification record 用来把状态切到 queued 的那一行——排队信息既给活着的 agent 看,也留在记录的 queue 字段里给重启后的读者看。

6. 顺手堵上的另一个洞:swap 涨得太快⌗

验收不可靠还有一个来源:发起方所在的机器自己扛不住。本机上并发跑的测试会在内存吃紧时被“卸载”(shed)——终止非落地类的运行,给系统喘气。原规则是:

/** Swap alone is sticky on macOS; require kernel pressure as well. */

即只看 swap 占用率不够,因为 macOS 的 swap 用上去就不太下来;必须同时有内核内存压力 warn 以上才触发。这条规则防误杀是对的,但留了一个盲区:swap 在一分钟内暴涨几个 GB,而内核压力指标还没来得及升级——等它升级时,机器可能已经卡到连 shed 本身都跑不动了。

新增的规则看的是增速而不是水位:

const rapidSwap = cfg.shedSwapUsedPercent > 0
  && growth >= 2048
  && (next.swapGrowthMbPerMinute ?? 0) >= 2048

增速只在两次采样间隔不超过 90 秒时才计算。测试给出了边界:

前一次这一次间隔结果
6 GB8.8 GB60s触发,原因“swap 快速上涨”
6 GB6.5 GB60s不触发(增量不足 2 GB)
6 GB8.8 GB600s不触发(间隔太长,速率无意义)
6 GB8.8 GB0s不触发(无有效间隔)
任意任意—shedSwapUsedPercent = 0 时整个 swap 判据关闭

触发后,落地类运行依旧不在卸载范围里——测试断言里被终止的只有共享类那一个 pid。水位规则管“已经很满”,增速规则管“正在变满”,两者互补,原来防误杀的那条也没动。

7. 为什么这比“多加几次重试”好⌗

面对“验收结论总拿不到”,最常见的反应是加重试:超时就重跑,断线就重跑,重启后从头跑。这条路的问题是:

做法结果丢失时中断时对共享测试机
内存里的结论 + 重试只能重跑和失败混在一起负载越高重试越多,正反馈
持久记录 + 三态结论读记录即可独立状态,原因可分类不自动重跑,不加压

重试解决的是“偶发失败”,而这里的病根是“结论无处存放”。前者用后者的药,只会让共享测试机在最忙的时候收到最多的请求。

持久记录把问题换了一种问法:不再是“能不能再跑一次”,而是“上次那次留下了什么”。大多数时候,答案已经在磁盘上了。

8. 还没解决的⌗

公平起见,列一下这套设计的边界:

  • pid 存活判断是近似的。 主人进程死后,如果 pid 恰好被复用,读者会以为它还活着,记录会一直停在 queued / running。测试保证了“已判 interrupted 不会回退”,但没有防住“该判时没判”。更严格的做法要比对进程启动时间。
  • 记录只在发起方这一侧。 如果发起方在测试机跑完、但还没把结果写回之前就死了,记录会是 interrupted,而测试机那边其实有一个真实的退出码。要把它捞回来,需要测试机侧也留一份结果——这次没做。
  • PG 超时靠输出文本匹配。 识别依赖一条错误消息的措辞,换了数据库驱动或语言设置就可能漏判;漏判的后果是退回旧行为(当作普通非零退出),不会误把红灯变绿。

这些都不改变本次改动的核心判断:一个结论如果只能活在产生它的进程里,它就不是结论,只是一次运气。