← 返回博客2026-06-28

一个 bug 从来不是一个 bug

一个没跳转的 401,其实是 33 个。一个标着「可核验」的开关,其实是三个表面,不是一个。把单个 bug 报告变成一整类清扫的习惯——以及那行留下回执的 trailer。

问题

你撞到一个 bug。一个本该把未登录用户弹去登录页的界面,偏偏只是…弹了个让人摸不着头脑的报错。你把那个界面修了。发布。两天后,QA 在另一个界面上发现了同一个 bug。

这个教训,我们最早是用一种又便宜又丢人的方式学到的。一个修复在某个界面的牌桌类型标签里改掉了一处差一错误(bot_count + 1);结果 prod QA 在另一个界面上发现了一模一样的 bug——因为没人去 grep 第二处。那一次代价不大:多一次部署、十分钟。但这个原理是通用的,代价会随规模放大:你撞到的那个 bug,通常只是你代码库里某一问题的一个实例。修法不是补这个实例,而是 grep 出这一整类,在一个 commit 里全修掉。

这套纪律

当一个 bug 是模式——一次次重复的坏 helper 调用、漂走的标签、差一错误、漏掉的守卫——第一个动作不是去改那一行,而是 grep 出每一个兄弟,然后一起修。我们把它做成了 commit 约定:这类修复要带一行 trailer。

Sibling-grep: redirectOnAuthError applied to all 33 audited auth-gated catch sites

# 或者,当它确实是个一次性的单点修复:
Sibling-grep: N/A (single-site fix, no antipattern)

这行 trailer 干两件事:它让「这一类扫过了吗」变成 commit 在宣称修好之前必须回答的问题;它也留下一张下一个人能核对的回执——哪个 grep、什么范围、豁免了什么。

例一:一处漏掉的跳转,其实是 33 处漏掉的跳转

报上来的 bug:一个未登录用户在公开的 setup 页点「创建房间」,请求 401,他拿到的是一个死胡同报错提示,而不是登录页。一个界面、一处修复?不。「一个 401 本该把你送去登录、却没送」的 fetch 是一个模式,而我们的客户端在每一处登录后才加载数据的地方都有这个模式。

所以我们没去修 setup,而是写了一个 helper——redirectOnAuthError(err, router)——把 401 变成清掉会话并跳登录(把访客的 registration_required 403 变成跳注册),其余一切都返回 false,让调用方自己的常规错误处理照常走。然后我们把每一个登录后才能调的 catch 都接到它上面:20 个文件里的 33 个调用点——视图和底部弹窗一视同仁——在一个 commit 里全改完。

这次扫荡教了我们两件事,而这才是真正有用的部分:

那张豁免清单本身就是修复的一部分

无脑扫荡本身就是个 bug。有四类 401 绝不能跳转:启动探测(/me 把「未登录」当成普通数据返回)、登录和注册表单本身(那里的 401 意思是「凭证无效」,就地处理)、牌桌内的旁路请求(教练快速提示——一次旁路请求 401 了,不能把你从正在打的一手牌里拽出来)、以及 iOS 的底部弹窗(会话过期时它要保持打开)。我们把这些豁免就写在 helper 旁边——因为「这个模式在哪里适用」,和它在哪里适用,一样是规格的一部分。

扫荡把风险集中到一个可审查的表面上

把 33 个点都接到同一个 helper 上,等于把整个改动集中到一个评审者真能拿在手里看的表面上——而正是对这个表面的评审,在合并之前抓住了 helper 第一版自己引入的两个 bug。它把用户推到 /login,却忘了清掉过期的本地会话,于是路由守卫看到「仍然已登录」,把 /login 又原路弹回了首页——一个无声的死胡同,比我们一开始那个报错提示还糟。还有一个统计界面把 loading 标志置上、走了跳转分支、然后没有 finally 就返回了,本来会卡在加载中回来。徒手去改同样的 33 个 catch,这两个 bug 都会发出去;把它们集中到一个 helper 里,一次评审就把两个都抓住了。(这次扫荡也确实翻出了一个真正早已存在的缺口:路由守卫会把访客从 /login/register 弹开,新加的跳注册本来会一头撞上它——这个洞在扫荡之前就在,跟着扫荡一起修掉了。)

