技术8/21/2026·约 4 分钟

拿文件监视器去测文件监视器

我们的架构守卫一边监视着它正在校验的那个仓库。那个监视器的测试在 macOS 通知时序上反复 flake,而结局是删掉了它们本来要守的那条不变式。

守卫在监视它所守的东西

我们的架构守卫做了一件有点不寻常的事:它在校验仓库的同时监视这个仓库。

理由是成立的。一个靠读文件来决定某条不变式是否成立的守卫,只有在它读到的文件就是现存的文件时才正确——而代码、测试和配置都在一次长时间的检查底下发生变化。所以这个工具在工作时记录原生的文件系统活动,用平台自己的变更通知 API,并把对目录树的意外写入当成一个要上报的条件,而不是悄悄校验一个移动中的目标。

它所强制的那条规则是粗暴版本:运行期间仓库不发生变化。 违反它,这一次运行就不可信。

测试在 flake,而那不是测试的错

那个监视器的测试间歇性失败。不频繁,也不明显——这是失败最坏的形状,因为每一次出现都像一次性事件,而它们从不累积成一个你能据此行动的规律。

原因不在我们的代码里。在 macOS 上,文件系统变更通知由一个系统 daemon 投递,而投递受那个 daemon 调度的限制。事件不是在它发生时投递的,而是在 daemon 想起来时才投递的。这个窗口很小、通常看不见、偶尔不是。

于是我们造出了一个通过与否取决于一个我们既不控制、也无法缩短的区间的测试。我们最先伸手去拿的每一种缓解手段——更长的 sleep、重试、放宽超时——都是在治症状。更长的 sleep 提高了通过的概率,却没有改变这个断言是关于一次投递时间这个事实。

这个区别比听起来重要。一个「多等一会儿」的测试,仍然是在断言某个不可观测的东西,只是更贵地断言。如果投递确实可能迟到,那么在某个超时值上断言就从保证变成了抛硬币,而那个数字是你猜的、穿着工程外衣的。

平台实际上保证了什么

真正有产出的一步,是不再要求监视器去证明它证明不了的事,并把问题拆成两半。

FSEvents 投递元数据和命名空间变化。它擅长这些,而它是因果栅栏的正确工具——也就是「即将被处理的那个事件属于本次运行、而不是更早某次」的保证。它是一个投递机制,有一个明确声明的异步契约。

kqueue 回答的是另一个问题。它钉住仓库根的 inode,让结构性身份变化可以被同步地观察到,并监视单个文件,从而让某次授权交换的目标可以被直接检查、而不是从一个事件流里推断。任何一个非零的证明增量,都必须由那个特定文件上的重命名或删除来匹配。

两个机制、两个保证、两种不同的失效方式——而真正起作用的那条规则是,它们都不从访问时间推断因果。原来的 bootstrap 做的恰恰是这件事,所以把它换成因果边界才是那个真正推动局面的改动,而不是那个加了一次 sleep 的改动。

原生那一半工作的另一半在上游。我们 pin 的那个通知库自己有生命周期问题——一个被吞掉的流启动错误、一个在负载下可能唤不醒 run loop 的关闭、丢弃一个 watcher 会清掉它本不该清的设备级历史,以及一条原始路径或一个未知的未来事件标志能够穿过原生回调展开。每一个都是那类一个月出现一次、在别人的机器上、而且无法按需复现的缺陷。把上游的修复取过来并 pin 住,比把它们诊断两遍更便宜。

十二个提交,终点是一次删除

这个顺序值得列出来,因为结局是没人会预先计划的那部分:

  • 访问时间 bootstrap 换成因果边界;
  • vnode 期望查找限定到 macOS——因为它在别处从来就没工作过,只是一直在假装工作;
  • 上游生命周期修复被取进来,随后又被部分回退,因为它们改变了我们依赖的行为;
  • FSEvents 生命周期测试改成确定性的;
  • 补上 panic 覆盖,因为原生回调路径需要可证;
  • 这些测试之间互相串行化;
  • 最后一天:移除依赖 daemon 的 FSEvents 探针。

两天之后,规则本身也没了。守卫不再守「运行期间仓库不发生变化」。

不是因为它不再是真相。而是因为我们无法验证它,而且我们已经用两周确立了尝试的代价是真实的:那些因代码之外的原因而 flake 的测试,被重跑而不是被阅读,并且训练一支团队把守卫变红当成噪音而不是信息。

一个你无法可靠观测的不变式不是安全属性。它是一个带着好故事的 flake 来源。

我们留下了什么

这些工作几乎没有浪费,而活下来的部分正是那些不依赖投递时序的部分:

  • 投递栅栏与结构身份之间的拆分,这比一开始那个单一监视器是更好的设计;
  • 「因果绝不从访问时间推断」这条规则;
  • 被 pin 的上游修复,它们现在归我们了,不再是一个月一次的意外;
  • 以及被移除的那些探针——它们把沉默的 flake 换成了一个诚实的缺席。

最后这一条是我会第一个指出来的改动。一套因为不再包含一个无法变可靠的测试而变绿的守卫,比一套靠运气变绿的守卫更值得信任。我们接受的失败是一个真实的失败——运行期间的一次未授权编辑,不再被那条具体的规则抓住——而它是一个我们选择的失败,带着名字,而不是我们靠无视一个 flaky 测试两周而累积出来的那种。

小结

在写下一个关于时序窗口的断言之前,先弄清它归谁所有。如果那个区间被另一个 daemon 的调度所界定,那你写的不是一个慢的测试;你写的是一个迟早会和那台机器意见相左的测试,而调超时改变不了这一点。断言那个保证,而不是那次投递。

把一个复合保证拆成平台真正提供的那些部分。我们曾让一个监视器干两件事,而异步的那一半污染了同步的那一半。用投递机制去栅栏因果,用同步的那个去钉住身份,让各自以各自的方式失效。

更多重试不等于确定性。每一个延长等待的缓解手段,都是在赌下一轮会拿到一台更好的机器;而它输的那一天,你学到的是这个测试从来就没在检查任何东西。

并且删掉你无法强制的不变式。这是一个正当的工程结果,不是纪律的失败——前提是你把放弃的东西说了名字。一套因为不再断言某个不可知之物而变绿的守卫,比一套靠运气变绿的更值得信任——而它不再吸收的那些重跑,本来就是你没有在读红色构建上花掉的时间。

继续阅读