兄弟清扫的分诊流程 报上来的 bug 一个实例 提炼这一类的 搜索特征 (grep) 候选命中 这里:20 个文件 33 处 同样的缺陷 语义? 逐个追溯 共享修复 一个 commit + 回归测试 写明的 豁免 本就该如此—— 规格的一部分 Sibling-grep trailer 范围、命中、豁免——一份可复查的记录 写法不同的兄弟—— grep 会漏,靠追溯补上

每个命中都要人工追溯分诊,不是自动修掉;trailer 记录的是清扫过的范围——它是一张回执,不是机器核验的证明。

例二:sibling-grep 不只针对代码,也针对「声明」

这个习惯能推广到代码路径之外,而这正是它最值钱的地方。我们发过一个「共同洗牌(可核验)」的开关,文案承诺一份「可独立核验的」公平洗牌记录。但在生产环境里,那条路径当时跑的是mock密码学——服务器自己都把那份记录标成了「不可证明公平」。牌没有被做手脚(它仍是一次诚实的、操作系统随机源的洗牌),但那个声明被做了手脚:我们在宣传一个我们并没有兑现的密码学保证。

外行的修法是在开关旁边加句免责声明。但「这个声明」不止一个表面。它在开关的标题里、在副文案里,而且——最关键的——它就藏在「有这么一个标着『可核验』、还开着的开关」这件事本身里。所以我们像扫 bug 一样扫这个声明:在未打开标志位的生产构建里,把这个开关闸掉(这类构建现在会禁用它——置灰、强制关闭,并显示一句「当前版本未启用」的提示——而不是承诺它们证明不了的东西),并把每一处文案都改成只陈述它实际做的事——「全员参与洗牌;服务器仍能看到牌」——把「可核验」的声明整个拿掉。此后服务器又把线上的口子也封死了:生产环境里任何共同洗牌的选择都会被钉回普通的服务器发牌,另一条 engine-blind 路径则在所有构建里都被发布闸门锁死(ADR-090)——这个声明不会再悄悄回来。同一套纪律,换了一类对象:找出做出这个虚假声明的每一个表面,一次全修,而不是只修你刚好看到的那一个。

诚实的边界:grep 是必要的,但不充分

sibling-grep 是先撒下去的一张网,不是一个闭着眼就能跑的宏:

  • grep 找的是写法,不是含义。同一段文本在一个调用方是对的,在另一个调用方可能就是错的——真人玩家(hero)路径和机器人路径可能一字不差地读着同一个共享 toCall 值,但它在两条路径上的含义并不相同。一个命中不等于一个 bug;在你「修」它之前,先把每一处追溯清楚。(反过来,一个写法不同的兄弟会彻底躲过字面 grep——这张网是有洞的。)
  • 拓宽一个类型,触发的是一次扫荡,而不只是一次编辑。改了某个字段的类型或含义,所有消费方都要重新审一遍,哪怕它们全都还能编过。构建是绿的;语义已经移位了。
  • 那张豁免清单就是规格。如果你列不出这个模式在哪里合理地适用,那你对这个模式的理解,还不足以安全地去扫它。

小结

  1. 一个 bug 几乎从来不是一个 bug——它是你代码库里重复出现的某一类问题的一个实例。只修你撞到的那个实例,等于把其余的发给了你的用户。
  2. grep 出这一类、在一个 commit 里修完、留一行 Sibling-grep: trailer——trailer 让「扫过没有」成为必须回答的问题,也留下一张回执。
  3. 两份回报:扫荡会枚举出豁免(这个模式在哪里该适用——这本身就是规格的一部分),也会把改动集中到一个表面上,让评审在发布之前就抓住第一版弄坏的东西(那个过期会话的死胡同、那个卡在加载中的界面)。
  4. 它不只针对代码:一个虚假的产品声明,活在做出它的每一个表面上——标题、副文案、那个开关本身的存在——所以把它们全扫一遍,而不是只扫你注意到的那个。
  5. grep 找的是写法不是含义:每个命中都追溯,类型拓宽时把消费方重审一遍。